acpmux: _acpmux/retry, acpmux retry and retry_session retry a failed turn now, in the same session

Merged
TE
10d ago
+1043-27

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"} (typed RetryParams / RetryResult / RetryKind in the IR and the webview's generated types; capability turn-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-n after P or P~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.rs already put the joined messages back), when it carried hidden context (_meta.acpmux.context, never recorded; its user_message now says hiddenContext: 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 records auto_resume_skipped superseded.
    • 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 (or retry.nothing_failed once 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 is retry.busy until the stopped attempt's task has ended (Mark::Stopping), so it is never joined to the stopped one.
    • Refusals, in data.reason: retry.busy while a turn runs, a prompt waits to run, a continuation or retry is about to start; retry.stopped; retry.nothing_failed when 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. Its user_message carries _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 subagentId asks to retry that subagent, so it never retries the parent's turn instead: it is refused with retry.subagent_unsupported (in Hub::retry, the one entry for the RPC and the MCP tool). When #25234 lands, Hub::retry swaps the refusal for Hub::retry_subagent (no second handler), per hb-acpmux.
  • CLI acpmux retry <session> [--subagent ID], with --json (cli/retry.rs).
  • MCP (cmux-subsessions) retry_session {session, subagent?} (sessionId also accepted). It calls the new _acpmux/subsession/retry, which applies stop_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, as send_message), and then the same retry. Its provenance carries from: <caller>, so family_dispatch_check repeats that check at dispatch. Control is Web when either session drove a Web turn, as for family prompts. retry_session counts as a messaging tool for the agent-pair guard, like stop_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's user_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/retry joins the existing agent_messages arm. cli/command.rs stays at 1199 lines; the new variant's args live in cli/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) and retry_sends_a_failed_turn_again_from_the_cli failed, because the method did not exist.
  • hub_integration auto_resume::retry: refusals (nothing_failed before any turn, after a completed turn and after a cancelled one; busy while a turn runs); a failed turn with no output is resent as the same prompt p-f~resume-1 with manual provenance, 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/retry of another family's session, or of the caller's own parent, is subsessions.not_family (the token retries only its own sub-sessions, subsession_write_target(caller, key, parent_ok: false) as stop); of its own child with nothing failed it is retry.nothing_failed, and with subagentId it is retry.subagent_unsupported.
  • auto_resume::retry::a_subagent_retry_is_refused_and_never_retries_the_parent_turn: a failed turn plus subagentId is refused, and no prompt is sent; without it the turn retries. Red at 2bd28a57 (run 40262860484), green at c37f2db7 (run 40263126332: --lib 843, hub_integration 431, 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 retry refuses when nothing failed, retries p-f~resume-1 after 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 --check clean (run 40262354685): --lib 843 passed; --test hub_integration 430 passed; subsessions, session_resume_cli and routes_rpc passed; clippy --all-targets clean. The fmt and IR bless from that run are committed in a411f172, and the webview codegen in bd2a01df (acpmux-protocol:check current).

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_integration 435, 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 is retry.busy, the first retry.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 gets subsessions.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_status failed 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