acpmux: wait --print shows a failed turn's error and its retry, never the reply before it

Merged
TE
11d ago
+801-21

The gui-push coordinator (2026-10-20-gui-push-6f, remote acpmux ergonomics), word for word:

"(1) acpmux wait --print after a turn that failed (capacity) prints the previous turn's reply, not the error, so a failed turn reads as done"

(Part (2), send --prompt-file, is #25362. It was split out to keep each PR near 800 lines; this one is 822, about 470 of them tests.)

Cause

wait --print picked "the last reply" with last_replies(…, 1), the newest assistant item in the transcript. A turn that failed before it said anything has none, so the reply came from the turn before it. The daemon's _acpmux/wait could also answer in the moment between a failed turn's end and its automatic retry being scheduled, so even a careful client saw retry: null.

What changed

CLI (cli/orchestrate.rs): when the resolved session's last turn failed and no turn runs:

  • --print prints that turn's error. If the turn streamed output of its own (output::turn_reply for lastTurn.turnId), that is printed too, never the turn before it.
  • It prints the automatic retry that waits (#25232's retry): which attempt, what it waits out, when the next attempt runs, and acpmux retry NAME to run it now. If none waits, it says that.
  • The state line reads ready: turn failed[, retry waits], and --notify says NAME's turn failed. It exits 1 as before. --json rows gain error and retry.

Daemon (hub/auto_resume.rs, hub/views.rs, server/wait.rs, protocol/session.rs):

  • The session row gains retryDeciding, read together with retry from one read of the chat's auto-resume mark (Hub::retry_row). It is true while the last turn failed on an error a retry answers and whether, and when, it is retried is not settled yet: the turn's end is being settled, the retry's time is not set yet, or the retry is starting or running. If the retry the row read is gone by the time it is checked (it fired meanwhile), the mark is read again, up to 3 times (settled_retry_row), so a row never shows neither a retry nor its decision. The field is absent when false and from older peers. The protocol IR and the webviews types are regenerated.
  • A decision for no retry on such a turn records auto_resume_settled {turnId, why} (stopped, off, not_retried), forwarded to watchers and peers, so a peer's copy of the row clears too. Stop records it for a mark it takes (settled_by_stop).
  • _acpmux/wait holds its answer for a row that says retryDeciding (and whose turn is null and is not closed, missing or stale), for at most 5 s from when that wait first saw it. That only bounds a row nothing refreshes, such as a record its peer link lost. It adds no timer: the wait's existing record wake and 1 s re-read end the hold.

Before (base, from the red run):

$ acpmux wait lane --print        # last turn: "API Error: 503 no non-exhausted claude accounts …"
lane                     ready
echo: the answer before

After (a CI run of the built binary, https://github.com/manaflow-ai/cmux/actions/runs/40276666964):

$ acpmux wait lane --print
lane                     ready: turn failed, retry waits
error: API Error: 503 no non-exhausted claude accounts available. This is a server-side issue, usually temporary — try again in a moment. If it persists, check your inference gateway (127.0.0.1:4016). (error type server_error, HTTP 503)
retry 1 of 1 waits out capacity: the provider or the gateway is full; the next attempt runs in 1m12s (`acpmux retry lane` runs it now)
$ echo $?
1
$ acpmux --json wait lane      # its row, abridged
{"name": "lane", "status": "ready", "error": "API Error: 503 no non-exhausted claude accounts …",
 "retry": {"attempt": 1, "of": 1, "reason": "capacity", "nextAt": 1792994132107, "promptId": "…", "turnId": "…"},
 "reply": ""}

Takes back #25232: its row line retry: self.waiting_retry(session) (hub/views.rs) becomes Hub::retry_row, which reads the same retry together with the new retryDeciding from one read of the mark. The retry a row shows is unchanged, and waiting_retry is folded into retry_row.

Tests (red, then green)

Review

Codex gpt-6-astra (xhigh), reading the bare mirror only, four rounds: BLOCK at af47d2c (a row read before its retry was set; peers), BLOCK at d0b375f (a retry starting between the mark read and its check; a peer row left deciding), BLOCK at eff6645 (Stop taking a waiting mark with retries off), each fixed with a red test first. SHIP at 9f17e1e46b493094fe91186ab23b0499061aa17b ("No blocking findings. stop_auto_resume now captures Ending and Waiting marks under the lock and emits exactly one settlement record, while Firing/running paths retain their existing handling.").

No capture: CLI text and a row field only. The before and after output is above.

Stop conditions: no new timer, observer or loop. The wait's hold ends at its existing 1 s re-read or record wake, after at most 5 s per chat, or at the wait's own timeout. settled_retry_row reads the mark at most 3 times per row.

Agent session: big-red
🤖 Generated with Claude Code