Skip to content

Let a user stop an in-flight agent turn (CL-7201) - #513

Merged
TheGreatAxios merged 22 commits into
mainfrom
cl-7201-let-a-user-stop-an-in-flight-agent-turn
Aug 31, 2026
Merged

Let a user stop an in-flight agent turn (CL-7201)#513
TheGreatAxios merged 22 commits into
mainfrom
cl-7201-let-a-user-stop-an-in-flight-agent-turn

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

There was no cancel/abort/interrupt endpoint for a running agent turn anywhere in the chat routes — the only bound on a wedged turn was the multi-minute dispatch/wait-until-free timeouts. This adds one.

Stacked on cl-7193-with-timeout-abort (#483, not yet merged) — it exports the AbortSignal seams (withTimeout's per-call signal, dispatchTurn's signal param, AgentTurnStore.finishTurn's compare-and-set) this cancellation is built on. Review against that base, not main.

  • withTimeout (packages/chat/src/with-timeout.ts) now accepts an optional external AbortSignal, composed with its own timeout signal — a caller's own cancellation cuts work short exactly like a timeout does, without waiting for the timeout's clock.
  • A new workbench-keyed TurnCancelRegistry (packages/chat/src/turn-cancellation.ts) is the live abort seam a running turn is reachable through while still on our own call stack (dispatchTurnBatch's waitUntilFree wait, dispatchTurn's sendMail call).
  • AgentTurnStore gains a "cancelled" status and findRunningTurns (plural, uncapped — the existing listTurns pages and has no status filter, wrong primitive for a settle-everything sweep).
  • New cancelWorkbenchTurn (packages/chat/src/workbench-service.ts) runs two mechanisms together: aborting any live controller (reaches work still in flight) and sweeping every snapshotted running turn through the same finishTurn compare-and-set (reaches a turn whose dispatch has already resolved and moved off our call stack — the agent is generating, or parked on a gate somewhere in the execution plane this package can't see into). Whichever wins a given row's CAS is the only one that settles it and posts the one honest turnCancelled notice for it.
  • New POST /workbenches/:id/turns/cancel route, wired through the hub's chat/workflow/Slack send paths.
  • Composer gets a Stop affordance (running/onStop props) shown whenever a turn is in flight, independent of the composer's own send state — a follow-up message can still be typed and queued while a turn runs.

What cancellation actually stops vs. records (CL-7230)

@corbits/agent-lifecycle's wake and @corbits/folded-runs' mail send accept no AbortSignal. Once sendMail is in flight, cancellation cannot forcibly stop the underlying agent process — it can only interrupt bookkeeping/waiting phases still on our own call stack, and settles the turn row honestly (cancelled, never a false claim of a clean stop). A late reply that lands anyway simply finds no running row left to attach to (the same CAS guarantee CL-7193 already gives a timed-out turn against a late reply — tested here for the cancel outcome specifically).

Question-gate scope

CL-7190/7191 (the actual message_response ask_user gate, PRs #500/#501) are not merged to main and not stacked into this branch — the gate's own state lives entirely in unmerged vendored @intx/inference/packages/interaction-tools code I have no access to from here. From this package's point of view, a gate-parked turn is just an ordinary running AgentTurn row, so the generic mechanism above settles it the same way. Verified (not just asserted) that a late answer arriving after cancellation — standing in for a delayed gate reply — cannot reopen an already-cancelled row, via finishTurn's existing compare-and-set.

Known, disclosed gaps

  • A message still sitting in turn-queue.ts's own pendingByWorkbench queue (queued behind another turn for the same agent, not yet dispatched) has nothing registered to abort yet — cancelling at that exact moment is not currently reachable by this mechanism. Real gap, not covered here.
  • No DOM-rendering test for the Composer's Stop button or the timeline's CancelledTurnStrip — the repo has no existing DOM-test precedent for either's sibling (the send button, FailedTurnStrip), so pure-function tests cover the logic instead.

Test plan

  • cd packages/chat && bun run typecheck && bun test (794 tests, 0 fail)
  • cd packages/chat-ui && bun run typecheck && bun test (821 tests, 0 fail)
  • cd packages/icons && bun run typecheck && bun test
  • cd apps/hub && bun run typecheck && bun test (207 tests, 0 fail)
  • bunx prettier --check on all touched files
  • bun run lint (root) — 0 errors, pre-existing unrelated warnings only

DO NOT MERGE.

@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Ran a Critique pass against the full diff before marking this ready. It found three real bugs, all now fixed with regression tests:

  1. dispatchTurn fired a brand-new sendMail for a turn already known to be cancelled. If signal.aborted was already true at entry (a cancel landing in the gap between waitUntilFree resolving and dispatchTurn starting), the code closed the row cancelled but fell through and sent mail anyway — beyond CL-7230's disclosed ceiling (that ceiling is about a send already in flight, not one that hasn't started). Fixed: returns immediately after closing the row when already aborted. Regression test: packages/chat/test/dispatch-turn-abort.test.ts.
  2. withTimeout leaked a listener on the external signal when its own timeout won the race. Both "work settles first" branches removed the listener; the timeout branch didn't. Reused across two sequential calls per recipient (waitUntilFree then dispatchTurn), this could stack a stale listener under a live one. Fixed, with a regression test using node:events' getEventListeners.
  3. The composer's Stop button got stuck disabled forever if the cancel request itself failed (network error, denied grant) — the only reset path was "the host reports running went false," which never happens for a request that never landed. Fixed: onStop may now return a promise, and a rejection re-enables the button immediately. Added real DOM tests for the Stop button in packages/chat-ui/test/composer.test.tsx (there was existing precedent for this via test/composer.test.tsx's send-button suite that I'd missed).

Also worth noting for review: Critique confirmed cancelWorkbenchTurn's two-mechanism race (live abort vs. sweep) is sound under every interleaving traced, all SendWorkbenchMessageDeps call sites got turnCancellation threaded through, and the question-gate scoping doesn't overclaim anything beyond what's actually tested.

All packages (chat, chat-ui, icons, hub) still green: typecheck + test + prettier + eslint.

@TheGreatAxios
TheGreatAxios force-pushed the cl-7201-let-a-user-stop-an-in-flight-agent-turn branch from 3e43b64 to e6b8b06 Compare August 30, 2026 21:46
@TheGreatAxios
TheGreatAxios force-pushed the cl-7201-let-a-user-stop-an-in-flight-agent-turn branch 2 times, most recently from 7e6d495 to c7a7873 Compare August 31, 2026 04:03
Base automatically changed from cl-7193-with-timeout-abort to main August 31, 2026 05:29
@TheGreatAxios
TheGreatAxios force-pushed the cl-7201-let-a-user-stop-an-in-flight-agent-turn branch 2 times, most recently from 303f5ca to de4e52b Compare August 31, 2026 07:05
Stop was keyed off the tokenless typing pulse, so it vanished the
moment tokens streamed. Offer it for the whole awaiting phase, and
settle a cancelled turn as replied so a late inference event cannot
reopen that pulse.
A cancelled 1:1 turn still has exactly one membership and no running
row, so the sole-membership fallback would post the late reply as an
unattached message. Check the latest turn status before falling back.
@TheGreatAxios
TheGreatAxios force-pushed the cl-7201-let-a-user-stop-an-in-flight-agent-turn branch from de4e52b to 99cbe21 Compare August 31, 2026 07:12
@TheGreatAxios
TheGreatAxios marked this pull request as ready for review August 31, 2026 07:22
@TheGreatAxios
TheGreatAxios merged commit 5040256 into main Aug 31, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant