acpmux: _acpmux/retry, acpmux retry and retry_session retry a failed turn now, in the same session
Leo, word for word (2026-10-26): "this needs to retry :("
subsession-card's ask, daemon half (b): "An RPC, a CLI command and an MCP tool that retry a failed session's last turn on demand. The webview's Retry button calls the RPC."
PR 2 of 2. PR 1 (#25232, merged) made capacity errors retry by themselves and put retry on the session row.
What changed
- RPC
_acpmux/retry {sessionId, subagentId?}returns{promptId, turnId?, kind: "turn"}(typedRetryParams/RetryResult/RetryKindin the IR and the webview's generated types; capabilityturn-retry-v1). Code:hub/auto_resume/retry_turn.rs.- When the last turn failed, the turn goes again now in the same session as the next attempt of its prompt (
P~resume-nafterPorP~resume-(n-1), the same ids automatic retries use). It resends the same prompt (its text and the images acpmux kept, in order) when the log shows nothing from the model since that prompt and nothing steered in. It sends the continuation an automatic retry sends ("Your previous turn was cut off by ⦠Check what already finished ā¦") when the model had started, when a message was steered in, when it was a turn of agent messages (digest.rsalready put the joined messages back), when it carried hidden context (_meta.acpmux.context, never recorded; itsuser_messagenow sayshiddenContext: true), or when the prompt can't be rebuilt (a document, a link, an image that wasn't kept). - When a retry is waiting (the row's
retry), this takes it over: the same attempt id is sent now under the caller's rules, and the timer recordsauto_resume_skippedsuperseded. - The claim is atomic: under the auto-resume lock it checks that the chat still ends on the failed turn and marks it firing. A double click is
retry.busy(orretry.nothing_failedonce the first went through), so it runs once. Stop or close between the claim and the turn's start refuses it (retry.stopped), and a retry after that isretry.busyuntil the stopped attempt's task has ended (Mark::Stopping), so it is never joined to the stopped one. - Refusals, in
data.reason:retry.busywhile a turn runs, a prompt waits to run, a continuation or retry is about to start;retry.stopped;retry.nothing_failedwhen the last turn didn't fail (completed, cancelled, or none). - It runs under the caller's rules, as any prompt they send:
control_of, the trust gate and a paired machine's Full access, checked before the claim (so a refused caller leaves a waiting retry as it was) and again at dispatch. Itsuser_messagecarries_meta.provenance = {kind: "auto_resume", promptId, attempt, turnId, manual: true}. When its turn completes, the prompts the failed turn held go on (held_by, as for automatic retries). - A request with
subagentIdasks to retry that subagent, so it never retries the parent's turn instead: it is refused withretry.subagent_unsupported(inHub::retry, the one entry for the RPC and the MCP tool). When #25234 lands,Hub::retryswaps the refusal forHub::retry_subagent(no second handler), per hb-acpmux.
- When the last turn failed, the turn goes again now in the same session as the next attempt of its prompt (
- CLI
acpmux retry <session> [--subagent ID], with--json(cli/retry.rs). - MCP (cmux-subsessions)
retry_session {session, subagent?}(sessionIdalso accepted). It calls the new_acpmux/subsession/retry, which appliesstop_session's ownership check (subsession_write_target: the caller's own descendants only), refuses a target that asks less than the caller (receiver_check,subsessions.receiver_asks_less, assend_message), and then the same retry. Its provenance carriesfrom: <caller>, sofamily_dispatch_checkrepeats that check at dispatch. Control is Web when either session drove a Web turn, as for family prompts.retry_sessioncounts as a messaging tool for the agent-pair guard, likestop_session. The skill and README mention it.
Names match hb-acpmux's contract (retry-contract.md). Additions: the sub-session route _acpmux/subsession/retry, which the MCP tool needs because a caller token only proves itself on the sub-session methods; the refusal retry.stopped; provenance manual: true (and from for a sub-session's retry); hiddenContext on user_message. One change in meaning: a waiting retry is taken over and sent now under the caller's rules, rather than woken under the rules it was scheduled with, and the answer then carries its turnId.
Decisions
-
A Stop during the failed turn suppresses automatic retries, but not a retry the person asks for afterwards: clicking Retry is a new request.
-
"Produced nothing" means no model update (
tap::logged_from_the_model) after the prompt'suser_message. Resending a prompt the model had started on could redo work, so that case gets the continuation. -
requests.rs(1682 lines, over budget on base) gains no lines:_acpmux/retryjoins the existingagent_messagesarm.cli/command.rsstays at 1199 lines; the new variant's args live incli/retry.rs, and two wrapped doc comments were joined, with their text unchanged. -
Size: about 1070 changed lines, over the ~800 guide. About 95 are generated (IR, TS) and about 340 are tests; the review fixes added most of the rest. Splitting the RPC from its CLI and MCP surfaces now would re-review the same core twice, so it stays one PR.
Stop conditions
No new timer, observer or loop. Taking over a waiting retry drops its mark, which wakes the one existing auto-resume wait (PR 1's Wake); that task records the skip and exits.
Tests (red first)
- Red at c9e695be (Blacksmith fast-checks run 40261362890):
auto_resume::retry::*(4) andretry_sends_a_failed_turn_again_from_the_clifailed, because the method did not exist. hub_integrationauto_resume::retry: refusals (nothing_failedbefore any turn, after a completed turn and after a cancelled one;busywhile a turn runs); a failed turn with no output is resent as the same promptp-f~resume-1withmanualprovenance, in the same agent session, once for two concurrent clicks; a failed turn with output gets the continuation; a waiting 60 s retry fires at once.subsessions:_acpmux/subsession/retryof another family's session, or of the caller's own parent, issubsessions.not_family(the token retries only its own sub-sessions,subsession_write_target(caller, key, parent_ok: false)asstop); of its own child with nothing failed it isretry.nothing_failed, and withsubagentIdit isretry.subagent_unsupported.auto_resume::retry::a_subagent_retry_is_refused_and_never_retries_the_parent_turn: a failed turn plussubagentIdis refused, and no prompt is sent; without it the turn retries. Red at 2bd28a57 (run 40262860484), green at c37f2db7 (run 40263126332:--lib843,hub_integration431,subsessions,session_resume_cli,routes_rpc; clippy clean; fmt no diff). IR and generated types are current at 7d22f6f2 (run 40263469275).session_resume_cli:acpmux --json retryrefuses when nothing failed, retriesp-f~resume-1after a failure (the agent echoes the same prompt), then refuses again.- Unit: rebuilding a prompt from its record (text, pasted text, kept images, in order); refusing to rebuild documents, links and images that weren't kept; continuation wording;
retry_session's argument mapping. - Green at 930a58b1 (run 40261651004), and again at bd2a01df with
cargo fmt --checkclean (run 40262354685):--lib843 passed;--test hub_integration430 passed;subsessions,session_resume_cliandroutes_rpcpassed; clippy--all-targetsclean. The fmt and IR bless from that run are committed in a411f172, and the webview codegen in bd2a01df (acpmux-protocol:checkcurrent).
No capture: nothing on screen changes. The only webview paths are the generated protocol types (generated/types.ts, generated/methods.ts: RetryParams, RetryResult, RetryKind and the two method names), which nothing renders yet. The webview lane's Retry button will call _acpmux/retry.
- Review fixes (red first): red at d47911ba (run 40267159295: the six new or changed tests failed), green at 04e0ff65 (run 40267163499:
hub_integration435,subsessions,subsessions_safety,session_resume_cli,routes_rpc).auto_resume::retry: a Web caller that may not prompt the chat leaves a waiting retry untouched; Retry, Stop, then a second Retry isretry.busy, the firstretry.stopped, one prompt, and a later Retry goes (red at 8d87d8fb, run 40268445028, green at cb4b0d45 run 40268450513); a failed digest is continued and each joined message runs once; a failed turn with hidden context is continued.subsessions_safety: a caller that asks more than its child getssubsessions.receiver_asks_less, one in full access retries it. Clippy clean at cb4b0d45; fmt applied in cea2b511. --lib:cli::harness_login::tests::codex_bridge_status_asks_codex_login_statusfailed in these runs, and fails the same way on base bb04400e (run 40269127818): not this change.
Review
Codex gpt-6-astra (xhigh), session hb-retry-turn-review, reading the bare mirror only. Round 1 at 7d22f6f2: BLOCK, five findings (caller authority on waiting retries, family checks, Stop before dispatch, digest resend, hidden context), all fixed above. Round 2 at 04e0ff65: BLOCK, one finding (a retry joined to one Stop overtook), fixed in cb4b0d45. Round 3: SHIP at cea2b5111e7ea613b70866d31d2396abbca401e4 ("Stopping closes the round-2 race: retries remain busy until the stopped task exits the prompt ledger, and cleanup is scoped to its cut turn. Earlier authorization, scope, and replay fixes remain intact.").
Agent session: big-red
š¤ Generated with Claude Code