Skip to main content

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}