Skip to content

acpmux: Stop really stops an agent that works through the cancel - #27685

Merged
teamleaderleo merged 20 commits into
feat-cmux-nextfrom
leo/acpmux-stop-escalate
Oct 30, 2026
Merged

teamleaderleo merged 20 commits into
feat-cmux-nextfrom
leo/acpmux-stop-escalate

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Leo (2026-10-29): "are these messages literally not getting to you ... i literally can't stop you i clicked the stop button and it didnt do anything so this is still busted didnt we say we were going to fix this in acpmux? otherwise i have to manually just click steer on all of them unless you come up with better solulus"

What happened

A coordinator chat had 319 queued prompts, all from other agents. It read state=running and also stuck (harness_unavailable, a 502 from the gateway) while it kept making tool calls. Leo pressed Stop. cmux showed "The agent didn't answer Stop, so cmux ended its turn", but the agent kept making tool calls and the chat stayed running.

Why the chat still read as running after the forced end (from the code)

Mostly the queue. The agent's events alone could not keep or reopen a turn:

  • Events cannot open a turn. No inbound path creates a TurnInfo or sets Running. on_update/note_activity attach to session.turn only if one exists, and Running is set only by run_prompt (turns.rs), adoption and permission answers.
  • A queued prompt started the next turn immediately. cancel.rs's forced end (force_cancel after CANCEL_GRACE) settles the waiting prompt. finish_turn then takes the turn and notifies queue_moved. The head queued prompt's task, already blocked in queue_turn on turn_lock, takes the lock at once: nothing in the queue checks for a Stop. That head was an agent message, so take_joined pulled in up to 128 KiB of the other agent messages, and the new turn set Running.
  • The old turn's work then landed in that new turn. The harness process was still in the stopped turn. Its tool calls attached to the new TurnInfo.doing, and its heard signs kept the new turn from ever reading silent or stalled. To the person it looked like Stop did nothing.
  • The comment on cancel_preserving_queued was wrong. It said "A plain Stop withdraws every queued prompt, including one promoted with Send now". On base, only the compacting branch withdraws anything (remove_queued_because(.., "cancel_forced")). A plain Stop withdrew nothing, so the queue drained straight on.
  • Stuck could not clear while the agent worked. HarnessFailures resets only on a completed turn, and the agent never completed one.

The fix

  1. Escalation for an agent that works through the cancel (hub/cancel.rs escalate_stop). If the model kept sending signs of work through the 10 s grace (Session::heard moved since the cancel), acpmux sends session/cancel again. That is the adapter's own interrupt:

    • claude-stdio writes Claude Code's control_request {subtype: interrupt};
    • the Codex adapter sends turn/interrupt for the thread and its subagents;
    • other ACP adapters get session/cancel.

    No adapter has anything stronger short of ending the process. If the agent still works 5 s later (INTERRUPT_GRACE), acpmux takes the agent out of the slot, marks it end_on_purpose (no failover, no continuation) and calls ChildAgent::kill. That kills only the session's own recorded process group (SIGTERM, then SIGKILL, plus its setsid escapees). For a hosted agent it sends the host's Terminate. The turn ends as cancelled with cancelForced and restarted: true.

    The agent then starts again on the same conversation through the existing child_for path (--resume / session/load). This happens at the end of run_prompt, while the stopped turn's lock is still held, so the next queued turn finds it running. An agent that goes quiet keeps today's plain forced end.

  2. Never ended while it still works. From the first Stop until the turn really ends, the row's state reads stopping (a stopping record, which watchers hear as status). Updates that the ended process still had in flight (by its agent generation) are dropped in the inbound loop, so they cannot reach the next turn.

  3. Queued agent messages after the person's Stop. A session/cancel from the person's connection (Hub::person_stop) holds the other agents' queued messages (fromAgent / agent provenance). They stay queued, marked held, and are logged as queue_paused with reason: "stop". Agent messages that arrive while the hold lasts are held too. The hold ends when:

    • the person's next turn completes (release_held_agent_messages);
    • the queue is resumed.

    A failed turn's automatic retry leaves them held while the hold lasts, and an inbox digest (acpmux session inbox --digest) of them waits held and does not steer.

    Nothing is dropped, and the person's own queued prompts run next as before. The compacting branch now withdraws only the person's prompts and holds the agents' messages instead of dropping them. Stops from agents (acpmux send --interrupt, sub-session stop, remote floor) keep today's behaviour, and the misleading comment is corrected.

  4. Stuck clears on work. A session marked stuck is unstuck on the first live tool_call, thought or plan update from its agent (not a session/load replay). Its failures are forgotten and an unstuck record is logged, which watchers hear as status.

Tests (red at the test-only commit, green at the fix)

  • stop::an_agent_working_through_stop_is_restarted_on_the_same_conversation: the fake agent ignores every cancel and emits a tool call every 100 ms. The test checks:
    • the row reads stopping;
    • the steps are interrupt then restart, then the turn ends cancelled/cancelForced;
    • the new process resumed the same agentSessionId;
    • no tool call from the old pid lands after the turn ended;
    • no turn runs afterwards;
    • both agent messages (one queued before the Stop, one after) stay queued and held.
  • stop::after_the_persons_stop_their_prompt_runs_next_and_agents_wait_for_it: the person's queued prompt runs next, the agent's message is held during it and goes in after it.
  • stuck_liveness::a_stuck_session_whose_agent_works_again_is_unstuck_at_its_first_tool_call.
  • Existing digest tests that stopped the busy turn only to release agent messages now stop it as an agent would (hub.cancel), since the person's Stop holds those messages.
run commit result
red 7789cf5 (tests only) run 40970757260: all 3 new tests fail
green 79a19a0 (focused: agent_digest, stop, stuck_liveness, 5 loops) run 40975402953
red (retry vs Stop hold) 20c35c7 (test only) run 41012944541: stop::after_the_persons_stop_a_failed_turns_retry_leaves_agent_messages_held fails
green (retry vs Stop hold) ee6cc93 run 41012946423: the three stop::after_the_persons_* tests pass
clippy + full crate 84c74f4 (final head) fleet Linux run: clippy clean, every hub_integration test passes. Only hermetic_daemon_guard::only_the_hermetic_helper_starts_the_acpmux_binary (also red on base) fails, plus two daemon::state_file_tests::managed_unit_* lib tests. Those two fail only on the fleet pool's environment, pass in every Blacksmith run of the crate, and are in code this PR does not touch (src/daemon/).
base reds base 7789cf5 / c62a8db run 40973408643: the hermetic guard fails the same way on base

PR CI reds at a7f4d79, each also red on base (re-checked at the final head before landing):

  • cmux-next checks, "Prebuilt web bundles install and stamp". tests/test_build_web_bundles_from.sh hits a FileNotFoundError on webviews/package.json: since fix(cmux-next): make web OTA publication fail closed #27680, web-bundle-key.py reads that file and the test's scratch repo has none. The same failure on base tip 284e691 is run 41007450680. It runs here only because this PR touches a webviews file (the generated protocol types).
  • cmux-next checks, "Package dependency lists sorted", "Script tests" and "Sidebar look owner". All are red in base cmux-next runs 41004771340 and 41005881456. The signal 5 annotation comes from the swift-test-summary.test.sh fixture, and this PR changes no Swift.
  • release-notary and Guard status. The same two failing tests as base guards run 41006843794: the glaeda signing hook list and the What's New highlights. Guard status only aggregates.

Review

Independent reviews ran with Codex gpt-6.1-sol at high, with fast mode on, through the team route. The coordinator allowed this after gpt-6-astra hung on two GCP lanes tonight. Round by round:

  • BLOCK findings fixed:
    • the stopped agent's child is now re-read before escalating;
    • the killed agent's taps are muted;
    • the cancel epoch is kept for the respawn;
    • held agent messages now include ones sent now;
    • the idle-race hold;
    • the hold now has a number per turn, so a finishing turn cannot release a newer Stop's hold;
    • the hold is checked and ended under the queue lock;
    • after the base merge of acpmux: a turn ends when Claude Code ends it #27536 (carried turns), escalation follows a carried turn, and a carried turn's Stop restart starts the agent again.
  • After the base merge of acpmux: session inbox NAME --digest drains agent messages into one prompt #27523 (inbox digest):
    • a digest made while a person's Stop holds agent messages now waits held and does not steer;
    • a failed turn's automatic retry no longer ends a person's Stop hold. Only the person's next completed turn or a resume ends it.
    • acpmux: session inbox NAME --digest drains agent messages into one prompt #27523's tests that ended their busy turn with the person's Stop now end it with an agent's Stop, as this PR's other digest tests do.
  • Final verdict: SHIP at 84c74f4.

Risk

  • Restart cost. An agent that keeps producing model output for 15 s after Stop is restarted. A long tool call that the harness does not interrupt but that also sends no model signs is not restarted (heard counts model signs only, not tool heartbeats beyond Claude's tool_progress).
  • Hold length. After the person's Stop, agent messages (and an inbox digest of them) wait for the person's next completed turn or a queue resume. A daemon restart forgets the hold.
  • New state value. state: "stopping" is a new value of an existing string field.
  • Size. About 790 changed lines, tests included.

Overlaps (same author, coordinated by the acpmux area owner): #27536 changes cancel.rs force_cancel / cancel_preserving_queued (carried turns), and #27038 / #26747 change queue.rs enqueue.

Agent session: agent session
🤖 Generated with Claude Code

teamleaderleo and others added 3 commits October 29, 2026 16:04
Stop sent session/cancel and, 10 s later, ended only acpmux's logical turn.
An agent that ignored the cancel kept running: its tool calls landed in the
next queued turn (a batch of other agents' messages that started the moment
the forced end settled), and the chat read as running again.

Now an agent whose model still sends signs of work through the grace gets
the cancel once more (the adapter's own interrupt), and 5 s later acpmux
ends its process group, ends the turn as cancelled, and starts it again on
the same conversation before the next turn can start. The row reads
`stopping` until the turn really ended, and updates the ended process still
had in flight are dropped. The person's Stop holds the other agents' queued
messages (still queued, never dropped) until the person's next turn
completes or the queue is resumed; the person's own prompts run next as
before. A stuck chat whose agent starts a tool call again is unstuck at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
…d second cancel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Owned by an agent session · session

@github-actions github-actions Bot added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 29, 2026
@blacksmith-sh

blacksmith-sh Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Found 31 test failures on Blacksmith runners:

Failures

Test View Logs
/tmp/tmp8niy8tu4/summary-missing.json (live):/summary-missing.json (live): View Logs
/tmp/tmps1pxlqx9/summary-fail.json (live):/summary-fail.json (live): View Logs
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows/
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows
View Logs
[new_workspaces] the window draws no frames/[new_workspaces] the window draws no frames View Logs
[tabs] debug.tab_switch timed 0 of 40 switches/
[tabs] debug.tab_switch timed 0 of 40 switches
View Logs
[tabs] the window draws no frames/[tabs] the window draws no frames View Logs
convention violations found (ERROR lines above)./
convention violations found (ERROR lines above).
View Logs
test/transcript-follow.test.ts/transcript-follow.test View Logs
test/transcript-follow.test.ts/transcript-follow.test View Logs
test_every_linux_nightly_job_takes_a_runner_set_member (main.NightlyTrustedRouting.
test_every_linux_nightly_job_takes_a_runner_set_member) (job='attest-nightly-remote-dae
mon', runner='', primary='failure')/
test_every_linux_nightly_job_takes_a_runner_set_member (main.NightlyTrustedRouting.
test_every_linux_nightly_job_takes_a_runner_set_member) (job='attest-nightly-remote-dae
mon', runner='', primary='failure')
View Logs

...and 21 more test failures. View all on Blacksmith

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

… prompt starts nothing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
… the ended agent's tap counts no work; a later Stop starts nothing; a recovered turn restarts too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
… task there is not Send)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
…w too, and holds while anything is queued

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
teamleaderleo and others added 3 commits October 29, 2026 20:08
A release now ends only the hold its caller saw (compare-exchange under the
queue lock, where hold_agent_messages begins it), so a Stop that lands between
the read and the release keeps its agent messages held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
…stop-escalate

# Conflicts:
#	cmux-tui/crates/acpmux/src/hub/cancel.rs
#	cmux-tui/crates/acpmux/src/hub/turns.rs
The digest of the agent messages a person's Stop holds is placed held and does
not steer, read under the queue lock where the hold begins and ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
@cursor

cursor Bot commented Oct 30, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

teamleaderleo and others added 2 commits October 29, 2026 22:49
resume_held no longer clears stop_hold, and leaves agent messages held while a
person's Stop holds them; only the person's next turn or a resume ends it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
The person's Stop now holds agent messages, an inbox digest of them too, until
the person's next turn; these tests only need the turn to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
@github-actions

Copy link
Copy Markdown
Contributor

Shipped in:

@github-actions

Copy link
Copy Markdown
Contributor

Shipped in:

teamleaderleo added a commit that referenced this pull request Oct 30, 2026
…ts from #28046, #27685, #27809, #26984)

- cli/run/ls.rs (72c0200, #28046): the ls request params and the
  filtered sessions write through JsonSet::set.
- hub/cancel.rs (95243d1, #27685): forced_answer's `restarted` too.
- hub/remote_rows.rs (e863077, #27809): mark_peer_stale reads status,
  stale and lastSeen with get. A missing field compares the way the Null
  that indexing returned did.
- hub/restart_resume.rs (84705bd, #26984): BoundedJsonWriter copies at
  most `remaining` bytes with take, not a slice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d9e7e9f7fb2b87a57d5f
teamleaderleo added a commit that referenced this pull request Oct 30, 2026
…g 703 > 693): rewrite the new hits without panics (#28134)

* acpmux: restart tool previews cut strings without slicing (4 string_slice hits from #26984)

The Rust panic ratchet is red on feat-cmux-next: acpmux string_slice 98 > baseline 94.
The four new hits are in hub/restart_resume.rs, added by 84705bd (#26984):
input_prefix and masked_scan cut at a char boundary with `[..end]`,
mask_input_assignments with `[..assignment.end()]`, and safe_tool_text with `[..at]`.

The two char-boundary cuts share one `char_prefix` helper that reads with `str::get`;
the regex-end and rfind cuts use `get` too. Every index was already a char boundary,
so behaviour is unchanged; the unreachable None arms keep the redacting side
("[secret]" alone, or an empty prefix). A new test cuts multibyte text at each spot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d9e7e9f7fb2b87a57d5f

* acpmux ls: the table pads without indexing (3 indexing_slicing hits from #28046)

table() read each row's cell and each column's width by index, and render()
bumped a hidden count by index. They now use get / get_mut. A row shorter than
the header used to panic in table(); now its missing cells are left out. A
row longer than the header has its extra cells padded to no width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d9e7e9f7fb2b87a57d5f

* acpmux: JSON reads and writes without indexing (6 indexing_slicing hits from #28046, #27685, #27809, #26984)

- cli/run/ls.rs (72c0200, #28046): the ls request params and the
  filtered sessions write through JsonSet::set.
- hub/cancel.rs (95243d1, #27685): forced_answer's `restarted` too.
- hub/remote_rows.rs (e863077, #27809): mark_peer_stale reads status,
  stale and lastSeen with get. A missing field compares the way the Null
  that indexing returned did.
- hub/restart_resume.rs (84705bd, #26984): BoundedJsonWriter copies at
  most `remaining` bytes with take, not a slice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d9e7e9f7fb2b87a57d5f

* acpmux: JSON and slice reads and writes without indexing (15 indexing_slicing hits that landed after 08fb305)

Base gained acpmux indexing_slicing hits while this PR was open (703 > 693):
- cli/orchestrate.rs (65b5de8, #28016): harnessFrom via JsonSet::set.
- cli/resolve.rs (9d318f9, #28000): choice_commands finds the session
  argument with enumerate and checks the preceding argument with get.
- hub/key_route.rs (9d318f9, #28000): name and status are read with get.
- hub/message_events.rs, hub/mod.rs, prompt_provenance.rs, server/outbox.rs,
  server/peer_forward.rs
  (7050ae3, #27038): writes via set; outbox reads via pointer, where a
  missing value reads as Null, as indexing returned.
- hub/paging.rs (e0f3350, #27614): the moot slot is read and cleared
  with get / get_mut.
- server/requests/briefs.rs (4a0b8f0, #28172): peersFailed via set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d9e7e9f7fb2b87a57d5f

* panic ratchet: lower acpmux indexing_slicing 693 -> 688, unwrap_used 144 -> 143 (measured at 0c8a50c, run 41050016507)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d9e7e9f7fb2b87a57d5f

* acpmux: Stop reads its answer without indexing (2 indexing_slicing hits from #28154); keep the acpmux baseline at 693

hub/turns.rs (0a088aa, #28154): is_stopped_before_prompt and the
stopped answer read with get, not index. A missing reason compares like Null
did, and stopReason is always present in stopped_answer().

The acpmux baseline goes back to base's 693 / 144. acpmux hits landed three
times while this PR was open, and a baseline pinned to today's exact count
would turn base red the next time one lands before this PR merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d9e7e9f7fb2b87a57d5f

* acpmux: drop three JsonSet imports that super::* already brings in (clippy unused_imports)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d9e7e9f7fb2b87a57d5f

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch had an error being deployed

1 failed and 2 inactive deployments
sentry-dsyms — 84c74f4b Deployed Oct 30, 2026 by teamleaderleo via upload cmux-tui dSYMs to Sentry #81232
artifacts — 84c74f4b Deployed Oct 30, 2026 by teamleaderleo via publish to R2 #81232
ci-routing-agent — 84c74f4b Deployed Oct 30, 2026 by teamleaderleo via cmux-tui tree preflight #81232
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant