agentmux_srv/backend/blockcontroller/persistent/mod.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! PersistentSubprocessController: manages agent CLI as a long-running process
5//! with bidirectional NDJSON streaming via stdin/stdout.
6//!
7//! Architecture:
8//! A single CLI process is spawned on first message and kept alive for the
9//! entire session. User messages are written as NDJSON lines to stdin without
10//! closing it. This enables mid-turn input (redirecting the agent while it
11//! is still processing).
12//!
13//! State machine:
14//! INIT ─(first message)─> RUNNING ─(idle between turns)─> RUNNING
15//! RUNNING ─(kill/stop)─> DONE
16//! RUNNING ─(process crash)─> DONE (auto-restart possible via session_id)
17//!
18//! I/O model (3 async tasks per session):
19//! 1. stdin_writer: mpsc channel → process stdin (NDJSON lines)
20//! 2. stdout_reader: process stdout → .jsonl persistence + MPS blockfile events
21//! 3. process_waiter: wait for exit, update status
22//!
23//! Module layout (split 2026-09-22; one `impl PersistentSubprocessController`
24//! block per file, all private methods `pub(super)`):
25//! - `mod.rs` — types, shared helpers, construction, `Controller` impl
26//! - `status.rs` — runtime status snapshot/publication and heartbeat
27//! - `eager_resume.rs` — spawn-on-construction when a session id is on file
28//! - `queue.rs` — `send_message`, spawn claim, queue drain/replay
29//! - `resume_retry.rs` — stale-`--resume` recovery effects and retry batch
30//! - `input.rs` — user messages, question answers, tool decisions, stdin
31//! - `spawn.rs` — `spawn_process`: builds the command, spawns, wires the tasks
32//! - `stderr_reader.rs`, `stdout_reader.rs`, `process_waiter.rs`
33//! — the bodies of those per-session I/O tasks
34//! - `lifecycle.rs` — stop/restart, `session_id`, `needs_spawn`
35//! - `tests/` — one file per former inline test module
36
37use std::collections::{HashMap, VecDeque};
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::sync::{Arc, Mutex};
40
41use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
42use tokio::sync::mpsc;
43
44use super::{
45 BlockControllerRuntimeStatus, BlockInputUnion, Controller, SendOutcome, STATUS_DONE,
46 STATUS_INIT, STATUS_RUNNING,
47};
48use super::core;
49use super::health::TurnActivityTracker;
50use super::persistent_resume;
51use crate::backend::eventbus::EventBus;
52use crate::backend::storage::filestore::FileStore;
53use crate::backend::storage::store::Store;
54use crate::backend::subagent_watcher;
55use crate::backend::mps;
56
57/// MPS file subject name for persistent subprocess output.
58pub const PERSISTENT_OUTPUT_SUBJECT: &str = "output";
59
60pub const BLOCK_CONTROLLER_PERSISTENT: &str = "persistent";
61
62/// The one spawn refusal that is a *decision*, not a failure: `--resume
63/// <sid>` was asked for while another pane still holds (or is still
64/// closing) that same conversation — `session_held_elsewhere`. Built here
65/// so that `is_held_elsewhere_error` is checked against the same text
66/// `spawn_process` actually returns, rather than a second copy that could
67/// drift; `tests/reopen_guard.rs` pins the wording users see.
68pub(super) fn held_elsewhere_error(other: &str, closing: bool) -> String {
69 if closing {
70 format!("This conversation's previous process (block {other}) is still shutting down. Try again in a few seconds.")
71 } else {
72 format!(
73 "This conversation is already open in another pane (block {other}). Close it there, or switch to it, instead of opening a second copy."
74 )
75 }
76}
77
78/// Did `spawn_process` refuse because of `session_held_elsewhere`? The
79/// answer decides whether a caller may fall back to a FRESH spawn: for any
80/// other failure a blank session is a reasonable recovery, but for this one
81/// it silently hands the user's accepted prompt to a new conversation
82/// while the real one is open next door — exactly what the guard exists to
83/// prevent (codex P1 on PR #3551).
84pub(super) fn is_held_elsewhere_error(err: &str) -> bool {
85 err.starts_with("This conversation's previous process (block ")
86 || err.starts_with("This conversation is already open in another pane (block ")
87}
88
89/// What the process-waiter task's kill arm is asked to do.
90#[derive(Debug, Clone, Copy)]
91enum KillRequest {
92 /// Kill now.
93 Force,
94 /// Close stdin (EOF), wait for the process to exit until the deadline,
95 /// then kill.
96 Graceful(std::time::Instant),
97}
98
99/// The Claude control-protocol interrupt
100/// (docs/specs/SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md). Ends the turn in
101/// progress — measured at ~2s, with `result: error_during_execution` — while
102/// leaving the process and session alive (pane-close spec §5.1).
103fn interrupt_control_request_line() -> String {
104 serde_json::json!({
105 "type": "control_request",
106 "request_id": format!("agentmux-shutdown-{}", uuid::Uuid::new_v4()),
107 "request": { "subtype": "interrupt" },
108 })
109 .to_string()
110}
111
112/// Draws the next process-wide registration nonce (≥ 1) for a persistent
113/// spawn's muxbus/registry registrations — see the doc comment at the
114/// `my_registration_nonce` binding in `spawn_process` for why this is a
115/// srv-wide counter rather than the controller-local spawn generation
116/// (codex P1 on PR #2500: generations restart per controller instance
117/// and can collide across a `resync_controller` replacement).
118fn next_registration_nonce() -> u64 {
119 static NEXT_REGISTRATION_NONCE: AtomicU64 = AtomicU64::new(0);
120 NEXT_REGISTRATION_NONCE.fetch_add(1, Ordering::Relaxed) + 1
121}
122
123/// Builds the NDJSON line for a `persistent_resume::ResumeEffect::
124/// EmitSessionOutcome` — a free function (not a method) so it's callable
125/// directly from the stdout-reader, process-waiter, and stop-path match
126/// arms below, which only hold `_read`/`_wait`-suffixed clones, not
127/// `&self`. See SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md §2.1.
128fn session_outcome_line(
129 outcome: persistent_resume::SessionOutcome,
130 attempted_sid: String,
131 actual_sid: Option<String>,
132) -> String {
133 session_outcome_line_with(outcome, attempted_sid, actual_sid, false)
134}
135
136/// [`session_outcome_line`], plus whether the fresh session was given
137/// AgentMux's record of the conversation (`continued`, SPEC_DURABLE_
138/// CONVERSATION_MEMORY_2026_09_23.md §4.8). The outcome stays `fresh`: the
139/// provider session is new, and every consumer that scopes scrollback on
140/// `fresh` keeps doing so. Only the pane's label changes.
141fn session_outcome_line_with(
142 outcome: persistent_resume::SessionOutcome,
143 attempted_sid: String,
144 actual_sid: Option<String>,
145 continued: bool,
146) -> String {
147 let outcome_str = match outcome {
148 persistent_resume::SessionOutcome::Resumed => "resumed",
149 persistent_resume::SessionOutcome::Fresh => "fresh",
150 };
151 format!(
152 "{}\n",
153 serde_json::json!({
154 "type": "system",
155 "subtype": "agentmux_session_outcome",
156 "outcome": outcome_str,
157 "attempted_sid": attempted_sid,
158 "actual_sid": actual_sid,
159 "continued": continued,
160 "timestamp": chrono::Utc::now().to_rfc3339(),
161 })
162 )
163}
164
165/// Should a spawn that attached NO `--resume` disclose itself as a fresh
166/// start (subject to the caller also confirming prior history actually
167/// exists — see `has_prior_transcript`)?
168///
169/// SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md §2.1 deliberately left
170/// this case unreported, reasoning that a spawn with no session id has
171/// "nothing to lose". That's true for a brand-new agent and false for the
172/// case `docs/status/STATUS_CROSS_CHANNEL_RESUME_STALE_SESSION_ID_2026_08_20.md`
173/// recorded: a long-lived named agent opened in a fresh channel whose shared
174/// registry pointer is empty gets no `--resume` at all, so the CLI never
175/// errors, `persistent_resume` never tracks anything, and no outcome is ever
176/// emitted — while the pane renders the entire prior conversation through
177/// `blockfile.rs`'s cross-channel read fallback. That is exactly the silent
178/// disagreement between "what the pane shows" and "what the model has" that
179/// `EmitSessionOutcome` exists to prevent.
180///
181/// `generation == 1` restricts this to a controller's FIRST spawn. Later
182/// generations also spawn without `--resume`, but each already has its own
183/// disclosure or deliberately has none: `retry_after_resume_failure`'s
184/// no-recovery-candidate path emits `Fresh` itself, and
185/// `respawn_once_for_leftover_queue` restarts a session whose fresh-vs-resumed
186/// status was decided on an earlier generation. Only a first spawn can be the
187/// "pane just opened onto history this process never had" case.
188///
189/// Kept a pure free function (the FileStore lookup stays at the call site) so
190/// the gate is unit-testable without a controller or a spawned process.
191fn fresh_start_needs_disclosure(attempted_resume_sid: Option<&str>, generation: u64) -> bool {
192 attempted_resume_sid.is_none() && generation == 1
193}
194
195/// How much of a transcript's tail `find_continuation_session_id` reads. The
196/// provider stamps its session id on every stream event, so the last few
197/// events suffice; a long-lived agent's transcript runs to many MB.
198const CONTINUATION_TAIL_BYTES: i64 = 256 * 1024;
199
200/// The last non-empty `field` on a complete JSON line of `tail`.
201/// `starts_mid_line`: `tail` was cut from a longer file, so its first line is
202/// a fragment and is dropped.
203fn last_session_id_in_stream(tail: &[u8], starts_mid_line: bool, field: &str) -> Option<String> {
204 let text = String::from_utf8_lossy(tail);
205 let skip = usize::from(starts_mid_line);
206 let lines: Vec<&str> = text.split('\n').skip(skip).collect();
207 lines.into_iter().rev().find_map(|line| {
208 let v: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
209 let sid = v.get(field)?.as_str()?.trim();
210 (!sid.is_empty()).then(|| sid.to_string())
211 })
212}
213
214/// Publish a `mps::EVENT_AGENT_RESUME_RETRY` status ping — a free function
215/// (not a method) for the same reason `session_outcome_line` above is one:
216/// callable from the stdout-reader/process-waiter match arms, which only
217/// hold `_read`/`_wait`-suffixed clones, not `&self`. `status` is `"retrying"`
218/// (set when a stale `--resume` is detected and a retry/recovery attempt is
219/// about to fire) or `"resolved"` (set the moment the retry's outcome —
220/// Fresh or Resumed — is actually known). See
221/// `docs/status/STATUS_STALE_RESUME_LIVE_REPRO_AND_FIX_PLAN_2026_08_23.md` §6.2.
222/// No-ops if `broker` is `None` (tests / non-wired controllers), same
223/// posture as every other best-effort MPS publish in this file.
224fn publish_resume_retry_status(broker: &Option<Arc<mps::Broker>>, block_id: &str, status: &str) {
225 let Some(broker) = broker else { return };
226 let payload = crate::backend::mps_events::ResumeRetryEvent {
227 status: status.to_string(),
228 started_at: (status == "retrying").then(|| chrono::Utc::now().to_rfc3339()),
229 };
230 broker.publish_event::<crate::backend::mps_events::AgentResumeRetry>(
231 &payload,
232 vec![format!("block:{}", block_id)],
233 // `persist: 2`, not 1 — `Broker::persist_event` trims history down
234 // to exactly this many MOST RECENT events per (event, scope): a
235 // bare `persist: 1` would let the "resolved" publish immediately
236 // evict "retrying" from history, so a pane that (re)subscribes in
237 // the narrow window right after resolution sees only "resolved"
238 // with no record a retry ever happened — harmless for the simple
239 // "currently reconnecting?" check, but makes this event's own
240 // history useless for anything else. 2 keeps the latest
241 // retrying→resolved pair together; a later cascaded "retrying"
242 // still correctly evicts the OLDER pair's "retrying", not this
243 // one's "resolved".
244 2,
245 );
246}
247
248/// Resolve the muxbus address (the agent's display name) from a spawn env map.
249/// `AGENTMUX_AGENT_ID` (= `agent.name`, set at block creation) is canonical;
250/// `WAVEMUX_AGENT_ID` is the legacy fallback. Returns `None` — i.e. not
251/// muxbus-addressable — when neither is present (a non-agent persistent block).
252fn muxbus_agent_id_from_env(env: &HashMap<String, String>) -> Option<String> {
253 super::agent_id_from_env(|key| env.get(key).map(String::as_str))
254}
255
256/// Configuration for spawning the persistent process.
257/// `PartialEq` is derived for `persistent_resume::RetryPayload`'s own
258/// derive and its exhaustive unit tests — not used elsewhere in this
259/// file itself.
260#[derive(Debug, Clone, PartialEq)]
261pub struct PersistentSpawnConfig {
262 pub cli_command: String,
263 pub cli_args: Vec<String>,
264 pub working_dir: String,
265 pub env_vars: HashMap<String, String>,
266 pub session_id_field: String,
267 /// Resume flag for this provider (e.g. "--resume"), read from
268 /// `agent:resume_flag` meta. Empty = provider has no simple-flag resume.
269 /// Mirrors `SubprocessSpawnConfig::resume_flag` so a respawn (after a
270 /// runtime/model change or the picker reattach path) continues the same
271 /// conversation instead of starting fresh.
272 pub resume_flag: String,
273 /// Session id to hydrate `inner.session_id` with BEFORE spawning, when the
274 /// controller hasn't captured one yet (fresh controller after a forced
275 /// restart, or picker reattach). Read from `agent:sessionid` meta. With a
276 /// non-empty `resume_flag` this makes `--resume <sid>` land on the respawn.
277 pub session_id: String,
278 /// Echoed back as `agent-message-accepted` so the frontend can promote the
279 /// pending entry. Matches `CommandAgentInputData.message_id` on the AgentInput
280 /// command; absent for legacy callers.
281 pub message_id: Option<String>,
282}
283
284/// Inner state protected by mutex.
285struct PersistentInner {
286 proc_status: String,
287 proc_exit_code: i32,
288 status_version: i32,
289 session_id: Option<String>,
290 /// A `--resume` id the CLI has confirmed (via stderr) it can't find under
291 /// the current config dir. The stdout reader echoes back whatever
292 /// `--resume` value it was given as its first line REGARDLESS of whether
293 /// resume actually succeeds, racing the stderr reader's own clear of
294 /// `session_id` — this stops that race from re-adopting a known-dead id.
295 /// Never reset back to `None`: a genuinely fresh session id (a new CLI-
296 /// generated UUID) will never equal this one, so a stale poison value
297 /// is permanently inert rather than something that needs clearing.
298 resume_poisoned: Option<String>,
299 /// The copy the resume gate relocated for the next spawn to
300 /// `--resume … --fork-session` from
301 /// (SPEC_RESUME_GATE_AND_SAME_IDENTITY_CONTINUATION_2026_09_25.md §4.3).
302 /// Taken by that spawn; removed if it ends up resuming nothing.
303 fork_copy: Option<crate::backend::continuity_relocate::Relocated>,
304 /// The next spawn resumes with `--fork-session` (a relocation, or a
305 /// session that grew outside AgentMux, spec §4.4). Taken by that spawn.
306 fork_next: bool,
307 /// Set when a forced controller resync (a `/model`, `/effort` or
308 /// `/permission-mode` change — see `frontend/.../runtime-apply.ts`) lands
309 /// while a turn is in flight. The restart is DEFERRED to the end of that
310 /// turn instead of killing the process mid-turn.
311 ///
312 /// Killing it was the old behaviour and it silently destroyed the user's
313 /// in-flight message: `stop_process` records a `StopRequested`, which the
314 /// resume state machine treats as an explicit user stop and therefore
315 /// suppresses the retry; `remove_controller_entry_only` then discards the
316 /// controller (and its queue) outright; and the replacement controller
317 /// "spawns on first message", so nothing resumed. The message had already
318 /// been written to the dead process's stdin, so no queue entry survived to
319 /// replay. Net effect: the turn vanished with no response and no error —
320 /// the pane simply went quiet. Diagnosed live on AgentX, 2026-08-28.
321 ///
322 /// Deferring is correct rather than merely safer: model/effort flags are
323 /// baked in at spawn, so they could not have applied to the running turn
324 /// anyway. Waiting until it ends costs nothing and loses nothing.
325 restart_when_idle: bool,
326 /// A deferred restart has COMMITTED — `stop_process` has been called and
327 /// the process is on its way out, but `stdin_tx` is still live because
328 /// `stop_process` only sends on `kill_tx` and leaves the writer channel
329 /// alone until the process actually exits.
330 ///
331 /// Without this, `decide_send_action` would see a live `stdin_tx` and take
332 /// `DeliverDirect` for any message arriving in that window — writing it
333 /// into a process about to receive EOF, acknowledging it, and losing it
334 /// (codex P1 on PR #2858). The window is not hypothetical: the restart
335 /// fires exactly at turn end, which is precisely when the frontend flushes
336 /// its queued follow-ups.
337 ///
338 /// Set under the same lock that consumes `restart_when_idle`, cleared when
339 /// the replacement process spawns.
340 restart_pending: bool,
341 /// The runtime flags (model, effort, permission) in the argv of the process
342 /// that is running now; `None` when no process is. What the menu compares
343 /// its selection against: the menu shows what was asked for, this is what
344 /// the process was given. Set at spawn, cleared when the status leaves
345 /// "running" (`set_status`).
346 spawn_runtime: Option<crate::backend::agent_runtime::SpawnRuntime>,
347 /// What the running CLI itself reported using (`get_settings`'s `applied`):
348 /// the alias resolved to a concrete model, and the effort in force. `None`
349 /// until it answers, for a CLI that cannot, and when no process runs.
350 effective_runtime: Option<crate::backend::agent_runtime::EffectiveRuntime>,
351 /// Whether the running process takes control requests, so it is asked
352 /// `get_settings` and `get_context_usage` (set at spawn: a
353 /// control-protocol launch).
354 settings_readback: bool,
355 /// The last `get_context_usage` answer published for the current process,
356 /// so an unchanged answer at each turn boundary isn't re-broadcast.
357 /// Cleared at spawn.
358 context_usage: Option<crate::backend::agent_context_usage::AgentContextUsage>,
359 /// A kill has been requested for the current process (`request_stop_on`).
360 /// `stdin_tx` stays live until it actually exits. Writing an automated
361 /// message then would put it into the dying process and lose it (codex
362 /// P2 on #3562 for `stop`, and the same window on the Stop button).
363 /// `try_write_stdin_locked` refuses while this is set, so the message
364 /// stays queued for the next process.
365 ///
366 /// Set under the same lock as the kill request, cleared when the
367 /// replacement process spawns, exactly like `restart_pending`.
368 stop_pending: bool,
369 /// The spawn generation a deferred runtime-config restart is replacing:
370 /// set by `turn_boundary_locked` in the same acquisition that commits the
371 /// restart, so the old process's exit handler knows to bring the
372 /// replacement up itself once the process is gone.
373 ///
374 /// Without it the pane was left with no process until a human typed into
375 /// it: automated delivery (`send_user_message`) never spawns, and it waits
376 /// while `restart_pending` is set, so jekts queued forever (Korp on narko,
377 /// 2026-09-29; `docs/retro/RETRO_DEFERRED_RESTART_NEVER_RESPAWNS_2026_09_29.md`).
378 /// A restart is also a stop (`stop_pending`, `StopRequested`), so neither
379 /// of those can tell it apart from a user's Stop; this can. Every stop
380 /// request except the restart's own clears it, and nothing but the
381 /// boundary sets it, so an explicit Stop or a pane close landing anywhere
382 /// in the window is never undone by a respawn (#3990). It is consumed
383 /// only in the acquisition that installs the replacement's freshly
384 /// started child as the live process (`commit_restart_spawn_locked`).
385 config_restart_generation: Option<u64>,
386 /// The spawn claimed for a config restart's replacement, by generation
387 /// (`try_claim_eager_resume_spawn_for`). `spawn_process` re-checks the
388 /// restart token against it right before starting the child, so a Stop
389 /// during the (possibly slow) credential gate still cancels it.
390 restart_spawn_for: Option<u64>,
391 /// This spawn generation's stale-`--resume` retry decision, plus any
392 /// held-back terminal error-result line — see
393 /// `persistent_resume::ResumeState`'s own doc comment for the full
394 /// design rationale. Replaces what used to be four separate fields
395 /// (`pending_resume_retry`, `confirmed_stale_resume_retry`,
396 /// `stop_requested_generation`, `pending_error_result_line`) mutated
397 /// directly by four independently-scheduled tasks racing on this same
398 /// mutex — that shape caused issue #2368 (and a live-reproduced
399 /// recurrence, agent "Marks", 2026-07-30) because no single owner
400 /// enforced valid transitions between them. Every mutation now goes
401 /// through `persistent_resume::update()`, a pure, exhaustively unit
402 /// tested `(state, event) -> (state, effects)` function — still
403 /// called under this same mutex (no new concurrency primitive is
404 /// introduced), but as ONE call per event instead of several separate
405 /// field reads/writes that could observe each other mid-transition.
406 resume: persistent_resume::ResumeState,
407 /// True from the moment a caller commits to calling `spawn_process`
408 /// (in `send_message` or `retry_after_resume_failure`) until it has
409 /// delivered every message enqueued for that spawn — see
410 /// `pending_send_messages`. Checked and set together with
411 /// `stdin_tx.is_some()` under this same lock, in one acquisition —
412 /// reagentx P1 on PR #2360 (sixth review pass): `send_message`'s
413 /// `is_running()` check and its `spawn_process()` call used to be two
414 /// separate operations; a second concurrent `send_message` call (a
415 /// genuine second RPC, or a muxbus delivery) landing in the gap
416 /// between them could ALSO observe "not running" and independently
417 /// spawn a second child process, orphaning one (leaked, unkillable via
418 /// `stop_process`, unregistered from muxbus). Whichever caller sees
419 /// `stdin_tx.is_none() && !spawning_in_progress` first becomes the
420 /// sole spawner for this round; every other caller queues instead of
421 /// racing its own spawn.
422 ///
423 /// This also fully subsumes the earlier `spawn_epoch`/
424 /// `should_skip_own_delivery` mechanism (rounds 3-5 of this same PR):
425 /// that check existed only to catch a delivery landing in the window
426 /// between a caller's own `spawn_process` returning and its own
427 /// tail-end stdin write — a window that no longer exists, since
428 /// delivery now happens as part of the same atomic spawn-claim, before
429 /// the lock is released (see `release_spawn_claim_and_drain_queue`).
430 spawning_in_progress: bool,
431 /// Messages queued while `spawning_in_progress` was `true` — includes
432 /// the enqueuing caller's own message when it became the spawner (see
433 /// `SendAction::BecomeSpawner`), so the post-spawn drain uses one
434 /// uniform delivery path regardless of whether a message triggered the
435 /// spawn or arrived while someone else's spawn was already in flight.
436 /// Drained by `release_spawn_claim_and_drain_queue`.
437 pending_send_messages: VecDeque<QueuedMessage>,
438 /// Non-human messages (jekt, muxbus, bridges, MCP `SendMessage`) that
439 /// arrived while the process could not take a write — a spawn in flight,
440 /// a committed restart, a requested stop, or another writer owning stdin
441 /// (`deferred_must_wait_locked`). Written, in order, by the watchdog once
442 /// it can. A live process that can take a write gets the message at once,
443 /// mid-turn or not (`SPEC_JEKT_IMMEDIATE_DELIVERY_2026_09_28.md` §2.1),
444 /// so nothing waits here for a turn to end.
445 ///
446 /// Distinct from `pending_send_messages`, the human path's queue around a
447 /// spawn. Entries are the fully encoded stream-json stdin lines, ready to
448 /// write. Read and written only under this mutex, together with the
449 /// must-wait state it depends on, so a message is never enqueued just as
450 /// a drain observes the queue empty and exits.
451 deferred_deliveries: VecDeque<String>,
452 /// Whether a deferred-delivery watchdog task is running for this
453 /// controller — see `ensure_deferred_watchdog`. Set and cleared only
454 /// under this lock, and cleared only by the watchdog itself when it
455 /// finds the queue empty, so an enqueue can never observe "armed" from a
456 /// watchdog that has already decided to exit.
457 deferred_watchdog_armed: bool,
458 /// Exclusive claim held by a stale-resume retry batch flush (issue
459 /// #2367; spec §4 option 2 of
460 /// SPEC_PERSISTENT_SPAWN_GENERATION_AND_MESSAGE_IDENTITY_2026_08_09).
461 /// Taken by `decide_retry_batch_action` in the SAME lock acquisition
462 /// in which it decides a live, newer-generation process should
463 /// receive the batch; while held, `decide_send_action`'s
464 /// `DeliverDirect` branch routes to `Queued` instead (exactly how it
465 /// already treats `spawning_in_progress`), making the queue the
466 /// single ordering authority — a concurrent send can no longer
467 /// `try_send` ahead of earlier-accepted retry messages. Released by
468 /// the flush task (`drain_queue_with_claim`) once the queue runs dry
469 /// or the process dies. Deliberately a NEW flag, not a reuse of
470 /// `spawning_in_progress` — round-14's starvation analysis stands:
471 /// that flag is never pre-asserted while no spawn is in progress.
472 drain_claim: bool,
473 /// Monotonic counter behind `QueuedMessage::seq` (issue #2365) —
474 /// pre-incremented at every fresh enqueue, so real seqs start at 1
475 /// and are never reused for this controller's lifetime. Redelivery
476 /// paths (a stale-resume retry batch) preserve a message's original
477 /// seq instead of drawing a new one — that preservation is what
478 /// makes "already queued?" an identity check rather than a content
479 /// comparison.
480 next_message_seq: u64,
481 /// True while the drain (`drain_queue_after_successful_spawn`) is
482 /// between successfully sending a message on the live stdin channel
483 /// and finishing its OWN follow-up append of that message into the
484 /// stale-`--resume` retry batch held in `resume`
485 /// (`ResumeEvent::MessageAppendedToRetryBatch`) — see the drain's own
486 /// comment at that append for why it exists (#2360): the
487 /// `Sender::send().await` and that follow-up append are two separate
488 /// lock acquisitions with an unavoidable gap between them (a mutex
489 /// can't be held across an `.await`). Without this flag, the
490 /// process-waiter's own exit-handling — running concurrently on a
491 /// DIFFERENT task — could resolve the retry batch
492 /// (`ResumeEvent::ProcessExited`) in that exact gap, dispatching a
493 /// retry that's missing a message the doomed
494 /// process's channel had ALREADY accepted: the message stays marked
495 /// "accepted" and gets persisted, but is never actually delivered to
496 /// any process again. The exit-handling waits (briefly, bounded) for
497 /// this to go false before deciding the retry batch is final — see
498 /// its own comment at that wait loop.
499 drain_send_in_flight: bool,
500 current_pid: Option<u32>,
501 /// Channel to send messages to the stdin writer task.
502 stdin_tx: Option<mpsc::Sender<String>>,
503 /// Handle to kill the process.
504 kill_tx: Option<tokio::sync::oneshot::Sender<KillRequest>>,
505 /// Set by [`Controller::shutdown`] to the spawn generation it is closing,
506 /// BEFORE it interrupts the turn. The interrupted turn ends in an
507 /// `is_error: true` result (`error_during_execution`) that is the stop we
508 /// asked for, not a failure — the stdout reader must not classify it,
509 /// raise a failure banner, or feed it to the stale-`--resume` retry
510 /// machine (spec §9.4).
511 shutdown_generation: Option<u64>,
512 /// Set by the kill arm the moment a requested stop's process has exited:
513 /// `(spawn generation, killed)`. `killed` = it had to be force-killed
514 /// (graceful deadline passed, or a forced stop). Watched by `shutdown`,
515 /// which must not wait on the kill arm's later cleanup (that can take
516 /// seconds when a descendant holds stdout open).
517 stop_exit: Option<(u64, bool)>,
518 /// Monotonic counter bumped once per `spawn_process` call (in the same
519 /// lock acquisition that reports `SpawnedWithResume`/`SpawnedFresh` to
520 /// `resume`), uniquely identifying that one spawn attempt for the rest
521 /// of this controller instance's lifetime. Note this is NOT the
522 /// `spawn_epoch`/`should_skip_own_delivery` mechanism removed earlier
523 /// (see `spawning_in_progress`'s own doc comment) — that existed to
524 /// dedup a spawn-claim race, a job `spawning_in_progress` now fully
525 /// owns. This counter exists for a different, narrower purpose: giving
526 /// every `ResumeEvent` (and the per-spawn tokens above, such as
527 /// `config_restart_generation`) something stable to compare against.
528 ///
529 /// ALSO bumped (without a spawn) by
530 /// `clear_session_id_for_fresh_spawn` to atomically retire every
531 /// existing generation's reader tasks alongside a session-id clear —
532 /// see its doc comment (codex P1 on PR #2500, second round). The
533 /// resulting numbering gap is deliberate and inert: generations are
534 /// only ever compared for equality; the invariant is "never reused,"
535 /// not "no gaps."
536 spawn_generation: u64,
537 /// A recovery candidate adopted by `settle_empty_resume_retry` while
538 /// accepted prompts were still queued behind the eager spawn claim
539 /// (codex P1 on PR #3551, fifth round). Those prompts were never
540 /// delivered to the doomed process, so they are not in the retry batch
541 /// — the eager path's own drain will find the dead `stdin_tx`, stall,
542 /// and hand them to `respawn_once_for_leftover_queue`, whose contract
543 /// is otherwise "spawn fresh". This tells that respawn to `--resume`
544 /// the candidate instead, so a recoverable session is not thrown away
545 /// for a prompt that happened to arrive during the eager attempt.
546 /// Consumed by that respawn; invalidated by any spawn (`spawn_process`
547 /// clears it under the generation bump), since a candidate only means
548 /// anything for the spawn that immediately follows its adoption.
549 leftover_resume_candidate: Option<String>,
550 /// AskUserQuestion `can_use_tool` control_requests awaiting a user answer:
551 /// `tool_use_id -> (request_id, questions JSON)`. Filled by the stdout
552 /// reader when the CLI sends a `can_use_tool` control_request for
553 /// AskUserQuestion; consumed by `answer_question` to build the matching
554 /// `control_response`. Spec: docs/specs/SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md.
555 pending_questions: HashMap<String, (String, serde_json::Value)>,
556 /// Ordinary (non-AskUserQuestion) tool-permission `can_use_tool`
557 /// control_requests awaiting a user decision: `tool_use_id ->
558 /// (request_id, tool_name, input JSON)`. Filled by
559 /// `park_tool_permission_request` when `should_route_to_decision_panel`
560 /// says so; consumed by `decide_tool_permission` to build the matching
561 /// `control_response`. Structurally the sibling of `pending_questions`
562 /// above — same in-memory-only, per-controller-instance lifetime — but
563 /// currently always empty in production: `should_route_to_decision_panel`
564 /// is hardcoded `false` pending the Phase 2 policy decision
565 /// (SPEC_DECISION_PROMPT_2026_04_24.md, SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md §5).
566 pending_permissions: HashMap<String, (String, String, serde_json::Value)>,
567 /// The single-live-instance lease the current CLI process holds
568 /// (`backend::agent_admission`, SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24
569 /// Phase 1). Set by `spawn_process` once the child is running; cleared —
570 /// which releases it — by the process-exit arms for the current
571 /// generation. An `Arc` so the pre-turn fence can verify it without
572 /// holding this lock across file I/O.
573 agent_lease: Option<Arc<crate::backend::agent_admission::HeldAgentLease>>,
574}
575
576impl PersistentInner {
577 /// Draws the next message seq (issue #2365) — pre-incremented, so
578 /// real seqs start at 1 and 0 can serve as "never a valid seq" in
579 /// tests. Must be called under the same lock acquisition as the
580 /// enqueue it identifies.
581 fn take_next_message_seq(&mut self) -> u64 {
582 self.next_message_seq += 1;
583 self.next_message_seq
584 }
585
586 /// Applies one `persistent_resume::ResumeEvent` to `self.resume`,
587 /// storing the resulting state back and returning the effects for
588 /// the caller to execute. The one and only place `self.resume` is
589 /// ever mutated — see `persistent_resume`'s module doc comment for
590 /// why routing every transition through this single pure function
591 /// (instead of several fields each caller used to read/write
592 /// directly) closes the exact race class issue #2368 kept
593 /// resurfacing.
594 fn apply_resume_event(
595 &mut self,
596 event: persistent_resume::ResumeEvent,
597 ) -> Vec<persistent_resume::ResumeEffect> {
598 let (new_state, effects) = persistent_resume::update(std::mem::take(&mut self.resume), event);
599 self.resume = new_state;
600 effects
601 }
602
603 /// Records `bad_sid` as confirmed-unreachable (the CLI reported "No
604 /// conversation found" for it) and clears it from `session_id` if it's
605 /// currently held there. Pairs with `try_capture_session_id` below —
606 /// whichever of the stderr/stdout reader tasks runs first, the
607 /// poisoned id never survives as the live `session_id`. `generation`
608 /// is the spawn attempt this poison applies to — see
609 /// `persistent_resume::ResumeEvent`'s own doc comment for why every
610 /// event carries one.
611 fn poison_resume(&mut self, bad_sid: &str, generation: u64) {
612 self.resume_poisoned = Some(bad_sid.to_string());
613 if self.session_id.as_deref() == Some(bad_sid) {
614 self.session_id = None;
615 }
616 // Promotion to a confirmed retry (only if `bad_sid` matches this
617 // generation's actual attempted sid — see
618 // `persistent_resume::update`'s `ResumeUnreachable` handling) now
619 // happens inside `update()` itself.
620 self.apply_resume_event(persistent_resume::ResumeEvent::ResumeUnreachable {
621 generation,
622 sid: bad_sid.to_string(),
623 });
624 }
625
626 /// Attempts to adopt `sid` as the live session id. Returns `false`
627 /// (does not adopt) if a session id is already held, or if `sid` is
628 /// the confirmed-poisoned id from a prior `poison_resume` call — the
629 /// CLI echoes back whatever `--resume` it was given as its first
630 /// stdout line even when that resume goes on to fail, so without this
631 /// check a losing race would silently re-adopt a known-dead id right
632 /// after `poison_resume` cleared it. A genuinely different (fresh)
633 /// sid is unaffected and still captured normally. `generation` is the
634 /// spawn attempt this capture applies to.
635 ///
636 /// codex P1 on PR #2371: resolves the tentative/confirmed retry
637 /// tracking whenever `sid` is confirmed genuine — NOT only on the
638 /// `adopted` (session_id was previously `None`) branch. A
639 /// `--resume <sid>` spawn ALWAYS has `session_id` already `Some`
640 /// BEFORE the process even starts (that's what makes `--resume` get
641 /// attached at all — see `spawn_process`), so on the common
642 /// resume-SUCCEEDED case the CLI's echoed sid matches what's already
643 /// held and this always used to return `false` immediately, WITHOUT
644 /// ever resolving tracking. Since persistent mode never exits between
645 /// turns, that would leave a generation's resume state live for the
646 /// rest of this potentially long-lived process's life, wrongly
647 /// holding back every LATER, completely unrelated `is_error:true`
648 /// result as if it might still need to be dropped for a stale-resume
649 /// retry.
650 ///
651 /// reagentx P0 on PR #2371: `is_confirmed_success` (true only for a
652 /// terminal `result` with `is_error:false`) is threaded through to
653 /// `persistent_resume::update`, which is what ACTUALLY decides
654 /// whether this capture is unambiguous enough to resolve tracking —
655 /// see `ResumeEvent::SessionCaptured`'s own doc comment for why a
656 /// same-sid echo alone (e.g. an early "system"/init frame) is NOT
657 /// proof of genuine progress: the CLI echoes the attempted sid
658 /// regardless of whether the resume goes on to fail.
659 ///
660 /// reagentx P0 on PR #2373: returns the effects `apply_resume_event`
661 /// produced, not just the `adopted` bool — resolving tracking here
662 /// can legitimately emit `FlushErrorLine` (an EARLIER turn on this
663 /// same still-alive generation held an error line back before this
664 /// LATER capture resolved tracking — see `SessionCaptured`'s own
665 /// handling in `persistent_resume::update`). Discarding that
666 /// silently lost the held-back line instead of flushing it,
667 /// reproducing the exact #2368 bug class this PR exists to fix. The
668 /// caller (the stdout reader) is responsible for actually executing
669 /// it, same as every other `ResumeEffect` this module produces.
670 fn try_capture_session_id(
671 &mut self,
672 sid: &str,
673 generation: u64,
674 is_confirmed_success: bool,
675 ) -> (bool, Vec<persistent_resume::ResumeEffect>) {
676 if self.resume_poisoned.as_deref() == Some(sid) {
677 return (false, vec![]);
678 }
679 // reagentx P1 (round 4 on this PR): `adopted` used to be gated
680 // solely on `session_id.is_none()` — but a `--resume <sid>` spawn
681 // ALWAYS hydrates `session_id` to the attempted sid BEFORE the
682 // process even starts (`spawn_process`), so that check can never
683 // be true for a resume attempt. When the CLI genuinely gives up
684 // on `--resume` internally and starts a fresh conversation with
685 // a DIFFERENT sid, `session_id` was left stuck on the stale
686 // attempted one — the controller believed it was still talking
687 // to the old conversation while the live process had moved on.
688 // `adopted` is now true whenever the captured sid actually
689 // differs from whatever `session_id` currently holds AND either
690 // nothing was captured yet at all, or this exact call is what
691 // resolves resume tracking (checked via the state transition,
692 // since that's the same unambiguous decision `update()` itself
693 // already makes for `SessionCaptured`).
694 let sid_is_new = self.session_id.as_deref() != Some(sid);
695 let session_id_was_none = self.session_id.is_none();
696 let was_tracking = !matches!(self.resume, persistent_resume::ResumeState::NotTracking { .. });
697 let effects = self.apply_resume_event(persistent_resume::ResumeEvent::SessionCaptured {
698 generation,
699 sid: sid.to_string(),
700 is_confirmed_success,
701 });
702 let just_resolved_tracking = was_tracking && matches!(self.resume, persistent_resume::ResumeState::NotTracking { .. });
703 // Ambient adoption is gated on generation currency (issue #2366):
704 // the state machine already drops a stale generation's
705 // `SessionCaptured` for TRACKING purposes, but the
706 // `session_id_was_none` arm below used to adopt regardless — so a
707 // doomed generation's still-draining stdout reader, echoing its
708 // stale attempted sid moments after `respawn_once_for_leftover_
709 // queue`'s plain clear of `session_id` (see that function's
710 // round-13 comment, which deferred exactly this race), could
711 // re-install the stale sid into ambient state. `resume_poisoned`
712 // doesn't cover that: the fallback path deliberately does NOT
713 // poison (the death may have nothing to do with a stale resume),
714 // and the fallback respawn passes `resume_retry_payload: None`,
715 // so a later spawn re-attaching `--resume <stale-sid>` would
716 // fail with nothing re-armed to catch it. A newer spawn owns the
717 // session identity by definition — a superseded generation's
718 // capture must never adopt.
719 let is_current_generation = generation == self.spawn_generation;
720 let adopted = is_current_generation && sid_is_new && (session_id_was_none || just_resolved_tracking);
721 if adopted {
722 self.session_id = Some(sid.to_string());
723 }
724 (adopted, effects)
725 }
726
727 /// The process of `generation` is still the current one, and still holds
728 /// `sid` as a session not known to be dead. A superseded generation's
729 /// draining stdout must not settle anything on the replacement's behalf
730 /// (codex P1 on #3907).
731 fn holds_current_session(&self, sid: &str, generation: u64) -> bool {
732 generation == self.spawn_generation
733 && self.session_id.as_deref() == Some(sid)
734 && self.resume_poisoned.as_deref() != Some(sid)
735 }
736}
737
738/// What `decide_send_action` determined a message's fate should be —
739/// see its own doc comment and `PersistentInner::spawning_in_progress`.
740enum SendAction {
741 /// The process is already running — deliver directly, no spawn
742 /// decision involved at all. The turn is already marked active: the
743 /// decision reserves it under the same `inner` acquisition, so an
744 /// automated send cannot see "idle" in the gap before the write (codex P1
745 /// on #3562). `was_active` is the pre-reservation state, for the caller
746 /// to start the heartbeat and to hand the turn back if the write fails.
747 DeliverDirect { was_active: bool },
748 /// Nobody else is currently spawning — this caller claimed the
749 /// exclusive right to do so and its message has already been enqueued
750 /// for the post-spawn drain. `own_seq` is that enqueued message's
751 /// `QueuedMessage::seq`, handed back so the caller can later tell
752 /// `release_spawn_claim_and_drain_queue` exactly which entry was its
753 /// own (issue #2365 — content matching could discard a different,
754 /// identical-text message instead).
755 BecomeSpawner { own_seq: u64 },
756 /// Another caller is already spawning (or a retry-batch flush holds
757 /// the drain claim — issue #2367) — this message has been enqueued
758 /// for that claim-holder's own drain to deliver.
759 Queued,
760 /// A kill is pending and the dying process still holds stdin: neither
761 /// written nor queued, reported to the sender as this error instead.
762 /// See `decide_send_action`.
763 Refused(String),
764}
765
766/// What `decide_retry_batch_action` determined a stale-resume retry
767/// batch's fate should be (issue #2367; spec §4). Split from
768/// [`SendAction`] because the batch's live-process outcome is not a
769/// direct delivery: the batch goes through the queue under the drain
770/// claim, never through a caller's own `try_send`.
771enum RetryBatchAction {
772 /// A live, NEWER-generation process is running with no claim held.
773 /// The batch was prepended to the queue AND `drain_claim` was taken
774 /// in the same lock acquisition that decided this — the caller must
775 /// start the queue flush
776 /// (`drain_queue_with_claim(.., QueueDrainClaim::RetryFlush)`).
777 FlushClaimed,
778 /// Someone else holds a claim (a spawn in flight, or another flush)
779 /// — the batch was prepended for that claim-holder's drain.
780 Queued,
781 /// Nobody holds a claim and no process is running — this retry
782 /// claimed the exclusive spawn right; `own_seq` is the batch's first
783 /// entry's seq (see `SendAction::BecomeSpawner`).
784 BecomeSpawner { own_seq: u64 },
785}
786
787/// Which exclusivity claim a queue-drain task runs under — the spawn
788/// claim (`spawning_in_progress`, the post-spawn drain) or the retry
789/// flush claim (`drain_claim`, issue #2367). The task must release
790/// exactly the claim its initiator took, and nothing else.
791#[derive(Clone, Copy, PartialEq)]
792enum QueueDrainClaim {
793 SpawnClaim,
794 RetryFlush,
795}
796
797impl QueueDrainClaim {
798 fn release(self, inner: &mut PersistentInner) {
799 match self {
800 QueueDrainClaim::SpawnClaim => inner.spawning_in_progress = false,
801 QueueDrainClaim::RetryFlush => inner.drain_claim = false,
802 }
803 }
804}
805
806/// A single entry in `pending_send_messages`: the formatted stdin JSON
807/// payload plus whether it's already been persisted to the blockfile.
808/// codex P2 on PR #2360 (sixth review pass, round 7): a stale-resume
809/// retry's redelivery has already been correctly persisted on its
810/// original (failed) attempt — see `retry_after_resume_failure`'s own doc
811/// comment — but the drain used to persist EVERY message it delivers
812/// unconditionally, double-persisting a replayed retry batch into the
813/// blockfile transcript.
814#[derive(Clone, Debug)]
815struct QueuedMessage {
816 /// Queue identity (issue #2365): assigned once from
817 /// `PersistentInner::next_message_seq` at first enqueue and preserved
818 /// verbatim across redelivery (`QueuedRetryEntry::seq` carries it
819 /// through a stale-resume retry batch), so dedup / own-message /
820 /// seed checks are exact identity — two genuinely different messages
821 /// with identical text can never be conflated.
822 seq: u64,
823 json_str: String,
824 already_persisted: bool,
825}
826
827impl QueuedMessage {
828 fn fresh(seq: u64, json_str: String) -> Self {
829 Self { seq, json_str, already_persisted: false }
830 }
831
832 fn already_persisted(seq: u64, json_str: String) -> Self {
833 Self { seq, json_str, already_persisted: true }
834 }
835}
836
837impl PartialEq<str> for QueuedMessage {
838 fn eq(&self, other: &str) -> bool {
839 self.json_str == other
840 }
841}
842
843impl PartialEq<&str> for QueuedMessage {
844 fn eq(&self, other: &&str) -> bool {
845 self.json_str == *other
846 }
847}
848
849impl PartialEq<String> for QueuedMessage {
850 fn eq(&self, other: &String) -> bool {
851 self.json_str == *other
852 }
853}
854
855/// Result of `try_eager_resume` — see `start()`'s doc comment. `DeclinedTo`
856/// carries a short, non-exhaustive reason for the log line; it is not meant
857/// to be pattern-matched on by callers, just read.
858enum EagerResumeOutcome {
859 Spawned,
860 DeclinedTo(&'static str),
861}
862
863/// PersistentSubprocessController keeps a long-running CLI process alive,
864/// sending user messages as NDJSON lines on stdin.
865pub struct PersistentSubprocessController {
866 #[allow(dead_code)]
867 tab_id: String,
868 block_id: String,
869 inner: Arc<Mutex<PersistentInner>>,
870 broker: Option<Arc<mps::Broker>>,
871 event_bus: Option<Arc<EventBus>>,
872 mstore: Option<Arc<Store>>,
873 /// FileStore for write-through persistence of output lines (Phase 1.3).
874 filestore: Option<Arc<FileStore>>,
875 /// Dependencies for `start()`'s eager-resume path
876 /// (`SPEC_PERSISTENT_CONTROLLER_EAGER_RESUME_ON_RECONNECT_2026_09_20.md`).
877 /// Set via `with_identity_stores()`, not `new()` — see that method's own
878 /// doc comment for why this is an opt-in builder step rather than a
879 /// required constructor argument.
880 id_store: Option<Arc<Store>>,
881 identity_store: Option<Arc<Store>>,
882 /// Empty when never supplied (every existing `new()` caller) — `start()`
883 /// treats an empty `auth_key` as "identity stores unavailable" the same
884 /// as `id_store`/`identity_store` being `None`, since a real spawn
885 /// always has a real key.
886 auth_key: String,
887 health_monitor: Arc<TurnActivityTracker>,
888 /// Monotonic counter bumped for every stdout line (including control frames).
889 /// The AskUserQuestion dead-air fallback snapshots this *before* sending the
890 /// answer and re-checks after a short window; any increment means the CLI
891 /// produced output (assistant content OR a follow-up control_request), i.e.
892 /// the turn resumed. Counting *all* frames — including control frames the
893 /// reader otherwise skips — avoids a spurious fallback when the resumed
894 /// turn's first activity is a tool-permission round-trip.
895 stdout_seq: Arc<AtomicU64>,
896 /// Weak self-reference for the stale-`--resume`-session retry (see
897 /// `retry_after_resume_failure`) — set by `set_self_ref` right after
898 /// construction. The process-waiter task (a detached `tokio::spawn`
899 /// that only captures cloned Arc fields, never `&self`) needs a way to
900 /// call back into an instance method once the underlying process
901 /// actually exits; a `Weak` avoids a reference cycle (this same
902 /// struct's `spawn_process` is what schedules that task).
903 self_ref: Mutex<Option<std::sync::Weak<Self>>>,
904 /// This controller's own muxbus/jekt identity, captured once at spawn
905 /// time from `muxbus_agent_id_from_env` — the independent source of
906 /// truth `Controller::agent_id()` exposes for `inject_message_inner`'s
907 /// recipient-identity check. Deliberately its own field, not part of
908 /// `PersistentInner` — `config` (and its `env_vars`, where the identity
909 /// is read from) isn't available until `spawn_process`, well after
910 /// `new()`, and this value doesn't participate in any of `PersistentInner`'s
911 /// carefully-ordered spawn/resume/queue state transitions. `None` for a
912 /// persistent block with no muxbus identity set (a non-agent process).
913 agent_id: Mutex<Option<String>>,
914 /// The STABLE jekt identity (`AGENTMUX_AGENT_ID`), captured once in
915 /// `spawn_process` and never overwritten afterward — unlike `agent_id`
916 /// above, which `input.rs`'s Register-tail deliberately refreshes every
917 /// turn to track the live, renameable display name (#2697). Exists
918 /// specifically so `Controller::stable_agent_id()` can back the jekt
919 /// registry's alias entry and the #2695 recipient-identity check even
920 /// after `agent_id` has moved on to a post-rename value. See
921 /// `INCIDENT_2026_09_09_JEKT_STABLE_ID_ALIAS.md`.
922 stable_agent_id: Mutex<Option<String>>,
923 /// Identity M2: `AGENTMUX_AGENT_UID` from the spawn env, captured at
924 /// spawn exactly like `stable_agent_id`. `None` until spawned, or when
925 /// the spawn env carried no UID (no `db_agents` row yet). See
926 /// `Controller::stable_agent_uid`.
927 stable_agent_uid: Mutex<Option<String>>,
928 /// The segment the latest spawn recorded (`segments.rs`), so a resume
929 /// retry can tell the agent's previous segment from the one that just
930 /// failed. `None` until a spawn with an agent UID.
931 current_segment: Mutex<Option<String>>,
932 /// Reports whatever is still deferred when this controller is dropped.
933 /// See [`DeferredDropReport`].
934 deferred_drop_report: DeferredDropReport,
935 /// Where this agent's single-live-instance lease lives, and this srv's
936 /// boot id (the lease owner). Set via `with_agent_lease_store()`; `None`
937 /// (tests, a registry that can't be opened) means no lease is taken.
938 lease_store: Option<Arc<crate::registry::LeaseStore>>,
939 boot_id: Arc<str>,
940}
941
942/// The last line of defence for §4.5: whatever is still deferred when the
943/// controller goes away is reported, not silently discarded with it.
944///
945/// `stop()` and `shutdown()` drain the queue, but a sender that fetched the
946/// controller before it was unregistered can still enqueue after that drain
947/// (codex P2 on #3562). The watchdog holds only a weak reference, so it dies
948/// with the controller and cannot catch it. `stop()` cannot refuse those late
949/// senders instead: the max-runtime watchdog stops controllers that stay
950/// registered and get used again.
951///
952/// A field rather than `Drop` on the controller itself, so struct-update
953/// construction (`..c`, used throughout the tests) keeps working. Holds the
954/// controller's own `inner`; `None` only transiently inside `new()`.
955struct DeferredDropReport(Option<(String, Arc<Mutex<PersistentInner>>)>);
956
957impl Drop for DeferredDropReport {
958 fn drop(&mut self) {
959 let Some((block_id, inner)) = self.0.take() else { return };
960 // Never panic in `drop`: a poisoned lock still holds the queue.
961 let stranded: Vec<String> = match inner.lock() {
962 Ok(mut g) => g.deferred_deliveries.drain(..).collect(),
963 Err(poisoned) => poisoned.into_inner().deferred_deliveries.drain(..).collect(),
964 };
965 PersistentSubprocessController::log_stranded_deferred(&block_id, "controller dropped", &stranded);
966 }
967}
968
969/// How long to wait after delivering an AskUserQuestion answer before assuming
970/// the turn did not resume and re-delivering the answer as a follow-up message.
971/// See `answer_question` and SPEC_ASK_USER_QUESTION_2026_06_15.md §10.1.
972const ANSWER_RESUME_FALLBACK_MS: u64 = 4000;
973
974/// Bound on `PersistentInner::deferred_deliveries`. Past this, enqueueing
975/// returns an error instead of a false success, so a caller is never told a
976/// message was delivered and then has it silently dropped
977/// (`SPEC_NO_MIDTURN_DELIVERY_2026_09_23.md` §4.5). Senders already handle
978/// transient delivery failure — the reactive handler, muxbus and the bridges
979/// all have to cope with an unreachable target instance — so surfacing a full
980/// queue as one more retryable failure needs no new machinery on their side.
981///
982/// 64 is chosen to be far above any plausible legitimate backlog for one
983/// startup or restart while still bounding memory: these are whole messages,
984/// and the queue is per-block.
985pub(super) const MAX_DEFERRED_DELIVERIES: usize = 64;
986
987/// How often the deferred-delivery watchdog re-checks a non-empty queue.
988/// Messages are queued only while the process cannot take a write (starting
989/// up, restarting, stopping), and the watchdog is what writes them once it
990/// can, so this bounds how long after the process comes up they arrive.
991pub(super) const DEFERRED_WATCHDOG_TICK: std::time::Duration = std::time::Duration::from_millis(500);
992
993/// Consecutive watchdog ticks with no process and no spawn in flight before
994/// the queue is declared stranded and reported (spec §4.5). Long enough to
995/// ride out the gap between a process exit and the respawn that follows it
996/// (a stale-`--resume` retry, a fallback respawn); short enough that a dead
997/// agent's backlog is surfaced rather than held forever.
998pub(super) const DEFERRED_ORPHAN_GRACE_TICKS: u32 = 20;
999
1000/// Whether the deferred-delivery watchdog keeps running after a tick.
1001#[derive(Debug, PartialEq)]
1002pub(super) enum WatchdogStep {
1003 Continue,
1004 Exit,
1005}
1006
1007/// Compose the directive follow-up message used by the AskUserQuestion dead-air
1008/// fallback. `answers` maps each question's text to the selected label(s) or free
1009/// text (the same object delivered in the control_response). The message is
1010/// deliberately directive so the model resumes the task instead of treating it
1011/// as a no-op (the "user sent an empty message" failure mode).
1012fn build_answer_resume_message(answers: &serde_json::Value) -> String {
1013 let mut out = String::from(
1014 "[AgentMux] Your earlier question was answered, but the turn had already ended, \
1015 so the answer is delivered here as a follow-up. Resume the task you were working \
1016 on using this answer — do not wait for further input:\n",
1017 );
1018 match answers.as_object() {
1019 Some(map) if !map.is_empty() => {
1020 for (question, answer) in map {
1021 let rendered = match answer {
1022 serde_json::Value::String(s) => s.clone(),
1023 serde_json::Value::Array(items) => items
1024 .iter()
1025 .filter_map(|v| v.as_str().map(str::to_string))
1026 .collect::<Vec<_>>()
1027 .join(", "),
1028 other => other.to_string(),
1029 };
1030 out.push_str(&format!("\n• {question}: {rendered}"));
1031 }
1032 }
1033 _ => out.push_str(&format!("\nAnswer: {answers}")),
1034 }
1035 out
1036}
1037
1038/// Fixed, server-owned decline message for a cancelled AskUserQuestion (the
1039/// Cancel button / Escape in `AgentQuestionPanel.tsx`) — not client-suppliable,
1040/// since Cancel and Escape both mean exactly one thing and there is no user-
1041/// authored content to carry. See `deny_question` and
1042/// docs/specs/SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md.
1043///
1044/// KEEP IN SYNC (no shared constant crosses the Rust/TypeScript boundary for
1045/// a plain string literal): `CANCEL_FALLBACK_MESSAGE` in
1046/// `frontend/app/view/agent/hooks/useAgentQuestions.ts` is a hand-copied
1047/// mirror of this exact text, used only for its SAFE_TO_RETRY_VIA_FOLLOWUP
1048/// fallback path. If you change this string, update that one too — the test
1049/// `ask_user_question_deny_message_matches_frontend_cancel_fallback_text`
1050/// below pins the literal on this side; the frontend test asserting
1051/// `sendMessage` was called with this exact text (in
1052/// `useAgentQuestions.test.ts`, "handleCancel fallback" describe block)
1053/// pins it on that side. Neither test can see the other language's
1054/// constant, so both must be updated by hand together.
1055pub(crate) const ASK_USER_QUESTION_DENY_MESSAGE: &str = "The user declined to answer this question.";
1056
1057/// Compose the directive follow-up message used by the AskUserQuestion dead-air
1058/// fallback when the answer was a DECLINE rather than a real answer. Mirrors
1059/// `build_answer_resume_message`'s directive tone (resume the task, don't wait
1060/// for further input) but tells the model the outcome was a decline so it
1061/// doesn't hallucinate a value the user never provided.
1062fn build_deny_resume_message(message: &str) -> String {
1063 format!(
1064 "[AgentMux] Your earlier question was declined, but the turn had already ended, \
1065 so this is delivered here as a follow-up. Resume the task you were working on \
1066 without this input — do not wait for further input. The user's response was: \
1067 \"{message}\""
1068 )
1069}
1070
1071/// Whether a `can_use_tool` control_request for `tool_name` (anything except
1072/// AskUserQuestion, which is always parked separately — see
1073/// `handle_control_frame`) should be routed to the frontend decision panel
1074/// instead of auto-allowed.
1075///
1076/// **Hardcoded to `false` — deliberately inert.** This is the one remaining
1077/// product decision `SPEC_DECISION_PROMPT_2026_04_24.md` Phase 2 and
1078/// `SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md` §5 Phase 2 both leave open,
1079/// and it is NOT safe to default it to "route everything non-trivial" —
1080/// that spec's own §7 risk section says so directly:
1081///
1082/// > Permission chatter: `--permission-mode default` may route many tools
1083/// > through `can_use_tool`. Mitigation: auto-allow in the handler (Phase 1);
1084/// > consider `--permission-mode acceptEdits` or allow-rules to cut volume.
1085///
1086/// `--permission-mode default` is the mode baked into every persistent
1087/// Claude agent's launch args today (`static CLAUDE` in
1088/// `crates/srv/src/backend/providers.rs`), and it routes most non-trivial
1089/// tool calls through this control_request. Flipping this function to
1090/// unconditionally `true` would turn every persistent Claude agent into a
1091/// prompt-per-tool-call experience the instant it ships — a real regression,
1092/// not a hypothetical one, and not something an engineering change should
1093/// decide unilaterally.
1094///
1095/// The parking/decide mechanism this gate controls —
1096/// `park_tool_permission_request` below and
1097/// `PersistentSubprocessController::decide_tool_permission` — is complete
1098/// and independently tested (see the "Phase 2 gate" tests in
1099/// `persistent/tests/send_input.rs`, which call both directly); flipping
1100/// this gate on is a policy call about which tools or modes actually
1101/// warrant a prompt (a risk-tiering scheme per
1102/// `SPEC_DECISION_PROMPT_2026_04_24.md`'s own speculative `risk` field,
1103/// `--permission-mode acceptEdits` instead of `default`, or persisted
1104/// allow-rules per that spec's §6 — none of which exist yet), not an
1105/// engineering task this function should pre-empt.
1106fn should_route_to_decision_panel(_tool_name: &str) -> bool {
1107 false
1108}
1109
1110/// Park an ordinary (non-AskUserQuestion) tool-permission `can_use_tool`
1111/// control_request into `PersistentInner::pending_permissions`, mirroring
1112/// how AskUserQuestion is parked into `pending_questions` two branches up in
1113/// `handle_control_frame` — same in-memory-only, per-controller-instance
1114/// lifetime, and the same "process respawned" failure mode on decide
1115/// (`PersistentSubprocessController::decide_tool_permission`'s error text
1116/// explains it the same way `answer_question`'s does).
1117///
1118/// Currently unreachable in production: `should_route_to_decision_panel`
1119/// always returns `false`, so `handle_control_frame` never calls this. Kept
1120/// as its own function (rather than inlined, unlike the AskUserQuestion
1121/// branch) specifically so it can be unit tested directly, independent of
1122/// that gate.
1123fn park_tool_permission_request(
1124 inner: &Arc<Mutex<PersistentInner>>,
1125 tool_use_id: String,
1126 request_id: String,
1127 tool_name: String,
1128 input: serde_json::Value,
1129) {
1130 let mut guard = inner.lock().unwrap();
1131 guard
1132 .pending_permissions
1133 .insert(tool_use_id, (request_id, tool_name, input));
1134}
1135
1136/// Compose the directive follow-up message used by the tool-permission
1137/// dead-air fallback (mirrors `build_answer_resume_message` /
1138/// `build_deny_resume_message`'s directive tone — resume the task, don't
1139/// wait for further input — but for an ordinary tool-permission decision
1140/// rather than an AskUserQuestion answer).
1141fn build_tool_decision_resume_message(tool_name: &str, outcome: &str, feedback: Option<&str>) -> String {
1142 if outcome == "allow" {
1143 format!(
1144 "[AgentMux] Your earlier request to use `{tool_name}` was approved, but the \
1145 turn had already ended, so this is delivered here as a follow-up. Resume the \
1146 task you were working on — the tool call is approved; do not wait for further \
1147 input."
1148 )
1149 } else {
1150 let reason = feedback.unwrap_or("Denied by user.");
1151 format!(
1152 "[AgentMux] Your earlier request to use `{tool_name}` was declined, but the \
1153 turn had already ended, so this is delivered here as a follow-up. Resume the \
1154 task you were working on without using that tool — do not wait for further \
1155 input. The user's response was: \"{reason}\""
1156 )
1157 }
1158}
1159
1160impl PersistentSubprocessController {
1161 pub fn new(
1162 tab_id: String,
1163 block_id: String,
1164 broker: Option<Arc<mps::Broker>>,
1165 event_bus: Option<Arc<EventBus>>,
1166 mstore: Option<Arc<Store>>,
1167 filestore: Option<Arc<FileStore>>,
1168 ) -> Self {
1169 let health_monitor = Arc::new(TurnActivityTracker::for_block(block_id.clone(), broker.as_ref()));
1170 let mut this = Self {
1171 tab_id,
1172 block_id,
1173 inner: Arc::new(Mutex::new(PersistentInner {
1174 proc_status: STATUS_INIT.to_string(),
1175 proc_exit_code: 0,
1176 status_version: 0,
1177 session_id: None,
1178 resume_poisoned: None,
1179 fork_copy: None,
1180 fork_next: false,
1181 restart_when_idle: false,
1182 restart_pending: false,
1183 spawn_runtime: None,
1184 effective_runtime: None,
1185 settings_readback: false,
1186 context_usage: None,
1187 stop_pending: false,
1188 config_restart_generation: None,
1189 restart_spawn_for: None,
1190 resume: persistent_resume::ResumeState::default(),
1191 spawning_in_progress: false,
1192 pending_send_messages: VecDeque::new(),
1193 deferred_deliveries: VecDeque::new(),
1194 deferred_watchdog_armed: false,
1195 drain_claim: false,
1196 next_message_seq: 0,
1197 drain_send_in_flight: false,
1198 current_pid: None,
1199 stdin_tx: None,
1200 kill_tx: None,
1201 shutdown_generation: None,
1202 stop_exit: None,
1203 spawn_generation: 0,
1204 leftover_resume_candidate: None,
1205 pending_questions: HashMap::new(),
1206 pending_permissions: HashMap::new(),
1207 agent_lease: None,
1208 })),
1209 broker,
1210 event_bus,
1211 mstore,
1212 filestore,
1213 id_store: None,
1214 identity_store: None,
1215 auth_key: String::new(),
1216 health_monitor,
1217 stdout_seq: Arc::new(AtomicU64::new(0)),
1218 self_ref: Mutex::new(None),
1219 agent_id: Mutex::new(None),
1220 stable_agent_id: Mutex::new(None),
1221 stable_agent_uid: Mutex::new(None),
1222 current_segment: Mutex::new(None),
1223 deferred_drop_report: DeferredDropReport(None),
1224 lease_store: None,
1225 boot_id: Arc::from(""),
1226 };
1227 this.deferred_drop_report =
1228 DeferredDropReport(Some((this.block_id.clone(), Arc::clone(&this.inner))));
1229 this
1230 }
1231
1232 /// Supplies the dependencies `start()`'s eager-resume path needs
1233 /// (`SPEC_PERSISTENT_CONTROLLER_EAGER_RESUME_ON_RECONNECT_2026_09_20.md`):
1234 /// the Layer 3 identity/credential spawn gate's two stores, and the
1235 /// auth key a spawned CLI needs for `AGENTMUX_AUTH_KEY` (MPS publish
1236 /// scoping via the bashwrap-invoked tool wrapper).
1237 ///
1238 /// A separate builder step, not three more `new()` parameters, so every
1239 /// existing caller — the ~15 test constructions in this file among
1240 /// them — is unaffected. A controller built without this call simply
1241 /// cannot eager-resume even when `agent:sessionid` is present; it falls
1242 /// back to `start()`'s ordinary lazy "wait for the first message" path
1243 /// rather than spawning without the identity gate. That fallback is the
1244 /// deliberately safe default: eagerly resuming a possibly-deauthed
1245 /// agent's session on whatever ambient credential happens to be present
1246 /// would silently reintroduce the exact vulnerability class
1247 /// `SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md` closed. See
1248 /// `start()`'s own doc comment for the full reasoning.
1249 pub fn with_identity_stores(
1250 mut self,
1251 id_store: Option<Arc<Store>>,
1252 identity_store: Option<Arc<Store>>,
1253 auth_key: String,
1254 ) -> Self {
1255 self.id_store = id_store;
1256 self.identity_store = identity_store;
1257 self.auth_key = auth_key;
1258 self
1259 }
1260
1261 /// Supplies the single-live-instance lease store (under the shared agent
1262 /// registry root) and this srv's boot id. A separate builder step for
1263 /// the same reason as `with_identity_stores`. Without it the controller
1264 /// takes no lease — the pre-Phase-1 behaviour.
1265 pub fn with_agent_lease_store(
1266 mut self,
1267 registry: Option<Arc<crate::registry::Registry>>,
1268 boot_id: Arc<str>,
1269 ) -> Self {
1270 self.lease_store = crate::backend::agent_admission::lease_store_for(registry);
1271 self.boot_id = boot_id;
1272 self
1273 }
1274
1275 /// Claim (or keep) this agent's single-live-instance lease for the
1276 /// process `spawn_process` is about to start. `Ok(None)` when leasing is
1277 /// unavailable (no store) or the spawn env carries no agent UID — those
1278 /// run unguarded, as before Phase 1, and are logged. `Err` is the
1279 /// user-facing refusal: another instance runs this agent.
1280 ///
1281 /// A lease this controller already holds (an earlier generation's, still
1282 /// valid) is reused rather than re-claimed: a fresh handle for the same
1283 /// key would release the shared file when the old one dropped.
1284 pub(super) fn acquire_agent_lease(
1285 &self,
1286 config: &PersistentSpawnConfig,
1287 session_hint: Option<&str>,
1288 ) -> Result<Option<Arc<crate::backend::agent_admission::HeldAgentLease>>, String> {
1289 let Some(store) = self.lease_store.as_ref() else { return Ok(None) };
1290 let uid = config
1291 .env_vars
1292 .get("AGENTMUX_AGENT_UID")
1293 .map(|u| u.trim().to_string())
1294 .filter(|u| !u.is_empty());
1295 let Some(uid) = uid else {
1296 tracing::warn!(
1297 block_id = %self.block_id,
1298 "agent_admission: spawn env carries no agent UID — no single-instance lease for this process"
1299 );
1300 return Ok(None);
1301 };
1302 let existing = self.inner.lock().unwrap().agent_lease.clone();
1303 if let Some(existing) = existing {
1304 existing.verify()?;
1305 return Ok(Some(existing));
1306 }
1307 let agent = muxbus_agent_id_from_env(&config.env_vars).unwrap_or_default();
1308 let inner = Arc::clone(&self.inner);
1309 let block_id = self.block_id.clone();
1310 let held = crate::backend::agent_admission::acquire(
1311 store,
1312 &uid,
1313 &agent,
1314 &self.boot_id,
1315 &self.block_id,
1316 session_hint,
1317 move |why| Self::stop_after_lease_loss(&inner, &block_id, &why),
1318 )?;
1319 Ok(Some(Arc::new(held)))
1320 }
1321
1322 /// The early admission check (spec I9) for this controller's agent:
1323 /// refuses while another instance holds it. Runs before any shared write.
1324 pub(crate) async fn check_admission(&self, uid: &str, agent: &str) -> Result<(), String> {
1325 crate::backend::agent_admission::check_before_spawn(
1326 self.lease_store.clone(),
1327 uid,
1328 agent,
1329 &self.boot_id,
1330 )
1331 .await
1332 }
1333
1334 /// The pre-turn fence (spec I4): `Err` if this pane's CLI process lost
1335 /// its lease to another instance. Also stops that process, so it cannot
1336 /// keep driving the agent. `Ok` when no lease is held (nothing spawned
1337 /// yet — the spawn will claim — or leasing is unavailable).
1338 pub(super) fn fence_check(&self) -> Result<(), String> {
1339 let lease = self.inner.lock().unwrap().agent_lease.clone();
1340 let Some(lease) = lease else { return Ok(()) };
1341 if let Err(e) = lease.verify() {
1342 Self::stop_after_lease_loss(&self.inner, &self.block_id, &e);
1343 return Err(e);
1344 }
1345 Ok(())
1346 }
1347
1348 /// Hand this pane's agent over to another AgentMux instance that asked
1349 /// for it (spec §4.6, `POST /agentmux/agent/release`): if this pane's CLI
1350 /// process holds the agent's single-live-instance lease, stop it
1351 /// gracefully — stdin EOF, so a turn in flight finishes, then kill at
1352 /// the deadline — and let its exit release the lease. `false` when this
1353 /// pane holds nothing (no process, or no lease).
1354 pub fn release_to_other_instance(&self, deadline: std::time::Instant) -> bool {
1355 let kill = {
1356 let mut inner = self.inner.lock().unwrap();
1357 if inner.agent_lease.is_none() {
1358 return false;
1359 }
1360 inner.kill_tx.take()
1361 };
1362 tracing::info!(block_id = %self.block_id, "agent_admission.takeover: handing this agent to another instance");
1363 match kill {
1364 Some(tx) => tx.send(KillRequest::Graceful(deadline)).is_ok(),
1365 None => false,
1366 }
1367 }
1368
1369 /// Kill the current CLI process after its lease was lost — the holder
1370 /// stops rather than racing the new one (spec §4.3).
1371 fn stop_after_lease_loss(inner: &Arc<Mutex<PersistentInner>>, block_id: &str, why: &str) {
1372 let kill = inner.lock().unwrap().kill_tx.take();
1373 tracing::error!(block_id, why, "agent_admission.fenced: stopping this pane's CLI process");
1374 if let Some(tx) = kill {
1375 let _ = tx.send(KillRequest::Force);
1376 }
1377 }
1378
1379 /// Sets the weak self-reference used by the process-waiter task to call
1380 /// back into `retry_after_resume_failure` once a doomed process (stale
1381 /// `--resume` session id) actually exits. Mirrors `SubprocessController::
1382 /// set_self_ref`'s queued-message-drain pattern. Must be called by the
1383 /// caller right after wrapping a fresh instance in `Arc` — a controller
1384 /// that's never had this called simply never retries (the check is a
1385 /// harmless no-op `Weak::upgrade()` failure), so this is safe to skip
1386 /// for e.g. throwaway test instances.
1387 pub fn set_self_ref(self: &Arc<Self>) {
1388 *self.self_ref.lock().unwrap() = Some(Arc::downgrade(self));
1389 }
1390}
1391
1392impl Controller for PersistentSubprocessController {
1393 /// Lazy by default — "spawns on first message" — EXCEPT for a pane
1394 /// resuming an existing session across a backend restart/reconnect, per
1395 /// `SPEC_PERSISTENT_CONTROLLER_EAGER_RESUME_ON_RECONNECT_2026_09_20.md`.
1396 /// Without this, every persistent agent pane comes back from a crash/
1397 /// OOM/update as an inert shell — correct layout, correct chrome, `view:
1398 /// "agent"`, no live process — until a human or another agent happens to
1399 /// message it. Live incident: 3 of 7 panes in one window silently never
1400 /// respawned after 2026-09-20's restarts (issue #3463).
1401 ///
1402 /// `agent:sessionid` meta present and non-empty is the signal: this pane
1403 /// has a real, interrupted conversation rather than being genuinely new
1404 /// (which still spawns lazily, unchanged — no reason to burn a process
1405 /// before it's needed). Mirrors `ShellController::start()`'s already-
1406 /// established "transparently revive on restart" behavior for shells.
1407 ///
1408 /// **Eager resume is gated on the SAME Layer 3 identity/credential spawn
1409 /// gate a live message send goes through**
1410 /// (`identity::resolver::inject_identity_env`,
1411 /// `SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md`) — this is not
1412 /// an optional hardening pass, it is why this method does not simply
1413 /// spawn unconditionally the moment a session id is found. Skipping the
1414 /// gate here would let an agent whose bound account was deleted/revoked
1415 /// between the crash and the restart get silently respawned anyway, on
1416 /// whatever ambient credential happens to be lying around: exactly the
1417 /// vulnerability class that gate exists to close, reintroduced through a
1418 /// call site the gate was never wired into. Any of the following falls
1419 /// back to today's lazy behavior — NOT a hard error, since a pane that
1420 /// merely stays inert is still recoverable by a message, unlike one
1421 /// spawned on the wrong credentials:
1422 /// - `id_store`/`identity_store`/`auth_key` weren't supplied (i.e. this
1423 /// controller was constructed without `with_identity_stores()` —
1424 /// should not happen for a production `resync_controller` call, but a
1425 /// test or future caller that omits it must not silently spawn
1426 /// ungated rather than just not eager-resuming).
1427 /// - `mstore` is unavailable.
1428 /// - `cmd`/`cmd:args` meta is missing (should not happen for a pane
1429 /// that has ever actually run — `agent_open.rs` always seeds them —
1430 /// but guessing CLI flags for a persistent agent is worse than not
1431 /// eager-resuming).
1432 /// - The gate itself returns `Err` (`SpawnGateError`).
1433 fn start(
1434 &self,
1435 block_meta: super::super::obj::MetaMapType,
1436 _rt_opts: Option<serde_json::Value>,
1437 _force: bool,
1438 ) -> Result<(), String> {
1439 let session_id = crate::backend::obj::meta_get_string(&block_meta, core::META_SESSION_ID, "");
1440 if session_id.is_empty() {
1441 tracing::info!(
1442 block_id = %self.block_id,
1443 "persistent controller registered (spawns on first message)"
1444 );
1445 return Ok(());
1446 }
1447
1448 match self.try_eager_resume(&block_meta, &session_id) {
1449 EagerResumeOutcome::Spawned => {}
1450 EagerResumeOutcome::DeclinedTo(reason) => {
1451 tracing::info!(
1452 block_id = %self.block_id,
1453 session_id = %session_id,
1454 reason = %reason,
1455 "persistent controller has a prior session but is not eager-resuming it \
1456 — registered lazily instead (spawns on next message)"
1457 );
1458 }
1459 }
1460 Ok(())
1461 }
1462
1463 fn stop(&self, _graceful: bool, new_status: &str) -> Result<(), String> {
1464 // Not `stop_process` then a separate drain: see
1465 // `request_stop_draining_deferred` for the race between the two.
1466 let stranded = self.request_stop_draining_deferred(KillRequest::Force);
1467 Self::log_stranded_deferred(&self.block_id, "controller stopped", &stranded);
1468 let mut inner = self.inner.lock().unwrap();
1469 if inner.proc_status != new_status {
1470 Self::set_status(&mut inner, new_status);
1471 }
1472 drop(inner);
1473 // Leaving "running" clears what the process was spawned with; the menu
1474 // must hear that too, or it keeps judging a process that is gone.
1475 self.publish_runtime();
1476 Ok(())
1477 }
1478
1479 /// Pane-close shutdown (spec §9.3), built on the Phase 0 measurements
1480 /// (§5.1): EOF alone does NOT end a running turn — the CLI finishes it
1481 /// first — but the control-protocol interrupt ends it in ~2s, and EOF
1482 /// then exits the process in ~0.5s. So: interrupt an active turn, wait
1483 /// for its result, EOF, and force-kill only at the deadline.
1484 fn shutdown(&self, deadline: std::time::Instant) -> super::ShutdownFuture {
1485 use super::StopOutcome;
1486 const POLL: std::time::Duration = std::time::Duration::from_millis(50);
1487 let inner = Arc::clone(&self.inner);
1488 let health = Arc::clone(&self.health_monitor);
1489 let block_id = self.block_id.clone();
1490 Box::pin(async move {
1491 let (running, stranded) = {
1492 let mut g = inner.lock().unwrap();
1493 // Drained BEFORE the no-process return: a message deferred
1494 // behind a spawn that then failed sits here with no process,
1495 // and the watchdog holds only a weak ref, so it dies with this
1496 // controller. Returning first discarded the message unreported
1497 // (codex P2 on #3562).
1498 let stranded: Vec<String> = g.deferred_deliveries.drain(..).collect();
1499 // A pane close outranks a committed config restart in EVERY
1500 // branch, including this no-process one: the restart's
1501 // replacement may still be mid-spawn with no pid yet, and
1502 // would otherwise pass `commit_restart_spawn_locked` and
1503 // install a child for a closed pane (codex P1 on #3990).
1504 g.config_restart_generation = None;
1505 g.restart_spawn_for = None;
1506 let running = if g.current_pid.is_none() {
1507 // Never spawned (lazy), or already gone. A spawn still in
1508 // flight is killed by the caller's tracker drop.
1509 None
1510 } else {
1511 // Gate writes now, in the same section as the drain, not
1512 // later in `request_stop_on`: a sender that already holds
1513 // this controller can still enqueue, and the interrupt's
1514 // own `result` would flush that into the process being
1515 // shut down (codex P2 on #3562). The interrupt itself
1516 // goes out on `stdin_tx` directly, not through the gate.
1517 g.stop_pending = true;
1518 // Nothing queued may start a new turn after the interrupt.
1519 g.pending_send_messages.clear();
1520 // Before the interrupt: its `is_error` result is our stop,
1521 // not a failure (§9.4).
1522 g.shutdown_generation = Some(g.spawn_generation);
1523 g.stop_exit = None;
1524 Some((g.spawn_generation, g.stdin_tx.clone()))
1525 };
1526 (running, stranded)
1527 };
1528 Self::log_stranded_deferred(&block_id, "pane shutdown", &stranded);
1529 let Some((generation, stdin_tx)) = running else {
1530 return StopOutcome::NotRunning;
1531 };
1532
1533 if health.is_active_turn() {
1534 if let Some(tx) = stdin_tx.as_ref() {
1535 if tx.send(interrupt_control_request_line()).await.is_ok() {
1536 // Leave a second for EOF + exit after the turn ends.
1537 let turn_deadline = deadline
1538 .checked_sub(std::time::Duration::from_secs(1))
1539 .unwrap_or(deadline);
1540 while health.is_active_turn() && std::time::Instant::now() < turn_deadline {
1541 tokio::time::sleep(POLL).await;
1542 }
1543 }
1544 }
1545 }
1546 // Our clone of the stdin sender would keep stdin open — EOF needs
1547 // every sender gone.
1548 drop(stdin_tx);
1549 let _ = Self::request_stop_on(&inner, KillRequest::Graceful(deadline));
1550
1551 // The kill arm records the exit the moment it happens; allow a
1552 // little past the deadline for the forced kill itself.
1553 let give_up = deadline + std::time::Duration::from_secs(2);
1554 loop {
1555 {
1556 let g = inner.lock().unwrap();
1557 if let Some((gen, killed)) = g.stop_exit {
1558 if gen == generation {
1559 return if killed { StopOutcome::Killed } else { StopOutcome::Exited };
1560 }
1561 }
1562 // Exited on its own through the natural-exit arm, or was
1563 // replaced — either way this process is gone.
1564 if g.spawn_generation != generation || g.current_pid.is_none() {
1565 return StopOutcome::Exited;
1566 }
1567 }
1568 if std::time::Instant::now() >= give_up {
1569 tracing::warn!(
1570 block_id = %block_id,
1571 "agent_shutdown: no exit recorded by the deadline; the caller's tracker drop will kill it"
1572 );
1573 return StopOutcome::Killed;
1574 }
1575 tokio::time::sleep(POLL).await;
1576 }
1577 })
1578 }
1579
1580 fn get_runtime_status(&self) -> BlockControllerRuntimeStatus {
1581 self.get_status_snapshot()
1582 }
1583
1584 fn send_input(&self, input: BlockInputUnion, _seq: Option<u64>) -> Result<(), String> {
1585 // Persistent controllers have no PTY and don't take raw keystrokes —
1586 // user messages go through send_message(). But the agent-pane Stop
1587 // button / Esc delivers an *interrupt* as a signal via
1588 // `ControllerInputCommand({signame:"SIGINT"})` (see useAgentCommands
1589 // `stopAgent`). Without handling it here, stopping a persistent (e.g.
1590 // Claude stream-json) agent failed with "does not accept raw input".
1591 // Route the interrupt to the same kill path `stop()` uses, mirroring
1592 // SubprocessController. The session_id is retained, so the next message
1593 // resumes the conversation.
1594 if let Some(sig) = input.sig_name.as_deref() {
1595 if sig == "SIGINT" || sig == "SIGTERM" {
1596 tracing::info!(
1597 block_id = %self.block_id,
1598 sig = %sig,
1599 "persistent controller: received signal, stopping current process"
1600 );
1601 return self.stop_process(true);
1602 }
1603 return Err(format!(
1604 "persistent controller: unsupported signal {sig} (only SIGINT/SIGTERM)"
1605 ));
1606 }
1607 // Raw keystrokes are genuinely unsupported — user messages go through
1608 // send_message(), not the PTY input channel.
1609 if input.input_data.is_some() {
1610 return Err(
1611 "persistent controller does not accept raw input; use send_message()".to_string(),
1612 );
1613 }
1614 // Term resize / other benign input types: accepted no-op. A persistent
1615 // controller has no PTY, so there is nothing to resize — but the agent
1616 // pane's `usePtyWidth` hook sends a `termsize` on every running turn
1617 // (it can't tell a PTY-backed controller from a PTY-less one). Returning
1618 // an error here surfaced a spurious "resize to N cols failed" warning in
1619 // the agent pane's activity log. Mirror SubprocessController, which
1620 // already no-ops termsize. See AGENT_PANE_PTY_RESIZE_RACE_2026_06_16.md.
1621 Ok(())
1622 }
1623
1624 fn turn_tracker(&self) -> Option<Arc<TurnActivityTracker>> {
1625 Some(Arc::clone(&self.health_monitor))
1626 }
1627
1628 fn controller_type(&self) -> &str {
1629 BLOCK_CONTROLLER_PERSISTENT
1630 }
1631
1632 fn block_id(&self) -> &str {
1633 &self.block_id
1634 }
1635
1636 fn agent_id(&self) -> Option<String> {
1637 self.agent_id.lock().unwrap().clone()
1638 }
1639
1640 fn turn_provenance(&self) -> Option<crate::backend::blockcontroller::health::TurnProvenance> {
1641 self.health_monitor.provenance()
1642 }
1643
1644 fn set_agent_id(&self, id: Option<String>) {
1645 *self.agent_id.lock().unwrap() = id;
1646 }
1647
1648 fn stable_agent_id(&self) -> Option<String> {
1649 self.stable_agent_id.lock().unwrap().clone()
1650 }
1651
1652 fn stable_agent_uid(&self) -> Option<String> {
1653 self.stable_agent_uid.lock().unwrap().clone()
1654 }
1655
1656 fn as_any(&self) -> &dyn std::any::Any {
1657 self
1658 }
1659}
1660
1661/// Classify a flushed error line, if it's confident enough to surface to
1662/// the pane's failure-recovery UI. `line` is usually the same result-frame
1663/// JSON the mid-stream error-result path handles, but
1664/// `persistent_resume::ResumeEffect::FlushErrorLine` can also carry an
1665/// earlier held-back turn's line — falls back to raw-text keyword matching
1666/// (the same `classify()` uses for a stderr tail) if it doesn't parse as
1667/// JSON. `exit_code` is `Some` at the process-exit arm and `None` at the
1668/// mid-generation flush sites (a superseded spawn generation, or a
1669/// `SessionCaptured` resolution), where no fresh exit code exists for the
1670/// line being flushed. Returns `None` for an unrecognized, non-retryable
1671/// `FailureClass::UnknownNonZero` to avoid a low-confidence banner from
1672/// noisy flushed text — every other recognized class is surfaced, not just
1673/// the retryable ones, since the recovery banner has value for e.g. `Auth`
1674/// too (see `SPEC_AGENT_FAILURE_RECOVERY_UI_2026_06_16.md`'s per-class
1675/// action matrix), just without auto-retry.
1676/// See `SPEC_PERSISTENT_CONTROLLER_FAILURE_CLASSIFICATION_2026_08_04.md`.
1677fn classify_exit_line(exit_code: Option<i32>, line: &str) -> Option<crate::agents::failure::AgentFailure> {
1678 let parsed_line: Option<serde_json::Value> = serde_json::from_str(line).ok();
1679 let stderr_text = if parsed_line.is_none() { line } else { "" };
1680 let failure = crate::agents::failure::classify(exit_code, None, stderr_text, parsed_line.as_ref());
1681 if failure.retryable || failure.code != crate::agents::failure::FailureClass::UnknownNonZero {
1682 Some(failure)
1683 } else {
1684 None
1685 }
1686}
1687
1688/// Record `failure` as the block's `agent:last_failure`, then publish it as
1689/// `agent-failure` on `block:<id>` (persisted for replay) when there is a broker.
1690fn surface_failure(
1691 block_id: &str,
1692 failure: &crate::agents::failure::AgentFailure,
1693 broker: Option<&mps::Broker>,
1694 mstore: &Option<Arc<Store>>,
1695 event_bus: &Option<Arc<EventBus>>,
1696) {
1697 core::persist_last_failure(block_id, Some(failure), mstore, event_bus);
1698 if let Some(broker) = broker {
1699 broker.publish_event::<crate::backend::mps_events::AgentFailed>(failure, vec![format!("block:{}", block_id)], 1);
1700 }
1701}
1702
1703/// Classify a final error line with [`classify_exit_line`] and, if it is a
1704/// failure, [`surface_failure`] it. Returns whether it was surfaced.
1705fn surface_error_line(
1706 block_id: &str,
1707 exit_code: Option<i32>,
1708 line: &str,
1709 broker: Option<&mps::Broker>,
1710 mstore: &Option<Arc<Store>>,
1711 event_bus: &Option<Arc<EventBus>>,
1712) -> bool {
1713 let Some(failure) = classify_exit_line(exit_code, line) else {
1714 return false;
1715 };
1716 surface_failure(block_id, &failure, broker, mstore, event_bus);
1717 true
1718}
1719
1720mod eager_resume;
1721mod input;
1722mod lifecycle;
1723mod process_waiter;
1724mod queue;
1725mod resume_retry;
1726mod segments;
1727pub(crate) use resume_retry::pane_history_session_id;
1728mod spawn;
1729mod status;
1730mod stderr_reader;
1731mod stdout_reader;
1732
1733#[cfg(test)]
1734mod tests;