Skip to content

fix(agent): honor a cancel that lands while a turn is being set up - #3809

Open
haacked wants to merge 2 commits into
mainfrom
haacked/adapter-cancel-during-setup
Open

fix(agent): honor a cancel that lands while a turn is being set up#3809
haacked wants to merge 2 commits into
mainfrom
haacked/adapter-cancel-during-setup

Conversation

@haacked

@haacked haacked commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Problem

A cancel arriving while prompt() was still setting up got silently dropped. That setup can run for seconds: ensureLocalToolsConnected awaits an MCP status RPC bounded at 5s, and an unknown slash command reloads skills before that. Incoming messages are dispatched concurrently, so a cancel landing in that window found an empty turn queue and no active turn. interrupt() had nothing to settle and armed no force-cancel backstop, so setup went on to hand the message to the SDK. The turn then ran to completion and reported end_turn, with tools still executing after the user pressed stop.

Changes

session.cancelSeq counts cancels on the session. prompt() snapshots it on entry before any await, then re-checks it once setup finishes. A higher count means the cancel targets this prompt, so it returns cancelled without pushing to the SDK. That is the last point where the turn can still be stopped, and no await separates the check from the push.

The count is the whole test. session.cancelled belongs to the session, and activateTurn clears it for whichever turn runs next, so a prompt that raced past the stalled one into the queue erases it — a local-only command such as /context skips the pre-prompt status check entirely and can get there first. The count survives that. The flag itself is deliberately left standing on this path, because an earlier turn may still be settling against it.

Cancels landing outside that window behave as before. Once a turn is queued interrupt() sweeps it, and once it is active the existing backstop applies.

cancel() now assigns interruptReason on every call, including when the client supplies none, so a turn no longer reports a stale reason left by an earlier cancel.

The Codex adapter takes cancelSeq: 0 as an inert field. It resets cancelled synchronously at prompt entry and refuses concurrent prompts, so it has no equivalent window.

The package's engines.node moves to >=22.19.0 to match the root, which the new test's Promise.withResolvers requires.

One downstream effect worth knowing about: focusing a worktree broadcasts a moving_to_worktree cancel to local sessions, and that cancel now registers in the setup window where it used to slip through. Autoresearch treats any cancelled as a user stop and pauses the run. This already happens on main whenever the cancel lands on an active turn, so the change makes it consistent rather than introducing it. Whether that case should be an interrupted with auto-recovery instead of a paused is a separate question in autoresearch.ts.

How did you test this?

New test file covering the race, 6 cases. Neutralizing the early return fails 4 of them, so they guard the behavior rather than just describing it. The sixth case drives the concurrent interleaving: one prompt stalled in setup, a cancel, then a /context that queues and activates in that window. Putting the session.cancelled term back into the check fails that case alone.

Full @posthog/agent suite: 1648 tests across 98 files, all passing. pnpm typecheck clean.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

A cancel arriving after `prompt()` was called but before the prompt reached
the SDK was silently dropped. Setup can take seconds: `ensureLocalToolsConnected`
("pre-prompt") awaits an MCP status RPC bounded at 5s, and an unknown slash
command reloads skills first. Incoming messages are dispatched concurrently, so
a cancel in that window found an empty turn queue and no active turn.
`interrupt()` had nothing to settle and armed no force-cancel backstop, and
setup went on to hand the message to the SDK, which ran the turn to completion
and reported `end_turn` while tools kept executing.

`session.cancelSeq` counts cancels. `prompt()` snapshots it on entry, before any
await, and re-checks it once setup finishes: a higher count means the cancel
targets this prompt, so it returns `cancelled` without pushing to the SDK. That
is the last point where the turn can still be stopped, and no await separates
the check from the push. `session.cancelled` is left standing, because an
earlier turn may still be settling against it; activation clears it for the next
turn that runs.

Cancels landing outside that window are unchanged. Once a turn is queued
`interrupt()` sweeps it, and once it is active the existing backstop applies.

`cancel()` now assigns `interruptReason` on every call, including when the
client supplies none, so it always describes the current cancel instead of
reporting a stale reason left by an earlier one.

Raise the package's `engines.node` to match the root's Node 22 requirement,
which the new test's `Promise.withResolvers` depends on.

Claude-Session: https://claude.ai/code/session_01KT3ZQ47bqT9mW4fi458Q6V
@trunk-io

trunk-io Bot commented Jul 25, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit eec78b6.

@posthog

posthog Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 1 should fix, 0 consider.

Published 1 finding (view the review).

@haacked haacked added the reviewhog ($$$) Reviews pull requests before humans do label Jul 25, 2026
@posthog

posthog Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewHog Report

Changes

Issues: 1 issue

Files (4)
  • packages/agent/package.json
  • packages/agent/src/adapters/base-acp-agent.ts
  • packages/agent/src/adapters/claude/claude-agent.ts
  • packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts

Comment thread packages/agent/src/adapters/claude/claude-agent.ts
The bail also required `session.cancelled`, which any turn's activation
clears. A local-only command skips the pre-prompt status check the stalled
prompt is parked in, so it can queue and activate in that window; the reset
erased the very cancel the check exists to catch, and the cancelled prompt
went to the SDK anyway.

`cancelSeq` is monotonic and incremented only in `cancel()`, in the same
synchronous block that sets `cancelled`, so comparing it against the entry
snapshot survives an unrelated activation. The flag stays untouched here,
because an earlier turn may still be settling against it.

Claude-Session: https://claude.ai/code/session_01KT3ZQ47bqT9mW4fi458Q6V
@haacked
haacked marked this pull request as ready for review July 25, 2026 05:26
@haacked
haacked requested a review from a team July 25, 2026 05:27
@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "fix(agent): decide the setup-window canc..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

reviewhog ($$$) Reviews pull requests before humans do

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant