Skip to main content

agentmux_srv/backend/blockcontroller/persistent/
spawn.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `spawn_process`: builds the CLI argv/env, spawns the child, and starts the
5//! per-session I/O tasks (stdin writer, stdout reader, stderr reader, waiter).
6//! The stdin writer runs inline; the other three bodies live in
7//! `stderr_reader.rs`, `stdout_reader.rs` and `process_waiter.rs`.
8
9#[cfg(windows)]
10use agentmux_common::win32::NoWindow;
11use super::*;
12use super::process_waiter::ProcessWaiterCtx;
13use super::stderr_reader::StderrReaderCtx;
14use super::stdout_reader::StdoutReaderCtx;
15
16impl PersistentSubprocessController {
17    /// Spawn the persistent CLI process. Called only while the caller
18    /// holds the exclusive spawn claim (`spawning_in_progress`, see
19    /// `decide_send_action`) — never directly.
20    pub(super) fn spawn_process(
21        &self,
22        config: PersistentSpawnConfig,
23        resume_retry_payload: Option<persistent_resume::QueuedRetryEntry>,
24    ) -> Result<(), String> {
25        // Captured before `resume_retry_payload` is moved further down (the
26        // resume-bookkeeping match), for the turn-active decision near the
27        // end of this function — see that check's own comment. Every
28        // pre-existing caller either passes `Some(payload)` (a message is
29        // about to be delivered directly) or `None` because a message is
30        // already sitting in `pending_send_messages` waiting to be drained
31        // right after this spawn succeeds (`respawn_once_for_leftover_
32        // queue`) — this flag alone can't tell those two `None` cases
33        // apart, which is exactly why the check below also looks at the
34        // queue.
35        let had_retry_payload = resume_retry_payload.is_some();
36
37        // Build command — use make_cli_cmd to resolve .cmd wrappers to node on Windows
38        let mut cmd = crate::server::cli_handlers::make_cli_cmd(&config.cli_command);
39
40        // Hydrate the captured session id from the config when we don't have one
41        // yet (fresh controller after a forced restart — e.g. a /model change —
42        // or the picker reattach path). Mirrors SubprocessController::
43        // hydrate_session_id_from_config so the respawn resumes the same
44        // conversation instead of starting blank.
45        if !config.session_id.is_empty() {
46            let mut inner = self.inner.lock().unwrap();
47            if inner.session_id.is_none() {
48                inner.session_id = Some(config.session_id.clone());
49            }
50        }
51
52        // First spawn with no id, onto a pane that renders prior history:
53        // continue that history's session instead of pairing it with a blank
54        // model (SPEC_DURABLE_CONVERSATION_MEMORY_2026_09_23.md §1.1, §5 P0a).
55        // A session live in another pane is left alone — adopting it would
56        // turn a plain open into the held-elsewhere refusal below.
57        let first_spawn_without_sid = {
58            let inner = self.inner.lock().unwrap();
59            inner.session_id.is_none() && inner.spawn_generation == 0
60        };
61        // The session the pane's history belongs to when `--resume` can't
62        // reach it here: the resume gate may relocate it (spec §4.3).
63        let mut unreachable_history_session: Option<String> = None;
64        if first_spawn_without_sid {
65            if let Some(sid) = self.find_continuation_session_id(&config) {
66                if self.session_held_elsewhere(&sid).is_none() {
67                    tracing::info!(
68                        block_id = %self.block_id,
69                        session_id = %sid,
70                        "continuity: first spawn has no session id; continuing the session this pane's history belongs to"
71                    );
72                    self.inner.lock().unwrap().session_id = Some(sid);
73                } else {
74                    tracing::info!(
75                        block_id = %self.block_id,
76                        session_id = %sid,
77                        "continuity: prior session is live in another pane; starting fresh"
78                    );
79                }
80            } else if !config.resume_flag.is_empty() {
81                unreachable_history_session = pane_history_session_id(
82                    self.filestore.as_deref(),
83                    self.mstore.as_deref(),
84                    &self.block_id,
85                    &config.session_id_field,
86                )
87                .filter(|sid| self.session_held_elsewhere(sid).is_none());
88            }
89        }
90        // Claim the agent's single-live-instance lease, then — under it — let
91        // the resume gate decide which session is resumed. Any early return
92        // after this point drops the handle, which releases the lease.
93        let gfs = crate::backend::agent_session::global_transcript_store();
94        let agent_lease = self.lease_then_gate(&config, gfs.map(|g| &**g), unreachable_history_session)?;
95
96        // Append `--resume <sid>` when we have a session id and the provider
97        // supports simple-flag resume — same construction as
98        // SubprocessController::spawn_turn. This is what makes a model/effort
99        // change (which respawns the persistent CLI with new flags) preserve the
100        // conversation. cli_args carries the runtime flags (model/effort/perm)
101        // already rebuilt by the frontend (useAgentCommands buildRuntimeArgs).
102        let mut spawn_args = config.cli_args.clone();
103        // Recorded so the stderr reader can tell "No conversation found" apart
104        // from an unrelated CLI error, and so it knows exactly which id to
105        // poison against the stdout reader's own capture (`stdout_reader.rs`) — a
106        // provider that echoes back whatever --resume it was given as its
107        // first stdout line, even when that id turns out to be unreachable.
108        let mut attempted_resume_sid: Option<String> = None;
109        // One session, one process. Resuming a session another live process
110        // is still running on — the agent's other pane, or one that is
111        // closing and hasn't exited yet — puts two CLIs on one transcript.
112        // Refuse instead: the user closes the other one (or waits the few
113        // seconds a close takes) and tries again. Covers every reopen path
114        // (picker reattach, launch modal, MCP), not just `agent.open`
115        // (SPEC_AGENT_PANE_CLOSE_GRACEFUL_SHUTDOWN_2026_09_18.md §4.7).
116        // A spawn that forks (the resume gate placed a copy, or the session
117        // grew outside AgentMux) reads the session and writes a new one, so a
118        // live process on it is no conflict: a fork started while its parent
119        // runs (the fork-isolation report in #4744, F5).
120        let requested_sid = if config.resume_flag.is_empty() {
121            None
122        } else {
123            let inner = self.inner.lock().unwrap();
124            if inner.fork_copy.is_some() || inner.fork_next { None } else { inner.session_id.clone() }
125        };
126        // Check and claim must be one step: the check reads other
127        // controllers' `current_pid`, which a concurrent resume of the same
128        // session only sets further down this function. Held from here until
129        // this spawn's `current_pid` is set (or it returns early), so two
130        // reopens of one session — two picker clicks, picker + MCP — can't
131        // both pass. Keyed by session (reagent P1 on #3421): a fixed stripe
132        // of locks chosen by hashing the session id — every reopen of one
133        // session takes the same lock, unrelated agents' resume respawns
134        // almost never share one, and memory stays bounded (a per-session map
135        // would grow forever — reagent P2). Only resume spawns take one;
136        // `spawn_process` is synchronous, and no caller holds an `inner` lock
137        // across it.
138        // The agent's memory is in its folder before the provider reads it.
139        // Runs before the resume claim below, not under it: a pass can take
140        // up to its one-second budget, and that lock is meant to be held only
141        // briefly (reagent P1 on #3721). Not for a session already live in
142        // another pane — that spawn is about to be refused, and its pass
143        // would run beside the live provider writing the same folder. (A
144        // second pass for the agent at the same moment skips on its lease.)
145        if requested_sid.as_deref().is_none_or(|sid| self.session_held_elsewhere(sid).is_none()) {
146            self.reconcile_memory_before_spawn(&config);
147        }
148        let resume_claim = requested_sid.as_deref().map(|sid| {
149            const STRIPES: usize = 64;
150            static RESUME_SPAWN_LOCKS: [Mutex<()>; STRIPES] = [const { Mutex::new(()) }; STRIPES];
151            let stripe = {
152                use std::hash::{Hash, Hasher};
153                let mut h = std::collections::hash_map::DefaultHasher::new();
154                sid.hash(&mut h);
155                (h.finish() as usize) % STRIPES
156            };
157            RESUME_SPAWN_LOCKS[stripe].lock().unwrap_or_else(|e| e.into_inner())
158        });
159        if let Some(sid) = requested_sid.as_deref() {
160            if let Some((other, closing)) = self.session_held_elsewhere(sid) {
161                return Err(held_elsewhere_error(&other, closing));
162            }
163        }
164        // Set when the resume gate relocated the session this spawn resumes
165        // (spec §4.3): the copy it placed, which the fork reads and never
166        // writes.
167        let mut relocated_copy: Option<crate::backend::continuity_relocate::Relocated> = None;
168        // This spawn resumes with `--fork-session` (relocated, or the session
169        // grew outside AgentMux, spec §4.4).
170        let mut forked = false;
171        {
172            let mut inner = self.inner.lock().unwrap();
173            let fork_copy = inner.fork_copy.take();
174            let fork_next = std::mem::take(&mut inner.fork_next);
175            if let Some(sid) = inner.session_id.clone() {
176                if !config.resume_flag.is_empty() {
177                    spawn_args.push(config.resume_flag.clone());
178                    spawn_args.push(sid.clone());
179                    if fork_copy.is_some() || fork_next {
180                        // The launch's own args may carry it already (a fork
181                        // launched from the picker); once is enough.
182                        if !spawn_args.iter().any(|a| a == "--fork-session") {
183                            spawn_args.push("--fork-session".to_string());
184                        }
185                        relocated_copy = fork_copy.clone();
186                        forked = true;
187                    }
188                    attempted_resume_sid = Some(sid);
189                }
190            }
191            if relocated_copy.is_none() {
192                if let Some(copy) = fork_copy {
193                    crate::backend::continuity_relocate::remove(&copy);
194                }
195            }
196        }
197        cmd.args(&spawn_args);
198
199        core::apply_working_dir(&mut cmd, &self.block_id, &config.working_dir, &config.env_vars);
200        // Identity M4a: record what this process is actually given.
201        crate::backend::identity_spawn::record_process_spawn(
202            &self.block_id,
203            crate::backend::identity_spawn::SpawnPath::Persistent,
204            &config.env_vars,
205        );
206
207        // On Windows: suppress console-window allocation. The srv runs without a
208        // console of its own, so spawning the agent CLI without CREATE_NO_WINDOW
209        // makes Windows allocate a fresh console — which Windows 11's default-
210        // terminal handler renders as a NEW Windows Terminal window. One leaks per
211        // agent start / resume / respawn; a flapping or restart-heavy session
212        // accumulates dozens. stdio is piped here, so the console is never needed.
213        // See docs/retro/retro-windows-terminal-window-leak-2026-06-21.md.
214        // Matches acp.rs / subprocess/host_spawn.rs; sibling of shell/pty.rs's
215        // PTY path.
216        #[cfg(windows)]
217        {
218            cmd.no_window();
219        }
220
221        cmd.stdin(std::process::Stdio::piped());
222        cmd.stdout(std::process::Stdio::piped());
223        cmd.stderr(std::process::Stdio::piped());
224
225        // Don't even start a config restart's replacement a Stop has already
226        // cancelled (see `restart_spawn_still_permitted_locked`); the decision
227        // itself is made where the child is installed, below.
228        Self::restart_spawn_still_permitted_locked(&mut self.inner.lock().unwrap())?;
229
230        // In the block's tracker before it runs (`spawn_tracked`).
231        let mut child = crate::backend::process_tracker::registry::spawn_tracked(&self.block_id, &mut cmd, crate::backend::process_tracker::registry::Join::Agent).map_err(|e| {
232            tracing::error!(block_id = %self.block_id, error = %e, "persistent process spawn failed");
233            format!("failed to spawn persistent process: {e}")
234        })?;
235
236        // Stash the TENTATIVE resume-retry payload synchronously, right
237        // here — before any background task (stdin writer, stdout/stderr
238        // readers, process-waiter) is created below — reagentx P1 on PR
239        // #2360: doing this later, back in send_message after this
240        // function returned, left a window where a process that dies fast
241        // enough (the exact case this exists to catch) lets the
242        // process-waiter task — already racing on another thread once
243        // it's spawned — observe the exit and take() this payload while
244        // it's still `None`, silently losing the retry for the very case
245        // it's meant to catch. Keyed on the EXACT sid this spawn attempted
246        // (not `config.session_id`, which can differ from what's actually
247        // held in `inner.session_id` once an earlier call has already
248        // hydrated it) so `poison_resume`'s later confirmation check is
249        // unambiguous.
250        // Bumped in this SAME lock acquisition — see `spawn_generation`'s
251        // own doc comment for what this identifies and why.
252        let (my_generation, superseded_effects) = {
253            let mut inner = self.inner.lock().unwrap();
254            // A config restart's replacement is installed only if no Stop has
255            // cancelled it, decided in this acquisition: before it, a Stop has
256            // no `kill_tx` to reach the child through (codex P1 on #3990).
257            if let Err(e) = Self::commit_restart_spawn_locked(&mut inner) {
258                drop(inner);
259                let _ = child.start_kill();
260                tokio::spawn(async move {
261                    let _ = child.wait().await;
262                });
263                tracing::info!(
264                    block_id = %self.block_id,
265                    "config restart: a stop landed while the replacement was starting — killed it"
266                );
267                return Err(e);
268            }
269            // The replacement process is here, so the quiesce window is over —
270            // messages may take `DeliverDirect` against this new `stdin_tx`
271            // again. Cleared unconditionally rather than only when set: any
272            // spawn ends the window by definition, whatever opened it.
273            inner.restart_pending = false;
274            // Same for a stop request: it targeted the process this spawn
275            // replaces.
276            inner.stop_pending = false;
277            // …and for a config restart's pending replacement: this spawn is
278            // it (or supersedes it), so no exit handler may start another.
279            inner.config_restart_generation = None;
280            inner.restart_spawn_for = None;
281            // …and any deferred restart is moot now, for the same reason: this
282            // spawn read `cmd:args` fresh from block meta, so the new config is
283            // already applied and there is nothing left to restart FOR.
284            //
285            // This is what stops a leak (reagent P1 on PR #2858):
286            // `restart_when_idle` is consumed at the `is_result_frame` turn
287            // end, but a generation that dies abnormally instead — a user
288            // Stop/SIGINT, a crash, a permanently-failed turn resolving via
289            // `ProcessExited` + `PublishDone` — never reaches that branch. The
290            // stale `true` would then ride into an unrelated later generation
291            // and kill a healthy process at the end of some future turn.
292            // Clearing per-spawn scopes the flag to the generation that
293            // requested it, which is the only one it ever meant anything for.
294            inner.restart_when_idle = false;
295            inner.spawn_generation += 1;
296            // Any spawn consumes or invalidates an adopted leftover
297            // candidate — it only ever meant "for the very next spawn".
298            inner.leftover_resume_candidate = None;
299            let generation = inner.spawn_generation;
300            let effects = match (attempted_resume_sid.clone(), resume_retry_payload) {
301                (Some(sid), Some(retry_json)) => {
302                    inner.apply_resume_event(persistent_resume::ResumeEvent::SpawnedWithResume {
303                        generation,
304                        attempted_sid: sid,
305                        retry: persistent_resume::RetryPayload { config: config.clone(), messages: vec![retry_json] },
306                    })
307                }
308                // `--resume <sid>` WAS attempted, just with no message of
309                // our own to seed the retry batch with — the eager-resume
310                // path (issue #3463), which revives a session with nothing
311                // queued rather than in response to a message. codex P1 on
312                // PR #3513: routing this to `SpawnedFresh` below (the
313                // catch-all's original behavior) discarded `attempted_sid`
314                // entirely, leaving `NotTracking` in place for a spawn that
315                // in fact has an unconfirmed `--resume` in flight. If that
316                // resume turns out to be stale and a direct message arrives
317                // before the failure is detected, `MessageAppendedToRetryBatch`
318                // below has nothing to append it to and no retry ever fires
319                // when the doomed process exits — the message is silently
320                // lost. An empty `messages` starts the SAME `AwaitingOutcome`
321                // tracking a seeded resume gets; `MessageAppendedToRetryBatch`
322                // (`DeliverDirect`, below) is what fills it in as messages
323                // actually arrive. Never reachable before eager resume
324                // existed — every other caller either omits `--resume`
325                // entirely (`respawn_once_for_leftover_queue` clears
326                // `session_id` first) or always has a real payload
327                // (`BecomeSpawner`, `retry_after_resume_failure`).
328                (Some(sid), None) => {
329                    inner.apply_resume_event(persistent_resume::ResumeEvent::SpawnedWithResume {
330                        generation,
331                        attempted_sid: sid,
332                        retry: persistent_resume::RetryPayload { config: config.clone(), messages: vec![] },
333                    })
334                }
335                (None, _) => inner.apply_resume_event(persistent_resume::ResumeEvent::SpawnedFresh { generation }),
336            };
337            (generation, effects)
338        };
339        // Identity for this spawn's muxbus/registry registrations
340        // (`registration_nonce` on `AgentRegistration`/`AgentEntry`).
341        // Deliberately NOT `my_generation`: the generation is
342        // controller-LOCAL (starts at 0 per controller instance), so a
343        // replacement controller for this same block
344        // (`resync_controller` stops the old one asynchronously and
345        // constructs a new one immediately) restarts at generation 1 —
346        // the old and new processes could then share a generation, and
347        // the old exit-handler's compare-and-remove would accept the
348        // replacement's fresh registration as its own and delete it
349        // (codex P1 on PR #2500). A process-wide counter can never
350        // collide across controller instances.
351        let my_registration_nonce = next_registration_nonce();
352        // reagentx P1 (round 7 on this PR): this fresh spawn can supersede
353        // a PRIOR generation that was still AwaitingOutcome/ConfirmedRetry
354        // with a held error line (see `resolve_superseded_generation`'s
355        // own doc comment for the exact race — reachable via
356        // `respawn_once_for_leftover_queue`) — flush it now, outside the
357        // lock, same as every other `ResumeEffect` call site in this
358        // module. Discarding this return value silently lost the exact
359        // "error disappears" bug class (#2368) this PR exists to fix.
360        for effect in superseded_effects {
361            match effect {
362                persistent_resume::ResumeEffect::FlushErrorLine(line)
363                | persistent_resume::ResumeEffect::PersistImmediately(line) => {
364                    self.flush_error_line_now(line);
365                }
366                other => {
367                    tracing::warn!(
368                        block_id = %self.block_id,
369                        effect = ?other,
370                        "unexpected ResumeEffect from a fresh spawn superseding a prior generation"
371                    );
372                }
373            }
374        }
375
376        // This process starts with none of the conversation the pane is about
377        // to display — say so, in the transcript, before any of its own output
378        // lands. See `fresh_start_needs_disclosure` for why
379        // `persistent_resume`'s `SpawnedFresh` can't decide this itself.
380        //
381        // `attempted_sid` is empty: there was no id to attempt, which is the
382        // whole point. The frontend renders that as "—" rather than a blank
383        // (`DocumentRow.tsx`'s session-outcome body).
384        let mut continuation: Option<String> = None;
385        let fresh_onto_history = attempted_resume_sid.is_none() && self.has_prior_transcript();
386        if fresh_onto_history {
387            // The provider can't give this process the conversation the pane
388            // shows, so AgentMux's own record rides on its first message
389            // (SPEC_DURABLE_CONVERSATION_MEMORY_2026_09_23.md §4.4). On any
390            // generation, not just the first: a resume the CLI rejected falls
391            // through to here within the same launch (§4.2, "fall through,
392            // don't fail"), and its retry needs the record as much as a
393            // first spawn does. The disclosure above stays first-spawn-only;
394            // the retry path emits its own.
395            continuation = self.carry_continuation();
396        }
397        // After the packet is built, so the disclosure can say whether this
398        // fresh session was given the record ("continued") or not.
399        if fresh_onto_history && fresh_start_needs_disclosure(attempted_resume_sid.as_deref(), my_generation) {
400            tracing::info!(
401                block_id = %self.block_id,
402                continued = continuation.is_some(),
403                "spawned with no --resume while prior history exists — disclosing a fresh start"
404            );
405            self.emit_fresh_outcome_now(String::new(), continuation.is_some());
406        }
407        // True until the record goes out with the first message: until then the
408        // user hasn't spoken, and the pane gets a pick-up note once the new
409        // session's card is written (`continued_note`).
410        let record_pending = Arc::new(std::sync::atomic::AtomicBool::new(continuation.is_some()));
411        if let (Some(packet), Some(mstore), Some(broker)) = (&continuation, &self.mstore, &self.broker) {
412            crate::backend::continued_note::schedule(
413                mstore.clone(),
414                broker.clone(),
415                self.block_id.clone(),
416                packet.clone(),
417                record_pending.clone(),
418                agentmux_common::time::now_ms(),
419            );
420        }
421
422        let pid = child.id().unwrap_or(0);
423
424        // Notify the turn-activity tracker that a turn is starting — but
425        // only if one actually is. Every pre-existing caller of this method
426        // always has a message about to flow through, one way or the other
427        // (see `had_retry_payload`'s own comment), so this was previously
428        // unconditionally correct. `PersistentSubprocessController::
429        // start()`'s eager-resume path (issue #3463) is the first caller
430        // that genuinely has nothing queued — it revives a session so it is
431        // ready for the NEXT message, the same as `ShellController::
432        // start()` reviving a shell leaves it idle rather than mid-command.
433        // Marking a turn active here with no message ever sent meant a
434        // freshly eager-resumed pane was reported as perpetually WORKING —
435        // to the pane UI, Swarm view, subagent watcher, and shutdown logic,
436        // all of which read this flag — until a human happened to notice
437        // and send it something, since nothing would ever produce the
438        // `result` frame that normally clears it (codex P1 on PR #3513).
439        if had_retry_payload || !self.inner.lock().unwrap().pending_send_messages.is_empty() {
440            self.health_monitor.set_active_turn(true);
441        }
442
443        tracing::info!(
444            block_id = %self.block_id,
445            pid = pid,
446            cmd = %config.cli_command,
447            args = ?spawn_args,
448            working_dir = %config.working_dir,
449            "persistent process spawned"
450        );
451
452        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<KillRequest>();
453        let stdin = child.stdin.take()
454            .ok_or_else(|| format!("[persistent] stdin not captured for block {}", self.block_id))?;
455        let stdout = child.stdout.take()
456            .ok_or_else(|| format!("[persistent] stdout not captured for block {}", self.block_id))?;
457        let stderr = child.stderr.take();
458
459        // Drain stderr in background — log lines for debugging. The
460        // JoinHandle is kept (not discarded) so the process-waiter task
461        // can await this task's full completion before deciding whether a
462        // stale-resume retry was confirmed — codex P1/P2 on PR #2360
463        // (second review pass): `child.wait()` resolving is NOT proof this
464        // task has already seen and reacted to a "No conversation found"
465        // line, or finished its OWN subsequent `persist_session_id("")`
466        // call (`stderr_reader.rs`). See the process-waiter's own comment
467        // (`process_waiter.rs`) for the two
468        // failure modes this closes.
469        let stderr_reader_handle: Option<tokio::task::JoinHandle<()>> = stderr.map(|stderr_pipe| {
470            let block_id_stderr = self.block_id.clone();
471            let inner_stderr = Arc::clone(&self.inner);
472            let mstore_stderr = self.mstore.clone();
473            let event_bus_stderr = self.event_bus.clone();
474            let attempted_resume_sid = attempted_resume_sid.clone();
475            let my_generation_stderr = my_generation;
476            tokio::spawn(Self::run_stderr_reader(StderrReaderCtx {
477                stderr_pipe,
478                block_id_stderr,
479                inner_stderr,
480                mstore_stderr,
481                event_bus_stderr,
482                attempted_resume_sid,
483                my_generation_stderr,
484            }))
485        });
486
487        // Create stdin writer channel
488        let (msg_tx, mut msg_rx) = mpsc::channel::<String>(32);
489
490        {
491            let mut inner = self.inner.lock().unwrap();
492            inner.current_pid = Some(pid);
493            inner.kill_tx = Some(kill_tx);
494            inner.stdin_tx = Some(msg_tx);
495            // The same `Arc` when an earlier generation's lease was reused
496            // (see `acquire_agent_lease`), so no release happens here.
497            inner.agent_lease = agent_lease.clone();
498            Self::set_status(&mut inner, STATUS_RUNNING);
499            inner.spawn_runtime = Some(crate::backend::agent_runtime::spawn_runtime_from_args(&spawn_args));
500            inner.effective_runtime = None;
501            // Ask the CLI what it is really using as soon as it is up: the argv
502            // carries an alias, the CLI resolves it. Only a control-protocol
503            // process reads such requests from stdin. An answer is folded into
504            // the `agentruntime` event (the stdout reader, `stdout_reader.rs`).
505            inner.settings_readback = spawn_args.iter().any(|a| a == "--permission-prompt-tool");
506            inner.context_usage = None;
507            if inner.settings_readback {
508                if let Some(tx) = inner.stdin_tx.as_ref() {
509                    let _ = tx.try_send(crate::backend::agent_runtime::settings_request_line());
510                    // And where it will auto-compact (agent_context_usage.rs),
511                    // answered before the first message too.
512                    let _ = tx.try_send(crate::backend::agent_context_usage::context_usage_request_line());
513                }
514            }
515        }
516        // Now visible to `session_held_elsewhere` — a concurrent resume of
517        // the same session may run its check.
518        drop(resume_claim);
519        self.publish_status();
520
521        // Auto-register with the muxbus reactive handler so inter-agent
522        // messages reach this persistent (no-PTY) agent. The PTY shell
523        // controller (shell.rs) was the only prior auto-register path, so
524        // stream-json agents were in the directory but absent from the
525        // delivery registry — `inject_message` returned "agent not found"
526        // (issue #1470). Tier-1 delivery is routed through the controller-
527        // aware MessageSender (→ send_user_message), not PTY keystrokes.
528        // See SPEC_MUXBUS_AGENT_DISCOVERY_AND_PERSISTENT_DELIVERY_2026_06_16.
529        let agent_id_for_muxbus = muxbus_agent_id_from_env(&config.env_vars);
530        *self.agent_id.lock().unwrap() = agent_id_for_muxbus.clone();
531        // Write-once: unlike `agent_id` above (refreshed every turn by
532        // `input.rs`'s Register-tail), `stable_agent_id` is only ever set
533        // here, at spawn, and never touched again — see its field doc
534        // comment. `spawn_process` can in principle run again for the same
535        // controller on a respawn; re-writing the SAME env-derived value
536        // each time is harmless (idempotent), and a respawn with genuinely
537        // different env (rare, config change) correctly updates the alias
538        // to match, same as the primary registration already does.
539        *self.stable_agent_id.lock().unwrap() = agent_id_for_muxbus.clone();
540        // Identity M2: the UID carried in the spawn env (M1a). Captured with
541        // the same write-once discipline as `stable_agent_id`; `None` when
542        // the env carried none (no `db_agents` row at spawn — a continuation
543        // launch eager-resumes before the frontend creates the row, spec
544        // §4.4.4 Q1 — or a quick-launch pane), in which case this spawn
545        // registers name-only and the first turn's Register-tail upgrades
546        // the block in place.
547        let agent_uid_for_registry = config
548            .env_vars
549            .get("AGENTMUX_AGENT_UID")
550            .map(|u| u.trim().to_string())
551            .filter(|u| !u.is_empty());
552        *self.stable_agent_uid.lock().unwrap() = agent_uid_for_registry.clone();
553        // This process is one segment of the agent's conversation
554        // (SPEC_DURABLE_CONVERSATION_MEMORY_2026_09_23.md §4.1, P1). The
555        // stdout reader records its provider session id, the waiter its end.
556        let segment: super::segments::SegmentRef = agent_uid_for_registry.as_deref().and_then(|uid| {
557            self.record_segment_start(
558                uid,
559                &config,
560                attempted_resume_sid.as_deref(),
561                attempted_resume_sid.as_deref().filter(|_| forked),
562                relocated_copy.is_some(),
563                continuation.is_some(),
564                agent_lease.as_ref().map(|l| l.epoch()),
565            )
566        });
567        *self.current_segment.lock().unwrap() = segment.as_ref().map(|(_, id)| id.clone());
568        let segment_read = segment.clone();
569        let segment_wait = segment;
570        // A relocated resume's copy, removed once the fork reports its id.
571        let relocated_copy_read = relocated_copy.clone();
572        let forked_from_read = attempted_resume_sid.clone().filter(|_| forked);
573        // Defaults to this spawn's own nonce; overridden below if
574        // registration is skipped (reagent P1 on PR #3084 — see the `Err`
575        // arm just below for why `my_registration_nonce` alone is wrong
576        // once a skip is possible).
577        let mut exit_cleanup_nonce = my_registration_nonce;
578        if let Some(ref agent_id) = agent_id_for_muxbus {
579            // `_with_nonce` variants record this spawn's process-wide
580            // registration nonce so this exact spawn's exit-handler can
581            // compare-and-remove its own registrations instead of
582            // blindly wiping a fallback respawn's (or replacement
583            // controller's) fresh ones (issue #2363; codex P1 on PR
584            // #2500 for why not the controller-local generation).
585            // `try_register_agent_with_nonce`, not the plain
586            // `register_agent_with_nonce` — this spawn can be running on the
587            // same thread as an in-flight `inject_message` (the
588            // reactive-delivery fallback's synchronous respawn), and that
589            // call already holds this same handler's lock. The plain
590            // version would re-lock it on that thread and deadlock the
591            // reactive handler process-wide. See
592            // `ReactiveHandler::try_register_agent_with_nonce`'s doc comment
593            // and `docs/incident/INCIDENT_2026_09_07_BACKEND_UPTIME_TIMER_FROZEN.md`.
594            match crate::backend::reactive::get_global_handler()
595                .try_register_agent_full(
596                    agent_id,
597                    &self.block_id,
598                    Some(&self.tab_id),
599                    my_registration_nonce,
600                    // Also park `agent_id` (== AGENTMUX_AGENT_ID here) as the
601                    // block's STABLE name binding, kept when `input.rs`'s
602                    // Register-tail replaces the display binding with the
603                    // live display name on this agent's very first turn —
604                    // otherwise nothing would be left to answer a jekt
605                    // tagged with the stable ID. See
606                    // `INCIDENT_2026_09_09_JEKT_STABLE_ID_ALIAS.md`.
607                    Some(agent_id.as_str()),
608                    // Identity M2: the key. Carried from the env, never
609                    // looked up here — nothing new is read under the
610                    // handler's lock (spec §4.4.4 Q5).
611                    agent_uid_for_registry.as_deref(),
612                    "registration.no_uid.spawn",
613                )
614            {
615                Ok(()) => {
616                    tracing::info!(
617                        block_id = %self.block_id,
618                        agent_id = %agent_id,
619                        "muxbus: auto-registered persistent agent"
620                    );
621                    // Also write the cross-instance (Tier-2) file registry,
622                    // and its host-global sibling (Tier 2b, issue #1916) —
623                    // this auto-register path bypasses the HTTP register
624                    // handler entirely, so it needs its own mirror call
625                    // exactly like that handler does.
626                    if let Ok(local_url) = std::env::var("AGENTMUX_LOCAL_URL") {
627                        let data_dir = crate::backend::base::get_mux_data_dir();
628                        crate::backend::reactive::registry::write_with_nonce(
629                            &data_dir,
630                            agent_id,
631                            &local_url,
632                            &self.block_id,
633                            my_registration_nonce,
634                        );
635                        crate::backend::reactive::registry::write_shared_from_env_with_nonce(
636                            agent_id,
637                            &local_url,
638                            &self.block_id,
639                            my_registration_nonce,
640                        );
641                    }
642                }
643                Err(e) => {
644                    tracing::warn!(
645                        block_id = %self.block_id,
646                        agent_id = %agent_id,
647                        error = %e,
648                        "muxbus: persistent auto-register failed"
649                    );
650                    // reagent P1 on PR #3084: registration was skipped, so
651                    // `my_registration_nonce` was never written to the
652                    // handler. Using it as this spawn's exit-time cleanup
653                    // key would always mismatch whatever nonce IS on
654                    // record, making the exit-handler wrongly conclude "a
655                    // newer respawn already took over" and skip BOTH the
656                    // reactive-handler unregister and the cloud_subscriber
657                    // deregister — leaking both on every skip. Target
658                    // whichever nonce is actually on record instead, so
659                    // this spawn's own eventual exit can still correctly
660                    // clean it up. `registration_nonce == 0` (no nonce on
661                    // record at all) is handled safely by
662                    // `unregister_block_if_nonce` itself — it never
663                    // matches, so this degrades to today's no-cleanup
664                    // behavior rather than a wrong one.
665                    //
666                    // `try_get_agent_by_block`, NOT the plain
667                    // `get_agent_by_block` — this Err arm is reached
668                    // precisely when we might be on the same thread as an
669                    // in-flight `inject_message` (reagent P0 on PR #3084,
670                    // caught after the first attempt used the blocking
671                    // version here and reproduced INCIDENT_2026_09_07's
672                    // exact deadlock). In that reentrant case this
673                    // correctly returns `None` after its own retry budget
674                    // instead of hanging forever; `exit_cleanup_nonce`
675                    // then simply stays at its default, same safe
676                    // no-cleanup degradation as before this fix existed.
677                    if let Some(current) = crate::backend::reactive::get_global_handler()
678                        .try_get_agent_by_block(&self.block_id)
679                    {
680                        exit_cleanup_nonce = current.registration_nonce;
681                    }
682                }
683            }
684        }
685
686        // Record active pid for crash recovery (Phase 4.2). If the server
687        // dies while this subprocess is running, scan_orphans() will find
688        // the stale pid on next boot and flag the session as interrupted.
689        if let Some(ref mstore) = self.mstore {
690            super::super::session_recovery::mark_active_pid(mstore, &self.block_id, pid);
691        }
692
693        // Spawn stdin writer task
694        tokio::spawn(async move {
695            let mut stdin = stdin;
696            let mut continuation = continuation;
697            while let Some(msg) = msg_rx.recv().await {
698                // The user has spoken: no pick-up note. AgentMux's own hidden
699                // memory re-delivery doesn't count, even when the packet
700                // rides on it (#4707).
701                if record_pending.load(std::sync::atomic::Ordering::Acquire)
702                    && crate::backend::continued_note::is_user_message(&msg)
703                {
704                    record_pending.store(false, std::sync::atomic::Ordering::Release);
705                }
706                // Only this write carries the packet; the pane's record keeps
707                // the message as typed. Control responses pass through.
708                let msg = match continuation
709                    .as_deref()
710                    .and_then(|packet| crate::backend::continuity::prefix_user_message(&msg, packet))
711                {
712                    Some(with_packet) => {
713                        continuation = None;
714                        with_packet
715                    }
716                    None => msg,
717                };
718                if let Err(e) = stdin.write_all(msg.as_bytes()).await {
719                    tracing::warn!("persistent stdin write error: {}", e);
720                    break;
721                }
722                if let Err(e) = stdin.write_all(b"\n").await {
723                    tracing::warn!("persistent stdin newline error: {}", e);
724                    break;
725                }
726                if let Err(e) = stdin.flush().await {
727                    tracing::warn!("persistent stdin flush error: {}", e);
728                    break;
729                }
730            }
731            // Channel closed or write error → stdin drops → process gets EOF
732            drop(stdin);
733        });
734
735        // Spawn stdout reader task
736        let block_id_read = self.block_id.clone();
737        let broker_read = self.broker.clone();
738        let inner_read = Arc::clone(&self.inner);
739        let mstore_read = self.mstore.clone();
740        let event_bus_read = self.event_bus.clone();
741        let filestore_read = self.filestore.clone();
742        let health_read = Arc::clone(&self.health_monitor);
743        // Weak, like every other self-reference here: the reader task must not
744        // keep the controller alive. Used only for the deferred
745        // runtime-config restart at turn end (`request_restart_when_idle`).
746        let self_ref_read = self.self_ref.lock().unwrap().clone();
747        let stdout_seq_read = Arc::clone(&self.stdout_seq);
748        let session_id_field = config.session_id_field.clone();
749        let my_generation_read = my_generation;
750        // Resolve the agent's GLOBAL transcript zone (`agent:<defId>:current`)
751        // once, from the block's `agentId` meta, so every `output` line is also
752        // mirrored to the cross-channel store. `None` for non-agent blocks.
753        let global_output_zone =
754            super::super::shell::resolve_global_output_zone(&self.mstore, &self.block_id);
755        // Cloned before `global_output_zone` moves into the stdout-reader
756        // task below — the process-waiter task (spawned further down) needs
757        // its own copy for the segment-end record and to append the resume
758        // state machine's exit-time lines (a held-back error-result line, a
759        // session-outcome event).
760        let global_output_zone_wait = global_output_zone.clone();
761        // Writer-side fence on that shared record
762        // (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24 Phase 3): once this
763        // process has lost the agent's lease, its output still reaches this
764        // pane's own log but no longer the agent's record. `None` when no
765        // lease is held (no store, or no UID) — nothing to fence.
766        let record_fence = agent_lease.as_ref().map(|l| l.record_fence());
767        let record_fence_wait = record_fence.clone();
768
769        // codex P1 on PR #2371: the JoinHandle is kept (not discarded) so the
770        // process-waiter task can await this task's full completion before
771        // resolving the retry decision, mirroring `stderr_reader_handle`
772        // below. `child.wait()` resolving is NOT proof this task has already
773        // read and held back the doomed attempt's terminal error-result line
774        // (`ResumeState`'s `held_error_line`) — without this wait, the
775        // waiter could resolve the resume state (`ProcessExited`) and launch
776        // the retry first, after which this (now-lagging) reader would find
777        // the state already `NotTracking` and persist the error line
778        // immediately, reproducing the exact bubble this PR exists to
779        // suppress.
780        let stdout_reader_handle: tokio::task::JoinHandle<()> = tokio::spawn(Self::run_stdout_reader(StdoutReaderCtx {
781            stdout,
782            block_id_read,
783            broker_read,
784            inner_read,
785            mstore_read,
786            event_bus_read,
787            filestore_read,
788            health_read,
789            self_ref_read,
790            stdout_seq_read,
791            session_id_field,
792            my_generation_read,
793            mcp_owner_read: my_registration_nonce,
794            global_output_zone,
795            record_fence,
796            segment_read,
797            relocated_copy_read,
798            forked_from_read,
799        }));
800
801        self.spawn_status_heartbeat();
802
803        // Spawn process waiter task
804        let block_id_wait = self.block_id.clone();
805        let inner_wait = Arc::clone(&self.inner);
806        let broker_wait = self.broker.clone();
807        let mstore_wait = self.mstore.clone();
808        // Needed to persist a classified failure (rate-limit/overloaded/etc.)
809        // into block meta alongside the MPS publish below — mirrors
810        // `event_bus_read`'s equivalent clone for the stdout-reader task.
811        let event_bus_wait = self.event_bus.clone();
812        let health_wait = Arc::clone(&self.health_monitor);
813        // Needed to append the resume state machine's exit-time lines: a
814        // held-back error-result line when the stale-resume retry is NOT
815        // confirmed (or is overridden by an explicit stop), and the
816        // session-outcome event — see the exit-handler's own comment in
817        // `process_waiter.rs`.
818        let filestore_wait = self.filestore.clone();
819        // Captured so the waiter can deregister this agent from muxbus on exit.
820        let agent_id_wait = agent_id_for_muxbus.clone();
821        // See `set_self_ref` / `retry_after_resume_failure` — lets this
822        // detached task call back into an instance method once the process
823        // actually exits, to transparently retry a stale-`--resume` failure.
824        let self_ref_wait = self.self_ref.lock().unwrap().clone().unwrap_or_default();
825        // This exact spawn's identity — every resume event below carries it,
826        // and `persistent_resume::update()` ignores one whose generation
827        // isn't the tracked one (see that module's doc comment).
828        let my_generation_wait = my_generation;
829        // This exact spawn's OS pid, for the compare-and-clear below — the
830        // unconditional clear could wipe a fallback respawn's fresh
831        // registration (issue #2363, see clear_active_pid_if_pid).
832        let pid_wait = pid;
833        // This spawn's registration identity, for the guarded
834        // muxbus/registry removals below (issue #2363 / codex P1 on PR
835        // #2500 — see `my_registration_nonce`'s own doc comment). NOT
836        // `my_registration_nonce` directly — `exit_cleanup_nonce` is that
837        // same value UNLESS registration was skipped above, in which case
838        // it's whatever nonce actually ended up on record instead (reagent
839        // P1 on PR #3084; see the skip arm's own comment for why using our
840        // own never-written nonce here would leak the registration).
841        let nonce_wait = exit_cleanup_nonce;
842
843        tokio::spawn(Self::run_process_waiter(ProcessWaiterCtx {
844            child,
845            kill_rx,
846            stderr_reader_handle,
847            stdout_reader_handle,
848            segment_wait,
849            global_output_zone_wait,
850            record_fence_wait,
851            block_id_wait,
852            inner_wait,
853            broker_wait,
854            mstore_wait,
855            event_bus_wait,
856            health_wait,
857            filestore_wait,
858            agent_id_wait,
859            self_ref_wait,
860            my_generation_wait,
861            pid_wait,
862            nonce_wait,
863            mcp_owner_wait: my_registration_nonce,
864        }));
865
866        Ok(())
867    }
868}