Skip to content

ts-server: a cancelled turn's late teardown no longer hangs the next turn - #516

Merged
brentrager merged 1 commit into
mainfrom
fix/ts-turn-scoped-registry-teardown
Aug 20, 2026
Merged

ts-server: a cancelled turn's late teardown no longer hangs the next turn#516
brentrager merged 1 commit into
mainfrom
fix/ts-turn-scoped-registry-teardown

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

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.

  1. Turn A is inside a slow tool. Client hits Stop → cancelled (499), slot freed.
  2. Client sends turn B on the same session. B calls a confirm-gated tool → registry.register(sessionId)write_confirmation_required → B awaits the deferred.
  3. Turn A's tool finally returns → next stream event → TurnCancelledError → its finallyconfirmations.clear(sessionId) deletes B's deferred.
  4. confirm_tool_action returns NO_PENDING_CONFIRMATION. Turn B is parked forever — there is no timeout on the TS confirmation path; the only other thing that ever settles one is rejectPendingConfirmations() on disconnect.

interactionPark.clear(sessionId) in frameDispatcher.ts has the identical shape; there the damage is bounded by INTERACTION_TIMEOUT_MS, so a parked card silently degrades to no_response after 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 own cancelSignal fired. That is the same condition the runner already uses to decide it was cancelled.
  • frameDispatcher.ts — hoists the existing slot-ownership check into ours and 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() and InteractionParkRegistry.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 verdict rejectAll() already uses, and the contract the SEP ui/confirm bridge 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 existing cancel-unpark.test.ts on 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).

  1. TurnRunner level. The barrier is await expect(turnA).rejects.toBeInstanceOf(TurnCancelledError) — turn A's finally has 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).
  2. Over a real socket. The full reported sequence, end to end. Without the fix it hangs — timed out waiting for a frame — which is the reported symptom itself.

Full suite, on this branch:

 Test Files  41 passed (41)
      Tests  387 passed (387)

That includes the testcontainers Postgres suites and the existing cancel-unpark, turn-cancel, submit-interaction, choices, identity-intake and extensions tests. pnpm typecheck is 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

…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-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6ed6897

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@smooai/smooth-operator Patch
@smooai/smooth-operator-web-chat-example Patch

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

@brentrager
brentrager merged commit aab1498 into main Aug 20, 2026
2 checks passed
@brentrager
brentrager deleted the fix/ts-turn-scoped-registry-teardown branch August 20, 2026 00:08
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