Skip to main content

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}