ts-server: a cancelled turn's late teardown no longer hangs the next turn - #516
Merged
Merged
Conversation
…n-scoped A cancelled turn's LATE teardown wiped the NEXT turn's HITL registration and hung it forever. Cancellation here is cooperative: cancelActiveTurn() frees the connection's turn slot synchronously, but the cancelled turn runs on until its next stream event — a whole slow tool call away. The client can start a new turn on that session immediately, and if it parks on a write-confirmation, the cancelled turn's eventual finally cleared the registration out from under it. The client's confirm_tool_action then came back NO_PENDING_CONFIRMATION and the new turn never resumed: nothing else settles a confirmation short of a disconnect. The registries key on sessionId, not on turn identity. All three teardown clears are now turn-scoped, reusing the guard the dispatcher already applied to the active-turn slot: the runner skips its clear when its own cancelSignal fired, and the dispatcher clears only while it still holds the slot. A cancelled turn has nothing of its own left to drop — cancelActiveTurn settles its confirmation and its parked interaction when it fires the abort. Both clear() methods now SETTLE the deferred they drop (rejected / no_response) instead of deleting it silently, so a clear can only ever deny an awaiting turn, never strand it. That is the verdict rejectAll() already uses and the contract the SEP ui/confirm bridge already documented without delivering. Regression tests in typescript/server/test/late-teardown.test.ts, both failing without the fix. They differ from cancel-unpark.test.ts on the two points that matter: turn 1 parks in a SLOW TOOL rather than at the confirmation, and turn 2 REGISTERS a confirmation of its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 6ed6897 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
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.
Problem
A cancelled turn's late teardown wipes the next turn's HITL registration, hanging it forever (pearl
th-700ecb).Cancellation in the TypeScript server is cooperative.
cancelActiveTurn()fires the abort and frees the connection's turn slot synchronously, but the cancelled turn itself keeps running until its next stream event — which, if it is sitting in a slow tool, can be a long time. The client is free to start a new turn on that session immediately.cancelled(499), slot freed.registry.register(sessionId)→write_confirmation_required→ B awaits the deferred.TurnCancelledError→ itsfinally→confirmations.clear(sessionId)deletes B's deferred.confirm_tool_actionreturnsNO_PENDING_CONFIRMATION. Turn B is parked forever — there is no timeout on the TS confirmation path; the only other thing that ever settles one isrejectPendingConfirmations()on disconnect.interactionPark.clear(sessionId)inframeDispatcher.tshas the identical shape; there the damage is bounded byINTERACTION_TIMEOUT_MS, so a parked card silently degrades tono_responseafter 5 minutes instead of hanging outright.Rust cannot hit this:
handle.abort()drops the turn future, and the(cfg.clear)calls are plain statements after the executor await, so they never run on the aborted path.Fix
The registries key on
sessionId, not on turn identity. All three teardown clears are now turn-scoped, reusing the guard the dispatcher already applied to the active-turn slot one line above (this.activeTurn?.controller === controller) rather than inventing a new mechanism:turnRunner.ts— skips its confirmation clear when its owncancelSignalfired. That is the same condition the runner already uses to decide it was cancelled.frameDispatcher.ts— hoists the existing slot-ownership check intooursand gates the SEP confirmation clear and the interaction clear on it.A cancelled turn has nothing of its own left to drop anyway:
cancelActiveTurn()already settles its confirmation and its parked interaction when it fires the abort.Separately,
ConfirmationRegistry.clear()andInteractionParkRegistry.clear()now settle the deferred they drop (rejected /no_response) instead of deleting it silently. A deleted-but-awaited deferred is precisely what turns this into a hang rather than an error, so this makes any future clear-site able to deny an awaiting turn but never strand it. It is the verdictrejectAll()already uses, and the contract the SEPui/confirmbridge already documents in a comment ("the turn ends and it resolves false") without delivering.Tests
typescript/server/test/late-teardown.test.ts— two tests, both fail without the fix. They differ from the existingcancel-unpark.test.tson the two points that let this slip through: turn 1 parks in a slow tool (not at the confirmation), and turn 2 registers a confirmation of its own (not a plain text answer).TurnRunnerlevel. The barrier isawait expect(turnA).rejects.toBeInstanceOf(TurnCancelledError)— turn A'sfinallyhas provably run by then, so there is no timing in the assertion at all. Without the fix:expected false to be true(B's deferred is gone).timed out waiting for a frame— which is the reported symptom itself.Full suite, on this branch:
That includes the testcontainers Postgres suites and the existing
cancel-unpark,turn-cancel,submit-interaction,choices,identity-intakeandextensionstests.pnpm typecheckis clean.Not in scope
The Python and Go servers were not audited for the same shape; sibling agents are in those files. Rust and .NET are ruled out above / by #514.
🤖 Generated with Claude Code