agentmux_srv/backend/blockcontroller/persistent/queue.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Message intake and the spawn-claim/queue-drain protocol: `send_message` and
5//! everything that decides whether a message is written to stdin now, queued
6//! behind an in-flight spawn, or replayed after one.
7
8use super::*;
9
10impl PersistentSubprocessController {
11 /// Atomically decides what to do with `json_str`, given the caller
12 /// wants it delivered to the persistent process — see
13 /// `PersistentInner::spawning_in_progress`'s doc comment for the race
14 /// this closes. All three outcomes are decided under ONE lock
15 /// acquisition so nothing can slip through the gaps between them:
16 /// - the process is already running AND nobody is still draining a
17 /// backlog into it → `DeliverDirect`, no spawn decision at all.
18 /// - someone is currently spawning OR still draining a backlog
19 /// (`spawning_in_progress`) → `json_str` is enqueued for THAT
20 /// caller's own drain to deliver, and this call returns `Queued`
21 /// with nothing further to do. reagentx P1 on PR #2360 (sixth
22 /// review pass, round 4): `spawn_process` sets `stdin_tx` well
23 /// before the queued message that triggered the spawn is actually
24 /// delivered (that happens later, on a background drain task —
25 /// see `drain_queue_after_successful_spawn`). Gating `DeliverDirect`
26 /// on `stdin_tx.is_some()` alone let a second, genuinely concurrent
27 /// `send_message` call land in that exact window and write straight
28 /// to stdin via `try_send`, racing ahead of the drain's own
29 /// `Sender::send().await` for the message that actually triggered
30 /// the spawn — silently reordering user input. Checking
31 /// `!spawning_in_progress` too routes it into the queue instead,
32 /// where the SAME already-running drain loop (it stays `true` for
33 /// its entire lifetime — see the field's own doc comment) picks it
34 /// up next, in order.
35 /// - nobody is running AND nobody is spawning → this caller claims the
36 /// exclusive right to (`spawning_in_progress = true`), enqueues
37 /// `json_str` alongside that claim, and returns `BecomeSpawner` —
38 /// the caller must then call `spawn_process` and, regardless of
39 /// outcome, call `release_spawn_claim_and_drain_queue`.
40 ///
41 /// `skip_if_seq_queued` is always `None` for the sole production call
42 /// site (`send_message_with_images_from`, which `send_message` reaches):
43 /// a user legitimately re-sending the exact same text while an
44 /// unrelated spawn is in flight must still queue both, so this must
45 /// never dedup there.
46 ///
47 /// `Some(seq)` is NOT exercised by any production call site (#2360) —
48 /// `retry_after_resume_failure` uses `decide_retry_batch_action`
49 /// instead, a separate function with its own atomic,
50 /// batch-aware dedup/prepend logic (see that function's own doc
51 /// comment). The `Some` path here now only exists for unit tests
52 /// (`tests/send_input.rs`), which document the exact scenario
53 /// `decide_retry_batch_action`'s own dedup check handles for a batch
54 /// instead — a KNOWN re-delivery of
55 /// content that may ALREADY be sitting in `pending_send_messages`,
56 /// pushed by the very spawn attempt whose failure triggered a retry,
57 /// if that spawn's own drain hasn't reached it yet; blindly queueing
58 /// another copy there let a fallback spawn eventually deliver the
59 /// same prompt twice.
60 /// `skip_if_seq_queued`: `Some(seq)` marks this call a KNOWN
61 /// re-delivery of the message originally enqueued under `seq` — skip
62 /// enqueueing if that exact entry is still queued, and preserve the
63 /// original seq (not a fresh one) if it must be re-queued, so the
64 /// message keeps one identity for its whole lifetime (issue #2365:
65 /// the old content-equality check here treated a genuinely
66 /// different, identical-text message as "already queued" and
67 /// silently dropped it).
68 #[cfg(test)]
69 pub(super) fn decide_send_action(&self, json_str: &str, skip_if_seq_queued: Option<u64>) -> SendAction {
70 self.decide_send_action_from(json_str, skip_if_seq_queued, None)
71 }
72
73 /// [`Self::decide_send_action`], saying who the input is from (turn
74 /// provenance, SPEC_AGENT_SELF_QUIT §6.3). A direct delivery reports it
75 /// with the turn mark; a queued one tells the tracker about the turn it
76 /// will start.
77 pub(super) fn decide_send_action_from(
78 &self,
79 json_str: &str,
80 skip_if_seq_queued: Option<u64>,
81 origin: Option<crate::backend::blockcontroller::health::TurnInput>,
82 ) -> SendAction {
83 let mut inner = self.inner.lock().unwrap();
84 // `!restart_pending`: a committed deferred restart leaves `stdin_tx`
85 // live until the process actually exits, and writing into a process
86 // about to be killed loses the message (codex P1 on PR #2858). Falling
87 // through queues it for the replacement instead. `!stop_pending`: the
88 // same loss for a requested kill; see the `Refused` branch below.
89 if inner.stdin_tx.is_some()
90 && !inner.spawning_in_progress
91 && !inner.drain_claim
92 && !inner.restart_pending
93 && !inner.stop_pending
94 {
95 // Reserve the turn before `inner` is released. Lock order is
96 // `inner` → `health_monitor`, as everywhere else.
97 let was_active = self.health_monitor.mark_turn_active_from(origin);
98 SendAction::DeliverDirect { was_active }
99 } else if inner.spawning_in_progress || inner.drain_claim {
100 let already_queued = skip_if_seq_queued
101 .is_some_and(|seq| inner.pending_send_messages.iter().any(|m| m.seq == seq));
102 if !already_queued {
103 let seq = skip_if_seq_queued.unwrap_or_else(|| inner.take_next_message_seq());
104 inner
105 .pending_send_messages
106 .push_back(QueuedMessage::fresh(seq, json_str.to_string()));
107 }
108 SendAction::Queued
109 } else if inner.stop_pending && !inner.restart_pending && inner.stdin_tx.is_some() {
110 // A kill is pending and the dying process still holds stdin
111 // (reagent P1 on #3562). Writing loses the message; spawning is
112 // wrong too, since the stop may be a teardown (`delete_controller`,
113 // pane shutdown) and would start a process for a closing pane.
114 // Refuse it explicitly: the window lasts until the exit, after
115 // which a send respawns as usual. A committed restart also sets
116 // `stop_pending`, and keeps its own rule: the message falls
117 // through and queues for the replacement.
118 SendAction::Refused(
119 "this agent is stopping — send the message again once it has stopped".to_string(),
120 )
121 } else {
122 inner.spawning_in_progress = true;
123 let own_seq = skip_if_seq_queued.unwrap_or_else(|| inner.take_next_message_seq());
124 inner
125 .pending_send_messages
126 .push_back(QueuedMessage::fresh(own_seq, json_str.to_string()));
127 SendAction::BecomeSpawner { own_seq }
128 }
129 }
130
131 /// The write half of [`SendAction::DeliverDirect`], under a second `inner`
132 /// acquisition. On failure nothing was written, and the turn the decision
133 /// reserved is handed back: left active, it would hold every deferred
134 /// message for a `result` that never comes. Nobody else can have started a
135 /// turn meanwhile, since they all saw it reserved and deferred, and the
136 /// watchdog each armed releases them once it reads idle.
137 ///
138 /// Written through [`Self::try_write_stdin_locked`], like every other
139 /// write site, so the gates are re-checked under THIS acquisition: a stop
140 /// or config restart committed after the decision (heartbeat and status
141 /// publish run in between, with `stdin_tx` still live) refuses the write
142 /// instead of putting the message into the dying process (reagent P1 on
143 /// #3562). The same call tracks it into the current generation's retry
144 /// batch (codex P1 on PR #3513: eager resume can reach `DeliverDirect`
145 /// with its `--resume` unconfirmed and no seed tracked), reading the
146 /// generation in this same acquisition (reagentx P1 on PR #2373).
147 pub(super) fn deliver_direct(&self, json_str: &str, was_active: bool) -> Result<(), String> {
148 let mut inner = self.inner.lock().unwrap();
149 if let Err(e) = Self::try_write_stdin_locked(&mut inner, json_str) {
150 if !was_active {
151 self.health_monitor.set_active_turn(false);
152 }
153 drop(inner);
154 self.publish_status();
155 return Err(e);
156 }
157 Ok(())
158 }
159
160 /// Releases the exclusive spawn claim taken by `decide_send_action`
161 /// returning `BecomeSpawner`. `spawn_succeeded` distinguishes two very
162 /// different situations:
163 ///
164 /// - **Failed spawn**: discards only the entry with `own_seq` — the
165 /// specific message THIS spawner pushed when it claimed
166 /// `BecomeSpawner` (`SendAction::BecomeSpawner::own_seq`), found by
167 /// queue identity rather than assumed to be at the front. codex P2
168 /// on PR #2360 (round 14, commit 8c2bc99ab): the queue is NOT always
169 /// empty at the moment a new spawner claims it — the "second stall"
170 /// path (`drain_queue_after_successful_spawn` with
171 /// `allow_fallback_respawn: false`) deliberately releases
172 /// `spawning_in_progress` while leaving genuinely leftover messages
173 /// queued (see that function's own doc comment). A later
174 /// `send_message` can then claim `BecomeSpawner` and `push_back` its
175 /// own message BEHIND those leftovers. Assuming "front == my own
176 /// message" in that case discarded an OLDER, unrelated, already-
177 /// accepted prompt instead of the actually-failed one — silent data
178 /// loss, plus handing the wrong (already-failed) message to the
179 /// fallback respawn. Originally fixed by matching content instead
180 /// of position, which still left two GENUINELY DIFFERENT messages
181 /// sharing identical text (e.g. two "yes" replies) ambiguous; seq
182 /// matching (issue #2365) closes that residue too. codex P1 on PR
183 /// #2360 (sixth review pass): leaving this message queued let an
184 /// unrelated LATER successful spawn silently execute a prompt the
185 /// caller was already told had failed (`send_message`/
186 /// `retry_after_resume_failure` already report the failure),
187 /// sometimes duplicating a message the user had re-sent by hand.
188 /// Anything else queued (from other callers who got
189 /// `SendAction::Queued` and were already told "accepted") is left in
190 /// place for the next successful spawn.
191 /// - **Successful spawn**: hands the drain off to a background task
192 /// that delivers everything queued, in order, via `Sender::send`
193 /// (which awaits free capacity) rather than `try_send`. codex P2 on
194 /// PR #2360 (sixth review pass): a synchronous `try_send` loop
195 /// popped a message off the queue and then discarded it outright if
196 /// the bounded stdin channel was momentarily full — losing input
197 /// despite already having told that caller "accepted". Deferring to
198 /// a task lets delivery simply wait for capacity instead.
199 ///
200 /// If the background task discovers the process it was meant to drain
201 /// into has ALREADY died (`stdin_tx` gone) with messages still left
202 /// queued, it hands off to `respawn_once_for_leftover_queue` using
203 /// `retry_config` — reagentx/codex P1 on PR #2360 (sixth review pass,
204 /// rounds 3-4): a fast-dying child can let the process-waiter run this
205 /// SAME controller's own `retry_after_resume_failure` (via
206 /// `decide_send_action` returning `Queued`, since this spawn's claim
207 /// hasn't been released yet) BEFORE this caller's own thread even
208 /// reaches this function; that retry's `Queued` branch then does
209 /// nothing further, assuming (wrongly, in this exact race) that this
210 /// drain will deliver it. Without a fallback respawn, NOTHING is ever
211 /// left responsible for the leftover messages or for telling the
212 /// frontend the turn ended — the ORIGINAL exit deliberately suppressed
213 /// its own terminal-status publish expecting the retry to eventually
214 /// publish one.
215 pub(super) fn release_spawn_claim_and_drain_queue(&self, spawn_succeeded: bool, retry_config: PersistentSpawnConfig, own_seq: u64) {
216 if !spawn_succeeded {
217 // Discard only the `own_seq` entry — see this function's own doc
218 // comment above for why position (front) is not a safe
219 // assumption. If anything else is queued (from other callers
220 // already told "accepted"), hand off to a bounded fallback
221 // respawn rather than stranding it with nobody responsible for
222 // delivering it.
223 //
224 // codex P2 on PR #2360 (round 13, commit e9678091f): clearing
225 // `spawning_in_progress` in a SEPARATE, later lock acquisition
226 // left a window between the emptiness check above and that
227 // clear where a concurrent `send_message` could observe
228 // `spawning_in_progress` still `true`, enqueue its message via
229 // `decide_send_action`'s `Queued` branch, and be told
230 // "accepted" — then this function's second lock would clear
231 // the claim without ever rechecking the queue, stranding that
232 // accepted message with no spawner and no drain ever
233 // responsible for it. The emptiness check and the flag clear
234 // must be one atomic decision under a single lock acquisition
235 // (same shape as the round-9 regression this whole PR already
236 // fixed once): only clear the claim here if the queue is STILL
237 // empty at the exact moment we're about to clear it; otherwise
238 // leave the claim held and hand off to the fallback respawn.
239 let leftovers = {
240 let mut inner = self.inner.lock().unwrap();
241 if let Some(idx) = inner.pending_send_messages.iter().position(|m| m.seq == own_seq) {
242 inner.pending_send_messages.remove(idx);
243 } else {
244 // Shouldn't normally happen — defensively log rather
245 // than guess which OTHER entry to discard instead.
246 tracing::warn!(
247 block_id = %self.block_id,
248 "failed spawn's own message was not found in the queue to discard"
249 );
250 }
251 if inner.pending_send_messages.is_empty() {
252 inner.spawning_in_progress = false;
253 false
254 } else {
255 true
256 }
257 };
258 if leftovers {
259 self.respawn_once_for_leftover_queue(retry_config);
260 }
261 return;
262 }
263 self.drain_queue_after_successful_spawn(retry_config, true);
264 }
265
266 /// Drains the queue via a background task after a successful spawn —
267 /// see `release_spawn_claim_and_drain_queue`'s doc comment for the
268 /// `try_send` → `Sender::send` rationale. `allow_fallback_respawn`
269 /// bounds retry depth to exactly one extra hop: `true` from the public
270 /// entry point, `false` when called from `respawn_once_for_leftover_queue`
271 /// itself, so a SECOND stall just publishes a status update instead of
272 /// cascading indefinitely.
273 pub(super) fn drain_queue_after_successful_spawn(&self, retry_config: PersistentSpawnConfig, allow_fallback_respawn: bool) {
274 self.drain_queue_with_claim(retry_config, allow_fallback_respawn, QueueDrainClaim::SpawnClaim);
275 }
276
277 /// The queue-drain loop itself, parameterized by which exclusivity
278 /// claim it runs under ([`QueueDrainClaim`]) — the post-spawn drain
279 /// (`SpawnClaim`) and issue #2367's retry-batch flush (`RetryFlush`)
280 /// share every delivery invariant (Sender::send backpressure,
281 /// seed-aware retry-batch appends, delivery-order persistence,
282 /// stall handling); the ONLY difference is which flag they release.
283 /// `RetryFlush` callers always pass `allow_fallback_respawn: false`:
284 /// the flush targets an already-running process, so a stall means
285 /// that process died — leftovers stay queued for the next spawn and
286 /// the stalled branch publishes a status update.
287 pub(super) fn drain_queue_with_claim(
288 &self,
289 retry_config: PersistentSpawnConfig,
290 allow_fallback_respawn: bool,
291 claim: QueueDrainClaim,
292 ) {
293 debug_assert!(
294 !(claim == QueueDrainClaim::RetryFlush && allow_fallback_respawn),
295 "a retry flush never respawns — see this function's doc comment"
296 );
297 let inner_arc = Arc::clone(&self.inner);
298 let block_id = self.block_id.clone();
299 let self_ref = self.self_ref.lock().unwrap().clone().unwrap_or_default();
300 tokio::spawn(async move {
301 // The message `spawn_process` already seeded synchronously
302 // into the retry batch in `inner.resume` (before this task even
303 // existed — necessary so a process that dies before this task's
304 // first poll can't lose it, see `ResumeState`'s own doc comment)
305 // must be identified exactly, not by "whatever this drain pops
306 // first" (#2360):
307 // a purely positional flag breaks exactly the way
308 // `release_spawn_claim_and_drain_queue`'s front-popping
309 // assumption did (see that function's own fix history): a
310 // prior "second stall" can leave older leftover messages
311 // queued ahead of a later spawner's own triggering message
312 // (`push_back` appends behind them), so the FIRST thing this
313 // drain pops isn't necessarily the seeded one. Treating it as
314 // if it were: (a) skips recording the OLDER leftover into the
315 // retry-batch tracking at all — silently dropping it forever
316 // if this process ALSO later dies from a stale resume, and
317 // (b) records the ACTUAL seeded/triggering message a SECOND
318 // time (once via `spawn_process`'s synchronous seed, once via
319 // this drain's own append) — a confirmed stale-resume retry
320 // would then redeliver that one message TWICE. Originally
321 // fixed by matching content (with `seed_already_matched` as a
322 // one-shot so an identical-text later delivery wasn't ALSO
323 // skipped); now matched by queue identity
324 // (`QueuedMessage::seq`, issue #2365), which identifies the
325 // seeded entry exactly regardless of position or duplicate
326 // text. The one-shot flag is kept as cheap defense-in-depth —
327 // seqs are never reused, so it can no longer fire twice.
328 let mut seed_already_matched = false;
329 let stalled_with_leftovers = loop {
330 let next = {
331 let mut inner = inner_arc.lock().unwrap();
332 match inner.stdin_tx.clone() {
333 Some(tx) => inner.pending_send_messages.pop_front().map(|m| (m, tx)),
334 None => None,
335 }
336 };
337 let Some((queued, tx)) = next else {
338 // Either the queue is empty, or the process has
339 // already exited again before we got to it. Release
340 // the claim ONLY if we're not about to hand off to a
341 // fallback respawn — reagentx P1 on PR #2360 (sixth
342 // review pass, round 4): releasing it unconditionally
343 // here left a window, between this release and
344 // `respawn_once_for_leftover_queue`'s own
345 // `spawn_process` call re-establishing state, where a
346 // concurrent `send_message`/`retry_after_resume_failure`
347 // could see "not running, not spawning" and
348 // independently spawn its own child process for the
349 // same block — reintroducing the exact orphaned/
350 // duplicate-process race `spawning_in_progress` was
351 // added in this same PR to close.
352 let mut inner = inner_arc.lock().unwrap();
353 // Re-check the queue under THIS lock before releasing:
354 // the pop above observing "empty" and this release are
355 // two separate acquisitions, and a concurrent caller
356 // routed to `Queued` (claim still held) can enqueue in
357 // that gap. With a live tx, releasing now would strand
358 // that message — no claim holder ever drains it, and
359 // every future send takes `DeliverDirect` straight
360 // past it. Loop back and drain it instead (issue
361 // #2367's queue-authority guarantee; the same gap
362 // existed for the spawn-claim drain).
363 if inner.stdin_tx.is_some() && !inner.pending_send_messages.is_empty() {
364 drop(inner);
365 continue;
366 }
367 let stalled = inner.stdin_tx.is_none() && !inner.pending_send_messages.is_empty();
368 if !(stalled && allow_fallback_respawn) {
369 claim.release(&mut inner);
370 }
371 break stalled;
372 };
373 let QueuedMessage { seq, json_str, already_persisted } = queued;
374 let delivered_copy = json_str.clone();
375 let is_the_seed = !seed_already_matched && {
376 let inner = inner_arc.lock().unwrap();
377 inner.resume.is_seeded_message(inner.spawn_generation, seq)
378 };
379 if is_the_seed {
380 seed_already_matched = true;
381 }
382 // reagentx P1 on PR #2360 (sixth review pass, round 9):
383 // mark "in flight" for the ENTIRE send-then-append
384 // sequence below, not just the send — see
385 // `PersistentInner::drain_send_in_flight`'s own doc
386 // comment for the race this closes (the process-waiter's
387 // exit-handling can `.take()` the confirmed retry batch
388 // in the gap between a successful send and this task
389 // getting back around to recording it there).
390 inner_arc.lock().unwrap().drain_send_in_flight = true;
391 if let Err(e) = tx.send(json_str).await {
392 tracing::warn!(
393 block_id = %block_id,
394 "failed to deliver a queued message — receiver dropped, process likely exited"
395 );
396 // The channel's gone, so no send from here will ever
397 // succeed again — put the message back (rather than
398 // silently discarding it) for a future spawn to pick
399 // up. Same claim-retention rule as above.
400 let mut inner = inner_arc.lock().unwrap();
401 inner.drain_send_in_flight = false;
402 inner.pending_send_messages.push_front(QueuedMessage { seq, json_str: e.0, already_persisted });
403 let stalled = !inner.pending_send_messages.is_empty();
404 if !(stalled && allow_fallback_respawn) {
405 claim.release(&mut inner);
406 }
407 break stalled;
408 }
409 // codex P1 on PR #2360 (sixth review pass, round 5): track
410 // every message actually handed to this process's stdin
411 // channel beyond the first — not just the one that
412 // triggered the spawn — so a confirmed stale-resume retry
413 // redelivers all of them (see `RetryPayload`'s own doc
414 // comment), since channel acceptance is not proof the CLI
415 // ever read it. `MessageAppendedToRetryBatch` is applied
416 // whether the state is still `AwaitingOutcome` or has
417 // already been promoted to `ConfirmedRetry` — codex P1 on
418 // PR #2360 (sixth review pass, round 6): `poison_resume`
419 // (the stderr-reader task, running concurrently) can
420 // promote at any point, and `persistent_resume::update`
421 // handles the append identically either way (see its own
422 // `MessageAppendedToRetryBatch` match arms), so there's no
423 // window where a message delivered right after that
424 // promotion is silently dropped.
425 if !is_the_seed {
426 // reagentx P1 on PR #2373: reading `spawn_generation`
427 // and applying the event were two SEPARATE lock
428 // acquisitions — a concurrent respawn in between would
429 // bump `spawn_generation`, making this event carry a
430 // now-stale generation that `update()`'s catch-all
431 // silently ignores, losing the message from the retry
432 // batch. One lock acquisition closes the gap.
433 let mut inner = inner_arc.lock().unwrap();
434 let generation = inner.spawn_generation;
435 inner.apply_resume_event(persistent_resume::ResumeEvent::MessageAppendedToRetryBatch {
436 generation,
437 entry: persistent_resume::QueuedRetryEntry { seq, json: delivered_copy.clone() },
438 });
439 }
440 inner_arc.lock().unwrap().drain_send_in_flight = false;
441 // Persist in actual delivery order — codex P2 on PR #2360
442 // (sixth review pass, round 5): persisting at the
443 // `decide_send_action` call site instead let two callers'
444 // own synchronous code run (and thus persist) in a
445 // different order than their messages are actually
446 // delivered, producing a blockfile transcript that
447 // doesn't match what the agent received. Skipped for a
448 // stale-resume retry's redelivery — codex P2 on PR #2360
449 // (sixth review pass, round 7): that content was already
450 // correctly persisted on its ORIGINAL (failed) attempt;
451 // persisting it again here duplicated every replayed
452 // prompt in the blockfile transcript.
453 if !already_persisted {
454 if let Some(ctrl) = self_ref.upgrade() {
455 ctrl.persist_message_to_blockfile(&delivered_copy);
456 }
457 }
458 };
459 if stalled_with_leftovers {
460 match self_ref.upgrade() {
461 Some(ctrl) if allow_fallback_respawn => ctrl.respawn_once_for_leftover_queue(retry_config),
462 Some(ctrl) => ctrl.publish_status(),
463 None => {
464 // Nobody left to call back (e.g. a throwaway
465 // instance that never called `set_self_ref`) — the
466 // claim was deliberately kept held above pending
467 // this hand-off, so it must still be released here
468 // or no future caller could ever spawn again.
469 claim.release(&mut inner_arc.lock().unwrap());
470 }
471 }
472 }
473 });
474 }
475
476 /// Attempts exactly one fallback respawn when either call site above is
477 /// about to release its claim with messages still queued and nobody
478 /// left responsible for delivering them. Forces `session_id` empty so
479 /// this fallback spawn never attempts `--resume` — reusing a
480 /// possibly-still-stale session id here would risk repeating the exact
481 /// failure this whole retry mechanism exists to recover from. If THIS
482 /// spawn also fails, or its own process also dies before its own drain
483 /// completes, gives up and publishes a status update rather than
484 /// cascading indefinitely — the queue itself is never discarded (this
485 /// function never pops anything itself — it isn't tied to a specific
486 /// message the way the original spawn attempt was), so a genuinely
487 /// later, unrelated send will still eventually pick it up.
488 /// Atomically clears the ambient session id AND reserves a fresh
489 /// spawn generation, so every reader task belonging to any EXISTING
490 /// generation is stale from this point on. Used by both fresh-start
491 /// respawn paths (`respawn_once_for_leftover_queue`,
492 /// `retry_after_resume_failure`'s `BecomeSpawner` arm) in place of a
493 /// bare `session_id = None`.
494 ///
495 /// codex P1 on PR #2500 (second round): clearing alone left a window
496 /// — until `spawn_process`'s own generation bump, which happens AFTER
497 /// the `--resume` decision reads `session_id` and after the process
498 /// is spawned — where the dying generation still equaled
499 /// `spawn_generation`, so its stdout reader's stale-sid echo passed
500 /// `try_capture_session_id`'s currency gate (issue #2366) and was
501 /// re-adopted: the supposedly fresh spawn could reattach
502 /// `--resume <stale-sid>` with no retry payload armed to catch the
503 /// repeat failure. Reserving the next generation in the same lock
504 /// acquisition as the clear closes the whole window.
505 ///
506 /// The reserved generation is never itself spawned (`spawn_process`
507 /// bumps again) — see `spawn_generation`'s doc comment for why the
508 /// gap is inert. A `stop_process` racing into the reserve window
509 /// records a `StopRequested` for the never-spawned generation, which
510 /// the resume state machine ignores; both callers only reach this
511 /// point after the prior generation's tracking has already resolved,
512 /// so no stop-intent is lost that wasn't equally lost before.
513 pub(super) fn clear_session_id_for_fresh_spawn(&self) {
514 let mut inner = self.inner.lock().unwrap();
515 inner.spawn_generation += 1;
516 inner.session_id = None;
517 }
518
519 pub(super) fn respawn_once_for_leftover_queue(&self, mut config: PersistentSpawnConfig) {
520 config.session_id = String::new();
521 // reagentx P0 on PR #2360 (sixth review pass, round 11): clearing
522 // `config.session_id` alone does nothing — `spawn_process`'s own
523 // `--resume` decision reads `inner.session_id` directly (see its
524 // own doc comment), never `config.session_id` (that field is only
525 // consulted to HYDRATE `inner.session_id` when it's still `None`,
526 // which is skipped here anyway since it's now empty). If the
527 // doomed process's stderr reader hasn't cleared `inner.session_id`
528 // yet (poison_resume races the drain's own `tx.send()` failure —
529 // exactly the fast-fail case this whole mechanism targets), this
530 // fallback respawn would reattach `--resume <stale-sid>` and
531 // reproduce the identical failure — and since this call passes
532 // `resume_retry_payload: None`, nothing re-arms to catch the
533 // repeat, so the process-waiter finds no confirmed retry and
534 // silently drops the message for good. Mirrors
535 // `retry_after_resume_failure`'s own explicit clear for the exact
536 // same reason.
537 //
538 // A plain clear, deliberately NOT `poison_resume` — reagentx P1
539 // on PR #2360 (sixth review pass, round 13): a prior cut of this
540 // fix called `poison_resume` here to defensively close a narrower
541 // race (a still-racing stdout-reader task from the doomed process
542 // could re-capture the same sid right after a plain clear). That
543 // was a regression: `respawn_once_for_leftover_queue` is reached
544 // from TWO triggers that have nothing to do with a CONFIRMED
545 // stale `--resume` — `release_spawn_claim_and_drain_queue`'s
546 // `!spawn_succeeded` branch (ANY `spawn_process` failure — a
547 // missing binary, an OS error) and
548 // `drain_queue_after_successful_spawn`'s stall branch (ANY
549 // process exit/crash with messages still queued, not
550 // specifically a stale-resume death). In both, `inner.session_id`
551 // could just as easily be a legitimately hydrated-but-unattempted
552 // id, or a genuinely valid, already-captured session from a
553 // process that ran fine and crashed for an unrelated reason.
554 // `poison_resume` is documented as PERMANENT (`resume_poisoned` is
555 // "never reset back to None") — poisoning a sid never actually
556 // confirmed dead by the CLI permanently breaks conversation
557 // continuity for that session, with no disclosure, for the rest
558 // of this controller instance's lifetime. A plain clear only
559 // affects THIS respawn's own `--resume` decision (already forced
560 // empty via `config.session_id` above) and carries no such
561 // permanent, over-broad risk. The narrower race the poisoning was
562 // meant to close — a still-racing reader task from the doomed
563 // process re-capturing the same sid right after a plain clear —
564 // is now closed WITHOUT poisoning: the clear also reserves a
565 // fresh spawn generation, making every existing generation's
566 // capture stale to `try_capture_session_id`'s currency gate (see
567 // `clear_session_id_for_fresh_spawn`'s own doc comment).
568 // An adopted recovery candidate (`leftover_resume_candidate`, set by
569 // `settle_empty_resume_retry` when prompts were queued behind the
570 // eager claim — codex P1 on PR #3551, fifth round) is the ONE case
571 // this respawn does not start fresh: it resumes the candidate,
572 // through the same tracked `--resume` an eager resume gets, so a
573 // stale candidate cascades into the normal retry path rather than
574 // being trusted. The generation is still reserved here, exactly as
575 // the fresh clear does, so a late capture from the doomed process
576 // cannot overwrite the candidate before `spawn_process` reads it.
577 let candidate = {
578 let mut inner = self.inner.lock().unwrap();
579 let candidate = inner.leftover_resume_candidate.take();
580 if let Some(ref sid) = candidate {
581 inner.spawn_generation += 1;
582 inner.session_id = Some(sid.clone());
583 }
584 candidate
585 };
586 match candidate {
587 Some(sid) => config.session_id = sid,
588 None => self.clear_session_id_for_fresh_spawn(),
589 }
590 let retry_config = config.clone();
591 let spawn_result = self.spawn_process(config, None);
592 match &spawn_result {
593 Ok(_) => {
594 self.mark_turn_active_and_publish();
595 self.drain_queue_after_successful_spawn(retry_config, false);
596 }
597 Err(e) => {
598 tracing::error!(
599 block_id = %self.block_id,
600 error = %e,
601 "fallback respawn for a leftover queue failed"
602 );
603 // A candidate respawn can be refused by the duplicate-session
604 // guard (codex P1 on PR #3551, sixth round). Taking the
605 // candidate above consumed the only marker, so leaving the
606 // prompts queued would let the NEXT send's generic failed-
607 // spawn handling call this function again — candidate-less,
608 // clearing to fresh — and deliver them to a blank
609 // conversation after all. Same answer as the eager path's
610 // refusal: release, discard, report which prompts and why.
611 if is_held_elsewhere_error(e) {
612 self.settle_eager_spawn_failure(e, retry_config);
613 // Same broadcast as the generic arm below (codex P2 on PR
614 // #3554): the doomed eager process's exit path hands off
615 // `FireRetry` and deliberately suppresses `PublishDone`,
616 // so nothing else tells the pane it is no longer working
617 // until the 20-second heartbeat does.
618 self.publish_status();
619 return;
620 }
621 self.inner.lock().unwrap().spawning_in_progress = false;
622 self.publish_status();
623 }
624 }
625 }
626
627 /// Send a user message to the running CLI process.
628 /// If the process isn't spawned yet, spawns it first.
629 /// Emit `agent-message-accepted` for a given message_id, if set.
630 /// Mirrors the subprocess controller's `emit_message_accepted` — signals the
631 /// frontend to promote the pending entry from queued to in-document.
632 pub(super) fn emit_message_accepted(&self, message_id: Option<&str>) {
633 let Some(id) = message_id else { return };
634 let Some(ref broker) = self.broker else { return };
635 super::super::publish_message_accepted(broker, &self.block_id, id);
636 tracing::info!(
637 block_id = %self.block_id,
638 message_id = %id,
639 "emitted agent-message-accepted"
640 );
641 }
642
643 /// Persists a formatted stdin JSON line to the blockfile + global zone
644 /// so `parseHistoryLines` can reconstruct the `user_message` node on
645 /// the next pane open. Published as an `echo: "stdin"` transcript event
646 /// (Phase 5a-3c): the pane already shows the message through the
647 /// `agent-message-accepted` path (UUID node), so it doesn't add a second
648 /// node, but its stream keeps no gap where this record sits.
649 pub(super) fn persist_message_to_blockfile(&self, json_str: &str) {
650 let global_zone = super::super::shell::resolve_global_output_zone(&self.mstore, &self.block_id);
651 // A line with inline image attachments is stored without the base64:
652 // its text still lists every image, and every reader of stored user
653 // lines expects string content.
654 let stored = crate::backend::attachments::prompt::persisted_line(json_str);
655 let line_with_newline = format!("{stored}\n");
656 // Two SQLite write transactions (the block's store and the host-global
657 // transcript store every instance writes to), each allowed to wait out
658 // a 5s busy timeout. The async callers (run_agent_turn, agent.send)
659 // reach this on a runtime worker; inline, that worker stops polling
660 // the I/O driver and srv stops answering sockets for the duration.
661 // Synchronous on purpose — messages must persist in delivery order.
662 crate::backend::blocking::off_async_worker(|| {
663 super::super::shell::persist_user_line(
664 self.broker.as_deref(),
665 &self.block_id,
666 line_with_newline.as_bytes(),
667 self.filestore.as_ref(),
668 global_zone.as_deref(),
669 )
670 });
671 }
672
673 pub fn send_message(&self, message: String, config: PersistentSpawnConfig) -> Result<(), String> {
674 self.send_message_from(message, config, None)
675 }
676
677 /// [`Self::send_message`], saying who the message is from, so the turn it
678 /// starts (or joins) carries that provenance (SPEC_AGENT_SELF_QUIT §6.3).
679 /// `None` = unknown, which never counts as the user.
680 pub fn send_message_from(
681 &self,
682 message: String,
683 config: PersistentSpawnConfig,
684 origin: Option<crate::backend::blockcontroller::health::TurnInput>,
685 ) -> Result<(), String> {
686 self.send_message_with_images_from(message, Vec::new(), config, origin)
687 }
688
689 /// [`Self::send_message_from`] with Anthropic `image` content blocks
690 /// placed before the text (image attachments,
691 /// SPEC_AGENT_PANE_IMAGE_ATTACHMENTS_2026_09_26.md §6.6). With no images
692 /// the line is byte-for-byte what `send_message_from` always wrote.
693 /// Stored copies drop the images again — see
694 /// `persist_message_to_blockfile`.
695 pub fn send_message_with_images_from(
696 &self,
697 message: String,
698 images: Vec<serde_json::Value>,
699 config: PersistentSpawnConfig,
700 origin: Option<crate::backend::blockcontroller::health::TurnInput>,
701 ) -> Result<(), String> {
702 // Pre-turn fence (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24 §4.3):
703 // a process that lost the agent to another instance starts no turn.
704 self.fence_check()?;
705 // Format as stream-json user message.
706 let json_str = crate::backend::attachments::prompt::claude_user_line(message, images);
707
708 let queued_origin = origin.clone();
709 let action = self.decide_send_action_from(&json_str, None, origin);
710 if matches!(action, SendAction::Queued | SendAction::BecomeSpawner { .. }) {
711 // Delivered when the process spawns: the turn it starts is this one's.
712 match queued_origin {
713 Some(o) => self.health_monitor.hint_next_turn(o),
714 None => self.health_monitor.hint_next_turn_unlabelled(),
715 }
716 }
717 match action {
718 // Nothing was written, queued or persisted, so the caller's own
719 // error handling is the whole story.
720 SendAction::Refused(e) => Err(e),
721 SendAction::Queued => {
722 // Persistence happens later, inside the drain, at the
723 // exact moment this message is actually delivered — see
724 // `drain_queue_after_successful_spawn`. codex P2 on PR
725 // #2360 (sixth review pass, round 5): persisting
726 // immediately here instead let whichever caller's
727 // synchronous code happened to run first persist first,
728 // even when queue position said a DIFFERENT message
729 // (already sitting there, from a caller further along in
730 // its own `BecomeSpawner` spawn_process call) is actually
731 // delivered first — producing a blockfile transcript that
732 // doesn't match delivery order.
733 self.emit_message_accepted(config.message_id.as_deref());
734 Ok(())
735 }
736 SendAction::DeliverDirect { was_active } => {
737 // spawn_process already marks a fresh process's first turn
738 // active (and starts its watchdog); for an already-running
739 // process (the common case — every turn after the first)
740 // this is the only place that re-marks the turn active,
741 // since the persistent process never exits between turns.
742 // Without this, `turn_active` would go stale after turn 1.
743 // The mark itself happened inside `decide_send_action` (see
744 // `SendAction::DeliverDirect`); this publishes it.
745 if !was_active {
746 self.spawn_status_heartbeat();
747 }
748 self.publish_status();
749 // Persist only AFTER a successful send — reagentx P1 on PR
750 // #2360 (sixth review pass, round 5): `stdin_tx` can have
751 // gone `None` (process died) between `decide_send_action`'s
752 // check and this later lock re-acquisition, or `try_send`
753 // can fail with `Full` under load; persisting beforehand
754 // reproduces, for this path, the exact "persisted a
755 // never-delivered message" bug the immediately prior
756 // commit fixed for `BecomeSpawner`. Matches
757 // `send_user_message`'s existing (correct) ordering.
758 self.deliver_direct(&json_str, was_active)?;
759 self.persist_message_to_blockfile(&json_str);
760 self.emit_message_accepted(config.message_id.as_deref());
761 Ok(())
762 }
763 SendAction::BecomeSpawner { own_seq } => {
764 // `resume_retry_payload` is stashed SYNCHRONOUSLY inside
765 // spawn_process, before any background task exists —
766 // reagentx P1 on PR #2360: stashing it after spawn_process
767 // returned left a window where a process that dies fast
768 // enough lets the already-scheduled process-waiter task
769 // observe the exit and take() this payload as still
770 // `None`, silently losing the retry for the exact case it
771 // exists to catch. This is independent of, and still
772 // needed alongside, the spawn-claim/queue mechanism below:
773 // that mechanism only prevents a SECOND process from being
774 // spawned concurrently — it does nothing once THIS
775 // process is running and later dies from a stale
776 // `--resume`, which is what the retry payload is for.
777 let message_id = config.message_id.clone();
778 let retry_config = config.clone();
779 let spawn_result = self.spawn_process(
780 config,
781 Some(persistent_resume::QueuedRetryEntry { seq: own_seq, json: json_str }),
782 );
783 // Only emit "accepted" on success — codex P2 on PR #2360
784 // (sixth review pass, round 4): an earlier cut of this fix
785 // persisted unconditionally here, letting a rejected spawn
786 // (missing executable, bad launch config) leave a
787 // "user_message" line in the blockfile for a prompt that
788 // was NEVER actually delivered. Persistence itself now
789 // happens later, inside the drain, at the exact moment
790 // this message is actually delivered (round 5 — see
791 // `drain_queue_after_successful_spawn`) — which already
792 // only runs on a successful spawn, so the same guarantee
793 // holds without needing a persist call here at all.
794 if spawn_result.is_ok() {
795 self.mark_turn_active_and_publish();
796 self.emit_message_accepted(message_id.as_deref());
797 }
798 self.release_spawn_claim_and_drain_queue(spawn_result.is_ok(), retry_config, own_seq);
799 spawn_result
800 }
801 }
802 }
803}