Repository navigation
[REA-6854] Read starting_input and steps from the start body - #232
Merged
Merged
Conversation
Contributor
Author
This was referenced Oct 3, 2026
This was referenced Oct 3, 2026
tempusfrangit
approved these changes
Oct 3, 2026
tempusfrangit
left a comment
Contributor
There was a problem hiding this comment.
Reviewed body validation and adoption at the accepted start transition. Malformed shapes leave session state intact, and existing start bodies remain compatible.
ggoldens
approved these changes
Oct 5, 2026
Contributor
Author
Dere-Wah
changed the base branch from
dere/rea-6854-deliver-commands-and-reactor-events-in-order
to
graphite-base/232
October 5, 2026 18:07
A session that no client drives needs everything it will do on its start request: the commands to apply and how many steps to run. The start body carries them as two optional keys, and a body without them starts a session as before. The runtime checks the shape of the keys before the session moves. A malformed key answers 400 and leaves the session ready. Whether a starting command exists and whether its data fits stays the model contract's decision, made later on the normal command path. The parsed shape is resolved at the start transition, like the recording id, so a rejected start never replaces a live session's shape. Signed-off-by: Dere-Wah <derexcontact@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
A starting command whose arguments fail the model's contract was only found when the command ran, after the session had started: the command was journalled as an error and the session stayed open, so a session with steps waited for a step its setup would never let run. The start now checks every starting command against the contract before the session moves, the same check a client's command gets: the command exists, carries only arguments it declares, and gives each a value of the right type within its constraints. Each state key is checked as the set_<key> command its field generates. A refused command answers 400 naming its key in the body and why, for example "starting_input.state. color is refused: color: ...", and the session stays ready. The check reads only the body and the contract. An upload argument is checked as a reference; its bytes arrive after the start and are resolved when the command runs. ModelBridge gains check_command(), which validates like submit_command() without admitting the command. Signed-off-by: Dere-Wah <derexcontact@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Dere-Wah
force-pushed
the
dere/rea-6854-parse-the-session-start-body
branch
from
October 5, 2026 18:11
1e2f786 to
d83772f
Compare
Dere-Wah
added a commit
that referenced
this pull request
Oct 5, 2026
…233) ## Why A caller that starts a session without a client still needs the model set up: the prompt, the seed, an image to start from. Those arrive as `starting_input` on the start request, and they should go through the same path a client's commands take, so validation, upload resolution, and the journal behave the same and the model needs no change. Two things stood in the way. A handler that takes `client` had no client to receive. And a model only generates while someone is connected: the default step loop waits for a client, and hand-written `run()` loops gate on `self.connected`. Both are solved by giving such a session a client the runtime owns. That client must not change what the session state means. `streaming` is the runtime's occupancy signal: it says a client is being served, and anything that reads `/events` uses the state pair on each connection move to tell when a session gained or lost its clients. A live session is not served while the runtime applies its starting input, so the runtime's own client must not make it look occupied. ## What Changed **The starting input.** `StartingInput.as_commands()` turns each `state` key into the `set_<key>` command the model's state generates, followed by `commands` in order. Right after the session starts, the runner submits them one at a time through `_submit_command`. Each command's arguments were already checked against the contract when the session started (#232), so what can still fail here is what only running a command shows, such as an upload whose bytes never arrive: that command is journalled as an error and the rest still run. A command that names an upload waits up to the orphan timeout for its bytes, since the caller can only seed them once the session exists. Client commands wait until the list is submitted, the list runs once per session, and it stops if the session ends. The descriptor reports `"starting_input": {"applied": N}`, the number of commands the list expanded to. **The system client.** `runner/system_client.py` adds `SystemConnection`, a connection with no wire on connection id `0`, which no transport mints. It advertises no media, so the media fan-out skips it and a model is never held to a playout rate nobody is watching. Anything sent to it is dropped. The runner registers it for a session that has a starting input or `steps`, right after the start, so the model sees `SessionStarted`, then `ClientConnected(system=True)`, then the starting commands, which it sends. It leaves once the list is submitted, unless the session has `steps`; then it stays until teardown. Because it is a real connection, `self.connected` is set while it is there, so existing models generate with no change. **The system client occupies only a session with `steps`.** In a session with `steps` it is the session's only client, so it counts like any other connection: the session streams, and the orphan timeout cannot close it before its steps are done. In a session without `steps` it only sends the starting commands, so it does not count. `SessionStateMachine` gains a second entry point for that case. It applies a connection open or close as a self-loop on the current state and leaves the live count alone: ```python sm.send(SessionEvent.CONNECTION_OPENED, conn_id=1002) # counts: waiting -> streaming sm.send_without_occupancy(SessionEvent.CONNECTION_OPENED, conn_id=0) # does not: waiting -> waiting ``` `ConnectionManager.register(conn, system=True, occupies=False)` records the connection as not occupying the session, so its later `drop()` closes it the same way it opened. A client that joins while the system client is still applying the list opens the session as usual (`waiting -> streaming`), the system client's close is then a `streaming -> streaming` self-loop, and the client's own close leaves the session orphaned. Because a session without `steps` stays `waiting`, its orphan timer keeps running from the start, as it does for any session no client has joined yet. The journal for a live session with a starting input: ``` start_session ready -> waiting connection_opened waiting -> waiting {"conn_id": 0, "system": true} command waiting {"name": "set_spin_speed", "args": {"spin_speed": 2.0}, "conn_id": 0} connection_closed waiting -> waiting {"conn_id": 0, "system": true} connection_opened waiting -> streaming {"conn_id": 1002} (the first client of its own) ``` And for a session with `steps`, where the system client holds the session until it ends: ``` start_session ready -> waiting connection_opened waiting -> streaming {"conn_id": 0, "system": true} command streaming {"name": "set_spin_speed", "args": {"spin_speed": 2.0}, "conn_id": 0} ``` A session with `steps` answers `/start_session` with `"state": "streaming"`, because the system client has already connected. A session with only a starting input, and an older body with neither key, answer `"waiting"`. **The first step waits for the starting input.** Without a viewer the system client paces nothing, so the default loop steps as fast as the model renders, and it began stepping the moment the system client connected, ahead of the starting commands. On `examples/starter`, a session with `steps: 40` finished every step and closed in the 200 ms before the caller seeded the image its starting input named; the upload was then refused and the command rejected. So `SessionStarted` now says whether a starting input follows, and the runner posts an internal `StartingInputApplied` after the last starting command, on the same ordered queue, once any upload it names has resolved or timed out. Until it arrives the loop's gate stays shut, though `self.connected` is set and starting handlers still receive the system client as `client`. `ReactorPipeline`'s loop waits on the same gate; a model with its own `run()` is unaffected. In a session without `steps` the system client leaves before the event, so a live session still takes its first step only once a viewer joins. `SessionStarted.starting_input` defaults to `False`, so anything else that posts it keeps today's behaviour. Rerun on the starter with the fix: no step ran before the image arrived, every saved step showed the uploaded image, and a step's state carried the starting `spin_speed` rather than the default. The system client's connection moves carry `"system": true` in the journal detail; other connections' detail is unchanged. The runtime's own metrics leave it out: it does not count in the connection series or in `runtime_session_time_to_first_client_seconds`. A model that needs to tell it apart reads the new `ClientInfo.system`, which `ClientConnected` carries. Checked against `examples/starter`: a starting `state` of `{spin_speed: 2.0, paused: true}` and a `set_static_interval` of `4` left the model's live state at those values before any client connected, a `999` was rejected by the field's `le=300` and journalled, and the next session started from the defaults.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Why
A session that no client drives has to carry everything it will do on its start request: the commands to apply before anyone connects, and how many steps to run before it ends. This PR teaches
/start_sessionto read those two keys. The PRs above it in the stack make them do something.What Changed
runner/session_start.pyparses two optional keys from the/start_sessionbody into a frozenSessionStart:It checks the shape:
starting_inputis an object withstate(an object) andcommands(a list of{command, data}), andstepsis a positive integer. A key of the wrong shape answers400with a message naming it, and the session staysready;api/openapi.jsongains that400, which the breaking-change gate classifies as additive. A body without either key starts a session exactly as before.It also checks every starting command against the model's contract before the session moves, the same check a client's command gets: the command exists, carries only arguments it declares, and gives each a value of the right type within its constraints. Each
statekey is checked as theset_<key>command its field generates. A refused command answers400naming its key and why, and the session staysready:Without this, a command with a bad argument was only found when it ran: it was journalled as an error, the session stayed open, and a session with
stepswaited for a step its setup would never let run. The check reads only the body and the contract. An upload argument is checked as a reference; its bytes arrive after the start and are resolved when the command runs.ModelBridge.check_command()validates likesubmit_command()without admitting the command.The parsed shape is adopted at the start transition, the same way the recording id is, so a rejected start never replaces a live session's shape.