Skip to content

[REA-6854] Read starting_input and steps from the start body - #232

Merged
Dere-Wah merged 2 commits into
mainfrom
dere/rea-6854-parse-the-session-start-body
Oct 5, 2026
Merged

Dere-Wah merged 2 commits into
mainfrom
dere/rea-6854-parse-the-session-start-body

Conversation

@Dere-Wah

@Dere-Wah Dere-Wah commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

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_session to read those two keys. The PRs above it in the stack make them do something.

What Changed

runner/session_start.py parses two optional keys from the /start_session body into a frozen SessionStart:

POST /start_session
{
  "session_id": "8c0e7a52-1111-4222-8333-444444444444",
  "starting_input": {
    "state": { "seed": 42 },
    "commands": [ { "command": "enqueue", "data": { "prompt": "a red fox" } } ]
  },
  "steps": 1
}

It checks the shape: starting_input is an object with state (an object) and commands (a list of {command, data}), and steps is a positive integer. A key of the wrong shape answers 400 with a message naming it, and the session stays ready; api/openapi.json gains that 400, 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 state key is checked as the set_<key> command its field generates. A refused command answers 400 naming its key and why, and the session stays ready:

POST /start_session {"starting_input": {"state": {"spin_speed": 99}}, "steps": 1}
400 {"detail": "starting_input.state.spin_speed is refused: spin_speed: 99 > le(5.0)"}

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 steps waited 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 like submit_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.

@Dere-Wah
Dere-Wah requested a review from a team as a code owner October 3, 2026 00:36
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

[codex-review] - [P1] src/reactor_runtime/runner/runner.py: starting_input and steps are accepted but never consumed, so both behaviors are silently ignored.

Scope: full (415024a..d2fe770).

View workflow run.

Comment thread src/reactor_runtime/runner/runner.py

@tempusfrangit tempusfrangit left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed body validation and adoption at the accepted start transition. Malformed shapes leave session state intact, and existing start bodies remain compatible.

Dere-Wah commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Merge activity

  • Oct 5, 6:07 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Oct 5, 6:11 PM UTC: Graphite rebased this pull request as part of a merge.
  • Oct 5, 6:13 PM UTC: @Dere-Wah merged this pull request with Graphite.

@Dere-Wah
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
@Dere-Wah
Dere-Wah changed the base branch from graphite-base/232 to main October 5, 2026 18:10
Dere-Wah and others added 2 commits October 5, 2026 18:11
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
Dere-Wah force-pushed the dere/rea-6854-parse-the-session-start-body branch from 1e2f786 to d83772f Compare October 5, 2026 18:11
@Dere-Wah
Dere-Wah merged commit 4306965 into main Oct 5, 2026
10 checks passed
@Dere-Wah
Dere-Wah deleted the dere/rea-6854-parse-the-session-start-body branch October 5, 2026 18:13
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants