agentmux_srv/backend/blockcontroller/persistent/resume_retry.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Stale-`--resume` recovery: executing the effects `persistent_resume` decides,
5//! locating a recoverable session id, and the retry-batch rewrite.
6
7use super::*;
8
9impl PersistentSubprocessController {
10 /// Appends a terminal error-result line to this block's output now and
11 /// surfaces its classified failure: a held-back line confirmed final
12 /// outside the process-waiter task (its retry failed to launch, or its
13 /// generation was superseded), or an error frame synthesized for
14 /// prompts that could not be delivered. See
15 /// `retry_after_resume_failure`'s doc comment for when a held line is
16 /// flushed rather than dropped.
17 pub(super) fn flush_error_line_now(&self, line: String) {
18 let Some(ref broker) = self.broker else { return };
19 let global_output_zone = super::super::shell::resolve_global_output_zone(&self.mstore, &self.block_id);
20 super::super::shell::handle_append_block_file(
21 broker,
22 &self.block_id,
23 PERSISTENT_OUTPUT_SUBJECT,
24 line.as_bytes(),
25 self.filestore.as_ref(),
26 global_output_zone.as_deref(),
27 );
28 // Same gap reagentx flagged (PR #2421 P2) at the other FlushErrorLine
29 // call sites: this is also a previously held-back turn's error only
30 // now confirmed final (a fresh spawn superseding a still-tracking
31 // generation, or a retry batch exhausted with nothing left to
32 // deliver) — give it the same classify/persist/publish treatment so
33 // it isn't silently dropped from the pane's failure-recovery UI. No
34 // exit code exists for this now-superseded turn.
35 surface_error_line(&self.block_id, None, &line, Some(broker.as_ref()), &self.mstore, &self.event_bus);
36 }
37
38 /// A `Fresh` outcome whose new session will carry AgentMux's record of
39 /// the conversation, so the pane says "continued" rather than "new".
40 pub(super) fn emit_fresh_outcome_now(&self, attempted_sid: String, continued: bool) {
41 self.emit_session_outcome_now_with(persistent_resume::SessionOutcome::Fresh, attempted_sid, None, continued);
42 }
43
44 /// Whether a fresh spawn right now would carry a continuation packet
45 /// (what `spawn` gives any no-`--resume` spawn onto prior history).
46 /// Reads the transcript tail: call it on rare paths, never under `inner`.
47 pub(super) fn fresh_spawn_would_continue(&self) -> bool {
48 self.has_prior_transcript() && self.continuation_packet().is_some()
49 }
50
51 fn emit_session_outcome_now_with(
52 &self,
53 outcome: persistent_resume::SessionOutcome,
54 attempted_sid: String,
55 actual_sid: Option<String>,
56 continued: bool,
57 ) {
58 let Some(ref broker) = self.broker else { return };
59 let line = session_outcome_line_with(outcome, attempted_sid, actual_sid, continued);
60 let global_output_zone = super::super::shell::resolve_global_output_zone(&self.mstore, &self.block_id);
61 super::super::shell::handle_append_block_file(
62 broker,
63 &self.block_id,
64 PERSISTENT_OUTPUT_SUBJECT,
65 line.as_bytes(),
66 self.filestore.as_ref(),
67 global_output_zone.as_deref(),
68 );
69 }
70
71 /// Does a transcript already exist for this pane — either in this
72 /// channel's own blockfile, or in the agent's GLOBAL transcript zone?
73 ///
74 /// The second half is the one that matters for
75 /// [`fresh_start_needs_disclosure`]: on a cross-channel or cross-version
76 /// open this channel's blockfile is empty, but the pane still renders the
77 /// full prior conversation through `app_api::global_output_source`'s read
78 /// fallback. Mirrors that function's checks (agent-anchored, not archived,
79 /// non-empty `output`) so "the pane will show history" and "we disclose a
80 /// fresh start" can't disagree.
81 ///
82 /// Two `stat` calls at most, on the spawn path only — cheap enough to run
83 /// unconditionally behind the caller's own generation gate.
84 pub(super) fn has_prior_transcript(&self) -> bool {
85 if let Some(ref fs) = self.filestore {
86 if matches!(fs.stat(&self.block_id, PERSISTENT_OUTPUT_SUBJECT), Ok(Some(ref f)) if f.size > 0)
87 {
88 return true;
89 }
90 }
91 let Some(zone) = global_prior_zone(self.mstore.as_deref(), &self.block_id) else {
92 return false;
93 };
94 let Some(gfs) = crate::backend::agent_session::global_transcript_store() else {
95 return false;
96 };
97 matches!(
98 gfs.stat(&zone, crate::backend::agent_session::OUTPUT_FILE),
99 Ok(Some(ref f)) if f.size > 0
100 )
101 }
102
103 /// The session to continue on a first spawn that holds no id of its own
104 /// (SPEC_DURABLE_CONVERSATION_MEMORY_2026_09_23.md §5 P0a): the session
105 /// of the history this pane renders ([`pane_history_session_id`]), when
106 /// `--resume` would find it under this spawn's config dir and it isn't
107 /// already poisoned.
108 pub(super) fn find_continuation_session_id(&self, config: &PersistentSpawnConfig) -> Option<String> {
109 if config.resume_flag.is_empty() {
110 return None;
111 }
112 let config_dir = config.env_vars.get("CLAUDE_CONFIG_DIR")?;
113 let sid = pane_history_session_id(
114 self.filestore.as_deref(),
115 self.mstore.as_deref(),
116 &self.block_id,
117 &config.session_id_field,
118 )?;
119 if self.inner.lock().unwrap().resume_poisoned.as_deref() == Some(sid.as_str()) {
120 return None;
121 }
122 crate::backend::session_backfill::session_is_reachable(config_dir, &config.working_dir, &sid)
123 .then_some(sid)
124 }
125
126 /// AgentMux's record of the conversation this pane renders, as a
127 /// continuation packet for a process that starts without it
128 /// (SPEC_DURABLE_CONVERSATION_MEMORY_2026_09_23.md §4.4).
129 pub(super) fn continuation_packet(&self) -> Option<String> {
130 let zone = global_prior_zone(self.mstore.as_deref(), &self.block_id);
131 let gfs = crate::backend::agent_session::global_transcript_store();
132 let global = zone.as_deref().zip(gfs.map(|g| &**g)).map(|(z, g)| (g, z));
133 let (tail, starts_mid_line) = packet_history_tail(
134 self.filestore.as_deref(),
135 &self.block_id,
136 global,
137 crate::backend::continuity::PACKET_TAIL_BYTES,
138 )?;
139 // The agent's running summary, kept beside its global transcript
140 // (`continuity_state.rs`). Missing for panes that aren't anchored to
141 // an agent, or until the first update has run.
142 let state = global.and_then(|(g, z)| crate::backend::continuity_state::latest_state(g, z));
143 crate::backend::continuity::build_continuation_packet(
144 &tail,
145 starts_mid_line,
146 state.as_ref().map(|s| crate::backend::continuity::RunningState {
147 text: &s.text,
148 created_at_ms: s.created_at_ms,
149 }),
150 )
151 }
152
153 /// The continuation packet a fresh spawn onto prior history carries, or
154 /// `None` when there is no record to give it.
155 ///
156 /// Carrying one also retracts the "Couldn't resume the previous
157 /// conversation — started a new one" banner a rejected `--resume` raised
158 /// on the previous generation: the conversation wasn't lost, this session
159 /// continues it from AgentMux's record. Same reasoning as the `Resumed`
160 /// retract in the stdout reader — the banner must not contradict the
161 /// "continued" outcome the pane shows.
162 pub(super) fn carry_continuation(&self) -> Option<String> {
163 let packet = self.continuation_packet()?;
164 tracing::info!(
165 block_id = %self.block_id,
166 packet_chars = packet.len(),
167 "continuity: carrying AgentMux's record of the conversation into the fresh session"
168 );
169 if let Some(ref store) = self.mstore {
170 super::super::session_recovery::clear_resume_failed(store, &self.event_bus, &self.block_id);
171 }
172 Some(packet)
173 }
174
175 /// After a confirmed-stale `--resume` failure, try to recover a REAL
176 /// session instead of giving up and starting blank
177 /// (`docs/status/STATUS_CROSS_CHANNEL_RESUME_STALE_SESSION_ID_2026_08_20.md`).
178 /// Looks for the largest on-disk session under this spawn's own
179 /// `CLAUDE_CONFIG_DIR`/working dir — the same "largest session wins"
180 /// recovery `session_backfill::backfill_session_ids` already trusts
181 /// for the analogous cross-channel-open case.
182 ///
183 /// Returns `None` (caller falls back to starting blank, same as
184 /// before this existed) when: `CLAUDE_CONFIG_DIR` isn't set on this
185 /// config, no session exists there, or the only candidate found is
186 /// the SAME id already confirmed poisoned — guards against looping
187 /// forever "recovering" an id that is itself dead (nothing on disk
188 /// changes between attempts, so an unguarded retry would rediscover
189 /// the identical bad id every time).
190 pub(super) fn find_recovery_session_id(&self, config: &PersistentSpawnConfig) -> Option<String> {
191 let config_dir = config.env_vars.get("CLAUDE_CONFIG_DIR")?;
192 let candidate = crate::backend::session_backfill::find_largest_session_for_working_dir(
193 config_dir,
194 &config.working_dir,
195 )?;
196 let already_poisoned = self.inner.lock().unwrap().resume_poisoned.as_deref() == Some(candidate.as_str());
197 if already_poisoned {
198 return None;
199 }
200 // The largest session under this config dir and cwd is only this
201 // conversation if it's the head of the agent's segment chain
202 // (SPEC_DURABLE_CONVERSATION_MEMORY_2026_09_23.md §4.2). Otherwise
203 // (after an account switch, typically) it's older history. Returning
204 // `None` makes the retry a fresh session, which now carries the
205 // continuation packet.
206 if let (Some(uid), Some(gfs)) = (self.stable_agent_uid(), crate::backend::agent_session::global_transcript_store()) {
207 let chain = crate::backend::continuity_segments::segments(gfs, &uid);
208 let current = self.current_segment.lock().unwrap().clone();
209 let head = crate::backend::continuity_segments::head_session(&chain, current.as_deref());
210 if !crate::backend::continuity_segments::recovery_allowed(&candidate, head.as_deref()) {
211 tracing::info!(
212 block_id = %self.block_id,
213 candidate = %candidate,
214 head = ?head,
215 "continuity: not recovering a session that isn't the head of the agent's chain"
216 );
217 return None;
218 }
219 }
220 Some(candidate)
221 }
222
223 /// Retries the message that triggered a `--resume <sid>` attempt this
224 /// controller's own stderr reader just confirmed is unreachable ("No
225 /// conversation found with session ID" — see `poison_resume`). Called
226 /// from the process-waiter task once the doomed process has actually
227 /// exited, via the weak self-reference (mirrors `SubprocessController`'s
228 /// queued-message drain — see `set_self_ref`), and ONLY for a
229 /// `persistent_resume::ResumeEffect::FireRetry` (a `ConfirmedRetry`
230 /// generation's exit) — never for an unrelated exit.
231 ///
232 /// Respawns with the stale `session_id` replaced: by a recovered
233 /// on-disk session if `find_recovery_session_id` finds one, otherwise
234 /// cleared so no `--resume` is attempted. Redelivers EVERY message the
235 /// doomed process's stdin channel had accepted (the drain appends each
236 /// later delivery to the batch —
237 /// `persistent_resume::ResumeEvent::MessageAppendedToRetryBatch` — so
238 /// this is a batch, not just the one that triggered the spawn) — does
239 /// NOT re-persist to the blockfile or re-emit `agent-message-accepted`
240 /// for any of them, since both already happened correctly on each
241 /// message's original (failed) attempt; only the underlying CLI
242 /// process needed a fresh start.
243 ///
244 /// This is itself a spawn attempt, and must not race a genuinely
245 /// concurrent `send_message` call the same way the ORIGINAL doomed
246 /// spawn could — see `PersistentInner::spawning_in_progress`'s doc
247 /// comment. By the time this runs, the original `send_message` call
248 /// that triggered the doomed process has long since returned (its own
249 /// spawn-claim-and-deliver sequence completed synchronously, well
250 /// before this process even exited), so the WHOLE batch can safely go
251 /// through `decide_retry_batch_action` — the SAME decision
252 /// `send_message` uses, but enqueueing everything atomically in one
253 /// lock acquisition (see its own doc comment for why per-message
254 /// decisions aren't safe for a batch).
255 ///
256 /// A held-back error line must reach the user if a confirmed retry
257 /// turns out NOT to actually launch (#2371) — otherwise an
258 /// already-accepted prompt ends in total silence: neither the original
259 /// error nor a replacement one. It is flushed only when a
260 /// `BecomeSpawner` respawn fails; the `FlushClaimed` and `Queued` arms
261 /// hand off to a background drain whose own `stalled_with_leftovers`
262 /// branch already publishes a status update on genuine total failure,
263 /// so those arms drop the line instead — see the `Queued` arm below
264 /// for why flushing eagerly there would reproduce the very bubble
265 /// #2371 removed.
266 ///
267 /// `retry_generation` is the spawn generation whose `ProcessExited`
268 /// fired this retry (the process-waiter's own `my_generation_wait`) —
269 /// `decide_retry_batch_action` needs it to tell a live NEWER spawn
270 /// apart from the impossible "our own process is somehow still
271 /// running" case (issue #2367). `attempted_sid` is the id that was
272 /// just confirmed unreachable — threaded through from
273 /// `persistent_resume::ResumeEffect::FireRetry` so this function can
274 /// emit the eventual `EmitSessionOutcome` itself once recovery is
275 /// known (reagentx + Codex on PR #2693 — `persistent_resume.rs` can
276 /// no longer decide Fresh-vs-Resumed at the point it fires this
277 /// retry, since recovery might succeed).
278 pub(super) fn retry_after_resume_failure(
279 &self,
280 retry_generation: u64,
281 mut config: PersistentSpawnConfig,
282 mut entries: Vec<persistent_resume::QueuedRetryEntry>,
283 held_error_line: Option<String>,
284 attempted_sid: String,
285 ) {
286 // An empty batch is not a retry at all — see
287 // `settle_empty_resume_retry`. Decided BEFORE the "retrying" publish
288 // and the recovery search below (codex P1, second round on PR
289 // #3551): both are user-visible side effects, and for a superseded
290 // generation they must not happen either.
291 if entries.is_empty() {
292 self.settle_empty_resume_retry(retry_generation, config, held_error_line, attempted_sid);
293 return;
294 }
295 // "Reconnecting…" starts here regardless of which branch below is
296 // taken — the user-visible gap begins the moment a retry is known
297 // to be needed, not once recovery search finishes. See §6.2.
298 publish_resume_retry_status(&self.broker, &self.block_id, "retrying");
299 let recovered = self.find_recovery_session_id(&config);
300 config.session_id = recovered.clone().unwrap_or_default();
301 match &recovered {
302 // A real on-disk session was found — don't claim "Resumed"
303 // yet (codex P1 on PR #2693: the CLI can still reject this
304 // recovered id too, e.g. a corrupt file or a non-top-level
305 // session). Left unemitted here on purpose: the spawn below
306 // now threads this attempt through the SAME resume-tracking
307 // machinery a genuine first-time `--resume` uses (`Some(first
308 // .clone())`, not `None`), so `persistent_resume.rs`'s
309 // already-correct, already-tested `SessionCaptured` handling
310 // emits `Resumed` once the CLI actually confirms it — or, if
311 // this recovered id is ALSO stale, cascades into another
312 // `ConfirmedRetry` → this same function again, where
313 // `find_recovery_session_id`'s poison guard refuses to
314 // re-recover the identical dead id and this arm's `None`
315 // branch below correctly emits `Fresh` instead. "Reconnecting…"
316 // is deliberately NOT resolved here — the eventual
317 // `EmitSessionOutcome` handling (stdout-reader / process-exit
318 // match arms) is what clears it, once the CLI actually confirms
319 // this recovered id one way or the other.
320 Some(_) => {}
321 // No recovery candidate — this IS genuinely, unambiguously a
322 // fresh conversation, decided right now. Safe to emit
323 // immediately: nothing downstream can turn this back into a
324 // resume. Also resolves "Reconnecting…" immediately, in step
325 // with the outcome itself.
326 None => {
327 publish_resume_retry_status(&self.broker, &self.block_id, "resolved");
328 // The respawn below carries the continuation packet whenever
329 // there's history (§4.2.1), so say so.
330 let continued = self.fresh_spawn_would_continue();
331 self.emit_fresh_outcome_now(attempted_sid, continued)
332 }
333 }
334 // Non-empty by the guard at the top.
335 let first = entries.remove(0);
336 let rest = entries;
337
338 match self.decide_retry_batch_action(retry_generation, &first, &rest) {
339 RetryBatchAction::FlushClaimed => {
340 // A live, NEWER-generation process was already running by
341 // the time this retry got scheduled (issue #2367 — the
342 // retry's own process exited; a live `stdin_tx` is by
343 // definition an unrelated spawn that raced ahead). The
344 // batch was prepended to the queue and `drain_claim` was
345 // taken in the SAME lock acquisition that decided this
346 // arm, so every concurrent `decide_send_action` caller
347 // already routes to `Queued` behind it — the queue is the
348 // single ordering authority. Flush through the same drain
349 // loop a successful spawn uses (`Sender::send`
350 // backpressure, seed-aware retry-batch appends,
351 // delivery-order persistence); it releases `drain_claim`
352 // once the queue runs dry. This subsumes the previous
353 // `try_send`-then-fall-back-to-queue design: everything
354 // goes through the queue up front, so the batch can never
355 // reorder relative to itself or to racing sends, and the
356 // spawn flag is no longer borrowed for a non-spawn (the
357 // round-7/round-8 fallback machinery this replaces).
358 self.mark_turn_active_and_publish();
359 // `allow_fallback_respawn: false` — the flush targets an
360 // already-running process; a stall means that process
361 // died, and its own exit handling (or the next send's
362 // spawn) picks the leftovers up.
363 self.drain_queue_with_claim(config.clone(), false, QueueDrainClaim::RetryFlush);
364 // reagentx P1 (round 2 on PR #2371): eventual delivery is
365 // the overwhelmingly common outcome — flushing the held
366 // line eagerly here would show a stale, wrong error
367 // bubble immediately followed by the real (successful)
368 // response. On the rare total-failure path the drain's
369 // own `stalled_with_leftovers` branch (with
370 // `allow_fallback_respawn: false`) already calls
371 // `publish_status()` — never silent forever, even without
372 // the specific original error text.
373 drop(held_error_line);
374 }
375 RetryBatchAction::Queued => {
376 // Someone else is already spawning — their own
377 // `release_spawn_claim_and_drain_queue` will deliver this
378 // (or, if their process turns out to already be dead, its
379 // own bounded fallback respawn will).
380 //
381 // reagentx P1 on PR #2371 (round 2): flushing eagerly HERE
382 // (an earlier cut of this fix did, reasoning that losing
383 // it silently on eventual failure was worse) contradicts
384 // this codebase's own established pattern for a `Queued`
385 // outcome — `decide_send_action`'s doc comment: side
386 // effects for a queued item happen "later, inside the
387 // drain, at the exact moment this message is actually
388 // delivered," not eagerly at enqueue time. Eventual
389 // success is the OVERWHELMINGLY common outcome for a
390 // queued message (that's the whole point of the
391 // queue/drain/fallback-respawn infrastructure below), so
392 // flushing eagerly would show a stale, wrong error bubble
393 // in the common case, immediately followed by the real
394 // (successful) response — reproducing the exact bug this
395 // PR exists to fix, just via a different path. Dropped
396 // instead: on the rare total-failure path (the fallback
397 // respawn ALSO fails), `release_spawn_claim_and_drain_queue`'s
398 // own stalled-fallback branch already publishes a status
399 // update and keeps the messages queued for a future spawn
400 // attempt — never silent forever, even without the
401 // specific original error text.
402 drop(held_error_line);
403 }
404 RetryBatchAction::BecomeSpawner { own_seq } => {
405 // Only clear inner.session_id now that THIS retry is
406 // actually about to spawn — codex P2 on PR #2360 (sixth
407 // review pass, round 3): clearing it unconditionally up
408 // front could erase a session id a DIFFERENT,
409 // concurrently-installed process had already legitimately
410 // captured, if this retry instead resolved via
411 // `DeliverDirect` or `Queued` above — breaking in-memory
412 // session tracking and turn-end subagent reconciliation
413 // for that process's remaining lifetime.
414 // Clear + reserve a fresh generation in one lock
415 // acquisition (same race as the leftover-queue fallback —
416 // see `clear_session_id_for_fresh_spawn`).
417 self.clear_session_id_for_fresh_spawn();
418 let retry_config = config.clone();
419 // `Some(first.clone())`, not `None` (codex P1 on PR
420 // #2693): when `config.session_id` holds a recovered
421 // on-disk session (not empty), this MUST thread through
422 // as a real resume-tracking seed — same as any first-time
423 // `--resume` attempt (mirrors `SendAction::BecomeSpawner`'s
424 // own `spawn_process` call above) — or a recovered id
425 // that turns out to be ALSO stale/rejected has no
426 // detection/retry-cascade at all, stranding the batch on
427 // that attempt's raw error instead of eventually falling
428 // through to the promised blank-conversation fallback.
429 // Harmless when there's nothing to resume: `spawn_process`
430 // only constructs `SpawnedWithResume` tracking when
431 // `inner.session_id` actually ends up populated, which it
432 // won't if `config.session_id` is empty — this always
433 // correctly falls back to `SpawnedFresh` in that case,
434 // same as passing `None` used to.
435 let spawn_result = self.spawn_process(config, Some(first.clone()));
436 match &spawn_result {
437 Ok(_) => self.mark_turn_active_and_publish(),
438 Err(e) => tracing::error!(
439 block_id = %self.block_id,
440 error = %e,
441 "failed to respawn after a stale --resume session id"
442 ),
443 }
444 self.release_spawn_claim_and_drain_queue(spawn_result.is_ok(), retry_config, own_seq);
445 if spawn_result.is_err() {
446 // Surface this, or the pane hangs forever with NO
447 // signal at all — codex P2 on PR #2360 (fifth review
448 // pass): the outer process-waiter already suppressed
449 // its own terminal-status publish for the ORIGINAL
450 // exit specifically because a retry was in flight, and
451 // send_message already returned success (possibly
452 // emitting agent-message-accepted) for the message
453 // this retry was supposed to deliver. If this respawn
454 // attempt ALSO fails, nothing else will ever tell the
455 // frontend this turn is over. `inner.proc_status`/
456 // `turn_active` are already `STATUS_DONE`/`false` (set
457 // by the original exit's own cleanup before this
458 // function was ever called) — this just actually
459 // broadcasts that state, which the original exit
460 // deliberately withheld pending this retry's outcome.
461 self.publish_status();
462 // codex P2 on PR #2371: the retry never actually
463 // launched — flush any held error line now instead
464 // of silently dropping it, so the user gets at least
465 // one explanation (the original error) instead of
466 // total silence.
467 if let Some(line) = held_error_line {
468 self.flush_error_line_now(line);
469 }
470 }
471 }
472 }
473 }
474
475 /// Same shape of decision as `decide_send_action`, but atomically
476 /// enqueues the ENTIRE batch (not just one message) in every arm —
477 /// used only by `retry_after_resume_failure`. codex P2 on PR #2360
478 /// (sixth review pass, round 6): deciding and enqueueing a
479 /// multi-message retry batch one call at a time (each through its own
480 /// `decide_send_action` call) left a window between them where a
481 /// genuinely new, unrelated message could interleave into the MIDDLE
482 /// of the same original batch, reordering it relative to how the
483 /// doomed process actually received it. Batch prepend/dedup rules
484 /// live in [`Self::prepend_retry_batch`].
485 ///
486 /// Issue #2367 (spec §4, option 2): the live-process outcome is no
487 /// longer a caller-side `try_send` (`DeliverDirect`) — that let a
488 /// concurrent send race ahead of, or into the middle of, the batch.
489 /// It is now [`RetryBatchAction::FlushClaimed`]: batch prepended and
490 /// `drain_claim` taken under this one lock, then flushed through the
491 /// shared queue drain.
492 pub(super) fn decide_retry_batch_action(
493 &self,
494 retry_generation: u64,
495 first: &persistent_resume::QueuedRetryEntry,
496 rest: &[persistent_resume::QueuedRetryEntry],
497 ) -> RetryBatchAction {
498 let mut inner = self.inner.lock().unwrap();
499 if inner.stdin_tx.is_some() && !inner.spawning_in_progress && !inner.drain_claim {
500 // The retry's own process exited — that exit is what fired
501 // this retry, and the exit-handler clears `stdin_tx` under
502 // the same `is_current_generation` gate — so a live
503 // `stdin_tx` here is by definition a NEWER, unrelated spawn
504 // that raced ahead during the exit-handler's cleanup window
505 // (issue #2367).
506 debug_assert!(
507 inner.spawn_generation != retry_generation,
508 "a retry's own generation cannot still be current while stdin_tx is live"
509 );
510 // Prepend the batch and take the drain claim in THIS SAME
511 // lock acquisition (spec §4 option 2): from this instant
512 // every `decide_send_action` caller routes to `Queued`
513 // behind the batch, so the queue — not a caller's own
514 // `try_send` — is the single ordering authority. The only
515 // residue is a `DeliverDirect` *decided* before this lock
516 // was taken that lands mid-batch: that send was already
517 // racing the process exit itself, and no claim scheme can
518 // sequence it.
519 inner.drain_claim = true;
520 Self::prepend_retry_batch(&mut inner, first, rest);
521 RetryBatchAction::FlushClaimed
522 } else if inner.spawning_in_progress || inner.drain_claim {
523 Self::prepend_retry_batch(&mut inner, first, rest);
524 RetryBatchAction::Queued
525 } else {
526 inner.spawning_in_progress = true;
527 inner
528 .pending_send_messages
529 .push_back(QueuedMessage::already_persisted(first.seq, first.json.clone()));
530 for entry in rest {
531 inner
532 .pending_send_messages
533 .push_back(QueuedMessage::already_persisted(entry.seq, entry.json.clone()));
534 }
535 RetryBatchAction::BecomeSpawner { own_seq: first.seq }
536 }
537 }
538
539 /// Prepends a retry batch to `pending_send_messages`, deduping only
540 /// `first` by seq. codex P2 on PR #2360 (sixth review pass, round
541 /// 11): prepend rather than append — this batch represents messages
542 /// accepted by the doomed process BEFORE whatever's currently
543 /// queued (anything queued arrived after the original spawn claimed
544 /// `spawning_in_progress`, so it's chronologically later); appending
545 /// would let the fresh process receive later-arriving input first.
546 ///
547 /// `first` may itself STILL be sitting in the queue (see
548 /// `decide_send_action`'s doc comment on `skip_if_seq_queued`) —
549 /// always at index 0 if so, since it's the ONLY thing ever present
550 /// when a claim starts and nothing but `push_back` ever touches
551 /// this queue elsewhere. In that case `rest` belongs immediately
552 /// after it (same batch, preserving order), not ahead of it. `rest`
553 /// is always pushed as-is: dedup must never apply WITHIN the same
554 /// batch (two entries can legitimately carry identical text and
555 /// both need redelivering).
556 pub(super) fn prepend_retry_batch(
557 inner: &mut PersistentInner,
558 first: &persistent_resume::QueuedRetryEntry,
559 rest: &[persistent_resume::QueuedRetryEntry],
560 ) {
561 let first_already_queued = inner.pending_send_messages.iter().any(|m| m.seq == first.seq);
562 if first_already_queued {
563 for (i, entry) in rest.iter().enumerate() {
564 inner
565 .pending_send_messages
566 .insert(i + 1, QueuedMessage::already_persisted(entry.seq, entry.json.clone()));
567 }
568 } else {
569 let mut front: VecDeque<QueuedMessage> = VecDeque::new();
570 front.push_back(QueuedMessage::already_persisted(first.seq, first.json.clone()));
571 for entry in rest {
572 front.push_back(QueuedMessage::already_persisted(entry.seq, entry.json.clone()));
573 }
574 front.append(&mut inner.pending_send_messages);
575 inner.pending_send_messages = front;
576 }
577 }
578}
579
580impl PersistentSubprocessController {
581 /// `retry_after_resume_failure`'s handling of an EMPTY retry batch:
582 /// `--resume <sid>` was attempted with nothing queued — the eager-resume
583 /// path (issue #3463) — and the CLI rejected the id before any prompt
584 /// arrived. There is no message to re-send, so this is a terminal
585 /// idle-resume failure, not a retry. Codex P1 on PR #3538: returning
586 /// early as the old empty-batch branch did left the pane with no
587 /// controller status ever published, because the process-waiter hands
588 /// `FireRetry` (not `PublishDone`) to the retry path on the assumption
589 /// that a launch follows. Complete the recovery *decision* without a
590 /// launch:
591 ///
592 /// - adopt whatever `find_recovery_session_id` found (or `None`) so the
593 /// NEXT message resumes it through the same gated, tracked `--resume`
594 /// a first-time resume gets — its outcome (`Resumed`) is emitted then,
595 /// by the CLI's actual confirmation, never claimed here (codex P1 on
596 /// PR #2693); with no candidate, `Fresh` IS decidable now and is
597 /// disclosed now;
598 /// - publish the same terminal status a `PublishDone` exit would.
599 ///
600 /// No "Reconnecting…" is shown or resolved here, unlike the non-empty
601 /// path: no user turn is in flight, so there is no gap to narrate.
602 ///
603 /// EVERYTHING here is conditioned on this retry's generation still
604 /// owning the controller state (codex P1 on PR #3551, both rounds): a
605 /// message that arrived after the doomed process cleared `stdin_tx` but
606 /// before this ran may already have spawned a newer generation with its
607 /// own live session id — possibly resuming a DIFFERENT conversation.
608 /// Overwriting that id, stamping `done` over it, or appending a `Fresh`
609 /// disclosure to its history would each be wrong. The check runs twice:
610 /// once before the recovery search (which does disk I/O outside the
611 /// lock, and must not even start for a superseded generation), and
612 /// again under the same lock acquisition as the mutation, so nothing
613 /// that raced in during the search can be overwritten either.
614 /// `spawn_generation` is bumped in the same lock acquisition that
615 /// installs a new spawn, and `spawning_in_progress` covers the claim
616 /// window before it — together they say whether anyone has moved on.
617 ///
618 /// The side effects (outcome frame, status publish, error flush) run
619 /// UNDER that same lock acquisition, not after it (reagent P1 and codex
620 /// P1 on PR #3551, rounds three and four). Every scheme that released
621 /// the lock first had a window: a check-then-act with nothing holding
622 /// the state still, or a spawn claim that either this settlement took
623 /// (fine) or someone else already held (the eager path's own unwinding
624 /// claim — whose owner could release it mid-effect and let a send
625 /// become a newer generation while the `Fresh` line was still being
626 /// written). Holding `inner` across the effects makes the window not
627 /// exist: a concurrent `decide_send_action` blocks on the lock and,
628 /// once it gets it, becomes a spawner on the already-adopted session.
629 /// This is safe because nothing below re-enters the controller — the
630 /// broker and event bus are `mpsc` channel sends, the block-file append
631 /// and failure persistence touch the file store and SQLite only — and
632 /// cheap enough for a path taken once per failed eager resume. It is
633 /// the one deliberate exception to the file's convention of dropping
634 /// `inner` before effects, which exists because most effect handlers
635 /// call back into methods that lock it; these three do not.
636 pub(super) fn settle_empty_resume_retry(
637 &self,
638 retry_generation: u64,
639 config: PersistentSpawnConfig,
640 held_error_line: Option<String>,
641 attempted_sid: String,
642 ) {
643 // Ownership is generation + no live process — deliberately NOT
644 // `!spawning_in_progress` (codex P1 on PR #3551, third round). That
645 // flag can still be THIS generation's own eager claim: the eagerly
646 // resumed CLI can reject a stale id fast enough for its waiter to
647 // fire before `try_eager_resume` reaches the arm that releases it.
648 // Treating that as "superseded" dropped the whole settlement while
649 // `FireRetry` had already suppressed the waiter's `PublishDone` —
650 // status stuck on `running`, recovery candidate and `Fresh` lost.
651 // A genuinely newer generation is identified by `spawn_generation`,
652 // which `spawn_process` bumps under the lock before anything of the
653 // new process exists; a newer spawner that has claimed but not yet
654 // bumped has no session of its own yet, and adopting the recovered
655 // id underneath it is exactly what it would then `--resume`.
656 let owns = |inner: &PersistentInner| inner.spawn_generation == retry_generation && inner.stdin_tx.is_none();
657 if !owns(&self.inner.lock().unwrap()) {
658 tracing::debug!(
659 block_id = %self.block_id,
660 retry_generation,
661 "empty resume-retry settlement superseded by a newer generation — nothing to do"
662 );
663 return;
664 }
665 let recovered = self.find_recovery_session_id(&config);
666 // Decided before the lock below: it reads the transcript tail.
667 let continued = recovered.is_none() && self.fresh_spawn_would_continue();
668 // Second check, mutation, and every side effect under ONE lock
669 // acquisition — see the doc comment for why nothing may sit
670 // between them.
671 let mut inner = self.inner.lock().unwrap();
672 if !owns(&inner) {
673 return;
674 }
675 inner.session_id = recovered.clone();
676 // Accepted prompts queued behind the eager spawn claim (codex P1 on
677 // PR #3551, fifth round): they never reached the doomed process, so
678 // they are not in the (empty) retry batch — but they are real work,
679 // and this is NOT a message-free settlement. The eager path's own
680 // drain will find the dead `stdin_tx`, stall, and hand them to
681 // `respawn_once_for_leftover_queue`; leave it the candidate so that
682 // respawn resumes it instead of clearing to fresh, and leave the
683 // terminal status and the held error line alone — a spawn follows
684 // immediately, and the CLI's stale-resume error would otherwise
685 // show as a stale bubble right before a successful reply (reagentx
686 // P1 on PR #2371's reasoning). `Fresh` is still disclosed when
687 // there is nothing to recover: that decision is final either way.
688 let queued_behind_claim = !inner.pending_send_messages.is_empty();
689 if queued_behind_claim {
690 inner.leftover_resume_candidate = recovered.clone();
691 } else {
692 Self::set_status(&mut inner, STATUS_DONE);
693 }
694 if recovered.is_none() {
695 self.emit_fresh_outcome_now(attempted_sid, continued);
696 }
697 if queued_behind_claim {
698 drop(inner);
699 return;
700 }
701 // `publish_status` would re-lock `inner`; build the same snapshot
702 // from the guard we already hold.
703 if let Some(ref broker) = self.broker {
704 let status = super::super::agent_runtime_status(
705 &self.block_id,
706 inner.status_version,
707 &inner.proc_status,
708 inner.proc_exit_code,
709 self.health_monitor.is_active_turn(),
710 );
711 super::super::publish_controller_status(broker, &status);
712 // `set_status(STATUS_DONE)` above cleared what the process was
713 // spawned with; announce it from the guard already held.
714 super::status::announce_runtime(broker, &self.block_id, &inner);
715 }
716 // Nothing was accepted from the user, but the CLI's own account of
717 // why the resume failed still reaches them (codex P2 on PR #2371).
718 if let Some(line) = held_error_line {
719 self.flush_error_line_now(line);
720 }
721 drop(inner);
722 }
723}
724
725/// The session id of the conversation a pane renders: the last provider
726/// session id in its own transcript, else in the agent's global transcript
727/// zone. Same sources, same order, as `has_prior_transcript`, so "the pane
728/// shows history" and "the spawn continues it" can't disagree.
729///
730/// Taken from AgentMux's own record rather than a scan of the provider's
731/// dir, so an archived conversation (every explicit "start fresh" action
732/// archives) is never picked up, and a subagent's transcript can't pass for
733/// the agent's own. No reachability check; callers decide what an
734/// unreachable id means.
735pub(crate) fn pane_history_session_id(
736 filestore: Option<&FileStore>,
737 mstore: Option<&Store>,
738 block_id: &str,
739 session_id_field: &str,
740) -> Option<String> {
741 if session_id_field.is_empty() {
742 return None;
743 }
744 let (tail, starts_mid_line) = pane_history_tail(filestore, mstore, block_id, CONTINUATION_TAIL_BYTES)?;
745 last_session_id_in_stream(&tail, starts_mid_line, session_id_field)
746}
747
748/// The last `max_bytes` of the history a pane renders: its own transcript,
749/// else the agent's global zone. The `bool` is whether the window starts
750/// partway into the file (so its first line is a fragment).
751pub(crate) fn pane_history_tail(
752 filestore: Option<&FileStore>,
753 mstore: Option<&Store>,
754 block_id: &str,
755 max_bytes: i64,
756) -> Option<(Vec<u8>, bool)> {
757 if let Some(tail) = filestore.and_then(|fs| read_transcript_tail(fs, block_id, PERSISTENT_OUTPUT_SUBJECT, max_bytes)) {
758 return Some(tail);
759 }
760 let zone = global_prior_zone(mstore, block_id)?;
761 let gfs = crate::backend::agent_session::global_transcript_store()?;
762 read_transcript_tail(gfs, &zone, crate::backend::agent_session::OUTPUT_FILE, max_bytes)
763}
764
765/// The transcript a continuation packet is built from: the agent's global
766/// zone when the pane has one, else the pane's own transcript.
767///
768/// Deliberately NOT [`pane_history_tail`]'s local-first order. The zone
769/// mirrors every append to the pane file, so it holds everything the pane
770/// does, plus the conversation from earlier channels and builds. On a new
771/// channel the pane file is created by a rejected resume's own settlement
772/// (the session-outcome line and the CLI's error result), and reading it
773/// first built no packet at all while the global record held the whole
774/// conversation (REPORT_AGENT_HISTORY_LOST_ON_NEW_BUILD_2026_09_25.md §4).
775pub(super) fn packet_history_tail(
776 filestore: Option<&FileStore>,
777 block_id: &str,
778 global: Option<(&FileStore, &str)>,
779 max_bytes: i64,
780) -> Option<(Vec<u8>, bool)> {
781 global
782 .and_then(|(gfs, zone)| read_transcript_tail(gfs, zone, crate::backend::agent_session::OUTPUT_FILE, max_bytes))
783 .or_else(|| filestore.and_then(|fs| read_transcript_tail(fs, block_id, PERSISTENT_OUTPUT_SUBJECT, max_bytes)))
784}
785
786/// The agent's global transcript zone, when this pane would render it:
787/// agent-anchored and not archived.
788pub(super) fn global_prior_zone(mstore: Option<&Store>, block_id: &str) -> Option<String> {
789 let block = mstore?.must_get::<crate::backend::obj::Block>(block_id).ok()?;
790 let archived = block
791 .meta
792 .get(crate::backend::session_archive::META_SESSION_ARCHIVED_AT)
793 .and_then(|v| v.as_i64())
794 .is_some_and(|v| v > 0);
795 if archived {
796 return None;
797 }
798 crate::backend::agent_session::agent_zone_for_block_meta(&block.meta)
799}
800
801/// The last `max_bytes` of a transcript, and whether that window starts
802/// partway into the file (so its first line is a fragment).
803fn read_transcript_tail(fs: &FileStore, zone: &str, name: &str, max_bytes: i64) -> Option<(Vec<u8>, bool)> {
804 let file = fs.stat(zone, name).ok()??;
805 if file.size <= 0 {
806 return None;
807 }
808 let offset = (file.size - max_bytes).max(0);
809 let (actual_offset, data) = fs.read_at(zone, name, offset, file.size - offset).ok()?;
810 (!data.is_empty()).then_some((data, actual_offset > 0))
811}