acpmux: _acpmux/retry with subagentId resumes that failed subagent now, in the same session
Leo, 2026-10-26: "this needs to retry :(" (two "Subagent failed" rows in his chat, both Claude Agent-tool subagents failed on SR2's "503 no non-exhausted claude accounts available").
The contract subsession-card builds the Retry button against (retry-contract.md (b)): "RPC: _acpmux/retry {sessionId, subagentId?} returns {promptId, turnId?, kind: "turn" | "subagent"}. When a retry is already waiting, it fires that retry now. ... With subagentId, it resumes that subagent." CLI: acpmux retry <session> [--subagent ID]. MCP: retry_session {session, subagent?}.
#25296 shipped _acpmux/retry for the chat's turn and refused a subagentId with retry.subagent_unsupported; #25234 shipped the automatic subagent retry. This PR is the subagent branch on top of both.
What changed
_acpmux/retry {sessionId, subagentId}(andacpmux retry NAME --subagent ID,retry_session {session, subagent},_acpmux/subsession/retry) resumes that failed subagent now, in the same session (hub/subagent_retry_manual.rs). The subagent is named by its_meta.acpmux.subagentsid or by its agent id.- When its automatic retry waits, that attempt fires now: the same prompt id and attempt, its
firedrecord (now withmanual: true), and the waiting task wakes and finds it taken. - Otherwise one more attempt goes now (after a Stop, after the automatic attempts are used up, or before the first wait was planned).
- Both go through #25234's own send path (
send_attempt, split out ofattempt_retry): the parent gets the prompt the automatic retry sends (SendMessage to the agent id, a new subagent only if that fails), never a new session, and a Stop, resume or replacement takes it back as before (Retries::start). - Provenance
{kind: "subagent_retry", subagentId, agentId, attempt, manual: true}, plusfromover_acpmux/subsession/retry(checked again at dispatch byfamily_dispatch_check, which already coversmanual+from). - Answers
{promptId, turnId, kind: "subagent"}once the prompt is accepted, under the caller's rules (retry_allowed, the same as the turn retry). - Once its turn ends with the subagent still failed, the automatic attempts go on as they would have; a subagent that had none left (stopped or given up) stays that way and is still retryable by a person.
- When its automatic retry waits, that attempt fires now: the same prompt id and attempt, its
- Refusals:
retry.busywhile a turn runs or is about to start (acpmux's or Claude Code's own notification turn), a prompt waits to run, a turn continuation may still fire, the chat moves, a sub-session's run slots are full, or this subagent's retry is already under way. This matches #25296's turn case: it refuses, it never queues.PromptOptions::no_queuemakesqueue_slotrefuse where the prompt would wait, so a turn that starts between the check and the send also makes itretry.busy, and a prompt refused before it was recorded gives its claim back (a wait it took over waits again).retry.nothing_failedwithdata.why(closed,running,resumed,replaced,ended,forgotten,not_retryable,skipped), and the newretry.unknown_subagent.retry.subagent_unsupportedis gone. - To say why, the hub remembers the last 64 subagents per chat that can no longer be retried (dropped when the chat closes).
- Capability
subagent-retry-v1(wire_acpmux/retry), next toturn-retry-v1: a daemon without it refuses a subagent retry, so the pane shows a subagent's Retry button only under this id. - Docs: README, SKILL.md, the CLI's doc text, the MCP tool's
subagentdescription, theRetryParams.subagentIddoc (spec JSON and generated types.ts updated to match).
Tests
Red on the test-only commit (2b5f4bb, run): all four fail with retry.subagent_unsupported.
subagent_retry::a_person_retries_a_stopped_subagent_now: after a Stop, the retry sends the resume prompt now,manual: true; the subagent runs again; a second retry isretry.nothing_failed(resumed).subagent_retry::a_person_retry_fires_the_waiting_retry_now: by agent id, the waiting attempt fires now (same prompt id), once.subagent_retry::a_person_subagent_retry_is_refused_by_name:retry.unknown_subagent,retry.busywhile a turn runs (the waiting retry untouched),retry.nothing_failed(not_retryable) for a bad-request failure.auto_resume::retry::an_unknown_subagent_retry_is_refused_and_never_retries_the_parent_turnandtests/subsessions.rs: the oldretry.subagent_unsupportedassertions now expectretry.unknown_subagent.- Unit (
race_tests): why a subagent can't be retried (replaced by id or agent id, running, closed); a stopped subagent a person retried stays stopped; a retry refused at dispatch answers at once (red at 430b44d); overtaken between claim and send (turn lock, Claude Code's own turn) is busy and gives the wait back; Stop, resume or replacement before its start isnothing_failed(skipped); a double click is busy; a sub-session's retry passes the family check and names its sender.
Green at the head 63d91cb (fast-checks run): fmt and clippy clean, --lib 938 passed, --test hub_integration 474 passed (1 ignored), --test subsessions and --test full_access_subsessions passed.
Review
Codex gpt-6-astra xhigh, five rounds, SHIP at 63d91cbeaad0a1d9d62e93cab169b4755a815c96.
- Round 1 (BLOCK): a manual retry could queue behind a turn that started after its check, and a stale queued retry was still acknowledged. Fixed with
PromptOptions::no_queueand the claim given back on refusal. - Round 2 (BLOCK): the continuation and Claude Code's own-turn checks were not atomic with the turn start, and the queue check could miss an entry.
start_turnnow does the final check under theauto_resumeslock.hold_retry_startis skipped for no_queue, and the queue is read under its lock. - Round 3 (BLOCK): a sub-session's retry bypassed the parent's run slots, and the queue had to be re-read once the turn lock is held.
is_family_retryis now shared byfamily_dispatch_checkandrun_gate, and the queue is re-checked under the turn lock. - Round 4 (BLOCK): Stop must withdraw a queued sub-session retry.
withdraw_family_promptsnow covers it. - Round 5: SHIP.
Side effect on #25296's turn retry: one a sub-session asks for (retry_session without subagent) now takes one of the parent's run slots, as family prompts do. It waits for a slot (it is not no_queue), and Stop or close withdraws it while it waits.
Base reds, not this PR: gallery health (an allowlisted entry now renders, and budgets are stale for agent-pane.diff-panel and image-viewer), "publish the merge tree" (HTTP 422 on the ref update), and "same-tree cmux-tui", which follows from it. #25353 merged with the same reds.
Remaining race, by design: Claude Code can start its own notification turn just after the last harness_turn read. The two processes share no lock, and #25234 names this as the race any person's prompt has.
Stop conditions
No new timer or observer. The one spawned task per person's retry ends when its prompt's turn ends (or when the prompt is refused, after giving its claim back); the automatic attempts it may hand back to are #25234's, with their existing stop conditions (Stop, close, resume, replacement, exhaustion).
No capture: daemon-only change, nothing visible until #25226's button calls it.
Agent session: big-red
š¤ Generated with Claude Code