acpmux: _acpmux/retry with subagentId resumes that failed subagent now, in the same session

Merged
TE
10d ago
+1023-73

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} (and acpmux 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.subagents id or by its agent id.
    • When its automatic retry waits, that attempt fires now: the same prompt id and attempt, its fired record (now with manual: 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 of attempt_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}, plus from over _acpmux/subsession/retry (checked again at dispatch by family_dispatch_check, which already covers manual + 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.
  • Refusals: retry.busy while 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_queue makes queue_slot refuse where the prompt would wait, so a turn that starts between the check and the send also makes it retry.busy, and a prompt refused before it was recorded gives its claim back (a wait it took over waits again). retry.nothing_failed with data.why (closed, running, resumed, replaced, ended, forgotten, not_retryable, skipped), and the new retry.unknown_subagent. retry.subagent_unsupported is 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 to turn-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 subagent description, the RetryParams.subagentId doc (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 is retry.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.busy while 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_turn and tests/subsessions.rs: the old retry.subagent_unsupported assertions now expect retry.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 is nothing_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_queue and 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_turn now does the final check under the auto_resumes lock. hold_retry_start is 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_retry is now shared by family_dispatch_check and run_gate, and the queue is re-checked under the turn lock.
  • Round 4 (BLOCK): Stop must withdraw a queued sub-session retry. withdraw_family_prompts now 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