agentmux_srv/backend/blockcontroller/mod.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Block controller: manages lifecycle of each block (terminal, command, web app).
5//! Port of Go's pkg/blockcontroller/blockcontroller.go.
6
7//!
8//! Architecture:
9//! - Global controller registry maps block_id → Controller
10//! - Each controller manages the lifecycle of one block
11//! - ShellController handles "shell" and "cmd" block types
12//! - Controllers dispatch I/O between the user and the process/service
13
14pub mod acp;
15pub mod agent_lock;
16pub mod app_server;
17pub mod app_server_controller;
18pub mod app_server_protocol;
19pub mod core;
20pub mod durable_mode;
21pub mod durable_ssh;
22pub mod health;
23pub mod persistent;
24mod persistent_resume;
25pub mod pidregistry;
26pub mod process_tree;
27pub mod session_recovery;
28pub mod session_stats;
29pub mod shell;
30pub mod subprocess;
31pub mod term_size;
32pub mod watchdog;
33
34use std::any::Any;
35use std::collections::HashMap;
36use std::sync::{Arc, RwLock};
37
38use serde::{Deserialize, Serialize};
39
40use super::eventbus::EventBus;
41use super::obj::{Block, MetaMapType, TermSize};
42use super::storage::filestore::FileStore;
43use super::storage::store::Store;
44use super::mps::Broker;
45
46// ---- Controller status constants (match Go) ----
47
48pub const STATUS_INIT: &str = "init";
49pub const STATUS_RUNNING: &str = "running";
50pub const STATUS_DONE: &str = "done";
51
52/// Sentinel error string `resync_controller` returns when it declines to
53/// respawn a `STATUS_DONE` controller because its caller passed
54/// `respawn_if_done: false` (see that parameter's own doc comment). Callers
55/// that want to treat "already exited" differently from a genuine failure
56/// (e.g. `try_attach_to_existing_shell` falling through to a fresh shell
57/// instead of surfacing a 500) match on this exact string rather than
58/// parsing every other possible error message `resync_controller` can
59/// return.
60pub const RESYNC_ERR_ALREADY_EXITED: &str = "resync_controller: controller already exited (respawn suppressed)";
61
62// ---- Controller type constants (match Go) ----
63
64pub const BLOCK_CONTROLLER_SHELL: &str = "shell";
65pub const BLOCK_CONTROLLER_CMD: &str = "cmd";
66pub const BLOCK_CONTROLLER_SUBPROCESS: &str = "subprocess";
67pub const BLOCK_CONTROLLER_PERSISTENT: &str = "persistent";
68pub const BLOCK_CONTROLLER_ACP: &str = "acp";
69pub const BLOCK_CONTROLLER_APP_SERVER: &str = "app-server";
70
71// ---- Block metadata key constants (match Go) ----
72
73pub const META_KEY_CONTROLLER: &str = "controller";
74pub const META_KEY_CONNECTION: &str = "connection";
75pub const META_KEY_CMD: &str = "cmd";
76pub const META_KEY_CMD_CWD: &str = "cmd:cwd";
77#[allow(dead_code)]
78pub const META_KEY_CMD_SHELL: &str = "cmd:shell";
79pub const META_KEY_CMD_ARGS: &str = "cmd:args";
80pub const META_KEY_CMD_ENV: &str = "cmd:env";
81/// On an agent block: the block id of its shell drawer, the headless terminal
82/// the drawer, the agent's PtyShell and a paired device's pane list share.
83pub const META_KEY_SHELL_SUBBLOCK_ID: &str = "term:shellsubblockid";
84
85/// A meta value that is an object of strings, or a JSON string encoding one,
86/// as a string map. Non-string values are skipped; any other shape is empty.
87pub fn meta_string_map(meta: &MetaMapType, key: &str) -> HashMap<String, String> {
88 match meta.get(key) {
89 Some(serde_json::Value::Object(values)) => values
90 .iter()
91 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
92 .collect(),
93 Some(serde_json::Value::String(s)) => serde_json::from_str(s).unwrap_or_default(),
94 _ => HashMap::new(),
95 }
96}
97
98/// A block's `cmd:env` as a string map. The frontend writes an object; the
99/// JSON-string form is accepted too, so every spawn path reads the same env.
100pub fn cmd_env_of(meta: &MetaMapType) -> HashMap<String, String> {
101 meta_string_map(meta, META_KEY_CMD_ENV)
102}
103
104/// A line from AgentMux in a terminal pane's output: dimmed, in brackets, on a
105/// line of its own, so it reads as AgentMux's and not the shell's.
106pub fn pane_note(text: &str) -> Vec<u8> {
107 format!("\r\n\x1b[2m[{text}]\x1b[0m\r\n").into_bytes()
108}
109
110/// The agent id an env map names: `AGENTMUX_AGENT_ID`, else the legacy
111/// `WAVEMUX_AGENT_ID`, trimmed, skipping blank values.
112pub(crate) fn agent_id_from_env<'a>(lookup: impl Fn(&str) -> Option<&'a str>) -> Option<String> {
113 ["AGENTMUX_AGENT_ID", "WAVEMUX_AGENT_ID"]
114 .into_iter()
115 .filter_map(lookup)
116 .map(str::trim)
117 .find(|v| !v.is_empty())
118 .map(str::to_string)
119}
120#[allow(dead_code)]
121pub const META_KEY_CMD_JWT: &str = "cmd:jwt";
122pub const META_KEY_CMD_RUN_ON_START: &str = "cmd:runonstart";
123pub const META_KEY_CMD_RUN_ONCE: &str = "cmd:runonce";
124pub const META_KEY_CMD_CLEAR_ON_START: &str = "cmd:clearonstart";
125pub const META_KEY_CMD_CLOSE_ON_EXIT: &str = "cmd:closeonexit";
126pub const META_KEY_CMD_CLOSE_ON_EXIT_FORCE: &str = "cmd:closeonexitforce";
127pub const META_KEY_CMD_CLOSE_ON_EXIT_DELAY: &str = "cmd:closeonexitdelay";
128#[allow(dead_code)]
129pub const META_KEY_CMD_INIT_SCRIPT: &str = "cmd:initscript";
130#[allow(dead_code)]
131pub const META_KEY_CMD_INIT_SCRIPT_BASH: &str = "cmd:initscript.bash";
132#[allow(dead_code)]
133pub const META_KEY_CMD_INIT_SCRIPT_ZSH: &str = "cmd:initscript.zsh";
134#[allow(dead_code)]
135pub const META_KEY_CMD_INIT_SCRIPT_FISH: &str = "cmd:initscript.fish";
136#[allow(dead_code)]
137pub const META_KEY_CMD_INIT_SCRIPT_PWSH: &str = "cmd:initscript.pwsh";
138pub const META_KEY_TERM_LOCAL_SHELL_PATH: &str = "term:localshellpath";
139pub const META_KEY_TERM_LOCAL_SHELL_OPTS: &str = "term:localshellopts";
140
141// ---- Default timeouts ----
142
143/// Default controller operation timeout in milliseconds.
144#[allow(dead_code)]
145pub const DEFAULT_TIMEOUT_MS: u64 = 2000;
146
147/// Grace period before forceful kill in milliseconds.
148#[allow(dead_code)]
149pub const DEFAULT_GRACEFUL_KILL_WAIT_MS: u64 = 400;
150
151// ---- Input union (matches Go's BlockInputUnion) ----
152
153/// Input sent to a block controller.
154/// Can be raw terminal data, a signal, or a resize event.
155#[derive(Debug, Clone)]
156pub struct BlockInputUnion {
157 /// Raw terminal input bytes (base64 decoded from wire format).
158 pub input_data: Option<Vec<u8>>,
159 /// Signal name (e.g., "SIGTERM", "SIGINT").
160 pub sig_name: Option<String>,
161 /// Terminal resize event.
162 pub term_size: Option<TermSize>,
163}
164
165impl BlockInputUnion {
166 pub fn data(data: Vec<u8>) -> Self {
167 Self {
168 input_data: Some(data),
169 sig_name: None,
170 term_size: None,
171 }
172 }
173
174 pub fn signal(name: &str) -> Self {
175 Self {
176 input_data: None,
177 sig_name: Some(name.to_string()),
178 term_size: None,
179 }
180 }
181
182 pub fn resize(size: TermSize) -> Self {
183 Self {
184 input_data: None,
185 sig_name: None,
186 term_size: Some(size),
187 }
188 }
189}
190
191fn is_false(v: &bool) -> bool {
192 !v
193}
194
195// ---- Runtime status (matches Go's BlockControllerRuntimeStatus) ----
196
197/// Runtime status of a block controller, sent to the UI.
198#[derive(Debug, Clone, Serialize, Deserialize, Default, ts_rs::TS)]
199#[ts(export, export_to = "../../../frontend/types/rpc/")]
200pub struct BlockControllerRuntimeStatus {
201 pub blockid: String,
202 #[serde(default)]
203 pub version: i32,
204 #[serde(default, skip_serializing_if = "String::is_empty")]
205 #[ts(optional, as = "Option<String>")]
206 pub shellprocstatus: String,
207 #[serde(default, skip_serializing_if = "String::is_empty")]
208 #[ts(optional, as = "Option<String>")]
209 pub shellprocconnname: String,
210 #[serde(default)]
211 pub shellprocexitcode: i32,
212 /// A durable SSH pane (`durable_ssh`): its shell lives on the host.
213 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
214 #[ts(optional, as = "Option<bool>")]
215 pub durable: bool,
216 /// Unix timestamp (ms) when the process was spawned; None until first spawn.
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 #[ts(optional, type = "number")]
219 pub spawn_ts_ms: Option<i64>,
220 /// PID of the controller's own child process. Set by the shell
221 /// controller, which is the one the UI needs it for: the agent pane's
222 /// Shell drawer identifies its shell by PID (see
223 /// docs/specs/SPEC_AGENT_SHELL_DRAWER_INFO_PANEL_2026_09_19.md §4.1).
224 /// None before the first spawn, and for controllers that don't set it.
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 #[ts(optional)]
227 pub shellprocpid: Option<u32>,
228 /// Program name of that child (`pwsh`, `bash`, …). The process tracker
229 /// can't supply it — it omits each block's root process by design, and
230 /// for a shell sub-block the root IS the shell.
231 #[serde(default, skip_serializing_if = "String::is_empty")]
232 #[ts(optional, as = "Option<String>")]
233 pub shellprocname: String,
234 /// True if this pane is running an agent CLI (e.g. claude, codex, gemini, kimi, openclaw, pi).
235 #[serde(default, skip_serializing_if = "is_false")]
236 #[ts(optional, as = "Option<bool>")]
237 pub is_agent_pane: bool,
238 /// True if a turn is currently in flight (message sent, no terminating
239 /// `"result"` event observed yet). For `PersistentSubprocessController`
240 /// this is the only signal that distinguishes "actively generating a
241 /// turn" from "process alive, idle between turns" — `shellprocstatus`
242 /// stays `"running"` for the whole process lifetime either way. Backed
243 /// by `TurnActivityTracker.active_turn` (see `blockcontroller/health.rs`).
244 /// Frontend seeds `TurnPhase` from this at mount instead of always
245 /// defaulting to `Idle` — see
246 /// docs/specs/REPORT_AGENT_PANE_STATE_RECONCILIATION_2026_07_07.md
247 /// Finding 1.
248 #[serde(default, skip_serializing_if = "is_false")]
249 #[ts(optional, as = "Option<bool>")]
250 pub turn_active: bool,
251}
252
253// ---- Controller trait ----
254
255/// How a [`Controller::shutdown`] ended. Logged per closed agent
256/// (spec §9.8) — a provider that is often `Killed` needs a better shutdown or
257/// a longer grace period.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum StopOutcome {
260 /// No process was running (lazy controller never spawned, or already gone).
261 NotRunning,
262 /// Stopped the immediate way (default impl) — no wait, no verdict.
263 Stopped,
264 /// The process exited on its own before the deadline.
265 Exited,
266 /// The process was still alive at the deadline and was killed.
267 Killed,
268}
269
270impl StopOutcome {
271 pub fn as_str(&self) -> &'static str {
272 match self {
273 Self::NotRunning => "not_running",
274 Self::Stopped => "stopped",
275 Self::Exited => "exited",
276 Self::Killed => "killed",
277 }
278 }
279}
280
281pub type ShutdownFuture = std::pin::Pin<Box<dyn std::future::Future<Output = StopOutcome> + Send>>;
282
283/// Grace period for closing agents: one deadline per close, shared by every
284/// agent it stops (spec §9.2). Matches the kill task's long-standing 5s.
285pub const SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
286
287/// Trait for block controllers. Each block type has its own implementation.
288/// Port of Go's `blockcontroller.Controller` interface.
289pub trait Controller: Send + Sync {
290 /// Start the controller. May spawn background tasks.
291 /// `force` restarts even if already running.
292 fn start(
293 &self,
294 block_meta: MetaMapType,
295 rt_opts: Option<serde_json::Value>,
296 force: bool,
297 ) -> Result<(), String>;
298
299 /// Stop the controller.
300 /// `graceful` waits for process to exit; `new_status` is the target state.
301 fn stop(&self, graceful: bool, new_status: &str) -> Result<(), String>;
302
303 /// Stop this controller because it's being REPLACED by a new one for
304 /// the same block (session restart / resync's `needs_replace` path),
305 /// NOT because the block itself is being closed. Must terminate this
306 /// controller's own CLI process so it doesn't linger, but — unlike
307 /// `stop()` — must NOT reach any declared-background descendant the
308 /// process may have spawned (e.g. a `task dev` instance launched via
309 /// `run_in_background: true`); those must survive the replace. See
310 /// docs/specs/SPEC_BACKGROUND_TASK_TEARDOWN_SURVIVAL_2026_08_20.md.
311 ///
312 /// Default delegates to `stop()` — correct for any controller type
313 /// with no subprocess tree of its own to be careful about (nothing to
314 /// preserve, so the two calls are equivalent). Only `ShellController`
315 /// overrides this today.
316 fn stop_for_replace(&self, new_status: &str) -> Result<(), String> {
317 self.stop(true, new_status)
318 }
319
320 /// Stop gracefully because the block is being CLOSED: end any turn in
321 /// progress, ask the process to exit, force-kill at `deadline`. The
322 /// returned future resolves once the process has actually exited (or
323 /// there was none). Does NOT drop the block's process tracker — the
324 /// caller does that after this resolves, so nothing the agent started
325 /// outlives it. See
326 /// docs/specs/SPEC_AGENT_PANE_CLOSE_GRACEFUL_SHUTDOWN_2026_09_18.md §9.3.
327 ///
328 /// Default: today's immediate stop. Right for controllers with no agent
329 /// session worth ending cleanly (shells, cmd, per-turn subprocess) and for
330 /// providers whose exit behaviour hasn't been measured yet (ACP,
331 /// app-server — §9.3 requires that before they get a graceful path).
332 fn shutdown(&self, _deadline: std::time::Instant) -> ShutdownFuture {
333 let _ = self.stop(true, STATUS_DONE);
334 Box::pin(async { StopOutcome::Stopped })
335 }
336
337 /// Get the current runtime status.
338 fn get_runtime_status(&self) -> BlockControllerRuntimeStatus;
339
340 /// Send input (terminal data, signal, or resize) to the controller.
341 /// `seq` is the per-TermViewModel monotonic counter; `None` means fire-and-forget (no ordering).
342 fn send_input(&self, input: BlockInputUnion, seq: Option<u64>) -> Result<(), String>;
343
344 /// Get the controller type (e.g., "shell", "cmd").
345 fn controller_type(&self) -> &str;
346
347 /// Get the block ID.
348 #[allow(dead_code)]
349 fn block_id(&self) -> &str;
350
351 /// This block's own live, spawn-time-captured jekt/muxbus identity, if
352 /// it has one — an independent source of truth for the recipient-
353 /// identity check in `ReactiveHandler::inject_message_inner`, deliberately
354 /// NOT derived from `ReactiveHandler`'s own `name_to_blocks`/`agent_info`
355 /// maps (checking a registry against itself would be a tautology and
356 /// catch nothing). Default `None` for controller types that aren't
357 /// jekt-addressable at all (e.g. plain terminals) — only `ShellController`
358 /// and `PersistentSubprocessController` currently override this, mirroring
359 /// the two paths that already resolve a jekt-registration identity at
360 /// spawn time (`resolve_agent_id_for_jekt`, `muxbus_agent_id_from_env`).
361 fn agent_id(&self) -> Option<String> {
362 None
363 }
364
365 /// What started this agent's current turn (SPEC_AGENT_SELF_QUIT §6.3), if
366 /// the controller tracks it. `None` = unknown, which never counts as the
367 /// user — the safe answer for controllers that can't tell (ACP, App
368 /// Server, subprocess: both origins reach them through one method).
369 fn turn_provenance(&self) -> Option<health::TurnProvenance> {
370 None
371 }
372
373 /// This controller's turn tracker, for the turn ledger's bookkeeping from
374 /// outside the controller (a held message's join hint). `None` for
375 /// controllers that track no turns.
376 fn turn_tracker(&self) -> Option<std::sync::Arc<health::TurnActivityTracker>> {
377 None
378 }
379
380 /// Refresh this block's own captured jekt/muxbus identity (see
381 /// [`agent_id`](Controller::agent_id)'s doc comment). Called whenever
382 /// `ReactiveHandler::register_agent`/`register_agent_with_nonce`
383 /// (re-)registers THIS block's block_id under a (possibly different)
384 /// agent_id, so the two independently-written copies never drift apart
385 /// (#2697: `agent_id()` was captured once at spawn and never
386 /// refreshed, while the registry's name bindings get re-keyed on every
387 /// `register_agent` call — e.g. `handle_reactive_register`'s
388 /// frontend-initiated HTTP path — causing a legitimately renamed or
389 /// reconfigured agent's own messages to be falsely rejected as an
390 /// identity mismatch). Default no-op for controller types that don't
391 /// override [`agent_id`](Controller::agent_id) either.
392 fn set_agent_id(&self, _id: Option<String>) {}
393
394 /// This block's STABLE jekt identity — `AGENTMUX_AGENT_ID` as captured
395 /// once at spawn, never refreshed. Distinct from [`agent_id`](Controller::agent_id),
396 /// which tracks the LIVE, renameable display name and is deliberately
397 /// refreshed every turn (see that method's doc comment on #2697).
398 /// AGENTMUX_AGENT_ID is embedded verbatim in PR-body tags specifically
399 /// because it does NOT change if the agent is later renamed — but
400 /// `ReactiveHandler`'s registry only tracks one key per block, and
401 /// every turn's `agent_id()` refresh evicts whatever this block was
402 /// previously registered under. `ReactiveHandler::inject_message_inner`'s
403 /// recipient-identity check (#2695) uses this alongside `agent_id()` so
404 /// a jekt addressed to the stable ID is validated correctly even after
405 /// the primary registry key has moved on to the live display name. See
406 /// `INCIDENT_2026_09_09_JEKT_STABLE_ID_ALIAS.md`. Default `None`,
407 /// mirroring `agent_id`'s default — only `PersistentSubprocessController`
408 /// currently overrides this.
409 fn stable_agent_id(&self) -> Option<String> {
410 None
411 }
412 /// Identity M2: this block's UID (`db_agents.id`) as carried in its
413 /// spawn env (`AGENTMUX_AGENT_UID`, set by `build_persistent_spawn_env`
414 /// since M1a), captured once at spawn like [`stable_agent_id`]. Backs the
415 /// registry's UID confirmer: consulted only for targets that resolved by
416 /// UID, and `None` — a registered-but-not-yet-spawned controller, or a
417 /// block with no row — is "unverifiable", not a mismatch. Default
418 /// `None`; only `PersistentSubprocessController` overrides this.
419 fn stable_agent_uid(&self) -> Option<String> {
420 None
421 }
422
423 /// Downcast support for concrete controller types.
424 fn as_any(&self) -> &dyn Any;
425}
426
427// ---- Global controller registry ----
428
429/// Thread-safe global controller registry.
430/// Maps block_id → Arc<dyn Controller>.
431static CONTROLLER_REGISTRY: std::sync::LazyLock<RwLock<HashMap<String, Arc<dyn Controller>>>> =
432 std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
433
434/// Get a controller by block ID.
435pub fn get_controller(block_id: &str) -> Option<Arc<dyn Controller>> {
436 CONTROLLER_REGISTRY.read().unwrap().get(block_id).cloned()
437}
438
439/// Register a controller, stopping any previous one for the same block.
440pub fn register_controller(block_id: &str, controller: Arc<dyn Controller>) {
441 let mut registry = CONTROLLER_REGISTRY.write().unwrap();
442 if let Some(old) = registry.remove(block_id) {
443 // Stop the old controller before replacing
444 let _ = old.stop(true, STATUS_DONE);
445 }
446 registry.insert(block_id.to_string(), controller);
447 drop(registry);
448 notify_tracked_blocks_changed();
449}
450
451/// Announce a `CONTROLLER_REGISTRY` membership change to the Process Broker,
452/// if one is running (never set in most unit tests). See
453/// `ProcessBroker::emit_tracked_blocks_changed`'s doc comment for why this
454/// exists: `agent.tracked-blocks` reads this registry directly, but nothing
455/// used to tell a subscriber when a write here changed what that read would
456/// return — Swarm's client-side list depended on two unrelated, independently-
457/// timed events instead, and went stale whenever neither happened to fire for
458/// a given block. See docs/reports/REPORT_SWARM_MOUNT_DEPENDENT_TRACKING_GAP_2026_09_15.md.
459fn notify_tracked_blocks_changed() {
460 if let Some(broker) = crate::broker::process::global() {
461 broker.emit_tracked_blocks_changed();
462 }
463}
464
465/// Remove a controller from `CONTROLLER_REGISTRY` only — does NOT touch the
466/// process tracker or the Process Broker's cached status, unlike
467/// `delete_controller` below. For `resync_controller`'s replace path
468/// (session restart), where a NEW controller for the same block is about
469/// to be registered right after this call: `AgentProcessRegistry::
470/// ensure_tracker` is idempotent by design ("the job survives controller
471/// re-creation" — its own doc comment), so leaving the tracker alone here
472/// means any declared-background descendant (e.g. `task dev`) the old
473/// controller's process spawned stays alive and gets reattached to the new
474/// controller's own `track_spawned` call, instead of dying with the old
475/// one. The caller is responsible for actually terminating the OLD
476/// controller's own process first (via `stop_for_replace`, not `stop`) —
477/// this function only ever touches the registry map. See
478/// docs/specs/SPEC_BACKGROUND_TASK_TEARDOWN_SURVIVAL_2026_08_20.md.
479fn remove_controller_entry_only(block_id: &str) {
480 CONTROLLER_REGISTRY.write().unwrap().remove(block_id);
481}
482
483/// Blocks whose pane is being closed. Between stopping a block's
484/// controller and deleting its record, a `resync_controller` (frontend
485/// ControllerResync, `agent.open`, ...) would otherwise find the block
486/// still present and spawn a fresh process for it — an orphan the close
487/// never sees. See docs/specs/SPEC_AGENT_PANE_CLOSE_GRACEFUL_SHUTDOWN_2026_09_18.md
488/// §4.2 step 1.
489///
490/// Each entry also carries a completion signal (`true` once the block's
491/// process has exited), so a reopen racing the close can wait for the old
492/// process instead of starting a second `--resume` on the same session
493/// (spec §9.6).
494static CLOSING_BLOCKS: std::sync::LazyLock<RwLock<HashMap<String, tokio::sync::watch::Sender<bool>>>> =
495 std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
496
497/// Refuse to (re)spawn a controller for `block_id` until [`unmark_closing`].
498pub fn mark_closing(block_id: &str) {
499 CLOSING_BLOCKS
500 .write()
501 .unwrap()
502 .entry(block_id.to_string())
503 .or_insert_with(|| tokio::sync::watch::channel(false).0);
504}
505
506/// Signal that a closing block's process has exited. The block stays marked
507/// closing (no respawn) until [`unmark_closing`].
508pub fn mark_closing_stopped(block_id: &str) {
509 if let Some(tx) = CLOSING_BLOCKS.read().unwrap().get(block_id) {
510 tx.send_replace(true);
511 }
512}
513
514/// Lift [`mark_closing`]. Called once the block's record is gone (or the
515/// close failed and the block stays). Wakes anyone still waiting.
516pub fn unmark_closing(block_id: &str) {
517 if let Some(tx) = CLOSING_BLOCKS.write().unwrap().remove(block_id) {
518 tx.send_replace(true);
519 }
520}
521
522pub fn is_closing(block_id: &str) -> bool {
523 CLOSING_BLOCKS.read().unwrap().contains_key(block_id)
524}
525
526/// Blocks mid-close whose process has not exited yet. Their controllers have
527/// already left `CONTROLLER_REGISTRY`, so a registry scan can't see them.
528pub fn closing_blocks_still_running() -> Vec<String> {
529 CLOSING_BLOCKS
530 .read()
531 .unwrap()
532 .iter()
533 .filter(|(_, stopped)| !*stopped.borrow())
534 .map(|(id, _)| id.clone())
535 .collect()
536}
537
538/// Wait until a closing block's process has exited, or `timeout`. Returns
539/// immediately for a block that isn't closing.
540pub async fn wait_closing_stopped(block_id: &str, timeout: std::time::Duration) {
541 let rx = CLOSING_BLOCKS.read().unwrap().get(block_id).map(|tx| tx.subscribe());
542 if let Some(mut rx) = rx {
543 let _ = tokio::time::timeout(timeout, rx.wait_for(|stopped| *stopped)).await;
544 }
545}
546
547/// Unregister (delete) a controller by block ID, stopping it first.
548/// Removes from the registry before calling stop() so no new callers can reach it.
549pub fn delete_controller(block_id: &str) {
550 let ctrl = take_controller(block_id);
551 if let Some(ctrl) = ctrl {
552 let _ = ctrl.stop(true, STATUS_DONE);
553 }
554 release_block_processes(block_id);
555 term_size::forget(block_id);
556}
557
558/// Remove a controller from `CONTROLLER_REGISTRY` and hand it back, without
559/// stopping it — for the graceful close, which awaits
560/// [`Controller::shutdown`] and only then calls [`release_block_processes`].
561/// Once removed, no new input reaches it.
562pub fn take_controller(block_id: &str) -> Option<Arc<dyn Controller>> {
563 CONTROLLER_REGISTRY.write().unwrap().remove(block_id)
564}
565
566/// Drop the block's process tracker (killing anything left in its tree) and
567/// its Process Broker entry. The second half of [`delete_controller`]; a
568/// graceful close calls it only after the process has exited.
569pub fn release_block_processes(block_id: &str) {
570 // Drop the process tracker for this block: the Windows job's
571 // `KILL_ON_JOB_CLOSE`, the Linux cgroup tracker's `Drop` (`cgroup.kill`)
572 // and the `ScanTracker`'s `Drop` (its tagged tree, best effort: macOS,
573 // Linux without delegation) end the whole descendant tree. Nothing on the
574 // stub (Unix terminals). No-op if the tracker global isn't initialized.
575 if let Some(registry) = crate::backend::process_tracker::registry::global() {
576 registry.remove(block_id);
577 }
578 // Drop the Process Broker's cached status for this block, so a closed
579 // pane's stale entry doesn't linger unreachable (get_all_controllers()
580 // already won't list it — see ProcessBroker::forget's doc comment).
581 if let Some(broker) = crate::broker::process::global() {
582 broker.forget(block_id);
583 broker.emit_tracked_blocks_changed();
584 }
585 // Identity M4a: the block's "spawned with a token" record goes with it.
586 crate::backend::identity_spawn::forget_block(block_id);
587}
588
589// ---- Close-on-exit handler (SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md §10) ----
590
591/// `shell/lifecycle.rs`'s PTY wait/cleanup task needs to actually CLOSE a
592/// pane (delete its block, prune it from the owning tab's layout tree, and
593/// notify any connected frontend) when the shell inside it exits — but that
594/// task lives deep in `backend::blockcontroller`, which has no `AppState`
595/// handle (only the individual `Store`/`EventBus`/`Broker` pieces
596/// `ShellController` was constructed with). The actual close logic
597/// (`crate::sagas::delete_block::run`) needs the full `AppState` — its
598/// reducer state, saga-id allocator, and saga log, not just those three
599/// pieces. Rather than threading `AppState` through every
600/// `ShellController::new`/`resync_controller` call site (a much larger,
601/// architecture-inverting change), this mirrors the exact shape
602/// `crate::broker::process::set_global`/`global` already use for the
603/// identical class of problem (`broker::process`'s `AppState`-derived
604/// `ProcessBroker` also needs to be reachable from deep in `blockcontroller`
605/// without a direct dependency): a process-wide callback slot, set once at
606/// boot from `bootstrap::install_close_on_exit_handler` (which has
607/// `AppState` in scope) and read from `shell/lifecycle.rs`.
608type CloseOnExitFn = Arc<dyn Fn(String, String) + Send + Sync>;
609
610static CLOSE_ON_EXIT_HANDLER: std::sync::OnceLock<CloseOnExitFn> = std::sync::OnceLock::new();
611
612/// Install the close-on-exit callback. Called exactly once, from
613/// `bootstrap::install_close_on_exit_handler` right after `AppState` is
614/// built in `main.rs` — see that function's doc comment for what the
615/// callback actually does. A second call is a no-op (`OnceLock::set`
616/// silently returns `Err` on an already-initialized cell, which this
617/// discards — matching `broker::process::set_global`'s identical
618/// fire-and-forget style; there is exactly one legitimate caller).
619pub fn set_close_on_exit_handler(f: CloseOnExitFn) {
620 let _ = CLOSE_ON_EXIT_HANDLER.set(f);
621}
622
623/// Trigger the installed close-on-exit handler for `(tab_id, block_id)`, if
624/// one has been installed. Fire-and-forget: the handler owns spawning
625/// whatever async work it needs (the saga call is `async`; this function
626/// itself is not). A missing handler (astronomically early in boot, before
627/// `main.rs` finishes wiring `AppState` — no real caller can reach this
628/// before then, since nothing spawns a PTY that early) just skips closing;
629/// the pane sits inert the same way it did before this feature existed, not
630/// a hard failure.
631pub(crate) fn close_on_exit(tab_id: String, block_id: String) {
632 match CLOSE_ON_EXIT_HANDLER.get() {
633 Some(f) => f(tab_id, block_id),
634 None => {
635 tracing::warn!(
636 block_id = %block_id,
637 "close_on_exit: handler not installed yet, skipping pane close"
638 );
639 }
640 }
641}
642
643/// Get all controllers (snapshot).
644pub fn get_all_controllers() -> HashMap<String, Arc<dyn Controller>> {
645 CONTROLLER_REGISTRY.read().unwrap().clone()
646}
647
648/// Stop all running controllers gracefully.
649#[allow(dead_code)]
650pub fn stop_all_controllers() {
651 let controllers = get_all_controllers();
652 for (_, ctrl) in controllers {
653 let _ = ctrl.stop(true, STATUS_DONE);
654 }
655}
656
657// ---- Public API functions ----
658
659/// Get the runtime status for a block's controller.
660/// Returns None if no controller is registered.
661pub fn get_block_controller_status(block_id: &str) -> Option<BlockControllerRuntimeStatus> {
662 get_controller(block_id).map(|c| c.get_runtime_status())
663}
664
665/// Stop a block's controller gracefully.
666#[allow(dead_code)]
667pub fn stop_block_controller(block_id: &str) -> Result<(), String> {
668 match get_controller(block_id) {
669 Some(ctrl) => ctrl.stop(true, STATUS_DONE),
670 None => Ok(()), // No controller = already stopped
671 }
672}
673
674/// Send input to a block's controller.
675pub fn send_input(block_id: &str, input: BlockInputUnion, seq: Option<u64>) -> Result<(), String> {
676 let resize = input
677 .term_size
678 .as_ref()
679 .filter(|ts| ts.rows > 0 && ts.cols > 0)
680 .map(|ts| (ts.rows.min(u16::MAX as i64) as u16, ts.cols.min(u16::MAX as i64) as u16));
681 match get_controller(block_id) {
682 Some(ctrl) => {
683 ctrl.send_input(input, seq)?;
684 // The size a paired device watching the terminal renders at.
685 if let Some((rows, cols)) = resize {
686 term_size::record(block_id, rows, cols);
687 }
688 Ok(())
689 }
690 None => Err(format!("no controller for block {block_id}")),
691 }
692}
693
694/// What an automated send to a persistent agent did with one message.
695///
696/// A live process gets it at once, mid-turn or not; it is queued only while
697/// the process cannot take a write (starting up, restarting, stopping).
698/// Spec: `docs/specs/SPEC_JEKT_IMMEDIATE_DELIVERY_2026_09_28.md` §2.1.
699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700pub enum SendOutcome {
701 /// Written to the agent's input now.
702 Sent,
703 /// Queued because the agent's process is starting up, restarting or
704 /// stopping (or behind older messages queued for that reason); written as
705 /// soon as the process can take it.
706 Deferred,
707}
708
709/// How a controller-aware agent message was delivered.
710#[derive(Debug)]
711pub enum AgentDelivery {
712 /// Accepted on the controller's structured input channel — a persistent
713 /// stream-json stdin line or an ACP `session/prompt` — and written now. No
714 /// PTY keystrokes are needed.
715 Structured,
716 /// Accepted like [`AgentDelivery::Structured`], but queued because the
717 /// agent's process is starting up, restarting or stopping; written as soon
718 /// as it can take it. The controller has taken responsibility for it; the
719 /// agent has not seen it yet.
720 StructuredDeferred,
721 /// The controller is PTY/terminal-based (shell/term) or otherwise has no
722 /// structured input channel. The caller should fall back to keystroke
723 /// injection.
724 Pty,
725}
726
727/// Deliver an inter-agent / muxbus message to a running agent the way its controller
728/// expects.
729///
730/// Callers of this function are automated senders by definition — muxbus, the
731/// reactive/jekt handler, MCP `SendMessage`, the messaging bridges. The message
732/// reaches the agent at once, whatever it is doing: the agent gets it as soon
733/// as it can act on it (`docs/specs/SPEC_JEKT_IMMEDIATE_DELIVERY_2026_09_28.md`
734/// §2.1). The human operator's own input does not come through here — it goes
735/// via the `agentinput` RPC.
736///
737/// - **Persistent** (stream-json) agents have no PTY: the message is written as a
738/// `{type:"user",…}` line on the live stdin, mid-turn included. It is queued
739/// only while the process is starting up, restarting or stopping.
740/// - **ACP** agents receive the message as a `session/prompt` (the ACP controller's
741/// `send_input` already wraps raw input that way).
742/// - **App Server** (Codex) agents have no PTY either: the message is queued/dispatched
743/// as a `turn/start` request via [`app_server_controller::AppServerController::send_message`].
744/// - Everything else (shell/term PTY agents, one-shot subprocess agents) is reported
745/// as [`AgentDelivery::Pty`] so the caller uses keystroke injection — preserving
746/// today's behavior.
747///
748/// This is the controller-aware delivery primitive muxbus Tier-1 needs: PTY
749/// keystrokes silently fail to reach a persistent stream-json agent (it rejects raw
750/// input). Spec: docs/specs/SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md §6 (Phase 3).
751pub fn deliver_agent_message(block_id: &str, message: &str) -> Result<AgentDelivery, String> {
752 deliver_agent_message_from(block_id, message, None)
753}
754
755/// [`deliver_agent_message`], saying who the input is from (turn provenance,
756/// SPEC_AGENT_SELF_QUIT §6.3). Only the persistent controller records it
757/// (see `send_user_message_outcome_from`); ACP and App Server turns carry no
758/// provenance on any path today, the user's own typed turn included.
759pub fn deliver_agent_message_from(
760 block_id: &str,
761 message: &str,
762 origin: Option<health::TurnInput>,
763) -> Result<AgentDelivery, String> {
764 let ctrl =
765 get_controller(block_id).ok_or_else(|| format!("no controller for block {block_id}"))?;
766
767 if let Some(persistent_ctrl) = ctrl
768 .as_any()
769 .downcast_ref::<persistent::PersistentSubprocessController>()
770 {
771 return Ok(match persistent_ctrl.send_user_message_outcome_from(message.to_string(), origin)? {
772 SendOutcome::Sent => AgentDelivery::Structured,
773 SendOutcome::Deferred => AgentDelivery::StructuredDeferred,
774 });
775 }
776
777 if ctrl.controller_type() == BLOCK_CONTROLLER_ACP {
778 ctrl.send_input(BlockInputUnion::data(message.as_bytes().to_vec()), None)?;
779 return Ok(AgentDelivery::Structured);
780 }
781
782 if let Some(app_server_ctrl) = ctrl
783 .as_any()
784 .downcast_ref::<app_server_controller::AppServerController>()
785 {
786 app_server_ctrl.send_message(message.to_string())?;
787 return Ok(AgentDelivery::Structured);
788 }
789
790 Ok(AgentDelivery::Pty)
791}
792
793/// Is this forced replace merely a runtime-config change (model / effort /
794/// permission) on a controller that stays persistent — i.e. the one case that
795/// may be deferred to the end of an in-flight turn rather than killing it?
796///
797/// Requires the type to be persistent on BOTH sides. Checking only the
798/// existing side was a real bug (reagent P2 on PR #2858): the container-agent
799/// override in `resync_controller` rewrites the target persistent ->
800/// subprocess, so a forced resync landing mid-turn on such a block would defer
801/// and silently keep the persistent controller — precisely the
802/// incompatibility that override exists to correct.
803///
804/// A genuine type change must replace immediately, mid-turn or not; losing an
805/// in-flight turn is the lesser harm against running a container agent on a
806/// controller that cannot do per-turn `docker exec` at all.
807fn is_runtime_config_only_replace(existing_type: &str, target_type: &str, force: bool) -> bool {
808 force
809 && existing_type == BLOCK_CONTROLLER_PERSISTENT
810 && target_type == BLOCK_CONTROLLER_PERSISTENT
811}
812
813/// Resync a block's controller — the main entry point for starting/restarting blocks.
814/// Port of Go's `ResyncController`.
815///
816/// Logic, for the `block` passed in:
817/// 1. Determine controller type from meta["controller"]
818/// 2. If the existing controller needs replacing (type changed, conn changed, force), stop it
819/// 3. Create a new controller if needed
820/// 4. Start it if status is init, or done when `respawn_if_done` allows (below)
821///
822/// `respawn_if_done`: when the existing controller's status is
823/// `STATUS_DONE` (its process already exited), should this call revive it
824/// via `ctrl.start()` (the historical, unconditional behavior — pass
825/// `true`), or leave it dead and return `Err(RESYNC_ERR_ALREADY_EXITED)`
826/// instead (pass `false`)? `true` is correct for crash/backend-restart
827/// recovery (the WS `ControllerResyncCommand` path, `TermResyncHandler` —
828/// see its own doc comment for why a dead PTY must come back transparently
829/// there). `false` is for a caller that needs to tell "this exited on
830/// purpose" apart from "this needs reviving" using the SAME status read
831/// `resync_controller` itself makes, rather than checking status in the
832/// caller first and racing a StatusDone transition landing between that
833/// check and this call — `try_attach_to_existing_shell`
834/// (`server/mod.rs`, PtyShellCreate's pane-shell-reuse path) is the one
835/// caller that needs this; see
836/// `docs/specs/SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md` §7.
837pub fn resync_controller(
838 block: &Block,
839 tab_id: &str,
840 rt_opts: Option<serde_json::Value>,
841 force: bool,
842 respawn_if_done: bool,
843 broker: Option<Arc<Broker>>,
844 event_bus: Option<Arc<EventBus>>,
845 mstore: Option<Arc<Store>>,
846 filestore: Option<Arc<FileStore>>,
847 // `id_store`/`identity_store`: the Layer 3 identity/credential spawn
848 // gate's dependencies (`identity::resolver::inject_identity_env`, per
849 // `SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md`). Only consulted
850 // by `PersistentSubprocessController::start()`'s eager-resume path
851 // (`SPEC_PERSISTENT_CONTROLLER_EAGER_RESUME_ON_RECONNECT_2026_09_20.md`)
852 // — every other controller type ignores them. `None` here does not
853 // disable the gate for a LIVE message send (that path, `agent_handlers::
854 // input`, has its own direct access to these stores) — it only means a
855 // controller CONSTRUCTED via this call can't eager-resume at all, and
856 // falls back to today's lazy "wait for the first message" behavior even
857 // when `agent:sessionid` is present. That fallback is the deliberately
858 // safe default: spawning an eager resume WITHOUT the gate would spawn a
859 // possibly-deauthed agent on whatever ambient credential is lying
860 // around, silently reintroducing the exact vulnerability class that gate
861 // was built to close. See `PersistentSubprocessController::start()`'s
862 // own doc comment.
863 id_store: Option<Arc<Store>>,
864 identity_store: Option<Arc<Store>>,
865 registry: Option<Arc<crate::registry::Registry>>,
866 boot_id: Arc<str>,
867 auth_key: &str,
868 // The live, file-watched config (`AppState::config_watcher`). Only the
869 // shell/cmd controller reads it, for the global `cmd:env` defaults its
870 // interactive-shell spawn applies. `None` (tests) means no global
871 // defaults.
872 config: Option<Arc<crate::backend::wconfig::ConfigState>>,
873) -> Result<(), String> {
874 let block_id = &block.oid;
875 let block_meta = &block.meta;
876
877 // Get controller type from block meta
878 let controller_type = super::obj::meta_get_string(block_meta, META_KEY_CONTROLLER, "");
879
880 if controller_type.is_empty() {
881 // No controller type = web/static block, nothing to manage
882 return Ok(());
883 }
884
885 if is_closing(block_id) {
886 return Err(format!("resync_controller: block {block_id} is closing"));
887 }
888
889 // Container agents (agentMode == "container") require a subprocess controller for
890 // per-turn docker exec. Persistent is incompatible. Override any stale "persistent"
891 // value that may have been written before this invariant was enforced at creation time.
892 let agent_mode = super::obj::meta_get_string(block_meta, "agentMode", "");
893 let controller_type =
894 if agent_mode == "container" && controller_type == BLOCK_CONTROLLER_PERSISTENT {
895 tracing::warn!(
896 block_id = %block_id,
897 "container agent has persistent controller in meta — overriding to subprocess"
898 );
899 BLOCK_CONTROLLER_SUBPROCESS.to_string()
900 } else {
901 controller_type
902 };
903
904 tracing::info!(
905 block_id = %block_id,
906 controller_type = %controller_type,
907 mstore_present = mstore.is_some(),
908 event_bus_present = event_bus.is_some(),
909 force,
910 "[dnd-debug] resync_controller entry"
911 );
912
913 // Check if existing controller needs to be replaced
914 let existing = get_controller(block_id);
915 if let Some(ref ctrl) = existing {
916 let needs_replace = if ctrl.controller_type() != controller_type || force {
917 true // Type changed or forced restart
918 } else {
919 let status = ctrl.get_runtime_status();
920 // Check if connection changed
921 let new_conn = super::obj::meta_get_string(block_meta, META_KEY_CONNECTION, "local");
922 status.shellprocconnname != new_conn
923 };
924
925 // A forced resync that lands mid-turn on a persistent agent must NOT
926 // tear the controller down: doing so kills the CLI process with the
927 // user's in-flight message already written to its stdin, and nothing
928 // replays it — `stop_process` records a `StopRequested` (which the
929 // resume machine reads as an explicit user stop and so suppresses the
930 // retry), the old controller's queue is discarded with it, and the
931 // replacement "spawns on first message", i.e. does nothing. The turn
932 // vanished with no response and no error. Diagnosed live on AgentX,
933 // 2026-08-28.
934 //
935 // Instead the controller restarts itself the moment the turn ends,
936 // picking up the new `cmd:args` (rebuilt from block meta at spawn) on
937 // the next message. Nothing is lost by waiting: model/effort flags are
938 // baked in at spawn, so they could never have applied to the turn
939 // already running.
940 //
941 // Scoped to a runtime-config tweak: the controller must be persistent
942 // now AND still be persistent afterwards. A controller-TYPE change is a
943 // genuine replacement that has to happen immediately, even mid-turn.
944 //
945 // Checking only the EXISTING type was a real bug (reagent P2): the
946 // container-agent override a few lines above rewrites `controller_type`
947 // persistent -> subprocess, so a forced resync landing mid-turn on such
948 // a block would have deferred and silently kept the persistent
949 // controller — the exact incompatibility that override exists to
950 // correct, and the opposite of what this comment claims.
951 if needs_replace
952 && is_runtime_config_only_replace(ctrl.controller_type(), &controller_type, force)
953 {
954 if let Some(p) = ctrl
955 .as_any()
956 .downcast_ref::<persistent::PersistentSubprocessController>()
957 {
958 if p.request_restart_when_idle() {
959 return Ok(());
960 }
961 }
962 }
963
964 if needs_replace {
965 // stop_for_replace + remove_controller_entry_only, NOT stop +
966 // delete_controller: a session restart/resync must not tear
967 // down the block's shared process tracker — only the old
968 // controller's own CLI process should die. See
969 // docs/specs/SPEC_BACKGROUND_TASK_TEARDOWN_SURVIVAL_2026_08_20.md.
970 let _ = crate::sagas::agent_teardown::replace_now(ctrl.as_ref());
971 remove_controller_entry_only(block_id);
972 } else {
973 // Existing controller is fine, just check if it needs starting
974 let status = ctrl.get_runtime_status();
975 tracing::info!(
976 block_id = %block_id,
977 status = %status.shellprocstatus,
978 "[dnd-debug] existing controller — skipping spawn (no cmd:cwd seed)"
979 );
980 if status.shellprocstatus == STATUS_INIT {
981 return ctrl.start(block_meta.clone(), rt_opts, force);
982 }
983 if status.shellprocstatus == STATUS_DONE {
984 if respawn_if_done {
985 return ctrl.start(block_meta.clone(), rt_opts, force);
986 }
987 return Err(RESYNC_ERR_ALREADY_EXITED.to_string());
988 }
989 return Ok(());
990 }
991 }
992
993 // Create new controller
994 match controller_type.as_str() {
995 // A durable SSH pane: the shell lives in the helper's session on the
996 // host, reached over ssh (durable_ssh.rs). Same `shell` type and
997 // connection name as the meta says, so a resync keeps it.
998 BLOCK_CONTROLLER_SHELL
999 if durable_mode::wants(
1000 block_meta,
1001 config.as_ref().map(|c| c.get_full_config()).as_deref(),
1002 ) =>
1003 {
1004 // When the user declines the helper, the pane restarts as a plain
1005 // SSH terminal: the same resync, forced, which then finds the
1006 // pane no longer durable (durable_mode::wants).
1007 let fallback: durable_ssh::Fallback = {
1008 let (block_id, tab_id, rt_opts) = (block_id.to_string(), tab_id.to_string(), rt_opts.clone());
1009 let (broker, event_bus, mstore, filestore) =
1010 (broker.clone(), event_bus.clone(), mstore.clone(), filestore.clone());
1011 let (id_store, identity_store, registry) = (id_store.clone(), identity_store.clone(), registry.clone());
1012 let (boot_id, auth_key, config) = (boot_id.clone(), auth_key.to_string(), config.clone());
1013 Arc::new(move || {
1014 let Some(block) = mstore.as_ref().and_then(|s| s.get::<Block>(&block_id).ok().flatten()) else {
1015 return false;
1016 };
1017 // Still durable: a restart would only start the same
1018 // durable controller again. Leave the pane stopped.
1019 if durable_mode::wants(&block.meta, config.as_ref().map(|c| c.get_full_config()).as_deref()) {
1020 tracing::warn!(block_id = %block_id, "durable ssh: still durable, not falling back");
1021 return false;
1022 }
1023 if let Err(e) = resync_controller(
1024 &block,
1025 &tab_id,
1026 rt_opts.clone(),
1027 true,
1028 true,
1029 broker.clone(),
1030 event_bus.clone(),
1031 mstore.clone(),
1032 filestore.clone(),
1033 id_store.clone(),
1034 identity_store.clone(),
1035 registry.clone(),
1036 boot_id.clone(),
1037 &auth_key,
1038 config.clone(),
1039 ) {
1040 tracing::warn!(block_id = %block_id, error = %e, "durable ssh: plain terminal fallback failed");
1041 return false;
1042 }
1043 true
1044 })
1045 };
1046 let ctrl = Arc::new(durable_ssh::DurableSshController::new(
1047 block_id.to_string(),
1048 broker,
1049 event_bus,
1050 mstore,
1051 filestore,
1052 auth_key.to_string(),
1053 config.clone(),
1054 Some(fallback),
1055 ));
1056 register_controller(block_id, ctrl.clone());
1057 ctrl.start(block_meta.clone(), rt_opts, force)
1058 }
1059 BLOCK_CONTROLLER_SHELL | BLOCK_CONTROLLER_CMD => {
1060 let ctrl = shell::ShellController::new(
1061 controller_type.clone(),
1062 tab_id.to_string(),
1063 block_id.to_string(),
1064 broker,
1065 event_bus,
1066 mstore,
1067 filestore,
1068 auth_key.to_string(),
1069 )
1070 .with_config(config);
1071 let ctrl = Arc::new(ctrl);
1072 register_controller(block_id, ctrl.clone());
1073 let result = ctrl.start(block_meta.clone(), rt_opts, force);
1074 // codex P2 on PR #3219: ShellController::new starts with
1075 // is_agent_pane: false and start() only sets it (synchronously,
1076 // before returning — shell/lifecycle.rs's start()) after actually
1077 // spawning. register_controller's notification above therefore
1078 // fired before list_agent_panes()'s is_agent() filter would have
1079 // included this block; notify again now that start() has run so
1080 // the classified state gets an authoritative announcement too,
1081 // instead of depending solely on a proxy event or the poll.
1082 notify_tracked_blocks_changed();
1083 result
1084 }
1085 BLOCK_CONTROLLER_SUBPROCESS => {
1086 let ctrl = subprocess::SubprocessController::new(
1087 tab_id.to_string(),
1088 block_id.to_string(),
1089 broker,
1090 event_bus,
1091 mstore,
1092 filestore,
1093 registry,
1094 boot_id,
1095 );
1096 let ctrl = Arc::new(ctrl);
1097 ctrl.set_self_ref();
1098 register_controller(block_id, ctrl.clone());
1099 let result = ctrl.start(block_meta.clone(), rt_opts, force);
1100 notify_tracked_blocks_changed();
1101 result
1102 }
1103 BLOCK_CONTROLLER_PERSISTENT => {
1104 let ctrl = persistent::PersistentSubprocessController::new(
1105 tab_id.to_string(),
1106 block_id.to_string(),
1107 broker,
1108 event_bus,
1109 mstore,
1110 filestore,
1111 )
1112 .with_identity_stores(id_store, identity_store, auth_key.to_string())
1113 // One live instance per agent (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24).
1114 .with_agent_lease_store(registry, boot_id);
1115 let ctrl = Arc::new(ctrl);
1116 ctrl.set_self_ref();
1117 register_controller(block_id, ctrl.clone());
1118 let result = ctrl.start(block_meta.clone(), rt_opts, force);
1119 notify_tracked_blocks_changed();
1120 result
1121 }
1122 BLOCK_CONTROLLER_ACP => {
1123 let ctrl = acp::AcpController::new(
1124 tab_id.to_string(),
1125 block_id.to_string(),
1126 broker,
1127 event_bus,
1128 mstore,
1129 filestore,
1130 );
1131 let ctrl = Arc::new(ctrl);
1132 register_controller(block_id, ctrl.clone());
1133 let result = ctrl.start(block_meta.clone(), rt_opts, force);
1134 notify_tracked_blocks_changed();
1135 result
1136 }
1137 BLOCK_CONTROLLER_APP_SERVER => {
1138 let ctrl = app_server_controller::AppServerController::new(
1139 block_id.to_string(),
1140 broker,
1141 event_bus,
1142 mstore,
1143 filestore,
1144 );
1145 let ctrl = Arc::new(ctrl);
1146 ctrl.set_self_ref();
1147 register_controller(block_id, ctrl.clone());
1148 ctrl.start(block_meta.clone(), rt_opts, force)
1149 }
1150 _ => Err(format!("unknown controller type: {controller_type}")),
1151 }
1152}
1153
1154/// Publish a controller status event via MPS broker. The sole publish point
1155/// for `controllerstatus`, used by every controller type (persistent CLI,
1156/// subprocess CLI, ACP agents, plain shell/PTY panes — 13 call sites).
1157///
1158/// `persist: 1` so a reconnecting subscriber (WS reconnect, srv restart)
1159/// replays the last known status instead of seeing nothing until the next
1160/// live event — mirrors `EVENT_AGENT_FAILURE`'s identical `persist: 1` in
1161/// `subprocess/host_spawn.rs`. Closes the "missed the one turn-end push,
1162/// stuck showing Working forever" gap for the reconnect case; see
1163/// REPORT_LOGIN_PERSIST_FAILURE_AND_STUCK_WORKING_2026_07_27.md §3/§4 item 5.
1164/// (A same-connection pane remount, which doesn't clear the broker's
1165/// per-route replay tracking, isn't covered by persist alone — see the
1166/// focus-triggered reconcile in `agent-view.tsx` for that case.)
1167pub fn publish_controller_status(
1168 broker: &super::mps::Broker,
1169 status: &BlockControllerRuntimeStatus,
1170) {
1171 broker.publish_event::<super::mps_events::ControllerStatus>(status, vec![format!("block:{}", status.blockid)], 1);
1172}
1173
1174/// The status an agent-CLI controller reports: a local agent pane with no PID,
1175/// program name or spawn time, so only these five fields vary between sites.
1176pub(crate) fn agent_runtime_status(
1177 block_id: &str,
1178 version: i32,
1179 proc_status: &str,
1180 exit_code: i32,
1181 turn_active: bool,
1182) -> BlockControllerRuntimeStatus {
1183 BlockControllerRuntimeStatus {
1184 durable: false,
1185 blockid: block_id.to_string(),
1186 version,
1187 shellprocstatus: proc_status.to_string(),
1188 shellprocconnname: "local".to_string(),
1189 shellprocexitcode: exit_code,
1190 shellprocpid: None,
1191 shellprocname: String::new(),
1192 spawn_ts_ms: None,
1193 is_agent_pane: true,
1194 turn_active,
1195 }
1196}
1197
1198/// The `agent-message-accepted` event's payload (`mps_events::AgentMessageAccepted`).
1199#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
1200#[ts(export, export_to = "../../../frontend/types/rpc/")]
1201pub struct MessageAcceptedEvent {
1202 pub block_id: String,
1203 pub message_id: String,
1204}
1205
1206/// Publish `agent-message-accepted` for `message_id` on `block:<id>`, telling
1207/// the frontend to promote that pending entry from queued to in-document.
1208pub(crate) fn publish_message_accepted(broker: &super::mps::Broker, block_id: &str, message_id: &str) {
1209 let payload = MessageAcceptedEvent { block_id: block_id.to_string(), message_id: message_id.to_string() };
1210 broker.publish_event::<super::mps_events::AgentMessageAccepted>(&payload, vec![format!("block:{}", block_id)], 0);
1211}
1212
1213#[cfg(test)]
1214mod cmd_env_tests {
1215 use super::{cmd_env_of, MetaMapType, META_KEY_CMD_ENV};
1216
1217 #[test]
1218 fn cmd_env_reads_the_object_form_and_the_json_string_form() {
1219 let mut meta = MetaMapType::new();
1220 meta.insert(META_KEY_CMD_ENV.to_string(), serde_json::json!({"K": "v", "N": 1}));
1221 let env = cmd_env_of(&meta);
1222 assert_eq!(env.get("K").map(String::as_str), Some("v"));
1223 assert!(!env.contains_key("N"), "non-string values are skipped");
1224
1225 meta.insert(META_KEY_CMD_ENV.to_string(), serde_json::json!("{\"K\":\"w\"}"));
1226 assert_eq!(cmd_env_of(&meta).get("K").map(String::as_str), Some("w"));
1227
1228 meta.insert(META_KEY_CMD_ENV.to_string(), serde_json::json!(["not", "a", "map"]));
1229 assert!(cmd_env_of(&meta).is_empty());
1230 assert!(cmd_env_of(&MetaMapType::new()).is_empty());
1231 }
1232
1233 #[test]
1234 fn agent_id_from_env_prefers_the_canonical_key_and_skips_blanks() {
1235 use super::agent_id_from_env;
1236 let id = |pairs: &[(&'static str, &'static str)]| {
1237 let pairs = pairs.to_vec();
1238 agent_id_from_env(move |k| pairs.iter().find(|(key, _)| *key == k).map(|(_, v)| *v))
1239 };
1240 assert_eq!(id(&[("AGENTMUX_AGENT_ID", " new "), ("WAVEMUX_AGENT_ID", "old")]).as_deref(), Some("new"));
1241 assert_eq!(id(&[("AGENTMUX_AGENT_ID", " "), ("WAVEMUX_AGENT_ID", "old")]).as_deref(), Some("old"));
1242 assert_eq!(id(&[("WAVEMUX_AGENT_ID", "")]), None);
1243 assert_eq!(id(&[]), None);
1244 }
1245}
1246
1247#[cfg(test)]
1248mod tests;