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(©);
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}