feat(spine): add turn_id and tool event fidelity to lifecycle events - #356
Open
ZuyiZhou wants to merge 1 commit into
Open
feat(spine): add turn_id and tool event fidelity to lifecycle events#356ZuyiZhou wants to merge 1 commit into
ZuyiZhou wants to merge 1 commit into
Conversation
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]>
12 tasks
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.
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_idis the second correlation axis alongsideconversation_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.sendnow puts the id it returns onto the request. Without that the lane mints its own, the sink's ownership check never matches whatturn.sendbound, 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
noticeinstead 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 asdetail, 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.startcarriesblocking, 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.completecarriesmetadataanddiff: 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_modulebecause the files it edits only exist at their new paths after that rename. Merge #353 first.Reviewers: 0xKT, gloryfromca.
Type
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) andtest_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/andruff 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
mediacase inchatStream.tsmakes tsc reportType '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_idchange 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 whyturn.sendbinding 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.
Related Issues
N/A