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}