Skip to main content

agentmux_srv/backend/blockcontroller/
persistent_resume.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! One spawn attempt's stale-`--resume` retry decision, plus any terminal
5//! error-result line observed for it, as a single explicit state machine.
6//!
7//! This replaces three fields that used to live directly on
8//! `persistent::PersistentInner` — `pending_resume_retry`,
9//! `confirmed_stale_resume_retry`, and `pending_error_result_line` — which
10//! were mutated directly by four independently-scheduled tokio tasks
11//! (stdin writer, stdout reader, stderr reader, process-waiter) racing on
12//! a shared mutex. That shape caused the exact bug this module exists to
13//! prevent, twice: issue #2368's original report, and a live-reproduced
14//! recurrence (agent "Marks", 2026-07-30) where the stderr reader's
15//! `poison_resume` promotion landed ~500ms before the stdout reader
16//! finished draining lines, and a check of only one of the two fields
17//! missed the stash entirely.
18//!
19//! `update()` is a pure `(state, event) -> (state, effects)` function —
20//! every transition is exhaustively unit-testable without a real child
21//! process, a real tokio task, or any timing assumptions. Callers
22//! (the I/O tasks `persistent/spawn.rs` starts: `persistent/stdout_reader.rs`
23//! and `persistent/process_waiter.rs`) are responsible for actually executing
24//! the returned `ResumeEffect`s (persisting/dropping/flushing a line,
25//! firing the retry, publishing terminal status) — this module only
26//! decides, never performs I/O.
27//!
28//! Every event that could affect in-flight state carries the exact
29//! `generation` it was observed for. `update()` ignores an event whose
30//! generation doesn't match the state's currently-tracked generation, so
31//! a stale event from an already-resolved generation can never corrupt a
32//! later, unrelated generation's state — generation numbers are never
33//! reused (see `spawn_generation`'s doc comment in `persistent/mod.rs`).
34
35use super::persistent::PersistentSpawnConfig;
36
37/// One redeliverable stdin message in a [`RetryPayload`] batch: the
38/// formatted JSON plus its queue identity.
39///
40/// `seq` is the message's `QueuedMessage::seq` (issue #2365): assigned
41/// once from the controller's monotonic counter at first enqueue and
42/// preserved across redelivery, so every "is this message already
43/// queued / is this the seeded message" check is exact identity, never
44/// content equality — two genuinely different messages with identical
45/// text (two "yes" replies) can no longer be conflated.
46#[derive(Debug, Clone, PartialEq)]
47pub(super) struct QueuedRetryEntry {
48    pub seq: u64,
49    pub json: String,
50}
51
52/// The spawn config + the growing batch of stdin messages to redeliver if
53/// this generation's `--resume` turns out to be stale. Named separately
54/// from the state enum so `MessageAppendedToRetryBatch` can grow it in
55/// place without reconstructing the whole variant.
56#[derive(Debug, Clone, PartialEq)]
57pub(super) struct RetryPayload {
58    pub config: PersistentSpawnConfig,
59    pub messages: Vec<QueuedRetryEntry>,
60}
61
62impl RetryPayload {
63    /// Grow the batch keeping it in `seq` order, not arrival order.
64    ///
65    /// `seq` is assigned when a message is ACCEPTED, so seq order is the
66    /// order the user actually sent things. Arrival order at this batch is
67    /// not: the message that becomes the spawner seeds the batch first, and
68    /// the drain appends everything else afterwards — so a backlog retained
69    /// across a refused spawn (an eager resume whose credential gate failed,
70    /// `try_eager_resume`) would replay BEHIND the newer prompt that finally
71    /// spawned, reversing their delivery order (codex P2 on PR #3551).
72    /// Inserting by seq makes the replay order independent of which message
73    /// happened to seed.
74    pub fn append_in_seq_order(&mut self, entry: QueuedRetryEntry) {
75        let at = self.messages.iter().position(|m| m.seq > entry.seq).unwrap_or(self.messages.len());
76        self.messages.insert(at, entry);
77    }
78}
79
80/// One spawn attempt's resume/error-line lifecycle.
81#[derive(Debug, Clone, PartialEq)]
82pub(super) enum ResumeState {
83    /// No `--resume` attempt is in flight for the current generation —
84    /// either this spawn never attached `--resume`, or a prior attempt
85    /// already resolved (session id captured, retry fired, or exited
86    /// with an unrelated error). `current_generation` is whichever
87    /// generation this resolved FROM (or `0` if no spawn has ever
88    /// happened — see `Default` below). reagentx P1 (round 5 on PR
89    /// #2373): without this, `NotTracking` couldn't tell a legitimate
90    /// `ProcessExited` for the generation that just resolved apart from
91    /// a STALE one for an already-superseded older generation, letting
92    /// the older one incorrectly publish terminal status over a newer,
93    /// actively-running generation. See `ProcessExited`'s handling below.
94    NotTracking { current_generation: u64 },
95    /// `--resume <attempted_sid>` was attached at spawn; outcome not yet
96    /// known. `held_error_line` is `Some` once a terminal
97    /// `result`/`is_error:true` stdout line has arrived but its
98    /// persistence is still undecided. `stop_requested` records an
99    /// explicit user Stop that must override a retry even if one gets
100    /// confirmed later.
101    AwaitingOutcome {
102        generation: u64,
103        attempted_sid: String,
104        retry: RetryPayload,
105        held_error_line: Option<String>,
106        stop_requested: bool,
107    },
108    /// The stderr reader confirmed `attempted_sid` unreachable under the
109    /// current config dir — the retry WILL fire the moment the process
110    /// actually exits, unless `stop_requested` overrides it.
111    ///
112    /// reagentx P1 (round 9 on this PR, also flagged inline by codex):
113    /// `attempted_sid` is carried forward from `AwaitingOutcome` (not
114    /// dropped at promotion) so `SessionCaptured` can still tell an
115    /// ambiguous same-sid echo apart from unambiguous progress even
116    /// after confirmation — see that arm's own doc comment for why
117    /// treating EVERY capture as unambiguous once confirmed was wrong:
118    /// `resume_poisoned` (`persistent/mod.rs`) is a single, non-generation-
119    /// scoped, permanent field, so a lagging stderr-reader task from an
120    /// OLDER, already-superseded generation can overwrite it to a
121    /// different sid while THIS generation is still `ConfirmedRetry`,
122    /// silently disabling `try_capture_session_id`'s poison guard for
123    /// this generation's own attempted sid and letting a routine
124    /// same-sid echo reach this arm.
125    ConfirmedRetry {
126        generation: u64,
127        attempted_sid: String,
128        retry: RetryPayload,
129        held_error_line: Option<String>,
130        stop_requested: bool,
131    },
132}
133
134impl Default for ResumeState {
135    /// `current_generation: 0` — real generations start at 1
136    /// (`PersistentInner::spawn_generation` is bumped before its first
137    /// use), so this default can never accidentally match a genuine
138    /// `ProcessExited` event.
139    fn default() -> Self {
140        ResumeState::NotTracking { current_generation: 0 }
141    }
142}
143
144impl ResumeState {
145    /// Read-only accessor for the drain: is `delivered_seq` the exact
146    /// message `spawn_process` originally seeded the retry batch with
147    /// (its first entry), for the given `generation`? Used to identify
148    /// the already-recorded seed before appending any LATER message the
149    /// drain delivers. Matched by queue identity (`QueuedRetryEntry::seq`),
150    /// neither by position (see
151    /// `PersistentSubprocessController::drain_queue_after_successful_spawn`'s
152    /// own doc comment for why the front of the queue isn't necessarily
153    /// the seed) nor by content (issue #2365 — a later, genuinely
154    /// different delivery sharing identical text must not be mistaken
155    /// for the seed).
156    pub(super) fn is_seeded_message(&self, generation: u64, delivered_seq: u64) -> bool {
157        match self {
158            ResumeState::AwaitingOutcome { generation: g, retry, .. }
159            | ResumeState::ConfirmedRetry { generation: g, retry, .. }
160                if *g == generation =>
161            {
162                // By seq, not by position: the seed used to be identified
163                // as `messages.first()`, which stopped being true once
164                // `append_in_seq_order` (codex P2 on PR #3551) started
165                // placing an older backlog entry AHEAD of a newer seed —
166                // the drain then failed to recognise the seed and appended
167                // it a second time. "Already in the batch" is the property
168                // the drain actually needs, and it is position-free.
169                retry.messages.iter().any(|e| e.seq == delivered_seq)
170            }
171            _ => false,
172        }
173    }
174}
175
176/// Events the stdin writer / stdout reader / stderr reader / process-
177/// waiter sensor tasks report. Each carries the exact spawn `generation`
178/// it was observed for.
179#[derive(Debug, Clone)]
180pub(super) enum ResumeEvent {
181    /// A fresh spawn attached `--resume <attempted_sid>`; `generation` is
182    /// this spawn's identity for the rest of its lifetime.
183    SpawnedWithResume { generation: u64, attempted_sid: String, retry: RetryPayload },
184    /// A fresh spawn did NOT attach `--resume` (no session id held yet).
185    /// `generation` is this spawn's identity, carried so `NotTracking`
186    /// can tell a later, legitimate `ProcessExited` for THIS generation
187    /// apart from a stale one for whatever generation it superseded.
188    SpawnedFresh { generation: u64 },
189    /// A session-id-bearing stdout frame arrived for this generation.
190    /// `sid` is the id it carried; `is_confirmed_success` is true only
191    /// when THIS exact frame is a terminal `result` with `is_error:false`
192    /// — a fully-completed, genuinely successful turn.
193    ///
194    /// reagentx P0 on PR #2371: the CLI echoes back whatever `--resume`
195    /// sid it was given as its FIRST stdout line REGARDLESS of whether
196    /// that resume goes on to succeed or fail — this is true even for a
197    /// frame that isn't itself an error (e.g. a "system"/init frame), and
198    /// this first echo can arrive before the independently-scheduled
199    /// stderr reader has had a chance to report "No conversation found"
200    /// and poison it. So `sid == attempted_sid` alone is NOT proof of
201    /// genuine progress — it's ambiguous until EITHER a different sid
202    /// appears (the CLI gave up and started fresh — unambiguous) OR a
203    /// successful terminal result confirms the WHOLE turn actually
204    /// completed (also unambiguous). Only those two cases resolve
205    /// tracking; a same-sid echo on any other frame type is left
206    /// untouched, giving `ResumeUnreachable` a real chance to promote the
207    /// retry first if this turns out to be the doomed case.
208    SessionCaptured { generation: u64, sid: String, is_confirmed_success: bool },
209    /// The drain appended another message to this generation's retry
210    /// batch before it was known to be doomed (or after it was
211    /// confirmed doomed but before the process actually exited).
212    MessageAppendedToRetryBatch { generation: u64, entry: QueuedRetryEntry },
213    /// stderr reader saw "No conversation found" for this exact sid.
214    ResumeUnreachable { generation: u64, sid: String },
215    /// stdout reader saw a terminal `result`/`is_error:true` line.
216    ErrorResultLine { generation: u64, line: String },
217    /// The process actually exited.
218    ProcessExited { generation: u64 },
219    /// `stop_process` was called while this generation was live.
220    StopRequested { generation: u64 },
221}
222
223/// A resume attempt's outcome, once definitively known. See
224/// `ResumeEffect::EmitSessionOutcome`'s doc comment for how this is used.
225#[derive(Debug, Clone, PartialEq)]
226pub(super) enum SessionOutcome {
227    /// The CLI continued the exact session `--resume` was given.
228    Resumed,
229    /// The CLI could not continue that session — a new one was (or is
230    /// about to be) started. The model has none of the prior turns.
231    Fresh,
232}
233
234/// What the caller must actually DO in response to an event — kept
235/// separate from `ResumeState` so `update()` stays a pure function with
236/// no I/O, fully exercisable in a unit test.
237#[derive(Debug, Clone, PartialEq)]
238pub(super) enum ResumeEffect {
239    /// Persist `line` to the blockfile immediately — this was never a
240    /// resume-retry candidate (fresh spawn, or a generation that already
241    /// resolved), so today's behavior (persist as soon as it arrives) is
242    /// unchanged.
243    PersistImmediately(String),
244    /// No retry followed after all (never confirmed, or an explicit
245    /// Stop overrode a confirmed one) — flush the held-back line now.
246    FlushErrorLine(String),
247    /// Fire the retry with this exact payload. `held_error_line` is
248    /// bundled here rather than pre-decided (dropped) by this module:
249    /// whether the line should actually be dropped depends on whether
250    /// the retry ITSELF successfully launches, which this pure function
251    /// has no way to know (that's determined later, asynchronously, by
252    /// the caller's own spawn attempt). The caller drops it on success
253    /// and flushes it on failure — see
254    /// `persistent::PersistentSubprocessController::retry_after_resume_failure`.
255    /// `attempted_sid` is the id that was just confirmed unreachable —
256    /// carried through so the caller can emit the eventual
257    /// `EmitSessionOutcome` itself once it knows whether recovery
258    /// succeeded (PR #2693 — this module can no longer decide that here).
259    FireRetry { retry: RetryPayload, held_error_line: Option<String>, attempted_sid: String },
260    /// Genuinely done, not retrying — publish the terminal status.
261    PublishDone,
262    /// A `--resume <attempted_sid>` attempt's outcome just became
263    /// unambiguously known — surface it as a persisted transcript event
264    /// (not just a trace log line), so the pane's scrollback can never
265    /// silently disagree with what the model actually has in context.
266    /// `actual_sid` is `Some` when the CLI reported a different session id
267    /// in the same breath that resolved this generation (the
268    /// `SessionCaptured` divergence case); `None` for the `FireRetry` path,
269    /// where no fresh sid is known yet — the retry hasn't launched.
270    /// See SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md §2.1.
271    EmitSessionOutcome { outcome: SessionOutcome, attempted_sid: String, actual_sid: Option<String> },
272}
273
274/// Resolves a PRIOR generation's still-unresolved tracking
275/// (`AwaitingOutcome`/`ConfirmedRetry`) that a fresh spawn is about to
276/// supersede.
277///
278/// reagentx P1 (round 5 on PR #2373): `SpawnedWithResume`/`SpawnedFresh`
279/// used to reset straight into the new generation's state with NO
280/// effects at all — normally safe, since a new spawn_process call is only
281/// supposed to happen after the previous generation's own `ProcessExited`
282/// already resolved it, but reachable out of order via
283/// `persistent/queue.rs`'s `respawn_once_for_leftover_queue` (a stall-triggered
284/// fallback respawn, on a completely different path than this module's
285/// own confirmed-retry firing) racing the process-waiter task that would
286/// otherwise resolve the OLD generation first. Silently discarding a
287/// still-held error line lost a real user-facing error; the retry itself
288/// is dropped (not fired) rather than silently discarded-and-lost,
289/// because a brand-new spawn is already underway via a completely
290/// different code path — firing ANOTHER retry on top of it would
291/// double-spawn.
292fn resolve_superseded_generation(prior: ResumeState) -> Vec<ResumeEffect> {
293    match prior {
294        ResumeState::AwaitingOutcome { held_error_line, .. }
295        | ResumeState::ConfirmedRetry { held_error_line, .. } => {
296            held_error_line.map(|line| vec![ResumeEffect::FlushErrorLine(line)]).unwrap_or_default()
297        }
298        ResumeState::NotTracking { .. } => vec![],
299    }
300}
301
302/// Effects of a `SessionCaptured` that resolves tracking (a different sid, or a
303/// confirmed success): flush any held error line, then emit the session outcome.
304fn capture_resolution_effects(
305    attempted_sid: String,
306    held_error_line: Option<String>,
307    sid: String,
308    is_confirmed_success: bool,
309) -> Vec<ResumeEffect> {
310    // A `held_error_line` already stashed (an EARLIER turn on this same
311    // still-alive generation held one back, before this LATER capture resolves
312    // tracking) must be flushed, not silently discarded along with the state
313    // (#2373).
314    let mut effects: Vec<ResumeEffect> = held_error_line.map(ResumeEffect::FlushErrorLine).into_iter().collect();
315    // SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md §2.1: this is one of the
316    // two points where a resume attempt's fate becomes unambiguously known —
317    // surface it.
318    let outcome = if sid == attempted_sid && is_confirmed_success {
319        SessionOutcome::Resumed
320    } else {
321        SessionOutcome::Fresh
322    };
323    let actual_sid = if sid != attempted_sid { Some(sid) } else { None };
324    effects.push(ResumeEffect::EmitSessionOutcome { outcome, attempted_sid, actual_sid });
325    effects
326}
327
328/// The pure state transition. See the module doc comment for why this
329/// shape exists and what it replaces.
330pub(super) fn update(state: ResumeState, event: ResumeEvent) -> (ResumeState, Vec<ResumeEffect>) {
331    match (state, event) {
332        // A fresh spawn always starts (or restarts) tracking from
333        // scratch — normally the previous generation, if any, has
334        // already resolved (its own ProcessExited already ran) before a
335        // new spawn_process call happens on this same controller, but
336        // `resolve_superseded_generation` closes the gap for the rare
337        // out-of-order case (see its own doc comment).
338        (prior, ResumeEvent::SpawnedWithResume { generation, attempted_sid, retry }) => {
339            let effects = resolve_superseded_generation(prior);
340            (
341                ResumeState::AwaitingOutcome {
342                    generation,
343                    attempted_sid,
344                    retry,
345                    held_error_line: None,
346                    stop_requested: false,
347                },
348                effects,
349            )
350        }
351        (prior, ResumeEvent::SpawnedFresh { generation }) => {
352            let effects = resolve_superseded_generation(prior);
353            (ResumeState::NotTracking { current_generation: generation }, effects)
354        }
355
356        // A session capture resolves tracking ONLY when it's unambiguous
357        // — see `SessionCaptured`'s own doc comment for why a same-sid
358        // echo alone isn't proof (the CLI echoes the attempted sid
359        // regardless of eventual success or failure). A different sid
360        // (the CLI gave up and started fresh) or a genuinely successful
361        // terminal result (the whole turn completed) both prove real
362        // progress; anything else (the ambiguous early echo, a
363        // non-terminal frame) is left untouched so `ResumeUnreachable`
364        // still gets a real chance to promote the retry if this turns
365        // out to be the doomed case after all.
366        (
367            ResumeState::AwaitingOutcome { generation, attempted_sid, retry, held_error_line, stop_requested },
368            ResumeEvent::SessionCaptured { generation: g, sid, is_confirmed_success },
369        ) if generation == g => {
370            if sid != attempted_sid || is_confirmed_success {
371                (
372                    ResumeState::NotTracking { current_generation: generation },
373                    capture_resolution_effects(attempted_sid, held_error_line, sid, is_confirmed_success),
374                )
375            } else {
376                (
377                    ResumeState::AwaitingOutcome { generation, attempted_sid, retry, held_error_line, stop_requested },
378                    vec![],
379                )
380            }
381        }
382        // reagentx P1 (round 9 on this PR, also flagged inline by codex):
383        // this arm used to resolve UNCONDITIONALLY, reasoning that a
384        // confirmed retry means the sid is already known dead so the
385        // first-echo ambiguity couldn't apply. That reasoning breaks
386        // because `resume_poisoned` (persistent/mod.rs) is a single,
387        // non-generation-scoped, PERMANENT field — a lagging stderr-
388        // reader task from an OLDER, already-superseded generation can
389        // overwrite it to a different sid while THIS generation is
390        // still `ConfirmedRetry`, silently disabling
391        // `try_capture_session_id`'s poison guard for this generation's
392        // own attempted sid. A routine same-sid echo (the CLI sends the
393        // attempted sid on every frame, ambiguous or not) can then reach
394        // this arm and used to wipe out the confirmed retry payload
395        // without ever firing it — silently losing every message the
396        // doomed process had already accepted, reproducing the exact
397        // #2368 bug class this PR exists to fix. Same unambiguity check
398        // as the `AwaitingOutcome` arm above: only a different sid or a
399        // confirmed terminal success resolves tracking here too.
400        (
401            ResumeState::ConfirmedRetry { generation, attempted_sid, retry, held_error_line, stop_requested },
402            ResumeEvent::SessionCaptured { generation: g, sid, is_confirmed_success },
403        ) if generation == g => {
404            if sid != attempted_sid || is_confirmed_success {
405                (
406                    ResumeState::NotTracking { current_generation: generation },
407                    capture_resolution_effects(attempted_sid, held_error_line, sid, is_confirmed_success),
408                )
409            } else {
410                (
411                    ResumeState::ConfirmedRetry { generation, attempted_sid, retry, held_error_line, stop_requested },
412                    vec![],
413                )
414            }
415        }
416
417        // The drain can keep appending messages to the retry batch while
418        // still merely pending, or even after already confirmed (up
419        // until the doomed process actually exits and the batch is
420        // redelivered).
421        (
422            ResumeState::AwaitingOutcome { generation, attempted_sid, mut retry, held_error_line, stop_requested },
423            ResumeEvent::MessageAppendedToRetryBatch { generation: g, entry },
424        ) if generation == g => {
425            retry.append_in_seq_order(entry);
426            (
427                ResumeState::AwaitingOutcome { generation, attempted_sid, retry, held_error_line, stop_requested },
428                vec![],
429            )
430        }
431        (
432            ResumeState::ConfirmedRetry { generation, attempted_sid, mut retry, held_error_line, stop_requested },
433            ResumeEvent::MessageAppendedToRetryBatch { generation: g, entry },
434        ) if generation == g => {
435            retry.append_in_seq_order(entry);
436            (ResumeState::ConfirmedRetry { generation, attempted_sid, retry, held_error_line, stop_requested }, vec![])
437        }
438
439        // Promotion: only when the poisoned sid is the EXACT one this
440        // generation attempted — an unrelated/mismatched sid leaves the
441        // state untouched (falls through to the catch-all below).
442        (
443            ResumeState::AwaitingOutcome { generation, attempted_sid, retry, held_error_line, stop_requested },
444            ResumeEvent::ResumeUnreachable { generation: g, sid },
445        ) if generation == g && attempted_sid == sid => {
446            (ResumeState::ConfirmedRetry { generation, attempted_sid, retry, held_error_line, stop_requested }, vec![])
447        }
448
449        // A terminal error-result line arriving while a resume outcome
450        // is still undecided (or already confirmed, but the process
451        // hasn't exited yet) is held back — its fate is resolved only
452        // once ProcessExited arrives. This is the crux of the whole
453        // module: unlike checking two separate mutex fields, ONE state
454        // enum makes "is this line still a retry candidate" a single,
455        // unambiguous question regardless of which order stderr's
456        // confirmation and stdout's error line happen to arrive in.
457        //
458        // reagentx P2 (round 4 on this PR): `held_error_line` holds
459        // exactly one line — a genuinely doomed resume produces at most
460        // one `is_error:true` before the retry decision resolves, but
461        // persistent mode can accept another user message and get a
462        // SECOND error before this generation's resume outcome is ever
463        // settled. A second line arriving is unambiguous proof the FIRST
464        // is a separate, already-settled turn's error — flush it
465        // immediately instead of silently overwriting it.
466        (
467            ResumeState::AwaitingOutcome { generation, attempted_sid, retry, held_error_line, stop_requested },
468            ResumeEvent::ErrorResultLine { generation: g, line },
469        ) if generation == g => {
470            let effects = held_error_line.map(|old| vec![ResumeEffect::PersistImmediately(old)]).unwrap_or_default();
471            (
472                ResumeState::AwaitingOutcome {
473                    generation,
474                    attempted_sid,
475                    retry,
476                    held_error_line: Some(line),
477                    stop_requested,
478                },
479                effects,
480            )
481        }
482        (
483            ResumeState::ConfirmedRetry { generation, attempted_sid, retry, held_error_line, stop_requested },
484            ResumeEvent::ErrorResultLine { generation: g, line },
485        ) if generation == g => {
486            let effects = held_error_line.map(|old| vec![ResumeEffect::PersistImmediately(old)]).unwrap_or_default();
487            (
488                ResumeState::ConfirmedRetry { generation, attempted_sid, retry, held_error_line: Some(line), stop_requested },
489                effects,
490            )
491        }
492        (ResumeState::NotTracking { current_generation }, ResumeEvent::ErrorResultLine { line, .. }) => {
493            (ResumeState::NotTracking { current_generation }, vec![ResumeEffect::PersistImmediately(line)])
494        }
495        // reagentx P2 on PR #2373: an ErrorResultLine whose generation
496        // does NOT match what's currently tracked (a stale/lagging
497        // reader's line arriving after a respawn already moved on to a
498        // new generation) must still persist — falling through to the
499        // generic catch-all below would silently lose it (no effect, no
500        // state to hold it in), which a caller checking "were there any
501        // effects" can't distinguish from a genuine, correctly-tracked
502        // hold-back. Explicit arms here (rather than relying on the
503        // catch-all) keep that distinction unambiguous.
504        (state @ (ResumeState::AwaitingOutcome { .. } | ResumeState::ConfirmedRetry { .. }), ResumeEvent::ErrorResultLine { line, .. }) => {
505            (state, vec![ResumeEffect::PersistImmediately(line)])
506        }
507
508        // An explicit Stop must win over a retry even if one gets
509        // confirmed later (or was already confirmed) — recorded now,
510        // consulted when ProcessExited actually resolves the state.
511        (
512            ResumeState::AwaitingOutcome { generation, attempted_sid, retry, held_error_line, .. },
513            ResumeEvent::StopRequested { generation: g },
514        ) if generation == g => (
515            ResumeState::AwaitingOutcome {
516                generation,
517                attempted_sid,
518                retry,
519                held_error_line,
520                stop_requested: true,
521            },
522            vec![],
523        ),
524        (
525            ResumeState::ConfirmedRetry { generation, attempted_sid, retry, held_error_line, .. },
526            ResumeEvent::StopRequested { generation: g },
527        ) if generation == g => {
528            (
529                ResumeState::ConfirmedRetry { generation, attempted_sid, retry, held_error_line, stop_requested: true },
530                vec![],
531            )
532        }
533
534        // Resolution. This is the only place a retry actually fires or a
535        // held-back line's fate is finally decided.
536        (
537            ResumeState::AwaitingOutcome { generation, held_error_line, .. },
538            ResumeEvent::ProcessExited { generation: g },
539        ) if generation == g => {
540            // Never confirmed (auth failure, rate limit, network blip,
541            // or the process simply exited before stderr ever reported
542            // anything) — genuinely done, not retrying. A held-back line
543            // is a real error the user must still see.
544            let mut effects = Vec::new();
545            if let Some(line) = held_error_line {
546                effects.push(ResumeEffect::FlushErrorLine(line));
547            }
548            effects.push(ResumeEffect::PublishDone);
549            (ResumeState::NotTracking { current_generation: generation }, effects)
550        }
551        (
552            ResumeState::ConfirmedRetry { generation, attempted_sid, retry, held_error_line, stop_requested },
553            ResumeEvent::ProcessExited { generation: g },
554        ) if generation == g => {
555            let effects = if stop_requested {
556                // The user's explicit Stop wins over a confirmed retry —
557                // treat this exactly like "genuinely done, not
558                // retrying" instead of silently reviving the agent with
559                // the same prompt right after being asked to stop.
560                let mut effects = Vec::new();
561                if let Some(line) = held_error_line {
562                    effects.push(ResumeEffect::FlushErrorLine(line));
563                }
564                effects.push(ResumeEffect::PublishDone);
565                effects
566            } else {
567                // Whether the held line should actually be dropped
568                // depends on whether this retry ITSELF successfully
569                // launches — this pure function has no way to know that
570                // (it's determined later, asynchronously, by the
571                // caller's own spawn attempt), so it's bundled into the
572                // effect rather than pre-decided here.
573                //
574                // reagentx + Codex (PR #2693 review): the outcome is NO
575                // LONGER decidable here. This module used to assume the
576                // retry unconditionally clears `session_id` and starts
577                // blank — true before #2693, which added a recovery
578                // lookup (`retry_after_resume_failure` now tries to find
579                // a real on-disk session before giving up). Pre-emitting
580                // `Fresh` here was provably wrong whenever recovery
581                // succeeds: the frontend treats a `Fresh` node as a
582                // permanent scroll-anchor that a later `Resumed` outcome
583                // can never clear (`session-outcome.ts`), so the pane
584                // would show a truncated scrollback even when the model
585                // genuinely has full prior context. The caller now
586                // decides and emits the outcome itself, once recovery is
587                // actually known — immediately if it falls back to blank
588                // (see `PersistentSubprocessController::retry_after_resume_failure`),
589                // or via this same module's existing, already-correct
590                // `SessionCaptured` handling above once the recovery
591                // attempt (now itself resume-tracked, not fire-and-forget)
592                // resolves. `attempted_sid` is threaded through
593                // `FireRetry` so the caller has it either way. See
594                // `docs/status/STATUS_CROSS_CHANNEL_RESUME_STALE_SESSION_ID_2026_08_20.md`.
595                vec![ResumeEffect::FireRetry { retry, held_error_line, attempted_sid }]
596            };
597            (ResumeState::NotTracking { current_generation: generation }, effects)
598        }
599        // reagentx P1 (round 5 on PR #2373): gated on `current_generation
600        // == g` — without this, a STALE `ProcessExited` for an
601        // already-superseded generation (one a fresh spawn moved past
602        // via `SpawnedFresh`/`SpawnedWithResume` before this exact exit
603        // event ever arrived) would match unconditionally and incorrectly
604        // `PublishDone` (turn_active:false) over a brand-new, actively
605        // running generation. A mismatched generation instead falls
606        // through to the catch-all below — a safe no-op, same treatment
607        // every OTHER state gives a stale event.
608        (
609            ResumeState::NotTracking { current_generation },
610            ResumeEvent::ProcessExited { generation: g },
611        ) if current_generation == g => {
612            (ResumeState::NotTracking { current_generation }, vec![ResumeEffect::PublishDone])
613        }
614
615        // Any other (state, event) pairing is either a mismatched sid
616        // (an unrelated tentative retry left untouched by a poison for a
617        // different sid) or a stale event whose generation no longer
618        // matches what's currently tracked — both are safe no-ops.
619        (state, _) => (state, vec![]),
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    fn dummy_config() -> PersistentSpawnConfig {
628        PersistentSpawnConfig {
629            cli_command: "definitely-not-a-real-binary-xyz".to_string(),
630            cli_args: vec![],
631            working_dir: String::new(),
632            env_vars: std::collections::HashMap::new(),
633            session_id_field: "session_id".to_string(),
634            resume_flag: "--resume".to_string(),
635            session_id: "dead-sid".to_string(),
636            message_id: None,
637        }
638    }
639
640    /// Shorthand for a retry-batch entry with an explicit queue seq
641    /// (issue #2365 — retry batches carry identity, not just text).
642    fn qentry(seq: u64, json: &str) -> QueuedRetryEntry {
643        QueuedRetryEntry { seq, json: json.to_string() }
644    }
645
646    fn dummy_retry() -> RetryPayload {
647        RetryPayload { config: dummy_config(), messages: vec![qentry(1, "{}")] }
648    }
649
650    fn spawned_with_resume(generation: u64) -> ResumeState {
651        let (state, effects) = update(
652            ResumeState::default(),
653            ResumeEvent::SpawnedWithResume {
654                generation,
655                attempted_sid: "dead-sid".to_string(),
656                retry: dummy_retry(),
657            },
658        );
659        assert!(effects.is_empty(), "spawning never produces an effect on its own");
660        state
661    }
662
663    #[test]
664    fn fresh_spawn_persists_error_lines_immediately() {
665        let (state, effects) = update(
666            ResumeState::default(),
667            ResumeEvent::ErrorResultLine { generation: 1, line: "boom".to_string() },
668        );
669        assert_eq!(state, ResumeState::default());
670        assert_eq!(effects, vec![ResumeEffect::PersistImmediately("boom".to_string())]);
671    }
672
673    #[test]
674    fn fresh_spawn_exit_just_publishes_done() {
675        let (state, effects) = update(ResumeState::default(), ResumeEvent::SpawnedFresh { generation: 1 });
676        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
677        assert!(effects.is_empty());
678
679        let (state, effects) = update(state, ResumeEvent::ProcessExited { generation: 1 });
680        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
681        assert_eq!(effects, vec![ResumeEffect::PublishDone]);
682    }
683
684    #[test]
685    fn successful_session_capture_stands_down_tracking() {
686        let state = spawned_with_resume(1);
687        let (state, effects) = update(
688            state,
689            ResumeEvent::SessionCaptured {
690                generation: 1,
691                sid: "dead-sid".to_string(),
692                is_confirmed_success: true,
693            },
694        );
695        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
696        assert_eq!(
697            effects,
698            vec![ResumeEffect::EmitSessionOutcome {
699                outcome: SessionOutcome::Resumed,
700                attempted_sid: "dead-sid".to_string(),
701                actual_sid: None,
702            }]
703        );
704
705        // A later, unrelated exit on this now-resolved generation must
706        // not retry or dredge up anything.
707        let (state, effects) = update(state, ResumeEvent::ProcessExited { generation: 1 });
708        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
709        assert_eq!(effects, vec![ResumeEffect::PublishDone]);
710    }
711
712    // reagentx P0 on PR #2373: the CLI echoes the attempted sid on its
713    // FIRST stdout line regardless of whether the resume goes on to
714    // succeed or fail (e.g. a "system"/init frame) — so a same-sid
715    // capture that is NOT a confirmed success must leave tracking
716    // untouched, giving `ResumeUnreachable` a real chance to promote the
717    // retry afterward if this turns out to be the doomed case.
718    #[test]
719    fn ambiguous_same_sid_echo_that_is_not_a_confirmed_success_leaves_tracking_untouched() {
720        let state = spawned_with_resume(1);
721        let (state, effects) = update(
722            state,
723            ResumeEvent::SessionCaptured {
724                generation: 1,
725                sid: "dead-sid".to_string(),
726                is_confirmed_success: false,
727            },
728        );
729        assert!(
730            matches!(&state, ResumeState::AwaitingOutcome { .. }),
731            "an unconfirmed same-sid echo must not resolve tracking"
732        );
733        assert!(effects.is_empty());
734
735        // The retry still gets a real chance to be promoted afterward.
736        let (state, _) =
737            update(state, ResumeEvent::ResumeUnreachable { generation: 1, sid: "dead-sid".to_string() });
738        assert!(matches!(state, ResumeState::ConfirmedRetry { .. }));
739    }
740
741    #[test]
742    fn error_line_before_poison_is_held_then_bundled_into_the_firing_retry() {
743        // Reproduces the ORIGINAL issue #2368 mechanism: the stdout
744        // reader's error line arrives before the stderr reader confirms
745        // the poison.
746        let state = spawned_with_resume(1);
747        let (state, effects) =
748            update(state, ResumeEvent::ErrorResultLine { generation: 1, line: "boom".to_string() });
749        assert!(effects.is_empty(), "held back, not persisted yet");
750        assert!(matches!(&state, ResumeState::AwaitingOutcome { held_error_line: Some(l), .. } if l == "boom"));
751
752        let (state, effects) =
753            update(state, ResumeEvent::ResumeUnreachable { generation: 1, sid: "dead-sid".to_string() });
754        assert!(effects.is_empty(), "promotion alone produces no effect");
755        assert!(matches!(&state, ResumeState::ConfirmedRetry { held_error_line: Some(l), .. } if l == "boom"));
756
757        let (state, effects) = update(state, ResumeEvent::ProcessExited { generation: 1 });
758        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
759        assert_eq!(
760            effects,
761            vec![ResumeEffect::FireRetry {
762                retry: dummy_retry(),
763                held_error_line: Some("boom".to_string()),
764                attempted_sid: "dead-sid".to_string(),
765            }]
766        );
767    }
768
769    // reagentx P2 (round 4 on PR #2373): `held_error_line` holds exactly
770    // one line — a genuinely doomed resume produces at most one
771    // `is_error:true` before the retry decision resolves, but persistent
772    // mode can accept another user message and get a SECOND error before
773    // this generation's resume outcome is ever settled. The first line
774    // must not be silently lost to the second.
775    #[test]
776    fn a_second_error_line_supersedes_and_flushes_the_first_instead_of_losing_it() {
777        let state = spawned_with_resume(1);
778        let (state, effects) =
779            update(state, ResumeEvent::ErrorResultLine { generation: 1, line: "first turn's error".to_string() });
780        assert!(effects.is_empty(), "nothing was held yet, so nothing to supersede");
781        assert!(matches!(&state, ResumeState::AwaitingOutcome { held_error_line: Some(l), .. } if l == "first turn's error"));
782
783        let (state, effects) =
784            update(state, ResumeEvent::ErrorResultLine { generation: 1, line: "second turn's error".to_string() });
785        assert_eq!(
786            effects,
787            vec![ResumeEffect::PersistImmediately("first turn's error".to_string())],
788            "the first held line must be flushed immediately, not silently discarded"
789        );
790        assert!(
791            matches!(&state, ResumeState::AwaitingOutcome { held_error_line: Some(l), .. } if l == "second turn's error"),
792            "the newest line is what's still held, pending this generation's own retry decision"
793        );
794    }
795
796    #[test]
797    fn poison_before_error_line_still_holds_and_bundles_it_into_the_retry() {
798        // Reproduces the LIVE-REPRODUCED race (agent "Marks",
799        // 2026-07-30): stderr confirms the poison well before the
800        // stdout reader ever reaches the doomed line. A design that only
801        // checked "is a retry still pending" (not "is one already
802        // confirmed") missed this ordering — this state machine doesn't
803        // have that gap, because AwaitingOutcome and ConfirmedRetry are
804        // the SAME kind of "still tracking, line still undecided" state
805        // from ErrorResultLine's point of view.
806        let state = spawned_with_resume(1);
807        let (state, _) =
808            update(state, ResumeEvent::ResumeUnreachable { generation: 1, sid: "dead-sid".to_string() });
809        assert!(matches!(state, ResumeState::ConfirmedRetry { .. }));
810
811        let (state, effects) =
812            update(state, ResumeEvent::ErrorResultLine { generation: 1, line: "boom".to_string() });
813        assert!(effects.is_empty(), "still held back even though already confirmed");
814        assert!(matches!(&state, ResumeState::ConfirmedRetry { held_error_line: Some(l), .. } if l == "boom"));
815
816        let (state, effects) = update(state, ResumeEvent::ProcessExited { generation: 1 });
817        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
818        assert_eq!(
819            effects,
820            vec![ResumeEffect::FireRetry {
821                retry: dummy_retry(),
822                held_error_line: Some("boom".to_string()),
823                attempted_sid: "dead-sid".to_string(),
824            }]
825        );
826    }
827
828    #[test]
829    fn unrelated_error_never_confirmed_flushes_instead_of_retrying() {
830        // An auth failure / rate limit / network blip on the very first
831        // message of a fresh --resume spawn: an error line arrives, but
832        // stderr never reports "No conversation found" for it, so no
833        // promotion ever happens. The line must still reach the user —
834        // just flushed at exit time instead of immediately.
835        let state = spawned_with_resume(1);
836        let (state, _) =
837            update(state, ResumeEvent::ErrorResultLine { generation: 1, line: "auth failed".to_string() });
838
839        let (state, effects) = update(state, ResumeEvent::ProcessExited { generation: 1 });
840        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
841        assert_eq!(
842            effects,
843            vec![ResumeEffect::FlushErrorLine("auth failed".to_string()), ResumeEffect::PublishDone]
844        );
845    }
846
847    #[test]
848    fn mismatched_sid_does_not_promote() {
849        let state = spawned_with_resume(1);
850        let (state, effects) =
851            update(state, ResumeEvent::ResumeUnreachable { generation: 1, sid: "some-other-sid".to_string() });
852        assert!(effects.is_empty());
853        assert!(
854            matches!(state, ResumeState::AwaitingOutcome { .. }),
855            "a poison for an unrelated sid must not promote this generation's tentative retry"
856        );
857    }
858
859    #[test]
860    fn stale_generation_event_is_ignored() {
861        // Generation 1 already resolved; a late-arriving event still
862        // tagged with generation 1 must not corrupt generation 2's fresh
863        // state.
864        let state = spawned_with_resume(1);
865        let (state, _) = update(state, ResumeEvent::ProcessExited { generation: 1 });
866        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
867
868        let state = spawned_with_resume(2);
869        let (state, effects) =
870            update(state, ResumeEvent::ResumeUnreachable { generation: 1, sid: "dead-sid".to_string() });
871        assert!(effects.is_empty());
872        assert!(
873            matches!(state, ResumeState::AwaitingOutcome { generation: 2, .. }),
874            "a stale generation-1 event must not affect generation 2's state"
875        );
876    }
877
878    #[test]
879    fn message_appended_while_awaiting_outcome_grows_the_batch() {
880        let state = spawned_with_resume(1);
881        let (state, effects) =
882            update(state, ResumeEvent::MessageAppendedToRetryBatch { generation: 1, entry: qentry(2, "{\"m\":2}") });
883        assert!(effects.is_empty());
884        match state {
885            ResumeState::AwaitingOutcome { retry, .. } => {
886                assert_eq!(retry.messages, vec![qentry(1, "{}"), qentry(2, "{\"m\":2}")])
887            }
888            other => panic!("expected AwaitingOutcome, got {other:?}"),
889        }
890    }
891
892    /// Codex P2 on PR #3551: a backlog retained across a refused spawn has
893    /// OLDER seqs than the prompt that eventually seeds the next spawn, but
894    /// reaches the batch AFTER it (the seed is first, the drain appends the
895    /// rest). Replay must follow seq — acceptance — order, not arrival.
896    #[test]
897    fn a_backlog_appended_behind_a_newer_seed_replays_in_acceptance_order() {
898        let (state, _) = update(
899            ResumeState::default(),
900            ResumeEvent::SpawnedWithResume {
901                generation: 1,
902                attempted_sid: "dead-sid".to_string(),
903                retry: RetryPayload { config: dummy_config(), messages: vec![qentry(3, "{\"m\":3}")] },
904            },
905        );
906        let (state, _) =
907            update(state, ResumeEvent::MessageAppendedToRetryBatch { generation: 1, entry: qentry(1, "{\"m\":1}") });
908        let (state, _) =
909            update(state, ResumeEvent::MessageAppendedToRetryBatch { generation: 1, entry: qentry(2, "{\"m\":2}") });
910        match state {
911            ResumeState::AwaitingOutcome { retry, .. } => assert_eq!(
912                retry.messages,
913                vec![qentry(1, "{\"m\":1}"), qentry(2, "{\"m\":2}"), qentry(3, "{\"m\":3}")],
914                "older backlog entries must replay ahead of the newer seed"
915            ),
916            other => panic!("expected AwaitingOutcome, got {other:?}"),
917        }
918    }
919
920    #[test]
921    fn message_appended_after_confirmed_still_grows_the_batch() {
922        // Matches persistent/tests/send_input.rs's existing
923        // `drain_appends_to_confirmed_retry_once_already_promoted_from_pending`
924        // — the drain can still be delivering messages after the retry
925        // is confirmed but before the doomed process has actually
926        // exited.
927        let state = spawned_with_resume(1);
928        let (state, _) =
929            update(state, ResumeEvent::ResumeUnreachable { generation: 1, sid: "dead-sid".to_string() });
930        let (state, effects) =
931            update(state, ResumeEvent::MessageAppendedToRetryBatch { generation: 1, entry: qentry(2, "{\"m\":2}") });
932        assert!(effects.is_empty());
933        match state {
934            ResumeState::ConfirmedRetry { retry, .. } => {
935                assert_eq!(retry.messages, vec![qentry(1, "{}"), qentry(2, "{\"m\":2}")])
936            }
937            other => panic!("expected ConfirmedRetry, got {other:?}"),
938        }
939    }
940
941    #[test]
942    fn stop_requested_before_confirmation_overrides_a_later_confirmed_retry() {
943        let state = spawned_with_resume(1);
944        let (state, _) = update(state, ResumeEvent::StopRequested { generation: 1 });
945        let (state, _) =
946            update(state, ResumeEvent::ResumeUnreachable { generation: 1, sid: "dead-sid".to_string() });
947        assert!(matches!(state, ResumeState::ConfirmedRetry { stop_requested: true, .. }));
948
949        let (state, effects) = update(state, ResumeEvent::ProcessExited { generation: 1 });
950        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
951        assert_eq!(
952            effects,
953            vec![ResumeEffect::PublishDone],
954            "an explicit stop must win over a confirmed retry, not silently revive the agent"
955        );
956    }
957
958    #[test]
959    fn stop_requested_after_confirmation_still_overrides_the_retry() {
960        let state = spawned_with_resume(1);
961        let (state, _) =
962            update(state, ResumeEvent::ResumeUnreachable { generation: 1, sid: "dead-sid".to_string() });
963        let (state, _) = update(state, ResumeEvent::StopRequested { generation: 1 });
964        assert!(matches!(state, ResumeState::ConfirmedRetry { stop_requested: true, .. }));
965
966        let (state, effects) = update(state, ResumeEvent::ProcessExited { generation: 1 });
967        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
968        assert_eq!(effects, vec![ResumeEffect::PublishDone]);
969    }
970
971    #[test]
972    fn stop_requested_with_a_held_error_line_still_flushes_it() {
973        // A stop overriding a confirmed retry still owes the user
974        // whatever error was actually observed, same as the
975        // never-confirmed case.
976        let state = spawned_with_resume(1);
977        let (state, _) =
978            update(state, ResumeEvent::ErrorResultLine { generation: 1, line: "boom".to_string() });
979        let (state, _) =
980            update(state, ResumeEvent::ResumeUnreachable { generation: 1, sid: "dead-sid".to_string() });
981        let (state, _) = update(state, ResumeEvent::StopRequested { generation: 1 });
982
983        let (state, effects) = update(state, ResumeEvent::ProcessExited { generation: 1 });
984        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
985        assert_eq!(
986            effects,
987            vec![ResumeEffect::FlushErrorLine("boom".to_string()), ResumeEffect::PublishDone]
988        );
989    }
990
991    // reagentx P2 on PR #2373: a `held_error_line` stashed by an EARLIER
992    // turn on this same still-alive generation must be flushed, not
993    // silently discarded, when a LATER turn's session-id capture
994    // resolves tracking before ProcessExited ever arrives.
995    #[test]
996    fn session_captured_flushes_a_held_error_line_from_an_earlier_turn() {
997        let state = spawned_with_resume(1);
998        let (state, _) =
999            update(state, ResumeEvent::ErrorResultLine { generation: 1, line: "boom".to_string() });
1000        assert!(matches!(&state, ResumeState::AwaitingOutcome { held_error_line: Some(_), .. }));
1001
1002        let (state, effects) = update(
1003            state,
1004            ResumeEvent::SessionCaptured { generation: 1, sid: "dead-sid".to_string(), is_confirmed_success: true },
1005        );
1006        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
1007        assert_eq!(
1008            effects,
1009            vec![
1010                ResumeEffect::FlushErrorLine("boom".to_string()),
1011                ResumeEffect::EmitSessionOutcome {
1012                    outcome: SessionOutcome::Resumed,
1013                    attempted_sid: "dead-sid".to_string(),
1014                    actual_sid: None,
1015                },
1016            ],
1017            "the held error must reach the user, not vanish along with the resolved tracking"
1018        );
1019    }
1020
1021    // Same as above, but the retry was ALREADY confirmed (defensive path
1022    // — a genuine capture shouldn't happen for a poisoned sid in
1023    // practice, but must not silently lose a held line if it somehow
1024    // does).
1025    #[test]
1026    fn session_captured_flushes_a_held_error_line_even_after_confirmation() {
1027        let state = spawned_with_resume(1);
1028        let (state, _) =
1029            update(state, ResumeEvent::ErrorResultLine { generation: 1, line: "boom".to_string() });
1030        let (state, _) =
1031            update(state, ResumeEvent::ResumeUnreachable { generation: 1, sid: "dead-sid".to_string() });
1032        assert!(matches!(&state, ResumeState::ConfirmedRetry { held_error_line: Some(_), .. }));
1033
1034        // An unambiguous capture (a genuinely different sid, or a
1035        // confirmed terminal success) still resolves tracking and
1036        // flushes the held line even after confirmation.
1037        let (state, effects) = update(
1038            state,
1039            ResumeEvent::SessionCaptured { generation: 1, sid: "dead-sid".to_string(), is_confirmed_success: true },
1040        );
1041        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
1042        assert_eq!(
1043            effects,
1044            vec![
1045                ResumeEffect::FlushErrorLine("boom".to_string()),
1046                ResumeEffect::EmitSessionOutcome {
1047                    outcome: SessionOutcome::Resumed,
1048                    attempted_sid: "dead-sid".to_string(),
1049                    actual_sid: None,
1050                },
1051            ]
1052        );
1053    }
1054
1055    // reagentx P1 (round 9 on this PR, also flagged inline by codex): the
1056    // `ConfirmedRetry` + `SessionCaptured` arm used to resolve
1057    // UNCONDITIONALLY, discarding the confirmed `retry` payload without
1058    // ever firing it. `resume_poisoned` (persistent/mod.rs) is a single,
1059    // non-generation-scoped, PERMANENT field — a lagging stderr-reader
1060    // task from an OLDER, already-superseded generation can overwrite it
1061    // to a different sid while THIS generation is still `ConfirmedRetry`,
1062    // silently disabling `try_capture_session_id`'s poison guard for
1063    // this generation's own attempted sid and letting a routine same-sid
1064    // echo (ambiguous, not a confirmed success) reach this arm. That
1065    // must NOT wipe out the confirmed retry — same unambiguity check as
1066    // the `AwaitingOutcome` arm.
1067    #[test]
1068    fn ambiguous_same_sid_echo_after_confirmation_does_not_discard_the_confirmed_retry() {
1069        let state = spawned_with_resume(1);
1070        let (state, _) =
1071            update(state, ResumeEvent::ResumeUnreachable { generation: 1, sid: "dead-sid".to_string() });
1072        assert!(matches!(&state, ResumeState::ConfirmedRetry { attempted_sid, .. } if attempted_sid == "dead-sid"));
1073
1074        // The same attempted sid, echoed on a non-terminal frame — the
1075        // exact ambiguous case the poison guard normally screens out,
1076        // reachable here only because a stale, unrelated poison
1077        // overwrote `resume_poisoned` first.
1078        let (state, effects) = update(
1079            state,
1080            ResumeEvent::SessionCaptured { generation: 1, sid: "dead-sid".to_string(), is_confirmed_success: false },
1081        );
1082        assert!(effects.is_empty(), "an ambiguous echo must not flush or fire anything");
1083        match &state {
1084            ResumeState::ConfirmedRetry { retry, .. } => {
1085                assert_eq!(retry.messages, vec![qentry(1, "{}")], "the confirmed retry must survive intact")
1086            }
1087            other => panic!("expected the confirmed retry to survive an ambiguous echo, got {other:?}"),
1088        }
1089
1090        // The retry still fires normally once the process actually exits.
1091        let (state, effects) = update(state, ResumeEvent::ProcessExited { generation: 1 });
1092        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
1093        assert_eq!(
1094            effects,
1095            vec![ResumeEffect::FireRetry {
1096                retry: dummy_retry(),
1097                held_error_line: None,
1098                attempted_sid: "dead-sid".to_string(),
1099            }]
1100        );
1101    }
1102
1103    // reagentx P2 on PR #2373: an ErrorResultLine whose generation
1104    // doesn't match what's currently tracked (a stale/lagging reader's
1105    // line arriving after a respawn already moved on) must still persist
1106    // — a caller checking "were there any effects" to decide hold-back
1107    // vs. not can't otherwise distinguish this from a genuine,
1108    // correctly-tracked hold-back, silently losing the line.
1109    #[test]
1110    fn mismatched_generation_error_line_persists_instead_of_vanishing() {
1111        let state = spawned_with_resume(2);
1112        let (state, effects) =
1113            update(state, ResumeEvent::ErrorResultLine { generation: 1, line: "orphaned".to_string() });
1114        assert_eq!(
1115            effects,
1116            vec![ResumeEffect::PersistImmediately("orphaned".to_string())],
1117            "a line tagged with a generation that no longer matches must still reach the user"
1118        );
1119        // The mismatched event must not disturb generation 2's own state.
1120        assert!(matches!(state, ResumeState::AwaitingOutcome { generation: 2, held_error_line: None, .. }));
1121    }
1122
1123    #[test]
1124    fn spawned_fresh_with_no_resume_never_tracks_anything() {
1125        let (state, effects) = update(ResumeState::default(), ResumeEvent::SpawnedFresh { generation: 1 });
1126        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
1127        assert!(effects.is_empty());
1128
1129        let (state, effects) =
1130            update(state, ResumeEvent::ErrorResultLine { generation: 1, line: "boom".to_string() });
1131        assert_eq!(effects, vec![ResumeEffect::PersistImmediately("boom".to_string())]);
1132        let (_, effects) = update(state, ResumeEvent::ProcessExited { generation: 1 });
1133        assert_eq!(effects, vec![ResumeEffect::PublishDone]);
1134    }
1135
1136    // reagentx P1 (round 5 on this PR): the exact race reagentx flagged —
1137    // a fresh (or resumed) respawn firing via `SpawnedFresh`/
1138    // `SpawnedWithResume` while a PRIOR generation is still
1139    // `AwaitingOutcome`/`ConfirmedRetry` (its own `ProcessExited` hasn't
1140    // arrived yet — reachable via `persistent/queue.rs`'s
1141    // `respawn_once_for_leftover_queue` racing the process-waiter task).
1142    // The prior generation's held error line must reach the user instead
1143    // of vanishing, and its own eventual (belated) `ProcessExited` must
1144    // NOT corrupt the new generation's status.
1145    #[test]
1146    fn a_fresh_spawn_superseding_an_unresolved_generation_flushes_its_held_error_and_ignores_its_stale_exit() {
1147        let state = spawned_with_resume(1);
1148        let (state, _) =
1149            update(state, ResumeEvent::ErrorResultLine { generation: 1, line: "generation 1's error".to_string() });
1150        assert!(matches!(&state, ResumeState::AwaitingOutcome { generation: 1, held_error_line: Some(_), .. }));
1151
1152        // A brand-new spawn (generation 2) supersedes generation 1 before
1153        // generation 1's own ProcessExited ever arrived.
1154        let (state, effects) = update(state, ResumeEvent::SpawnedFresh { generation: 2 });
1155        assert_eq!(
1156            effects,
1157            vec![ResumeEffect::FlushErrorLine("generation 1's error".to_string())],
1158            "generation 1's held error must reach the user, not vanish when generation 2 supersedes it"
1159        );
1160        assert_eq!(state, ResumeState::NotTracking { current_generation: 2 });
1161
1162        // Generation 1's own (belated) ProcessExited must be a safe
1163        // no-op now — NOT another PublishDone stomping generation 2.
1164        let (state, effects) = update(state, ResumeEvent::ProcessExited { generation: 1 });
1165        assert!(
1166            effects.is_empty(),
1167            "a stale ProcessExited for a superseded generation must not publish done over the new one"
1168        );
1169        assert_eq!(state, ResumeState::NotTracking { current_generation: 2 });
1170
1171        // Generation 2's OWN eventual exit must still work normally.
1172        let (state, effects) = update(state, ResumeEvent::ProcessExited { generation: 2 });
1173        assert_eq!(state, ResumeState::NotTracking { current_generation: 2 });
1174        assert_eq!(effects, vec![ResumeEffect::PublishDone]);
1175    }
1176
1177    // Same race, but the prior generation had already been CONFIRMED
1178    // stale (poison_resume won) before the fresh respawn superseded it —
1179    // its own queued retry must be dropped (not fired), not silently
1180    // discarded, since a brand-new spawn is already underway via a
1181    // completely different path.
1182    #[test]
1183    fn a_fresh_spawn_superseding_a_confirmed_retry_drops_it_without_double_spawning() {
1184        let state = spawned_with_resume(1);
1185        let (state, _) =
1186            update(state, ResumeEvent::ResumeUnreachable { generation: 1, sid: "dead-sid".to_string() });
1187        assert!(matches!(state, ResumeState::ConfirmedRetry { generation: 1, .. }));
1188
1189        let (state, effects) = update(state, ResumeEvent::SpawnedFresh { generation: 2 });
1190        assert!(
1191            effects.is_empty(),
1192            "no held error line existed, so superseding a confirmed (but line-less) retry produces no effect"
1193        );
1194        assert_eq!(state, ResumeState::NotTracking { current_generation: 2 });
1195
1196        let (state, effects) = update(state, ResumeEvent::ProcessExited { generation: 1 });
1197        assert!(effects.is_empty(), "generation 1's stale exit must not fire its now-superseded retry");
1198        assert_eq!(state, ResumeState::NotTracking { current_generation: 2 });
1199    }
1200
1201    // SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md §2.1: the CLI itself
1202    // can silently roll to a DIFFERENT session id (rather than failing
1203    // outright) before `ResumeUnreachable` ever gets a chance to fire — the
1204    // `sid != attempted_sid` branch of the unambiguity check. This must
1205    // still be reported as Fresh, with `actual_sid` carrying the id the CLI
1206    // actually landed on.
1207    #[test]
1208    fn session_captured_with_a_different_sid_emits_fresh_outcome_with_actual_sid() {
1209        let state = spawned_with_resume(1);
1210        let (state, effects) = update(
1211            state,
1212            ResumeEvent::SessionCaptured {
1213                generation: 1,
1214                sid: "brand-new-sid".to_string(),
1215                is_confirmed_success: false,
1216            },
1217        );
1218        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
1219        assert_eq!(
1220            effects,
1221            vec![ResumeEffect::EmitSessionOutcome {
1222                outcome: SessionOutcome::Fresh,
1223                attempted_sid: "dead-sid".to_string(),
1224                actual_sid: Some("brand-new-sid".to_string()),
1225            }]
1226        );
1227    }
1228
1229    // Regression guard for SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md
1230    // §2.1's "deliberately not touched" call: a fresh spawn with no resume
1231    // attempt at all (no session existed yet — nothing to lose), and a
1232    // resume attempt that's never confirmed either way (auth failure etc.,
1233    // §2.1's `unrelated_error_never_confirmed_flushes_instead_of_retrying`
1234    // case), must NOT emit a session-outcome event — there's no positively-
1235    // known outcome to report in either case.
1236    #[test]
1237    fn spawned_fresh_and_never_confirmed_paths_never_emit_a_session_outcome() {
1238        let (state, effects) = update(ResumeState::default(), ResumeEvent::SpawnedFresh { generation: 1 });
1239        assert!(!effects.iter().any(|e| matches!(e, ResumeEffect::EmitSessionOutcome { .. })));
1240
1241        let state2 = spawned_with_resume(2);
1242        let (state2, _) =
1243            update(state2, ResumeEvent::ErrorResultLine { generation: 2, line: "auth failed".to_string() });
1244        let (_, effects) = update(state2, ResumeEvent::ProcessExited { generation: 2 });
1245        assert!(
1246            !effects.iter().any(|e| matches!(e, ResumeEffect::EmitSessionOutcome { .. })),
1247            "a never-confirmed resume attempt has no positively-known outcome to report"
1248        );
1249
1250        // Keep `state` (generation 1) used so the compiler doesn't flag it —
1251        // its only purpose above is exercising the SpawnedFresh path.
1252        assert_eq!(state, ResumeState::NotTracking { current_generation: 1 });
1253    }
1254}