Skip to main content

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;