Skip to content

feat(spine): add turn_id and tool event fidelity to lifecycle events - #356

Open
ZuyiZhou wants to merge 1 commit into
refactor/rename_tui_rpc_modulefrom
feat/spine_turn_correlation
Open

feat(spine): add turn_id and tool event fidelity to lifecycle events#356
ZuyiZhou wants to merge 1 commit into
refactor/rename_tui_rpc_modulefrom
feat/spine_turn_correlation

Conversation

@ZuyiZhou

Copy link
Copy Markdown

Summary

A turn's lifecycle events now name the turn they belong to, and a tool event carries what a client needs to draw the call.

turn_id is the second correlation axis alongside conversation_id: the lane is WHERE a turn ran, this is WHICH turn ran. A consumer keyed only on the lane stamps a turn's end with whatever a per-lane slot last held, which is a different turn whenever the runtime submits one of its own onto a busy lane. The lane resolves the id on the way in, so a turn cancelled while still queued can still name itself on its terminal event.

turn.send now puts the id it returns onto the request. Without that the lane mints its own, the sink's ownership check never matches what turn.send bound, and the active-turn slot is never released, so every later send is refused. The slots are released only by the turn that bound them, for the same reason: a runtime turn ending on a busy lane would otherwise free a queued client turn's bindings and drop its usage before its own end reads it.

A blocked action reaches the client as a notice instead of as token deltas. Pushed down the token stream it landed in the buffer holding the model's own prose, so it rendered as the model's answer: run together with whatever had just been narrated, and wearing the answer's copy and branch actions. The blocking tool's own first line rides along as detail, because the canned sentence says an operation stopped and never which one. A surface with no notice outlet still falls back to the token stream, since silence is worse.

Rendering it in the terminal is not optional: before this event existed the same sentence was at least visible, so a client that received the notice and drew nothing would end the turn in silence. The sentence is written in the component rather than looked up, because this repo has no i18n layer.

tool.start carries blocking, so a client that clocks the event stream for liveness can stop that clock instead of declaring a call that is merely waiting on a person dead. tool.complete carries metadata and diff: the arguments hold the new content and nothing else, so a client without the diff draws a whole-file write as all additions.

A new gate compares each union variant's payload field names across the schema and the models. The existing union test compares only the set of variants, so a field added to one side alone passed it, while both declarations forbid unknown fields and a validating consumer drops the whole event.

Stacked on #353: this branch is cut from refactor/rename_tui_rpc_module because the files it edits only exist at their new paths after that rename. Merge #353 first.

Reviewers: 0xKT, gloryfromca.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

Commands run and their results:

  • uv run --frozen --all-extras pytest -q -> 6726 passed, 19 failed. All 19 are pre-existing on the base branch: test_provider_rates.py (15) and test_agent_loop_usage_sink.py (4), both pricing-table and network-cache tests. Verified by running the same two files in a clean worktree at the base commit, which fails the same 19.

  • uv run --frozen python scripts/coverage_gate.py diff --base-ref refactor/rename_tui_rpc_module -> 100.00% (50/50 executable changed lines).

  • uv run --frozen python scripts/coverage_gate.py ratchet -> line +1.07pp, branch +1.53pp, PASS.

  • uv run --frozen ruff check raven/ tests/ and ruff format --check -> clean.

  • cd ui-tui && npm run lint:rpc -> generated.ts in sync. npm run type-check -> exit 0. npm test -> 87 files, 998 tests passed.

  • Each new test was checked to fail without its fix: removing the notice branch turns 4 of them red, and removing the media case in chatStream.ts makes tsc report Type 'NoticeEvent' is not assignable to type 'never'.

  • Relevant tests pass locally

  • Relevant lint / type checks pass locally

  • User-facing docs or screenshots are updated when needed

Risk

The behaviour change a user can see is that a blocked action now arrives as runtime prose in its own line rather than inside the assistant's answer. Everything else is additive: three optional payload fields and one new union variant, all with defaults, so a client that ignores them behaves exactly as before.

The turn_id change is the one to look at twice. The lane now mints an id when the submitter supplies none, and the sink releases a lane's slots only for the turn that bound them. A mismatch between the two would leave the active-turn slot held and refuse every later send, which is why turn.send binding its own id onto the request is part of this change rather than a follow-up.

Rollback is a plain revert: no data migration, no persisted format change, and the wire additions are optional fields.

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

Related Issues

N/A

A turn's lifecycle events now name the turn they belong to, and a tool event
carries what a client needs to draw the call.

turn_id is the second correlation axis alongside conversation_id: the lane is
WHERE a turn ran, this is WHICH turn ran. A consumer keyed only on the lane
stamps a turn's end with whatever a per-lane slot last held, which is a
different turn whenever the runtime submits one of its own onto a busy lane.
The lane resolves the id on the way in, so a turn cancelled while still queued
can still name itself on its terminal event.

turn.send now puts the id it returns onto the request. Without that the lane
mints its own, the sink's ownership check never matches what turn.send bound,
and the active turn slot is never released, so every later send is refused.
The slots are released only by the turn that bound them, for the same reason:
a runtime turn ending on a busy lane would otherwise free a queued client
turn's bindings and drop its usage before its own end reads it.

A blocked action reaches the client as a notice instead of as token deltas.
Pushed down the token stream it landed in the buffer holding the model's own
prose, so it rendered as the model's answer: run together with whatever had
just been narrated and wearing the answer's copy and branch actions. The
blocking tool's own first line rides along as detail, because the canned
sentence says an operation stopped and never which one. A surface with no
notice outlet still falls back to the token stream, since silence is worse.

Rendering it in the terminal is not optional: before this event existed the
same sentence was at least visible, so a client that received the notice and
drew nothing would end the turn in silence. The sentence is written in the
component rather than looked up, because this repo has no i18n layer.

tool.start carries blocking, so a client that clocks the event stream for
liveness can stop that clock instead of declaring a call that is merely
waiting on a person dead. tool.complete carries metadata and diff: the
arguments hold the new content and nothing else, so a client without the diff
draws a whole-file write as all additions.

A new gate compares each union variant's payload field names across the schema
and the models. The existing union test compares only the set of variants, so
a field added to one side alone passed it, while both declarations forbid
unknown fields and a validating consumer drops the whole event.

Co-authored-by: Claude (claude-opus-5[1m]) <[email protected]>
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.

1 participant