Skip to main content

agentmux_srv/backend/blockcontroller/shell/
lifecycle.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The `impl Controller for ShellController` block: start/stop/send_input/
5//! get_runtime_status. Kept whole in one file (a trait impl cannot be split
6//! across modules); it orchestrates the helpers extracted into the sibling
7//! `pty`, `file_ops`, and `translation` modules.
8
9use std::io::Read as _;
10use std::sync::atomic::Ordering;
11use std::sync::Arc;
12use std::time::Instant;
13
14#[cfg(unix)]
15use libc;
16
17use portable_pty::{native_pty_system, CommandBuilder, PtySize};
18use tokio::sync::mpsc;
19
20use super::super::{
21    BlockControllerRuntimeStatus, BlockInputUnion, Controller, META_KEY_CMD_ENV, STATUS_DONE,
22    STATUS_RUNNING,
23};
24#[cfg(unix)]
25use super::controller::KILL_GRACE_SECS;
26use super::controller::{ShellController, SHELL_INPUT_CH_SIZE};
27use super::file_ops::handle_append_block_file;
28use super::pty::{
29    FLUSHER_BARRIER_TIMEOUT, FLUSHER_EOF_GRACE,
30    PTY_CHANNEL_CAPACITY, PTY_COALESCE_MAX_BYTES, PTY_READ_BUF_SIZE,
31};
32use super::translation::accumulate_and_translate;
33use crate::backend::obj::{self, MetaMapType};
34use crate::backend::shellexec::ShellProc;
35use crate::backend::mps;
36
37/// One PTY read's worth of already-OSC-cleaned output, on its way from the
38/// blocking read loop to the coalescing flusher (`start()`, below). See
39/// docs/reports/REPORT_RENDERER_CPU_UNBATCHED_PTY_OUTPUT_2026_09_11.md.
40struct PtyReadChunk {
41    data: Vec<u8>,
42    osc_events: Vec<crate::backend::osc_extractor::OscEvent>,
43    /// Flush barrier. When set, the flusher signals it AFTER committing the
44    /// batch this chunk lands in — so a sender that enqueues a barrier and
45    /// awaits it knows everything queued before it has reached the FileStore
46    /// and the broker.
47    ///
48    /// Exists because "the flusher task ended" is NOT a usable completion
49    /// signal on Windows: ConPTY's console host outlives the direct child, so
50    /// the read loop never sees EOF, never drops its sender, and the channel
51    /// never closes. Waiting on the task therefore always burned the full
52    /// `FLUSHER_DRAIN_TIMEOUT` (see its doc comment) even though the flusher
53    /// itself had been idle for most of that second. A barrier asks the
54    /// question actually being asked — "is prior output committed yet?" —
55    /// and is answered as soon as the batch it lands in commits.
56    ack: Option<tokio::sync::oneshot::Sender<()>>,
57}
58
59/// Do the actual per-batch work the read loop used to do per-read: write
60/// through to the FileStore, broadcast the raw bytes, publish any OSC
61/// activity, and (for agent panes) feed the JSON-stream translator. Called
62/// once per coalesced batch instead of once per (up to 4KB) PTY read.
63#[allow(clippy::too_many_arguments)]
64fn flush_pty_batch(
65    broker: &mps::Broker,
66    block_id: &str,
67    data: &[u8],
68    osc_events: &[crate::backend::osc_extractor::OscEvent],
69    filestore: Option<&Arc<crate::backend::storage::filestore::FileStore>>,
70    line_buf: &mut Vec<u8>,
71    translator: Option<&mut crate::agents::translator::claude::ClaudeTranslator>,
72) {
73    if data.is_empty() && osc_events.is_empty() {
74        return;
75    }
76
77    handle_append_block_file(
78        broker,
79        block_id,
80        "term",
81        data,
82        // Write-through so scrollback survives a reconnect
83        // (SPEC_TERMINAL_SCROLLBACK_PERSISTENCE_2026_07_23.md
84        // §2.1) — was `None` (raw PTY bytes discarded after
85        // the live broadcast), which meant every `view:"term"`
86        // pane (standalone Terminal panes and the agent-shell
87        // drawer alike) lost all output on remount.
88        filestore,
89        None, // not an agent output stream; no global mirror
90    );
91
92    for ev in osc_events {
93        mps::publish_block_activity(broker, block_id, &ev.payload);
94    }
95
96    if let Some(t) = translator {
97        accumulate_and_translate(broker, block_id, line_buf, data, t);
98    }
99}
100
101/// Drain `rx`, coalescing into batches whatever is already queued at the
102/// moment each batch is assembled — never waiting on output that has not
103/// arrived, and bounded by `PTY_COALESCE_MAX_BYTES` — then flushing each
104/// batch through [`flush_pty_batch`]. Runs until `rx` closes
105/// (the PTY read loop's sender dropped, i.e. the PTY hit EOF or an error),
106/// flushing any final partial batch before returning.
107///
108/// A standalone fn rather than inlined into its `tokio::spawn` call site so
109/// it can be driven directly in a test with a synthetic channel and no real
110/// PTY — see `pty_output_flusher_tests` below.
111async fn run_pty_output_flusher(
112    mut rx: mpsc::Receiver<PtyReadChunk>,
113    broker: Option<Arc<mps::Broker>>,
114    block_id: String,
115    filestore: Option<Arc<crate::backend::storage::filestore::FileStore>>,
116    is_agent: bool,
117) {
118    let Some(broker) = broker else {
119        // No broker means the read loop's sender never had anything worth
120        // sending (see its own `broker_read.is_some()` gate) — nothing to
121        // drain or flush.
122        return;
123    };
124    // Phase 1.5 PR 1 (additive): if this is an agent pane, also try to
125    // interpret stdout as Claude Code stream-json line-by-line, feeding
126    // successful parses through ClaudeTranslator and emitting AgentEvents
127    // on a new MPS scope `agent_event:<block_id>`. The existing raw-chunk
128    // path stays byte-equal — interactive panes (which don't emit JSON) see
129    // no behavior change because every line fails the JSON parse and is
130    // silently dropped. The future stream-json-mode pane and the drone
131    // inspector (issue #830 / Phase 1.5 PR 3) will be the first real
132    // consumers.
133    let mut translator: Option<crate::agents::translator::claude::ClaudeTranslator> = if is_agent
134    {
135        Some(crate::agents::translator::claude::ClaudeTranslator::new())
136    } else {
137        None
138    };
139    // Per-block line-buffer (raw bytes — see `extract_agent_events`). Capped
140    // to AGENT_LINE_BUFFER_CAP so a producer that never emits a newline
141    // can't grow the buffer unboundedly.
142    let mut line_buf: Vec<u8> = Vec::new();
143
144    loop {
145        let Some(first) = rx.recv().await else {
146            break; // channel closed, nothing pending
147        };
148        let mut batch = first.data;
149        let mut batch_osc = first.osc_events;
150        // Barriers collected into THIS batch. A barrier ends accumulation as
151        // soon as it is seen (below): everything already in `batch` is about
152        // to be flushed, and holding the batch open for the rest of the
153        // coalescing window would delay the ack for no benefit.
154        let mut batch_acks: Vec<tokio::sync::oneshot::Sender<()>> = Vec::new();
155        if let Some(ack) = first.ack {
156            batch_acks.push(ack);
157        }
158        // Drain what is ALREADY queued into this batch, and nothing more —
159        // never wait for output that has not arrived yet.
160        //
161        // This previously waited up to a fixed 20ms window for more chunks.
162        // That is application-layer Nagle, and it sat directly on the
163        // keystroke echo path: typing one character into an otherwise idle
164        // pane produces exactly one chunk, so the window could never be
165        // satisfied early and the echo paid the full 20ms — longer than a
166        // 60Hz frame — before it was ever broadcast. It is the same mistake
167        // SPEC_TERM_DOUBLE_RAF_TEAROUT_2026_05_30 removed from the frontend
168        // (a second frame gate on the echo path, symptom: "not silky"
169        // hiccups); that spec's own prescription for a backend version was a
170        // ~2-4ms merge of consecutive reads, not a 20ms wait.
171        //
172        // Dropping the wait costs no batching, because batching here is
173        // self-tuning without it: a flush takes real time (SQLite write +
174        // broadcast, on spawn_blocking), and everything the producer writes
175        // while that flush is in flight queues up behind it. So the busier
176        // the pane the bigger the next batch — which is exactly the burst
177        // case REPORT_RENDERER_CPU_UNBATCHED_PTY_OUTPUT_2026_09_11.md
178        // measured — while an idle pane, where there is nothing to coalesce
179        // in the first place, now pays nothing to find that out.
180        let closed_mid_batch = loop {
181            if batch.len() >= PTY_COALESCE_MAX_BYTES {
182                break false;
183            }
184            match rx.try_recv() {
185                Ok(next) => {
186                    batch.extend_from_slice(&next.data);
187                    batch_osc.extend(next.osc_events);
188                    if let Some(ack) = next.ack {
189                        batch_acks.push(ack);
190                        break false; // flush now; someone is waiting on it
191                    }
192                }
193                // Nothing more queued — flush immediately rather than
194                // waiting to see whether something shows up.
195                Err(mpsc::error::TryRecvError::Empty) => break false,
196                Err(mpsc::error::TryRecvError::Disconnected) => break true,
197            }
198        };
199
200        // spawn_blocking, not block_in_place (reagentx P2 on PR #3206,
201        // correcting the round before it): flush_pty_batch does blocking
202        // SQLite I/O and takes std::sync::Mutex locks, so it must not run
203        // on a Tokio async worker directly — this closure is inside a
204        // plain `tokio::spawn`, not `spawn_blocking`. The first fix for
205        // that reached for `block_in_place` specifically to avoid moving
206        // `&mut line_buf`/`translator.as_mut()` across a 'static+Send
207        // boundary — but `block_in_place` gets its own replacement worker
208        // via THE SAME shared `spawn_blocking` pool internally (verified
209        // directly against the vendored tokio 1.52.3 source,
210        // `runtime/scheduler/multi_thread/worker.rs`'s `block_in_place`: (comment-hygiene: allow)
211        // `runtime::spawn_blocking(move || run(worker))`), so per flush it
212        // draws on that shared pool TWICE — once for the replacement
213        // worker, once implicitly for the original thread's own blocking
214        // — for work `spawn_blocking` alone would cover with one. Tokio's
215        // own docs single out `block_in_place`'s one real advantage
216        // (protecting OTHER concurrent work in the SAME task, e.g. across
217        // a `join!`) as the reason to prefer it over `spawn_blocking` —
218        // this task does nothing else concurrently, so that advantage
219        // never applies here, and `spawn_blocking` is strictly the better
220        // fit. The ownership problem is solved properly instead: `batch`/
221        // `batch_osc` are freshly built each iteration (cheap to move),
222        // and `line_buf`/`translator` — the only state the flush actually
223        // MUTATES — are moved into the closure and handed back out via the
224        // `JoinHandle`'s return value, rather than trying to keep them
225        // borrowed across the call.
226        let broker_owned = broker.clone();
227        let block_id_owned = block_id.clone();
228        let filestore_owned = filestore.clone();
229        let (new_line_buf, new_translator) = tokio::task::spawn_blocking(move || {
230            flush_pty_batch(
231                &broker_owned,
232                &block_id_owned,
233                &batch,
234                &batch_osc,
235                filestore_owned.as_ref(),
236                &mut line_buf,
237                translator.as_mut(),
238            );
239            (line_buf, translator)
240        })
241        .await
242        .expect("PTY output flush task panicked");
243
244        // Everything enqueued up to and including the barrier is now
245        // committed. A dropped receiver (the waiter timed out and moved on)
246        // is fine — that is the bounded-wait path doing its job.
247        for ack in batch_acks {
248            let _ = ack.send(());
249        }
250        line_buf = new_line_buf;
251        translator = new_translator;
252
253        if closed_mid_batch {
254            break;
255        }
256    }
257}
258
259/// Resolve the effective AGENTMUX_AGENT_ID for jekt auto-registration from a
260/// block's own spawn metadata — both the canonical key and the legacy
261/// WAVEMUX_AGENT_ID alias, checked in the SAME block-scoped `cmd:env` map.
262/// Deliberately does NOT fall back to `std::env::var` for either key: that
263/// reads THIS SRV PROCESS's own environment, shared by every block/pane
264/// this instance spawns — reintroducing exactly the same collision as the
265/// global-settings fallback below (#2694). Nor does it
266/// fall back to the global settings' cmd_env.AGENTMUX_AGENT_ID: that value
267/// is shared across every pane in the instance/channel, so a pane spawned
268/// without its own explicit per-block ID would silently inherit whatever
269/// another pane happened to configure there — and register_agent_with_nonce
270/// unconditionally evicts whoever previously held that agent_key, so the
271/// second such pane to register silently steals the first's jekt identity
272/// (same-host cross-instance jekt misdelivery). See `cmd_env_overrides` below
273/// (its `settings.cmd_env` loop) for the matching fix that keeps
274/// this global value out of the pane's OWN environment too — otherwise a
275/// pane could still pick it up via OSC 16162 and re-register through the
276/// frontend's `/agentmux/reactive/register` path instead, even with THIS
277/// function never reading it directly. A pane with
278/// no block-scoped identity is simply not jekt-registered, matching
279/// persistent/mod.rs's muxbus_agent_id_from_env, which never had either fallback.
280fn resolve_agent_id_for_jekt(block_meta: &MetaMapType) -> Option<String> {
281    let cmd_env = block_meta.get(META_KEY_CMD_ENV).and_then(|m| m.as_object());
282    crate::backend::blockcontroller::agent_id_from_env(|key| {
283        cmd_env.and_then(|obj| obj.get(key)).and_then(|v| v.as_str())
284    })
285}
286
287/// Whether `key` may be injected into a pane's actual OS environment from
288/// the global settings' `cmd_env` defaults (`cmd_env_overrides`, lowest
289/// priority — the block's own `cmd:env` override, highest
290/// priority, is never filtered by this and can always set either key
291/// explicitly). Identity keys are excluded (reagentx P1, round 2 on #2694):
292/// without this, a global cmd_env.AGENTMUX_AGENT_ID setting would still land
293/// in every pane's real environment even though `resolve_agent_id_for_jekt`
294/// never reads it for registration — shell integration echoes an injected
295/// AGENTMUX_AGENT_ID back via OSC 16162, and the frontend's own
296/// `POST /agentmux/reactive/register` path (triggered by that OSC) would
297/// pick it up and register the pane under the shared value anyway, silently
298/// reopening the same-host identity collision this PR removes from the
299/// backend auto-register path specifically.
300fn is_global_cmd_env_injectable(key: &str) -> bool {
301    !matches!(key, "AGENTMUX_AGENT_ID" | "WAVEMUX_AGENT_ID")
302}
303
304/// The `cmd:env` part of an interactive shell's environment, computed from
305/// the global settings and the block's own metadata. See
306/// [`cmd_env_overrides`].
307#[derive(Debug)]
308struct CmdEnvOverrides {
309    /// `(key, value)` pairs in application order: global settings first,
310    /// block metadata last. Applied one by one, so a later entry for the same
311    /// key wins. Kept as a list rather than merged into a map because
312    /// `CommandBuilder` matches env keys case-insensitively on Windows: a
313    /// block's `PATH` must still override a global `Path`.
314    vars: Vec<(String, String)>,
315    /// Whether the block's own `cmd:env` sets `AGENTMUX_AGENT_ID`. When it
316    /// does not, the inherited agent-identity vars are stripped.
317    block_sets_agent_id: bool,
318}
319
320impl CmdEnvOverrides {
321    fn apply_to(&self, c: &mut CommandBuilder) {
322        for (k, v) in &self.vars {
323            c.env(k, v);
324        }
325        // Strip host-inherited agent identity unless the block's own cmd:env
326        // sets it. (Global settings never can: `is_global_cmd_env_injectable`.)
327        // This also supersedes the old WAVEMUX backward-compat bridge — both
328        // AGENTMUX_* and WAVEMUX_* vars are removed so new panes start as
329        // plain "Terminal".
330        if !self.block_sets_agent_id {
331            c.env_remove("AGENTMUX_AGENT_ID");
332            c.env_remove("WAVEMUX_AGENT_ID");
333            c.env_remove("WAVEMUX_AGENT_COLOR");
334        }
335    }
336}
337
338/// Compute an interactive shell's `cmd:env` overrides.
339///
340/// Precedence, lowest to highest:
341/// 1. `settings.cmd_env`, the global `cmd:env` from settings.json, minus the
342///    identity keys `is_global_cmd_env_injectable` excludes.
343/// 2. The block's own `cmd:env` metadata.
344///
345/// Values get `~` expanded. `settings` must come from the live config
346/// ([`ShellController::live_settings`]). The spawn once read a freshly built
347/// default config instead, whose `cmd_env` is always empty, so the global
348/// setting never reached any shell.
349fn cmd_env_overrides(
350    settings: &crate::backend::wconfig::SettingsType,
351    block_meta: &MetaMapType,
352) -> CmdEnvOverrides {
353    let mut vars = Vec::new();
354    let mut block_sets_agent_id = false;
355
356    // Global defaults.
357    for (k, v) in &settings.cmd_env {
358        if !is_global_cmd_env_injectable(k) {
359            continue;
360        }
361        let expanded = crate::backend::base::expand_home_dir_safe(v);
362        vars.push((k.clone(), expanded.to_string_lossy().into_owned()));
363    }
364
365    // Block metadata (per-block overrides).
366    // Sorted, matching the key order serde_json's map gave before.
367    let mut block_env: Vec<(String, String)> = super::super::cmd_env_of(block_meta).into_iter().collect();
368    block_env.sort();
369    for (k, val) in block_env {
370        if k == "AGENTMUX_AGENT_ID" {
371            block_sets_agent_id = true;
372        }
373        let expanded = crate::backend::base::expand_home_dir_safe(&val);
374        vars.push((k, expanded.to_string_lossy().into_owned()));
375    }
376
377    CmdEnvOverrides { vars, block_sets_agent_id }
378}
379
380impl ShellController {
381    /// Apply `cmd:env` to an interactive shell's command: the live global
382    /// settings, then the block's own metadata, then the identity strip.
383    fn apply_cmd_env(&self, c: &mut CommandBuilder, block_meta: &MetaMapType) {
384        cmd_env_overrides(&self.live_settings(), block_meta).apply_to(c);
385    }
386
387    /// The process a `cmd` pane runs: `cmd` itself when it has `cmd:args` or
388    /// `cmd:interactive`, otherwise `cmd` through the platform shell. Either
389    /// way with the pane's `cmd:env`, as an interactive shell gets it: a pane
390    /// running a provider CLI's login needs the account's config directory.
391    ///
392    /// Spawned directly, an npm `.cmd` shim on Windows is resolved to what it
393    /// runs: the shim itself hangs under ConPTY (`resolve_cli_spawn_target`).
394    /// One that can't be read is refused, rather than started into a pane
395    /// that would never respond.
396    fn command_process(
397        &self,
398        cmd_str: &str,
399        cmd_args: &[String],
400        interactive: bool,
401        block_meta: &MetaMapType,
402    ) -> Result<CommandBuilder, String> {
403        let mut c = if !cmd_args.is_empty() || interactive {
404            // Spawned directly (no shell wrapper), so args pass through intact.
405            let (program, prefix) = agentmux_common::resolve_cli_spawn_target(cmd_str).ok_or_else(|| {
406                format!(
407                    "can't run {cmd_str}: it's a command wrapper AgentMux can't read, and run as it is it would hang; \
408                     reinstall the tool, or run its own executable"
409                )
410            })?;
411            let mut c = CommandBuilder::new(&program);
412            c.args(&prefix);
413            c.args(cmd_args);
414            c
415        } else if cfg!(windows) {
416            let mut c = CommandBuilder::new("cmd.exe");
417            c.args(["/C", cmd_str]);
418            c
419        } else {
420            let mut c = CommandBuilder::new("/bin/sh");
421            c.args(["-c", cmd_str]);
422            c
423        };
424        self.apply_cmd_env(&mut c, block_meta);
425        Ok(c)
426    }
427
428    /// Ends a start that was refused before anything ran: the pane is done,
429    /// with no exit code, and the controller can be started again.
430    fn refuse_start(&self, message: String) -> Result<(), String> {
431        let mut inner = self.inner.lock().unwrap();
432        Self::set_status(&mut inner, STATUS_DONE);
433        inner.proc_exit_code = -1;
434        inner.input_tx = None;
435        drop(inner);
436        self.unlock_run();
437        Err(message)
438    }
439}
440
441impl Controller for ShellController {
442    fn start(
443        &self,
444        block_meta: MetaMapType,
445        rt_opts: Option<serde_json::Value>,
446        force: bool,
447    ) -> Result<(), String> {
448        let cmd_str_preview = Self::get_cmd_str(&block_meta);
449        let interactive_preview = Self::is_interactive(&block_meta);
450        tracing::info!(
451            block_id = %self.block_id,
452            controller = %self.controller_type,
453            cmd = %cmd_str_preview,
454            interactive = interactive_preview,
455            force = force,
456            "block start requested"
457        );
458
459        // Check if we should run
460        if !force && !Self::should_run_on_start(&block_meta) {
461            tracing::info!(block_id = %self.block_id, "skipping start: run_on_start is false");
462            return Ok(());
463        }
464
465        // A pane on a connection AgentMux cannot run must not quietly get a
466        // local shell under a remote name: it gets no shell, and its connection
467        // overlay says why (SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md).
468        // WSL runs on Windows (P1); SSH wherever the system `ssh` is (P2). The
469        // test-only mock factory keeps its own path.
470        if self.conn_factory.lock().unwrap().is_none() {
471            let conn = Self::get_conn_name(&block_meta);
472            match crate::backend::remote::ConnTarget::parse(&conn) {
473                Ok(target) if target.is_local() => {}
474                #[cfg(windows)]
475                Ok(crate::backend::remote::ConnTarget::Wsl(_)) => {}
476                Ok(crate::backend::remote::ConnTarget::Ssh(_))
477                    if crate::backend::remote::ssh::binary().is_some() => {}
478                Ok(crate::backend::remote::ConnTarget::Ssh(_)) => {
479                    return Err(crate::backend::remote::ssh::missing_binary_message());
480                }
481                Ok(target) => {
482                    return Err(format!(
483                        "connection {} is not available yet: this version of AgentMux runs local terminals only",
484                        target.name()
485                    ));
486                }
487                Err(e) => return Err(format!("connection {conn:?}: {e}")),
488            }
489        }
490
491        // Try to acquire run lock
492        if !self.try_lock_run() {
493            return Err("controller is already running".to_string());
494        }
495
496        // Get connection info
497        let conn_name = Self::get_conn_name(&block_meta);
498
499        // Update status to running.
500        {
501            let mut inner = self.inner.lock().unwrap();
502            Self::set_status(&mut inner, STATUS_RUNNING);
503            inner.conn_name = conn_name.clone();
504        }
505
506        // Create input channel. Unbounded: input must never be silently
507        // dropped on burst (the paste-truncation bug). See SHELL_INPUT_CH_SIZE.
508        let (input_tx, input_rx) = mpsc::unbounded_channel();
509        {
510            let mut inner = self.inner.lock().unwrap();
511            inner.input_tx = Some(input_tx);
512            inner.input_seq_next = 0;
513            inner.input_seq_buf.clear();
514        }
515
516        // Publish "running" only AFTER `input_tx` exists, so the event is a
517        // truthful readiness signal: a frontend that resizes the PTY the instant
518        // it sees "running" will not hit `send_input`'s "controller is not
519        // running" guard (which fires while `input_tx` is None). The channel is
520        // unbounded, so a resize enqueued before the input task spawns is
521        // buffered, not lost.
522        // See docs/analysis/AGENT_PANE_PTY_RESIZE_RACE_2026_06_16.md.
523        self.publish_status();
524
525        // Check if we have a conn_factory (test/mock path)
526        let has_factory = self.conn_factory.lock().unwrap().is_some();
527
528        if has_factory {
529            // Mock path: use ConnInterface factory (synchronous, for tests)
530            let conn_result = {
531                let factory = self.conn_factory.lock().unwrap();
532                factory.as_ref().unwrap()(&conn_name, &block_meta)
533            };
534
535            let mut conn = match conn_result {
536                Ok(c) => c,
537                Err(e) => {
538                    let mut inner = self.inner.lock().unwrap();
539                    Self::set_status(&mut inner, STATUS_DONE);
540                    inner.proc_exit_code = -1;
541                    inner.input_tx = None;
542                    self.unlock_run();
543                    return Err(format!("failed to create connection: {e}"));
544                }
545            };
546
547            if let Err(e) = conn.start() {
548                let mut inner = self.inner.lock().unwrap();
549                Self::set_status(&mut inner, STATUS_DONE);
550                inner.proc_exit_code = -1;
551                inner.input_tx = None;
552                self.unlock_run();
553                return Err(format!("failed to start process: {e}"));
554            }
555
556            let mut shell_proc = ShellProc::new(conn_name, conn);
557            let _done_rx = shell_proc.take_done_rx();
558            let exit_code = shell_proc.wait_and_signal();
559
560            {
561                let mut inner = self.inner.lock().unwrap();
562                inner.proc_exit_code = exit_code;
563                Self::set_status(&mut inner, STATUS_DONE);
564                inner.input_tx = None;
565            }
566            self.publish_status();
567            self.unlock_run();
568            return Ok(());
569        }
570
571        // Real PTY path.
572        //
573        // Open the PTY at the size the frontend computed from the pane (passed
574        // as `rtopts.termsize` on the resync), so the agent CLI and its tools
575        // wrap correctly from the very first byte. The earlier code always
576        // opened at a fixed 200 cols and relied on a post-spawn resize RPC to
577        // correct it — but that RPC races controller startup and could fail
578        // outright, leaving output wrapped at 200 all session. Seeding the size
579        // here removes that race; `pty_size_from_rt_opts` falls back to the
580        // historical 25x200 when no size was supplied (e.g. programmatic
581        // spawns). See docs/analysis/AGENT_PANE_PTY_RESIZE_RACE_2026_06_16.md.
582        let pty_system = native_pty_system();
583        let pty_size = self.spawn_pty_size(&rt_opts);
584
585        let pair = pty_system.openpty(pty_size).map_err(|e| {
586            tracing::error!(block_id = %self.block_id, error = %e, "failed to open PTY");
587            let mut inner = self.inner.lock().unwrap();
588            Self::set_status(&mut inner, STATUS_DONE);
589            inner.proc_exit_code = -1;
590            inner.input_tx = None;
591            self.unlock_run();
592            format!("failed to open PTY: {e}")
593        })?;
594        tracing::info!(block_id = %self.block_id, rows = pty_size.rows, cols = pty_size.cols, "PTY opened");
595
596        // Determine shell command
597        let cmd_str = Self::get_cmd_str(&block_meta);
598        let cmd_args = Self::get_cmd_args(&block_meta);
599        let interactive = Self::is_interactive(&block_meta);
600
601        // Resolve effective AGENTMUX_AGENT_ID for jekt auto-registration.
602        // See resolve_agent_id_for_jekt's doc comment for why this
603        // deliberately does not fall back to global settings.
604        let agent_id_for_jekt: Option<String> = resolve_agent_id_for_jekt(&block_meta);
605
606        // Close-on-exit (SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md §10-11):
607        // resolved from `block_meta` now, while it's still in scope, for
608        // the wait/cleanup task below to act on once the process actually
609        // exits. See `effective_close_on_exit`'s own doc comment for the
610        // per-controller-type default. (`should_close_on_exit_force` is
611        // deliberately NOT read here — see its own doc comment for why no
612        // call site uses it.)
613        //
614        // Forced off for a SUB-block — a block whose `parentoref` names
615        // another BLOCK (`block:<id>`): an agent's composer-drawer shell
616        // (`AgentShellSubblock.tsx`, via `createsubblock`) or a
617        // `PtyShellCreate`-spawned headless shell. Those are not
618        // independent panes, and closing one via
619        // `sagas::delete_block::run` leaves the parent's own
620        // `term:shellsubblockid` pointer dangling (a field that saga knows
621        // nothing about), after which the drawer's own "attach or create"
622        // logic silently recreates a fresh shell — reproducing the
623        // original respawn loop by a different route (§11).
624        //
625        // A TOP-LEVEL pane's `parentoref` names its owning TAB
626        // (`tab:<id>`, set by `wcore::block`/`persist_subscriber`) — NOT
627        // empty. Checking `!parentoref.is_empty()` here (as the first cut
628        // of §11 did) therefore disabled close-on-exit for *every* pane,
629        // including the plain `Terminal` widget this feature exists for —
630        // the regression the reporter hit as "still hangs" (§12). Match
631        // on the `block:` prefix specifically, the same discriminator
632        // `websocket.rs`'s `strip_prefix("block:")` already uses.
633        let is_sub_block = self
634            .mstore
635            .as_ref()
636            .and_then(|store| store.get::<obj::Block>(&self.block_id).ok().flatten())
637            .map(|b| b.parentoref.starts_with("block:"))
638            .unwrap_or(false);
639        let close_on_exit = Self::effective_close_on_exit(&block_meta, &self.controller_type) && !is_sub_block;
640        let close_on_exit_delay_ms = Self::close_on_exit_delay_ms(&block_meta);
641
642        // Detect agent pane: cmd contains a known agent CLI or has AGENTMUX_AGENT_ID set.
643        // Computed here (before spawn) rather than after so the commit-aware admission
644        // gate below can use it; also reused post-spawn to set `inner.is_agent_pane`.
645        let is_agent = agent_id_for_jekt.is_some()
646            || cmd_str.to_lowercase().contains("claude")
647            || cmd_str.to_lowercase().contains("codex")
648            || cmd_str.to_lowercase().contains("gemini")
649            || cmd_str.to_lowercase().contains("qwen");
650
651        // Pillar 3 — commit-aware admission control, extended to the interactive
652        // agent pane. The drone Agent block's one-shot spawn (`agents::runner::
653        // run_agent`) already refuses to start another `claude.exe` when system
654        // commit headroom is below the reserve, to avoid tipping the box into a
655        // Chromium OOM abort (0xE0000008). That gate covered only the drone path;
656        // this interactive PTY spawn is the OTHER (likely more common) place a
657        // fresh agent CLI process gets started, and previously had no protection
658        // at all. Reuse the same pure decision + reserve lookup here, gated on
659        // `is_agent` so plain shell/cmd panes are unaffected — only a new
660        // claude/codex/gemini/qwen process is refused under commit pressure.
661        // See SPEC_WIN10_PAGEFILE_OOM_CRASH_2026_06_29.md, PR #1853.
662        if is_agent {
663            if let Err(e) = crate::agents::runner::admit_spawn(
664                crate::backend::sysinfo::available_commit_gb(),
665                crate::agents::runner::agent_commit_reserve_gb(),
666            ) {
667                tracing::warn!(
668                    block_id = %self.block_id,
669                    cmd = %cmd_str,
670                    error = %e,
671                    "admission gate: refusing interactive agent spawn under commit pressure"
672                );
673                return self.refuse_start(format!(
674                    "memory full — not enough memory to start a new agent right now, free up memory and try again ({e})"
675                ));
676            }
677        }
678
679        // A WSL pane runs `wsl.exe -d <distro>` (Windows only; the guard above
680        // refuses it elsewhere). Its cwd is a path inside the distro, passed as
681        // `--cd`, never set as the Windows working directory below.
682        let wsl_distro = match crate::backend::remote::ConnTarget::parse(&conn_name) {
683            Ok(crate::backend::remote::ConnTarget::Wsl(d)) if cfg!(windows) => Some(d),
684            _ => None,
685        };
686        // An SSH pane runs the system `ssh` (remote::ssh). Its cwd is a path on
687        // the remote host, handed to the remote shell, never set here. An
688        // agent's (its PtyShell, marked below) needs one that forces askpass.
689        let ssh_plan = match crate::backend::remote::ConnTarget::parse(&conn_name) {
690            Ok(crate::backend::remote::ConnTarget::Ssh(dest)) => {
691                let agents = !obj::meta_get_string(
692                    &block_meta,
693                    crate::backend::remote::askpass::META_KEY_AGENT_BLOCK,
694                    "",
695                )
696                .is_empty();
697                if agents {
698                    crate::backend::remote::ssh::binary()
699                } else {
700                    crate::backend::remote::ssh::binary_for_user()
701                }
702                .map(|path| (path, dest))
703            }
704            _ => None,
705        };
706        // An agent's SSH PtyShell (`http_pty_shell.rs` marks it): a secret for
707        // its ssh's prompts, revoked when this shell's wait task ends.
708        let askpass_secret = ssh_plan.as_ref().and_then(|_| {
709            let agent_block = obj::meta_get_string(&block_meta, crate::backend::remote::askpass::META_KEY_AGENT_BLOCK, "");
710            (!agent_block.is_empty()).then(|| {
711                crate::backend::remote::askpass::grant(crate::backend::remote::askpass::AskpassGrant {
712                    agent_block_id: agent_block,
713                    agent: obj::meta_get_string(&block_meta, crate::backend::remote::askpass::META_KEY_AGENT, ""),
714                    connection: conn_name.clone(),
715                    user_pane: false,
716                })
717            })
718        });
719        let askpass_guard = askpass_secret.clone().map(crate::backend::remote::askpass::Revoke);
720        let mut cmd = if let Some(distro) = &wsl_distro {
721            let cwd = obj::meta_get_string(&block_meta, super::super::META_KEY_CMD_CWD, "");
722            let plan = crate::backend::remote::wsl::launch(distro, &cmd_str, &cmd_args, &cwd);
723            tracing::info!(block_id = %self.block_id, distro = %distro, args = ?plan.args, "wsl spawn path");
724            let mut c = CommandBuilder::new("wsl.exe");
725            c.args(plan.args.iter().map(String::as_str));
726            c.env("TERM", "xterm-256color");
727            c.env("COLORTERM", "truecolor");
728            c.env("TERM_PROGRAM", "agentmux");
729            c.env("AGENTMUX_BLOCKID", &self.block_id);
730            c.env("AGENTMUX_TABID", &self.tab_id);
731            c.env("AGENTMUX_VERSION", env!("CARGO_PKG_VERSION"));
732            // Configured cmd:env (global, then the block's), as a local
733            // interactive shell gets it, and listed in WSLENV so it crosses.
734            let overrides = cmd_env_overrides(&self.live_settings(), &block_meta);
735            overrides.apply_to(&mut c);
736            c.env(
737                "WSLENV",
738                crate::backend::remote::wsl::wslenv_with(
739                    &std::env::var("WSLENV").unwrap_or_default(),
740                    overrides.vars.iter().map(|(k, _)| k.as_str()),
741                ),
742            );
743            c
744        } else if let Some((ssh_path, dest)) = &ssh_plan {
745            let cwd = obj::meta_get_string(&block_meta, super::super::META_KEY_CMD_CWD, "");
746            tracing::info!(block_id = %self.block_id, "ssh spawn");
747            let what = (cmd_str.as_str(), cmd_args.as_slice(), cwd.as_str());
748            super::ssh_pane::command(ssh_path, dest, what, askpass_secret.as_deref(), &self.auth_key, &self.block_id)
749        } else if !cmd_str.is_empty() && (!cmd_args.is_empty() || interactive) {
750            // Direct spawn: cmd:args provided or cmd:interactive set.
751            // Spawn the CLI directly (no sh -c wrapper) so args are passed correctly.
752            tracing::info!(block_id = %self.block_id, cmd = %cmd_str, args = ?cmd_args, "direct spawn path");
753            match self.command_process(&cmd_str, &cmd_args, interactive, &block_meta) {
754                Ok(c) => c,
755                Err(e) => return self.refuse_start(e),
756            }
757        } else if !cmd_str.is_empty() {
758            // "cmd" controller: run a specific command string via shell wrapper
759            tracing::info!(block_id = %self.block_id, cmd = %cmd_str, "shell-wrapped spawn path");
760            match self.command_process(&cmd_str, &cmd_args, interactive, &block_meta) {
761                Ok(c) => c,
762                Err(e) => return self.refuse_start(e),
763            }
764        } else {
765            // "shell" controller: the block's or the default shell, with AgentMux integration (backend::shells).
766            let (shell_path, shell_opts) = crate::backend::shells::for_block(&self.block_id, &block_meta, &self.live_settings());
767            let shell_path = shell_path.display().to_string();
768
769            let shell_type = crate::backend::shellintegration::detect_shell_type(&shell_path);
770
771            // Deploy shell integration scripts under the user's home rather than
772            // AGENTMUX_DATA_HOME.  MSIX packages virtualise writes to %LocalAppData%,
773            // so files written by the packaged backend aren't visible to child
774            // processes (pwsh, bash, etc.) spawned via ConPTY.  The home dir is never
775            // virtualised, so the scripts are always reachable at their literal path.
776            //
777            // The path is keyed per instance: this runs on EVERY shell spawn, so an
778            // unkeyed home-global dir let two concurrently-running versions rewrite
779            // each other's scripts indefinitely (I6).  See `integration_base`.
780            let shell_home = crate::backend::shellintegration::integration_base();
781            crate::backend::shellintegration::deploy_scripts(&shell_home);
782
783            tracing::info!(block_id = %self.block_id, shell = %shell_path, shell_type = ?shell_type, "interactive shell path");
784
785            let mut c = CommandBuilder::new(&shell_path);
786
787
788            // Apply shell-specific startup args (--rcfile, -File, etc.)
789            let startup = crate::backend::shellintegration::get_shell_startup(shell_type, &shell_home);
790            let integration = startup.as_ref().map(|s| s.extra_args.as_slice()).unwrap_or_default();
791            c.args(crate::backend::shells::with_user_opts(integration, &shell_opts));
792            for (k, v) in startup.iter().flat_map(|s| &s.env_vars) {
793                c.env(k, v);
794            }
795
796            // Inject terminal capability env vars into the PTY environment.
797            // ConPTY on Windows fully supports VT/ANSI sequences, so set TERM
798            // on all platforms. Without this, CLI tools (e.g. Claude Code) use
799            // different Unicode width tables, causing ANSI color offset on Windows.
800            c.env("TERM", "xterm-256color");
801            c.env("COLORTERM", "truecolor");
802            c.env("TERM_PROGRAM", "agentmux");
803            c.env("AGENTMUX_BLOCKID", &self.block_id);
804            c.env("AGENTMUX_TABID", &self.tab_id);
805            c.env("AGENTMUX_VERSION", env!("CARGO_PKG_VERSION"));
806
807            // Inject log directory so agents can find logs without guessing.
808            // Always ~/.agentmux/logs/ — matches both host and sidecar.
809            let log_dir = dirs::home_dir()
810                .unwrap_or_default()
811                .join(".agentmux")
812                .join("logs");
813            c.env("AGENTMUX_LOG_DIR", log_dir.to_string_lossy().as_ref());
814
815            // AGENTMUX_LOCAL_URL / AGENTMUX_AUTH_KEY: injected below, applied
816            // to ALL THREE branches (not just this one) — see that comment
817            // for why.
818
819            // Wire AgentMux-managed tool dirs into the agent's PATH.
820            //
821            // Two stores, two precedence rules:
822            //
823            //   • Bundled store (`<exe_dir>/tools/bin`) ships the app's OWN
824            //     version-locked binaries — notably `agentmux-bashwrap`, the
825            //     streaming hook that MUST match the running build. It is
826            //     PREPENDED so it wins over any stale copy elsewhere on the
827            //     system PATH. A stale system-PATH `agentmux-bashwrap` (from a
828            //     leftover portable) silently shadowed the fixed one and
829            //     reintroduced the exit-130 bug — see
830            //     docs/retro/RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md. The
831            //     bundled jq/rg winning too is intended: agents get the app's
832            //     curated, deterministic tool versions regardless of host.
833            //
834            //   • User-managed store (`~/.agentmux/tools/bin`) holds tools the
835            //     user installed via /tools. APPENDED so the user's own system
836            //     PATH still wins for those.
837            {
838                let current_path = std::env::var("PATH").unwrap_or_default();
839                let mut bundled: Option<std::path::PathBuf> = None;
840
841                // Bundled store — prepended (app-owned, version-locked).
842                if let Some(bundled_bin) = crate::backend::tool_store::bundled_tools_dir() {
843                    if bundled_bin.exists() {
844                        // Guardrail: log which agentmux-bashwrap the agent will
845                        // actually run. A stale system-PATH copy silently
846                        // shadowing the bundled one is exactly the exit-130
847                        // trap (RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md); this
848                        // one line makes "which binary?" answerable at a glance
849                        // (cross-check the version with `agentmux-bashwrap
850                        // --version`).
851                        let bashwrap_exe = if cfg!(windows) {
852                            "agentmux-bashwrap.exe"
853                        } else {
854                            "agentmux-bashwrap"
855                        };
856                        let bw = bundled_bin.join(bashwrap_exe);
857                        if bw.exists() {
858                            tracing::info!(
859                                target: "agent-tools",
860                                path = %bw.display(),
861                                "agent bashwrap: bundled (version-locked, prepended to PATH)"
862                            );
863                        } else {
864                            tracing::warn!(
865                                target: "agent-tools",
866                                dir = %bundled_bin.display(),
867                                "agent bashwrap: bundled store present but agentmux-bashwrap MISSING — agent will resolve via system PATH (risk of a stale copy; see RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md)"
868                            );
869                        }
870                        bundled = Some(bundled_bin.clone());
871                    } else {
872                        tracing::warn!(
873                            target: "agent-tools",
874                            "agent bashwrap: no bundled tools dir — agent will resolve agentmux-bashwrap via system PATH (risk of a stale copy; see RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md)"
875                        );
876                    }
877                }
878
879                // User-managed store — after the system PATH, which still wins.
880                let user = crate::backend::tool_store::user_tools_dir().filter(|d| d.exists());
881                if let Some(path) = crate::backend::tool_store::compose_tools_path(
882                    &current_path,
883                    bundled.as_deref(),
884                    user.as_deref(),
885                    crate::backend::tool_store::UserToolsPrecedence::AfterExisting,
886                ) {
887                    c.env("PATH", path);
888                }
889            }
890
891            // cmd:env: global settings (lowest priority), then the block's
892            // own metadata (highest), then the agent-identity strip. The
893            // settings come from the live config — see `cmd_env_overrides`.
894            self.apply_cmd_env(&mut c, &block_meta);
895
896            c
897        };
898
899        // Propagate this instance's local API endpoint + auth key to ALL
900        // THREE branches above, not just the interactive shell — same "don't
901        // half-fix it" lesson as the sanitize_pty_command call below (muxreview
902        // P0 on PR #3326). `muxsh`/`muxspect`/`muxopen`/`muxlog` — meant for
903        // a human to run from inside a plain Terminal-widget pane, the same
904        // role Wave Terminal's `wsh` played — need both to authenticate
905        // against this instance's REST API; without them they fail with
906        // "AGENTMUX_LOCAL_URL / AGENTMUX_AUTH_KEY not set" even when run from
907        // a pane AgentMux itself opened, which is exactly the case they're
908        // documented to support (SPEC_MUXSH_FULL_COLLECTION_2026_09_16.md).
909        //
910        // AGENTMUX_LOCAL_URL is set by main.rs after binding and never
911        // scrubbed from this process's own env, so it can be read straight
912        // back (absent in test/mock contexts — graceful no-op). AUTH_KEY
913        // cannot be read the same way: config.rs deliberately removes it
914        // from this process's own environment right after startup (PR #801),
915        // so it has to be threaded in explicitly via `self.auth_key` instead
916        // — the same value agent panes already receive via their own
917        // `PersistentSpawnConfig`/`SubprocessSpawnConfig` env_vars.
918        if let Ok(local_url) = std::env::var("AGENTMUX_LOCAL_URL") {
919            cmd.env("AGENTMUX_LOCAL_URL", &local_url);
920        }
921        if !self.auth_key.is_empty() {
922            cmd.env("AGENTMUX_AUTH_KEY", &self.auth_key);
923        }
924
925        // Applied to ALL THREE branches above, not just the interactive shell.
926        // The direct-spawn branch launches agent CLIs and the shell-wrapped `cmd`
927        // branch can launch anything; both inherited the full instance identity
928        // while this lived inside the third branch (ReAgent P0 on PR #3326).
929        crate::backend::pane_env::sanitize_pty_command(&mut cmd);
930        // An agent's PtyShell carries the agent's plain-`gh` guard (set by
931        // `http_pty_shell.rs`): the agent types into this shell, so `gh` here
932        // must not act as the user. Every branch, after the sanitizer; a WSL
933        // shell's WSLENV already lists GH_CONFIG_DIR as a distro path.
934        let gh_guard = obj::meta_get_string(
935            &block_meta,
936            crate::backend::gh_guard::META_KEY_PTYSHELL_GH_CONFIG_DIR,
937            "",
938        );
939        if !gh_guard.is_empty() {
940            cmd.env(crate::backend::gh_guard::GH_CONFIG_DIR, &gh_guard);
941        }
942        // Its publish-guard hooks too (`backend::publish_guard`). A host path,
943        // so a WSL shell, whose WSLENV doesn't list these, is unaffected.
944        let hooks = obj::meta_get_string(
945            &block_meta,
946            crate::backend::publish_guard::META_KEY_PTYSHELL_GIT_HOOKS_PATH,
947            "",
948        );
949        if !hooks.is_empty() {
950            // On top of the GIT_CONFIG_* entries this shell already has (the
951            // process env plus cmd:env), so none of them is overwritten.
952            let entries = crate::backend::publish_guard::git_config_entries(
953                |k| cmd.get_env(k).and_then(|v| v.to_str()).map(str::to_string),
954                "core.hooksPath",
955                &hooks,
956            );
957            for (key, value) in entries {
958                cmd.env(key, value);
959            }
960        }
961        // Identity M4a: a terminal pane never carries a token. No counter —
962        // most panes are not agents — but an agent CLI that registers from
963        // this block shows in the live gauge.
964        crate::backend::identity_spawn::record_spawn(&self.block_id, false, None);
965
966        // Working directory: the pane's own, else (a local shell) the user's
967        // home, not srv's own working directory.
968        let remote = wsl_distro.is_some() || ssh_plan.is_some();
969        let cwd = super::spawn_cwd::spawn_cwd(&obj::meta_get_string(&block_meta, super::super::META_KEY_CMD_CWD, ""), remote);
970        if !cwd.is_empty() && !remote {
971            cmd.cwd(&cwd);
972        }
973
974        // Program name for the UI, captured before `cmd` is consumed by the
975        // spawn. The process tracker can't supply it: it deliberately omits
976        // each block's root process (#3430), and for this sub-block the root
977        // IS the shell.
978        let prog_name = cmd
979            .get_argv()
980            .first()
981            .map(|p| {
982                std::path::Path::new(p)
983                    .file_stem()
984                    .map(|s| s.to_string_lossy().into_owned())
985                    .unwrap_or_else(|| p.to_string_lossy().into_owned())
986            })
987            .unwrap_or_default();
988
989        if is_agent {
990            crate::backend::process_tracker::registry::place_pty_spawn(&self.block_id, &mut cmd);
991        }
992        let mut child = pair.slave.spawn_command(cmd).map_err(|e| {
993            tracing::error!(block_id = %self.block_id, error = %e, cmd = %cmd_str, "spawn failed");
994            let mut inner = self.inner.lock().unwrap();
995            Self::set_status(&mut inner, STATUS_DONE);
996            inner.proc_exit_code = -1;
997            inner.input_tx = None;
998            self.unlock_run();
999            format!("failed to spawn command: {e}")
1000        })?;
1001        tracing::info!(block_id = %self.block_id, "process spawned successfully");
1002
1003        // Register PID and record spawn metadata.
1004        let spawn_ts_ms = agentmux_common::time::now_ms();
1005        {
1006            let mut inner = self.inner.lock().unwrap();
1007            if let Some(pid) = child.process_id() {
1008                super::super::pidregistry::register(&self.block_id, pid);
1009                if is_agent {
1010                    crate::backend::process_tracker::registry::track_spawned_agent(&self.block_id, pid);
1011                } else {
1012                    crate::backend::process_tracker::registry::track_spawned(&self.block_id, pid);
1013                }
1014                inner.child_pid = Some(pid);
1015            }
1016            inner.spawn_ts_ms = Some(spawn_ts_ms);
1017            inner.shell_name = prog_name.clone();
1018            inner.is_agent_pane = is_agent;
1019            inner.agent_id = agent_id_for_jekt.clone();
1020        }
1021
1022        // Re-publish now that the spawn metadata exists. The `running` status
1023        // above goes out BEFORE the PTY child is created, so it carries no
1024        // pid, program name or spawn time, and nothing else publishes until
1025        // the process exits — a subscriber would show a running shell as an
1026        // anonymous one for its whole life (Codex P1 on #3436). `persist: 1`
1027        // means this snapshot is also what a later subscriber gets replayed.
1028        self.publish_status();
1029
1030        // Auto-register with jekt if AGENTMUX_AGENT_ID was set in the block env.
1031        // This maps agent_id → block_id in the ReactiveHandler so jekt can deliver
1032        // messages directly to this PTY without a separate /agentmux/reactive/register call.
1033        if let Some(ref agent_id) = agent_id_for_jekt {
1034            // Identity M2: a PTY pane has no `db_agents` row, so no UID —
1035            // registered by name only, counted (spec §4.4.1 row 4).
1036            match crate::backend::reactive::get_global_handler()
1037                .register_agent_full(
1038                    agent_id,
1039                    &self.block_id,
1040                    Some(&self.tab_id),
1041                    0,
1042                    None,
1043                    None,
1044                    "registration.no_uid.shell_pane",
1045                )
1046            {
1047                Ok(()) => {
1048                    tracing::info!(
1049                        block_id = %self.block_id,
1050                        agent_id = %agent_id,
1051                        "jekt: auto-registered"
1052                    );
1053                    // Also write to cross-instance file registry, and its
1054                    // host-global sibling (Tier 2b, issue #1916) — this
1055                    // auto-register path bypasses the HTTP register handler
1056                    // entirely, so it needs its own mirror call too.
1057                    if let Ok(local_url) = std::env::var("AGENTMUX_LOCAL_URL") {
1058                        let data_dir = crate::backend::base::get_mux_data_dir();
1059                        crate::backend::reactive::registry::write(
1060                            &data_dir,
1061                            agent_id,
1062                            &local_url,
1063                            &self.block_id,
1064                        );
1065                        crate::backend::reactive::registry::write_shared_from_env(
1066                            agent_id,
1067                            &local_url,
1068                            &self.block_id,
1069                        );
1070                    }
1071                }
1072                Err(e) => tracing::warn!(
1073                    block_id = %self.block_id,
1074                    agent_id = %agent_id,
1075                    error = %e,
1076                    "jekt: auto-register failed"
1077                ),
1078            }
1079        }
1080        tracing::info!(
1081            block_id = %self.block_id,
1082            mstore_present = self.mstore.is_some(),
1083            event_bus_present = self.event_bus.is_some(),
1084            "[dnd-debug] pre-seed state after spawn"
1085        );
1086
1087        // Seed cmd:cwd in block meta immediately after spawn so drag-and-drop works
1088        // before the shell emits its first OSC 7 (or for shells without integration).
1089        // Not for an SSH or WSL pane: its cwd is a path on the other side, and
1090        // this machine's directory would be sent there on the next launch.
1091        let remote_pane = ssh_plan.is_some() || wsl_distro.is_some();
1092        if let Some(store) = self.mstore.as_ref().filter(|_| !remote_pane) {
1093            let effective_cwd = cwd.clone(); // spawn_cwd: never empty for a local shell
1094            tracing::debug!(block_id = %self.block_id, cwd = %effective_cwd, "seeding cmd:cwd");
1095            if !effective_cwd.is_empty() {
1096                let oref_str = format!("block:{}", self.block_id);
1097                let mut meta_update = MetaMapType::new();
1098                meta_update.insert(
1099                    super::super::META_KEY_CMD_CWD.to_string(),
1100                    serde_json::Value::String(effective_cwd),
1101                );
1102                // Only set if not already populated — don't clobber a restored session CWD
1103                match store.must_get::<crate::backend::obj::Block>(&self.block_id) {
1104                    Ok(block) if obj::meta_get_string(&block.meta, super::super::META_KEY_CMD_CWD, "").is_empty() => {
1105                        match crate::server::service::update_object_meta(store, &oref_str, &meta_update) {
1106                            Ok(()) => {
1107                                // Re-read updated block and broadcast obj:update so the
1108                                // frontend Jotai atom refreshes (update_object_meta only writes
1109                                // to SQLite — it does NOT send a WebSocket event on its own).
1110                                if let Ok(updated_block) = store.must_get::<crate::backend::obj::Block>(&self.block_id) {
1111                                    if let Some(ref event_bus) = self.event_bus {
1112                                        let update = obj::MuxObjUpdate {
1113                                            updatetype: "update".into(),
1114                                            otype: "block".into(),
1115                                            oid: self.block_id.clone(),
1116                                            obj: Some(obj::mux_obj_to_value(&updated_block)),
1117                                        };
1118                                        event_bus.broadcast_event(&crate::backend::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::MuxObjUpdated>(&update, oref_str.clone()));
1119                                        tracing::info!(block_id = %self.block_id, "cmd:cwd seeded and broadcast to frontend");
1120                                    } else {
1121                                        tracing::warn!(block_id = %self.block_id, "cmd:cwd written to store but no event_bus to broadcast — frontend won't update");
1122                                    }
1123                                }
1124                            }
1125                            Err(e) => {
1126                                tracing::warn!(block_id = %self.block_id, error = %e, "failed to seed cmd:cwd in store");
1127                            }
1128                        }
1129                    }
1130                    Ok(_) => {
1131                        tracing::debug!(block_id = %self.block_id, "cmd:cwd already set, skipping seed");
1132                    }
1133                    Err(e) => {
1134                        tracing::warn!(block_id = %self.block_id, error = %e, "failed to read block for cmd:cwd seed");
1135                    }
1136                }
1137            }
1138        }
1139
1140        // Get reader/writer from master
1141        let reader = pair.master.try_clone_reader().map_err(|e| {
1142            let _ = child.kill();
1143            let mut inner = self.inner.lock().unwrap();
1144            Self::set_status(&mut inner, STATUS_DONE);
1145            inner.proc_exit_code = -1;
1146            inner.input_tx = None;
1147            self.unlock_run();
1148            format!("failed to clone PTY reader: {e}")
1149        })?;
1150
1151        let writer = pair.master.take_writer().map_err(|e| {
1152            let _ = child.kill();
1153            let mut inner = self.inner.lock().unwrap();
1154            Self::set_status(&mut inner, STATUS_DONE);
1155            inner.proc_exit_code = -1;
1156            inner.input_tx = None;
1157            self.unlock_run();
1158            format!("failed to take PTY writer: {e}")
1159        })?;
1160
1161        // Spawn PTY read task (blocking I/O → spawn_blocking) + a companion
1162        // coalescing flusher (async task). Split in two, connected by an
1163        // mpsc channel, so the actual OS-level `read()` loop's blocking
1164        // semantics are untouched (this file supports both Unix and
1165        // Windows PTYs and that boundary is not something to risk getting
1166        // subtly wrong per-platform) while the expensive per-chunk work —
1167        // file write, WebSocket broadcast, OSC/translation — is batched.
1168        // See docs/reports/REPORT_RENDERER_CPU_UNBATCHED_PTY_OUTPUT_2026_09_11.md.
1169        let block_id_read = self.block_id.clone();
1170        let block_id_flush = self.block_id.clone();
1171        let broker_read = self.broker.clone();
1172        let broker_flush = self.broker.clone();
1173        let inner_read = self.inner.clone();
1174        let filestore_flush = self.filestore.clone();
1175        let is_agent_read = is_agent;
1176        let is_agent_flush = is_agent;
1177        // Bounded (reagentx P1 on PR #3206) — see PTY_CHANNEL_CAPACITY's own
1178        // doc comment for why: an unbounded channel here would let a
1179        // sustained fast producer grow queued memory without limit if the
1180        // flusher ever falls behind the read rate.
1181        let (pty_tx, pty_rx) = mpsc::channel::<PtyReadChunk>(PTY_CHANNEL_CAPACITY);
1182        // Kept for the post-exit flush barrier (see the wait/cleanup task).
1183        //
1184        // A WEAK sender, emphatically not a clone. ReAgent P1 (round 3) on PR
1185        // #3261: an mpsc channel closes only once EVERY `Sender` is dropped, so
1186        // a strong clone held by the wait task keeps the channel open for as
1187        // long as that task lives — which is precisely across the window where
1188        // the task waits for the flusher to end. `flusher_handle` could then
1189        // never resolve, on any platform, making the "prefer true EOF" fast
1190        // path dead code and silently applying the fallback's residual race to
1191        // every exit everywhere. An earlier revision of this very comment
1192        // claimed a clone "does NOT keep the channel open"; that was simply
1193        // wrong.
1194        //
1195        // `WeakSender` does not count towards keeping the channel alive, and
1196        // `upgrade()` returning `None` is itself the answer the barrier wants:
1197        // the channel is closed, so everything in it was drained.
1198        let barrier_tx: tokio::sync::mpsc::WeakSender<PtyReadChunk> = pty_tx.downgrade();
1199
1200        tokio::task::spawn_blocking(move || {
1201            let mut reader = reader;
1202            let mut buf = [0u8; PTY_READ_BUF_SIZE];
1203
1204            // OSC extractor: only for agent panes. Terminal panes forward
1205            // raw bytes to xterm.js which handles OSC natively — stripping
1206            // there would suppress the native window-title update. Agent
1207            // panes don't use xterm.js; OSC bytes in FileStore corrupt the
1208            // document renderer, so we extract and strip them here. Kept
1209            // in THIS (per-read) loop rather than the flusher: it is
1210            // cheap, and its own internal state must see every read in
1211            // order exactly as they arrive — moving it to the batched side
1212            // would change nothing about correctness but would mean
1213            // holding it across an .await, so it stays here for simplicity.
1214            let mut osc_extractor: Option<crate::backend::osc_extractor::OscExtractor> =
1215                if is_agent_read {
1216                    Some(crate::backend::osc_extractor::OscExtractor::new())
1217                } else {
1218                    None
1219                };
1220
1221            loop {
1222                match reader.read(&mut buf) {
1223                    Ok(0) => break, // EOF
1224                    Ok(n) => {
1225                        inner_read.lock().unwrap().last_pty_output = Some(Instant::now());
1226                        if broker_read.is_some() {
1227                            let raw = &buf[..n];
1228
1229                            // Extract OSC sequences from agent-pane output.
1230                            // `cleaned` has OSC bytes removed; `osc_events` carries
1231                            // any normalised Claude Code title strings found in this chunk.
1232                            let mut cleaned_storage: Vec<u8> = Vec::new();
1233                            let mut osc_events: Vec<crate::backend::osc_extractor::OscEvent> = Vec::new();
1234                            if let Some(ref mut ext) = osc_extractor {
1235                                let (cleaned, evs) = ext.feed(raw);
1236                                cleaned_storage = cleaned;
1237                                osc_events = evs;
1238                            }
1239                            let chunk: &[u8] = if osc_extractor.is_some() {
1240                                &cleaned_storage
1241                            } else {
1242                                raw
1243                            };
1244
1245                            // `blocking_send`, not `send` — this closure runs
1246                            // on a `spawn_blocking` thread, not async, and a
1247                            // bounded channel's plain `send` is itself async.
1248                            // Blocks THIS (already-dedicated-to-blocking)
1249                            // thread once the channel is full, which is
1250                            // exactly the backpressure PTY_CHANNEL_CAPACITY's
1251                            // doc comment describes — never the async runtime.
1252                            //
1253                            // Best-effort beyond that: if the flusher task is
1254                            // gone (block torn down mid-read), there is
1255                            // nothing useful to do with this chunk — the PTY
1256                            // read loop itself must keep draining so the
1257                            // child process's stdout never backs up.
1258                            let _ = pty_tx.blocking_send(PtyReadChunk {
1259                                ack: None,
1260                                data: chunk.to_vec(),
1261                                osc_events,
1262                            });
1263                        }
1264                    }
1265                    Err(e) => {
1266                        tracing::debug!("PTY read error for {}: {}", block_id_read, e);
1267                        break;
1268                    }
1269                }
1270            }
1271            // `pty_tx` drops here, closing the channel — the flusher below
1272            // sees `recv()` return `None` once it has drained everything
1273            // already sent, flushes any final partial batch, and exits.
1274            // (Still true with the barrier handle in play: that one is a
1275            // `WeakSender`, which does not keep the channel open. It must stay
1276            // weak — see its own comment above.)
1277        });
1278
1279        // Coalescing flusher: batches whatever chunks the read loop above
1280        // has already queued (bounded by PTY_COALESCE_MAX_BYTES, and never
1281        // waiting for more to arrive) before doing the
1282        // actual file write + broadcast + translation — once per batch
1283        // instead of once per (up to 4KB) PTY read. A standalone fn (not
1284        // inlined into the spawn) so it can be driven directly in tests
1285        // with a synthetic channel, no real PTY required.
1286        //
1287        // Handle kept (reagentx P1 on PR #3206, third round): the wait
1288        // task below must await this before publishing STATUS_DONE — see
1289        // its own comment for why.
1290        let flusher_handle = tokio::spawn(run_pty_output_flusher(
1291            pty_rx,
1292            broker_flush,
1293            block_id_flush,
1294            filestore_flush,
1295            is_agent_flush,
1296        ));
1297
1298        // Spawn input task (routes input channel → PTY writer + resize + signals)
1299        // Owns writer and master — dropping them closes the PTY, causing child to exit.
1300        let master = pair.master;
1301        tokio::spawn(async move {
1302            let mut writer = writer;
1303            let mut input_rx = input_rx;
1304            while let Some(input) = input_rx.recv().await {
1305                if let Some(data) = input.input_data {
1306                    use std::io::Write;
1307                    if let Err(e) = writer.write_all(&data) {
1308                        tracing::debug!("PTY write error: {}", e);
1309                        break;
1310                    }
1311                }
1312                if let Some(ref size) = input.term_size {
1313                    let pty_size = PtySize {
1314                        rows: size.rows as u16,
1315                        cols: size.cols as u16,
1316                        pixel_width: 0,
1317                        pixel_height: 0,
1318                    };
1319                    if let Err(e) = master.resize(pty_size) {
1320                        tracing::debug!("PTY resize error: {}", e);
1321                    }
1322                }
1323                if input.sig_name.is_some() {
1324                    // Drop writer + master to close PTY, which terminates the child
1325                    break;
1326                }
1327            }
1328            // writer and master drop here → PTY closes → child gets EOF/terminates
1329        });
1330
1331        // Spawn wait task (monitors process exit)
1332        let inner_wait = Arc::clone(&self.inner);
1333        let block_id_wait = self.block_id.clone();
1334        let tab_id_wait = self.tab_id.clone();
1335        let agent_id_wait = agent_id_for_jekt.clone();
1336        let broker_wait = self.broker.clone();
1337        // An SSH pane holds its connection open from here to the wait task's
1338        // pane_ended (ssh_pane). Counted only now, after every step that can
1339        // still fail and return, so each start has exactly one end.
1340        if ssh_plan.is_some() {
1341            crate::backend::remote::link::pane_started(&conn_name, false);
1342        }
1343        let filestore_note = self.filestore.clone();
1344        let ssh_wait = ssh_plan
1345            .as_ref()
1346            .map(|(_, dest)| (conn_name.clone(), dest.destination.clone()));
1347        // Moved into the wait task: the secret lives exactly as long as the shell.
1348        let askpass_guard_wait = askpass_guard;
1349        // For the agent-lease release below: clearing the `term:agentlockuntil`
1350        // meta copy (not just the in-memory registry) needs both, since that
1351        // is what the frontend's own gate reads.
1352        let mstore_wait = self.mstore.clone();
1353        let event_bus_wait = self.event_bus.clone();
1354        let run_lock = Arc::clone(&self.run_lock);
1355        // Async outer task, not a bare spawn_blocking (reagentx P1 on PR
1356        // #3206, third round): before this fix, the read loop flushed
1357        // every chunk SYNCHRONOUSLY inline, so by the time it observed PTY
1358        // EOF (closely tracking child exit) all output was already
1359        // committed to FileStore and broadcast — no explicit join needed,
1360        // the ordering fell out of the code being synchronous. Now the
1361        // read loop only drops the channel sender on EOF; the flusher (its
1362        // `JoinHandle` above) still has to notice the closed channel,
1363        // drain its accumulation loop, and complete an async
1364        // `spawn_blocking` flush — plus
1365        // queueing behind other panes' flushes — with nothing previously
1366        // synchronizing that completion against this task. A fast-exiting
1367        // command's trailing output could be missing from FileStore / not
1368        // yet broadcast at the moment clients observe `STATUS_DONE`,
1369        // widening a race that used to be negligible into a real one.
1370        //
1371        // codex P1 on PR #3206 (same round, caught on the fix above before
1372        // merge): reaping the child must NOT be gated behind awaiting the
1373        // flusher. If a background descendant inherits the PTY slave fd and
1374        // keeps it open after the direct child (the shell) exits — e.g. it
1375        // ignores SIGHUP — the read loop's `read()` may never observe EOF,
1376        // `pty_tx` is never dropped, the flusher's accumulation loop never
1377        // sees its channel close, and `flusher_handle` never resolves.
1378        // Awaiting it before `child.wait()` would then hang cleanup,
1379        // `STATUS_DONE` publication, and `run_lock` release for that
1380        // descendant's entire remaining lifetime — the pane reports running
1381        // forever and can never restart. `persistent/process_waiter.rs`'s stdout/stderr
1382        // reader cleanup hits the identical descendant-held-descriptor case
1383        // and already establishes the fix: reap the child first
1384        // (unconditional, not gated on any reader/flusher), then bound the
1385        // reader/flusher wait with a timeout rather than waiting forever —
1386        // see the timeout's own comment below for why expiry does NOT
1387        // `abort()` here, unlike `persistent/process_waiter.rs`'s version of this bound.
1388        tokio::spawn(async move {
1389            // Reap the child (blocking OS call) on its own — depends only
1390            // on the direct child exiting, never on PTY EOF. Clones
1391            // `block_id_wait` in (rather than moving the outer binding)
1392            // since the rest of this task still needs it afterward.
1393            let block_id_reap = block_id_wait.clone();
1394            let (exit_code, raw_exit_code) = tokio::task::spawn_blocking(move || {
1395                let mut child = child;
1396                match child.wait() {
1397                    // The pane keeps its 0 / 1 / -1; the raw code is for an
1398                    // SSH pane, where 255 means ssh could not connect.
1399                    Ok(status) => (if status.success() { 0 } else { 1 }, Some(status.exit_code())),
1400                    Err(e) => {
1401                        tracing::warn!("wait error for block {}: {}", block_id_reap, e);
1402                        (-1, None)
1403                    }
1404                }
1405            })
1406            .await
1407            .expect("PTY child wait task panicked");
1408            drop(askpass_guard_wait);
1409            // An SSH pane that lost its connection stays, saying so (ssh_pane).
1410            let lost_note = ssh_wait.as_ref().and_then(|(conn, dest)| {
1411                let stopped_by_srv = inner_wait.lock().unwrap().proc_status != STATUS_RUNNING;
1412                super::ssh_pane::ended(conn, dest, raw_exit_code, stopped_by_srv)
1413            });
1414            let close_on_exit = close_on_exit && lost_note.is_none();
1415
1416            tracing::info!(block_id = %block_id_wait, exit_code = exit_code, "process exited");
1417
1418            // Bounded wait for the flusher to drain trailing output — see
1419            // `FLUSHER_DRAIN_TIMEOUT`'s own doc comment for why this must be
1420            // bounded. Deliberately does NOT `abort()` on timeout (reagentx
1421            // P1, same round, on the timeout+abort version of this fix):
1422            // the flusher (consumer) and the PTY read loop (producer,
1423            // `spawn_blocking` doing a raw, un-cancellable OS-level
1424            // `reader.read()`) are separate tasks, unlike persistent/stdout_reader.rs's
1425            // single combined reader task. Aborting only the flusher would
1426            // stop it draining `pty_tx` — but the read loop, still blocked
1427            // in real OS I/O, cannot be interrupted by tokio's cooperative
1428            // cancellation and keeps running regardless, so the leaked
1429            // thread is NOT reclaimed either way. Aborting would only add a
1430            // regression on top: every later `blocking_send` finds the
1431            // receiver gone and is silently discarded, permanently losing
1432            // any further output from a still-live descendant with no log
1433            // at the point of loss. Simply letting `flusher_handle` drop
1434            // here (no `abort()`) detaches it — the flusher and its read
1435            // loop keep running independently in the background, still
1436            // persisting and broadcasting whatever the descendant produces
1437            // next, and exit naturally once the PTY genuinely reaches EOF.
1438            //
1439            // Two signals, strongest first (ReAgent P1 on PR #3261).
1440            //
1441            // The flusher task ENDING is the strong one: it only ends when the
1442            // channel closes, which only happens once the read loop observed
1443            // true EOF and dropped its sender — so every byte the child ever
1444            // produced is both enqueued and committed. Wherever EOF actually
1445            // arrives (Linux, macOS) this resolves in milliseconds and the
1446            // guarantee is exactly what it was before the barrier existed.
1447            //
1448            // The barrier is the fallback, for where EOF never comes at all:
1449            // on Windows ConPTY's console host outlives the direct child, so
1450            // the read loop stays blocked in a read that never returns and the
1451            // task can never end. Waiting on it there always burned the full
1452            // ceiling (§12) — the second a human sees after typing `exit`.
1453            //
1454            // Residual, stated plainly: the barrier cannot order against a
1455            // chunk the read loop has read but not yet enqueued, so on the
1456            // fallback path a final chunk racing it can land after
1457            // STATUS_DONE. Pre-PR behaviour on that same path was also
1458            // best-effort (it timed out and detached), so this trades grace
1459            // for responsiveness rather than trading away a guarantee that was
1460            // ever actually held there. Where the guarantee WAS held — EOF
1461            // platforms — it still is.
1462            //
1463            // Total worst case stays FLUSHER_DRAIN_TIMEOUT: the two waits
1464            // split that budget rather than each taking it (ReAgent P2).
1465            if close_on_exit {
1466                drop(flusher_handle);
1467            } else {
1468                match tokio::time::timeout(FLUSHER_EOF_GRACE, flusher_handle).await {
1469                    Ok(Ok(())) => {}
1470                    Ok(Err(e)) => {
1471                        tracing::warn!(
1472                            block_id = %block_id_wait,
1473                            error = %e,
1474                            "PTY output flusher task panicked or was cancelled before completing; \
1475                             proceeding with pane teardown anyway — some trailing output may be missing"
1476                        );
1477                    }
1478                    Err(_) => {
1479                        // No EOF within the grace — assume it is never coming
1480                        // (ConPTY) and settle for the bounded barrier.
1481                        // `None` means every sender is gone — the channel
1482                        // closed and the flusher drained it, which is the
1483                        // guarantee the barrier exists to obtain.
1484                        let barrier = match barrier_tx.upgrade() {
1485                            Some(tx) => flush_barrier(&tx, FLUSHER_BARRIER_TIMEOUT).await,
1486                            None => Ok(()),
1487                        };
1488                        if barrier.is_err() {
1489                            tracing::warn!(
1490                                block_id = %block_id_wait,
1491                                timeout = ?FLUSHER_BARRIER_TIMEOUT,
1492                                "PTY output flush barrier went unanswered after process exit (a                                  wedged flusher, or a background descendant still holding the PTY                                  open?) — proceeding with pane teardown; the flusher and its read                                  loop stay detached and running, so later output is still not lost,                                  but some trailing output may land after STATUS_DONE"
1493                            );
1494                        }
1495                    }
1496                }
1497            }
1498
1499            // After the drain above, so it comes after ssh's own last words.
1500            if let (Some(text), Some(broker)) = (lost_note, broker_wait.clone()) {
1501                super::ssh_pane::write_note(broker, filestore_note, block_id_wait.clone(), text).await;
1502            }
1503
1504            // Cloned before the inner `spawn_blocking` below moves
1505            // `block_id_wait` — still needed after it completes, for the
1506            // close-on-exit trigger.
1507            let block_id_for_close = block_id_wait.clone();
1508            let tab_id_for_close = tab_id_wait.clone();
1509            // Identity of THIS controller instance, for the
1510            // still-the-current-controller check at close time — see the
1511            // trigger below.
1512            let inner_for_close = Arc::clone(&inner_wait);
1513
1514            // Entire body below is UNCHANGED from before this fix, still
1515            // inside its own spawn_blocking — registry::remove (among
1516            // others here) does its own synchronous file I/O, so it
1517            // doesn't belong on an async worker either.
1518            tokio::task::spawn_blocking(move || {
1519                // Unregister PID from per-pane metrics
1520                super::super::pidregistry::unregister(&block_id_wait);
1521
1522                // Deregister from jekt — removes the agent_id → block_id mapping so
1523                // subsequent jekt attempts fall back to MessageBus rather than a dead PTY.
1524                crate::backend::reactive::get_global_handler().unregister_block(&block_id_wait);
1525
1526                // Also remove from cross-instance file registry and cloud subscriber.
1527                if let Some(ref agent_id) = agent_id_wait {
1528                    let data_dir = crate::backend::base::get_mux_data_dir();
1529                    crate::backend::reactive::registry::remove(&data_dir, agent_id);
1530                    crate::backend::reactive::registry::remove_shared_from_env(agent_id);
1531                    if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
1532                        sub.remove_agent(agent_id);
1533                    }
1534                }
1535
1536                // Release any PtyShell agent lease this block still holds.
1537                //
1538                // The lease (`blockcontroller::agent_lock`) locks a human out
1539                // of their own keyboard while an agent drives the pane's
1540                // shell, and it is deliberately time-bounded so it always
1541                // lapses on its own. But "the shell this lease belongs to has
1542                // exited" is a stronger signal than the clock: there is
1543                // nothing left to collide with, and holding the lock for the
1544                // remainder of its window would drop keystrokes aimed at
1545                // whatever comes next in this pane. Matters most for the
1546                // exact case that motivated close-on-exit
1547                // (SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md §10): a human
1548                // typing `exit` in a shell an agent was using must not have
1549                // their following keystrokes swallowed by a lease for a PTY
1550                // that is already gone.
1551                //
1552                // Guarded on this task still belonging to the REGISTERED
1553                // controller, the same `Arc::ptr_eq` identity check the
1554                // close-on-exit trigger below already uses, and for the same
1555                // reason (Codex P1 / ReAgent P1 on PR #3249): a force-restart
1556                // (`resync_controller` → `stop_for_replace`) kills this child
1557                // and registers a REPLACEMENT controller for the same
1558                // block_id. This wait task belongs to the OLD one. Without
1559                // the guard it would fire afterwards and clear a lease the
1560                // agent had legitimately taken on the NEW shell, letting
1561                // human input race the agent's writes on a pane that never
1562                // stopped being driven. Leases are keyed by block_id, so a
1563                // stale task cannot tell its own lease from its successor's —
1564                // controller identity is what distinguishes them.
1565                //
1566                // Check-then-act, like its precedent: a replacement
1567                // registered in the window between this check and the remove
1568                // would still lose its lease. Same residual race the
1569                // close-on-exit guard accepts, and self-correcting here — the
1570                // agent's next write re-takes the lease, since
1571                // `lock_shell_for_agent` runs before every one.
1572                release_lease_if_current(
1573                    &block_id_wait,
1574                    &inner_wait,
1575                    &mstore_wait,
1576                    &event_bus_wait,
1577                );
1578
1579                // Update inner state
1580                {
1581                    let mut inner = inner_wait.lock().unwrap();
1582                    inner.proc_exit_code = exit_code;
1583                    ShellController::set_status(&mut inner, STATUS_DONE);
1584                    inner.input_tx = None;
1585                }
1586
1587                // Publish done status
1588                if let Some(ref broker) = broker_wait {
1589                    let status = {
1590                        let inner = inner_wait.lock().unwrap();
1591                        BlockControllerRuntimeStatus {
1592                            durable: false,
1593                            blockid: block_id_wait.clone(),
1594                            version: inner.status_version,
1595                            shellprocstatus: inner.proc_status.clone(),
1596                            shellprocconnname: inner.conn_name.clone(),
1597                            shellprocexitcode: inner.proc_exit_code,
1598                            // Kept on the exit status too, so the drawer's
1599                            // info line can still say which PID exited.
1600                            shellprocpid: inner.child_pid,
1601                            shellprocname: inner.shell_name.clone(),
1602                            spawn_ts_ms: inner.spawn_ts_ms,
1603                            is_agent_pane: inner.is_agent_pane,
1604                            turn_active: false,
1605                        }
1606                    };
1607                    super::super::publish_controller_status(broker, &status);
1608                }
1609
1610                // Release run lock
1611                run_lock.store(false, Ordering::SeqCst);
1612            })
1613            .await
1614            .expect("PTY wait/cleanup task panicked");
1615
1616            // Close-on-exit (SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md
1617            // §10): fire-and-forget, deliberately AFTER STATUS_DONE is
1618            // published and `run_lock` is released above — closing the
1619            // pane doesn't need to block or gate either of those.
1620            //
1621            // Reached promptly after the child is reaped: for a CLOSING
1622            // pane the flusher-drain wait above is skipped outright (§13),
1623            // so none of `FLUSHER_DRAIN_TIMEOUT` is paid here, and
1624            // `close_on_exit_delay_ms` defaults to 0. A pane that is NOT
1625            // closing still takes the full ordered drain wait — now
1626            // bounded at 1s rather than the original 10s (§12), because
1627            // that ceiling turned out to be reached on essentially every
1628            // ordinary exit on Windows (ConPTY's console host outlives
1629            // the direct child, so the read loop never sees EOF) and it
1630            // gates `STATUS_DONE` publication, i.e. every
1631            // STATUS_DONE-driven affordance — a pane's "done" icon,
1632            // `PtyShellStatus`'s `running: false` — not just this one.
1633            //
1634            // NOT gated on the flusher-drain outcome just logged above.
1635            // An earlier version of this code skipped closing whenever the
1636            // flusher timed out, reasoning that a still-live background
1637            // descendant might have more output coming. Live-tested on
1638            // Windows (this PR) and found to backfire completely: the
1639            // 10s `FLUSHER_DRAIN_TIMEOUT` was hit on essentially EVERY
1640            // plain `cmd.exe` exit, not as a rare edge case — ConPTY's own
1641            // console-host process routinely outlives the direct child by
1642            // design, so gating on this made close-on-exit silently never
1643            // fire on the one platform this was tested on. Simply closing
1644            // regardless matches what actually happens next either way:
1645            // the flusher and its read loop are ALREADY left running
1646            // detached in the background (see the comment above), so any
1647            // genuinely-still-arriving trailing output keeps being
1648            // persisted to the block's file exactly as it would if the
1649            // pane stayed open — closing the pane just stops anyone from
1650            // looking at it, the same as closing any other terminal app's
1651            // window while a background job still has an open handle.
1652            if close_on_exit {
1653                tokio::spawn(async move {
1654                    tokio::time::sleep(std::time::Duration::from_millis(close_on_exit_delay_ms)).await;
1655                    // Only close if THIS controller is still the one
1656                    // registered for the block (Codex P1 on PR #3230).
1657                    //
1658                    // A process exit is not by itself evidence that the
1659                    // pane should close — the child also dies when the
1660                    // controller is deliberately replaced. `resync_controller`'s
1661                    // force-restart path (the terminal's own refresh
1662                    // action, `forcerestart: true`) calls
1663                    // `stop_for_replace`, which kills this child, then
1664                    // registers a REPLACEMENT controller for the same
1665                    // block. This wait task belongs to the OLD controller
1666                    // and still carries `close_on_exit == true`, so
1667                    // without this check reaping that killed child would
1668                    // delete the pane — and with it the freshly
1669                    // registered replacement — instead of completing the
1670                    // restart. The delay above widens the same window for
1671                    // an explicitly-configured `cmd:closeonexitdelay`:
1672                    // restarting an exited shell during it must cancel the
1673                    // close, which re-checking AFTER the sleep is what
1674                    // makes true.
1675                    //
1676                    // Registry identity is the discriminator, and it
1677                    // covers the whole replace sequence: during it the old
1678                    // entry is removed (`remove_controller_entry_only`)
1679                    // and a new one registered, so this lookup finds
1680                    // either nothing or a DIFFERENT controller — both
1681                    // correctly skip. A natural exit leaves this
1682                    // controller registered and untouched, so it matches
1683                    // and the close proceeds.
1684                    let still_current = super::super::get_controller(&block_id_for_close)
1685                        .and_then(|ctrl| {
1686                            ctrl.as_any()
1687                                .downcast_ref::<ShellController>()
1688                                .map(|sc| Arc::ptr_eq(&sc.inner, &inner_for_close))
1689                        })
1690                        .unwrap_or(false);
1691                    if !still_current {
1692                        tracing::info!(
1693                            block_id = %block_id_for_close,
1694                            "close-on-exit: controller was replaced or removed since this \
1695                             process exited (restart in flight?) — skipping pane close"
1696                        );
1697                        return;
1698                    }
1699                    super::super::close_on_exit(tab_id_for_close, block_id_for_close);
1700                });
1701            }
1702        });
1703
1704        // Return immediately — PTY tasks run in background
1705        Ok(())
1706    }
1707
1708    fn stop(&self, _graceful: bool, new_status: &str) -> Result<(), String> {
1709        // Extract what we need from the lock, release it before any async work.
1710        #[allow(unused_variables)] // used under #[cfg(unix)] only
1711        let pid_to_kill = {
1712            let mut inner = self.inner.lock().unwrap();
1713            if inner.proc_status == new_status {
1714                return Ok(());
1715            }
1716            let pid = inner.child_pid;
1717            // Drop the input channel — closes PTY writer → delivers EOF/SIGHUP as
1718            // belt-and-suspenders in case signal delivery fails on the platform.
1719            inner.input_tx = None;
1720            Self::set_status(&mut inner, new_status);
1721            pid
1722        };
1723
1724        // Send SIGTERM to the process group so that child processes spawned by
1725        // the shell (e.g. `claude --dangerously-skip-permissions` and its subtree)
1726        // are also signalled. Negative pid targets the whole process group.
1727        // Schedule SIGKILL after KILL_GRACE_SECS as a backstop for processes
1728        // that ignore or delay on SIGTERM.
1729        #[cfg(unix)]
1730        if let Some(pid) = pid_to_kill {
1731            // SAFETY: kill() is a well-defined POSIX syscall.
1732            unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGTERM) };
1733            tokio::spawn(async move {
1734                tokio::time::sleep(tokio::time::Duration::from_secs(KILL_GRACE_SECS)).await;
1735                unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL) };
1736            });
1737        }
1738
1739        // A declared-background descendant (bashwrap, and transitively
1740        // whatever it PTY-spawned, e.g. `task dev`) deliberately detaches
1741        // into its OWN session at startup (`setsid()` in bash_wrap.rs's
1742        // `detach_declared_background_session`), specifically so a
1743        // session-RESTART's narrower kill (`stop_for_replace`, above)
1744        // can't reach it via PTY-hangup/session-leader-death SIGHUP. But
1745        // that same detachment also removes it from THIS process's group
1746        // — so the group-wide kill just above (intended to reach it on a
1747        // genuine STOP, e.g. pane close) no longer can either. The agent's
1748        // tracker reaches it (a cgroup on Linux, the best-effort
1749        // `ScanTracker` on macOS and Linux without delegation, by its env
1750        // tag), but only when the block's tracker is released; a terminal
1751        // block has the no-op stub. This step doesn't wait for either.
1752        // Without it, `stop()` (the real,
1753        // unmodified deletion path — used by `delete_tab`/`delete_block`/
1754        // `wcore::tab`) would leave it running past the stop on Unix (reagentx
1755        // finding, PR #2683). Kill each `Running`, known-pid declared-
1756        // background task's own (detached) process group explicitly, by
1757        // pid from the durable registry — the one piece of state that
1758        // still knows about it once it's escaped this controller's own
1759        // process tree. Windows is unaffected: Job Object membership
1760        // isn't process-group-based, so `delete_controller`'s existing
1761        // whole-job close already reaches it there, unchanged.
1762        #[cfg(unix)]
1763        if let Some(store) = &self.mstore {
1764            match store.background_task_list_for_block(&self.block_id) {
1765                Ok(tasks) => {
1766                    for task in tasks {
1767                        if task.status != crate::backend::storage::background_tasks::BackgroundTaskStatus::Running {
1768                            continue;
1769                        }
1770                        let Some(pid) = task.pid else { continue };
1771                        let pid = pid as libc::pid_t;
1772                        // SAFETY: kill() is a well-defined POSIX syscall.
1773                        unsafe { libc::kill(-pid, libc::SIGTERM) };
1774                        tokio::spawn(async move {
1775                            tokio::time::sleep(tokio::time::Duration::from_secs(KILL_GRACE_SECS)).await;
1776                            unsafe { libc::kill(-pid, libc::SIGKILL) };
1777                        });
1778                    }
1779                }
1780                Err(e) => {
1781                    tracing::warn!(
1782                        block_id = %self.block_id,
1783                        error = %e,
1784                        "stop(): failed to list declared-background tasks for cleanup",
1785                    );
1786                }
1787            }
1788        }
1789
1790        Ok(())
1791    }
1792
1793    fn stop_for_replace(&self, new_status: &str) -> Result<(), String> {
1794        // Same extraction as stop() — but the kill below targets ONLY this
1795        // one pid, never the process group/job, so a declared-background
1796        // descendant (e.g. `task dev`) survives. See
1797        // docs/specs/SPEC_BACKGROUND_TASK_TEARDOWN_SURVIVAL_2026_08_20.md.
1798        #[allow(unused_variables)] // used under #[cfg(unix)] only
1799        let pid_to_kill = {
1800            let mut inner = self.inner.lock().unwrap();
1801            if inner.proc_status == new_status {
1802                return Ok(());
1803            }
1804            let pid = inner.child_pid;
1805            inner.input_tx = None;
1806            Self::set_status(&mut inner, new_status);
1807            pid
1808        };
1809
1810        let Some(pid) = pid_to_kill else { return Ok(()) };
1811
1812        // Prefer the tracked kill (Windows: OpenProcess+TerminateProcess,
1813        // with a membership check against the block's job first — see
1814        // `process_tracker::registry::kill_pid`). This is expected to
1815        // succeed in every real production case: `track_spawned` already
1816        // ran for this exact pid right after this controller's own spawn.
1817        if let Some(registry) = crate::backend::process_tracker::registry::global() {
1818            if registry.kill_pid(&self.block_id, pid) {
1819                return Ok(());
1820            }
1821        }
1822
1823        // Fallback — reached when the registry global isn't set (tests;
1824        // same "silently skip tracker registration" convention
1825        // `track_spawned`'s own doc comment describes), OR `kill_pid`
1826        // itself returned `false` because this pid isn't (yet, or ever)
1827        // a recognized member of the block's tracker — e.g.
1828        // `track_spawned`'s own `assign_process` call failed at spawn
1829        // time (a real, already-logged non-fatal warning path in
1830        // `registry::track_spawned`). Unlike `stop()`, where a failed
1831        // direct kill is backstopped by `delete_controller`'s whole-job
1832        // teardown, `stop_for_replace` deliberately never touches the
1833        // job — so without a fallback here, that failure mode would leak
1834        // the old CLI process forever once resync_controller replaces it
1835        // (reagentx P1, PR #2683). A direct, single-PID (not group,
1836        // not job) kill on both platforms, so this can't reach a
1837        // declared-background descendant the way stop()'s `-(pid)` /
1838        // a whole-job close would.
1839        //
1840        // The primary path for a terminal block on Unix, whose tracker is the
1841        // no-op stub (`kill_pid` returns `false`). An agent block's tracker
1842        // (cgroup, or `ScanTracker` on macOS and Linux without delegation)
1843        // SIGKILLs the pid itself and returns `true`.
1844        #[cfg(unix)]
1845        {
1846            // SAFETY: kill() is a well-defined POSIX syscall.
1847            unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
1848            tokio::spawn(async move {
1849                tokio::time::sleep(tokio::time::Duration::from_secs(KILL_GRACE_SECS)).await;
1850                unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
1851            });
1852        }
1853        #[cfg(windows)]
1854        {
1855            // Mirrors process_tracker::windows::JobObjectTracker::kill_pid's
1856            // own kill step exactly, minus its job-membership pre-check
1857            // (which is precisely what already failed to get us here) —
1858            // a plain OpenProcess(PROCESS_TERMINATE) + TerminateProcess on
1859            // the raw pid, best-effort (the process may have already
1860            // exited on its own).
1861            use windows_sys::Win32::Foundation::CloseHandle;
1862            use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
1863            unsafe {
1864                let h = OpenProcess(PROCESS_TERMINATE, 0, pid);
1865                if !h.is_null() {
1866                    let ok = TerminateProcess(h, 1);
1867                    CloseHandle(h);
1868                    if ok == 0 {
1869                        tracing::warn!(
1870                            block_id = %self.block_id,
1871                            pid,
1872                            error = %std::io::Error::last_os_error(),
1873                            "stop_for_replace: fallback TerminateProcess failed"
1874                        );
1875                    }
1876                } else {
1877                    tracing::warn!(
1878                        block_id = %self.block_id,
1879                        pid,
1880                        error = %std::io::Error::last_os_error(),
1881                        "stop_for_replace: fallback OpenProcess failed — old CLI process may be leaked"
1882                    );
1883                }
1884            }
1885        }
1886
1887        Ok(())
1888    }
1889
1890    fn get_runtime_status(&self) -> BlockControllerRuntimeStatus {
1891        self.get_status_snapshot()
1892    }
1893
1894    fn send_input(&self, input: BlockInputUnion, seq: Option<u64>) -> Result<(), String> {
1895        let mut inner = self.inner.lock().unwrap();
1896        let tx = match &inner.input_tx {
1897            Some(tx) => tx.clone(),
1898            None => return Err("controller is not running".to_string()),
1899        };
1900        match seq {
1901            None => tx.send(input).map_err(|e| format!("send_input: {e}")),
1902            Some(s) => {
1903                // Detect session reset: seq==0 means the TermViewModel
1904                // restarted and its per-block counter is back at zero.
1905                // (A gap-threshold heuristic was tried and removed — a
1906                // stale/duplicate packet far behind could falsely trigger
1907                // a reset and replay old input.)
1908                if s == 0 && inner.input_seq_next > 0 {
1909                    tracing::info!(
1910                        block_id = %self.block_id,
1911                        prev_next = inner.input_seq_next,
1912                        new_seq = s,
1913                        "input seq reset (session reset detected)"
1914                    );
1915                    inner.input_seq_next = s;
1916                    inner.input_seq_buf.clear();
1917                }
1918
1919                let next = inner.input_seq_next;
1920                if s == next {
1921                    // Advance before sending — a send failure must not leave the
1922                    // backend stuck waiting for a seq the frontend will never resend.
1923                    inner.input_seq_next += 1;
1924                    if let Err(e) = tx.send(input) {
1925                        // Unbounded send only fails if the receiver is gone
1926                        // (controller stopped) — nothing to do but drop quietly.
1927                        tracing::warn!(
1928                            block_id = %self.block_id,
1929                            seq = s,
1930                            "send_input: input channel closed, discarding packet: {e}"
1931                        );
1932                        return Ok(());
1933                    }
1934                    // Drain any buffered out-of-order packets now in order.
1935                    loop {
1936                        let expected = inner.input_seq_next;
1937                        match inner.input_seq_buf.remove(&expected) {
1938                            Some(buffered) => {
1939                                inner.input_seq_next += 1;
1940                                if let Err(e) = tx.send(buffered) {
1941                                    tracing::warn!(
1942                                        block_id = %self.block_id,
1943                                        seq = expected,
1944                                        "send_input drain: input channel closed, discarding buffered packet: {e}"
1945                                    );
1946                                }
1947                            }
1948                            None => break,
1949                        }
1950                    }
1951                    Ok(())
1952                } else if s > next {
1953                    if inner.input_seq_buf.len() < SHELL_INPUT_CH_SIZE {
1954                        inner.input_seq_buf.insert(s, input);
1955                    } else {
1956                        tracing::warn!(block_id = %self.block_id, seq = s, "input reorder buffer full, dropping");
1957                    }
1958                    Ok(())
1959                } else {
1960                    tracing::warn!(block_id = %self.block_id, seq = s, next, "duplicate input seq, discarding");
1961                    Ok(())
1962                }
1963            }
1964        }
1965    }
1966
1967    fn controller_type(&self) -> &str {
1968        &self.controller_type
1969    }
1970
1971    fn block_id(&self) -> &str {
1972        &self.block_id
1973    }
1974
1975    fn agent_id(&self) -> Option<String> {
1976        self.inner.lock().unwrap().agent_id.clone()
1977    }
1978
1979    fn set_agent_id(&self, id: Option<String>) {
1980        self.inner.lock().unwrap().agent_id = id;
1981    }
1982
1983    fn as_any(&self) -> &dyn std::any::Any {
1984        self
1985    }
1986}
1987
1988#[cfg(test)]
1989mod agent_id_for_jekt_tests {
1990    use super::{is_global_cmd_env_injectable, resolve_agent_id_for_jekt};
1991    use crate::backend::blockcontroller::META_KEY_CMD_ENV;
1992    use crate::backend::obj::MetaMapType;
1993
1994    #[test]
1995    fn identity_keys_are_never_globally_injectable() {
1996        // reagentx P1, round 2 on #2694: these must never come from global
1997        // settings.cmd_env — only from a block's own explicit override.
1998        assert!(!is_global_cmd_env_injectable("AGENTMUX_AGENT_ID"));
1999        assert!(!is_global_cmd_env_injectable("WAVEMUX_AGENT_ID"));
2000    }
2001
2002    #[test]
2003    fn other_global_cmd_env_keys_remain_injectable() {
2004        // Unrelated global defaults (arbitrary user cmd_env entries) are
2005        // unaffected — only the two identity keys are excluded. Was
2006        // AGENTMUX_AGENT_COLOR before that var's decommission (2026-09-20,
2007        // SPEC_AGENT_HEADER_COLOR_UNIFICATION_2026_09_20.md) — swapped to a
2008        // generic name so this test doesn't reference a var with no live
2009        // meaning left in the codebase.
2010        assert!(is_global_cmd_env_injectable("SOME_COSMETIC_VAR"));
2011        assert!(is_global_cmd_env_injectable("SOME_OTHER_VAR"));
2012    }
2013
2014    fn meta_with_env(pairs: &[(&str, &str)]) -> MetaMapType {
2015        let mut obj = serde_json::Map::new();
2016        for (k, v) in pairs {
2017            obj.insert(k.to_string(), serde_json::json!(v));
2018        }
2019        let mut meta = MetaMapType::new();
2020        meta.insert(META_KEY_CMD_ENV.to_string(), serde_json::Value::Object(obj));
2021        meta
2022    }
2023
2024    #[test]
2025    fn no_block_scoped_identity_resolves_to_none() {
2026        // This is the collision-fix regression case: a pane with neither
2027        // AGENTMUX_AGENT_ID nor WAVEMUX_AGENT_ID set in its OWN cmd:env is
2028        // simply not jekt-registered — no fallback to global settings, and
2029        // (reagentx P1, round 2) no fallback to this srv PROCESS's own
2030        // std::env::var either, since that's process-global, not per-pane.
2031        let empty_meta = MetaMapType::new();
2032        assert_eq!(resolve_agent_id_for_jekt(&empty_meta), None);
2033    }
2034
2035    #[test]
2036    fn resolves_block_scoped_agentmux_agent_id() {
2037        let meta = meta_with_env(&[("AGENTMUX_AGENT_ID", "agentx")]);
2038        assert_eq!(resolve_agent_id_for_jekt(&meta), Some("agentx".to_string()));
2039    }
2040
2041    #[test]
2042    fn resolves_block_scoped_legacy_wavemux_agent_id() {
2043        // The legacy alias is read from the SAME block-scoped cmd:env map,
2044        // not process env (reagentx P1, round 2) — genuinely per-pane, so
2045        // it can't reintroduce the same-host collision.
2046        let meta = meta_with_env(&[("WAVEMUX_AGENT_ID", "legacy-agent")]);
2047        assert_eq!(
2048            resolve_agent_id_for_jekt(&meta),
2049            Some("legacy-agent".to_string())
2050        );
2051    }
2052
2053    #[test]
2054    fn agentmux_agent_id_wins_over_legacy_wavemux_when_both_set_on_the_same_block() {
2055        let meta = meta_with_env(&[
2056            ("AGENTMUX_AGENT_ID", "agentx"),
2057            ("WAVEMUX_AGENT_ID", "legacy-agent"),
2058        ]);
2059        assert_eq!(resolve_agent_id_for_jekt(&meta), Some("agentx".to_string()));
2060    }
2061
2062    #[test]
2063    fn blank_value_is_treated_as_absent() {
2064        // Matches persistent/mod.rs's muxbus_agent_id_from_env: a present-but-
2065        // whitespace-only value doesn't count as a real identity.
2066        let meta = meta_with_env(&[("AGENTMUX_AGENT_ID", "   ")]);
2067        assert_eq!(resolve_agent_id_for_jekt(&meta), None);
2068    }
2069
2070    #[test]
2071    fn a_different_blocks_env_never_leaks_in() {
2072        // resolve_agent_id_for_jekt takes ONE block's own metadata — there
2073        // is no code path here that could read another block's or the
2074        // process's own env, unlike the bug this fn was rewritten to fix.
2075        // This test exists to make that structurally obvious: the fn's
2076        // only input is the map passed in.
2077        let other_blocks_meta = meta_with_env(&[("AGENTMUX_AGENT_ID", "agenty")]);
2078        let this_blocks_meta = MetaMapType::new();
2079        assert_eq!(resolve_agent_id_for_jekt(&other_blocks_meta), Some("agenty".to_string()));
2080        assert_eq!(resolve_agent_id_for_jekt(&this_blocks_meta), None);
2081    }
2082}
2083
2084/// Global `cmd:env` (settings.json) reaching an interactive shell's env.
2085///
2086/// Settings enter a `ConfigState` the way they do in production: a
2087/// settings.json parsed by `read_config_file`, then `update_settings`, which
2088/// is what `config_watcher_fs` does at startup and on every save. The env is
2089/// read off a real `CommandBuilder` after `apply_cmd_env`, the call `start()`
2090/// makes.
2091#[cfg(test)]
2092mod global_cmd_env_tests {
2093    use std::sync::Arc;
2094
2095    use portable_pty::CommandBuilder;
2096
2097    use super::super::controller::ShellController;
2098    use super::cmd_env_overrides;
2099    use crate::backend::blockcontroller::META_KEY_CMD_ENV;
2100    use crate::backend::obj::MetaMapType;
2101    use crate::backend::wconfig::{self, ConfigState};
2102
2103    /// Load `settings_json` into `config` as the settings watcher does.
2104    fn load_settings(config: &ConfigState, settings_json: &str) {
2105        let dir = tempfile::tempdir().unwrap();
2106        let path = dir.path().join(wconfig::SETTINGS_FILE);
2107        std::fs::write(&path, settings_json).unwrap();
2108        let (settings, errors): (wconfig::SettingsType, _) = wconfig::read_config_file(&path);
2109        assert!(errors.is_empty(), "test settings.json must parse: {errors:?}");
2110        config.update_settings(settings);
2111    }
2112
2113    /// A config built like `bootstrap/background.rs`'s, with `settings_json` loaded.
2114    fn live_config(settings_json: &str) -> Arc<ConfigState> {
2115        let config = Arc::new(ConfigState::with_config(wconfig::build_default_config()));
2116        load_settings(&config, settings_json);
2117        config
2118    }
2119
2120    fn controller(config: Option<Arc<ConfigState>>) -> ShellController {
2121        ShellController::new(
2122            "shell".to_string(),
2123            "tab-cmd-env".to_string(),
2124            "block-cmd-env".to_string(),
2125            None,
2126            None,
2127            None,
2128            None,
2129            String::new(),
2130        )
2131        .with_config(config)
2132    }
2133
2134    fn block_env(pairs: &[(&str, &str)]) -> MetaMapType {
2135        let mut obj = serde_json::Map::new();
2136        for (k, v) in pairs {
2137            obj.insert(k.to_string(), serde_json::json!(v));
2138        }
2139        let mut meta = MetaMapType::new();
2140        meta.insert(META_KEY_CMD_ENV.to_string(), serde_json::Value::Object(obj));
2141        meta
2142    }
2143
2144    /// The env a spawn from `ctrl` gets for `block_meta`. Starts from an empty
2145    /// env so the test process's own variables can't leak into assertions.
2146    fn spawn_env(ctrl: &ShellController, block_meta: &MetaMapType) -> CommandBuilder {
2147        let mut c = CommandBuilder::new("shell-under-test");
2148        c.env_clear();
2149        ctrl.apply_cmd_env(&mut c, block_meta);
2150        c
2151    }
2152
2153    fn var(c: &CommandBuilder, key: &str) -> Option<String> {
2154        c.get_env(key).map(|v| v.to_string_lossy().into_owned())
2155    }
2156
2157    #[test]
2158    fn a_command_pane_gets_its_cmd_env_direct_or_through_the_shell() {
2159        // A login pane: the CLI run directly, with the account's config dir.
2160        let ctrl = controller(None);
2161        let meta = block_env(&[("CLAUDE_CONFIG_DIR", "/accounts/a")]);
2162        let args = vec!["auth".to_string(), "login".to_string()];
2163        let direct = ctrl.command_process("claude", &args, true, &meta).unwrap();
2164        assert_eq!(var(&direct, "CLAUDE_CONFIG_DIR").as_deref(), Some("/accounts/a"));
2165        let wrapped = ctrl.command_process("claude auth login", &[], false, &meta).unwrap();
2166        assert_eq!(var(&wrapped, "CLAUDE_CONFIG_DIR").as_deref(), Some("/accounts/a"));
2167    }
2168
2169    /// On Windows a direct-spawn pane runs what an npm `.cmd` shim points
2170    /// at, not the shim (which hangs under ConPTY), with the pane's args after.
2171    #[cfg(windows)]
2172    #[test]
2173    fn a_command_pane_runs_what_an_npm_shim_points_at() {
2174        let dir = tempfile::tempdir().unwrap();
2175        let bin = dir.path().join("node_modules").join(".bin");
2176        std::fs::create_dir_all(&bin).unwrap();
2177        let shim = bin.join("openclaw.cmd");
2178        std::fs::write(
2179            &shim,
2180            concat!(
2181                "@ECHO off
2182GOTO start
2183:find_dp0
2184SET dp0=%~dp0
2185EXIT /b
2186:start
2187SETLOCAL
2188",
2189                r#""%_prog%"  "%dp0%\..\openclaw\cli.js" %*"#,
2190                "
2191"
2192            ),
2193        )
2194        .unwrap();
2195        let args = vec!["login".to_string()];
2196        let c = controller(None).command_process(&shim.to_string_lossy(), &args, true, &MetaMapType::new()).unwrap();
2197        let argv: Vec<String> = c.get_argv().iter().map(|a| a.to_string_lossy().into_owned()).collect();
2198        assert_eq!(argv[0], "node");
2199        assert!(argv[1].ends_with("cli.js"), "the shim's script, got {argv:?}");
2200        assert_eq!(argv[2], "login");
2201    }
2202
2203    /// A wrapper that can't be read is refused rather than started into a
2204    /// pane that would hang.
2205    #[cfg(windows)]
2206    #[test]
2207    fn a_command_pane_refuses_a_wrapper_it_cannot_read() {
2208        let dir = tempfile::tempdir().unwrap();
2209        let shim = dir.path().join("odd.cmd");
2210        std::fs::write(&shim, "@echo off\r\nsomething-else %*\r\n").unwrap();
2211        let err = controller(None)
2212            .command_process(&shim.to_string_lossy(), &[], true, &MetaMapType::new())
2213            .err()
2214            .expect("an unreadable wrapper is refused");
2215        assert!(err.contains("can't run"), "{err}");
2216    }
2217
2218    #[test]
2219    fn defaults_carry_no_cmd_env() {
2220        // Precondition for every test below: a value they observe can only
2221        // have come from the loaded settings, never from the defaults.
2222        assert!(wconfig::build_default_config().settings.cmd_env.is_empty());
2223    }
2224
2225    #[test]
2226    fn global_cmd_env_from_the_live_config_is_applied() {
2227        let ctrl = controller(Some(live_config(
2228            r#"{ "cmd:env": { "AMUX_TEST_GLOBAL": "from-settings" } }"#,
2229        )));
2230        let env = spawn_env(&ctrl, &MetaMapType::new());
2231        assert_eq!(var(&env, "AMUX_TEST_GLOBAL").as_deref(), Some("from-settings"));
2232    }
2233
2234    #[test]
2235    fn block_cmd_env_overrides_the_global_value() {
2236        let ctrl = controller(Some(live_config(
2237            r#"{ "cmd:env": { "AMUX_TEST_SHARED": "global", "AMUX_TEST_GLOBAL_ONLY": "g" } }"#,
2238        )));
2239        let env = spawn_env(&ctrl, &block_env(&[("AMUX_TEST_SHARED", "block")]));
2240        assert_eq!(var(&env, "AMUX_TEST_SHARED").as_deref(), Some("block"));
2241        assert_eq!(
2242            var(&env, "AMUX_TEST_GLOBAL_ONLY").as_deref(),
2243            Some("g"),
2244            "a global key the block doesn't set still applies"
2245        );
2246    }
2247
2248    /// Windows env names are case-insensitive, and `CommandBuilder` matches
2249    /// them that way there. The block must still win when its key differs
2250    /// from the global one only in case.
2251    #[cfg(windows)]
2252    #[test]
2253    fn block_cmd_env_overrides_a_global_key_differing_only_in_case() {
2254        let ctrl = controller(Some(live_config(
2255            r#"{ "cmd:env": { "Amux_Test_Case": "global" } }"#,
2256        )));
2257        let env = spawn_env(&ctrl, &block_env(&[("AMUX_TEST_CASE", "block")]));
2258        assert_eq!(var(&env, "AMUX_TEST_CASE").as_deref(), Some("block"));
2259    }
2260
2261    #[test]
2262    fn global_agent_identity_keys_are_still_not_injected() {
2263        let ctrl = controller(Some(live_config(
2264            r#"{ "cmd:env": {
2265                "AGENTMUX_AGENT_ID": "shared-id",
2266                "WAVEMUX_AGENT_ID": "shared-legacy-id",
2267                "AMUX_TEST_NEIGHBOUR": "applied"
2268            } }"#,
2269        )));
2270
2271        // Excluded at injection (#2694), not merely removed by the strip.
2272        let overrides = cmd_env_overrides(&ctrl.live_settings(), &MetaMapType::new());
2273        let keys: Vec<&str> = overrides.vars.iter().map(|(k, _)| k.as_str()).collect();
2274        assert_eq!(keys, vec!["AMUX_TEST_NEIGHBOUR"]);
2275
2276        let plain = spawn_env(&ctrl, &MetaMapType::new());
2277        assert_eq!(var(&plain, "AMUX_TEST_NEIGHBOUR").as_deref(), Some("applied"));
2278        assert_eq!(var(&plain, "AGENTMUX_AGENT_ID"), None);
2279        assert_eq!(var(&plain, "WAVEMUX_AGENT_ID"), None);
2280
2281        // A block with its own identity skips the strip, so only the global
2282        // exclusion keeps the shared legacy id out here.
2283        let agent = spawn_env(&ctrl, &block_env(&[("AGENTMUX_AGENT_ID", "block-id")]));
2284        assert_eq!(var(&agent, "AGENTMUX_AGENT_ID").as_deref(), Some("block-id"));
2285        assert_eq!(var(&agent, "WAVEMUX_AGENT_ID"), None);
2286    }
2287
2288    #[test]
2289    fn a_settings_change_reaches_the_next_spawn_of_the_same_controller() {
2290        let config = live_config(r#"{ "cmd:env": { "AMUX_TEST_LIVE": "v1" } }"#);
2291        let ctrl = controller(Some(Arc::clone(&config)));
2292        assert_eq!(var(&spawn_env(&ctrl, &MetaMapType::new()), "AMUX_TEST_LIVE").as_deref(), Some("v1"));
2293
2294        // A later settings.json save, reloaded as the watcher does.
2295        load_settings(&config, r#"{ "cmd:env": { "AMUX_TEST_LIVE": "v2" } }"#);
2296        assert_eq!(var(&spawn_env(&ctrl, &MetaMapType::new()), "AMUX_TEST_LIVE").as_deref(), Some("v2"));
2297
2298        // A Settings-pane save: the `setconfig` handler merges into memory.
2299        let mut keys = serde_json::Map::new();
2300        keys.insert("cmd:env".to_string(), serde_json::json!({ "AMUX_TEST_LIVE": "v3" }));
2301        let merged = crate::backend::config_watcher_fs::merge_settings_into_current(&config, keys);
2302        config.update_settings(merged);
2303        assert_eq!(var(&spawn_env(&ctrl, &MetaMapType::new()), "AMUX_TEST_LIVE").as_deref(), Some("v3"));
2304
2305        // Dropping the key from settings.json drops it from the next spawn.
2306        load_settings(&config, "{}");
2307        assert_eq!(var(&spawn_env(&ctrl, &MetaMapType::new()), "AMUX_TEST_LIVE"), None);
2308    }
2309
2310    #[test]
2311    fn no_attached_config_applies_only_the_block_env() {
2312        let ctrl = controller(None);
2313        let env = spawn_env(&ctrl, &block_env(&[("AMUX_TEST_BLOCK", "b")]));
2314        assert_eq!(var(&env, "AMUX_TEST_BLOCK").as_deref(), Some("b"));
2315    }
2316
2317    /// The production entry point: `resync_controller` must hand the config
2318    /// it is given to the shell controller it creates.
2319    #[test]
2320    fn resync_controller_gives_a_new_shell_controller_the_live_config() {
2321        use crate::backend::blockcontroller as bc;
2322
2323        let block_id = "cmd-env-resync-wiring";
2324        let config = live_config(r#"{ "cmd:env": { "AMUX_TEST_WIRED": "yes" } }"#);
2325        let mut meta = MetaMapType::new();
2326        meta.insert(bc::META_KEY_CONTROLLER.to_string(), serde_json::json!(bc::BLOCK_CONTROLLER_SHELL));
2327        // Register the controller without spawning a real shell.
2328        meta.insert(bc::META_KEY_CMD_RUN_ON_START.to_string(), serde_json::json!(false));
2329        let block = crate::backend::obj::Block {
2330            oid: block_id.to_string(),
2331            meta,
2332            ..Default::default()
2333        };
2334
2335        bc::resync_controller(
2336            &block, "tab-cmd-env", None, false, true,
2337            None, None, None, None, None, None, None,
2338            Arc::from("test-boot"), "test-key",
2339            Some(Arc::clone(&config)),
2340        )
2341        .expect("resync");
2342        let registered = bc::get_controller(block_id).expect("controller registered");
2343        let shell = registered
2344            .as_any()
2345            .downcast_ref::<ShellController>()
2346            .expect("a shell controller");
2347        let seen = var(&spawn_env(shell, &MetaMapType::new()), "AMUX_TEST_WIRED");
2348        bc::delete_controller(block_id);
2349
2350        assert_eq!(seen.as_deref(), Some("yes"));
2351    }
2352}
2353
2354/// Ask the output flusher to confirm that everything enqueued so far has been
2355/// committed, bounded by `timeout`.
2356///
2357/// This is the ordering guarantee the post-exit wait actually needs: clients
2358/// must not observe `STATUS_DONE` before the output that preceded it is
2359/// readable. `PtyShellStatus` → `PtyShellRead` is the sharpest case — an agent
2360/// polls until `running: false` and then reads the file, so a `STATUS_DONE`
2361/// that outran the final flush hands it truncated command output (Codex P1 on
2362/// PR #3261).
2363///
2364/// Waiting on the flusher TASK was the previous way to get that, and it is a
2365/// much blunter instrument: the task only ends when the channel closes, which
2366/// on Windows never happens (ConPTY's console host outlives the child, so the
2367/// read loop stays blocked in a read that never returns and never drops its
2368/// sender). Every ordinary exit therefore paid the full
2369/// `FLUSHER_DRAIN_TIMEOUT` while the flusher sat idle — a visible second of
2370/// dead UI, and the reason `exit` in a shell felt stuck.
2371///
2372/// `Err(())` means the barrier could not be confirmed within `timeout` (or the
2373/// flusher is already gone). Callers fall back to the old bounded wait.
2374async fn flush_barrier(
2375    tx: &mpsc::Sender<PtyReadChunk>,
2376    timeout: std::time::Duration,
2377) -> Result<(), ()> {
2378    let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
2379    // The SEND is inside the timeout too, not just the ack wait (ReAgent P1 on
2380    // PR #3261). `tx` is a bounded channel: if it is full and the flusher is
2381    // stalled — a wedged FileStore write, a jammed broker — an unbounded
2382    // `send().await` blocks the whole wait/cleanup task forever, and with it
2383    // STATUS_DONE, the jekt lease release, and the close-on-exit trigger. The
2384    // entire point of this path is to be bounded; a bound with an unbounded
2385    // step inside it is not one.
2386    let deadline = tokio::time::Instant::now() + timeout;
2387    let send_fut = tx.send(PtyReadChunk {
2388        data: Vec::new(),
2389        osc_events: Vec::new(),
2390        ack: Some(ack_tx),
2391    });
2392    match tokio::time::timeout_at(deadline, send_fut).await {
2393        // Receiver gone — the flusher has already exited, which means the
2394        // channel closed, which means everything in it was drained.
2395        Ok(Err(_)) => return Ok(()),
2396        Ok(Ok(())) => {}
2397        Err(_) => return Err(()),
2398    }
2399    match tokio::time::timeout_at(deadline, ack_rx).await {
2400        Ok(Ok(())) => Ok(()),
2401        _ => Err(()),
2402    }
2403}
2404
2405/// Release `block_id`'s PtyShell agent lease — both copies — but ONLY if
2406/// `inner` still identifies the controller currently registered for that
2407/// block. Returns whether it released.
2408///
2409/// The guard is the same `Arc::ptr_eq` controller-identity check the
2410/// close-on-exit trigger uses, for the same reason (Codex P1 / ReAgent P1 on
2411/// PR #3249): a force-restart (`resync_controller` → `stop_for_replace`)
2412/// kills this child and registers a REPLACEMENT controller under the same
2413/// block_id. The killed child's wait/cleanup task belongs to the OLD
2414/// controller; without this check it fires afterwards and clears a lease the
2415/// agent legitimately took on the NEW shell, letting human input race the
2416/// agent's writes on a pane that never stopped being driven. Leases are keyed
2417/// by block_id alone, so a stale task cannot tell its own lease from its
2418/// successor's — controller identity is what distinguishes them.
2419///
2420/// Check-then-act, like its precedent: a replacement registered in the window
2421/// between the check and the remove would still lose its lease. Same residual
2422/// race the close-on-exit guard accepts, and self-correcting here, since
2423/// `lock_shell_for_agent` re-takes the lease before every agent write.
2424pub(super) fn release_lease_if_current(
2425    block_id: &str,
2426    inner: &Arc<std::sync::Mutex<super::controller::ShellControllerInner>>,
2427    mstore: &Option<Arc<crate::backend::storage::store::Store>>,
2428    event_bus: &Option<Arc<crate::backend::eventbus::EventBus>>,
2429) -> bool {
2430    let is_current = super::super::get_controller(block_id)
2431        .and_then(|ctrl| {
2432            ctrl.as_any()
2433                .downcast_ref::<ShellController>()
2434                .map(|sc| Arc::ptr_eq(&sc.inner, inner))
2435        })
2436        .unwrap_or(false);
2437    if !is_current {
2438        tracing::info!(
2439            block_id = %block_id,
2440            "agent lease: controller was replaced since this process exited \
2441             (restart in flight?) — leaving the successor's lease alone"
2442        );
2443        return false;
2444    }
2445    super::super::agent_lock::release_and_clear_meta(block_id, mstore, event_bus);
2446    true
2447}
2448
2449#[cfg(test)]
2450mod flush_barrier_tests {
2451    use super::{flush_barrier, run_pty_output_flusher, PtyReadChunk, PTY_CHANNEL_CAPACITY};
2452
2453    /// The guarantee Codex's P1 on PR #3261 is about: the barrier must not
2454    /// resolve until output enqueued BEFORE it has actually been flushed.
2455    /// `PtyShellStatus` -> `PtyShellRead` depends on this — an agent polls for
2456    /// `running: false` and then reads the file, so a `STATUS_DONE` that
2457    /// outran the final flush hands it truncated output.
2458    ///
2459    /// Uses the real flusher with no broker, which makes it return
2460    /// immediately and close the channel — so this asserts the OTHER half:
2461    /// that a gone flusher resolves rather than hanging (see below). The
2462    /// ordering itself is asserted by
2463    /// `barrier_does_not_resolve_until_prior_output_is_committed`.
2464    #[tokio::test]
2465    async fn a_gone_flusher_resolves_the_barrier_instead_of_hanging() {
2466        let (tx, rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(PTY_CHANNEL_CAPACITY);
2467        // No broker → the flusher returns at once, dropping the receiver.
2468        let flusher = tokio::spawn(run_pty_output_flusher(rx, None, "blk".into(), None, false));
2469        flusher.await.expect("flusher task");
2470
2471        let res = flush_barrier(&tx, std::time::Duration::from_secs(5)).await;
2472        assert_eq!(res, Ok(()), "a closed channel means nothing is left unflushed");
2473    }
2474
2475    /// THE load-bearing property: the ack must not fire until the output
2476    /// enqueued before it is actually COMMITTED — written to the FileStore,
2477    /// not merely dequeued.
2478    ///
2479    /// Runs the real flusher against a real in-memory FileStore and reads the
2480    /// file at the moment the barrier resolves. An earlier version of this
2481    /// test drained the channel by hand instead, which only proved the
2482    /// channel is FIFO — it passed unchanged when the ack was moved to BEFORE
2483    /// the flush, i.e. it could not see the bug it existed to catch.
2484    #[tokio::test]
2485    async fn barrier_does_not_resolve_until_prior_output_is_committed() {
2486        use super::super::super::super::storage::filestore::FileStore;
2487        use std::sync::Arc;
2488
2489        let (broker, _events) = super::pty_output_flusher_tests::broker_recording_block_file_events();
2490        let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2491        let (tx, rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(PTY_CHANNEL_CAPACITY);
2492
2493        tx.send(PtyReadChunk { data: b"committed output".to_vec(), osc_events: Vec::new(), ack: None })
2494            .await
2495            .expect("send data");
2496
2497        let flusher = tokio::spawn(run_pty_output_flusher(
2498            rx,
2499            Some(Arc::new(broker)),
2500            "block-1".to_string(),
2501            Some(fs.clone()),
2502            false,
2503        ));
2504
2505        flush_barrier(&tx, std::time::Duration::from_secs(5))
2506            .await
2507            .expect("barrier must resolve");
2508
2509        // The barrier has resolved — the output it was queued behind must be
2510        // readable RIGHT NOW, with no further waiting.
2511        let data = fs
2512            .read_file("block-1", "term")
2513            .expect("read_file ok")
2514            .expect("output must be committed by the time the barrier resolves");
2515        assert_eq!(
2516            data,
2517            b"committed output".to_vec(),
2518            "barrier resolved before the preceding output reached the FileStore"
2519        );
2520
2521        drop(tx);
2522        let _ = flusher.await;
2523    }
2524
2525    /// The property that makes the "prefer true EOF" fast path reachable at
2526    /// all, and the exact thing ReAgent's round-3 P1 found broken: the wait
2527    /// task's barrier handle must NOT keep the channel open.
2528    ///
2529    /// An mpsc channel closes only once EVERY `Sender` is dropped. A strong
2530    /// clone held across the EOF wait means `recv()` never yields `None`, the
2531    /// flusher never ends, `flusher_handle` never resolves — and the fast path
2532    /// is dead code on every platform, silently applying the fallback's
2533    /// residual race everywhere.
2534    ///
2535    /// The production site is additionally type-annotated as `WeakSender`, so
2536    /// swapping it back to `clone()` is a compile error rather than a silent
2537    /// behaviour change.
2538    #[tokio::test]
2539    async fn a_weak_barrier_handle_does_not_keep_the_flusher_alive() {
2540        use super::super::super::super::storage::filestore::FileStore;
2541        use std::sync::Arc;
2542
2543        let (broker, _events) = super::pty_output_flusher_tests::broker_recording_block_file_events();
2544        let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2545        let (tx, rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(PTY_CHANNEL_CAPACITY);
2546
2547        let weak: tokio::sync::mpsc::WeakSender<PtyReadChunk> = tx.downgrade();
2548        let flusher = tokio::spawn(run_pty_output_flusher(
2549            rx,
2550            Some(Arc::new(broker)),
2551            "block-1".to_string(),
2552            Some(fs),
2553            false,
2554        ));
2555
2556        // The read loop's sender going away is the ONLY strong sender.
2557        drop(tx);
2558
2559        tokio::time::timeout(std::time::Duration::from_secs(5), flusher)
2560            .await
2561            .expect("the flusher must end once the last STRONG sender drops — a weak handle must not hold it open")
2562            .expect("flusher task");
2563
2564        assert!(
2565            weak.upgrade().is_none(),
2566            "a closed channel must not be upgradeable — the barrier reads this as 'already drained'"
2567        );
2568    }
2569
2570    /// ReAgent P1 on PR #3261: the SEND has to be inside the timeout too.
2571    /// `tx` is bounded, so a full channel plus a stalled flusher makes an
2572    /// unbounded `send().await` block the whole wait/cleanup task forever —
2573    /// taking STATUS_DONE, the lease release and close-on-exit with it. A
2574    /// bound with an unbounded step inside it is not a bound.
2575    ///
2576    /// Fills the channel to capacity with a receiver that never reads, so the
2577    /// send genuinely cannot complete.
2578    #[tokio::test]
2579    async fn a_full_channel_expires_instead_of_blocking_forever() {
2580        let (tx, _rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(1);
2581        tx.send(PtyReadChunk { data: b"wedged".to_vec(), osc_events: Vec::new(), ack: None })
2582            .await
2583            .expect("first send fills the channel");
2584
2585        let started = std::time::Instant::now();
2586        let res = flush_barrier(&tx, std::time::Duration::from_millis(80)).await;
2587        assert_eq!(res, Err(()), "a send that cannot complete must expire");
2588        assert!(
2589            started.elapsed() < std::time::Duration::from_secs(5),
2590            "must expire on the timeout, not block: took {:?}",
2591            started.elapsed()
2592        );
2593    }
2594
2595    /// The bounded-wait contract: if nothing ever acks (a wedged flusher), the
2596    /// barrier gives up rather than hanging the exit path forever. This is the
2597    /// case the old code always hit, and it is now the fallback rather than
2598    /// the norm.
2599    #[tokio::test]
2600    async fn an_unanswered_barrier_times_out() {
2601        let (tx, _rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(PTY_CHANNEL_CAPACITY);
2602        // `_rx` is held open but never read, so the ack can never fire.
2603        let res = flush_barrier(&tx, std::time::Duration::from_millis(50)).await;
2604        assert_eq!(res, Err(()), "an unanswered barrier must expire, not block forever");
2605    }
2606}
2607
2608#[cfg(test)]
2609mod agent_lease_release_tests {
2610    use super::*;
2611    use crate::backend::blockcontroller::agent_lock;
2612
2613    fn controller_for(block_id: &str) -> Arc<ShellController> {
2614        Arc::new(ShellController::new(
2615            "shell".to_string(),
2616            "tab-lease".to_string(),
2617            block_id.to_string(),
2618            None,
2619            None,
2620            None,
2621            None,
2622            String::new(),
2623        ))
2624    }
2625
2626    /// The natural case: this task's own controller is still the registered
2627    /// one (a human typed `exit`), so the lease goes.
2628    #[test]
2629    fn releases_when_this_task_still_owns_the_controller() {
2630        let block_id = "lease-guard-current";
2631        let ctrl = controller_for(block_id);
2632        let inner = Arc::clone(&ctrl.inner);
2633        super::super::super::register_controller(block_id, ctrl);
2634
2635        agent_lock::lock_until(block_id, agentmux_common::time::now_ms() + 60_000);
2636        assert!(release_lease_if_current(block_id, &inner, &None, &None));
2637        assert!(!agent_lock::is_locked(block_id), "an exited shell must drop its lease");
2638
2639        super::super::super::delete_controller(block_id);
2640        agent_lock::release(block_id);
2641    }
2642
2643    /// Force-restart: a REPLACEMENT controller is registered under the same
2644    /// block_id and the agent takes a fresh lease on it. This stale task —
2645    /// from the killed old process — must not clear that live lease, or human
2646    /// keystrokes race the agent's writes on a shell still being driven.
2647    #[test]
2648    fn leaves_a_successors_lease_alone_after_a_force_restart() {
2649        let block_id = "lease-guard-replaced";
2650        let old = controller_for(block_id);
2651        let old_inner = Arc::clone(&old.inner);
2652        let replacement = controller_for(block_id);
2653
2654        // The replacement is what's registered now — exactly what
2655        // `stop_for_replace` + re-register leaves behind.
2656        super::super::super::register_controller(block_id, replacement);
2657        agent_lock::lock_until(block_id, agentmux_common::time::now_ms() + 60_000);
2658
2659        assert!(
2660            !release_lease_if_current(block_id, &old_inner, &None, &None),
2661            "a stale task must not release"
2662        );
2663        assert!(
2664            agent_lock::is_locked(block_id),
2665            "the successor's lease must survive the old controller's cleanup"
2666        );
2667
2668        super::super::super::delete_controller(block_id);
2669        agent_lock::release(block_id);
2670    }
2671
2672    /// No controller registered at all (block already torn down) — nothing to
2673    /// own the lease, so this task must not assume the lease is its own.
2674    #[test]
2675    fn does_not_release_when_no_controller_is_registered() {
2676        let block_id = "lease-guard-unregistered";
2677        let orphan = controller_for(block_id);
2678        let inner = Arc::clone(&orphan.inner);
2679
2680        agent_lock::lock_until(block_id, agentmux_common::time::now_ms() + 60_000);
2681        assert!(!release_lease_if_current(block_id, &inner, &None, &None));
2682        assert!(agent_lock::is_locked(block_id));
2683
2684        agent_lock::release(block_id);
2685    }
2686}
2687
2688#[cfg(test)]
2689mod controller_agent_id_tests {
2690    use super::super::controller::ShellController;
2691    use super::super::super::Controller;
2692
2693    fn controller() -> ShellController {
2694        ShellController::new(
2695            "shell".to_string(),
2696            "tab1".to_string(),
2697            "block1".to_string(),
2698            None,
2699            None,
2700            None,
2701            None,
2702            String::new(),
2703        )
2704    }
2705
2706    /// reagentx P1 on #2697: `agent_id()` was captured once at spawn and
2707    /// never refreshed on re-registration, so a legitimately renamed or
2708    /// reconfigured agent's own messages could be falsely rejected as an
2709    /// identity mismatch by `inject_message_inner`'s recipient-identity
2710    /// check (#2695). `set_agent_id` (called from `handle_reactive_register`
2711    /// on every re-registration) must actually overwrite the captured value,
2712    /// not just the initial spawn-time one.
2713    #[test]
2714    fn set_agent_id_overwrites_a_stale_captured_value() {
2715        let ctrl = controller();
2716        assert_eq!(ctrl.agent_id(), None);
2717
2718        ctrl.set_agent_id(Some("agentx".to_string()));
2719        assert_eq!(ctrl.agent_id(), Some("agentx".to_string()));
2720
2721        // A later re-registration under a different name (rename,
2722        // reconfigured cmd:env) must be reflected, not stuck at "agentx".
2723        ctrl.set_agent_id(Some("agenty".to_string()));
2724        assert_eq!(ctrl.agent_id(), Some("agenty".to_string()));
2725
2726        ctrl.set_agent_id(None);
2727        assert_eq!(ctrl.agent_id(), None);
2728    }
2729}
2730
2731#[cfg(test)]
2732pub(super) mod pty_output_flusher_tests {
2733    use super::{flush_pty_batch, run_pty_output_flusher, PtyReadChunk, PTY_CHANNEL_CAPACITY};
2734    use crate::backend::storage::filestore::FileStore;
2735    use crate::backend::mps;
2736    use std::sync::{Arc, Mutex};
2737
2738    /// Records every event delivered to it — lets a test count broadcasts
2739    /// rather than only inspect final file content, which is what actually
2740    /// distinguishes "coalesced into one broadcast" from "one broadcast per
2741    /// PTY read" (both produce identical final content; only the broadcast
2742    /// COUNT tells them apart). Holds an `Arc` (not an owned `Vec`) because
2743    /// `Broker::set_client` takes ownership of the client, so the test needs
2744    /// its own handle to read events back out afterward.
2745    struct RecordingClient {
2746        events: Arc<Mutex<Vec<mps::MuxEvent>>>,
2747    }
2748
2749    impl mps::WpsClient for RecordingClient {
2750        fn send_event(&self, _route_id: &str, event: mps::MuxEvent) {
2751            self.events.lock().unwrap().push(event);
2752        }
2753    }
2754
2755    /// A broker wired to a `RecordingClient`, subscribed (all-scopes, so
2756    /// this doesn't need to know the exact `block:<id>` scope string) to
2757    /// `EVENT_BLOCK_FILE` — the event `handle_append_block_file` publishes.
2758    pub(super) fn broker_recording_block_file_events() -> (mps::Broker, Arc<Mutex<Vec<mps::MuxEvent>>>) {
2759        let broker = mps::Broker::new();
2760        let events = Arc::new(Mutex::new(Vec::new()));
2761        broker.set_client(Box::new(RecordingClient {
2762            events: events.clone(),
2763        }));
2764        broker.subscribe(
2765            "test-route",
2766            mps::SubscriptionRequest {
2767                event: mps::EVENT_BLOCK_FILE.to_string(),
2768                scopes: vec![],
2769                allscopes: true,
2770            },
2771        );
2772        (broker, events)
2773    }
2774
2775    /// Decode the base64 `data64` payload of a `term`-file `EVENT_BLOCK_FILE`
2776    /// broadcast back to raw bytes, for asserting on content.
2777    fn decode_event_data(event: &mps::MuxEvent) -> Vec<u8> {
2778        use base64::Engine;
2779        let data: mps::WSFileEventData =
2780            serde_json::from_value(event.data.clone().expect("event has data")).expect("valid WSFileEventData");
2781        base64::engine::general_purpose::STANDARD
2782            .decode(&data.data64)
2783            .expect("valid base64")
2784    }
2785
2786    #[test]
2787    fn flush_pty_batch_writes_and_broadcasts_one_call() {
2788        let (broker, events) = broker_recording_block_file_events();
2789        let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2790        let mut line_buf = Vec::new();
2791
2792        flush_pty_batch(&broker, "block-1", b"hello world", &[], Some(&fs), &mut line_buf, None);
2793
2794        let data = fs
2795            .read_file("block-1", "term")
2796            .expect("read_file ok")
2797            .expect("data present");
2798        assert_eq!(data, b"hello world");
2799
2800        let evs = events.lock().unwrap();
2801        assert_eq!(evs.len(), 1, "one flush call must produce exactly one broadcast");
2802        assert_eq!(decode_event_data(&evs[0]), b"hello world");
2803    }
2804
2805    #[test]
2806    fn flush_pty_batch_is_a_no_op_for_empty_input() {
2807        // Guards against a spurious empty broadcast on a batch that ended
2808        // up with nothing in it (shouldn't happen given how the flusher
2809        // calls this, but a no-op guard here is cheap and cheaper than
2810        // debugging a phantom empty append if that assumption ever breaks).
2811        let (broker, events) = broker_recording_block_file_events();
2812        let mut line_buf = Vec::new();
2813        flush_pty_batch(&broker, "block-1", b"", &[], None, &mut line_buf, None);
2814        assert!(events.lock().unwrap().is_empty());
2815    }
2816
2817    /// The actual property this whole change exists to deliver: N chunks
2818    /// that arrive back-to-back (well inside the coalescing window and the
2819    /// byte cap) collapse into far fewer broadcasts than N — ideally one —
2820    /// with no data lost, duplicated, or reordered.
2821    ///
2822    /// Deterministic, not timing-dependent: every chunk is sent to the
2823    /// channel BEFORE the flusher is ever polled, so its first "opportunistic
2824    /// drain" pass finds them all already queued and drains them without
2825    /// actually waiting out any real wall-clock delay — it's exercising the
2826    /// "channel closed mid-accumulation" exit path (see
2827    /// `run_pty_output_flusher`'s `closed_mid_batch`). The drain never waits
2828    /// on unarrived output at all now, so there is no window to race.
2829    #[tokio::test]
2830    async fn rapid_chunks_coalesce_into_far_fewer_broadcasts_with_no_data_loss() {
2831        let (broker, events) = broker_recording_block_file_events();
2832        let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2833        // Bounded, matching production (PTY_CHANNEL_CAPACITY) — comfortably
2834        // above the 50 messages below, so every send below still completes
2835        // without actually waiting for capacity; the determinism this test's
2836        // own doc comment relies on is unaffected by the channel being bounded.
2837        let (tx, rx) = tokio::sync::mpsc::channel(PTY_CHANNEL_CAPACITY);
2838
2839        let mut expected = Vec::new();
2840        for i in 0..50 {
2841            let piece = format!("line {i}\n");
2842            expected.extend_from_slice(piece.as_bytes());
2843            tx.send(PtyReadChunk {
2844                ack: None,
2845                data: piece.into_bytes(),
2846                osc_events: Vec::new(),
2847            })
2848            .await
2849            .unwrap();
2850        }
2851        drop(tx); // closes the channel once these 50 are drained
2852
2853        run_pty_output_flusher(rx, Some(Arc::new(broker)), "block-1".to_string(), Some(fs.clone()), false)
2854            .await;
2855
2856        let data = fs.read_file("block-1", "term").expect("read_file ok").expect("data present");
2857        assert_eq!(data, expected, "coalescing must not lose, duplicate, or reorder bytes");
2858
2859        // Deterministic exact count, not a loose bound: every chunk was
2860        // already queued before the flusher was ever polled (see the test's
2861        // own doc comment), so its very first opportunistic-drain pass finds
2862        // all 50 sitting in the channel and drains them without actually
2863        // waiting out any real time — one batch, one broadcast. Verified
2864        // stable across repeated local runs before asserting on it exactly;
2865        // a looser "< 50" bound would also pass on a much weaker result
2866        // (say, 40 broadcasts) that wouldn't actually demonstrate the fix.
2867        let broadcast_count = events.lock().unwrap().len();
2868        assert_eq!(
2869            broadcast_count, 1,
2870            "50 chunks queued before the flusher ever polled should collapse into \
2871             exactly one broadcast; got {broadcast_count}"
2872        );
2873        // Concatenating every broadcast's own payload, in delivery order,
2874        // must also reconstruct the exact original stream — proves the
2875        // coalescing boundaries themselves never split/reordered content
2876        // even if more than one broadcast happened.
2877        let mut reconstructed = Vec::new();
2878        for ev in events.lock().unwrap().iter() {
2879            reconstructed.extend(decode_event_data(ev));
2880        }
2881        assert_eq!(reconstructed, expected);
2882    }
2883
2884    /// The byte cap must still force a flush even if chunks keep arriving
2885    /// faster than the coalescing window would otherwise close — otherwise
2886    /// a sustained fast producer (`yes` piped into a shell pane) could grow
2887    /// one "batch" unboundedly.
2888    #[tokio::test]
2889    async fn byte_cap_forces_a_flush_before_the_channel_closes() {
2890        let (broker, events) = broker_recording_block_file_events();
2891        let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2892        // Bounded, matching production — 80 messages below is comfortably
2893        // under PTY_CHANNEL_CAPACITY, so this test still exercises the byte
2894        // cap (not channel backpressure) as the thing forcing the split.
2895        let (tx, rx) = tokio::sync::mpsc::channel(PTY_CHANNEL_CAPACITY);
2896
2897        // Comfortably over PTY_COALESCE_MAX_BYTES (64 * 4096 = 256KiB) in
2898        // total, sent as many small chunks so the cap — not the channel
2899        // closing — is what has to trigger the split.
2900        let piece = vec![b'x'; 4096];
2901        let mut expected = Vec::new();
2902        for _ in 0..80 {
2903            expected.extend_from_slice(&piece);
2904            tx.send(PtyReadChunk {
2905                ack: None,
2906                data: piece.clone(),
2907                osc_events: Vec::new(),
2908            })
2909            .await
2910            .unwrap();
2911        }
2912        drop(tx);
2913
2914        run_pty_output_flusher(rx, Some(Arc::new(broker)), "block-1".to_string(), Some(fs.clone()), false)
2915            .await;
2916
2917        let data = fs.read_file("block-1", "term").expect("read_file ok").expect("data present");
2918        assert_eq!(data, expected);
2919
2920        let broadcast_count = events.lock().unwrap().len();
2921        assert!(
2922            broadcast_count >= 2,
2923            "80 * 4096 bytes exceeds PTY_COALESCE_MAX_BYTES; expected at least 2 broadcasts \
2924             (the cap forcing an early flush), got {broadcast_count}"
2925        );
2926    }
2927
2928    /// The actual property reagentx's P1 asked for: a full channel makes
2929    /// `blocking_send` — what the real PTY read loop calls, from a real
2930    /// blocking (`spawn_blocking`) thread, not an async one — block until
2931    /// the receiver drains it, rather than growing queued memory without
2932    /// limit. A tiny capacity (not `PTY_CHANNEL_CAPACITY`) so the test is
2933    /// fast and the blocking is unambiguous with only 2 messages.
2934    #[test]
2935    fn blocking_send_backpressures_once_the_channel_is_full() {
2936        let (tx, mut rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(1);
2937
2938        // Fills the one slot; must return immediately (room available).
2939        tx.blocking_send(PtyReadChunk { data: vec![1], osc_events: Vec::new(), ack: None })
2940            .expect("first send has room");
2941
2942        // A real background thread — mirrors the production shape exactly:
2943        // blocking_send is called from a spawn_blocking thread, never from
2944        // an async task.
2945        let sent_second = Arc::new(std::sync::atomic::AtomicBool::new(false));
2946        let sent_second_writer = sent_second.clone();
2947        let handle = std::thread::spawn(move || {
2948            tx.blocking_send(PtyReadChunk { data: vec![2], osc_events: Vec::new(), ack: None })
2949                .expect("second send eventually succeeds once drained");
2950            sent_second_writer.store(true, std::sync::atomic::Ordering::SeqCst);
2951        });
2952
2953        // The channel is full and nobody has drained it yet — the second
2954        // send must still be blocked. A short, generous sleep rather than
2955        // asserting instantaneously: proving a negative ("hasn't returned
2956        // yet") is inherently a bit timing-sensitive, but 50ms is enormous
2957        // next to what an unblocked send would take (microseconds), so a
2958        // false pass here would need a wildly unlikely scheduling delay.
2959        std::thread::sleep(std::time::Duration::from_millis(50));
2960        assert!(
2961            !sent_second.load(std::sync::atomic::Ordering::SeqCst),
2962            "second blocking_send must still be blocked while the channel is full"
2963        );
2964
2965        // Drain the one queued message — this is the "flusher catches up"
2966        // moment in production. The blocked send must now complete.
2967        let first = rx.try_recv().expect("first message still queued");
2968        assert_eq!(first.data, vec![1]);
2969
2970        handle.join().expect("background thread should complete once drained");
2971        assert!(sent_second.load(std::sync::atomic::Ordering::SeqCst));
2972
2973        let second = rx.try_recv().expect("second message now queued");
2974        assert_eq!(second.data, vec![2]);
2975    }
2976
2977    #[tokio::test]
2978    async fn no_broker_is_a_clean_no_op() {
2979        // Mirrors the read loop's own `broker_read.is_some()` gate — the
2980        // flusher must not panic or hang when there is nothing to flush to.
2981        let (_tx, rx) = tokio::sync::mpsc::channel::<PtyReadChunk>(PTY_CHANNEL_CAPACITY);
2982        run_pty_output_flusher(rx, None, "block-1".to_string(), None, false).await;
2983    }
2984
2985    /// Pins the exact ordering guarantee the round-3 fix (reagentx P1:
2986    /// STATUS_DONE could publish before the flusher's final batch was
2987    /// broadcast) relies on: awaiting the `JoinHandle` returned by
2988    /// `tokio::spawn(run_pty_output_flusher(...))` — exactly what the wait
2989    /// task now does, before its own cleanup/status-publish logic — only
2990    /// returns once every batch, including one forced out by the channel
2991    /// closing, has actually been broadcast. The wait task itself spawns a
2992    /// real PTY + child process and isn't practical to drive from a unit
2993    /// test; this pins the one guarantee that fix leans on, against this
2994    /// crate's own flusher function.
2995    #[tokio::test]
2996    async fn awaiting_the_flusher_join_handle_observes_every_broadcast_first() {
2997        let (broker, events) = broker_recording_block_file_events();
2998        let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2999        let (tx, rx) = tokio::sync::mpsc::channel(PTY_CHANNEL_CAPACITY);
3000
3001        let flusher_handle = tokio::spawn(run_pty_output_flusher(
3002            rx,
3003            Some(Arc::new(broker)),
3004            "block-1".to_string(),
3005            Some(fs.clone()),
3006            false,
3007        ));
3008
3009        // Sent on a delay from a separate task, standing in for the real PTY
3010        // read loop's own separate blocking thread — proves the ordering
3011        // holds even when the flusher genuinely has to wait on the channel,
3012        // not just the pre-queued-before-first-poll shortcut the coalescing
3013        // tests above deliberately use for their own determinism.
3014        let sender = tokio::spawn(async move {
3015            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3016            tx.send(PtyReadChunk {
3017                ack: None,
3018                data: b"trailing output".to_vec(),
3019                osc_events: Vec::new(),
3020            })
3021            .await
3022            .unwrap();
3023            // Dropping `tx` here closes the channel — the same signal the
3024            // real read loop sends by dropping `pty_tx` on PTY EOF — which
3025            // is what lets the flusher's final accumulation loop unstick
3026            // and return.
3027        });
3028        sender.await.expect("sender task should not panic");
3029
3030        flusher_handle.await.expect("flusher task should not panic");
3031
3032        // The property under test: by the time the join handle above has
3033        // returned, the trailing batch must already be visible — both in
3034        // FileStore and as a broadcast — with no extra yield/sleep needed to
3035        // "catch up" to it. This is the exact sequencing the wait task now
3036        // depends on before it publishes STATUS_DONE.
3037        assert_eq!(
3038            events.lock().unwrap().len(),
3039            1,
3040            "flusher_handle.await must not return until the final batch was broadcast"
3041        );
3042        let data = fs
3043            .read_file("block-1", "term")
3044            .expect("read_file ok")
3045            .expect("data present");
3046        assert_eq!(data, b"trailing output");
3047    }
3048}