Skip to main content

fno_agents/
daemon.rs

1//! The supervisor daemon (Wave 3, tasks 3.0 + 3.4).
2//!
3//! One long-running per-user process. Lazy-started by the client on first need;
4//! lazy-exits after an idle window. Six observable states (each emits an event
5//! on entry), a startup recovery procedure that must complete before the socket
6//! serves requests, and a JSON-RPC serve loop routing `agent.*` / `channel.*`.
7//!
8//! Wave 3 lands the daemon skeleton, IPC transport, worker spawn/ask routing,
9//! and the correctness-critical recovery procedure. The drive WebSocket surface
10//! is Wave 4; the full lifecycle-verb polish is Wave 5; Python integration is
11//! Wave 6. The handlers here are deliberately the minimum that makes the daemon
12//! a working supervisor end-to-end.
13
14use crate::events::EventEmitter;
15use crate::paths::{self, AgentsHome};
16use crate::protocol::{
17    read_request, write_request, write_response, ErrorCode, Namespace, Request, Response,
18};
19use crate::state::{self, RegistryEntry};
20use crate::AgentStatus;
21use base64::Engine as _;
22use serde_json::{json, Map, Value};
23use std::os::unix::process::CommandExt; // process_group on std::process::Command
24use std::path::PathBuf;
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27use tokio::net::{UnixListener, UnixStream};
28
29/// Six observable daemon states (design "Daemon lifecycle" table). Each entry
30/// emits an event so events.jsonl reflects the lifecycle for an auditor.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum DaemonState {
33    ColdStart,
34    Recovering,
35    Serving,
36    IdlePendingExit,
37    ShuttingDown,
38    Exited,
39}
40
41impl DaemonState {
42    pub fn as_str(&self) -> &'static str {
43        match self {
44            DaemonState::ColdStart => "cold_start",
45            DaemonState::Recovering => "recovering",
46            DaemonState::Serving => "serving",
47            DaemonState::IdlePendingExit => "idle_pending_exit",
48            DaemonState::ShuttingDown => "shutting_down",
49            DaemonState::Exited => "exited",
50        }
51    }
52}
53
54/// Daemon tunables. Defaults match the design (30 min idle exit).
55#[derive(Debug, Clone)]
56pub struct DaemonOptions {
57    pub idle_exit: Duration,
58    /// Path to the `fno-agents-worker` binary. Resolved from the daemon's own
59    /// executable directory by default; overridable via `FNO_AGENTS_WORKER_BIN`
60    /// (tests point this at the cargo-built binary).
61    pub worker_bin: PathBuf,
62    /// Run one bounded reconcile sweep on daemon startup before serving any
63    /// client (Architecture B, plan ab-70faa65b). Default `true`; the opt-out
64    /// (env `FNO_AGENTS_NO_STARTUP_RECONCILE=1`, Claude's discretion #5) trades a
65    /// truthful first `list` for the fastest possible cold start.
66    pub reconcile_on_start: bool,
67}
68
69impl Default for DaemonOptions {
70    fn default() -> Self {
71        DaemonOptions {
72            idle_exit: Duration::from_secs(1800),
73            worker_bin: resolve_worker_bin(),
74            reconcile_on_start: true,
75        }
76    }
77}
78
79fn resolve_worker_bin() -> PathBuf {
80    if let Some(v) = std::env::var_os("FNO_AGENTS_WORKER_BIN") {
81        return PathBuf::from(v);
82    }
83    // Side-by-side with the daemon binary.
84    std::env::current_exe()
85        .ok()
86        .and_then(|p| p.parent().map(|d| d.join("fno-agents-worker")))
87        .unwrap_or_else(|| PathBuf::from("fno-agents-worker"))
88}
89
90#[derive(Debug, thiserror::Error)]
91pub enum DaemonError {
92    #[error("io: {0}")]
93    Io(#[from] std::io::Error),
94    #[error("another daemon is already serving on {0}")]
95    AlreadyRunning(PathBuf),
96    #[error("socket permission invariant failed: {0}")]
97    Permission(String),
98    #[error("filesystem does not support advisory locking at {0}: {1}")]
99    FlockUnsupported(PathBuf, String),
100    #[error("state: {0}")]
101    State(#[from] state::StateError),
102}
103
104/// Why a registry entry could not be reconciled against its `state.json` during
105/// recovery. Typed so the report distinguishes the two cases a bare short_id
106/// string elided (ab-3aea7437), mirroring `ReconcileOutcome`'s `(name, reason)`
107/// inconsistency record. `as_str()` is the wire/event `reason` value.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum InconsistencyReason {
110    /// Registry row present, but no readable `state.json` (never spawned, or the
111    /// file was removed out from under the daemon).
112    MissingStateJson,
113    /// `state.json` present but unreadable (I/O error or partial parse).
114    UnreadableStateJson,
115}
116
117impl InconsistencyReason {
118    pub fn as_str(&self) -> &'static str {
119        match self {
120            InconsistencyReason::MissingStateJson => "missing_state_json",
121            InconsistencyReason::UnreadableStateJson => "unreadable_state_json",
122        }
123    }
124}
125
126/// What recovery did, for the `daemon_started` event and tests.
127#[derive(Debug, Default, PartialEq)]
128pub struct RecoveryReport {
129    /// `(short_id, reason)` per entry whose `state.json` could not be
130    /// reconciled. The typed reason preserves *why* (missing vs unreadable),
131    /// which a bare `Vec<String>` of short_ids discarded (ab-3aea7437).
132    pub inconsistent: Vec<(String, InconsistencyReason)>,
133    pub archived_orphans: Vec<String>,
134    pub reaped_pids: Vec<u32>,
135    pub recovered_drives: Vec<String>,
136}
137
138// ---------------------------------------------------------------------------
139// Recovery procedure (sync, standalone-testable). Design steps 1-6; step 7
140// (begin serving) is the caller's job once this returns.
141// ---------------------------------------------------------------------------
142
143/// Run the startup recovery procedure. Pure of any socket I/O so it can be
144/// unit-tested against a hand-built `~/.fno/agents/` tree. The ordering
145/// invariant (READ `drive_active` BEFORE clearing it, finding #12 Critical) is
146/// enforced by [`crate::state::PtyState::take_active_drive`], which this calls.
147pub fn recover(home: &AgentsHome, emitter: &EventEmitter) -> RecoveryReport {
148    let mut report = RecoveryReport::default();
149    let registry = state::load_registry(&home.registry_json()).unwrap_or_default();
150
151    let registered: std::collections::BTreeSet<String> = registry
152        .entries
153        .iter()
154        .map(|e| e.short_id.clone())
155        .collect();
156
157    // Steps 2-5: per registry entry, reconcile its state.json.
158    for entry in &registry.entries {
159        // A row with no short_id is a non-PTY (shellout, e.g. Python-authored
160        // `claude`) agent: it has no per-agent state dir, so there is nothing to
161        // reconcile. Skip it rather than probe `state_json("")` -- which would
162        // collide across every such row on one path and emit a spurious
163        // `agent_inconsistent` per row (Gemini medium, PR #364). The orphan-PID
164        // reap below already skips these (their `pid` is `None`).
165        if entry.short_id.is_empty() {
166            continue;
167        }
168        let state_path = home.state_json(&entry.short_id);
169        match state::load_state(&state_path) {
170            Ok(Some(mut st)) => {
171                // Step 3/4/5: stale drive window -> drive_crashed, then clear.
172                let taken = st.pty.as_mut().and_then(|p| p.take_active_drive());
173                if let Some(drive) = taken {
174                    let mut fields = Map::new();
175                    if let Some(sid) = &drive.session_id {
176                        fields.insert("session_id".into(), Value::String(sid.clone()));
177                    }
178                    fields.insert("reason".into(), Value::String("daemon_restart".into()));
179                    // Emit BEFORE persisting the cleared state (the read already
180                    // happened inside take_active_drive; persistence is step 5).
181                    let _ = emitter.emit_fields("drive_crashed", fields);
182                    let _ = state::write_state_atomic(&state_path, &st);
183                    report.recovered_drives.push(entry.short_id.clone());
184                }
185            }
186            Ok(None) => {
187                // Step 2: registry entry without a readable state.json. Mark
188                // inconsistent; do NOT fabricate a state.json on its behalf.
189                let reason = InconsistencyReason::MissingStateJson;
190                let _ = emitter.emit_fields(
191                    "agent_inconsistent",
192                    json_obj(&[
193                        ("short_id", Value::String(entry.short_id.clone())),
194                        ("reason", Value::String(reason.as_str().into())),
195                    ]),
196                );
197                report.inconsistent.push((entry.short_id.clone(), reason));
198            }
199            Err(_) => {
200                // state.json present but unreadable. Emit the same event shape as
201                // the missing case (it previously recorded nothing), so an
202                // unreadable file is observable rather than silent.
203                let reason = InconsistencyReason::UnreadableStateJson;
204                let _ = emitter.emit_fields(
205                    "agent_inconsistent",
206                    json_obj(&[
207                        ("short_id", Value::String(entry.short_id.clone())),
208                        ("reason", Value::String(reason.as_str().into())),
209                    ]),
210                );
211                report.inconsistent.push((entry.short_id.clone(), reason));
212            }
213        }
214    }
215
216    // Step 2 (other half): state.json dir without a registry entry -> archive.
217    if let Ok(read) = std::fs::read_dir(home.root()) {
218        for entry in read.flatten() {
219            if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
220                continue;
221            }
222            let name = match entry.file_name().into_string() {
223                Ok(n) if !n.starts_with('.') => n,
224                _ => continue,
225            };
226            if registered.contains(&name) {
227                continue;
228            }
229            // Orphan dir (has a state.json but no registry row): archive it.
230            if home.state_json(&name).exists() {
231                let ts = now_compact();
232                let dest = home.orphan_archive_dest(&name, &ts);
233                let _ = std::fs::create_dir_all(home.orphaned_dir());
234                if std::fs::rename(home.agent_dir(&name), &dest).is_ok() {
235                    let _ = emitter.emit_fields(
236                        "agent_orphan_state_archived",
237                        json_obj(&[
238                            ("short_id", Value::String(name.clone())),
239                            (
240                                "archived_to",
241                                Value::String(dest.to_string_lossy().into_owned()),
242                            ),
243                        ]),
244                    );
245                    report.archived_orphans.push(name);
246                }
247            }
248        }
249    }
250
251    // Step 6: orphan-PID sweep. An entry whose pid is set but is no longer OUR
252    // worker is reaped (status -> exited). A live worker socket means the worker
253    // (Outcome B) is still up; leave it. "No longer ours" = dead (ESRCH) OR a
254    // recycled pid whose start time no longer matches what we recorded
255    // (ab-d19e6458) — without the start-time check a reused pid belonging to an
256    // unrelated process would keep a dead worker looking alive.
257    let live_workers = home.scan_worker_sockets();
258    let mut to_reap: Vec<(String, u32)> = Vec::new();
259    for entry in &registry.entries {
260        if live_workers.contains(&entry.short_id) {
261            continue; // worker still alive; not an orphan
262        }
263        if let Some(pid) = entry.pid {
264            if !pid_is_ours(pid, entry.pid_start_time) {
265                to_reap.push((entry.short_id.clone(), pid));
266            }
267        }
268    }
269    if !to_reap.is_empty() {
270        let reaped: std::collections::BTreeSet<String> =
271            to_reap.iter().map(|(s, _)| s.clone()).collect();
272        // Surface a reap-write failure rather than silently diverging the
273        // event log (which says reaped) from the on-disk registry (Gemini high).
274        if let Err(e) = state::update_registry(&home.registry_json(), |r| {
275            for e in r.entries.iter_mut() {
276                if reaped.contains(&e.short_id) {
277                    e.status = AgentStatus::Exited;
278                }
279            }
280        }) {
281            let _ = emitter.emit(
282                "daemon_recovery_error",
283                &json!({"op": "reap_orphans", "error": e.to_string()}),
284            );
285        }
286        for (short_id, pid) in to_reap {
287            let _ = emitter.emit_fields(
288                "agent_orphan_reaped",
289                json_obj(&[
290                    ("short_id", Value::String(short_id)),
291                    ("pid", Value::Number(pid.into())),
292                ]),
293            );
294            report.reaped_pids.push(pid);
295        }
296    }
297
298    report
299}
300
301/// A live process's start time, used to distinguish "our worker" from a recycled
302/// PID (ab-d19e6458). `None` if the process is gone or the lookup is
303/// unsupported/failed. The value is a per-host, per-boot quantity compared only
304/// for equality against a value captured for the SAME pid, so the differing
305/// units across platforms (Linux ticks vs macOS microseconds) do not matter.
306#[cfg(target_os = "linux")]
307pub fn process_start_time(pid: u32) -> Option<u64> {
308    // /proc/<pid>/stat field 22 (1-based) is `starttime` in clock ticks since
309    // boot. The comm field (2) can contain spaces and parens, so split on the
310    // LAST ')' and index from there. After "comm)" the space-separated fields are
311    // [state, ppid, ...], with starttime the 20th (0-based index 19).
312    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
313    let after = stat.rsplit_once(')')?.1;
314    after.split_whitespace().nth(19)?.parse::<u64>().ok()
315}
316
317/// macOS: `proc_pidinfo(PROC_PIDTBSDINFO)` fills a `proc_bsdinfo` whose
318/// `pbi_start_tvsec`/`pbi_start_tvusec` is the process start time; fold to
319/// microseconds. (`kinfo_proc` is not exposed by the libc crate.)
320#[cfg(target_os = "macos")]
321pub fn process_start_time(pid: u32) -> Option<u64> {
322    use std::mem;
323    let mut info: libc::proc_bsdinfo = unsafe { mem::zeroed() };
324    let size = mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
325    // SAFETY: buffer is a zeroed proc_bsdinfo of exactly `size` bytes.
326    // proc_pidinfo returns the number of bytes written; anything other than a
327    // full struct means the process is gone / not introspectable -> None.
328    let written = unsafe {
329        libc::proc_pidinfo(
330            pid as libc::c_int,
331            libc::PROC_PIDTBSDINFO,
332            0,
333            &mut info as *mut _ as *mut libc::c_void,
334            size,
335        )
336    };
337    if written != size {
338        return None;
339    }
340    Some(info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
341}
342
343#[cfg(not(any(target_os = "linux", target_os = "macos")))]
344pub fn process_start_time(_pid: u32) -> Option<u64> {
345    None
346}
347
348/// Is `pid` still OUR worker, not a recycled PID? True iff the process exists,
349/// we may signal it, AND its current start time matches `recorded`
350/// (ab-d19e6458). If a start time is unavailable on either side (`None` — lookup
351/// unsupported/failed, or no start time was recorded for a legacy entry), fall
352/// back to a bare existence check so behavior degrades to the pre-create_time
353/// semantics rather than mis-deciding.
354pub fn pid_is_ours(pid: u32, recorded: Option<u64>) -> bool {
355    // Never treat pid 0 or 1 as ours (gemini security-high, PR #472). `kill(0, sig)`
356    // signals the CALLER's whole process group and `kill(1, sig)` targets init;
357    // worse, a corrupt status/registry pid of 0 would otherwise pass the probe
358    // (kill(0,0)==0) and fall through to the `_ => true` arm, so a later
359    // `send_sigterm(0)` would SIGTERM the client's own process group. A real
360    // worker/daemon pid is never <= 1, so this only ever rejects a malformed pid.
361    if pid <= 1 {
362        return false;
363    }
364    // SAFETY: signal 0 is an existence/permission probe only. rc == 0 means the
365    // process exists AND we may signal it; a non-zero rc is ESRCH (dead) or
366    // EPERM (alive but owned by another user). Our worker is always the same user
367    // as the daemon, so an unsignalable pid is never ours -- this also closes the
368    // EPERM hole where a recycled foreign-user pid (no readable start time) would
369    // otherwise fall through to "trust liveness" and be mistaken for our worker
370    // (Gemini medium, PR #365).
371    if unsafe { libc::kill(pid as libc::pid_t, 0) } != 0 {
372        return false;
373    }
374    match (recorded, process_start_time(pid)) {
375        (Some(rec), Some(now)) => rec == now,
376        // No basis to prove reuse -> trust existence (legacy / unsupported).
377        _ => true,
378    }
379}
380
381// ---------------------------------------------------------------------------
382// Socket bind + perms + lazy-start race.
383// ---------------------------------------------------------------------------
384
385/// Bind the supervisor socket, resolving the lazy-start race and stale sockets.
386///
387/// - If a live daemon answers a connect to the existing socket, we are the race
388///   loser: return [`DaemonError::AlreadyRunning`] so the caller exits cleanly.
389/// - If the socket file exists but nothing answers (stale, from a crash), remove
390///   and bind.
391/// - Enforce dir 0700 / socket 0600 regardless of umask, fstat-verifying after
392///   (finding #6 Critical).
393pub async fn bind_supervisor_socket(home: &AgentsHome) -> Result<UnixListener, DaemonError> {
394    home.ensure_root()?;
395    flock_self_test(home)?;
396
397    let sock = home.supervisor_sock();
398    if sock.exists() {
399        // Probe for a live daemon.
400        if UnixStream::connect(&sock).await.is_ok() {
401            return Err(DaemonError::AlreadyRunning(sock));
402        }
403        // Stale: remove and continue to bind.
404        let _ = std::fs::remove_file(&sock);
405    }
406
407    let listener = match UnixListener::bind(&sock) {
408        Ok(l) => l,
409        Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
410            // A racing daemon bound between our probe and bind; it won.
411            return Err(DaemonError::AlreadyRunning(sock));
412        }
413        Err(e) => return Err(e.into()),
414    };
415
416    paths::set_file_mode_0600(&sock)?;
417
418    // fstat-verify the invariant; refuse to serve if either perm is wrong.
419    #[cfg(unix)]
420    {
421        if !paths::is_dir_mode_0700(home.root()) {
422            return Err(DaemonError::Permission(format!(
423                "{} is not mode 0700",
424                home.root().display()
425            )));
426        }
427        if !paths::is_file_mode_0600(&sock) {
428            return Err(DaemonError::Permission(format!(
429                "{} is not mode 0600",
430                sock.display()
431            )));
432        }
433    }
434
435    Ok(listener)
436}
437
438/// Prove the filesystem under `home` supports advisory locking before relying
439/// on it for cross-language coordination. Network filesystems (NFS/FUSE) can
440/// silently no-op flock; we refuse to start rather than corrupt shared state.
441fn flock_self_test(home: &AgentsHome) -> Result<(), DaemonError> {
442    let probe = home.root().join(".flock-probe");
443    let file = std::fs::OpenOptions::new()
444        .create(true)
445        .read(true)
446        .write(true)
447        .truncate(false)
448        .open(&probe)
449        .map_err(|e| DaemonError::FlockUnsupported(probe.clone(), e.to_string()))?;
450    // Always clean up the probe file, even when the lock fails: an early `?`
451    // here would otherwise leave a stray `.flock-probe` behind (ab-b396250f).
452    let lock_res = file.lock();
453    if lock_res.is_ok() {
454        let _ = file.unlock();
455    }
456    let _ = std::fs::remove_file(&probe);
457    lock_res.map_err(|e| DaemonError::FlockUnsupported(probe.clone(), e.to_string()))?;
458    Ok(())
459}
460
461// ---------------------------------------------------------------------------
462// Serve loop.
463// ---------------------------------------------------------------------------
464
465/// Run the daemon to completion: cold_start -> recovering -> serving ->
466/// (SIGTERM | idle) -> shutting_down -> exited. Returns when the process should
467/// exit. The race-loser path returns `Ok(())` after logging, so the client that
468/// lazy-forked it simply connects to the winner.
469pub async fn run(home: AgentsHome, opts: DaemonOptions) -> Result<(), DaemonError> {
470    let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
471
472    // State: cold_start.
473    let listener = match bind_supervisor_socket(&home).await {
474        Ok(l) => l,
475        Err(DaemonError::AlreadyRunning(_)) => {
476            // Race loser: nothing to do; the winner serves.
477            return Ok(());
478        }
479        Err(e) => return Err(e),
480    };
481
482    // State: recovering. Recovery must complete before we accept a request.
483    emit_state(&emitter, DaemonState::Recovering);
484    let report = recover(&home, &emitter);
485
486    // Architecture B (plan ab-70faa65b): ONE bounded reconcile sweep on startup,
487    // as part of recovery and BEFORE the accept loop serves any client, so the
488    // first `list` reads truthful process-liveness status instead of stale
489    // creation-time values. Reuses the same bounded machinery as the `reconcile`
490    // RPC (fairness order + 250ms/probe + 5s budget). Strictly non-fatal: a sweep
491    // that returns an error (registry write failed -> registry unchanged) or even
492    // panics degrades to serving last-recorded status -- we emit and continue,
493    // never abort the daemon (AC1-FR). Completing before `accept` upholds the
494    // Concurrency invariant that no client observes a half-applied sweep. Opt out
495    // via FNO_AGENTS_NO_STARTUP_RECONCILE for the fastest cold start (discretion #5).
496    if opts.reconcile_on_start {
497        // Collapse a panic into an Err so the degradation has a single shape. The
498        // FNO_AGENTS_FAIL_STARTUP_RECONCILE env is a test seam that forces the
499        // failure path (proving the daemon keeps serving last-recorded status
500        // instead of aborting -- AC1-FR); it is never set in production.
501        let swept: Result<ReconcileSweepResult, String> =
502            if std::env::var("FNO_AGENTS_FAIL_STARTUP_RECONCILE").is_ok() {
503                Err(
504                    "forced startup-reconcile failure (FNO_AGENTS_FAIL_STARTUP_RECONCILE)"
505                        .to_string(),
506                )
507            } else {
508                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
509                    run_reconcile_sweep(&home, &emitter)
510                }))
511                .unwrap_or_else(|_| {
512                    Err(
513                        "startup reconcile sweep panicked; serving last-recorded status"
514                            .to_string(),
515                    )
516                })
517            };
518        match swept {
519            Ok(result) => {
520                let _ = emitter.emit(
521                    "startup_reconcile_done",
522                    &json!({
523                        "updated": result.outcome.updated.len(),
524                        "deferred": result.outcome.deferred,
525                    }),
526                );
527            }
528            Err(msg) => {
529                let _ = emitter.emit("startup_reconcile_failed", &json!({"error": msg}));
530            }
531        }
532    }
533
534    // State: serving. daemon_started is emitted AFTER recovery (step 7 ordering:
535    // events.jsonl reflects reality from the first served request).
536    let started_at = Instant::now();
537    // Drift signal (ab-1891cdff): fingerprint the executable we are running so a
538    // later client can tell whether the on-disk binary has been replaced since.
539    // Also record our own pid start time so `restart` can pid-reuse-guard the
540    // SIGTERM, reusing the same check the daemon already applies to workers.
541    let exe_fingerprint = crate::drift::ExeFingerprint::current();
542    if exe_fingerprint.is_none() {
543        // Advisory only: a daemon that can't fingerprint itself just reports no
544        // fingerprint, and every client drift check fails safe to Unknown.
545        let _ = emitter.emit("daemon_exe_fingerprint_unavailable", &json!({}));
546    }
547    let pid_start_time = process_start_time(std::process::id());
548    let _ = emitter.emit(
549        "daemon_started",
550        &json!({
551            "pid": std::process::id(),
552            "version": env!("CARGO_PKG_VERSION"),
553            "recovered_drives": report.recovered_drives.len(),
554        }),
555    );
556    emit_state(&emitter, DaemonState::Serving);
557
558    // Shared across per-connection tasks (cheap Arc clone, no deep copy).
559    let ctx = Arc::new(Ctx {
560        home,
561        emitter,
562        opts,
563        started_at,
564        exe_fingerprint,
565        pid_start_time,
566        drives: crate::drive::new_table(),
567    });
568
569    // Active-backlog drain supervisor (node x-c070). Opt-in via
570    // config.active_backlog; the supervisor resolves its own enabled targets and
571    // stays dormant (live=false) when none, so this is byte-for-byte today's
572    // behavior unless an operator turns it on. Started AFTER the Serving
573    // transition (recovery is already complete here). `ab_live` keeps the daemon
574    // out of idle-exit while >=1 project is enabled; `ab_shutdown` winds the task
575    // down between ticks on daemon shutdown.
576    let ab_live = Arc::new(std::sync::atomic::AtomicBool::new(false));
577    let ab_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
578    let ab_handle = {
579        let abi_bin = std::env::var("FNO_BIN").unwrap_or_else(|_| "fno".to_string());
580        let ab_emitter = EventEmitter::new(ctx.home.events_jsonl(), "active-backlog");
581        let live = Arc::clone(&ab_live);
582        let shutdown = Arc::clone(&ab_shutdown);
583        tokio::spawn(crate::active_backlog::run_supervisor(
584            abi_bin, ab_emitter, live, shutdown,
585        ))
586    };
587
588    // SIGTERM -> graceful shutdown.
589    let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
590    let mut idle_check = tokio::time::interval(Duration::from_secs(5));
591    idle_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
592    let mut last_activity = Instant::now();
593
594    loop {
595        tokio::select! {
596            accepted = listener.accept() => {
597                if let Ok((stream, _)) = accepted {
598                    last_activity = Instant::now();
599                    // Serve each connection in its own task so a slow or hung
600                    // client cannot block the accept loop, SIGTERM, or other
601                    // clients (Gemini high). Shared state is advisory-lock
602                    // protected, so concurrent handling is safe.
603                    let ctx = Arc::clone(&ctx);
604                    tokio::spawn(async move {
605                        serve_connection(ctx, stream).await;
606                    });
607                }
608            }
609            _ = sigterm.recv() => {
610                emit_state(&ctx.emitter, DaemonState::ShuttingDown);
611                let _ = ctx.emitter.emit("daemon_shutting_down", &json!({"reason": "sigterm"}));
612                break;
613            }
614            _ = idle_check.tick() => {
615                // Reap any worker that exited since the last tick so it never
616                // lingers as a zombie under the long-lived daemon.
617                reap_zombies();
618                let empty = state::load_registry(&ctx.home.registry_json())
619                    .map(|r| r.entries.is_empty())
620                    .unwrap_or(true);
621                // Never idle-exit out from under an active drive session, even if
622                // the registry momentarily looks empty (review LOW #3).
623                let no_drives = ctx.drives.lock().await.is_empty();
624                // An enabled active-backlog project keeps the daemon resident even
625                // when the board is drained (OQ1 Option A): idle-exit must never
626                // kill a live drain supervisor.
627                let ab_active = ab_live.load(std::sync::atomic::Ordering::SeqCst);
628                if empty && no_drives && !ab_active && last_activity.elapsed() >= ctx.opts.idle_exit {
629                    emit_state(&ctx.emitter, DaemonState::IdlePendingExit);
630                    let _ = ctx.emitter.emit("daemon_idle_pending_exit", &json!({}));
631                    emit_state(&ctx.emitter, DaemonState::ShuttingDown);
632                    let _ = ctx.emitter.emit(
633                        "daemon_shutting_down",
634                        &json!({"reason": "idle"}),
635                    );
636                    break;
637                }
638            }
639        }
640    }
641
642    // Wind down the active-backlog supervisor: signal it to stop scheduling new
643    // ticks, then abort its await. An in-flight tick's spawn_blocking thread is
644    // not abortable, but that is safe by design - the dispatched worker owns its
645    // node:<id> claim independently, and on the next daemon start the live-claims
646    // filter excludes the still-in-flight node (no double-dispatch).
647    ab_shutdown.store(true, std::sync::atomic::Ordering::SeqCst);
648    ab_handle.abort();
649
650    let _ = std::fs::remove_file(ctx.home.supervisor_sock());
651    emit_state(&ctx.emitter, DaemonState::Exited);
652    let _ = ctx.emitter.emit("daemon_exited", &json!({"clean": true}));
653    Ok(())
654}
655
656/// Daemon-wide context passed to handlers.
657struct Ctx {
658    home: AgentsHome,
659    emitter: EventEmitter,
660    opts: DaemonOptions,
661    started_at: Instant,
662    /// Fingerprint of the executable this daemon is running (ab-1891cdff),
663    /// captured once at startup. `None` if `current_exe()`/stat failed; the
664    /// status payload then reports null and clients fail safe to `Unknown`.
665    exe_fingerprint: Option<crate::drift::ExeFingerprint>,
666    /// This daemon's own process start time, for the `restart` pid-reuse guard.
667    /// `None` on platforms/paths where it is unavailable (the guard degrades to
668    /// a bare existence check, like the worker path).
669    pid_start_time: Option<u64>,
670    /// In-memory drive registry (Wave 4): active drivers/watchers per agent,
671    /// for caps, single-driver enforcement, and stale takeover.
672    drives: crate::drive::DriveTable,
673}
674
675fn emit_state(emitter: &EventEmitter, state: DaemonState) {
676    let _ = emitter.emit("daemon_state", &json!({"state": state.as_str()}));
677}
678
679/// Idle cap for the first read on a connection: a client that connects but
680/// never sends a frame self-terminates rather than holding the task forever.
681const CONN_READ_TIMEOUT: Duration = Duration::from_secs(30);
682
683async fn serve_connection(ctx: Arc<Ctx>, mut stream: UnixStream) {
684    // One request per accepted connection (clients open per RPC). A read fault
685    // is mapped to a structured error response so callers get a deterministic
686    // error code rather than a transport EOF (Codex P2): only a clean hangup
687    // (UnexpectedEof) is silent. A silent client is bounded by the timeout.
688    let req = match tokio::time::timeout(CONN_READ_TIMEOUT, read_request(&mut stream)).await {
689        Err(_elapsed) => return, // client sent nothing within the window; drop
690        Ok(Ok(r)) => r,
691        Ok(Err(crate::protocol::ProtocolError::UnexpectedEof)) => return, // clean hangup
692        Ok(Err(e)) => {
693            // Malformed / oversized frame: we could not parse a request id, so
694            // reply against id 0 with a structured MalformedFrame error.
695            let resp = Response::err(0, ErrorCode::MalformedFrame, format!("{e}"));
696            let _ = write_response(&mut stream, &resp).await;
697            return;
698        }
699    };
700    // Drive is the one verb that does NOT fit the one-request/one-response
701    // shape: after the `agent.drive` ack the SAME stream is upgraded to a
702    // WebSocket and kept open for the session. Hand the owned stream to the
703    // drive handler instead of routing through dispatch (which returns a
704    // Response and drops the stream).
705    if req.method == "agent.drive" {
706        crate::drive::handle_drive(&ctx.home, &ctx.emitter, &ctx.drives, &req, stream).await;
707        return;
708    }
709    // `agent.logs` (with --follow) likewise upgrades the same stream to a
710    // WebSocket and streams appended log lines until the client detaches; it
711    // does not fit the one-request/one-response shape.
712    if req.method == "agent.logs" {
713        crate::logs::handle_logs(&ctx.home, &req, stream).await;
714        return;
715    }
716    let resp = dispatch(&ctx, &req).await;
717    let _ = write_response(&mut stream, &resp).await;
718}
719
720/// Run a synchronous (flock + CPU, no socket I/O) handler on the blocking pool
721/// so its advisory-lock wait never starves the async executor (Gemini high).
722async fn run_blocking<F>(ctx: &Arc<Ctx>, req: &Request, f: F) -> Response
723where
724    F: FnOnce(&Ctx, &Request) -> Response + Send + 'static,
725{
726    let ctx = Arc::clone(ctx);
727    let req = req.clone();
728    let id = req.id;
729    match tokio::task::spawn_blocking(move || f(&ctx, &req)).await {
730        Ok(resp) => resp,
731        Err(_) => Response::err(id, ErrorCode::Internal, "handler task panicked"),
732    }
733}
734
735/// Offload the blocking flock + file read of `state::load_registry` to the
736/// blocking pool so it never stalls an async handler's runtime thread
737/// (ab-e86e326b). Mirrors the existing `handle_status` offload and the
738/// `run_blocking` wrapper. A join failure or a read error both collapse to the
739/// empty registry, matching the `.unwrap_or_default()` the inline callers used.
740async fn load_registry_offloaded(path: PathBuf) -> state::Registry {
741    tokio::task::spawn_blocking(move || state::load_registry(&path))
742        .await
743        .ok()
744        .and_then(|r| r.ok())
745        .unwrap_or_default()
746}
747
748/// Offload the blocking read-modify-write of `state::update_registry` to the
749/// blocking pool (ab-e86e326b). The closure runs on the blocking thread, so it
750/// must be `Send + 'static` (callers move owned clones in). A join panic maps to
751/// a `StateError::Io` so callers' existing error handling fires.
752async fn update_registry_offloaded<F, T>(path: PathBuf, f: F) -> Result<T, state::StateError>
753where
754    F: FnOnce(&mut state::Registry) -> T + Send + 'static,
755    T: Send + 'static,
756{
757    match tokio::task::spawn_blocking(move || state::update_registry(&path, f)).await {
758        Ok(result) => result,
759        Err(e) => Err(state::StateError::Io(std::io::Error::other(format!(
760            "update_registry task panicked: {e}"
761        )))),
762    }
763}
764
765async fn dispatch(ctx: &Arc<Ctx>, req: &Request) -> Response {
766    match Namespace::of(&req.method) {
767        Namespace::Agent => dispatch_agent(ctx, req).await,
768        Namespace::Channel => dispatch_channel(ctx, req).await,
769        Namespace::Unknown => Response::err(
770            req.id,
771            ErrorCode::UnknownMethod,
772            format!("unknown namespace for method `{}`", req.method),
773        ),
774    }
775}
776
777async fn dispatch_agent(ctx: &Arc<Ctx>, req: &Request) -> Response {
778    // Async handlers (spawn/ask/stop) interleave worker-socket I/O and stay on
779    // the async runtime; pure-sync handlers go to the blocking pool.
780    match Namespace::verb(&req.method) {
781        Some("spawn") => handle_spawn(ctx, req).await,
782        Some("ask") => handle_ask(ctx, req).await,
783        Some("deliver") => handle_deliver(ctx, req).await,
784        Some("switchboard") => handle_switchboard(ctx, req).await,
785        Some("gate_check") => handle_gate_check(ctx, req).await,
786        Some("stop") => handle_stop(ctx, req).await,
787        Some("rm") => handle_rm(ctx, req).await,
788        Some("list") => run_blocking(ctx, req, handle_list).await,
789        // status reads the in-memory drive table for the active-drives count, so
790        // it stays on the async runtime rather than the blocking pool.
791        Some("status") => handle_status(ctx, req).await,
792        Some("reconcile") => run_blocking(ctx, req, handle_reconcile).await,
793        _ => Response::err(
794            req.id,
795            ErrorCode::UnknownMethod,
796            format!("unknown agent verb in `{}`", req.method),
797        ),
798    }
799}
800
801/// Validate an agent name: 1..=64 chars from `[A-Za-z0-9_-]` (US1 dispatch rule).
802fn valid_agent_name(name: &str) -> bool {
803    !name.is_empty()
804        && name.len() <= 64
805        && name
806            .chars()
807            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
808}
809
810/// Derive a short id from a name, made unique against the registry.
811fn derive_short_id(name: &str, registry: &state::Registry) -> String {
812    let base: String = name
813        .chars()
814        .filter(|c| c.is_ascii_alphanumeric())
815        .take(8)
816        .collect();
817    let base = if base.is_empty() {
818        "agent".into()
819    } else {
820        base
821    };
822    if registry.entries.iter().all(|e| e.short_id != base) {
823        return base;
824    }
825    for n in 1..10_000 {
826        let cand = format!("{base}{n}");
827        if registry.entries.iter().all(|e| e.short_id != cand) {
828            return cand;
829        }
830    }
831    format!("{base}-{}", now_compact())
832}
833
834/// Whether `e` records `uuid` as its resume target (any provider id field).
835fn entry_holds_session(e: &RegistryEntry, uuid: &str) -> bool {
836    e.codex_session_id.as_deref() == Some(uuid)
837        || e.gemini_session_id.as_deref() == Some(uuid)
838        || e.session_id.as_deref() == Some(uuid)
839}
840
841/// Non-terminal == has (or expects) a live backend. Exited/PermanentDead are
842/// the only terminal states.
843fn is_non_terminal(s: AgentStatus) -> bool {
844    !matches!(s, AgentStatus::Exited | AgentStatus::PermanentDead)
845}
846
847/// Result of `promote --from <uuid>` admission against a registry snapshot.
848#[derive(Debug, PartialEq, Eq)]
849enum PromoteAdmission {
850    /// Admit; host the session with this inferred provider.
851    Ok { provider: String },
852    /// Reject with a user-facing reason; spawn no worker.
853    Reject(String),
854}
855
856/// Pure admission for `fno agents promote --from <uuid>` (task 3.1, the
857/// concurrency invariant). Decided against a registry snapshot so the
858/// unknown-uuid / one-host / settled-source rules are unit-testable without a
859/// daemon. Enforces the integrity intent "at most one live interactive writer
860/// per session, and don't resume a session whose source is still mid-flight":
861///
862/// - empty/blank uuid -> reject (boundary).
863/// - a non-terminal interactive host already on this session -> reject
864///   (one-host; covers a re-promote AND a still-live interactive source).
865/// - no recorded agent holds this session id -> reject (unknown session).
866/// - the source is in a mid-flight transient state (Spawning/Restarting/Busy)
867///   -> reject (still running). NOTE: a *settled* `Live` exec source IS
868///   promotable -- codex/gemini `ask` leaves the row `Live` after the one-shot
869///   exits (codex_ask.rs stamps status=live on success), so the plan's literal
870///   "reject if status==live" would make every exec-born session
871///   un-promotable, contradicting AC1-HP. We reject only genuinely-in-flight
872///   states. (Deviation from the plan's literal status set, by intent.)
873fn admit_promote(entries: &[RegistryEntry], uuid: &str) -> PromoteAdmission {
874    if uuid.trim().is_empty() {
875        return PromoteAdmission::Reject(
876            "promote requires a non-empty --from <session-uuid>".into(),
877        );
878    }
879    // Search newest-first: the registry can hold several rows for one session id
880    // (the original exec source plus any promoted hosts), so the most recent row
881    // is the authoritative current state/provider (Gemini review MEDIUM, PR #373).
882    if let Some(h) = entries
883        .iter()
884        .rev()
885        .find(|e| e.is_interactive() && entry_holds_session(e, uuid) && is_non_terminal(e.status))
886    {
887        return PromoteAdmission::Reject(format!(
888            "session '{uuid}' is already hosted by live interactive agent '{}'; one interactive host per session",
889            h.name
890        ));
891    }
892    let Some(source) = entries.iter().rev().find(|e| entry_holds_session(e, uuid)) else {
893        return PromoteAdmission::Reject(format!(
894            "unknown session '{uuid}': no recorded agent has it as a codex/gemini session id"
895        ));
896    };
897    if matches!(
898        source.status,
899        AgentStatus::Spawning | AgentStatus::Restarting | AgentStatus::Busy
900    ) {
901        return PromoteAdmission::Reject(format!(
902            "source session '{uuid}' still running (agent '{}' status {:?}); only a settled session may be promoted",
903            source.name, source.status
904        ));
905    }
906    if source.provider.trim().is_empty() {
907        // A legacy/corrupt source row with no provider would otherwise infer an
908        // empty provider and fail late at argv dispatch; reject early and clearly.
909        return PromoteAdmission::Reject(format!(
910            "source agent '{}' for session '{uuid}' has no provider recorded; cannot infer interactive host",
911            source.name
912        ));
913    }
914    PromoteAdmission::Ok {
915        provider: source.provider.clone(),
916    }
917}
918
919async fn handle_spawn(ctx: &Ctx, req: &Request) -> Response {
920    let p = &req.params;
921    let name = match p.get("name").and_then(|v| v.as_str()) {
922        Some(n) if valid_agent_name(n) => n.to_string(),
923        Some(_) => {
924            return Response::err(
925                req.id,
926                ErrorCode::InvalidParams,
927                "name must be 1-64 chars of [A-Za-z0-9_-]",
928            )
929        }
930        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `name`"),
931    };
932    let mut provider = p
933        .get("provider")
934        .and_then(|v| v.as_str())
935        .unwrap_or("codex")
936        .to_string();
937    // A missing `cwd` means a misbehaving client: the daemon is a shared,
938    // long-lived process, so its own current_dir is whatever project it was
939    // first started in, NOT the caller's. Fall back to a neutral temp dir
940    // (matching the worker's own default) so a no-cwd request lands somewhere
941    // obviously-not-a-project instead of silently adopting the daemon's repo,
942    // and emit an event so the /tmp launch is greppable rather than silent. A
943    // well-behaved client always forwards cwd (client.rs ensure_request_cwd).
944    let cwd = match p.get("cwd").and_then(|v| v.as_str()) {
945        Some(c) => PathBuf::from(c),
946        None => {
947            let fallback = std::env::temp_dir();
948            let _ = ctx.emitter.emit(
949                "agent_spawn_cwd_fallback",
950                &json!({"name": name, "fallback": fallback.to_string_lossy()}),
951            );
952            fallback
953        }
954    };
955
956    // Resolve the PTY command. Explicit `argv` wins (tests/escape-hatch).
957    // When absent, resolve via the provider's ProviderWithPty::create_argv (Task 4.1).
958    let message = p
959        .get("message")
960        .and_then(|v| v.as_str())
961        .unwrap_or("")
962        .to_string();
963    let from_name = p
964        .get("from_name")
965        .and_then(|v| v.as_str())
966        .map(String::from);
967    let yolo = p.get("yolo").and_then(|v| v.as_bool()).unwrap_or(false);
968    // host_mode: "exec" (default, one-shot) or "interactive" (long-lived
969    // drivable TUI via `fno agents host`/`promote`). Absent reads as exec so
970    // every existing spawn call site keeps its current behavior. resume_id is
971    // the session UUID for an interactive promote (None for a fresh host).
972    let host_mode = p
973        .get("host_mode")
974        .and_then(|v| v.as_str())
975        .unwrap_or(crate::state::HOST_MODE_EXEC)
976        .to_string();
977    let interactive = host_mode == crate::state::HOST_MODE_INTERACTIVE;
978    let resume_id = p
979        .get("resume_id")
980        .and_then(|v| v.as_str())
981        .map(|s| s.to_string());
982
983    // Interactive promote admission (task 3.1): resolve the source session and
984    // enforce the unknown-uuid / one-host / settled-source invariants before
985    // building argv. `promote` infers the provider from the source row (the
986    // client only knows the UUID). This lock-free pass gives precise rejection
987    // messages for the common (non-racing) case; the authoritative one-host
988    // reservation re-checks atomically under the registry lock below.
989    if interactive {
990        // Claude front door (Group 3, ab-734fcd6c): an interactive claude host is
991        // the stream-json adoption lane, NOT the codex/gemini PTY lane. claude has
992        // no PTY (`as_pty` -> None); adopting it means resuming an idle session as
993        // a held stream-json thread (`claude -p --resume <uuid> ...`). Routed
994        // BEFORE `admit_promote`, which infers the provider from a codex/gemini
995        // source ROW -- a row claude adoption deliberately does not require, since
996        // `--resume` reconstructs the transcript for ANY idle session by id
997        // (AC1-HP "any idle session by id"). The single-writer guard is the
998        // registry one-host re-check (atomic, in `spawn_claude_stream_lane`) plus
999        // the `fno claim session:<uuid>` coordination record, mirroring how
1000        // codex/gemini enforce one host per session below.
1001        if provider == "claude" {
1002            let explicit_argv = p.get("argv").and_then(|v| v.as_array()).map(|a| {
1003                a.iter()
1004                    .filter_map(|v| v.as_str().map(String::from))
1005                    .collect::<Vec<String>>()
1006            });
1007            return spawn_claude_stream_lane(
1008                ctx,
1009                req,
1010                &name,
1011                &cwd,
1012                resume_id.as_deref(),
1013                explicit_argv,
1014            )
1015            .await;
1016        }
1017        if let Some(uuid) = resume_id.as_deref() {
1018            let registry = load_registry_offloaded(ctx.home.registry_json()).await;
1019            match admit_promote(&registry.entries, uuid) {
1020                PromoteAdmission::Ok { provider: inferred } => provider = inferred,
1021                PromoteAdmission::Reject(reason) => {
1022                    let _ = ctx.emitter.emit(
1023                        "agent_spawn_failed",
1024                        &json!({"name": name, "reason": "promote_rejected", "detail": reason}),
1025                    );
1026                    return Response::err(req.id, ErrorCode::InvalidParams, reason);
1027                }
1028            }
1029        }
1030        // Validate the (possibly inferred) provider for BOTH fresh host and
1031        // promote (AC3-ERR). Only codex/gemini are PTY-hostable here; claude took
1032        // the stream-json branch above, so any provider that reaches this point is
1033        // either an unknown CLI (no PTY impl) or a promote whose source row
1034        // resolved to one. This runs AFTER promote's provider inference so a
1035        // promote whose source resolves to claude/unknown is rejected here with a
1036        // clear message rather than failing later with a confusing PTY-missing
1037        // error (Gemini review HIGH on PR #373 -- the prior `else if` skipped this
1038        // for the promote path).
1039        if provider != "codex" && provider != "gemini" {
1040            let _ = ctx.emitter.emit(
1041                "agent_spawn_failed",
1042                &json!({"name": name, "reason": "bad_interactive_provider", "provider": provider}),
1043            );
1044            return Response::err(
1045                req.id,
1046                ErrorCode::InvalidParams,
1047                format!(
1048                    "interactive host_mode supports only codex, gemini, or claude (stream-json adopt via `promote --from <uuid> --provider claude`), not '{provider}'"
1049                ),
1050            );
1051        }
1052    }
1053
1054    let argv: Vec<String> = match p.get("argv").and_then(|v| v.as_array()) {
1055        Some(a) => a
1056            .iter()
1057            .filter_map(|v| v.as_str().map(|s| s.to_string()))
1058            .collect(),
1059        None => {
1060            // No explicit argv: resolve via provider trait.
1061            match provider_for_pty(&provider) {
1062                Some(pty_provider) => {
1063                    let create_ctx = crate::provider::CreateContext {
1064                        name: name.clone(),
1065                        message: message.clone(),
1066                        cwd: cwd.clone(),
1067                        from_name: from_name.clone(),
1068                        session_id: None,
1069                        yolo,
1070                    };
1071                    // Interactive routes to the host/promote argv; a provider
1072                    // with no interactive form (claude) returns None and is
1073                    // rejected here rather than silently falling back to exec.
1074                    let resolved = if interactive {
1075                        let built = match &resume_id {
1076                            Some(uuid) => {
1077                                pty_provider.resume_interactive_argv(&crate::provider::ResumeContext {
1078                                    session_id: uuid.clone(),
1079                                    message: message.clone(),
1080                                    cwd: cwd.clone(),
1081                                    from_name: from_name.clone(),
1082                                    yolo,
1083                                })
1084                            }
1085                            None => pty_provider.create_interactive_argv(&create_ctx),
1086                        };
1087                        match built {
1088                            Some(a) => a,
1089                            None => {
1090                                return Response::err(
1091                                    req.id,
1092                                    ErrorCode::InvalidParams,
1093                                    format!(
1094                                        "provider '{provider}' has no interactive host_mode; only codex/gemini support `fno agents host`/`promote`"
1095                                    ),
1096                                )
1097                            }
1098                        }
1099                    } else {
1100                        pty_provider.create_argv(&create_ctx)
1101                    };
1102                    if resolved.is_empty() {
1103                        return Response::err(
1104                            req.id,
1105                            ErrorCode::InvalidParams,
1106                            format!("provider '{provider}' returned empty argv"),
1107                        );
1108                    }
1109                    resolved
1110                }
1111                None => {
1112                    return Response::err(
1113                        req.id,
1114                        ErrorCode::InvalidParams,
1115                        format!(
1116                            "provider '{provider}' has no PTY implementation; pass --argv to spawn it manually"
1117                        ),
1118                    )
1119                }
1120            }
1121        }
1122    };
1123    if argv.is_empty() {
1124        return Response::err(req.id, ErrorCode::InvalidParams, "empty argv");
1125    }
1126
1127    // Collision check.
1128    let registry = load_registry_offloaded(ctx.home.registry_json()).await;
1129    if let Some(existing) = registry.find(&name) {
1130        return Response::err(
1131            req.id,
1132            ErrorCode::AgentExists,
1133            format!(
1134                "agent {name} already exists (short_id={}); use `fno agents rm` first",
1135                existing.short_id
1136            ),
1137        );
1138    }
1139    let short_id = derive_short_id(&name, &registry);
1140
1141    // Spawn the worker in its own process group (Outcome B: survives a kill of
1142    // the daemon's group; SIGKILL to the daemon pid alone never propagates).
1143    // We use std (not tokio) Command and forget the handle, then reap exited
1144    // workers via a non-blocking waitpid sweep on the idle tick — tokio's Child
1145    // would either kill-on-drop (wrong: the worker must outlive us) or leak a
1146    // zombie when dropped without await.
1147    let mut cmd = std::process::Command::new(&ctx.opts.worker_bin);
1148    cmd.arg("--short-id")
1149        .arg(&short_id)
1150        .arg("--home")
1151        .arg(ctx.home.root())
1152        .arg("--cwd")
1153        .arg(&cwd)
1154        .arg("--")
1155        .args(&argv);
1156    cmd.process_group(0);
1157    let child = match cmd.spawn() {
1158        Ok(c) => c,
1159        Err(e) => {
1160            let _ = ctx.emitter.emit(
1161                "agent_spawn_failed",
1162                &json!({"name": name, "reason": "binary_not_found", "detail": e.to_string()}),
1163            );
1164            return Response::err(
1165                req.id,
1166                ErrorCode::SpawnFailed,
1167                format!("could not launch worker: {e}"),
1168            );
1169        }
1170    };
1171    let worker_pid = child.id();
1172    // Capture the worker's start time NOW, paired with its pid, so later
1173    // liveness/reap/signal decisions can detect PID reuse (ab-d19e6458): a
1174    // recycled pid will have a different start time than the one recorded here.
1175    let worker_pid_start_time = process_start_time(worker_pid);
1176    // Detach: we do not await the worker; it outlives us by design. The idle
1177    // tick's reap_zombies() waitpid-sweeps any worker that later exits so it
1178    // never lingers as <defunct> under a long-lived daemon.
1179    drop(child);
1180
1181    // Wait (bounded) for the worker socket to appear, proving the PTY spawned.
1182    let sock = ctx.home.worker_sock(&short_id);
1183    let start = Instant::now();
1184    while !sock.exists() && start.elapsed() < Duration::from_secs(10) {
1185        tokio::time::sleep(Duration::from_millis(25)).await;
1186    }
1187    if !sock.exists() {
1188        let _ = ctx.emitter.emit(
1189            "agent_create_no_session",
1190            &json!({"name": name, "short_id": short_id}),
1191        );
1192        return Response::err(
1193            req.id,
1194            ErrorCode::SpawnFailed,
1195            "worker did not come up within 10s",
1196        );
1197    }
1198
1199    // The socket appearing proves the worker process bound it, but NOT that the
1200    // PTY child is alive (a binary that execs then immediately exits still lets
1201    // the worker bind). Confirm child liveness before declaring success, so
1202    // spawn never reports `live` for an already-dead agent (silent-failure #3).
1203    if !worker_reports_child_alive(&sock).await {
1204        let _ = ctx.emitter.emit(
1205            "agent_create_no_session",
1206            &json!({"name": name, "short_id": short_id, "reason": "child_not_alive"}),
1207        );
1208        // Best-effort: tell the worker to shut down so it does not linger.
1209        if let Ok(mut conn) = UnixStream::connect(&sock).await {
1210            let _ = write_request(&mut conn, &Request::new(1, "worker.shutdown", json!({}))).await;
1211            let _ = crate::protocol::read_response(&mut conn).await;
1212        }
1213        return Response::err(
1214            req.id,
1215            ErrorCode::SpawnFailed,
1216            "worker came up but its PTY child is not alive",
1217        );
1218    }
1219
1220    // Interactive host_mode: the child passed the instantaneous alive probe, but
1221    // a `codex resume`/`gemini -r` that errors (auth/unknown session) exits a
1222    // beat later. With no `--json` stream to confirm readiness, settle on "first
1223    // PTY paint + still alive" so a launched-then-died resume reports
1224    // spawn-failed with the worker's last bytes, never a live-then-exited row
1225    // (task 2.2, AC1-FR / AC1-UI). Exec spawns skip this (stream confirms later).
1226    if interactive {
1227        if let Err(last_bytes) = await_interactive_readiness(&sock).await {
1228            let tail: String = last_bytes
1229                .chars()
1230                .rev()
1231                .take(400)
1232                .collect::<Vec<_>>()
1233                .into_iter()
1234                .rev()
1235                .collect();
1236            let _ = ctx.emitter.emit(
1237                "agent_spawn_failed",
1238                &json!({
1239                    "name": name,
1240                    "short_id": short_id,
1241                    "reason": "interactive_child_exited_before_ready",
1242                    "last_bytes": tail,
1243                }),
1244            );
1245            best_effort_worker_shutdown(&sock).await;
1246            return Response::err(
1247                req.id,
1248                ErrorCode::SpawnFailed,
1249                format!(
1250                    "interactive {provider} worker exited before readiness (resume/launch failed); last output: {tail}"
1251                ),
1252            );
1253        }
1254    }
1255
1256    // Register.
1257    let entry = RegistryEntry {
1258        name: name.clone(),
1259        short_id: short_id.clone(),
1260        provider: provider.clone(),
1261        cwd: cwd.to_string_lossy().into_owned(),
1262        project_root: cwd.to_string_lossy().into_owned(),
1263        session_id: p
1264            .get("session_id")
1265            .and_then(|v| v.as_str())
1266            .map(String::from),
1267        claude_short_id: None,
1268        claude_session_uuid: None,
1269        messaging_socket_path: None,
1270        // An interactive promote resumes a known session UUID; record it in the
1271        // provider-specific id field so the one-host-per-session invariant
1272        // (task 3.1) and reconcile can match it. A fresh interactive host has no
1273        // prior UUID -> None (best-effort scrape is deferred, AC2-HP).
1274        codex_session_id: if interactive && provider == "codex" {
1275            resume_id.clone()
1276        } else {
1277            None
1278        },
1279        gemini_session_id: if interactive && provider == "gemini" {
1280            resume_id.clone()
1281        } else {
1282            None
1283        },
1284        mcp_channel_id: None,
1285        cc_session_id: None,
1286        // Skip-when-None keeps an exec row's on-disk shape unchanged (no key);
1287        // only an interactive row carries host_mode="interactive".
1288        host_mode: if interactive {
1289            Some(host_mode.clone())
1290        } else {
1291            None
1292        },
1293        status: AgentStatus::Live,
1294        last_message_at: Some(now_rfc3339_like()),
1295        created_at: now_rfc3339_like(),
1296        pid: Some(worker_pid),
1297        pid_start_time: worker_pid_start_time,
1298        log_path: Some(
1299            ctx.home
1300                .timeline_jsonl(&short_id)
1301                .to_string_lossy()
1302                .into_owned(),
1303        ),
1304        last_reconciled_at: None,
1305    };
1306    // Reserve the name atomically UNDER the exclusive registry lock. The
1307    // lock-free collision check above can be passed by two concurrent
1308    // agent.spawn calls for the same name (the per-connection serving model runs
1309    // their handlers in parallel); both would derive the same short_id, start
1310    // workers, and reach here. Re-checking inside the locked read-modify-write
1311    // means exactly one inserts; the loser must NOT push a duplicate row (Codex
1312    // P1, PR #365). The closure returns whether THIS call won the insert.
1313    let resume_for_lock = resume_id.clone();
1314    let interactive_for_lock = interactive;
1315    let insert = update_registry_offloaded(ctx.home.registry_json(), move |r| {
1316        if r.entries.iter().any(|e| e.name == entry.name) {
1317            return false;
1318        }
1319        // Atomic one-host re-check: two concurrent `promote --from <uuid>` calls
1320        // both pass the lock-free admit_promote above, then race to push here.
1321        // Under the exclusive registry lock exactly one wins; the loser must NOT
1322        // create a second interactive writer on the session (one-host invariant,
1323        // AC2-EDGE concurrency; same pattern as the name reservation).
1324        if interactive_for_lock {
1325            if let Some(uuid) = resume_for_lock.as_deref() {
1326                if r.entries.iter().any(|e| {
1327                    e.is_interactive() && entry_holds_session(e, uuid) && is_non_terminal(e.status)
1328                }) {
1329                    return false;
1330                }
1331            }
1332        }
1333        r.entries.push(entry);
1334        true
1335    })
1336    .await;
1337    match insert {
1338        Ok(true) => {}
1339        Ok(false) => {
1340            // Lost a concurrent spawn race for this name: our just-started worker
1341            // is a duplicate. Shut it down so we don't leak it, then report the
1342            // conflict (same code the lock-free collision check returns).
1343            best_effort_worker_shutdown(&sock).await;
1344            let _ = ctx.emitter.emit(
1345                "agent_spawn_failed",
1346                &json!({"name": name, "short_id": short_id, "reason": "name_taken_concurrent"}),
1347            );
1348            return Response::err(
1349                req.id,
1350                ErrorCode::AgentExists,
1351                format!("agent {name} already exists (won by a concurrent spawn); use `fno agents rm` first"),
1352            );
1353        }
1354        Err(e) => {
1355            // The worker is already up and bound but never made it into the
1356            // registry, so it would be unlistable/unstoppable. Shut it down before
1357            // returning rather than leaking a live, untracked process (Codex P2).
1358            best_effort_worker_shutdown(&sock).await;
1359            let _ = ctx.emitter.emit(
1360                "agent_spawn_failed",
1361                &json!({"name": name, "short_id": short_id, "reason": "registry_write_failed"}),
1362            );
1363            return Response::err(req.id, ErrorCode::Internal, format!("registry write: {e}"));
1364        }
1365    }
1366    let _ = ctx.emitter.emit(
1367        "agent_spawned",
1368        &json!({"name": name, "provider": provider, "short_id": short_id}),
1369    );
1370
1371    Response::ok(
1372        req.id,
1373        json!({"short_id": short_id, "provider": provider, "status": "live"}),
1374    )
1375}
1376
1377// ---------------------------------------------------------------------------
1378// Claude stream-json host lane front door (Group 3, ab-734fcd6c).
1379// ---------------------------------------------------------------------------
1380
1381/// The single-writer claim holder for an adopted claude stream thread, derived
1382/// from its short_id (stable + unique per thread). The worker releases the claim
1383/// by this EXACT string (passed via `--holder`), so the daemon's acquire and the
1384/// worker's RAII release must agree on it.
1385fn stream_claim_holder(short_id: &str) -> String {
1386    format!("stream:{short_id}")
1387}
1388
1389/// Is this row a LIVE writer for the one-host guard? Narrower than
1390/// [`is_non_terminal`]: it EXCLUDES the dead-but-non-terminal states (`Orphaned`
1391/// = the child died and the worker released its claim; `Failed` = the task
1392/// panicked) so a session whose adopted thread has died is re-adoptable. AC1-FR
1393/// marks a dead thread `orphaned` and releases the claim, and AC1-EDGE refuses a
1394/// second adopt only for a session "currently held LIVE by another process" —
1395/// using `is_non_terminal` here would wrongly keep an orphaned UUID un-adoptable
1396/// until a reconcile/rm cleared the row.
1397fn is_live_writer(status: AgentStatus) -> bool {
1398    matches!(
1399        status,
1400        AgentStatus::Live
1401            | AgentStatus::Ready
1402            | AgentStatus::Idle
1403            | AgentStatus::Busy
1404            | AgentStatus::Spawning
1405            | AgentStatus::Restarting
1406    )
1407}
1408
1409/// The `fno claim acquire session:<uuid> --holder <holder> -J` argv. Pure for
1410/// unit-testability (mirrors `stream_worker::claim_release_argv`). `-J` makes the
1411/// CLI emit the resulting claim record so the daemon can confirm WHO holds it.
1412fn claim_acquire_argv(uuid: &str, holder: &str) -> Vec<String> {
1413    vec![
1414        "claim".into(),
1415        "acquire".into(),
1416        format!("session:{uuid}"),
1417        "--holder".into(),
1418        holder.into(),
1419        "-J".into(),
1420    ]
1421}
1422
1423/// The worker argv for the claude stream-json lane (everything after the worker
1424/// BINARY path). `parse_stream_args` in bin/worker.rs accepts these flags in any
1425/// order before `--`; the child argv (normally
1426/// [`crate::provider::claude_stream_json_resume_argv`]) follows the separator.
1427/// Pure so the flag wiring is unit-testable without spawning a process.
1428fn claude_stream_worker_args(
1429    short_id: &str,
1430    home: &std::path::Path,
1431    cwd: &std::path::Path,
1432    uuid: &str,
1433    holder: &str,
1434    child_argv: &[String],
1435) -> Vec<String> {
1436    let mut args = vec![
1437        "--stream".into(),
1438        "--short-id".into(),
1439        short_id.into(),
1440        "--home".into(),
1441        home.to_string_lossy().into_owned(),
1442        "--cwd".into(),
1443        cwd.to_string_lossy().into_owned(),
1444        "--session-uuid".into(),
1445        uuid.into(),
1446        "--holder".into(),
1447        holder.into(),
1448        "--".into(),
1449    ];
1450    args.extend(child_argv.iter().cloned());
1451    args
1452}
1453
1454/// Build the registry row for an adopted claude stream thread. `provider`=claude
1455/// + `host_mode`=interactive (so `is_interactive()` keeps reconcile from
1456/// settling it `exited` like a one-shot) + the FULL `claude_session_uuid` (the
1457/// resume key, finally populated here -- the field G1 added is set by the front
1458/// door). Pure so the row shape is asserted without a live spawn.
1459fn build_claude_stream_entry(
1460    name: &str,
1461    short_id: &str,
1462    cwd: &std::path::Path,
1463    uuid: &str,
1464    pid: u32,
1465    pid_start_time: Option<u64>,
1466    log_path: PathBuf,
1467) -> RegistryEntry {
1468    let cwd_s = cwd.to_string_lossy().into_owned();
1469    RegistryEntry {
1470        name: name.into(),
1471        short_id: short_id.into(),
1472        provider: "claude".into(),
1473        cwd: cwd_s.clone(),
1474        project_root: cwd_s,
1475        session_id: None,
1476        claude_short_id: None,
1477        claude_session_uuid: Some(uuid.into()),
1478        messaging_socket_path: None,
1479        codex_session_id: None,
1480        gemini_session_id: None,
1481        mcp_channel_id: None,
1482        cc_session_id: None,
1483        host_mode: Some(crate::state::HOST_MODE_INTERACTIVE.into()),
1484        status: AgentStatus::Live,
1485        last_message_at: Some(now_rfc3339_like()),
1486        created_at: now_rfc3339_like(),
1487        pid: Some(pid),
1488        pid_start_time,
1489        log_path: Some(log_path.to_string_lossy().into_owned()),
1490        last_reconciled_at: None,
1491    }
1492}
1493
1494/// Outcome of the pre-spawn single-writer claim acquisition.
1495enum ClaimOutcome {
1496    /// We hold `session:<uuid>` (fresh acquire or idempotent re-acquire).
1497    Acquired,
1498    /// Another live writer holds it; refuse to double-adopt (AC1-EDGE).
1499    HeldByOther(String),
1500    /// The claim substrate could not be consulted (no `fno` on PATH, exec error,
1501    /// unparseable output). Fail OPEN: the registry one-host re-check under the
1502    /// lock is the authoritative in-daemon guard; the file-claim is the
1503    /// cross-process coordination record, best-effort like the worker's release.
1504    Unavailable(String),
1505}
1506
1507/// Acquire the `session:<uuid>` single-writer claim before spawning the stream
1508/// worker (Locked Decision 5; the worker's `SessionClaimGuard` RELEASES it on
1509/// orphan/exit, so the daemon only acquires). Shells the Python `fno claim` CLI
1510/// -- the claim substrate is Python-only and cross-worktree -- exactly as the
1511/// worker's `release_session_claim` does. Inherits the daemon environment so the
1512/// claims root matches the worker's release root.
1513fn acquire_session_claim(uuid: &str, holder: &str) -> ClaimOutcome {
1514    let output = std::process::Command::new("fno")
1515        .args(claim_acquire_argv(uuid, holder))
1516        .stdin(std::process::Stdio::null())
1517        .output();
1518    match output {
1519        Err(e) => ClaimOutcome::Unavailable(format!("fno claim acquire failed to run: {e}")),
1520        Ok(o) if !o.status.success() => {
1521            // Non-zero exit is the CLI's `ClaimHeldByOther` (a live different
1522            // holder) or another claim error: refuse, naming the conflict.
1523            let detail = String::from_utf8_lossy(&o.stderr).trim().to_string();
1524            ClaimOutcome::HeldByOther(if detail.is_empty() {
1525                "held by another writer".into()
1526            } else {
1527                detail
1528            })
1529        }
1530        Ok(o) => {
1531            // Exit 0: confirm WE hold it. The CLI is idempotent and can recover a
1532            // stale (dead-holder) claim, so a returned holder != ours means a
1533            // different live writer owns it -> refuse.
1534            match serde_json::from_slice::<Value>(&o.stdout) {
1535                Ok(v) => match v.get("holder").and_then(Value::as_str) {
1536                    Some(h) if h == holder => ClaimOutcome::Acquired,
1537                    Some(other) => ClaimOutcome::HeldByOther(other.to_string()),
1538                    None => ClaimOutcome::Acquired,
1539                },
1540                // Exit 0 but no parseable JSON (older CLI without -J support):
1541                // treat as acquired; the registry one-host guard still protects us.
1542                Err(_) => ClaimOutcome::Acquired,
1543            }
1544        }
1545    }
1546}
1547
1548/// RAII release for the daemon-held single-writer claim. Armed when the daemon
1549/// acquires `session:<uuid>` before spawn; on Drop it releases the claim UNLESS
1550/// disarmed (the worker has taken ownership of the claim once the row is
1551/// registered `live` and owns its own RAII release). This means every
1552/// early-return failure path releases exactly once with no manual call (gemini
1553/// review HIGH: prefer RAII over scattered manual releases), and the release is
1554/// fire-and-forget (`spawn`, not `status`) so it never blocks the async executor
1555/// (gemini review HIGH: no sync `Command` wait in async). The daemon's idle-tick
1556/// reaper waitpid-sweeps the detached child.
1557struct DaemonClaimGuard {
1558    session_uuid: String,
1559    holder: String,
1560    armed: bool,
1561}
1562
1563impl DaemonClaimGuard {
1564    /// The worker now owns the claim (registered live); the daemon must not
1565    /// release it on drop. Consumes the guard so it cannot fire afterward.
1566    fn disarm(mut self) {
1567        self.armed = false;
1568    }
1569}
1570
1571impl Drop for DaemonClaimGuard {
1572    fn drop(&mut self) {
1573        if !self.armed {
1574            return;
1575        }
1576        // Best-effort, non-blocking: a non-zero exit / missing `fno` is ignored
1577        // (the claim's PID-liveness + reconcile are the backstops). AC1-ERR: a
1578        // failed adopt must release any claim it acquired.
1579        let _ = std::process::Command::new("fno")
1580            .args([
1581                "claim",
1582                "release",
1583                &format!("session:{}", self.session_uuid),
1584                "--holder",
1585                &self.holder,
1586            ])
1587            .stdin(std::process::Stdio::null())
1588            .stdout(std::process::Stdio::null())
1589            .stderr(std::process::Stdio::null())
1590            .spawn();
1591    }
1592}
1593
1594/// Does the stream worker at `sock` report its `claude -p --resume` child ALIVE?
1595/// A dead-on-arrival resume (bad/expired UUID, auth failure) exits immediately,
1596/// yet the worker still binds its socket and answers `stream.ping`; querying
1597/// `stream.status.child_alive` (backed by `try_wait`) distinguishes "worker up +
1598/// child live" from "worker up + child already exited", so a DOA adopt is
1599/// rejected instead of registered `live` (AC1-ERR; codex review P2). Bounded so a
1600/// wedged worker never hangs the daemon; a timeout reads as not-alive.
1601async fn stream_worker_reports_child_alive(sock: &std::path::Path) -> bool {
1602    let probe = async {
1603        let mut conn = UnixStream::connect(sock).await.ok()?;
1604        write_request(&mut conn, &Request::new(1, "stream.status", json!({})))
1605            .await
1606            .ok()?;
1607        let resp = crate::protocol::read_response(&mut conn).await.ok()?;
1608        Some(
1609            resp.result()
1610                .and_then(|r| r.get("child_alive"))
1611                .and_then(Value::as_bool)
1612                .unwrap_or(false),
1613        )
1614    };
1615    matches!(
1616        tokio::time::timeout(Duration::from_secs(STREAM_PROBE_TIMEOUT_S), probe).await,
1617        Ok(Some(true))
1618    )
1619}
1620
1621/// Spawn (adopt) a claude session as a held stream-json thread under the daemon
1622/// (Task 5.1). This is the claude analog of the codex/gemini PTY promote path in
1623/// `handle_spawn`: validate -> single-writer guard -> spawn the per-session
1624/// worker (Outcome B: own process group, detached) -> confirm it serves the
1625/// stream protocol -> register `live`. The worker resumes the FULL session UUID
1626/// (`claude -p --resume`); readiness is the worker answering `stream.ping`
1627/// (Locked Decision 9: a stream-json session emits nothing until the first turn,
1628/// so we never wait for a spontaneous `init` event).
1629async fn spawn_claude_stream_lane(
1630    ctx: &Ctx,
1631    req: &Request,
1632    name: &str,
1633    cwd: &std::path::Path,
1634    resume_id: Option<&str>,
1635    explicit_argv: Option<Vec<String>>,
1636) -> Response {
1637    // 1. Adoption requires a resume target. A fresh `host --provider claude`
1638    //    (no --from) has nothing to resume; point the user at the adopt verb.
1639    let uuid = match resume_id {
1640        Some(u) if !u.trim().is_empty() => u,
1641        _ => {
1642            let _ = ctx.emitter.emit(
1643                "agent_spawn_failed",
1644                &json!({"name": name, "reason": "claude_host_needs_from"}),
1645            );
1646            return Response::err(
1647                req.id,
1648                ErrorCode::InvalidParams,
1649                "claude has no fresh interactive host; adopt an idle session: `fno agents promote <name> --from <session-uuid> --provider claude`",
1650            );
1651        }
1652    };
1653
1654    // 2. Lock-free pre-checks for clean messages (the authoritative re-checks run
1655    //    atomically under the registry lock at registration).
1656    let registry = load_registry_offloaded(ctx.home.registry_json()).await;
1657    if let Some(existing) = registry.find(name) {
1658        return Response::err(
1659            req.id,
1660            ErrorCode::AgentExists,
1661            format!(
1662                "agent {name} already exists (short_id={}); use `fno agents rm` first",
1663                existing.short_id
1664            ),
1665        );
1666    }
1667    // Single-writer one-host pre-check: refuse a second adopt of the same session
1668    // (AC1-EDGE). Matches a LIVE claude row already carrying this UUID; an
1669    // orphaned/exited row (dead child, claim released) is re-adoptable (AC1-FR).
1670    if let Some(h) = registry.entries.iter().rev().find(|e| {
1671        e.provider == "claude"
1672            && e.claude_session_uuid.as_deref() == Some(uuid)
1673            && is_live_writer(e.status)
1674    }) {
1675        return Response::err(
1676            req.id,
1677            ErrorCode::InvalidParams,
1678            format!(
1679                "session '{uuid}' is already hosted by live stream thread '{}'; one writer per session",
1680                h.name
1681            ),
1682        );
1683    }
1684    let short_id = derive_short_id(name, &registry);
1685    let holder = stream_claim_holder(&short_id);
1686
1687    // 3. Acquire the single-writer claim BEFORE spawning (Locked Decision 5). A
1688    //    clear held-by-other refusal aborts; an unavailable substrate fails open
1689    //    (the registry one-host re-check below is the authoritative in-daemon
1690    //    guard). Run on the blocking pool: `fno` is a short-lived subprocess.
1691    let uuid_owned = uuid.to_string();
1692    let holder_for_acq = holder.clone();
1693    let claim_outcome =
1694        tokio::task::spawn_blocking(move || acquire_session_claim(&uuid_owned, &holder_for_acq))
1695            .await
1696            .unwrap_or_else(|e| ClaimOutcome::Unavailable(format!("claim task panicked: {e}")));
1697    // The guard releases the claim on EVERY early return below until it is
1698    // disarmed at successful registration (the worker then owns the claim).
1699    let claim_guard = match claim_outcome {
1700        ClaimOutcome::Acquired => DaemonClaimGuard {
1701            session_uuid: uuid.to_string(),
1702            holder: holder.clone(),
1703            armed: true,
1704        },
1705        ClaimOutcome::HeldByOther(who) => {
1706            let _ = ctx.emitter.emit(
1707                "agent_spawn_failed",
1708                &json!({"name": name, "reason": "session_claimed", "detail": who}),
1709            );
1710            return Response::err(
1711                req.id,
1712                ErrorCode::InvalidParams,
1713                format!(
1714                    "session '{uuid}' is held by another writer ({who}); refusing to double-adopt"
1715                ),
1716            );
1717        }
1718        ClaimOutcome::Unavailable(why) => {
1719            let _ = ctx.emitter.emit(
1720                "agent_stream_claim_unavailable",
1721                &json!({"name": name, "session_uuid": uuid, "detail": why}),
1722            );
1723            // Nothing to release (we never acquired); a disarmed guard keeps the
1724            // rest of the function uniform.
1725            DaemonClaimGuard {
1726                session_uuid: uuid.to_string(),
1727                holder: holder.clone(),
1728                armed: false,
1729            }
1730        }
1731    };
1732
1733    // 4. Build the child argv and spawn the per-session stream worker in its own
1734    //    process group (Outcome B: survives a kill of the daemon's group). The
1735    //    explicit-argv escape hatch lets tests substitute a fake stream emitter so
1736    //    CI never spawns a real `claude -p` (Test discipline / Locked Decision 1).
1737    let child_argv =
1738        explicit_argv.unwrap_or_else(|| crate::provider::claude_stream_json_resume_argv(uuid));
1739    let worker_args =
1740        claude_stream_worker_args(&short_id, ctx.home.root(), cwd, uuid, &holder, &child_argv);
1741    let mut cmd = std::process::Command::new(&ctx.opts.worker_bin);
1742    cmd.args(&worker_args);
1743    cmd.process_group(0);
1744    let child = match cmd.spawn() {
1745        Ok(c) => c,
1746        Err(e) => {
1747            // claim_guard releases on return.
1748            let _ = ctx.emitter.emit(
1749                "agent_spawn_failed",
1750                &json!({"name": name, "reason": "binary_not_found", "detail": e.to_string()}),
1751            );
1752            return Response::err(
1753                req.id,
1754                ErrorCode::SpawnFailed,
1755                format!("could not launch stream worker: {e}"),
1756            );
1757        }
1758    };
1759    let worker_pid = child.id();
1760    let worker_pid_start_time = process_start_time(worker_pid);
1761    drop(child);
1762
1763    // 5. Wait (bounded) for the worker socket to appear, proving the worker bound.
1764    let sock = ctx.home.worker_sock(&short_id);
1765    let start = Instant::now();
1766    while !sock.exists() && start.elapsed() < Duration::from_secs(10) {
1767        tokio::time::sleep(Duration::from_millis(25)).await;
1768    }
1769    if !sock.exists() {
1770        // claim_guard releases on return.
1771        let _ = ctx.emitter.emit(
1772            "agent_create_no_session",
1773            &json!({"name": name, "short_id": short_id, "lane": "stream"}),
1774        );
1775        return Response::err(
1776            req.id,
1777            ErrorCode::SpawnFailed,
1778            "stream worker did not come up within 10s",
1779        );
1780    }
1781
1782    // 6. Confirm the worker actually serves the stream protocol (a `stream.ping`
1783    //    answer). This is the readiness proof (LD9: drive-a-turn, not wait-for-init
1784    //    -- the ping is the cheapest drive that confirms the worker, without
1785    //    spending a real turn). A bound-but-wrong worker fails here, not `live`.
1786    if !is_live_stream_thread(&sock).await {
1787        best_effort_worker_shutdown(&sock).await;
1788        let _ = ctx.emitter.emit(
1789            "agent_create_no_session",
1790            &json!({"name": name, "short_id": short_id, "reason": "not_a_stream_thread"}),
1791        );
1792        return Response::err(
1793            req.id,
1794            ErrorCode::SpawnFailed,
1795            "stream worker came up but does not serve the stream protocol",
1796        );
1797    }
1798
1799    // 6b. Confirm the resumed child is ALIVE before registering live (AC1-ERR;
1800    //     codex review P2). A dead-on-arrival `claude -p --resume` (bad/expired
1801    //     UUID, auth failure) exits immediately but the worker still binds its
1802    //     socket and answers `stream.ping`; `stream.status.child_alive` (try_wait)
1803    //     catches it so the adopt is rejected, not registered live then silently
1804    //     orphaned.
1805    if !stream_worker_reports_child_alive(&sock).await {
1806        best_effort_worker_shutdown(&sock).await;
1807        let _ = ctx.emitter.emit(
1808            "agent_create_no_session",
1809            &json!({"name": name, "short_id": short_id, "reason": "resume_child_exited"}),
1810        );
1811        return Response::err(
1812            req.id,
1813            ErrorCode::SpawnFailed,
1814            "claude --resume child exited before adoption (bad/expired session id, auth failure, or dead cwd)",
1815        );
1816    }
1817
1818    // 7. Register under the exclusive registry lock. Two concurrent adopts can
1819    //    both pass the lock-free checks above; the locked re-check (name + the
1820    //    one-host UUID guard) means exactly one inserts. The loser shuts its
1821    //    just-started worker down (which releases the claim via the worker's RAII
1822    //    guard) so it is never leaked untracked.
1823    let entry = build_claude_stream_entry(
1824        name,
1825        &short_id,
1826        cwd,
1827        uuid,
1828        worker_pid,
1829        worker_pid_start_time,
1830        ctx.home.timeline_jsonl(&short_id),
1831    );
1832    let uuid_for_lock = uuid.to_string();
1833    let insert = update_registry_offloaded(ctx.home.registry_json(), move |r| {
1834        if r.entries.iter().any(|e| e.name == entry.name) {
1835            return false;
1836        }
1837        if r.entries.iter().any(|e| {
1838            e.provider == "claude"
1839                && e.claude_session_uuid.as_deref() == Some(&uuid_for_lock)
1840                && is_live_writer(e.status)
1841        }) {
1842            return false;
1843        }
1844        r.entries.push(entry);
1845        true
1846    })
1847    .await;
1848    match insert {
1849        Ok(true) => {}
1850        Ok(false) => {
1851            best_effort_worker_shutdown(&sock).await;
1852            let _ = ctx.emitter.emit(
1853                "agent_spawn_failed",
1854                &json!({"name": name, "short_id": short_id, "reason": "session_taken_concurrent"}),
1855            );
1856            return Response::err(
1857                req.id,
1858                ErrorCode::AgentExists,
1859                format!("session '{uuid}' was adopted by a concurrent call; this one refused"),
1860            );
1861        }
1862        Err(e) => {
1863            best_effort_worker_shutdown(&sock).await;
1864            let _ = ctx.emitter.emit(
1865                "agent_spawn_failed",
1866                &json!({"name": name, "short_id": short_id, "reason": "registry_write_failed"}),
1867            );
1868            return Response::err(req.id, ErrorCode::Internal, format!("registry write: {e}"));
1869        }
1870    }
1871    // Registered live: the worker now owns the claim (its own SessionClaimGuard
1872    // releases it on orphan/exit), so the daemon must not release on drop.
1873    claim_guard.disarm();
1874    let _ = ctx.emitter.emit(
1875        "agent_spawned",
1876        &json!({"name": name, "provider": "claude", "short_id": short_id, "lane": "stream", "session_uuid": uuid}),
1877    );
1878
1879    Response::ok(
1880        req.id,
1881        json!({"short_id": short_id, "provider": "claude", "status": "live", "lane": "stream"}),
1882    )
1883}
1884
1885/// Map a provider name string to a per-CLI readiness detector.
1886///
1887/// NOTE: This is a local match rather than routing through `Box<dyn Provider>`
1888/// because the provider trait impls live in `provider.rs` with no `from_str`
1889/// constructor. A full resolver is the right long-term home (LD8); for now the
1890/// match is the surgical minimum that unblocks Task 1.1 without touching
1891/// provider.rs.
1892fn provider_readiness_detector(provider: &str) -> Box<dyn crate::readiness::ReadinessDetector> {
1893    use crate::provider::ProviderWithPty as _;
1894    match provider {
1895        "codex" => crate::provider::CodexProvider.readiness_detector(),
1896        "gemini" => crate::provider::GeminiProvider.readiness_detector(),
1897        // Carry the real provider name so the UnknownReadinessSignal error and
1898        // provider_name() name the actual CLI (e.g. "opencode") rather than the
1899        // literal "unknown" (cv-789fdba0).
1900        other => Box::new(crate::readiness::NoSignalDetector {
1901            provider: other.to_string(),
1902        }),
1903    }
1904}
1905
1906/// Resolve a provider name to its `ProviderWithPty` implementation (Task 4.1).
1907///
1908/// Returns `Some` for PTY-managed providers (codex, gemini); `None` for
1909/// shellout-only providers (claude) and unknown names. The same match structure
1910/// as `provider_readiness_detector` keeps the two in sync without introducing a
1911/// runtime registry.
1912fn provider_for_pty(provider: &str) -> Option<Box<dyn crate::provider::ProviderWithPty>> {
1913    match provider {
1914        "codex" => Some(Box::new(crate::provider::CodexProvider)),
1915        "gemini" => Some(Box::new(crate::provider::GeminiProvider)),
1916        // claude uses a shellout path (as_pty() -> None); not supported here.
1917        _ => None,
1918    }
1919}
1920
1921/// Poll the worker snapshot in a bounded loop until the per-provider readiness
1922/// detector reports the CLI is idle at a prompt, then return the settled screen
1923/// text. Returns `Err(String)` on timeout.
1924///
1925/// Each iteration feeds a FRESH `TerminalGrid` from the full snapshot string
1926/// (the snapshot is the whole current screen, not a delta) so the grid reflects
1927/// the current state without accumulated duplicates.
1928///
1929/// # Path choice (b) note
1930/// The worker's `worker.snapshot` RPC returns `text: String` (the lossy UTF-8
1931/// decoding of the PTY ring). Feeding `text.as_bytes()` back into a
1932/// `TerminalGrid` is slightly redundant for plain ASCII output but is correct
1933/// for all vt100-renderable content: the vt100 parser re-interprets the
1934/// decoded bytes. The alternative (adding a `raw_bytes_b64` field to the
1935/// snapshot RPC) was considered but would require a worker.rs protocol change;
1936/// given that the readiness detectors only examine prompt-glyph patterns on the
1937/// visible text, the lossy path is sufficient.
1938/// Failure modes of [`poll_until_ready`]. Distinguishes a CLI that never settled
1939/// within the budget from a worker whose snapshot read itself hung, so the daemon
1940/// (and anyone reading the ask error) can tell "slow CLI" from "stuck worker"
1941/// instead of two indistinguishable `String`s (cv-789fdba0). Display output is
1942/// byte-identical to the prior inline format strings.
1943#[derive(Debug, PartialEq, Eq)]
1944enum PollError {
1945    /// The readiness detector never reported ready before the deadline.
1946    Timeout { secs: u64 },
1947    /// A single worker-snapshot fetch did not return before the deadline.
1948    WorkerUnresponsive { secs: u64 },
1949}
1950
1951impl std::fmt::Display for PollError {
1952    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1953        match self {
1954            PollError::Timeout { secs } => {
1955                write!(f, "ask timed out after {secs}s before reply settled")
1956            }
1957            PollError::WorkerUnresponsive { secs } => write!(
1958                f,
1959                "ask timed out after {secs}s before reply settled (worker snapshot read did not return)"
1960            ),
1961        }
1962    }
1963}
1964
1965async fn poll_until_ready<F, Fut>(
1966    fetcher: F,
1967    detector: Box<dyn crate::readiness::ReadinessDetector>,
1968    poll_interval: Duration,
1969    timeout: Duration,
1970) -> Result<String, PollError>
1971where
1972    F: Fn() -> Fut,
1973    Fut: std::future::Future<Output = Option<String>>,
1974{
1975    let deadline = tokio::time::Instant::now() + timeout;
1976    loop {
1977        let now = tokio::time::Instant::now();
1978        if now >= deadline {
1979            return Err(PollError::Timeout {
1980                secs: timeout.as_secs(),
1981            });
1982        }
1983        // Bound the snapshot fetch by the remaining time to the deadline.
1984        // fetcher() performs socket I/O to the worker; without this cap a hung
1985        // or deadlocked worker would block the daemon indefinitely, since the
1986        // deadline check above only runs between iterations (gemini-code-assist
1987        // security-critical on PR #361). A per-fetch timeout converts a hung
1988        // read into the same bounded "ask timed out" error as a slow CLI.
1989        let remaining = deadline.saturating_duration_since(now);
1990        let fetched = match tokio::time::timeout(remaining, fetcher()).await {
1991            Ok(opt) => opt,
1992            Err(_) => {
1993                return Err(PollError::WorkerUnresponsive {
1994                    secs: timeout.as_secs(),
1995                })
1996            }
1997        };
1998        if let Some(text) = fetched {
1999            // Fresh grid each iteration: the snapshot is the full current screen.
2000            let mut grid = crate::screen::TerminalGrid::with_default_size();
2001            grid.feed(text.as_bytes());
2002            let owned = grid.snapshot();
2003            let view = owned.view();
2004            match detector.is_ready(&view) {
2005                Ok(true) => return Ok(owned.text),
2006                Ok(false) | Err(_) => {} // not ready yet; Err treated as not-ready (Open Question #9 discipline)
2007            }
2008        }
2009        tokio::time::sleep(poll_interval).await;
2010    }
2011}
2012
2013async fn handle_ask(ctx: &Ctx, req: &Request) -> Response {
2014    let name = match req.params.get("name").and_then(|v| v.as_str()) {
2015        Some(n) => n.to_string(),
2016        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `name`"),
2017    };
2018    let message = req
2019        .params
2020        .get("message")
2021        .and_then(|v| v.as_str())
2022        .unwrap_or("")
2023        .to_string();
2024
2025    let provider_param = req
2026        .params
2027        .get("provider")
2028        .and_then(|v| v.as_str())
2029        .map(String::from);
2030    let cwd_param = req
2031        .params
2032        .get("cwd")
2033        .and_then(|v| v.as_str())
2034        .map(PathBuf::from);
2035    let from_name_param = req
2036        .params
2037        .get("from_name")
2038        .and_then(|v| v.as_str())
2039        .map(String::from);
2040    let yolo_param = req
2041        .params
2042        .get("yolo")
2043        .and_then(|v| v.as_bool())
2044        .unwrap_or(false);
2045
2046    let registry = load_registry_offloaded(ctx.home.registry_json()).await;
2047    let entry = match registry.find(&name) {
2048        Some(e) => e.clone(),
2049        None => {
2050            // First contact: auto-spawn if --provider supplied (create-on-first-contact,
2051            // matching Python cmd_ask semantics). No provider = actionable error.
2052            let provider = match provider_param {
2053                Some(p) => p,
2054                None => {
2055                    return Response::err(
2056                        req.id,
2057                        ErrorCode::InvalidParams,
2058                        format!(
2059                        "agent '{name}' not found; pass --provider to create it on first contact"
2060                    ),
2061                    )
2062                }
2063            };
2064            // See handle_spawn: the daemon's own cwd is not the caller's, so
2065            // fall back to a neutral temp dir rather than its start dir, and
2066            // emit so a /tmp launch is greppable. A well-behaved client
2067            // forwards cwd (client.rs ensure_request_cwd).
2068            let spawn_cwd = match cwd_param {
2069                Some(c) => c,
2070                None => {
2071                    let fallback = std::env::temp_dir();
2072                    let _ = ctx.emitter.emit(
2073                        "agent_spawn_cwd_fallback",
2074                        &json!({
2075                            "name": name,
2076                            "fallback": fallback.to_string_lossy(),
2077                            "via": "ask_first_contact",
2078                        }),
2079                    );
2080                    fallback
2081                }
2082            };
2083            // Build a synthetic spawn request and delegate to handle_spawn.
2084            let mut spawn_params = serde_json::Map::new();
2085            spawn_params.insert("name".into(), serde_json::Value::String(name.clone()));
2086            spawn_params.insert("provider".into(), serde_json::Value::String(provider));
2087            spawn_params.insert(
2088                "cwd".into(),
2089                serde_json::Value::String(spawn_cwd.to_str().unwrap_or(".").to_string()),
2090            );
2091            spawn_params.insert("message".into(), serde_json::Value::String(message.clone()));
2092            if let Some(ref fn_val) = from_name_param {
2093                spawn_params.insert(
2094                    "from_name".into(),
2095                    serde_json::Value::String(fn_val.clone()),
2096                );
2097            }
2098            if yolo_param {
2099                spawn_params.insert("yolo".into(), serde_json::Value::Bool(true));
2100            }
2101            let spawn_req = Request::new(
2102                req.id,
2103                "agent.spawn",
2104                serde_json::Value::Object(spawn_params),
2105            );
2106            let spawn_resp = handle_spawn(ctx, &spawn_req).await;
2107            return match spawn_resp.payload {
2108                crate::protocol::ResponsePayload::Ok(ref result) => {
2109                    let short_id = result
2110                        .get("short_id")
2111                        .and_then(|v| v.as_str())
2112                        .unwrap_or("")
2113                        .to_string();
2114                    Response::ok(req.id, json!({"created": true, "short_id": short_id}))
2115                }
2116                crate::protocol::ResponsePayload::Err(_) => spawn_resp,
2117            };
2118        }
2119    };
2120    if entry.status == AgentStatus::Orphaned {
2121        return Response::err(
2122            req.id,
2123            ErrorCode::InvalidStatus,
2124            format!("agent {name} is orphaned; use `fno agents reconcile` or `rm`"),
2125        );
2126    }
2127
2128    let sock = ctx.home.worker_sock(&entry.short_id);
2129    let mut conn = match UnixStream::connect(&sock).await {
2130        Ok(c) => c,
2131        Err(_) => {
2132            return Response::err(
2133                req.id,
2134                ErrorCode::InvalidStatus,
2135                format!("worker for {name} is not reachable"),
2136            )
2137        }
2138    };
2139
2140    // Send the message to the PTY stdin. The provider envelope wrapping for the
2141    // non-Claude PTY paths is applied by the verb's full wiring (Wave 5/6); the
2142    // Wave 3 daemon forwards the raw line so the transport is exercised.
2143    let mut payload = message.clone();
2144    if !payload.ends_with('\n') {
2145        payload.push('\n');
2146    }
2147    if write_request(
2148        &mut conn,
2149        &Request::new(1, "worker.write", json!({"data": payload})),
2150    )
2151    .await
2152    .is_err()
2153    {
2154        return Response::err(req.id, ErrorCode::Internal, "worker write failed");
2155    }
2156    // Inspect the worker's write-ack: an error response (e.g. PTY writer fault)
2157    // must surface to the caller, not be reported as a successful ask with an
2158    // empty reply (silent-failure #4).
2159    match crate::protocol::read_response(&mut conn).await {
2160        Ok(ack) if ack.is_err() => {
2161            let msg = ack
2162                .error()
2163                .map(|e| e.message.clone())
2164                .unwrap_or_else(|| "worker rejected the write".into());
2165            return Response::err(req.id, ErrorCode::Internal, msg);
2166        }
2167        Ok(_) => {}
2168        Err(_) => {
2169            return Response::err(req.id, ErrorCode::Internal, "no write-ack from worker");
2170        }
2171    }
2172
2173    // Poll the worker snapshot through the per-provider readiness detector until
2174    // the CLI is idle at a prompt (settled reply), then return it. This replaces
2175    // the Wave 3 fixed 150 ms snapshot baseline (Task 1.1).
2176    let timeout_secs = req
2177        .params
2178        .get("timeout")
2179        .and_then(|v| v.as_u64())
2180        .unwrap_or(600);
2181    let detector = provider_readiness_detector(&entry.provider);
2182    let sock_path = sock.clone();
2183    let fetcher = move || {
2184        let p = sock_path.clone();
2185        async move { read_worker_snapshot(&p).await }
2186    };
2187    let reply = match poll_until_ready(
2188        fetcher,
2189        detector,
2190        Duration::from_millis(200),
2191        Duration::from_secs(timeout_secs),
2192    )
2193    .await
2194    {
2195        Ok(text) => text,
2196        Err(e) => {
2197            return Response::err(req.id, ErrorCode::Internal, e.to_string());
2198        }
2199    };
2200
2201    let ask_name = name.clone();
2202    let _ = update_registry_offloaded(ctx.home.registry_json(), move |r| {
2203        if let Some(e) = r.find_mut(&ask_name) {
2204            e.last_message_at = Some(now_rfc3339_like());
2205        }
2206    })
2207    .await;
2208    let _ = ctx
2209        .emitter
2210        .emit("agent_ask_done", &json!({"name": name, "backend": "pty"}));
2211
2212    Response::ok(req.id, json!({"reply": reply, "backend": "pty"}))
2213}
2214
2215// ---------------------------------------------------------------------------
2216// Shared PTY inject primitive (Task 2.2 / US4)
2217// ---------------------------------------------------------------------------
2218
2219/// Per-provider injection gate decision.
2220///
2221/// Before live PTY injection is enabled for a provider, an empirical check must
2222/// confirm the TUI queues mid-turn type+Enter without interrupting a running
2223/// turn. This enum models the outcome.
2224///
2225/// Per-provider injection gate decision.
2226///
2227/// The gate file (`injection-gate.json`) is the authoritative record; the
2228/// thread-local override supersedes it in tests only.
2229///
2230/// Conservative invariant: absent file / absent provider / malformed JSON all
2231/// map to `Unverified` — NEVER default to `Passed`.
2232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2233pub enum InjectionGate {
2234    /// Gate passed: PTY injection is safe for this provider.
2235    Passed,
2236    /// Empirical check ran and the provider TUI failed (interrupts mid-turn,
2237    /// concatenates, or otherwise misbehaves). Demotion uses a distinct reason
2238    /// so the event trail distinguishes "empirically failed" from "never checked".
2239    Failed,
2240    /// Gate not yet verified: demotion to durable is required.
2241    Unverified,
2242}
2243
2244// Thread-local override for tests. When set, any provider whose name appears
2245// in the comma-separated string is treated as Passed.  Thread-local means
2246// parallel tests cannot interfere with each other (unlike process-wide env vars).
2247std::thread_local! {
2248    static INJECTION_GATE_ALLOW: std::cell::RefCell<Option<String>> =
2249        const { std::cell::RefCell::new(None) };
2250}
2251
2252/// Query the injection gate for `provider` using the resolved `AgentsHome`.
2253///
2254/// Resolution order:
2255/// 1. Thread-local override (tests only) — if set, Passed beats any file record.
2256/// 2. `injection-gate.json` next to `registry.json` in `home`.
2257/// 3. Absent/malformed file or absent provider -> Unverified.
2258pub fn injection_gate_with_home(provider: &str, home: &AgentsHome) -> InjectionGate {
2259    // 1. Thread-local override takes precedence.
2260    let mut thread_allow = false;
2261    INJECTION_GATE_ALLOW.with(|cell| {
2262        if let Some(ref allow) = *cell.borrow() {
2263            for p in allow.split(',') {
2264                if p.trim() == provider {
2265                    thread_allow = true;
2266                }
2267            }
2268        }
2269    });
2270    if thread_allow {
2271        return InjectionGate::Passed;
2272    }
2273
2274    // 2. Read the gate file.
2275    let path = home.injection_gate_json();
2276    let text = match std::fs::read_to_string(&path) {
2277        Ok(t) => t,
2278        Err(_) => return InjectionGate::Unverified, // absent file -> Unverified
2279    };
2280    let doc: serde_json::Value = match serde_json::from_str(&text) {
2281        Ok(v) => v,
2282        Err(_) => return InjectionGate::Unverified, // malformed -> Unverified
2283    };
2284    // Unknown schema version -> conservative Unverified (never trust a format
2285    // we cannot validate).
2286    if doc.get("v").and_then(|v| v.as_u64()) != Some(1) {
2287        return InjectionGate::Unverified;
2288    }
2289    let status = doc
2290        .get("providers")
2291        .and_then(|p| p.get(provider))
2292        .and_then(|e| e.get("status"))
2293        .and_then(|s| s.as_str());
2294    match status {
2295        Some("passed") => InjectionGate::Passed,
2296        Some("failed") => InjectionGate::Failed,
2297        _ => InjectionGate::Unverified, // absent provider or unknown status -> Unverified
2298    }
2299}
2300
2301/// Query the injection gate for `provider`, resolving the home from the
2302/// environment. Use `injection_gate_with_home` when the home is already available.
2303pub fn injection_gate(provider: &str) -> InjectionGate {
2304    injection_gate_with_home(provider, &AgentsHome::from_env())
2305}
2306
2307/// Maximum body size (bytes) accepted by `inject_into_pty`. Mirrors
2308/// `MAX_FRAME_BYTES` from the protocol layer; an oversized body would produce
2309/// a worker-write frame too large for the framing layer to accept.
2310const MAX_INJECT_BODY_BYTES: usize = 16 * 1024 * 1024;
2311
2312/// Wrap `body` in the provenance container and write it into `worker_sock`
2313/// using bracketed paste so multi-line bodies land as one message.
2314///
2315/// The container format mirrors `_build_envelope` in
2316/// `cli/src/fno/agents/providers/claude.py`:
2317///
2318/// ```text
2319/// <cross-session-message from-name="<escaped>">
2320/// <body>
2321/// </cross-session-message>
2322/// ```
2323///
2324/// This identifies the sender as a PEER (anti-injection framing) rather than
2325/// as the operator, consistent with the socket-path delivery used for claude.
2326///
2327/// The bytes written to the PTY are:
2328///
2329///   ESC[200~  <container text>  ESC[201~  CR
2330///
2331/// Bracketed paste (RFC 2119 MUST per AC4-EDGE) ensures a multi-line body is
2332/// delivered as a single message and not submitted line-by-line.
2333///
2334/// Returns `Ok(())` on success or an error string for the caller to surface.
2335async fn inject_into_pty(
2336    worker_sock: &std::path::Path,
2337    body: &str,
2338    from_name: &str,
2339) -> Result<(), String> {
2340    // Body size guard: refuse before touching the socket.
2341    if body.len() > MAX_INJECT_BODY_BYTES {
2342        return Err(format!(
2343            "body too large: {} bytes > {MAX_INJECT_BODY_BYTES}",
2344            body.len()
2345        ));
2346    }
2347
2348    // Build the provenance container (matching claude.py _build_envelope shape
2349    // but for PTY delivery: no JSON envelope wrapper, just the XML tag).
2350    let safe_from = xml_attr_escape(from_name);
2351    let container = format!(
2352        "<cross-session-message from-name=\"{safe_from}\">\n{body}\n</cross-session-message>"
2353    );
2354
2355    // Bracketed paste: ESC[200~ <payload> ESC[201~ CR
2356    let mut frame: Vec<u8> = Vec::new();
2357    frame.extend_from_slice(b"\x1b[200~");
2358    frame.extend_from_slice(container.as_bytes());
2359    frame.extend_from_slice(b"\x1b[201~");
2360    frame.push(b'\r'); // single CR to submit after paste guard closes
2361
2362    // Connect to the worker socket and write via the bytes_b64 path (drive
2363    // shape) so arbitrary control sequences survive JSON encoding.
2364    let mut conn = UnixStream::connect(worker_sock)
2365        .await
2366        .map_err(|e| format!("worker unreachable: {e}"))?;
2367
2368    let b64 = base64::engine::general_purpose::STANDARD.encode(&frame);
2369    if write_request(
2370        &mut conn,
2371        &Request::new(1, "worker.write", json!({"bytes_b64": b64})),
2372    )
2373    .await
2374    .is_err()
2375    {
2376        return Err("worker write request failed".into());
2377    }
2378
2379    match crate::protocol::read_response(&mut conn).await {
2380        Ok(ack) if ack.is_err() => Err(format!(
2381            "worker rejected inject: {}",
2382            ack.error().map(|e| e.message.as_str()).unwrap_or("?")
2383        )),
2384        Ok(_) => Ok(()),
2385        Err(e) => Err(format!("no ack from worker: {e}")),
2386    }
2387}
2388
2389/// Minimal XML attribute escaping for the provenance container from-name.
2390/// Escapes `&`, `"`, `<`, `>` to their XML entity references.
2391fn xml_attr_escape(s: &str) -> String {
2392    let mut out = String::with_capacity(s.len());
2393    for ch in s.chars() {
2394        match ch {
2395            '&' => out.push_str("&amp;"),
2396            '"' => out.push_str("&quot;"),
2397            '<' => out.push_str("&lt;"),
2398            '>' => out.push_str("&gt;"),
2399            '\'' => out.push_str("&#x27;"),
2400            c => out.push(c),
2401        }
2402    }
2403    out
2404}
2405
2406// ---------------------------------------------------------------------------
2407// handle_deliver (agent.deliver RPC)
2408// ---------------------------------------------------------------------------
2409
2410/// Handle the `agent.deliver` RPC.
2411///
2412/// Params: `{name: string, body: string, from_name: string}`
2413///
2414/// Result (always Ok unless name unknown or params invalid):
2415/// - `{delivered: true, transport: "pty"}` - PTY inject succeeded.
2416/// - `{delivered: false, reason: "claude-routes-via-socket"}` - claude peer;
2417///   caller routes via socket/MCP (Locked Decision 9).
2418/// - `{delivered: false, reason: "injection-gate-unverified"}` - gate not
2419///   passed for this provider; caller demotes to durable.
2420/// - `{delivered: false, reason: "worker-unreachable"}` - socket connect or
2421///   write failed; durable envelope the caller already wrote is the record.
2422///
2423/// Errors (RPC error, not Ok):
2424/// - `AgentNotFound` - unknown name.
2425/// - `InvalidParams` - missing/invalid params.
2426async fn handle_deliver(ctx: &Ctx, req: &Request) -> Response {
2427    let name = match req.params.get("name").and_then(|v| v.as_str()) {
2428        Some(n) => n.to_string(),
2429        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `name`"),
2430    };
2431    let body = match req.params.get("body").and_then(|v| v.as_str()) {
2432        Some(b) => b.to_string(),
2433        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `body`"),
2434    };
2435    let from_name = req
2436        .params
2437        .get("from_name")
2438        .and_then(|v| v.as_str())
2439        .unwrap_or("unknown")
2440        .to_string();
2441
2442    // Body size guard before any registry I/O.
2443    if body.len() > MAX_INJECT_BODY_BYTES {
2444        return Response::err(
2445            req.id,
2446            ErrorCode::InvalidParams,
2447            format!(
2448                "body too large: {} bytes > {MAX_INJECT_BODY_BYTES}",
2449                body.len()
2450            ),
2451        );
2452    }
2453
2454    let registry = load_registry_offloaded(ctx.home.registry_json()).await;
2455    let entry = match registry.find(&name) {
2456        Some(e) => e.clone(),
2457        None => {
2458            return Response::err(
2459                req.id,
2460                ErrorCode::AgentNotFound,
2461                format!("agent '{name}' not found"),
2462            )
2463        }
2464    };
2465
2466    // Locked Decision 9: claude peers route via socket/MCP, never PTY.
2467    if entry.provider == "claude" {
2468        return Response::ok(
2469            req.id,
2470            json!({"delivered": false, "reason": "claude-routes-via-socket"}),
2471        );
2472    }
2473
2474    // Per-provider injection gate (reads injection-gate.json).
2475    match injection_gate_with_home(&entry.provider, &ctx.home) {
2476        InjectionGate::Passed => { /* proceed */ }
2477        InjectionGate::Failed => {
2478            let _ = ctx.emitter.emit(
2479                "agent_deliver_demoted",
2480                &json!({
2481                    "name": name,
2482                    "from_name": from_name,
2483                    "provider": entry.provider,
2484                    "reason": "injection-gate-failed",
2485                }),
2486            );
2487            return Response::ok(
2488                req.id,
2489                json!({"delivered": false, "reason": "injection-gate-failed"}),
2490            );
2491        }
2492        InjectionGate::Unverified => {
2493            let _ = ctx.emitter.emit(
2494                "agent_deliver_demoted",
2495                &json!({
2496                    "name": name,
2497                    "from_name": from_name,
2498                    "provider": entry.provider,
2499                    "reason": "injection-gate-unverified",
2500                }),
2501            );
2502            return Response::ok(
2503                req.id,
2504                json!({"delivered": false, "reason": "injection-gate-unverified"}),
2505            );
2506        }
2507    }
2508
2509    // Attempt PTY inject via the shared primitive.
2510    let sock = ctx.home.worker_sock(&entry.short_id);
2511    match inject_into_pty(&sock, &body, &from_name).await {
2512        Ok(()) => {
2513            let _ = ctx.emitter.emit(
2514                "agent_deliver_injected",
2515                &json!({"name": name, "from_name": from_name, "provider": entry.provider}),
2516            );
2517            Response::ok(req.id, json!({"delivered": true, "transport": "pty"}))
2518        }
2519        Err(reason) => {
2520            let _ = ctx.emitter.emit(
2521                "agent_deliver_demoted",
2522                &json!({
2523                    "name": name,
2524                    "from_name": from_name,
2525                    "provider": entry.provider,
2526                    "reason": reason,
2527                }),
2528            );
2529            // Return the actual reason so callers can distinguish inject
2530            // failures (e.g. body-too-large, no-ack) from pure connectivity
2531            // errors. The event and the result must agree (sigma-review F3).
2532            Response::ok(req.id, json!({"delivered": false, "reason": reason}))
2533        }
2534    }
2535}
2536
2537// ---------------------------------------------------------------------------
2538// handle_switchboard (agent.switchboard RPC) — Group 2, Task 3.1
2539// ---------------------------------------------------------------------------
2540//
2541// The session-to-session switchboard: `send A->B` where B is a held stream-json
2542// thread. The daemon writes a user turn to B's stdin (B's `stream.write_turn`
2543// RPC), polls B's frames until a `result` closes the turn, and — when A is also
2544// a held stream-json thread and the caller asked to mirror (the A2A default;
2545// Task 4.1 gates it by config) — writes B's reply back into A as a literal user
2546// turn. The `--replay-user-messages` echo (a `user_echo` frame) is a delivery
2547// RECEIPT, never re-counted as the reply (Invariant "mirror reply exactly once").
2548
2549/// Per-turn ceiling for a switchboard drive. The first `--resume` turn rehydrates
2550/// the transcript, so this default is generous; the daemon never hangs unbounded.
2551const SWITCHBOARD_TURN_TIMEOUT_MS: u64 = 120_000;
2552/// How often the switchboard polls B's frame log while a turn is in flight.
2553const SWITCHBOARD_POLL_MS: u64 = 50;
2554/// Bound for the liveness probe (connect + stream.ping). A wedged worker must
2555/// not hang the daemon on the probe.
2556const STREAM_PROBE_TIMEOUT_S: u64 = 2;
2557/// Bound for a fire-and-forget mirror write (connect + write_turn + ack).
2558const SWITCHBOARD_MIRROR_TIMEOUT_S: u64 = 5;
2559/// Grace added over the per-turn deadline for the OUTER bound on a drive, so a
2560/// hung connect / probe / write / read (none individually deadline-checked) can
2561/// never hang the daemon past the turn budget.
2562const SWITCHBOARD_DRIVE_GRACE_S: u64 = 5;
2563
2564/// Outcome of driving one turn against a held stream-json thread.
2565struct SwitchboardTurn {
2566    /// Concatenated assistant text — the reply to mirror into the peer.
2567    reply: String,
2568    /// `result.is_error` — the turn closed in an error state.
2569    is_error: bool,
2570    /// A `user_echo` (`--replay-user-messages`) frame was observed: the turn was
2571    /// delivered to B's stdin and B began processing it.
2572    saw_receipt: bool,
2573}
2574
2575/// Is the worker at `sock` a LIVE stream-json thread? Connects and sends a
2576/// `stream.ping`; `true` only when it answers ok. A non-stream worker (the PTY
2577/// lane serves `worker.*`, not `stream.*`) answers `UnknownMethod` -> `false`; a
2578/// session with no worker at all has no socket -> connect fails -> `false`. This
2579/// is the authoritative "held stream thread" test (no registry marking needed,
2580/// so it works before Group 3's front door stamps `host_mode`).
2581async fn is_live_stream_thread(sock: &std::path::Path) -> bool {
2582    // Bound the whole probe: a wedged / SIGSTOP'd worker must NOT hang the daemon
2583    // on connect or read (gemini-review HIGH). A timeout -> treat as not-live.
2584    let probe = async {
2585        let mut conn = UnixStream::connect(sock).await.ok()?;
2586        write_request(&mut conn, &Request::new(1, "stream.ping", json!({})))
2587            .await
2588            .ok()?;
2589        let resp = crate::protocol::read_response(&mut conn).await.ok()?;
2590        Some(!resp.is_err())
2591    };
2592    matches!(
2593        tokio::time::timeout(Duration::from_secs(STREAM_PROBE_TIMEOUT_S), probe).await,
2594        Ok(Some(true))
2595    )
2596}
2597
2598/// Write `text` into the held stream-json thread at `worker_sock` and poll frames
2599/// until a `result` closes the turn (or the child dies / the deadline elapses).
2600/// Discriminates the `user_echo` receipt from the assistant reply so the returned
2601/// `reply` is the assistant text exactly once (never the echo; the `result` text
2602/// is a fallback only when no assistant block carried text).
2603async fn drive_stream_turn(
2604    worker_sock: &std::path::Path,
2605    text: &str,
2606    deadline: Duration,
2607) -> Result<SwitchboardTurn, String> {
2608    let mut conn = UnixStream::connect(worker_sock)
2609        .await
2610        .map_err(|e| format!("target not live (worker unreachable): {e}"))?;
2611
2612    // Snapshot the log END before writing. The worker's frame log is append-only
2613    // across the WHOLE session (stream_worker::FrameLog), so a resumed / multi-turn
2614    // thread already holds prior turns' `result` frames. Polling from 0 would match
2615    // an OLD result and return a stale reply (a reply B never gave for THIS turn).
2616    // `read_frames` clamps cursor.min(end), so cursor=u64::MAX yields the current
2617    // end with an empty slice; we then only observe frames THIS turn produces.
2618    write_request(
2619        &mut conn,
2620        &Request::new(0, "stream.read_frames", json!({ "cursor": u64::MAX })),
2621    )
2622    .await
2623    .map_err(|e| format!("cursor probe send failed: {e}"))?;
2624    let probe = crate::protocol::read_response(&mut conn)
2625        .await
2626        .map_err(|e| format!("cursor probe recv failed: {e}"))?;
2627    let mut cursor = probe
2628        .result()
2629        .and_then(|r| r.get("next"))
2630        .and_then(|v| v.as_u64())
2631        .ok_or_else(|| "cursor probe returned no result".to_string())?;
2632
2633    // Write the turn; a rejected/failed write fails fast (Errors: broken pipe).
2634    write_request(
2635        &mut conn,
2636        &Request::new(1, "stream.write_turn", json!({ "text": text })),
2637    )
2638    .await
2639    .map_err(|e| format!("write_turn send failed: {e}"))?;
2640    match crate::protocol::read_response(&mut conn).await {
2641        Ok(ack) if ack.is_err() => {
2642            return Err(format!(
2643                "write_turn rejected: {}",
2644                ack.error().map(|e| e.message.as_str()).unwrap_or("?")
2645            ))
2646        }
2647        Ok(_) => {}
2648        Err(e) => return Err(format!("no write_turn ack: {e}")),
2649    }
2650
2651    // Poll frames until a result closes the turn (starting at the pre-write end).
2652    let start = Instant::now();
2653    let mut reply = String::new();
2654    let mut saw_receipt = false;
2655    let mut req_id = 100u64;
2656    loop {
2657        if start.elapsed() > deadline {
2658            return Err("turn timed out before result".into());
2659        }
2660        write_request(
2661            &mut conn,
2662            &Request::new(req_id, "stream.read_frames", json!({ "cursor": cursor })),
2663        )
2664        .await
2665        .map_err(|e| format!("read_frames send failed: {e}"))?;
2666        req_id += 1;
2667        let resp = crate::protocol::read_response(&mut conn)
2668            .await
2669            .map_err(|e| format!("read_frames recv failed: {e}"))?;
2670        let res = resp
2671            .result()
2672            .ok_or_else(|| "read_frames returned no result".to_string())?;
2673        if let Some(next) = res.get("next").and_then(|v| v.as_u64()) {
2674            cursor = next;
2675        }
2676        let child_alive = res
2677            .get("child_alive")
2678            .and_then(|v| v.as_bool())
2679            .unwrap_or(true);
2680        if let Some(frames) = res.get("frames").and_then(|v| v.as_array()) {
2681            for fr in frames {
2682                match fr.get("kind").and_then(|k| k.as_str()) {
2683                    Some("user_echo") => saw_receipt = true,
2684                    Some("assistant") => {
2685                        if let Some(t) = fr.get("text").and_then(|t| t.as_str()) {
2686                            reply.push_str(t);
2687                        }
2688                    }
2689                    Some("result") => {
2690                        let is_error = fr
2691                            .get("is_error")
2692                            .and_then(|e| e.as_bool())
2693                            .unwrap_or(false);
2694                        // The result text is a FALLBACK only: a `result` must not
2695                        // double-count the assistant message already collected.
2696                        if reply.is_empty() {
2697                            if let Some(r) = fr.get("result").and_then(|r| r.as_str()) {
2698                                reply.push_str(r);
2699                            }
2700                        }
2701                        return Ok(SwitchboardTurn {
2702                            reply,
2703                            is_error,
2704                            saw_receipt,
2705                        });
2706                    }
2707                    // Malformed frames are already logged at the worker; skip.
2708                    _ => {}
2709                }
2710            }
2711        }
2712        if !child_alive {
2713            return Err("target child exited before result (orphaned)".into());
2714        }
2715        tokio::time::sleep(Duration::from_millis(SWITCHBOARD_POLL_MS)).await;
2716    }
2717}
2718
2719/// Mirror `text` into the held stream-json thread at `worker_sock` as one user
2720/// turn (fire-and-forget: we do not wait for the peer's reply here — the
2721/// autonomous A<->B relay + ceiling is Task 4.1). Returns the worker's ack error
2722/// as `Err` so the caller can report a half-mirror rather than hide it.
2723async fn mirror_into(worker_sock: &std::path::Path, text: &str) -> Result<(), String> {
2724    let inner = async {
2725        let mut conn = UnixStream::connect(worker_sock)
2726            .await
2727            .map_err(|e| format!("mirror target unreachable: {e}"))?;
2728        write_request(
2729            &mut conn,
2730            &Request::new(1, "stream.write_turn", json!({ "text": text })),
2731        )
2732        .await
2733        .map_err(|e| format!("mirror write failed: {e}"))?;
2734        match crate::protocol::read_response(&mut conn).await {
2735            Ok(ack) if ack.is_err() => Err(format!(
2736                "mirror rejected: {}",
2737                ack.error().map(|e| e.message.as_str()).unwrap_or("?")
2738            )),
2739            Ok(_) => Ok(()),
2740            Err(e) => Err(format!("no mirror ack: {e}")),
2741        }
2742    };
2743    // Bound the whole mirror so a wedged peer cannot hang the daemon.
2744    match tokio::time::timeout(Duration::from_secs(SWITCHBOARD_MIRROR_TIMEOUT_S), inner).await {
2745        Ok(r) => r,
2746        Err(_) => Err("mirror timed out".into()),
2747    }
2748}
2749
2750/// Best-effort flip a registry row to `Orphaned` (the worker is gone and did not
2751/// self-stamp, e.g. it was SIGKILLed rather than hitting EOF). Offloaded so the
2752/// async runtime is not blocked on file I/O; failures are swallowed (the
2753/// reconcile sweep is the backstop).
2754async fn stamp_orphaned(home: &AgentsHome, short_id: &str) {
2755    let reg = home.registry_json();
2756    let sid = short_id.to_string();
2757    let _ = tokio::task::spawn_blocking(move || {
2758        let _ = state::update_registry(&reg, |r| {
2759            if let Some(e) = r.entries.iter_mut().find(|e| e.short_id == sid) {
2760                // Only flip a still-Live row. Do NOT clobber a terminal status
2761                // the worker already set (a clean `Exited` from stream.shutdown,
2762                // or `Failed`): clobbering Exited->Orphaned would make a
2763                // deliberately-stopped session look adoptable (stream_worker.rs
2764                // documents this hazard).
2765                if e.status == AgentStatus::Live {
2766                    e.status = AgentStatus::Orphaned;
2767                }
2768            }
2769        });
2770    })
2771    .await;
2772}
2773
2774/// Handle the `agent.switchboard` RPC (Group 2, Task 3.1).
2775///
2776/// Params: `{to: string, from: string, body: string, mirror?: bool,
2777/// timeout_ms?: u64}`.
2778///
2779/// Result (Ok unless `to` is unknown or params invalid):
2780/// - `{delivered: true, reply, is_error, mirrored, receipt, transport:
2781///   "switchboard"}` — the turn was driven against B and (when `mirror` and A is
2782///   a held stream thread) B's reply was written into A.
2783/// - `{delivered: false, reason: "not-a-live-stream-thread"}` — B is not a held
2784///   stream-json thread; the caller demotes to the durable/socket path.
2785/// - `{delivered: false, reason: "<drive error>"}` — B was a stream thread but
2786///   the turn failed (broken pipe / orphaned / timeout); B is stamped orphaned
2787///   and A is NOT touched (the exchange did not complete).
2788///
2789/// Errors: `AgentNotFound` (unknown `to`), `InvalidParams` (missing/oversized).
2790async fn handle_switchboard(ctx: &Ctx, req: &Request) -> Response {
2791    let to = match req.params.get("to").and_then(|v| v.as_str()) {
2792        Some(s) => s.to_string(),
2793        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `to`"),
2794    };
2795    let from = req
2796        .params
2797        .get("from")
2798        .and_then(|v| v.as_str())
2799        .unwrap_or("unknown")
2800        .to_string();
2801    let body = match req.params.get("body").and_then(|v| v.as_str()) {
2802        Some(b) => b.to_string(),
2803        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `body`"),
2804    };
2805    let mirror = req
2806        .params
2807        .get("mirror")
2808        .and_then(|v| v.as_bool())
2809        .unwrap_or(true);
2810    let timeout_ms = req
2811        .params
2812        .get("timeout_ms")
2813        .and_then(|v| v.as_u64())
2814        .unwrap_or(SWITCHBOARD_TURN_TIMEOUT_MS);
2815
2816    if body.len() > MAX_INJECT_BODY_BYTES {
2817        return Response::err(
2818            req.id,
2819            ErrorCode::InvalidParams,
2820            format!(
2821                "body too large: {} bytes > {MAX_INJECT_BODY_BYTES}",
2822                body.len()
2823            ),
2824        );
2825    }
2826
2827    let registry = load_registry_offloaded(ctx.home.registry_json()).await;
2828    let to_entry = match registry.find(&to) {
2829        Some(e) => e.clone(),
2830        None => {
2831            return Response::err(
2832                req.id,
2833                ErrorCode::AgentNotFound,
2834                format!("agent '{to}' not found"),
2835            )
2836        }
2837    };
2838
2839    // B must be a held stream-json thread. A non-claude peer (PTY lane) or a
2840    // claude session with no live stream worker demotes to the durable path.
2841    let to_sock = ctx.home.worker_sock(&to_entry.short_id);
2842    if to_entry.provider != "claude" || !is_live_stream_thread(&to_sock).await {
2843        return Response::ok(
2844            req.id,
2845            json!({"delivered": false, "reason": "not-a-live-stream-thread"}),
2846        );
2847    }
2848
2849    // Drive the turn against B. The OUTER timeout (turn budget + grace) is the
2850    // backstop: drive_stream_turn checks its deadline only at the poll-loop top,
2851    // so a hung connect / probe / write / read inside it is bounded here, never
2852    // hanging the daemon (gemini-review HIGH).
2853    let drive_deadline = Duration::from_millis(timeout_ms);
2854    let outer = drive_deadline + Duration::from_secs(SWITCHBOARD_DRIVE_GRACE_S);
2855    let drive_result =
2856        match tokio::time::timeout(outer, drive_stream_turn(&to_sock, &body, drive_deadline)).await
2857        {
2858            Ok(inner) => inner,
2859            Err(_) => Err("drive hung past the turn budget (timed out)".to_string()),
2860        };
2861    let outcome = match drive_result {
2862        Ok(o) => o,
2863        Err(reason) => {
2864            // B was a stream thread but the turn failed: the child is gone or the
2865            // pipe broke. Stamp B orphaned (AC2-ERR) and do NOT touch A — the
2866            // exchange did not complete, so A must not show a reply B never gave.
2867            stamp_orphaned(&ctx.home, &to_entry.short_id).await;
2868            let _ = ctx.emitter.emit(
2869                "agent_deliver_demoted",
2870                &json!({
2871                    "name": to,
2872                    "from_name": from,
2873                    "provider": "claude",
2874                    "transport": "switchboard",
2875                    "reason": reason,
2876                }),
2877            );
2878            return Response::ok(req.id, json!({"delivered": false, "reason": reason}));
2879        }
2880    };
2881
2882    // Mirror B's reply into A when asked AND A is itself a held stream thread.
2883    // A one-way drive (A absent / not a stream thread) still counts as delivered.
2884    // Never mirror a self-send (from == to): it would queue B's own reply back
2885    // into B as a spurious extra turn.
2886    let mut mirrored = false;
2887    if mirror && from != to {
2888        // Re-load the registry: driving B can take up to the turn budget (~120s),
2889        // during which A may have been restarted with a new short_id. The pre-turn
2890        // snapshot could point at A's old socket (gemini-review HIGH).
2891        let fresh = load_registry_offloaded(ctx.home.registry_json()).await;
2892        if let Some(from_entry) = fresh.find(&from) {
2893            let from_sock = ctx.home.worker_sock(&from_entry.short_id);
2894            if from_entry.provider == "claude" && is_live_stream_thread(&from_sock).await {
2895                match mirror_into(&from_sock, &outcome.reply).await {
2896                    Ok(()) => mirrored = true,
2897                    Err(e) => {
2898                        // The turn completed but the mirror failed: surface it
2899                        // (the reply is still returned for the caller to record),
2900                        // never silently drop it.
2901                        let _ = ctx.emitter.emit(
2902                            "agent_deliver_demoted",
2903                            &json!({
2904                                "name": from,
2905                                "from_name": to,
2906                                "provider": "claude",
2907                                "transport": "switchboard-mirror",
2908                                "reason": e,
2909                            }),
2910                        );
2911                    }
2912                }
2913            }
2914        }
2915    }
2916
2917    let _ = ctx.emitter.emit(
2918        "agent_deliver_injected",
2919        &json!({
2920            "name": to,
2921            "from_name": from,
2922            "provider": "claude",
2923            "transport": "switchboard",
2924            "mirrored": mirrored,
2925            "is_error": outcome.is_error,
2926        }),
2927    );
2928
2929    Response::ok(
2930        req.id,
2931        json!({
2932            "delivered": true,
2933            "transport": "switchboard",
2934            "reply": outcome.reply,
2935            "is_error": outcome.is_error,
2936            "mirrored": mirrored,
2937            "receipt": outcome.saw_receipt,
2938        }),
2939    )
2940}
2941
2942// handle_gate_check (agent.gate_check RPC)
2943// ---------------------------------------------------------------------------
2944
2945/// Handle the `agent.gate_check` RPC.
2946///
2947/// Two modes:
2948///
2949/// **Manual attestation** (`record: "manual"`): writes the provided status
2950/// and notes to `injection-gate.json` atomically. Params: `{provider, record:
2951/// "manual", status: "passed"|"failed", notes: "..."}`.
2952/// Result: `{status: "passed"|"failed", method: "manual"}`.
2953///
2954/// **Probe** (`record: "probe"`): attempts a best-effort automated check.
2955/// If no live worker for the provider exists (or the check is inconclusive),
2956/// returns `{status: "inconclusive"}` WITHOUT writing any record.
2957/// An inconclusive probe MUST never write `passed`.
2958/// Result: `{status: "inconclusive"}` (current implementation; probing
2959/// requires PTY observation that is not yet automatable - see Open Question 2).
2960///
2961/// Errors: `InvalidParams` for missing/invalid params.
2962async fn handle_gate_check(ctx: &Ctx, req: &Request) -> Response {
2963    let provider = match req.params.get("provider").and_then(|v| v.as_str()) {
2964        Some(p) => p.to_string(),
2965        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `provider`"),
2966    };
2967    let record_mode = match req.params.get("record").and_then(|v| v.as_str()) {
2968        Some(m) => m.to_string(),
2969        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `record`"),
2970    };
2971
2972    match record_mode.as_str() {
2973        "manual" => {
2974            let status = match req.params.get("status").and_then(|v| v.as_str()) {
2975                Some(s) if s == "passed" || s == "failed" => s.to_string(),
2976                Some(other) => {
2977                    return Response::err(
2978                        req.id,
2979                        ErrorCode::InvalidParams,
2980                        format!("`status` must be 'passed' or 'failed', got '{other}'"),
2981                    )
2982                }
2983                None => {
2984                    return Response::err(
2985                        req.id,
2986                        ErrorCode::InvalidParams,
2987                        "missing `status` for manual mode",
2988                    )
2989                }
2990            };
2991            let notes = req
2992                .params
2993                .get("notes")
2994                .and_then(|v| v.as_str())
2995                .unwrap_or("")
2996                .to_string();
2997            let checked_at = {
2998                use std::time::{SystemTime, UNIX_EPOCH};
2999                // RFC-3339 UTC timestamp
3000                let secs = SystemTime::now()
3001                    .duration_since(UNIX_EPOCH)
3002                    .unwrap_or_default()
3003                    .as_secs();
3004                // Format as YYYY-MM-DDTHH:MM:SSZ (no external dep)
3005                let s = secs;
3006                let sec = s % 60;
3007                let min = (s / 60) % 60;
3008                let hour = (s / 3600) % 24;
3009                let days = s / 86400;
3010                // Days since epoch to calendar (Gregorian, no leap-second)
3011                let (y, mo, d) = days_to_ymd(days);
3012                format!("{y:04}-{mo:02}-{d:02}T{hour:02}:{min:02}:{sec:02}Z")
3013            };
3014
3015            // Read existing record (if any) to merge providers.
3016            let gate_path = ctx.home.injection_gate_json();
3017            let mut existing: serde_json::Value = if gate_path.exists() {
3018                // An existing-but-unreadable/unparseable file must surface an
3019                // error: defaulting to an empty doc here would WIPE every other
3020                // provider's record on the subsequent write (gemini #459 high).
3021                match std::fs::read_to_string(&gate_path) {
3022                    Ok(t) => match serde_json::from_str(&t) {
3023                        Ok(v) => v,
3024                        Err(e) => {
3025                            return Response::err(
3026                                req.id,
3027                                ErrorCode::InvalidParams,
3028                                format!("failed to parse existing gate record: {e}"),
3029                            );
3030                        }
3031                    },
3032                    Err(e) => {
3033                        return Response::err(
3034                            req.id,
3035                            ErrorCode::InvalidParams,
3036                            format!("failed to read existing gate record: {e}"),
3037                        );
3038                    }
3039                }
3040            } else {
3041                json!({"v": 1, "providers": {}})
3042            };
3043            existing["providers"][&provider] = json!({
3044                "status": status,
3045                "checked_at": checked_at,
3046                "method": "manual",
3047                "notes": notes,
3048            });
3049
3050            // Atomic write: temp file + rename.
3051            let tmp_path = gate_path.with_extension("json.tmp");
3052            let serialized = serde_json::to_string(&existing).unwrap_or_default();
3053            if let Err(e) = std::fs::write(&tmp_path, &serialized) {
3054                return Response::err(
3055                    req.id,
3056                    ErrorCode::InvalidParams,
3057                    format!("failed to write gate record: {e}"),
3058                );
3059            }
3060            if let Err(e) = std::fs::rename(&tmp_path, &gate_path) {
3061                let _ = std::fs::remove_file(&tmp_path);
3062                return Response::err(
3063                    req.id,
3064                    ErrorCode::InvalidParams,
3065                    format!("failed to rename gate record: {e}"),
3066                );
3067            }
3068            Response::ok(req.id, json!({"status": status, "method": "manual"}))
3069        }
3070        "probe" => {
3071            // Probe mode: automated check. Current state: no reliable automated
3072            // signal exists for verifying PTY queueing behavior without a live TUI
3073            // and known-good input observer. Per Open Question 2 in the design doc,
3074            // the gate holds via demotion; probe returns inconclusive and writes
3075            // NOTHING. A future implementation can check for live workers and send
3076            // a sentinel, but only if a reliable verdict (pass/fail) is obtainable.
3077            // An inconclusive probe MUST NEVER write "passed".
3078            Response::ok(req.id, json!({"status": "inconclusive"}))
3079        }
3080        other => Response::err(
3081            req.id,
3082            ErrorCode::InvalidParams,
3083            format!("`record` must be 'manual' or 'probe', got '{other}'"),
3084        ),
3085    }
3086}
3087
3088/// Convert days-since-Unix-epoch to (year, month, day). Pure arithmetic,
3089/// Gregorian calendar, no DST / leap-second.
3090fn days_to_ymd(d: u64) -> (u64, u64, u64) {
3091    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
3092    // civil_from_days (unsigned variant for post-epoch only)
3093    let z = d + 719468;
3094    let era = z / 146097;
3095    let doe = z % 146097;
3096    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
3097    let y = yoe + era * 400;
3098    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
3099    let mp = (5 * doy + 2) / 153;
3100    let day = doy - (153 * mp + 2) / 5 + 1;
3101    let month = if mp < 10 { mp + 3 } else { mp - 9 };
3102    let year = if month <= 2 { y + 1 } else { y };
3103    (year, month, day)
3104}
3105
3106async fn read_worker_snapshot(sock: &std::path::Path) -> Option<String> {
3107    let mut conn = UnixStream::connect(sock).await.ok()?;
3108    write_request(&mut conn, &Request::new(2, "worker.snapshot", json!({})))
3109        .await
3110        .ok()?;
3111    let resp = crate::protocol::read_response(&mut conn).await.ok()?;
3112    resp.result()
3113        .and_then(|r| r.get("text").and_then(|t| t.as_str()).map(String::from))
3114}
3115
3116/// Ask the worker whether its PTY child is alive, retrying briefly.
3117///
3118/// The worker binds its socket BEFORE its accept loop is guaranteed to be
3119/// scheduled (the PTY child is spawned first, then the socket bound). Under
3120/// heavy load the first probe(s) can fail at the transport (connect refused, a
3121/// stalled read) even though the child is perfectly alive. A single-shot probe
3122/// then mis-declares a healthy agent dead and fails the spawn — a flaky spawn
3123/// under contention. Retrying over a short budget gives a CPU-starved-but-healthy
3124/// worker time to answer; a child that is genuinely dead reports `child_alive:
3125/// false` (or stays unreachable) across every attempt and still fails. The happy
3126/// path returns on the first probe with no added latency.
3127async fn worker_reports_child_alive(sock: &std::path::Path) -> bool {
3128    const ATTEMPTS: u32 = 10;
3129    const PROBE_READ_TIMEOUT: Duration = Duration::from_millis(300);
3130    for attempt in 0..ATTEMPTS {
3131        if attempt > 0 {
3132            tokio::time::sleep(Duration::from_millis(50)).await;
3133        }
3134        let Ok(mut conn) = UnixStream::connect(sock).await else {
3135            continue; // worker not accepting yet; retry
3136        };
3137        if write_request(&mut conn, &Request::new(3, "worker.status", json!({})))
3138            .await
3139            .is_err()
3140        {
3141            continue;
3142        }
3143        match tokio::time::timeout(
3144            PROBE_READ_TIMEOUT,
3145            crate::protocol::read_response(&mut conn),
3146        )
3147        .await
3148        {
3149            Ok(Ok(resp)) => {
3150                if resp
3151                    .result()
3152                    .and_then(|r| r.get("child_alive").and_then(|v| v.as_bool()))
3153                    .unwrap_or(false)
3154                {
3155                    return true;
3156                }
3157                // child_alive:false — could be a child still registering, or a
3158                // genuinely dead one; keep probing within the budget.
3159            }
3160            // Read error or per-probe timeout: transport not ready; retry.
3161            Ok(Err(_)) | Err(_) => continue,
3162        }
3163    }
3164    false
3165}
3166
3167/// One step of the interactive-readiness decision (task 2.2). Factored out of
3168/// the async poll loop so the exec-vs-interactive contract is unit-testable
3169/// without spinning a worker. Inputs are the latest worker.snapshot probe.
3170#[derive(Debug, PartialEq, Eq)]
3171enum ReadinessStep {
3172    /// Painted + alive (or alive at the deadline): report `live`.
3173    Ready,
3174    /// Child died within the settle window: report `spawn-failed`.
3175    Failed,
3176    /// Alive, not yet painted, deadline not reached: keep polling.
3177    Poll,
3178}
3179
3180fn interactive_readiness_step(
3181    alive: bool,
3182    painted: bool,
3183    dwell_satisfied: bool,
3184    deadline_reached: bool,
3185) -> ReadinessStep {
3186    if !alive {
3187        // The child exited during the settle window. This is the AC1-FR failure:
3188        // `codex resume`/`gemini -r` launched, painted (possibly an error), then
3189        // died (auth expired, unknown session). Report spawn-failed, never a
3190        // live-then-exited row. Survival -- not first paint -- is the signal,
3191        // because a resume that prints an error THEN exits would paint too.
3192        ReadinessStep::Failed
3193    } else if painted && dwell_satisfied {
3194        // Painted AND still alive after the minimum dwell: a real TUI that came
3195        // up and stayed up (an error-then-die would have flipped !alive first).
3196        ReadinessStep::Ready
3197    } else if deadline_reached {
3198        // Alive at the deadline (slow first paint, model warming): do NOT kill a
3199        // live process -- a false-failed that reaps a working agent is worse
3200        // than a brief blank pane the grid will fill. Treat as live.
3201        ReadinessStep::Ready
3202    } else {
3203        ReadinessStep::Poll
3204    }
3205}
3206
3207/// Probe `worker.snapshot`, returning `(rendered_text, child_alive)` or `None`
3208/// when the worker is not yet answering (caller retries within its budget).
3209async fn probe_worker_snapshot(sock: &std::path::Path) -> Option<(String, bool)> {
3210    let mut conn = UnixStream::connect(sock).await.ok()?;
3211    write_request(&mut conn, &Request::new(4, "worker.snapshot", json!({})))
3212        .await
3213        .ok()?;
3214    let resp = tokio::time::timeout(
3215        Duration::from_millis(300),
3216        crate::protocol::read_response(&mut conn),
3217    )
3218    .await
3219    .ok()?
3220    .ok()?;
3221    let r = resp.result()?;
3222    let text = r
3223        .get("text")
3224        .and_then(|v| v.as_str())
3225        .unwrap_or("")
3226        .to_string();
3227    let alive = r
3228        .get("child_alive")
3229        .and_then(|v| v.as_bool())
3230        .unwrap_or(false);
3231    Some((text, alive))
3232}
3233
3234/// Interactive readiness gate (host_mode=interactive). Exec mode confirms
3235/// readiness later from the `--json` stream; an interactive TUI has no such
3236/// stream, so readiness here is "the PTY painted its first frame and the child
3237/// is still alive" within a bounded settle window. Returns `Ok(())` when
3238/// ready (painted+alive, or alive-at-deadline), `Err(last_painted_bytes)` when
3239/// the child died within the window (spawn-failed, last bytes for diagnosis).
3240async fn await_interactive_readiness(sock: &std::path::Path) -> Result<(), String> {
3241    const SETTLE: Duration = Duration::from_millis(4000);
3242    // Minimum time a painted child must stay alive before we declare readiness,
3243    // so a resume that prints an error then exits is caught as Failed rather
3244    // than passing on its first (dying) paint.
3245    const MIN_DWELL: Duration = Duration::from_millis(1000);
3246    const POLL: Duration = Duration::from_millis(150);
3247    let start = Instant::now();
3248    let deadline = start + SETTLE;
3249    let mut last_text = String::new();
3250    // Dwell is measured from the FIRST PAINT, not from spawn start: a child that
3251    // takes >MIN_DWELL to paint its first frame must still survive MIN_DWELL
3252    // AFTER painting, or a slow-to-paint resume that errors-then-exits would pass
3253    // on its first painted poll (Gemini review HIGH on PR #373).
3254    let mut first_paint_at: Option<Instant> = None;
3255    loop {
3256        let now = Instant::now();
3257        let deadline_reached = now >= deadline;
3258        if let Some((text, alive)) = probe_worker_snapshot(sock).await {
3259            if !text.is_empty() {
3260                last_text = text;
3261                if first_paint_at.is_none() {
3262                    first_paint_at = Some(now);
3263                }
3264            }
3265            let dwell_satisfied =
3266                first_paint_at.is_some_and(|t| now.duration_since(t) >= MIN_DWELL);
3267            match interactive_readiness_step(
3268                alive,
3269                first_paint_at.is_some(),
3270                dwell_satisfied,
3271                deadline_reached,
3272            ) {
3273                ReadinessStep::Ready => return Ok(()),
3274                ReadinessStep::Failed => return Err(last_text),
3275                ReadinessStep::Poll => {}
3276            }
3277        } else if deadline_reached {
3278            // Worker stopped answering at the deadline: inconclusive, but the
3279            // earlier child-alive gate passed, so do not fail a maybe-live agent.
3280            return Ok(());
3281        }
3282        tokio::time::sleep(POLL).await;
3283    }
3284}
3285
3286/// Non-blocking reap of any exited worker child the daemon spawned, so a worker
3287/// that exits while the daemon lives never lingers as a `<defunct>` zombie. The
3288/// daemon spawns nothing but workers, so a `waitpid(-1, WNOHANG)` sweep is safe.
3289fn reap_zombies() {
3290    loop {
3291        let mut status: libc::c_int = 0;
3292        // SAFETY: waitpid with WNOHANG only reaps already-exited children and
3293        // returns 0 (none ready) or -1 (no children) without blocking.
3294        let pid = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) };
3295        if pid <= 0 {
3296            break;
3297        }
3298    }
3299}
3300
3301fn handle_list(ctx: &Ctx, req: &Request) -> Response {
3302    let all = req
3303        .params
3304        .get("all")
3305        .and_then(|v| v.as_bool())
3306        .unwrap_or(false);
3307
3308    // Task 3.1: accept cwd/provider/status filters matching Python list_agents.
3309    // Legacy project_root filter still accepted for backward compat.
3310    let filter_cwd = req
3311        .params
3312        .get("cwd")
3313        .and_then(|v| v.as_str())
3314        .map(String::from);
3315    let filter_provider = req
3316        .params
3317        .get("provider")
3318        .and_then(|v| v.as_str())
3319        .map(String::from);
3320    let filter_status = req
3321        .params
3322        .get("status")
3323        .and_then(|v| v.as_str())
3324        .map(String::from);
3325    let cwd_project = req
3326        .params
3327        .get("project_root")
3328        .and_then(|v| v.as_str())
3329        .map(String::from);
3330
3331    // Reject an invalid --status up front so a typo fails fast with exit 13
3332    // instead of silently returning zero rows + exit 0 (Codex P2 on PR #361).
3333    // Mirrors Python's AgentStatusFilter enum (live | orphaned), which Typer
3334    // rejects at parse time.
3335    if let Some(ref st) = filter_status {
3336        if st != "live" && st != "orphaned" {
3337            return Response::err(
3338                req.id,
3339                ErrorCode::InvalidStatus,
3340                format!("invalid --status '{st}' (expected: live | orphaned)"),
3341            );
3342        }
3343    }
3344    // Normalize the cwd filter so equivalent paths (`.` vs absolute, symlinks)
3345    // match, mirroring Python's `Path(cwd).resolve()` before filtering (Codex P2
3346    // on PR #361; this is the cwd half of cv-eeaad75d). canonicalize requires the
3347    // path to exist; fall back to the raw string when it can't resolve so a
3348    // non-existent filter still does an exact-string match rather than erroring.
3349    let norm_path = |p: &str| -> String {
3350        std::fs::canonicalize(p)
3351            .ok()
3352            .and_then(|pb| pb.to_str().map(String::from))
3353            .unwrap_or_else(|| p.to_string())
3354    };
3355    let filter_cwd_norm = filter_cwd.as_deref().map(&norm_path);
3356
3357    let registry = state::load_registry(&ctx.home.registry_json()).unwrap_or_default();
3358    let entries: Vec<Value> = registry
3359        .entries
3360        .iter()
3361        .filter(|e| {
3362            // Legacy all/project_root filter
3363            if !all {
3364                if let Some(ref p) = cwd_project {
3365                    if &e.project_root != p {
3366                        return false;
3367                    }
3368                }
3369            }
3370            // Task 3.1 filters: cwd, provider, status (matching Python list_agents order)
3371            if let Some(ref cwd) = filter_cwd_norm {
3372                if &norm_path(&e.cwd) != cwd {
3373                    return false;
3374                }
3375            }
3376            if let Some(ref prov) = filter_provider {
3377                if &e.provider != prov {
3378                    return false;
3379                }
3380            }
3381            if let Some(ref st) = filter_status {
3382                if format!("{:?}", e.status).to_lowercase() != st.to_lowercase() {
3383                    return false;
3384                }
3385            }
3386            true
3387        })
3388        .map(|e| {
3389            // Task 3.1: return the full serialize_entry 10-key shape matching Python.
3390            // Fields present in RegistryEntry are mapped directly; fields absent from
3391            // the Rust registry are emitted as null with a NOTE citing the carveout.
3392            //
3393            // DECIDED cv-eeaad75d: live_status is always null. The daemon does NOT
3394            // replicate Python list's per-row `claude agents --json` live_status
3395            // augmentation, and will not: under the replace architecture (the
3396            // daemon is the sole agents backend; cv-d28b266a, docs/distribution.md)
3397            // PTY-worker/registry `status` IS the canonical liveness signal. A
3398            // `claude agents` shellout would both contradict that and be
3399            // impossible to do consistently here (the daemon does not PTY-manage
3400            // claude; ClaudeProvider.as_pty() is None). The field is kept (null)
3401            // to preserve the serialize_entry parity shape for JSON consumers.
3402            //
3403            // session_id: Python uses the provider-specific resume id (claude_short_id
3404            // for claude, codex_session_id for codex, gemini_session_id for gemini).
3405            // The Rust registry stores these in separate optional fields; we replicate
3406            // the Python resolution logic here.
3407            // Provider-specific resume id, falling back to the generic
3408            // `session_id` when the provider field is None (matches Python's
3409            // resolution + the resolve_session_id helper below; gemini-code-assist
3410            // medium on PR #361 — without the fallback a row with only the generic
3411            // session_id set would report null here).
3412            let resume_id: Option<String> = match e.provider.as_str() {
3413                "claude" => e.claude_short_id.clone().or_else(|| e.session_id.clone()),
3414                "codex" => e.codex_session_id.clone().or_else(|| e.session_id.clone()),
3415                "gemini" => e.gemini_session_id.clone().or_else(|| e.session_id.clone()),
3416                _ => e.session_id.clone(),
3417            };
3418            let session_id: Value = resume_id.map(Value::String).unwrap_or(Value::Null);
3419            let short_id: Value = e
3420                .claude_short_id
3421                .as_deref()
3422                .map(|s| Value::String(s.to_string()))
3423                .unwrap_or(Value::Null);
3424            let log_path: Value = e
3425                .log_path
3426                .as_deref()
3427                .map(|s| Value::String(s.to_string()))
3428                .unwrap_or(Value::Null);
3429            json!({
3430                "name": e.name,
3431                "provider": e.provider,
3432                "short_id": short_id,
3433                "session_id": session_id,
3434                "cwd": e.cwd,
3435                "created_at": e.created_at,
3436                "last_message_at": e.last_message_at,
3437                "status": format!("{:?}", e.status).to_lowercase(),
3438                "live_status": null,  // DECIDED cv-eeaad75d: not replicated (see NOTE above)
3439                // Architecture C (plan ab-70faa65b): additive keys, never removing
3440                // live_status (Locked #4 back-compat). `pid` is the worker pid for
3441                // a PTY agent, null for a one-shot ask (no managed process). The
3442                // pid is cleared when a PTY row reconciles to exited (Locked #7),
3443                // so it never lingers as a misleading liveness signal.
3444                // `last_reconciled_at` is the raw RFC3339 of the last probe (null
3445                // when never reconciled); the client renders it as the CHECKED age.
3446                "pid": e.pid,
3447                "last_reconciled_at": e.last_reconciled_at,
3448                "log_path": log_path,
3449                // Superset of Python's serialize_entry: project_root is retained
3450                // as the daemon's native grouping key (existing daemon_e2e
3451                // contract) alongside the 10 Python parity fields. Python list
3452                // has no project_root; the extra key is a harmless superset.
3453                "project_root": e.project_root,
3454            })
3455        })
3456        .collect();
3457    // Echo the filters the daemon applied so `list --json` self-describes its
3458    // query, matching Python `read.list_agents`'s `filters_applied` (sigma-review:
3459    // the client previously always fell back to an all-null block because the
3460    // daemon omitted this field). `cwd` is the value the client sent; absolute
3461    // resolution to match Python's `Path(cwd).resolve()` is deferred (cv-eeaad75d).
3462    let filters_applied = json!({
3463        "cwd": filter_cwd_norm,
3464        "provider": filter_provider,
3465        "status": filter_status,
3466    });
3467    Response::ok(
3468        req.id,
3469        json!({"agents": entries, "filters_applied": filters_applied}),
3470    )
3471}
3472
3473/// Daemon diagnostics in the locked `status-v1.json` shape (US6.10, LD35):
3474///
3475/// ```json
3476/// {
3477///   "schema_version": 1,
3478///   "daemon":   {"state", "pid", "uptime_secs", "version",
3479///                "exe_path", "exe_mtime", "exe_size", "pid_start_time"},
3480///   "agents":   {"total", "by_status": {"<status>": <count>, ...}},
3481///   "drives":   {"active": <controlling-driver count>},
3482///   "restarts": {"queue_depth", "consecutive_failures_max_seen"},
3483///   "channels": {"registered": <entries with an mcp_channel_id>}
3484/// }
3485/// ```
3486///
3487/// The shape is the contract Wave 7's `status-v1.json` schema + CI parity check
3488/// codify; keep additions backward-compatible. `daemon.state` is always
3489/// `serving` here because a served RPC implies the daemon got past recovery.
3490async fn handle_status(ctx: &Ctx, req: &Request) -> Response {
3491    // load_registry does blocking flock I/O; offload it from the async worker
3492    // thread (Gemini review). The drive-table read below stays async.
3493    let reg_path = ctx.home.registry_json();
3494    let registry = tokio::task::spawn_blocking(move || state::load_registry(&reg_path))
3495        .await
3496        .ok()
3497        .and_then(|r| r.ok())
3498        .unwrap_or_default();
3499    let mut by_status: Map<String, Value> = Map::new();
3500    let mut restarting: u64 = 0;
3501    let mut channels_registered: u64 = 0;
3502    for e in &registry.entries {
3503        let key = format!("{:?}", e.status).to_lowercase();
3504        let n = by_status.get(&key).and_then(|v| v.as_u64()).unwrap_or(0) + 1;
3505        by_status.insert(key, Value::Number(n.into()));
3506        if e.status == AgentStatus::Restarting {
3507            restarting += 1;
3508        }
3509        if e.mcp_channel_id.is_some() {
3510            channels_registered += 1;
3511        }
3512    }
3513    let active_drives = crate::drive::active_drive_count(&ctx.drives).await;
3514    Response::ok(
3515        req.id,
3516        json!({
3517            "schema_version": 1,
3518            "daemon": {
3519                "state": DaemonState::Serving.as_str(),
3520                "pid": std::process::id(),
3521                "uptime_secs": ctx.started_at.elapsed().as_secs(),
3522                "version": env!("CARGO_PKG_VERSION"),
3523                // Drift signal (ab-1891cdff), additive. Null when the daemon
3524                // could not fingerprint itself; a client then reads Unknown.
3525                "exe_path": ctx
3526                    .exe_fingerprint
3527                    .as_ref()
3528                    .map(|f| f.path.to_string_lossy().into_owned()),
3529                "exe_mtime": ctx.exe_fingerprint.as_ref().map(|f| f.mtime_nanos),
3530                "exe_size": ctx.exe_fingerprint.as_ref().map(|f| f.size),
3531                // The daemon's own process start time, for the `restart`
3532                // pid-reuse guard.
3533                "pid_start_time": ctx.pid_start_time,
3534            },
3535            "agents": {
3536                "total": registry.entries.len(),
3537                "by_status": by_status,
3538            },
3539            "drives": { "active": active_drives },
3540            "restarts": {
3541                // queue_depth tracks agents currently restarting; the full
3542                // restart queue + consecutive-failure history is not yet
3543                // surfaced in the served status (Wave 5), so the max-seen
3544                // counter reports 0 until that subsystem is wired into Ctx.
3545                "queue_depth": restarting,
3546                "consecutive_failures_max_seen": 0,
3547            },
3548            "channels": { "registered": channels_registered },
3549        }),
3550    )
3551}
3552
3553async fn handle_stop(ctx: &Ctx, req: &Request) -> Response {
3554    let name = match req.params.get("name").and_then(|v| v.as_str()) {
3555        Some(n) => n.to_string(),
3556        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `name`"),
3557    };
3558    let registry = load_registry_offloaded(ctx.home.registry_json()).await;
3559    let entry = match registry.find(&name) {
3560        Some(e) => e.clone(),
3561        None => {
3562            return Response::err(
3563                req.id,
3564                ErrorCode::AgentNotFound,
3565                format!("agent {name} not found"),
3566            )
3567        }
3568    };
3569    let force = req
3570        .params
3571        .get("force")
3572        .and_then(|v| v.as_bool())
3573        .unwrap_or(false);
3574    if entry.status == AgentStatus::Exited {
3575        // An exited agent needs no stop work, but a controlling driver can
3576        // linger if a prior `stop --force` hit `drive_force_close_timeout`.
3577        // Since `rm` refuses unconditionally while a driver is active, without
3578        // this the agent is stuck until daemon restart. Let `--force` clear a
3579        // lingering driver even on the exited fast-path (Codex P2).
3580        if force
3581            && crate::drive::force_close_controlling(&ctx.drives, &entry.short_id, "stop_force")
3582                .await
3583        {
3584            crate::drive::await_driver_cleared(
3585                &ctx.drives,
3586                &entry.short_id,
3587                Duration::from_secs(2),
3588            )
3589            .await;
3590        }
3591        return Response::ok(
3592            req.id,
3593            json!({"already_exited": true, "short_id": entry.short_id}),
3594        );
3595    }
3596    // Driver-awareness (US6.7, LD26): refuse with exit 18 (Busy) while an
3597    // operator is driving, unless `--force` force-closes the driver first. A
3598    // watcher does not block — only the controlling driver does.
3599    if let Some((sid, _mode)) = crate::drive::controlling_driver(&ctx.drives, &entry.short_id).await
3600    {
3601        if !force {
3602            return Response::err(
3603                req.id,
3604                ErrorCode::Busy,
3605                format!(
3606                    "agent {name} has an active driver (session {sid}); detach first or pass --force"
3607                ),
3608            );
3609        }
3610        // `--force`: evict the driver. The drive session loop observes the
3611        // latched close, emits `drive_detached{reason:"stop_force"}`, and clears
3612        // the table slot in `cleanup`; we wait for that before stopping so the
3613        // authority window is gone by the time the agent exits.
3614        crate::drive::force_close_controlling(&ctx.drives, &entry.short_id, "stop_force").await;
3615        if !crate::drive::await_driver_cleared(&ctx.drives, &entry.short_id, Duration::from_secs(2))
3616            .await
3617        {
3618            // The driver did not detach within the grace window (slow/wedged
3619            // session loop). The operator asked to force the stop, so proceed —
3620            // but surface that the authority window may still be present in
3621            // state.json so the divergence is observable rather than silent.
3622            let _ = ctx.emitter.emit(
3623                "drive_force_close_timeout",
3624                &json!({"name": name, "short_id": entry.short_id}),
3625            );
3626        }
3627    }
3628    // Claude agents are not PTY-managed (LD8): there is no worker to shut down.
3629    // Shell out to the claude supervisor and propagate its outcome.
3630    if entry.provider == "claude" {
3631        return stop_claude(ctx, req, &name, &entry).await;
3632    }
3633    // A non-PTY row (empty short_id == Python-authored; the daemon's create path
3634    // always derives a non-empty short_id) for codex/gemini has no daemon worker
3635    // to stop. Mirror Python `stop_agent`: these providers are "synchronous
3636    // between asks (no persistent process to stop)" -- emit `agent_stopped` and
3637    // return cleanly, leaving the registry UNCHANGED. Falling through to the PTY
3638    // path would probe the agents-root `worker.sock` (absent -> "confirmed
3639    // down") and then write `status = Exited`, a status Python's loader rejects,
3640    // corrupting a Python-readable registry (Codex P1, PR #364).
3641    if entry.short_id.is_empty() {
3642        let _ = ctx.emitter.emit(
3643            "agent_stopped",
3644            &json!({"name": name, "provider": entry.provider, "claude_exit": Value::Null}),
3645        );
3646        return Response::ok(
3647            req.id,
3648            json!({"stopped": true, "provider": entry.provider, "no_op": true}),
3649        );
3650    }
3651    // Ask the worker to shut down its PTY child gracefully, then CONFIRM it
3652    // actually went away before reporting success: a swallowed shutdown
3653    // failure would mark the agent exited while the PTY keeps running (Codex
3654    // P1). A worker that shut down removes its socket and exits.
3655    if !stop_worker_confirmed(ctx, &entry).await {
3656        return Response::err(
3657            req.id,
3658            ErrorCode::Internal,
3659            format!("agent {name}: worker did not confirm shutdown; it may still be running"),
3660        );
3661    }
3662    // Surface a registry-write failure rather than reporting a clean stop while
3663    // the on-disk status still reads live: the worker is confirmed dead, but if
3664    // the status flip does not persist the registry diverges from reality
3665    // (silent-failure review). Mirrors handle_register_channel's house style.
3666    let stop_name = name.clone();
3667    if let Err(e) = update_registry_offloaded(ctx.home.registry_json(), move |r| {
3668        if let Some(e) = r.find_mut(&stop_name) {
3669            e.status = AgentStatus::Exited;
3670        }
3671    })
3672    .await
3673    {
3674        let _ = ctx.emitter.emit(
3675            "agent_stop_error",
3676            &json!({"name": name, "error": e.to_string()}),
3677        );
3678        return Response::err(
3679            req.id,
3680            ErrorCode::Internal,
3681            format!("agent {name}: worker stopped but registry write failed: {e}"),
3682        );
3683    }
3684    let _ = ctx.emitter.emit("agent_stopped", &json!({"name": name}));
3685    Response::ok(req.id, json!({"stopped": true, "short_id": entry.short_id}))
3686}
3687
3688/// Fire-and-forget `worker.shutdown` to a worker that must not be left running
3689/// (a spawn that failed or lost a name race): connect, ask it to tear down, and
3690/// move on. Best-effort by design — the caller is already on an error path.
3691async fn best_effort_worker_shutdown(sock: &std::path::Path) {
3692    if let Ok(mut conn) = UnixStream::connect(sock).await {
3693        let _ = write_request(&mut conn, &Request::new(1, "worker.shutdown", json!({}))).await;
3694        let _ = crate::protocol::read_response(&mut conn).await;
3695    }
3696}
3697
3698/// Graceful worker shutdown with SIGTERM -> SIGKILL escalation (US6.7), then
3699/// verify the worker process is actually gone. Returns true iff the worker is
3700/// confirmed down. A worker that never dies returns false so the caller can
3701/// refuse to claim a clean stop (a swallowed failure would mark the agent exited
3702/// while its PTY keeps running, Codex P1).
3703async fn stop_worker_confirmed(ctx: &Ctx, entry: &RegistryEntry) -> bool {
3704    let sock = ctx.home.worker_sock(&entry.short_id);
3705    // 1. Graceful: ask the worker to tear down its PTY child + exit.
3706    if let Ok(mut conn) = UnixStream::connect(&sock).await {
3707        let _ = write_request(&mut conn, &Request::new(1, "worker.shutdown", json!({}))).await;
3708        let _ = crate::protocol::read_response(&mut conn).await;
3709    }
3710    // 2. Up to the 5s grace for a clean exit. "Down" = the worker's SOCKET is
3711    //    unreachable, which is the authoritative, PID-reuse-immune liveness
3712    //    signal: the worker is identified by the socket it owns, not by a
3713    //    registry pid that can go stale after a crash (Codex P1).
3714    let mut down = worker_down_within(&sock, Duration::from_secs(5)).await;
3715    // 3. Escalate ONLY while the socket is still reachable, i.e. a worker is
3716    //    alive and ignoring shutdown. If the socket is already unreachable we
3717    //    are done and never signal a pid - this avoids SIGKILLing a stale or
3718    //    recycled pid when the real worker has already exited (Codex P1).
3719    //    Additionally, validate pid+create_time ownership before signaling
3720    //    (ab-d19e6458): if the recorded pid is alive but its start time no longer
3721    //    matches, the pid was recycled by an unrelated process and we must NOT
3722    //    SIGTERM/SIGKILL it. The socket-reachable worker (a restarted instance
3723    //    under a new pid) is left for the caller to report as not-confirmed.
3724    if !down {
3725        if let Some(pid) = entry.pid {
3726            if pid_is_ours(pid, entry.pid_start_time) {
3727                unsafe {
3728                    libc::kill(pid as libc::pid_t, libc::SIGTERM);
3729                }
3730                down = worker_down_within(&sock, Duration::from_secs(5)).await;
3731                if !down && pid_is_ours(pid, entry.pid_start_time) {
3732                    unsafe {
3733                        libc::kill(pid as libc::pid_t, libc::SIGKILL);
3734                    }
3735                    down = worker_down_within(&sock, Duration::from_secs(2)).await;
3736                }
3737            }
3738        }
3739    }
3740    // Only reap the socket file once the worker is confirmed unreachable, so we
3741    // never unlink a live worker's socket (Codex P1). A SIGKILLed worker cannot
3742    // remove its own socket; this reaps the stale file so a later reconcile /
3743    // list does not mistake it for a live worker.
3744    if down {
3745        let _ = std::fs::remove_file(&sock);
3746    }
3747    down
3748}
3749
3750/// Probe whether the worker is still serving on its socket. PID-reuse-immune:
3751/// the worker is identified by the socket it owns (per `short_id`), so a
3752/// recycled unrelated pid never answers here (Codex P1).
3753async fn worker_socket_reachable(sock: &std::path::Path) -> bool {
3754    UnixStream::connect(sock).await.is_ok()
3755}
3756
3757/// Poll until the worker's socket is unreachable (the worker is gone), or
3758/// `budget` elapses. Socket-based rather than pid-based so a stale / recycled
3759/// `entry.pid` can neither falsely report a live worker down nor cause a live
3760/// worker's socket to be unlinked (Codex P1).
3761async fn worker_down_within(sock: &std::path::Path, budget: Duration) -> bool {
3762    let start = Instant::now();
3763    loop {
3764        if !worker_socket_reachable(sock).await {
3765            return true;
3766        }
3767        if start.elapsed() >= budget {
3768            return false;
3769        }
3770        tokio::time::sleep(Duration::from_millis(50)).await;
3771    }
3772}
3773
3774/// Stop a Claude agent (AC7-EDGE). Claude is shellout-managed (LD8): there is no
3775/// worker PTY to signal, so the daemon shells out to the claude supervisor's
3776/// `stop` on the agent's short id and marks the registry row exited on success.
3777async fn stop_claude(ctx: &Ctx, req: &Request, name: &str, entry: &RegistryEntry) -> Response {
3778    let short = match entry
3779        .claude_short_id
3780        .as_deref()
3781        .or(entry.session_id.as_deref())
3782        .filter(|s| !s.is_empty())
3783    {
3784        Some(s) => s.to_string(),
3785        None => {
3786            return Response::err(
3787                req.id,
3788                ErrorCode::InvalidStatus,
3789                format!("agent {name} is claude but has no short id to stop"),
3790            )
3791        }
3792    };
3793    match tokio::process::Command::new("claude")
3794        .arg("stop")
3795        .arg(&short)
3796        .output()
3797        .await
3798    {
3799        Ok(o) if o.status.success() => {
3800            // Surface a persist failure rather than reporting a clean stop while
3801            // the registry still reads live (silent-failure review).
3802            let claude_name = name.to_string();
3803            if let Err(e) = update_registry_offloaded(ctx.home.registry_json(), move |r| {
3804                if let Some(e) = r.find_mut(&claude_name) {
3805                    e.status = AgentStatus::Exited;
3806                }
3807            })
3808            .await
3809            {
3810                return Response::err(
3811                    req.id,
3812                    ErrorCode::Internal,
3813                    format!("claude {name} stopped but registry write failed: {e}"),
3814                );
3815            }
3816            let _ = ctx
3817                .emitter
3818                .emit("agent_stopped", &json!({"name": name, "backend": "claude"}));
3819            // Report the id we actually stopped with (`short`), not
3820            // `entry.short_id`: a Python-authored claude row has an empty
3821            // short_id but a populated claude_short_id, so echoing entry.short_id
3822            // would print `stopped: <name> ()` and break the stop output
3823            // contract for exactly the rows ab-e5a57efa makes readable (Codex P2).
3824            Response::ok(
3825                req.id,
3826                json!({"stopped": true, "backend": "claude", "short_id": short}),
3827            )
3828        }
3829        Ok(o) => Response::err(
3830            req.id,
3831            ErrorCode::Internal,
3832            format!(
3833                "claude stop {short} failed: {}",
3834                String::from_utf8_lossy(&o.stderr).trim()
3835            ),
3836        ),
3837        Err(e) => Response::err(
3838            req.id,
3839            ErrorCode::Internal,
3840            format!("could not exec `claude stop`: {e}"),
3841        ),
3842    }
3843}
3844
3845async fn handle_rm(ctx: &Ctx, req: &Request) -> Response {
3846    let name = match req.params.get("name").and_then(|v| v.as_str()) {
3847        Some(n) => n.to_string(),
3848        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `name`"),
3849    };
3850    let force = req
3851        .params
3852        .get("force")
3853        .and_then(|v| v.as_bool())
3854        .unwrap_or(false);
3855    let registry = load_registry_offloaded(ctx.home.registry_json()).await;
3856    let entry = match registry.find(&name) {
3857        Some(e) => e.clone(),
3858        None => {
3859            return Response::err(
3860                req.id,
3861                ErrorCode::AgentNotFound,
3862                format!("agent {name} not found"),
3863            )
3864        }
3865    };
3866    // Driver-awareness (US6.8, LD27): rm refuses UNCONDITIONALLY while a driver
3867    // is active — even with `--force`. Unlike `stop`, `--force` does NOT evict
3868    // here; the operator must detach -> stop -> wait `exited` -> rm. Exit 18.
3869    if let Some((sid, _mode)) = crate::drive::controlling_driver(&ctx.drives, &entry.short_id).await
3870    {
3871        return Response::err(
3872            req.id,
3873            ErrorCode::Busy,
3874            format!(
3875                "agent {name} has an active driver (session {sid}); detach, stop, and wait for exited before rm (--force does NOT override this)"
3876            ),
3877        );
3878    }
3879    if entry.status == AgentStatus::Live && !force {
3880        return Response::err(
3881            req.id,
3882            ErrorCode::Busy,
3883            format!("agent {name} is still live; use `stop` first or pass --force"),
3884        );
3885    }
3886    // Force-removing a live agent must stop its worker first, or it leaks a PTY
3887    // process that `list`/`stop` can no longer address by name (Codex P2).
3888    if entry.status == AgentStatus::Live && force && !stop_worker_confirmed(ctx, &entry).await {
3889        return Response::err(
3890            req.id,
3891            ErrorCode::Internal,
3892            format!("agent {name}: could not stop the worker before force-remove; refusing to orphan a live PTY"),
3893        );
3894    }
3895    // Orphaned entries are removed with no subprocess action (AC8-FR); the
3896    // distinction is surfaced in the event for the operator's audit trail.
3897    let was_orphaned = entry.status == AgentStatus::Orphaned;
3898    // Surface a removal-write failure rather than reporting removed:true while
3899    // the entry still persists (silent-failure review): a force-rm has already
3900    // killed the worker, so a swallowed write leaves a dangling row pointing at
3901    // a dead worker.
3902    let rm_name = name.clone();
3903    if let Err(e) = update_registry_offloaded(ctx.home.registry_json(), move |r| {
3904        r.entries.retain(|e| e.name != rm_name);
3905    })
3906    .await
3907    {
3908        return Response::err(
3909            req.id,
3910            ErrorCode::Internal,
3911            format!("agent {name}: removal did not persist: {e}"),
3912        );
3913    }
3914    let _ = ctx.emitter.emit(
3915        "agent_removed",
3916        &json!({"name": name, "was_orphaned": was_orphaned}),
3917    );
3918    Response::ok(
3919        req.id,
3920        json!({"removed": true, "was_orphaned": was_orphaned}),
3921    )
3922}
3923
3924/// `reachability` per-call timeout (LD30): a single provider probe is bounded.
3925const RECONCILE_PROBE_TIMEOUT: Duration = Duration::from_millis(250);
3926/// Total reconcile sweep budget (LD30): beyond it, remaining agents defer to the
3927/// next tick so a large registry never blocks the daemon for long.
3928const RECONCILE_SWEEP_BUDGET: Duration = Duration::from_secs(5);
3929
3930/// A status change reconcile decided for one probed entry. `new_status: None`
3931/// means "probed, status unchanged" — its `last_reconciled_at` is still bumped
3932/// so the fairness ordering rotates.
3933struct ReconcileChange {
3934    name: String,
3935    new_status: Option<AgentStatus>,
3936}
3937
3938/// What a reconcile sweep did, for the `reconcile_done` event and tests.
3939#[derive(Default, PartialEq, Debug)]
3940struct ReconcileOutcome {
3941    updated: Vec<String>,
3942    orphans: Vec<String>,
3943    recovered: Vec<String>,
3944    /// `(name, reason)` for entries whose probe was inconclusive (status
3945    /// preserved, never flipped).
3946    inconsistent: Vec<(String, String)>,
3947    /// Count of trailing entries not probed because the budget elapsed.
3948    deferred: usize,
3949}
3950
3951/// Plan a reconcile sweep over `entries` (which the caller has ordered ASC by
3952/// `last_reconciled_at` for fairness). Pure of clock and I/O: `probe` answers
3953/// reachability tri-state per entry and `budget_exhausted` reports whether the
3954/// sweep budget has elapsed — both injected so the budget/fairness/tri-state
3955/// logic is deterministically unit-testable (the daemon wires the real provider
3956/// probe + a wall-clock deadline).
3957///
3958/// Transition rules (status-aware, design AC9):
3959/// - `Ok(true)` (reachable): recover an `Orphaned` entry to `Live`; leave any
3960///   other status (live-ish or terminal) unchanged.
3961/// - `Ok(false)` (unreachable): flip a live-ish entry to `Orphaned`; leave an
3962///   already-`Orphaned` or terminal (`Exited`/`PermanentDead`) entry unchanged.
3963/// - `Err` (inconclusive): preserve status, record an inconsistency. Never
3964///   orphan on a probe timeout (Failure Modes / Errors invariant).
3965fn plan_reconcile<P, D, L>(
3966    entries: &[RegistryEntry],
3967    mut probe: P,
3968    mut budget_exhausted: D,
3969    mut pid_live: L,
3970) -> (Vec<ReconcileChange>, ReconcileOutcome)
3971where
3972    P: FnMut(&RegistryEntry) -> Result<bool, crate::provider::ReachabilityProbeError>,
3973    D: FnMut() -> bool,
3974    L: FnMut(&RegistryEntry) -> bool,
3975{
3976    let mut changes = Vec::new();
3977    let mut out = ReconcileOutcome::default();
3978    for (i, entry) in entries.iter().enumerate() {
3979        if budget_exhausted() {
3980            out.deferred = entries.len() - i;
3981            break;
3982        }
3983        // A one-shot `ask` agent has no daemon-managed process, so its liveness is
3984        // decided by process-liveness alone (it has none): terminal `exited`.
3985        // Session-file reachability answers "resumable?" (surfaced via session_id),
3986        // never "running?" -- so a surviving session file must NOT keep an ask row
3987        // `live`. This is the actual cause of the reported stale-`live` rows: the
3988        // `probe` is skipped entirely here, so no provider reachability call can
3989        // decide an ask row's status. An already-terminal ask is left untouched.
3990        // [plan ab-70faa65b, Locked Decision #1]
3991        if entry.is_one_shot_ask() {
3992            let new_status = if is_non_terminal(entry.status) {
3993                out.updated.push(entry.name.clone());
3994                Some(AgentStatus::Exited)
3995            } else {
3996                None
3997            };
3998            changes.push(ReconcileChange {
3999                name: entry.name.clone(),
4000                new_status,
4001            });
4002            continue;
4003        }
4004        let new_status = match probe(entry) {
4005            Ok(true) => {
4006                if entry.status == AgentStatus::Orphaned {
4007                    out.recovered.push(entry.name.clone());
4008                    out.updated.push(entry.name.clone());
4009                    Some(AgentStatus::Live)
4010                } else {
4011                    None
4012                }
4013            }
4014            Ok(false) if entry.is_interactive() => {
4015                // host_mode=interactive (task 2.3 / US4): an interactive host is a
4016                // long-lived drivable TUI whose liveness is its PTY *process*, not
4017                // session-store membership. A live `codex resume`/`gemini -r` TUI
4018                // may not appear in the exec session index, so a store miss must
4019                // NOT orphan a healthy worker. But a genuinely dead interactive
4020                // worker must still be reaped DURING `reconcile` -- not only at
4021                // daemon restart via recover()'s pid sweep -- so check pid
4022                // liveness here and flip to Exited when the worker process is gone
4023                // ("unexpected exit is exited, not orphaned"; Codex P2, PR #373).
4024                if pid_live(entry) {
4025                    None
4026                } else {
4027                    out.updated.push(entry.name.clone());
4028                    Some(AgentStatus::Exited)
4029                }
4030            }
4031            Ok(false) => {
4032                // Only states that *should* have a live backend can go stale.
4033                // Restarting / Failed are intentionally excluded: the restart
4034                // supervisor owns those agents' lifecycle (backoff -> re-spawn
4035                // or permanent_dead), so reconcile must not race it by flipping
4036                // a mid-restart agent to orphaned. Terminal states (Exited /
4037                // PermanentDead) are likewise left alone.
4038                let live_ish = matches!(
4039                    entry.status,
4040                    AgentStatus::Live
4041                        | AgentStatus::Ready
4042                        | AgentStatus::Idle
4043                        | AgentStatus::Busy
4044                        | AgentStatus::Spawning
4045                );
4046                if live_ish {
4047                    out.orphans.push(entry.name.clone());
4048                    out.updated.push(entry.name.clone());
4049                    Some(AgentStatus::Orphaned)
4050                } else {
4051                    None
4052                }
4053            }
4054            Err(e) => {
4055                out.inconsistent
4056                    .push((entry.name.clone(), e.reason.clone()));
4057                None
4058            }
4059        };
4060        changes.push(ReconcileChange {
4061            name: entry.name.clone(),
4062            new_status,
4063        });
4064    }
4065    (changes, out)
4066}
4067
4068/// Apply one planned reconcile change to its registry row. Always freshens
4069/// `last_reconciled_at` (the probe was *attempted*, so `CHECKED` rotates even on
4070/// an inconclusive/no-change probe). On a status change, sets the new status and
4071/// -- when it is terminal `Exited` -- nulls `pid`/`pid_start_time` so `list`/
4072/// `--json` never surfaces a pid that no longer belongs to the agent (Locked
4073/// Decision #7: a stale pid is exactly the misleading liveness signal this work
4074/// removes; forensics live in the event log, not a dangling registry pid). The
4075/// pid is cleared only on `Exited` (the lone terminal status reconcile produces)
4076/// -- an `Orphaned` row keeps its pid, which is still the live-but-unowned
4077/// process an operator may want to `ps`/signal while investigating the orphan.
4078fn apply_reconcile_change(e: &mut RegistryEntry, new_status: Option<AgentStatus>, now: &str) {
4079    e.last_reconciled_at = Some(now.to_string());
4080    if let Some(s) = new_status {
4081        e.status = s;
4082        if matches!(s, AgentStatus::Exited) {
4083            e.pid = None;
4084            e.pid_start_time = None;
4085        }
4086    }
4087}
4088
4089/// Build the lean provider-probe projection from a registry row, preferring the
4090/// provider-specific session id over the generic one.
4091fn to_agent_entry(e: &RegistryEntry) -> crate::provider::AgentEntry {
4092    let session_id = match e.provider.as_str() {
4093        "codex" => e.codex_session_id.clone().or_else(|| e.session_id.clone()),
4094        "gemini" => e.gemini_session_id.clone().or_else(|| e.session_id.clone()),
4095        "claude" => e.claude_short_id.clone().or_else(|| e.session_id.clone()),
4096        _ => e.session_id.clone(),
4097    };
4098    crate::provider::AgentEntry {
4099        name: e.name.clone(),
4100        provider: e.provider.clone(),
4101        session_id,
4102        cwd: PathBuf::from(&e.cwd),
4103    }
4104}
4105
4106/// Everything the `reconcile` RPC needs to render its response, returned by
4107/// [`run_reconcile_sweep`] so the bounded sweep core is shared with the daemon's
4108/// startup pass (Architecture B, plan ab-70faa65b).
4109struct ReconcileSweepResult {
4110    /// Registry snapshot read at sweep start (per-name provider lookup).
4111    registry: crate::state::Registry,
4112    /// Entries in fairness order (ASC `last_reconciled_at`), as probed.
4113    entries: Vec<RegistryEntry>,
4114    outcome: ReconcileOutcome,
4115}
4116
4117/// Run ONE bounded reconcile sweep and persist it: probe each agent
4118/// least-recently-reconciled-first (250ms/probe, 5s total budget), settle status
4119/// by process-liveness (Architecture A), then batch-write every change + freshen
4120/// `last_reconciled_at` under one registry lock. Emits the same
4121/// `agent_inconsistent` / `reconcile_deferred` / `reconcile_done` events as
4122/// before. Returns the snapshot + outcome on success, or an error string when
4123/// the registry write fails (the registry is then unchanged, so callers degrade
4124/// to serving last-recorded status rather than reporting a sweep that did not
4125/// apply -- Codex P1). Shared by the `reconcile` RPC and the startup sweep.
4126fn run_reconcile_sweep(
4127    home: &AgentsHome,
4128    emitter: &EventEmitter,
4129) -> Result<ReconcileSweepResult, String> {
4130    use crate::provider::ReachabilityProbeError;
4131    let registry = state::load_registry(&home.registry_json()).unwrap_or_default();
4132
4133    // Fairness: probe least-recently-reconciled first (None < Some), so a
4134    // budget-exhausted sweep eventually covers every entry (finding #1).
4135    let mut entries = registry.entries.clone();
4136    entries.sort_by(|a, b| a.last_reconciled_at.cmp(&b.last_reconciled_at));
4137
4138    let start = Instant::now();
4139    let probe = |e: &RegistryEntry| -> Result<bool, ReachabilityProbeError> {
4140        // Fast path: a reachable worker socket is authoritative, PID-reuse-immune
4141        // liveness for a PTY-managed agent — no provider probe (and no 250ms
4142        // cost) needed. A sync connect is fine: reconcile runs on the blocking
4143        // pool (Codex P1: do not trust a possibly-stale registry pid).
4144        if std::os::unix::net::UnixStream::connect(home.worker_sock(&e.short_id)).is_ok() {
4145            return Ok(true);
4146        }
4147        // No live worker: ask the provider's session store (tri-state).
4148        match crate::provider::for_name(&e.provider) {
4149            Some(p) => p.reachability(&to_agent_entry(e), RECONCILE_PROBE_TIMEOUT),
4150            None => Err(ReachabilityProbeError::new(
4151                &e.provider,
4152                "unknown provider; cannot probe reachability",
4153            )),
4154        }
4155    };
4156    // pid-liveness for interactive hosts (Codex P2): a row with a recorded pid
4157    // that is no longer OUR live worker is a dead interactive host to reap to
4158    // Exited. A row with no pid is left alone (mirrors recover()'s sweep, which
4159    // only acts on entries that carry a pid).
4160    let pid_live = |e: &RegistryEntry| -> bool {
4161        e.pid.map_or(true, |pid| pid_is_ours(pid, e.pid_start_time))
4162    };
4163    let (changes, outcome) = plan_reconcile(
4164        &entries,
4165        probe,
4166        || start.elapsed() >= RECONCILE_SWEEP_BUDGET,
4167        pid_live,
4168    );
4169
4170    // Single batched write (US4-gemini pattern): apply all status changes and
4171    // bump last_reconciled_at for every probed entry in one lock window.
4172    let now = now_rfc3339_like();
4173    // Surface a persistence failure rather than emitting reconcile_done and
4174    // returning updated/orphans/recovered as if the sweep applied (Codex P1): on
4175    // a lock/IO failure the registry is unchanged, so reporting success would
4176    // mislead automation and hide stale lifecycle state.
4177    if let Err(err) = state::update_registry(&home.registry_json(), |r| {
4178        for ch in &changes {
4179            if let Some(e) = r.find_mut(&ch.name) {
4180                apply_reconcile_change(e, ch.new_status, &now);
4181            }
4182        }
4183    }) {
4184        let _ = emitter.emit("reconcile_error", &json!({"error": err.to_string()}));
4185        return Err(format!(
4186            "reconcile computed {} change(s) but the registry write failed: {err}",
4187            changes.len()
4188        ));
4189    }
4190
4191    for (name, reason) in &outcome.inconsistent {
4192        let _ = emitter.emit(
4193            "agent_inconsistent",
4194            &json!({"name": name, "reason": reason}),
4195        );
4196    }
4197    if outcome.deferred > 0 {
4198        let _ = emitter.emit(
4199            "reconcile_deferred",
4200            &json!({"remaining_count": outcome.deferred}),
4201        );
4202    }
4203    let _ = emitter.emit(
4204        "reconcile_done",
4205        &json!({
4206            "updated": outcome.updated.len(),
4207            "orphans": outcome.orphans.len(),
4208            "recovered": outcome.recovered.len(),
4209        }),
4210    );
4211    Ok(ReconcileSweepResult {
4212        registry,
4213        entries,
4214        outcome,
4215    })
4216}
4217
4218fn handle_reconcile(ctx: &Ctx, req: &Request) -> Response {
4219    let ReconcileSweepResult {
4220        registry,
4221        entries,
4222        outcome,
4223    } = match run_reconcile_sweep(&ctx.home, &ctx.emitter) {
4224        Ok(r) => r,
4225        Err(msg) => return Response::err(req.id, ErrorCode::Internal, msg),
4226    };
4227    // Task 3.1: emit the Python ReconcileResult JSON shape so the Rust client
4228    // can render --json output matching Python's cmd_reconcile contract:
4229    //   scanned, orphaned[], recovered[], skipped[], errors[]
4230    //
4231    // Mapping from internal outcome fields:
4232    //   scanned = total entries (matches Python `scanned=len(entries)`)
4233    //   orphaned = outcome.orphans wrapped as [{name, provider}] dicts
4234    //   recovered = outcome.recovered wrapped as [{name, provider}] dicts
4235    //   skipped = deferred entries, wrapped as [{name, provider}] dicts
4236    //   errors = inconsistent probes wrapped as [{name, reason}] dicts
4237    //
4238    // Legacy fields (updated, orphans, inconsistent, deferred) are preserved for
4239    // backward compat with any existing callers reading the raw daemon response.
4240    //
4241    // Python reports `scanned=len(entries)` (all entries, including the deferred
4242    // tail) and `skipped` as a separate list of the deferred entries; skipped is
4243    // a subset of scanned, not subtracted from it. The daemon previously reported
4244    // `scanned = entries - deferred`, a count-only divergence (cv-5b1a4164).
4245    let scanned = entries.len();
4246    // plan_reconcile probes the (least-recently-reconciled-first) sorted entries
4247    // in order and defers the tail when the sweep budget is exhausted, so the
4248    // deferred entries are exactly entries[probed..]. `probed` is the boundary,
4249    // distinct from the reported `scanned` count above (gemini-code-assist medium
4250    // on PR #361; closes carveout cv-5b1a4164's skipped half).
4251    let probed = entries.len() - outcome.deferred;
4252    let skipped_py: Vec<Value> = entries
4253        .iter()
4254        .skip(probed)
4255        .map(|e| json!({"name": e.name, "provider": e.provider}))
4256        .collect();
4257    let orphaned_py: Vec<Value> = outcome
4258        .orphans
4259        .iter()
4260        .map(|n| {
4261            let prov = registry
4262                .entries
4263                .iter()
4264                .find(|e| &e.name == n)
4265                .map(|e| e.provider.as_str())
4266                .unwrap_or("unknown");
4267            json!({"name": n, "provider": prov})
4268        })
4269        .collect();
4270    let recovered_py: Vec<Value> = outcome
4271        .recovered
4272        .iter()
4273        .map(|n| {
4274            let prov = registry
4275                .entries
4276                .iter()
4277                .find(|e| &e.name == n)
4278                .map(|e| e.provider.as_str())
4279                .unwrap_or("unknown");
4280            json!({"name": n, "provider": prov})
4281        })
4282        .collect();
4283    let errors_py: Vec<Value> = outcome
4284        .inconsistent
4285        .iter()
4286        .map(|(n, reason)| json!({"name": n, "reason": reason}))
4287        .collect();
4288    Response::ok(
4289        req.id,
4290        json!({
4291            // Python-matching keys (Task 3.1 parity contract)
4292            "scanned": scanned,
4293            "orphaned": orphaned_py,
4294            "recovered": recovered_py,
4295            "skipped": skipped_py,
4296            "errors": errors_py,
4297            // Legacy internal keys (backward compat)
4298            "updated": outcome.updated,
4299            "orphans": outcome.orphans,
4300            "inconsistent": outcome.inconsistent.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
4301            "deferred": outcome.deferred,
4302        }),
4303    )
4304}
4305
4306// ---------------------------------------------------------------------------
4307// channel.* (Phase 5 integration point; minimal Wave 3 surface).
4308// ---------------------------------------------------------------------------
4309
4310async fn dispatch_channel(ctx: &Arc<Ctx>, req: &Request) -> Response {
4311    // All channel handlers are pure flock + CPU; run on the blocking pool.
4312    match Namespace::verb(&req.method) {
4313        Some("register_channel") => run_blocking(ctx, req, handle_register_channel).await,
4314        Some("unregister_channel") => run_blocking(ctx, req, handle_unregister_channel).await,
4315        Some("push_to_channel") => run_blocking(ctx, req, handle_push_to_channel).await,
4316        _ => Response::err(
4317            req.id,
4318            ErrorCode::UnknownMethod,
4319            format!("unknown channel verb in `{}`", req.method),
4320        ),
4321    }
4322}
4323
4324fn handle_register_channel(ctx: &Ctx, req: &Request) -> Response {
4325    let cc_session_id = match req.params.get("cc_session_id").and_then(|v| v.as_str()) {
4326        Some(s) => s.to_string(),
4327        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `cc_session_id`"),
4328    };
4329    // Resolve the target agent: by name if given, else by matching cc_session_id.
4330    let name = req
4331        .params
4332        .get("name")
4333        .and_then(|v| v.as_str())
4334        .map(String::from);
4335    let channel_id = uuid_v4();
4336    let mut matched = false;
4337    // Surface a persist failure: without this, `matched` could be set in the
4338    // closure and the handler would return a successful mcp_channel_id even
4339    // though the mapping never hit disk, causing immediate routing drift
4340    // (Codex P1).
4341    if let Err(e) = state::update_registry(&ctx.home.registry_json(), |r| {
4342        let target = match &name {
4343            Some(n) => r.find_mut(n),
4344            None => r
4345                .entries
4346                .iter_mut()
4347                .find(|e| e.cc_session_id.as_deref() == Some(&cc_session_id)),
4348        };
4349        if let Some(e) = target {
4350            e.cc_session_id = Some(cc_session_id.clone());
4351            e.mcp_channel_id = Some(channel_id.clone());
4352            matched = true;
4353        }
4354    }) {
4355        return Response::err(
4356            req.id,
4357            ErrorCode::Internal,
4358            format!("registry write failed during channel registration: {e}"),
4359        );
4360    }
4361    if !matched {
4362        return Response::err(
4363            req.id,
4364            ErrorCode::ChannelUnknown,
4365            "no agent matched cc_session_id/name for registration",
4366        );
4367    }
4368    let _ = ctx
4369        .emitter
4370        .emit("channel_registered", &json!({"mcp_channel_id": channel_id}));
4371    Response::ok(req.id, json!({"mcp_channel_id": channel_id}))
4372}
4373
4374fn handle_unregister_channel(ctx: &Ctx, req: &Request) -> Response {
4375    let channel_id = match req.params.get("mcp_channel_id").and_then(|v| v.as_str()) {
4376        Some(s) => s.to_string(),
4377        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `mcp_channel_id`"),
4378    };
4379    let mut cleared = false;
4380    let _ = state::update_registry(&ctx.home.registry_json(), |r| {
4381        for e in r.entries.iter_mut() {
4382            if e.mcp_channel_id.as_deref() == Some(&channel_id) {
4383                e.mcp_channel_id = None;
4384                cleared = true;
4385            }
4386        }
4387    });
4388    if !cleared {
4389        return Response::err(req.id, ErrorCode::ChannelUnknown, "unknown channel id");
4390    }
4391    Response::ok(req.id, json!({"unregistered": true}))
4392}
4393
4394fn handle_push_to_channel(ctx: &Ctx, req: &Request) -> Response {
4395    let channel_id = match req.params.get("mcp_channel_id").and_then(|v| v.as_str()) {
4396        Some(s) => s.to_string(),
4397        None => return Response::err(req.id, ErrorCode::InvalidParams, "missing `mcp_channel_id`"),
4398    };
4399    let registry = state::load_registry(&ctx.home.registry_json()).unwrap_or_default();
4400    let found = registry
4401        .entries
4402        .iter()
4403        .any(|e| e.mcp_channel_id.as_deref() == Some(&channel_id));
4404    if !found {
4405        return Response::err(
4406            req.id,
4407            ErrorCode::ChannelUnknown,
4408            "channel id not registered (channel server should re-register)",
4409        );
4410    }
4411    // Routing the poke to the CC session's child pipe is the channel server's
4412    // job; the daemon confirms the route exists.
4413    Response::ok(req.id, json!({"routed": true}))
4414}
4415
4416// ---------------------------------------------------------------------------
4417// Small helpers.
4418// ---------------------------------------------------------------------------
4419
4420fn json_obj(pairs: &[(&str, Value)]) -> Map<String, Value> {
4421    let mut m = Map::new();
4422    for (k, v) in pairs {
4423        m.insert((*k).to_string(), v.clone());
4424    }
4425    m
4426}
4427
4428/// Compact UTC timestamp for filesystem names (`20260524T023300Z`).
4429fn now_compact() -> String {
4430    let secs = std::time::SystemTime::now()
4431        .duration_since(std::time::UNIX_EPOCH)
4432        .unwrap_or_default()
4433        .as_secs();
4434    let (y, mo, d, h, mi, s) = civil(secs);
4435    format!("{y:04}{mo:02}{d:02}T{h:02}{mi:02}{s:02}Z")
4436}
4437
4438/// RFC3339-like timestamp for the registry's `created_at` / `last_message_at`.
4439fn now_rfc3339_like() -> String {
4440    let secs = std::time::SystemTime::now()
4441        .duration_since(std::time::UNIX_EPOCH)
4442        .unwrap_or_default()
4443        .as_secs();
4444    let (y, mo, d, h, mi, s) = civil(secs);
4445    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
4446}
4447
4448fn civil(secs: u64) -> (i64, u32, u32, u32, u32, u32) {
4449    let days = (secs / 86_400) as i64;
4450    let rem = secs % 86_400;
4451    let (hh, mm, ss) = (
4452        (rem / 3600) as u32,
4453        ((rem % 3600) / 60) as u32,
4454        (rem % 60) as u32,
4455    );
4456    let z = days + 719_468;
4457    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
4458    let doe = z - era * 146_097;
4459    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
4460    let y = yoe + era * 400;
4461    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
4462    let mp = (5 * doy + 2) / 153;
4463    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
4464    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
4465    (if m <= 2 { y + 1 } else { y }, m, d, hh, mm, ss)
4466}
4467
4468/// Generate a RFC 4122 v4 UUID from OS randomness (`getentropy`/urandom via
4469/// libc). No `uuid` crate dependency; the daemon needs exactly one generator.
4470fn uuid_v4() -> String {
4471    let mut b = [0u8; 16];
4472    fill_random(&mut b);
4473    b[6] = (b[6] & 0x0f) | 0x40; // version 4
4474    b[8] = (b[8] & 0x3f) | 0x80; // variant 10
4475    format!(
4476        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
4477        b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13],
4478        b[14], b[15]
4479    )
4480}
4481
4482fn fill_random(buf: &mut [u8]) {
4483    // Read from /dev/urandom; if unavailable, fall back to a time+pid mix (the
4484    // mcp_channel_id uniqueness invariant tolerates this degraded path because
4485    // collisions across one daemon's lifetime are astronomically unlikely).
4486    if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
4487        use std::io::Read;
4488        if f.read_exact(buf).is_ok() {
4489            return;
4490        }
4491    }
4492    let seed = std::time::SystemTime::now()
4493        .duration_since(std::time::UNIX_EPOCH)
4494        .unwrap_or_default()
4495        .as_nanos() as u64
4496        ^ (std::process::id() as u64).rotate_left(17);
4497    let mut x = seed | 1;
4498    for byte in buf.iter_mut() {
4499        // xorshift64
4500        x ^= x << 13;
4501        x ^= x >> 7;
4502        x ^= x << 17;
4503        *byte = (x & 0xff) as u8;
4504    }
4505}
4506
4507#[cfg(test)]
4508mod tests {
4509    use super::*;
4510    use crate::state::{AgentState, DriveWindow, PtyState};
4511    use crate::worker::{run as run_worker, WorkerConfig};
4512
4513    /// Set the per-thread injection gate override for this test. Returns a guard
4514    /// that clears the override when dropped, so tests clean up automatically.
4515    fn with_injection_gate_allow(providers: &str) -> impl Drop {
4516        INJECTION_GATE_ALLOW.with(|cell| {
4517            *cell.borrow_mut() = Some(providers.to_string());
4518        });
4519        struct ClearGuard;
4520        impl Drop for ClearGuard {
4521            fn drop(&mut self) {
4522                INJECTION_GATE_ALLOW.with(|cell| {
4523                    *cell.borrow_mut() = None;
4524                });
4525            }
4526        }
4527        ClearGuard
4528    }
4529
4530    fn tmp_home(tag: &str) -> AgentsHome {
4531        let mut p = std::env::temp_dir();
4532        p.push(format!(
4533            "fno-agents-daemon-{}-{}-{}",
4534            tag,
4535            std::process::id(),
4536            std::time::SystemTime::now()
4537                .duration_since(std::time::UNIX_EPOCH)
4538                .unwrap()
4539                .as_nanos()
4540        ));
4541        let home = AgentsHome::at(&p);
4542        home.ensure_root().unwrap();
4543        home
4544    }
4545
4546    fn read_events(home: &AgentsHome) -> Vec<Value> {
4547        std::fs::read_to_string(home.events_jsonl())
4548            .unwrap_or_default()
4549            .lines()
4550            .filter_map(|l| serde_json::from_str::<Value>(l).ok())
4551            .collect()
4552    }
4553
4554    #[test]
4555    fn recovery_emits_drive_crashed_before_clearing_window() {
4556        let home = tmp_home("recover-drive");
4557        let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
4558
4559        // Registry entry + state.json with a stale active drive window.
4560        state::update_registry(&home.registry_json(), |r| {
4561            r.entries.push(RegistryEntry {
4562                name: "worker-A".into(),
4563                short_id: "wkA".into(),
4564                provider: "codex".into(),
4565                cwd: "/tmp".into(),
4566                project_root: "/tmp".into(),
4567                session_id: None,
4568                claude_short_id: None,
4569                claude_session_uuid: None,
4570                messaging_socket_path: None,
4571                codex_session_id: None,
4572                gemini_session_id: None,
4573                mcp_channel_id: None,
4574                cc_session_id: None,
4575                host_mode: None,
4576                status: AgentStatus::Live,
4577                last_message_at: None,
4578                created_at: "2026-05-24T00:00:00Z".into(),
4579                pid: Some(std::process::id()), // alive -> not reaped
4580                pid_start_time: None,
4581                log_path: None,
4582                last_reconciled_at: None,
4583            });
4584        })
4585        .unwrap();
4586        let mut st = AgentState::new_pty("wkA");
4587        st.status = AgentStatus::Live;
4588        st.pty = Some(PtyState {
4589            active: true,
4590            drive: Some(DriveWindow {
4591                session_id: Some("drive-xyz".into()),
4592                mode: Some("interactive".into()),
4593                last_heartbeat_at_monotonic_ns: Some(123),
4594            }),
4595        });
4596        state::write_state_atomic(&home.state_json("wkA"), &st).unwrap();
4597
4598        let report = recover(&home, &emitter);
4599        assert_eq!(report.recovered_drives, vec!["wkA".to_string()]);
4600
4601        // drive_crashed emitted, carrying the session id (proves read-before-clear).
4602        let events = read_events(&home);
4603        let crashed = events
4604            .iter()
4605            .find(|e| e["kind"] == "drive_crashed")
4606            .expect("drive_crashed emitted");
4607        assert_eq!(crashed["session_id"], "drive-xyz");
4608        assert_eq!(crashed["reason"], "daemon_restart");
4609
4610        // The on-disk state has the window cleared after recovery.
4611        let after = state::load_state(&home.state_json("wkA")).unwrap().unwrap();
4612        let pty = after.pty.unwrap();
4613        assert!(pty.drive.is_none());
4614        std::fs::remove_dir_all(home.root()).ok();
4615    }
4616
4617    #[test]
4618    fn recovery_marks_missing_state_inconsistent() {
4619        let home = tmp_home("recover-missing");
4620        let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
4621        state::update_registry(&home.registry_json(), |r| {
4622            r.entries.push(RegistryEntry {
4623                name: "ghost".into(),
4624                short_id: "ghost".into(),
4625                provider: "codex".into(),
4626                cwd: "/tmp".into(),
4627                project_root: "/tmp".into(),
4628                session_id: None,
4629                claude_short_id: None,
4630                claude_session_uuid: None,
4631                messaging_socket_path: None,
4632                codex_session_id: None,
4633                gemini_session_id: None,
4634                mcp_channel_id: None,
4635                cc_session_id: None,
4636                host_mode: None,
4637                status: AgentStatus::Live,
4638                last_message_at: None,
4639                created_at: "2026-05-24T00:00:00Z".into(),
4640                pid: None,
4641                pid_start_time: None,
4642                log_path: None,
4643                last_reconciled_at: None,
4644            });
4645        })
4646        .unwrap();
4647        // No state.json written for "ghost".
4648        let report = recover(&home, &emitter);
4649        assert_eq!(
4650            report.inconsistent,
4651            vec![("ghost".to_string(), InconsistencyReason::MissingStateJson)]
4652        );
4653        let events = read_events(&home);
4654        assert!(events
4655            .iter()
4656            .any(|e| e["kind"] == "agent_inconsistent" && e["reason"] == "missing_state_json"));
4657        std::fs::remove_dir_all(home.root()).ok();
4658    }
4659
4660    #[test]
4661    fn recovery_reaps_dead_pid() {
4662        let home = tmp_home("recover-reap");
4663        let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
4664        state::update_registry(&home.registry_json(), |r| {
4665            r.entries.push(RegistryEntry {
4666                name: "dead".into(),
4667                short_id: "dead".into(),
4668                provider: "codex".into(),
4669                cwd: "/tmp".into(),
4670                project_root: "/tmp".into(),
4671                session_id: None,
4672                claude_short_id: None,
4673                claude_session_uuid: None,
4674                messaging_socket_path: None,
4675                codex_session_id: None,
4676                gemini_session_id: None,
4677                mcp_channel_id: None,
4678                cc_session_id: None,
4679                host_mode: None,
4680                status: AgentStatus::Live,
4681                last_message_at: None,
4682                created_at: "2026-05-24T00:00:00Z".into(),
4683                // PID 2^31-ish: almost certainly not a live process.
4684                pid: Some(0x7fff_fff0),
4685                pid_start_time: None,
4686                log_path: None,
4687                last_reconciled_at: None,
4688            });
4689        })
4690        .unwrap();
4691        // Give it a state.json so it isn't flagged inconsistent.
4692        let mut st = AgentState::new_pty("dead");
4693        st.status = AgentStatus::Live;
4694        state::write_state_atomic(&home.state_json("dead"), &st).unwrap();
4695
4696        let report = recover(&home, &emitter);
4697        assert_eq!(report.reaped_pids, vec![0x7fff_fff0]);
4698        let reg = state::load_registry(&home.registry_json()).unwrap();
4699        assert_eq!(reg.find("dead").unwrap().status, AgentStatus::Exited);
4700        std::fs::remove_dir_all(home.root()).ok();
4701    }
4702
4703    #[test]
4704    fn recovery_marks_dead_interactive_exited_and_preserves_host_mode() {
4705        // AC2-FR (task 2.3): a genuinely dead interactive worker is reaped to
4706        // Exited (the design's "unexpected exit is exited, not orphaned"), and
4707        // its host_mode="interactive" round-trips through recovery unchanged so
4708        // a daemon restart that rediscovers it keeps the field.
4709        let home = tmp_home("recover-interactive");
4710        let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
4711        state::update_registry(&home.registry_json(), |r| {
4712            let mut e = rentry("hosted", AgentStatus::Live, None);
4713            e.host_mode = Some(crate::state::HOST_MODE_INTERACTIVE.to_string());
4714            e.pid = Some(0x7fff_fff0); // not a live process
4715            r.entries.push(e);
4716        })
4717        .unwrap();
4718        let mut st = AgentState::new_pty("hosted");
4719        st.status = AgentStatus::Live;
4720        state::write_state_atomic(&home.state_json("hosted"), &st).unwrap();
4721
4722        let _ = recover(&home, &emitter);
4723        let reg = state::load_registry(&home.registry_json()).unwrap();
4724        let row = reg.find("hosted").unwrap();
4725        assert_eq!(
4726            row.status,
4727            AgentStatus::Exited,
4728            "a dead interactive worker is exited, never orphaned"
4729        );
4730        assert_eq!(
4731            row.host_mode_or_default(),
4732            crate::state::HOST_MODE_INTERACTIVE,
4733            "host_mode must survive recovery"
4734        );
4735        std::fs::remove_dir_all(home.root()).ok();
4736    }
4737
4738    #[test]
4739    fn pid_is_ours_distinguishes_recycled_pid() {
4740        // ab-d19e6458: a live pid whose start time no longer matches the recorded
4741        // one is a recycled pid, not our worker.
4742        let me = std::process::id();
4743        let Some(st) = process_start_time(me) else {
4744            return; // platform without start-time support; nothing to assert
4745        };
4746        assert!(pid_is_ours(me, Some(st)), "correct start time -> ours");
4747        assert!(
4748            !pid_is_ours(me, Some(st.wrapping_add(1))),
4749            "alive but mismatched start time -> recycled, not ours"
4750        );
4751        assert!(
4752            !pid_is_ours(0x7fff_fff0, Some(st)),
4753            "dead pid is never ours"
4754        );
4755        assert!(
4756            pid_is_ours(me, None),
4757            "no recorded start time -> fall back to bare liveness (legacy)"
4758        );
4759    }
4760
4761    #[test]
4762    fn recovery_reaps_recycled_pid() {
4763        // ab-d19e6458: the recorded pid is ALIVE (our own), but its start time
4764        // does not match — the original worker died and the pid was reused by an
4765        // unrelated process. The reap must fire on the start-time mismatch, not
4766        // be fooled by bare liveness.
4767        let home = tmp_home("recover-recycled");
4768        let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
4769        let me = std::process::id();
4770        if process_start_time(me).is_none() {
4771            std::fs::remove_dir_all(home.root()).ok();
4772            return; // start-time unsupported here; reuse detection N/A
4773        }
4774        state::update_registry(&home.registry_json(), |r| {
4775            r.entries.push(RegistryEntry {
4776                name: "recycled".into(),
4777                short_id: "recycled".into(),
4778                provider: "codex".into(),
4779                cwd: "/tmp".into(),
4780                project_root: "/tmp".into(),
4781                session_id: None,
4782                claude_short_id: None,
4783                claude_session_uuid: None,
4784                messaging_socket_path: None,
4785                codex_session_id: None,
4786                gemini_session_id: None,
4787                mcp_channel_id: None,
4788                cc_session_id: None,
4789                host_mode: None,
4790                status: AgentStatus::Live,
4791                last_message_at: None,
4792                created_at: "2026-05-24T00:00:00Z".into(),
4793                pid: Some(me),
4794                // Bogus start time -> mismatch against our real one -> not ours.
4795                pid_start_time: Some(1),
4796                log_path: None,
4797                last_reconciled_at: None,
4798            });
4799        })
4800        .unwrap();
4801        let mut st = AgentState::new_pty("recycled");
4802        st.status = AgentStatus::Live;
4803        state::write_state_atomic(&home.state_json("recycled"), &st).unwrap();
4804
4805        let report = recover(&home, &emitter);
4806        assert_eq!(report.reaped_pids, vec![me]);
4807        let reg = state::load_registry(&home.registry_json()).unwrap();
4808        assert_eq!(reg.find("recycled").unwrap().status, AgentStatus::Exited);
4809        std::fs::remove_dir_all(home.root()).ok();
4810    }
4811
4812    #[test]
4813    fn recovery_archives_orphan_state_dir() {
4814        let home = tmp_home("recover-orphan");
4815        let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
4816        // A state dir with no registry entry.
4817        let mut st = AgentState::new_pty("loner");
4818        st.status = AgentStatus::Live;
4819        state::write_state_atomic(&home.state_json("loner"), &st).unwrap();
4820
4821        let report = recover(&home, &emitter);
4822        assert_eq!(report.archived_orphans, vec!["loner".to_string()]);
4823        assert!(!home.agent_dir("loner").exists(), "orphan dir moved aside");
4824        assert!(home.orphaned_dir().exists());
4825        std::fs::remove_dir_all(home.root()).ok();
4826    }
4827
4828    #[test]
4829    fn agent_name_validation() {
4830        assert!(valid_agent_name("worker-A_1"));
4831        assert!(!valid_agent_name(""));
4832        assert!(!valid_agent_name(&"x".repeat(65)));
4833        assert!(!valid_agent_name("has space"));
4834        assert!(!valid_agent_name("inject;rm"));
4835    }
4836
4837    #[test]
4838    fn uuid_v4_shape_and_uniqueness() {
4839        let a = uuid_v4();
4840        let b = uuid_v4();
4841        assert_ne!(a, b);
4842        assert_eq!(a.len(), 36);
4843        let parts: Vec<&str> = a.split('-').collect();
4844        assert_eq!(
4845            parts.iter().map(|p| p.len()).collect::<Vec<_>>(),
4846            vec![8, 4, 4, 4, 12]
4847        );
4848        // version nibble is 4; variant nibble is 8/9/a/b.
4849        assert_eq!(&a[14..15], "4");
4850        assert!(matches!(&a[19..20], "8" | "9" | "a" | "b"));
4851    }
4852
4853    #[test]
4854    fn short_id_derivation_dedups() {
4855        let mut reg = state::Registry::default();
4856        assert_eq!(derive_short_id("worker-A", &reg), "workerA");
4857        reg.entries.push(RegistryEntry {
4858            name: "x".into(),
4859            short_id: "workerA".into(),
4860            provider: "codex".into(),
4861            cwd: "/".into(),
4862            project_root: "/".into(),
4863            session_id: None,
4864            claude_short_id: None,
4865            claude_session_uuid: None,
4866            messaging_socket_path: None,
4867            codex_session_id: None,
4868            gemini_session_id: None,
4869            mcp_channel_id: None,
4870            cc_session_id: None,
4871            host_mode: None,
4872            status: AgentStatus::Live,
4873            last_message_at: None,
4874            created_at: "t".into(),
4875            pid: None,
4876            pid_start_time: None,
4877            log_path: None,
4878            last_reconciled_at: None,
4879        });
4880        assert_eq!(derive_short_id("worker-A", &reg), "workerA1");
4881    }
4882
4883    // --- plan_reconcile (US6.9): tri-state, status-aware transitions, budget ---
4884
4885    fn rentry(name: &str, status: AgentStatus, last_reconciled: Option<&str>) -> RegistryEntry {
4886        RegistryEntry {
4887            name: name.into(),
4888            short_id: name.into(),
4889            provider: "codex".into(),
4890            cwd: "/tmp".into(),
4891            project_root: "/tmp".into(),
4892            session_id: Some("sid".into()),
4893            claude_short_id: None,
4894            claude_session_uuid: None,
4895            messaging_socket_path: None,
4896            codex_session_id: None,
4897            gemini_session_id: None,
4898            mcp_channel_id: None,
4899            host_mode: None,
4900            cc_session_id: None,
4901            status,
4902            last_message_at: None,
4903            created_at: "t".into(),
4904            pid: None,
4905            pid_start_time: None,
4906            log_path: None,
4907            last_reconciled_at: last_reconciled.map(String::from),
4908        }
4909    }
4910
4911    fn probe_err() -> crate::provider::ReachabilityProbeError {
4912        crate::provider::ReachabilityProbeError::new("codex", "store unavailable")
4913    }
4914
4915    // ---- promote admission (task 3.1) ----
4916
4917    fn codex_source(name: &str, uuid: &str, status: AgentStatus) -> RegistryEntry {
4918        let mut e = rentry(name, status, None);
4919        e.provider = "codex".into();
4920        e.codex_session_id = Some(uuid.into());
4921        e
4922    }
4923
4924    #[test]
4925    fn admit_promote_empty_uuid_rejected() {
4926        match admit_promote(&[], "   ") {
4927            PromoteAdmission::Reject(r) => assert!(r.contains("non-empty")),
4928            other => panic!("expected reject, got {other:?}"),
4929        }
4930    }
4931
4932    #[test]
4933    fn admit_promote_unknown_uuid_rejected() {
4934        // AC1-ERR: no recorded agent holds the session id.
4935        let entries = vec![codex_source("other", "different-uuid", AgentStatus::Live)];
4936        match admit_promote(&entries, "missing-uuid") {
4937            PromoteAdmission::Reject(r) => assert!(r.contains("unknown session")),
4938            other => panic!("expected reject, got {other:?}"),
4939        }
4940    }
4941
4942    #[test]
4943    fn admit_promote_settled_exec_source_infers_provider() {
4944        // AC1-HP admission: a settled exec source (Live, the codex `ask`
4945        // convention) is promotable, and the provider is inferred from it.
4946        let entries = vec![codex_source("bot", "uuid-1", AgentStatus::Live)];
4947        assert_eq!(
4948            admit_promote(&entries, "uuid-1"),
4949            PromoteAdmission::Ok {
4950                provider: "codex".into()
4951            }
4952        );
4953        // gemini source infers gemini.
4954        let mut g = rentry("gbot", AgentStatus::Live, None);
4955        g.provider = "gemini".into();
4956        g.gemini_session_id = Some("uuid-g".into());
4957        assert_eq!(
4958            admit_promote(&[g], "uuid-g"),
4959            PromoteAdmission::Ok {
4960                provider: "gemini".into()
4961            }
4962        );
4963    }
4964
4965    #[test]
4966    fn admit_promote_in_flight_source_rejected() {
4967        // AC2-ERR: a source mid-flight (Busy) is not promotable.
4968        let entries = vec![codex_source("bot", "uuid-1", AgentStatus::Busy)];
4969        match admit_promote(&entries, "uuid-1") {
4970            PromoteAdmission::Reject(r) => assert!(r.contains("still running")),
4971            other => panic!("expected reject, got {other:?}"),
4972        }
4973    }
4974
4975    #[test]
4976    fn admit_promote_already_hosted_rejected() {
4977        // AC2-EDGE: a live interactive host already on this session -> reject the
4978        // second promote (one-host invariant).
4979        let source = codex_source("bot", "uuid-1", AgentStatus::Exited);
4980        let mut host = codex_source("bot2", "uuid-1", AgentStatus::Live);
4981        host.host_mode = Some(crate::state::HOST_MODE_INTERACTIVE.to_string());
4982        match admit_promote(&[source, host], "uuid-1") {
4983            PromoteAdmission::Reject(r) => assert!(r.contains("already hosted")),
4984            other => panic!("expected reject, got {other:?}"),
4985        }
4986    }
4987
4988    #[test]
4989    fn admit_promote_exited_source_is_promotable() {
4990        // An exited exec source is the canonical AC1-HP case.
4991        let entries = vec![codex_source("bot", "uuid-1", AgentStatus::Exited)];
4992        assert_eq!(
4993            admit_promote(&entries, "uuid-1"),
4994            PromoteAdmission::Ok {
4995                provider: "codex".into()
4996            }
4997        );
4998    }
4999
5000    // ---- interactive readiness decision (task 2.2) ----
5001
5002    #[test]
5003    fn interactive_readiness_child_dead_is_failed() {
5004        // The AC1-FR core: a resume that launches then dies within the settle
5005        // window must be spawn-failed regardless of paint/dwell/deadline state.
5006        assert_eq!(
5007            interactive_readiness_step(false, false, false, false),
5008            ReadinessStep::Failed
5009        );
5010        assert_eq!(
5011            interactive_readiness_step(false, true, true, true),
5012            ReadinessStep::Failed
5013        );
5014    }
5015
5016    #[test]
5017    fn interactive_readiness_painted_alive_and_dwelled_is_ready() {
5018        assert_eq!(
5019            interactive_readiness_step(true, true, true, false),
5020            ReadinessStep::Ready
5021        );
5022    }
5023
5024    #[test]
5025    fn interactive_readiness_painted_but_not_dwelled_keeps_polling() {
5026        // Painted + alive but the minimum dwell has not elapsed: keep polling, so
5027        // a paint-then-die (resume error printed then exit) is caught as Failed
5028        // on the next probe rather than passing on its dying first paint.
5029        assert_eq!(
5030            interactive_readiness_step(true, true, false, false),
5031            ReadinessStep::Poll
5032        );
5033    }
5034
5035    #[test]
5036    fn interactive_readiness_alive_unpainted_polls_then_ready_at_deadline() {
5037        // Alive but no paint yet, before the deadline -> keep polling.
5038        assert_eq!(
5039            interactive_readiness_step(true, false, false, false),
5040            ReadinessStep::Poll
5041        );
5042        // Alive at the deadline (slow paint / model warming) -> treat as live;
5043        // never kill a live process on a slow first paint.
5044        assert_eq!(
5045            interactive_readiness_step(true, false, false, true),
5046            ReadinessStep::Ready
5047        );
5048    }
5049
5050    #[test]
5051    fn concurrent_spawn_name_reservation_inserts_once() {
5052        // Codex P1 (PR #365): two concurrent agent.spawn calls for the same name
5053        // both pass the lock-free collision check, then race to push. The
5054        // reservation closure runs inside update_registry's exclusive flock, which
5055        // serializes the two, so the second observes the first's row and must NOT
5056        // duplicate it. update_registry's flock makes sequential calls here a
5057        // faithful stand-in for the serialized concurrent ones.
5058        let home = tmp_home("spawn-reserve");
5059        let path = home.registry_json();
5060        let reserve = |entry: RegistryEntry| -> bool {
5061            state::update_registry(&path, move |r| {
5062                if r.entries.iter().any(|e| e.name == entry.name) {
5063                    return false;
5064                }
5065                r.entries.push(entry);
5066                true
5067            })
5068            .unwrap()
5069        };
5070        assert!(
5071            reserve(rentry("dup", AgentStatus::Live, None)),
5072            "first wins"
5073        );
5074        assert!(
5075            !reserve(rentry("dup", AgentStatus::Live, None)),
5076            "second loses the race -> no insert"
5077        );
5078        let reg = state::load_registry(&path).unwrap();
5079        assert_eq!(
5080            reg.entries.iter().filter(|e| e.name == "dup").count(),
5081            1,
5082            "exactly one row for the contended name"
5083        );
5084        std::fs::remove_dir_all(home.root()).ok();
5085    }
5086
5087    #[test]
5088    fn reconcile_flips_unreachable_live_to_orphaned_and_recovers_orphaned() {
5089        let entries = vec![
5090            rentry("live-but-gone", AgentStatus::Live, None),
5091            rentry("back-from-dead", AgentStatus::Orphaned, None),
5092        ];
5093        let (changes, out) = plan_reconcile(
5094            &entries,
5095            |e| match e.name.as_str() {
5096                "live-but-gone" => Ok(false), // unreachable
5097                _ => Ok(true),                // reachable
5098            },
5099            || false,
5100            |_| true,
5101        );
5102        assert_eq!(out.orphans, vec!["live-but-gone".to_string()]);
5103        assert_eq!(out.recovered, vec!["back-from-dead".to_string()]);
5104        assert_eq!(out.updated.len(), 2);
5105        // Both probed -> both get a status change recorded.
5106        assert_eq!(
5107            changes[0].new_status,
5108            Some(AgentStatus::Orphaned),
5109            "unreachable live agent should orphan"
5110        );
5111        assert_eq!(changes[1].new_status, Some(AgentStatus::Live));
5112    }
5113
5114    #[test]
5115    fn reconcile_does_not_orphan_a_live_interactive_host_on_store_miss() {
5116        // US4 (task 2.3): an interactive host whose session-store probe returns
5117        // unreachable (a live `codex resume`/`gemini -r` TUI may not appear in
5118        // the exec session index) must NOT be orphaned -- its liveness is the PTY
5119        // process, governed by the pid-liveness sweep. An exec sibling with the
5120        // same probe result IS still orphaned, so the branch is host_mode-scoped.
5121        let mut interactive = rentry("hosted-tui", AgentStatus::Live, None);
5122        interactive.host_mode = Some(crate::state::HOST_MODE_INTERACTIVE.to_string());
5123        let exec = rentry("one-shot", AgentStatus::Live, None);
5124        let entries = vec![interactive, exec];
5125        let (changes, out) = plan_reconcile(&entries, |_| Ok(false), || false, |_| true);
5126        assert_eq!(
5127            changes[0].new_status, None,
5128            "a live interactive host must not be orphaned on a session-store miss"
5129        );
5130        assert_eq!(
5131            changes[1].new_status,
5132            Some(AgentStatus::Orphaned),
5133            "an exec sibling with the same probe result is still orphaned"
5134        );
5135        assert_eq!(out.orphans, vec!["one-shot".to_string()]);
5136    }
5137
5138    #[test]
5139    fn reconcile_reaps_a_dead_interactive_host_to_exited() {
5140        // Codex P2 (PR #373): a genuinely dead interactive worker (store-miss AND
5141        // pid no longer live) must be reaped to Exited DURING reconcile, not left
5142        // Live until a daemon restart. A live interactive host (pid_live) on the
5143        // same store-miss stays Live.
5144        let mut dead = rentry("dead-tui", AgentStatus::Live, None);
5145        dead.host_mode = Some(crate::state::HOST_MODE_INTERACTIVE.to_string());
5146        let mut live = rentry("live-tui", AgentStatus::Live, None);
5147        live.host_mode = Some(crate::state::HOST_MODE_INTERACTIVE.to_string());
5148        let entries = vec![dead, live];
5149        let (changes, out) = plan_reconcile(
5150            &entries,
5151            |_| Ok(false), // both store-miss
5152            || false,
5153            |e| e.name == "live-tui", // only live-tui's worker pid is alive
5154        );
5155        assert_eq!(
5156            changes[0].new_status,
5157            Some(AgentStatus::Exited),
5158            "a dead interactive host is reaped to Exited during reconcile"
5159        );
5160        assert_eq!(
5161            changes[1].new_status, None,
5162            "a live interactive host is left untouched"
5163        );
5164        // Reaped to Exited, never orphaned.
5165        assert!(out.orphans.is_empty());
5166        assert_eq!(out.updated, vec!["dead-tui".to_string()]);
5167    }
5168
5169    #[test]
5170    fn reconcile_inconclusive_preserves_status() {
5171        let entries = vec![rentry("flaky", AgentStatus::Live, None)];
5172        let (changes, out) = plan_reconcile(&entries, |_| Err(probe_err()), || false, |_| true);
5173        assert_eq!(changes[0].new_status, None, "must NOT flip on inconclusive");
5174        assert!(out.orphans.is_empty());
5175        assert_eq!(out.inconsistent.len(), 1);
5176        assert_eq!(out.inconsistent[0].0, "flaky");
5177    }
5178
5179    #[test]
5180    fn reconcile_leaves_terminal_states_untouched() {
5181        // An exited entry that probes unreachable must NOT become orphaned, and a
5182        // reachable exited entry must NOT be resurrected to live.
5183        let entries = vec![
5184            rentry("done", AgentStatus::Exited, None),
5185            rentry("dead", AgentStatus::PermanentDead, None),
5186        ];
5187        let (changes, out) = plan_reconcile(&entries, |_| Ok(false), || false, |_| true);
5188        assert!(changes.iter().all(|c| c.new_status.is_none()));
5189        assert!(out.orphans.is_empty() && out.updated.is_empty());
5190    }
5191
5192    /// One-shot `ask` shape: empty short_id + no pid (the discriminator
5193    /// `is_one_shot_ask` keys on), host_mode exec, a resumable provider session.
5194    fn ask_entry(name: &str, status: AgentStatus) -> RegistryEntry {
5195        let mut e = rentry(name, status, None);
5196        e.short_id = String::new();
5197        e.pid = None;
5198        e.codex_session_id = Some("resume-uuid".into());
5199        e.session_id = None;
5200        e
5201    }
5202
5203    #[test]
5204    fn reconcile_one_shot_ask_settles_to_exited_even_when_reachable() {
5205        // AC3-HP: a finished `ask` row settles to Exited regardless of whether its
5206        // provider session file still exists. The probe here returns Ok(true)
5207        // (reachable == session file present == "resumable"); the ask branch must
5208        // ignore it and settle to Exited by process-liveness alone. If the probe
5209        // were (wrongly) consulted for status, this Live row would stay Live.
5210        let entries = vec![ask_entry("codex-ask", AgentStatus::Live)];
5211        let (changes, out) = plan_reconcile(
5212            &entries,
5213            |_| Ok(true), // reachable: session file exists -> resumable, NOT running
5214            || false,
5215            |_| true,
5216        );
5217        assert_eq!(
5218            changes[0].new_status,
5219            Some(AgentStatus::Exited),
5220            "a finished ask settles to exited even when its session file is reachable"
5221        );
5222        assert_eq!(out.updated, vec!["codex-ask".to_string()]);
5223        assert!(out.orphans.is_empty(), "an ask is exited, never orphaned");
5224        // AC3-EDGE independence: the row's resumable session id is untouched by the
5225        // status settle (status == liveness; session_id == resumability, separate).
5226        assert_eq!(entries[0].codex_session_id.as_deref(), Some("resume-uuid"));
5227    }
5228
5229    #[test]
5230    fn reconcile_one_shot_ask_already_terminal_is_untouched() {
5231        // An ask already Exited must not be re-flagged as updated (idempotent).
5232        let entries = vec![ask_entry("done-ask", AgentStatus::Exited)];
5233        let (changes, out) = plan_reconcile(&entries, |_| Ok(true), || false, |_| true);
5234        assert_eq!(changes[0].new_status, None);
5235        assert!(out.updated.is_empty());
5236    }
5237
5238    #[test]
5239    fn apply_reconcile_change_clears_pid_only_on_exited() {
5240        // Locked Decision #7: a row reconciled to Exited drops its pid; any other
5241        // transition keeps it. Every applied change freshens last_reconciled_at.
5242        let mut to_exited = rentry("x", AgentStatus::Live, None);
5243        to_exited.pid = Some(4242);
5244        to_exited.pid_start_time = Some(99);
5245        apply_reconcile_change(&mut to_exited, Some(AgentStatus::Exited), "T1");
5246        assert_eq!(to_exited.status, AgentStatus::Exited);
5247        assert_eq!(to_exited.pid, None, "exited row must drop its pid");
5248        assert_eq!(to_exited.pid_start_time, None);
5249        assert_eq!(to_exited.last_reconciled_at.as_deref(), Some("T1"));
5250
5251        let mut to_orphaned = rentry("y", AgentStatus::Live, None);
5252        to_orphaned.pid = Some(4242);
5253        apply_reconcile_change(&mut to_orphaned, Some(AgentStatus::Orphaned), "T2");
5254        assert_eq!(to_orphaned.status, AgentStatus::Orphaned);
5255        assert_eq!(
5256            to_orphaned.pid,
5257            Some(4242),
5258            "non-exited transition keeps pid"
5259        );
5260
5261        // No status change: status held, but CHECKED still freshens (AC2-FR).
5262        let mut no_change = rentry("z", AgentStatus::Live, Some("OLD"));
5263        no_change.pid = Some(4242);
5264        apply_reconcile_change(&mut no_change, None, "T3");
5265        assert_eq!(no_change.status, AgentStatus::Live);
5266        assert_eq!(no_change.pid, Some(4242));
5267        assert_eq!(no_change.last_reconciled_at.as_deref(), Some("T3"));
5268    }
5269
5270    #[test]
5271    fn reconcile_defers_remaining_when_budget_exhausted() {
5272        let entries = vec![
5273            rentry("a", AgentStatus::Live, None),
5274            rentry("b", AgentStatus::Live, None),
5275            rentry("c", AgentStatus::Live, None),
5276        ];
5277        // Budget allows exactly one probe, then reports exhausted.
5278        let mut probes = 0;
5279        let (changes, out) = plan_reconcile(
5280            &entries,
5281            |_| {
5282                probes += 1;
5283                Ok(true)
5284            },
5285            {
5286                let mut checked = 0;
5287                move || {
5288                    let exhausted = checked >= 1;
5289                    checked += 1;
5290                    exhausted
5291                }
5292            },
5293            |_| true,
5294        );
5295        assert_eq!(out.deferred, 2, "two trailing entries should defer");
5296        assert_eq!(changes.len(), 1, "only one entry probed before budget");
5297    }
5298
5299    #[test]
5300    fn run_reconcile_sweep_empty_registry_is_noop() {
5301        // Boundaries (Architecture B): an empty registry sweeps cleanly -- no
5302        // entries, no changes -- the startup-path no-op case. Exercises the shared
5303        // sweep core (load -> sort -> write -> emit) directly.
5304        let home = tmp_home("sweep-empty");
5305        let emitter = EventEmitter::new(home.events_jsonl(), "daemon");
5306        let result = run_reconcile_sweep(&home, &emitter).expect("empty sweep ok");
5307        assert!(result.entries.is_empty());
5308        assert_eq!(result.outcome, ReconcileOutcome::default());
5309        std::fs::remove_dir_all(home.root()).ok();
5310    }
5311
5312    // ---------------------------------------------------------------------------
5313    // poll_until_ready unit tests (Task 1.1: readiness-detector wiring)
5314    // ---------------------------------------------------------------------------
5315
5316    /// A detector that reports ready as soon as the visible text ends with "❯".
5317    struct PromptDetector;
5318    impl crate::readiness::ReadinessDetector for PromptDetector {
5319        fn provider_name(&self) -> &str {
5320            "test-cli"
5321        }
5322        fn is_ready(
5323            &self,
5324            screen: &crate::readiness::ScreenView,
5325        ) -> Result<bool, crate::readiness::ReadinessError> {
5326            Ok(screen.visible_text.trim_end().ends_with('\u{276f}'))
5327        }
5328    }
5329
5330    /// A detector that always returns not-ready (simulates a hung CLI).
5331    struct NeverReadyDetector;
5332    impl crate::readiness::ReadinessDetector for NeverReadyDetector {
5333        fn provider_name(&self) -> &str {
5334            "never"
5335        }
5336        fn is_ready(
5337            &self,
5338            _screen: &crate::readiness::ScreenView,
5339        ) -> Result<bool, crate::readiness::ReadinessError> {
5340            Ok(false)
5341        }
5342    }
5343
5344    /// AC1-HP: poll_until_ready returns the settled screen text once the
5345    /// detector reports ready. The reply must come from the ready snapshot,
5346    /// NOT from an intermediate partial snapshot.
5347    #[tokio::test(flavor = "current_thread")]
5348    async fn poll_until_ready_returns_settled_reply_on_ready_prompt() {
5349        // Three snapshots: two "not ready" then one showing the idle prompt.
5350        let snapshots: &[&str] = &["loading...", "still loading...", "done \u{276f}"];
5351        let idx = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
5352        let idx2 = idx.clone();
5353        let fetcher = move || {
5354            let i = idx2.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5355            let text = snapshots[i.min(snapshots.len() - 1)].to_string();
5356            std::future::ready(Some(text))
5357        };
5358        let result = poll_until_ready(
5359            fetcher,
5360            Box::new(PromptDetector),
5361            Duration::from_millis(1),
5362            Duration::from_secs(5),
5363        )
5364        .await;
5365        assert!(result.is_ok(), "expected Ok, got {result:?}");
5366        let reply = result.unwrap();
5367        assert_eq!(
5368            reply, "done \u{276f}",
5369            "reply must be the settled snapshot text, got {reply:?}"
5370        );
5371    }
5372
5373    /// AC2-ERR: poll_until_ready returns Err when the timeout elapses before
5374    /// the detector ever reports ready. It must NOT silently return an empty or
5375    /// partial reply.
5376    #[tokio::test(flavor = "current_thread")]
5377    async fn poll_until_ready_returns_error_on_timeout() {
5378        let fetcher = || std::future::ready(Some("still thinking...".to_string()));
5379        let result = poll_until_ready(
5380            fetcher,
5381            Box::new(NeverReadyDetector),
5382            Duration::from_millis(10),
5383            Duration::from_millis(40), // very short timeout
5384        )
5385        .await;
5386        assert!(
5387            result.is_err(),
5388            "expected Err on timeout, got Ok({:?})",
5389            result.ok()
5390        );
5391    }
5392
5393    /// AC3-EDGE: a settled screen with no reply content returns an empty string,
5394    /// not fabricated text. (Matches Python `result.reply or ""`.)
5395    #[tokio::test(flavor = "current_thread")]
5396    async fn poll_until_ready_empty_settled_screen_returns_empty_string() {
5397        // The screen text is just the prompt glyph with nothing before it.
5398        let fetcher = || std::future::ready(Some("\u{276f}".to_string()));
5399        let result = poll_until_ready(
5400            fetcher,
5401            Box::new(PromptDetector),
5402            Duration::from_millis(1),
5403            Duration::from_secs(5),
5404        )
5405        .await;
5406        assert!(result.is_ok(), "expected Ok, got {result:?}");
5407        // The reply is the raw screen text at the settled state. An empty/glyph-only
5408        // screen is fine — callers use `reply or ""` to handle it.
5409        let reply = result.unwrap();
5410        assert!(!reply.contains("fabricated"), "must not fabricate content");
5411    }
5412
5413    // -----------------------------------------------------------------------
5414    // Task 4.1: provider_for_pty + handle_spawn provider-derived argv
5415    // -----------------------------------------------------------------------
5416
5417    /// AC1-HP: provider_for_pty returns Some for known PTY providers (codex/gemini)
5418    /// and None for unknown or non-PTY providers (claude).
5419    #[test]
5420    fn provider_for_pty_known_providers() {
5421        assert!(
5422            provider_for_pty("codex").is_some(),
5423            "codex must have a PTY provider"
5424        );
5425        assert!(
5426            provider_for_pty("gemini").is_some(),
5427            "gemini must have a PTY provider"
5428        );
5429        // claude is a shellout provider: as_pty() -> None
5430        assert!(
5431            provider_for_pty("claude").is_none(),
5432            "claude is not PTY-managed; provider_for_pty must return None"
5433        );
5434        // unknown provider
5435        assert!(
5436            provider_for_pty("nonexistent").is_none(),
5437            "unknown provider must return None"
5438        );
5439    }
5440
5441    fn test_ctx(home: AgentsHome, worker_bin: PathBuf) -> Ctx {
5442        Ctx {
5443            home,
5444            emitter: EventEmitter::new(std::path::PathBuf::from("/dev/null"), "daemon"),
5445            opts: DaemonOptions {
5446                idle_exit: Duration::from_secs(1800),
5447                worker_bin,
5448                reconcile_on_start: true,
5449            },
5450            started_at: std::time::Instant::now(),
5451            exe_fingerprint: crate::drift::ExeFingerprint::current(),
5452            pid_start_time: process_start_time(std::process::id()),
5453            drives: crate::drive::new_table(),
5454        }
5455    }
5456
5457    /// Like `test_ctx` but wires the emitter to `home.events_jsonl()` so
5458    /// that tests checking emitted events can read them back with `read_events`.
5459    fn test_ctx_with_events(home: AgentsHome, worker_bin: PathBuf) -> Ctx {
5460        let events_path = home.events_jsonl();
5461        Ctx {
5462            home,
5463            emitter: EventEmitter::new(events_path, "daemon"),
5464            opts: DaemonOptions {
5465                idle_exit: Duration::from_secs(1800),
5466                worker_bin,
5467                reconcile_on_start: true,
5468            },
5469            started_at: std::time::Instant::now(),
5470            exe_fingerprint: crate::drift::ExeFingerprint::current(),
5471            pid_start_time: process_start_time(std::process::id()),
5472            drives: crate::drive::new_table(),
5473        }
5474    }
5475
5476    // ---- Group 2, Task 3.1: switchboard tests --------------------------
5477    //
5478    // A fake stream-json emitter (NEVER a real `claude -p`): for each user turn
5479    // it reads on stdin it emits the canonical sequence (user-echo receipt, a
5480    // partial, the assistant reply, a result). Mirrors the stream_worker harness.
5481
5482    const FAKE_STREAM_EMITTER: &str = r#"
5483printf '%s\n' '{"type":"system","subtype":"init","session_id":"s1"}'
5484while IFS= read -r line; do
5485  printf '%s\n' '{"type":"user","message":{"role":"user"}}'
5486  printf '%s\n' '{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"par"}}}'
5487  printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"reply-text"}]}}'
5488  printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"result":"reply-text"}'
5489done
5490"#;
5491
5492    /// A SHORT-path agents home under `/tmp` (not the long `/var/folders` temp
5493    /// dir): a worker's `<root>/<short_id>/worker.sock` must fit in SUN_LEN
5494    /// (~104 chars on macOS), so switchboard tests that bind real worker sockets
5495    /// need a short root. Mirrors the stream_worker test harness.
5496    fn short_home(tag: &str) -> AgentsHome {
5497        use std::sync::atomic::{AtomicU32, Ordering};
5498        static C: AtomicU32 = AtomicU32::new(0);
5499        let n = C.fetch_add(1, Ordering::Relaxed);
5500        let p = PathBuf::from(format!("/tmp/abisb{tag}{}_{n}", std::process::id()));
5501        let home = AgentsHome::at(&p);
5502        home.ensure_root().unwrap();
5503        home
5504    }
5505
5506    /// Seed a held-stream-thread registry row (claude + full UUID + Live).
5507    fn seed_stream_row(home: &AgentsHome, name: &str, short_id: &str) {
5508        state::update_registry(&home.registry_json(), |r| {
5509            r.entries.push(RegistryEntry {
5510                name: name.into(),
5511                short_id: short_id.into(),
5512                provider: "claude".into(),
5513                cwd: "/tmp".into(),
5514                project_root: "/tmp".into(),
5515                session_id: None,
5516                claude_short_id: None,
5517                claude_session_uuid: Some(format!("uuid-{short_id}")),
5518                messaging_socket_path: None,
5519                codex_session_id: None,
5520                gemini_session_id: None,
5521                mcp_channel_id: None,
5522                cc_session_id: None,
5523                host_mode: None,
5524                status: AgentStatus::Live,
5525                last_message_at: None,
5526                created_at: "2026-06-09T00:00:00Z".into(),
5527                pid: None,
5528                pid_start_time: None,
5529                log_path: None,
5530                last_reconciled_at: None,
5531            });
5532        })
5533        .unwrap();
5534    }
5535
5536    /// Start a real stream worker (fake emitter child) on `home.worker_sock(id)`
5537    /// via the PUBLIC `stream_worker::run`; wait for its socket to appear.
5538    async fn start_stream_worker(home: &AgentsHome, short_id: &str, script: &str) -> PathBuf {
5539        let cfg = crate::stream_worker::StreamWorkerConfig::new(
5540            short_id,
5541            home.root().to_path_buf(),
5542            std::env::temp_dir(),
5543            vec!["bash".into(), "-c".into(), script.into()],
5544        );
5545        let short_id_dbg = short_id.to_string();
5546        std::thread::spawn(move || {
5547            let rt = tokio::runtime::Builder::new_current_thread()
5548                .enable_all()
5549                .build()
5550                .unwrap();
5551            rt.block_on(async {
5552                if let Err(e) = crate::stream_worker::run(cfg).await {
5553                    eprintln!("STREAM WORKER RUN ERROR ({short_id_dbg}): {e}");
5554                }
5555            });
5556        });
5557        let sock = home.worker_sock(short_id);
5558        let start = std::time::Instant::now();
5559        while !sock.exists() && start.elapsed() < Duration::from_secs(20) {
5560            tokio::time::sleep(Duration::from_millis(50)).await;
5561        }
5562        assert!(
5563            sock.exists(),
5564            "stream worker socket never appeared for {short_id}"
5565        );
5566        sock
5567    }
5568
5569    /// Locate the cargo-built `fno-agents-worker` next to the test binary
5570    /// (target/debug/deps/<test> -> target/debug/fno-agents-worker). `None` if it
5571    /// is not built, so the e2e adopt test SKIPS rather than failing in an
5572    /// environment where only the lib test target was compiled.
5573    fn built_worker_bin() -> Option<PathBuf> {
5574        let exe = std::env::current_exe().ok()?;
5575        let dir = exe.parent()?.parent()?; // deps -> debug
5576        let cand = dir.join("fno-agents-worker");
5577        cand.exists().then_some(cand)
5578    }
5579
5580    // ---- Group 3 (ab-734fcd6c): claude stream-json front door --------------
5581
5582    #[test]
5583    fn stream_claim_holder_is_short_id_scoped() {
5584        assert_eq!(stream_claim_holder("sw7"), "stream:sw7");
5585    }
5586
5587    #[test]
5588    fn is_live_writer_excludes_orphaned_and_terminal() {
5589        // Live-ish: a real writer holds the session -> one-host refuses a re-adopt.
5590        for s in [
5591            AgentStatus::Live,
5592            AgentStatus::Ready,
5593            AgentStatus::Idle,
5594            AgentStatus::Busy,
5595            AgentStatus::Spawning,
5596            AgentStatus::Restarting,
5597        ] {
5598            assert!(is_live_writer(s), "{s:?} should count as a live writer");
5599        }
5600        // Dead-but-non-terminal + terminal: the session is re-adoptable (AC1-FR).
5601        for s in [
5602            AgentStatus::Orphaned,
5603            AgentStatus::Failed,
5604            AgentStatus::Exited,
5605            AgentStatus::PermanentDead,
5606        ] {
5607            assert!(!is_live_writer(s), "{s:?} must NOT block re-adoption");
5608        }
5609    }
5610
5611    #[test]
5612    fn claim_acquire_argv_targets_session_key() {
5613        assert_eq!(
5614            claim_acquire_argv("U-1", "stream:sw1"),
5615            vec![
5616                "claim",
5617                "acquire",
5618                "session:U-1",
5619                "--holder",
5620                "stream:sw1",
5621                "-J"
5622            ]
5623        );
5624    }
5625
5626    #[test]
5627    fn claude_stream_worker_args_carry_stream_flags_and_child_argv() {
5628        let child = crate::provider::claude_stream_json_resume_argv("U-9");
5629        let args = claude_stream_worker_args(
5630            "sw9",
5631            std::path::Path::new("/home/agents"),
5632            std::path::Path::new("/work"),
5633            "U-9",
5634            "stream:sw9",
5635            &child,
5636        );
5637        // Selector + claim pair are present, the child argv follows `--`, and the
5638        // resume target is the FULL uuid (never the jobId).
5639        assert!(args.contains(&"--stream".to_string()));
5640        assert_eq!(
5641            args.iter()
5642                .position(|a| a == "--session-uuid")
5643                .map(|i| &args[i + 1]),
5644            Some(&"U-9".to_string())
5645        );
5646        assert_eq!(
5647            args.iter()
5648                .position(|a| a == "--holder")
5649                .map(|i| &args[i + 1]),
5650            Some(&"stream:sw9".to_string())
5651        );
5652        let sep = args
5653            .iter()
5654            .position(|a| a == "--")
5655            .expect("missing -- separator");
5656        assert_eq!(&args[sep + 1..], child.as_slice());
5657        assert_eq!(child[0], "claude");
5658        assert!(child.contains(&"--resume".to_string()) && child.contains(&"U-9".to_string()));
5659    }
5660
5661    #[test]
5662    fn build_claude_stream_entry_marks_interactive_claude_with_full_uuid() {
5663        let e = build_claude_stream_entry(
5664            "adopted",
5665            "sw3",
5666            std::path::Path::new("/proj"),
5667            "FULL-UUID-3",
5668            4242,
5669            Some(99),
5670            PathBuf::from("/proj/.fno/agents/sw3/timeline.jsonl"),
5671        );
5672        assert_eq!(e.provider, "claude");
5673        assert_eq!(
5674            e.host_mode.as_deref(),
5675            Some(crate::state::HOST_MODE_INTERACTIVE)
5676        );
5677        assert!(
5678            e.is_interactive(),
5679            "stream thread must read as interactive for reconcile"
5680        );
5681        assert_eq!(e.claude_session_uuid.as_deref(), Some("FULL-UUID-3"));
5682        assert_eq!(e.status, AgentStatus::Live);
5683        assert_eq!(e.pid, Some(4242));
5684        // The resume key lives in claude_session_uuid, NOT the jobId field.
5685        assert_eq!(e.claude_short_id, None);
5686    }
5687
5688    /// AC1-ERR / front-door routing: a fresh `host --provider claude` with no
5689    /// `--from` has nothing to resume; it is rejected (before any claim/spawn)
5690    /// with a pointer to the adopt verb, proving claude routed to the stream lane
5691    /// (not the codex/gemini PTY "only codex or gemini" gate).
5692    #[tokio::test(flavor = "current_thread")]
5693    async fn host_claude_without_from_rejected_with_adopt_pointer() {
5694        let home = short_home("clnofrom");
5695        let ctx = test_ctx(home.clone(), PathBuf::from("/nonexistent-worker"));
5696        let req = Request::new(
5697            1,
5698            "agent.spawn",
5699            json!({"name": "cl", "provider": "claude", "host_mode": "interactive"}),
5700        );
5701        let resp = handle_spawn(&ctx, &req).await;
5702        match &resp.payload {
5703            crate::protocol::ResponsePayload::Err(e) => {
5704                assert_eq!(e.code, ErrorCode::InvalidParams);
5705                assert!(
5706                    e.message.contains("promote") && e.message.contains("--from"),
5707                    "claude host without --from must point at the adopt verb; got: {}",
5708                    e.message
5709                );
5710            }
5711            _ => panic!("expected error for claude host without --from"),
5712        }
5713        std::fs::remove_dir_all(home.root()).ok();
5714    }
5715
5716    /// AC1-EDGE single-writer: a second adopt of a session already held by a live
5717    /// claude thread is refused (one writer per session), before any spawn. Uses
5718    /// the lock-free one-host pre-check so it is hermetic (no worker, no claim).
5719    #[tokio::test(flavor = "current_thread")]
5720    async fn promote_claude_duplicate_session_refused() {
5721        let home = short_home("cldup");
5722        seed_stream_row(&home, "first", "swDup"); // claude_session_uuid = uuid-swDup, Live
5723        let ctx = test_ctx(home.clone(), PathBuf::from("/nonexistent-worker"));
5724        let req = Request::new(
5725            1,
5726            "agent.spawn",
5727            json!({
5728                "name": "second", "provider": "claude", "host_mode": "interactive",
5729                "resume_id": "uuid-swDup"
5730            }),
5731        );
5732        let resp = handle_spawn(&ctx, &req).await;
5733        match &resp.payload {
5734            crate::protocol::ResponsePayload::Err(e) => {
5735                assert_eq!(e.code, ErrorCode::InvalidParams);
5736                assert!(
5737                    e.message.contains("already hosted") && e.message.contains("first"),
5738                    "duplicate adopt must name the existing host; got: {}",
5739                    e.message
5740                );
5741            }
5742            _ => panic!("expected single-writer refusal for duplicate adopt"),
5743        }
5744        std::fs::remove_dir_all(home.root()).ok();
5745    }
5746
5747    /// AC1-HP end-to-end: `promote --provider claude --from <uuid>` adopts an idle
5748    /// session by spawning the real `--stream` worker (with a FAKE emitter child,
5749    /// never a real `claude -p`) and registering it `live`. The row carries
5750    /// provider=claude + host_mode=interactive + the FULL uuid, and the worker
5751    /// serves the stream protocol. Skips when the worker binary is not built.
5752    #[tokio::test(flavor = "current_thread")]
5753    async fn promote_claude_spawns_live_stream_thread() {
5754        let Some(worker_bin) = built_worker_bin() else {
5755            eprintln!("skip promote_claude_spawns_live_stream_thread: worker bin not built");
5756            return;
5757        };
5758        let home = short_home("cle2e");
5759        // Hermetic claims: point `fno claim` at the test home so the real
5760        // acquire (daemon, this process) AND the worker child (inherits this env)
5761        // write `session:uuid-e2e` under /tmp, never the canonical, shared
5762        // ~/.fno/claims. A panic before teardown then leaks at worst into a
5763        // throwaway /tmp dir. Only this test exercises claims, so the process-wide
5764        // env set does not race the claim-free tests. (Edition 2021: set_var safe.)
5765        std::env::set_var("FNO_CLAIMS_ROOT", home.root());
5766        let ctx = test_ctx(home.clone(), worker_bin);
5767        let req = Request::new(
5768            1,
5769            "agent.spawn",
5770            json!({
5771                "name": "cl", "provider": "claude", "host_mode": "interactive",
5772                "resume_id": "uuid-e2e", "cwd": "/tmp",
5773                // Test escape hatch: a fake stream emitter stands in for `claude -p`.
5774                "argv": ["bash", "-c", FAKE_STREAM_EMITTER]
5775            }),
5776        );
5777        let resp = handle_spawn(&ctx, &req).await;
5778        let res = resp.result().expect("claude adopt errored");
5779        assert_eq!(res["provider"], "claude");
5780        assert_eq!(res["status"], "live");
5781        assert_eq!(res["lane"], "stream");
5782
5783        let reg = load_registry_offloaded(home.registry_json()).await;
5784        let row = reg.find("cl").expect("adopted row missing");
5785        assert_eq!(row.provider, "claude");
5786        assert_eq!(row.host_mode.as_deref(), Some("interactive"));
5787        assert_eq!(row.claude_session_uuid.as_deref(), Some("uuid-e2e"));
5788        assert_eq!(row.status, AgentStatus::Live);
5789
5790        let sock = home.worker_sock(&row.short_id);
5791        assert!(
5792            is_live_stream_thread(&sock).await,
5793            "adopted thread must serve the stream protocol"
5794        );
5795
5796        // Teardown: shut the worker down (its RAII guard releases the claim), then
5797        // drop the test home (which holds the redirected claims dir) and clear the
5798        // env override so later tests see the default claims root.
5799        best_effort_worker_shutdown(&sock).await;
5800        std::fs::remove_dir_all(home.root()).ok();
5801        std::env::remove_var("FNO_CLAIMS_ROOT");
5802    }
5803
5804    /// AC1-ERR (codex review P2): a dead-on-arrival `claude -p --resume` (bad uuid
5805    /// / auth fail, here a child that exits immediately) must NOT register live.
5806    /// The worker still binds + answers stream.ping, but stream.status.child_alive
5807    /// is false, so adopt is rejected and no row is created.
5808    #[tokio::test(flavor = "current_thread")]
5809    async fn promote_claude_dead_on_arrival_resume_rejected() {
5810        let Some(worker_bin) = built_worker_bin() else {
5811            eprintln!("skip promote_claude_dead_on_arrival_resume_rejected: worker bin not built");
5812            return;
5813        };
5814        let home = short_home("cldoa");
5815        std::env::set_var("FNO_CLAIMS_ROOT", home.root());
5816        let ctx = test_ctx(home.clone(), worker_bin);
5817        let req = Request::new(
5818            1,
5819            "agent.spawn",
5820            json!({
5821                "name": "cl", "provider": "claude", "host_mode": "interactive",
5822                "resume_id": "uuid-doa", "cwd": "/tmp",
5823                // Child exits immediately -> stands in for a bad/expired --resume id.
5824                "argv": ["bash", "-c", "exit 1"]
5825            }),
5826        );
5827        let resp = handle_spawn(&ctx, &req).await;
5828        assert!(
5829            resp.is_err(),
5830            "DOA resume child must be rejected, not registered"
5831        );
5832        assert_eq!(resp.error().unwrap().code, ErrorCode::SpawnFailed);
5833        let reg = load_registry_offloaded(home.registry_json()).await;
5834        assert!(
5835            reg.find("cl").is_none(),
5836            "no row may be registered for a DOA adopt"
5837        );
5838        std::fs::remove_dir_all(home.root()).ok();
5839        std::env::remove_var("FNO_CLAIMS_ROOT");
5840    }
5841
5842    /// AC2-HP: `send A->B` between two held stream threads drives B, discriminates
5843    /// the user-echo receipt from the reply, and mirrors B's reply into A.
5844    #[tokio::test(flavor = "current_thread")]
5845    async fn switchboard_drives_b_and_mirrors_into_a() {
5846        let home = short_home("hp");
5847        seed_stream_row(&home, "A", "swA");
5848        seed_stream_row(&home, "B", "swB");
5849        let _a = start_stream_worker(&home, "swA", FAKE_STREAM_EMITTER).await;
5850        let _b = start_stream_worker(&home, "swB", FAKE_STREAM_EMITTER).await;
5851        let ctx = test_ctx_with_events(home.clone(), PathBuf::from("/nonexistent-worker"));
5852
5853        let req = Request::new(
5854            1,
5855            "agent.switchboard",
5856            json!({"to": "B", "from": "A", "body": "hello"}),
5857        );
5858        let resp = handle_switchboard(&ctx, &req).await;
5859        let res = resp.result().expect("switchboard errored");
5860        assert_eq!(res["delivered"], true, "not delivered: {res:?}");
5861        assert_eq!(res["reply"], "reply-text");
5862        assert_eq!(res["is_error"], false);
5863        assert_eq!(res["receipt"], true, "user-echo receipt not observed");
5864        assert_eq!(res["mirrored"], true, "B's reply was not mirrored into A");
5865
5866        // The injected-event reuse carries the switchboard transport discriminator.
5867        let events = read_events(&home);
5868        assert!(
5869            events.iter().any(|e| e["kind"] == "agent_deliver_injected"
5870                && e["transport"] == "switchboard"
5871                && e["mirrored"] == true),
5872            "switchboard injected event missing: {events:?}"
5873        );
5874        std::fs::remove_dir_all(home.root()).ok();
5875    }
5876
5877    /// A second turn against the SAME persistent worker must return the SECOND
5878    /// turn's reply, not the stale first result still in the append-only frame
5879    /// log. Regression for the cursor=0 bug: the emitter tags each reply with a
5880    /// per-turn counter so a stale read is detectable.
5881    #[tokio::test(flavor = "current_thread")]
5882    async fn switchboard_second_turn_returns_fresh_reply() {
5883        const COUNTING_EMITTER: &str = r#"
5884printf '%s\n' '{"type":"system","subtype":"init","session_id":"s1"}'
5885n=0
5886while IFS= read -r line; do
5887  n=$((n+1))
5888  printf '%s\n' '{"type":"user","message":{"role":"user"}}'
5889  printf '%s\n' "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"reply-$n\"}]}}"
5890  printf '%s\n' "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"reply-$n\"}"
5891done
5892"#;
5893        let home = short_home("fresh");
5894        seed_stream_row(&home, "B", "swB");
5895        let _b = start_stream_worker(&home, "swB", COUNTING_EMITTER).await;
5896        let ctx = test_ctx(home.clone(), PathBuf::from("/nonexistent-worker"));
5897
5898        let r1 = handle_switchboard(
5899            &ctx,
5900            &Request::new(
5901                1,
5902                "agent.switchboard",
5903                json!({"to": "B", "from": "ghost", "body": "first"}),
5904            ),
5905        )
5906        .await;
5907        assert_eq!(r1.result().expect("hop1")["reply"], "reply-1");
5908
5909        let r2 = handle_switchboard(
5910            &ctx,
5911            &Request::new(
5912                2,
5913                "agent.switchboard",
5914                json!({"to": "B", "from": "ghost", "body": "second"}),
5915            ),
5916        )
5917        .await;
5918        assert_eq!(
5919            r2.result().expect("hop2")["reply"],
5920            "reply-2",
5921            "second drive returned a STALE reply (cursor not advanced past the prior turn)"
5922        );
5923        std::fs::remove_dir_all(home.root()).ok();
5924    }
5925
5926    /// Routing: a claude peer with no live stream worker demotes (the caller
5927    /// falls back to the durable/socket path), not an error.
5928    #[tokio::test(flavor = "current_thread")]
5929    async fn switchboard_demotes_when_b_not_a_live_stream_thread() {
5930        let home = short_home("demote");
5931        seed_stream_row(&home, "B", "swB"); // registered, but NO worker started
5932        let ctx = test_ctx(home.clone(), PathBuf::from("/nonexistent-worker"));
5933
5934        let req = Request::new(
5935            1,
5936            "agent.switchboard",
5937            json!({"to": "B", "from": "A", "body": "hi"}),
5938        );
5939        let resp = handle_switchboard(&ctx, &req).await;
5940        let res = resp
5941            .result()
5942            .expect("should be Ok-demote, not an RPC error");
5943        assert_eq!(res["delivered"], false);
5944        assert_eq!(res["reason"], "not-a-live-stream-thread");
5945        std::fs::remove_dir_all(home.root()).ok();
5946    }
5947
5948    /// Degenerate one-way drive: B is a held stream thread but A is not (absent),
5949    /// so the turn delivers to B with no mirror.
5950    #[tokio::test(flavor = "current_thread")]
5951    async fn switchboard_one_way_when_peer_absent() {
5952        let home = short_home("oneway");
5953        seed_stream_row(&home, "B", "swB");
5954        let _b = start_stream_worker(&home, "swB", FAKE_STREAM_EMITTER).await;
5955        let ctx = test_ctx(home.clone(), PathBuf::from("/nonexistent-worker"));
5956
5957        let req = Request::new(
5958            1,
5959            "agent.switchboard",
5960            json!({"to": "B", "from": "ghost", "body": "hi"}),
5961        );
5962        let resp = handle_switchboard(&ctx, &req).await;
5963        let res = resp.result().expect("switchboard errored");
5964        assert_eq!(res["delivered"], true);
5965        assert_eq!(res["reply"], "reply-text");
5966        assert_eq!(res["mirrored"], false, "no peer to mirror into");
5967        std::fs::remove_dir_all(home.root()).ok();
5968    }
5969
5970    /// An unknown `to` is an RPC error (AgentNotFound), not a silent no-op.
5971    #[tokio::test(flavor = "current_thread")]
5972    async fn switchboard_unknown_target_is_not_found() {
5973        let home = short_home("404");
5974        let ctx = test_ctx(home.clone(), PathBuf::from("/nonexistent-worker"));
5975        let req = Request::new(
5976            1,
5977            "agent.switchboard",
5978            json!({"to": "nope", "from": "A", "body": "hi"}),
5979        );
5980        let resp = handle_switchboard(&ctx, &req).await;
5981        if let crate::protocol::ResponsePayload::Err(ref e) = resp.payload {
5982            assert_eq!(e.code, ErrorCode::AgentNotFound);
5983        } else {
5984            panic!("expected AgentNotFound, got {resp:?}");
5985        }
5986        std::fs::remove_dir_all(home.root()).ok();
5987    }
5988
5989    /// AC2-HP: handle_spawn with a known provider and no explicit argv resolves
5990    /// the launch argv via ProviderWithPty::create_argv and registers the agent.
5991    #[tokio::test(flavor = "current_thread")]
5992    async fn handle_spawn_resolves_argv_from_provider_when_absent() {
5993        // We cannot actually launch a worker in a unit test, so we intercept at
5994        // the point where the argv resolution happens: the daemon returns SpawnFailed
5995        // (because the worker binary doesn't exist in the test env) rather than
5996        // InvalidParams. The key assertion is that it did NOT fail with InvalidParams
5997        // ("Wave 3 spawn requires explicit argv..."); it tried to spawn.
5998        let home = tmp_home("spawn-provider-argv");
5999        let ctx = test_ctx(
6000            home.clone(),
6001            PathBuf::from("/nonexistent/fno-agents-worker"),
6002        );
6003        // Spawn with provider="codex" but NO argv param.
6004        let req = Request::new(
6005            1,
6006            "agent.spawn",
6007            json!({"name": "test-agent", "provider": "codex"}),
6008        );
6009        let resp = handle_spawn(&ctx, &req).await;
6010        // Must NOT be InvalidParams (which would mean argv resolution didn't happen).
6011        if let crate::protocol::ResponsePayload::Err(ref e) = resp.payload {
6012            assert_ne!(
6013                e.code,
6014                ErrorCode::InvalidParams,
6015                "handle_spawn must not return InvalidParams when provider is known; got: {}",
6016                e.message
6017            );
6018            // SpawnFailed or Internal is expected (worker binary absent in test env).
6019        }
6020        std::fs::remove_dir_all(home.root()).ok();
6021    }
6022
6023    /// AC3-ERR: handle_spawn with an unknown/non-PTY provider and no argv returns InvalidParams.
6024    #[tokio::test(flavor = "current_thread")]
6025    async fn handle_spawn_unknown_provider_no_argv_returns_invalid_params() {
6026        let home = tmp_home("spawn-unknown-provider");
6027        let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6028        let req = Request::new(
6029            1,
6030            "agent.spawn",
6031            json!({"name": "test-agent", "provider": "nonexistent-provider"}),
6032        );
6033        let resp = handle_spawn(&ctx, &req).await;
6034        match &resp.payload {
6035            crate::protocol::ResponsePayload::Err(e) => {
6036                assert_eq!(
6037                    e.code,
6038                    ErrorCode::InvalidParams,
6039                    "unknown provider without argv must return InvalidParams"
6040                );
6041            }
6042            _ => panic!("expected error response for unknown provider"),
6043        }
6044        std::fs::remove_dir_all(home.root()).ok();
6045    }
6046
6047    /// AC4-HP: handle_ask on AgentNotFound with a provider param tries to spawn
6048    /// (does NOT return AgentNotFound).
6049    #[tokio::test(flavor = "current_thread")]
6050    async fn handle_ask_first_contact_with_provider_does_not_return_agent_not_found() {
6051        let home = tmp_home("ask-first-contact");
6052        let ctx = test_ctx(
6053            home.clone(),
6054            PathBuf::from("/nonexistent/fno-agents-worker"),
6055        );
6056        // Agent does not exist yet; provider="codex" is provided.
6057        let req = Request::new(
6058            1,
6059            "agent.ask",
6060            json!({"name": "new-agent", "message": "hello", "provider": "codex"}),
6061        );
6062        let resp = handle_ask(&ctx, &req).await;
6063        // The worker binary does not exist, so the create-on-first-contact spawn
6064        // must FAIL - and the failure must come from the spawn attempt, not from
6065        // the not-found short-circuit. Require a hard Err (the previous `if let`
6066        // passed vacuously on a spurious Ok) whose code is neither AgentNotFound
6067        // (would mean first-contact never tried to spawn) nor InvalidParams
6068        // (would mean the provider param was not honored). This proves the
6069        // first-contact branch routed into handle_spawn (cv-789fdba0 part a).
6070        match &resp.payload {
6071            crate::protocol::ResponsePayload::Err(e) => {
6072                assert_ne!(
6073                    e.code,
6074                    ErrorCode::AgentNotFound,
6075                    "first-contact ask with --provider must attempt a spawn, not short-circuit AgentNotFound; got: {}",
6076                    e.message
6077                );
6078                assert_ne!(
6079                    e.code,
6080                    ErrorCode::InvalidParams,
6081                    "provider was supplied, so this must not be the missing-provider error; got: {}",
6082                    e.message
6083                );
6084            }
6085            crate::protocol::ResponsePayload::Ok(v) => {
6086                panic!("spawn must fail with a missing worker binary, got Ok: {v}")
6087            }
6088        }
6089        std::fs::remove_dir_all(home.root()).ok();
6090    }
6091
6092    /// AC5-ERR: handle_ask on AgentNotFound WITHOUT a provider returns InvalidParams
6093    /// (mirrors Python requiring --provider on first contact).
6094    #[tokio::test(flavor = "current_thread")]
6095    async fn handle_ask_first_contact_without_provider_returns_invalid_params() {
6096        let home = tmp_home("ask-no-provider");
6097        let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6098        // Agent does not exist; NO provider param.
6099        let req = Request::new(
6100            1,
6101            "agent.ask",
6102            json!({"name": "ghost-agent", "message": "hello"}),
6103        );
6104        let resp = handle_ask(&ctx, &req).await;
6105        match &resp.payload {
6106            crate::protocol::ResponsePayload::Err(e) => {
6107                assert_eq!(
6108                    e.code,
6109                    ErrorCode::InvalidParams,
6110                    "first-contact ask without --provider must return InvalidParams; got: {}",
6111                    e.message
6112                );
6113                assert!(
6114                    e.message.contains("provider"),
6115                    "error message must mention 'provider', got: {}",
6116                    e.message
6117                );
6118            }
6119            _ => panic!("expected error for first-contact ask without provider"),
6120        }
6121        std::fs::remove_dir_all(home.root()).ok();
6122    }
6123
6124    // ── gate record tests (Task 2.3) ─────────────────────────────────────────
6125
6126    /// AC4-ERR (Task 2.3): provider with status=failed in injection-gate.json
6127    /// -> deliver returns delivered=false reason=injection-gate-failed.
6128    #[tokio::test(flavor = "current_thread")]
6129    async fn handle_deliver_gate_failed_returns_failed_reason() {
6130        let home = tmp_home("deliver-gate-failed");
6131        // Register a live codex worker.
6132        state::update_registry(&home.registry_json(), |r| {
6133            r.entries.push(RegistryEntry {
6134                name: "failed-gate-agent".into(),
6135                short_id: "fgA".into(),
6136                provider: "codex".into(),
6137                cwd: "/tmp".into(),
6138                project_root: "/tmp".into(),
6139                session_id: None,
6140                claude_short_id: None,
6141                claude_session_uuid: None,
6142                messaging_socket_path: None,
6143                codex_session_id: Some("some-uuid".into()),
6144                gemini_session_id: None,
6145                mcp_channel_id: None,
6146                cc_session_id: None,
6147                host_mode: Some("interactive".into()),
6148                status: AgentStatus::Live,
6149                last_message_at: None,
6150                created_at: "2026-06-07T00:00:00Z".into(),
6151                pid: None,
6152                pid_start_time: None,
6153                log_path: None,
6154                last_reconciled_at: None,
6155            });
6156        })
6157        .unwrap();
6158        // Write a gate record with status=failed for codex.
6159        let gate_path = home.injection_gate_json();
6160        std::fs::write(
6161            &gate_path,
6162            r#"{"v":1,"providers":{"codex":{"status":"failed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
6163        )
6164        .unwrap();
6165        let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6166        let req = Request::new(
6167            1,
6168            "agent.deliver",
6169            json!({"name": "failed-gate-agent", "body": "hello", "from_name": "sender"}),
6170        );
6171        let resp = handle_deliver(&ctx, &req).await;
6172        assert!(
6173            !resp.is_err(),
6174            "failed-gate deliver must return Ok, not RPC error"
6175        );
6176        let result = resp.result().unwrap();
6177        assert_eq!(result["delivered"], false);
6178        assert_eq!(
6179            result["reason"], "injection-gate-failed",
6180            "expected injection-gate-failed for status=failed, got {:?}",
6181            result["reason"]
6182        );
6183        std::fs::remove_dir_all(home.root()).ok();
6184    }
6185
6186    /// injection_gate precedence: thread-local override beats file record.
6187    #[test]
6188    fn injection_gate_thread_local_beats_file_record() {
6189        let home = tmp_home("gate-override-beats-file");
6190        // Write a gate record with status=failed for codex.
6191        let gate_path = home.injection_gate_json();
6192        std::fs::write(
6193            &gate_path,
6194            r#"{"v":1,"providers":{"codex":{"status":"failed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
6195        )
6196        .unwrap();
6197        // With thread-local override set to allow codex, gate must return Passed
6198        // even though the file says failed.
6199        let _guard = with_injection_gate_allow("codex");
6200        assert_eq!(
6201            injection_gate_with_home("codex", &home),
6202            InjectionGate::Passed,
6203            "thread-local override must beat file record"
6204        );
6205        std::fs::remove_dir_all(home.root()).ok();
6206    }
6207
6208    /// Malformed JSON in gate file -> Unverified (never crash, never Passed).
6209    #[test]
6210    fn injection_gate_malformed_file_returns_unverified() {
6211        let home = tmp_home("gate-malformed");
6212        let gate_path = home.injection_gate_json();
6213        std::fs::write(&gate_path, b"not-json!!!").unwrap();
6214        assert_eq!(
6215            injection_gate_with_home("codex", &home),
6216            InjectionGate::Unverified,
6217            "malformed gate file must return Unverified"
6218        );
6219        std::fs::remove_dir_all(home.root()).ok();
6220    }
6221
6222    /// Absent provider in gate file -> Unverified.
6223    #[test]
6224    fn injection_gate_absent_provider_returns_unverified() {
6225        let home = tmp_home("gate-absent-provider");
6226        let gate_path = home.injection_gate_json();
6227        // File exists but only has gemini, not codex.
6228        std::fs::write(
6229            &gate_path,
6230            r#"{"v":1,"providers":{"gemini":{"status":"passed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
6231        )
6232        .unwrap();
6233        assert_eq!(
6234            injection_gate_with_home("codex", &home),
6235            InjectionGate::Unverified,
6236            "absent provider must return Unverified"
6237        );
6238        std::fs::remove_dir_all(home.root()).ok();
6239    }
6240
6241    /// Provider with status=passed -> Passed.
6242    #[test]
6243    fn injection_gate_passed_status_returns_passed() {
6244        let home = tmp_home("gate-status-passed");
6245        let gate_path = home.injection_gate_json();
6246        std::fs::write(
6247            &gate_path,
6248            r#"{"v":1,"providers":{"codex":{"status":"passed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
6249        )
6250        .unwrap();
6251        assert_eq!(
6252            injection_gate_with_home("codex", &home),
6253            InjectionGate::Passed,
6254        );
6255        std::fs::remove_dir_all(home.root()).ok();
6256    }
6257
6258    /// gate_check RPC manual mode writes the gate record atomically.
6259    #[tokio::test(flavor = "current_thread")]
6260    async fn handle_gate_check_manual_writes_record() {
6261        let home = tmp_home("gate-check-manual");
6262        let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6263        let req = Request::new(
6264            1,
6265            "agent.gate_check",
6266            json!({"provider": "codex", "record": "manual", "status": "passed", "notes": "verified by operator"}),
6267        );
6268        let resp = handle_gate_check(&ctx, &req).await;
6269        assert!(
6270            !resp.is_err(),
6271            "gate_check manual must return Ok: {:?}",
6272            resp
6273        );
6274        let result = resp.result().unwrap();
6275        assert_eq!(result["status"], "passed");
6276        // Verify file was written.
6277        let gate_path = home.injection_gate_json();
6278        assert!(gate_path.exists(), "injection-gate.json must be written");
6279        let content: serde_json::Value =
6280            serde_json::from_str(&std::fs::read_to_string(&gate_path).unwrap()).unwrap();
6281        assert_eq!(content["v"], 1);
6282        assert_eq!(content["providers"]["codex"]["status"], "passed");
6283        assert_eq!(content["providers"]["codex"]["method"], "manual");
6284        assert_eq!(
6285            content["providers"]["codex"]["notes"],
6286            "verified by operator"
6287        );
6288        std::fs::remove_dir_all(home.root()).ok();
6289    }
6290
6291    /// gemini #459 high: an existing-but-malformed gate file must surface an
6292    /// error from gate_check, NOT be replaced by an empty doc (which would
6293    /// wipe every other provider's attestation on the merge-write).
6294    #[tokio::test]
6295    async fn handle_gate_check_manual_malformed_existing_file_errors_no_wipe() {
6296        let home = tmp_home("gate-check-malformed");
6297        let gate_path = home.injection_gate_json();
6298        std::fs::create_dir_all(gate_path.parent().unwrap()).unwrap();
6299        std::fs::write(&gate_path, "{not json").unwrap();
6300        let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6301        let req = Request::new(
6302            1,
6303            "agent.gate_check",
6304            json!({"provider": "codex", "record": "manual", "status": "passed", "notes": ""}),
6305        );
6306        let resp = handle_gate_check(&ctx, &req).await;
6307        assert!(
6308            resp.is_err(),
6309            "malformed existing gate file must error, got: {:?}",
6310            resp
6311        );
6312        // The malformed file is preserved byte-for-byte (no silent wipe).
6313        assert_eq!(
6314            std::fs::read_to_string(&gate_path).unwrap(),
6315            "{not json",
6316            "gate file must not be rewritten on parse failure"
6317        );
6318        std::fs::remove_dir_all(home.root()).ok();
6319    }
6320
6321    /// gate_check RPC probe mode returns inconclusive (never writes passed) when
6322    /// no live worker exists.
6323    #[tokio::test(flavor = "current_thread")]
6324    async fn handle_gate_check_probe_inconclusive_no_worker() {
6325        let home = tmp_home("gate-probe-inconclusive");
6326        let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6327        let req = Request::new(
6328            1,
6329            "agent.gate_check",
6330            json!({"provider": "codex", "record": "probe"}),
6331        );
6332        let resp = handle_gate_check(&ctx, &req).await;
6333        assert!(
6334            !resp.is_err(),
6335            "gate_check probe must return Ok: {:?}",
6336            resp
6337        );
6338        let result = resp.result().unwrap();
6339        assert_eq!(
6340            result["status"], "inconclusive",
6341            "probe without live worker must return inconclusive"
6342        );
6343        // Must NOT have written a gate record.
6344        assert!(
6345            !home.injection_gate_json().exists(),
6346            "probe inconclusive must NOT write gate record"
6347        );
6348        std::fs::remove_dir_all(home.root()).ok();
6349    }
6350
6351    /// gate_check demote event: deliver after failed gate record emits
6352    /// agent_deliver_demoted event.
6353    #[tokio::test(flavor = "current_thread")]
6354    async fn handle_deliver_failed_gate_emits_demoted_event() {
6355        let home = tmp_home("deliver-gate-failed-event");
6356        state::update_registry(&home.registry_json(), |r| {
6357            r.entries.push(RegistryEntry {
6358                name: "demote-agent".into(),
6359                short_id: "dmA".into(),
6360                provider: "codex".into(),
6361                cwd: "/tmp".into(),
6362                project_root: "/tmp".into(),
6363                session_id: None,
6364                claude_short_id: None,
6365                claude_session_uuid: None,
6366                messaging_socket_path: None,
6367                codex_session_id: Some("some-uuid".into()),
6368                gemini_session_id: None,
6369                mcp_channel_id: None,
6370                cc_session_id: None,
6371                host_mode: Some("interactive".into()),
6372                status: AgentStatus::Live,
6373                last_message_at: None,
6374                created_at: "2026-06-07T00:00:00Z".into(),
6375                pid: None,
6376                pid_start_time: None,
6377                log_path: None,
6378                last_reconciled_at: None,
6379            });
6380        })
6381        .unwrap();
6382        let gate_path = home.injection_gate_json();
6383        std::fs::write(
6384            &gate_path,
6385            r#"{"v":1,"providers":{"codex":{"status":"failed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
6386        )
6387        .unwrap();
6388        let ctx = test_ctx_with_events(home.clone(), PathBuf::from("fno-agents-worker"));
6389        let req = Request::new(
6390            1,
6391            "agent.deliver",
6392            json!({"name": "demote-agent", "body": "hi", "from_name": "tester"}),
6393        );
6394        let resp = handle_deliver(&ctx, &req).await;
6395        assert!(!resp.is_err());
6396        assert_eq!(resp.result().unwrap()["delivered"], false);
6397        // Demoted event must be emitted.
6398        let events = read_events(&home);
6399        assert!(
6400            events.iter().any(|e| e["kind"] == "agent_deliver_demoted"),
6401            "agent_deliver_demoted must be emitted when gate status=failed"
6402        );
6403        std::fs::remove_dir_all(home.root()).ok();
6404    }
6405
6406    // ── deliver RPC tests (Task 2.2) ─────────────────────────────────────────
6407
6408    /// AC4-ERR-shape (gate default): deliver to codex without gate override returns
6409    /// delivered=false reason=injection-gate-unverified and writes nothing to PTY.
6410    #[tokio::test(flavor = "current_thread")]
6411    async fn handle_deliver_gate_default_returns_unverified() {
6412        let home = tmp_home("deliver-gate-default");
6413        // Register a live codex worker with a known short_id.
6414        state::update_registry(&home.registry_json(), |r| {
6415            r.entries.push(RegistryEntry {
6416                name: "gate-agent".into(),
6417                short_id: "gtA".into(),
6418                provider: "codex".into(),
6419                cwd: "/tmp".into(),
6420                project_root: "/tmp".into(),
6421                session_id: None,
6422                claude_short_id: None,
6423                claude_session_uuid: None,
6424                messaging_socket_path: None,
6425                codex_session_id: Some("some-uuid".into()),
6426                gemini_session_id: None,
6427                mcp_channel_id: None,
6428                cc_session_id: None,
6429                host_mode: Some("interactive".into()),
6430                status: AgentStatus::Live,
6431                last_message_at: None,
6432                created_at: "2026-06-07T00:00:00Z".into(),
6433                pid: None,
6434                pid_start_time: None,
6435                log_path: None,
6436                last_reconciled_at: None,
6437            });
6438        })
6439        .unwrap();
6440        // Acquire the gate lock and keep it for the whole await so no parallel
6441        // No gate override - thread-local is unset so injection_gate returns Unverified.
6442        let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6443        let req = Request::new(
6444            1,
6445            "agent.deliver",
6446            json!({"name": "gate-agent", "body": "hello", "from_name": "sender"}),
6447        );
6448        let resp = handle_deliver(&ctx, &req).await;
6449        // Must be Ok (not an RPC error), delivered=false, reason=injection-gate-unverified.
6450        assert!(
6451            !resp.is_err(),
6452            "deliver gate-default must return Ok result, not RPC error"
6453        );
6454        let result = resp.result().unwrap();
6455        assert_eq!(result["delivered"], false);
6456        assert_eq!(result["reason"], "injection-gate-unverified");
6457        std::fs::remove_dir_all(home.root()).ok();
6458    }
6459
6460    /// claude entry -> delivered=false reason=claude-routes-via-socket, no error.
6461    #[tokio::test(flavor = "current_thread")]
6462    async fn handle_deliver_claude_returns_socket_route_result() {
6463        let home = tmp_home("deliver-claude");
6464        state::update_registry(&home.registry_json(), |r| {
6465            r.entries.push(RegistryEntry {
6466                name: "claude-agent".into(),
6467                short_id: "clA".into(),
6468                provider: "claude".into(),
6469                cwd: "/tmp".into(),
6470                project_root: "/tmp".into(),
6471                session_id: Some("abc123".into()),
6472                claude_short_id: Some("abc123".into()),
6473                claude_session_uuid: None,
6474                messaging_socket_path: None,
6475                codex_session_id: None,
6476                gemini_session_id: None,
6477                mcp_channel_id: None,
6478                cc_session_id: None,
6479                host_mode: None,
6480                status: AgentStatus::Live,
6481                last_message_at: None,
6482                created_at: "2026-06-07T00:00:00Z".into(),
6483                pid: None,
6484                pid_start_time: None,
6485                log_path: None,
6486                last_reconciled_at: None,
6487            });
6488        })
6489        .unwrap();
6490        let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6491        let req = Request::new(
6492            1,
6493            "agent.deliver",
6494            json!({"name": "claude-agent", "body": "ping", "from_name": "sender"}),
6495        );
6496        let resp = handle_deliver(&ctx, &req).await;
6497        assert!(!resp.is_err(), "claude deliver must return Ok result");
6498        let result = resp.result().unwrap();
6499        assert_eq!(result["delivered"], false);
6500        assert_eq!(result["reason"], "claude-routes-via-socket");
6501        std::fs::remove_dir_all(home.root()).ok();
6502    }
6503
6504    /// unknown name -> AgentNotFound.
6505    #[tokio::test(flavor = "current_thread")]
6506    async fn handle_deliver_unknown_agent_returns_not_found() {
6507        let home = tmp_home("deliver-not-found");
6508        let ctx = test_ctx(home.clone(), PathBuf::from("fno-agents-worker"));
6509        let req = Request::new(
6510            1,
6511            "agent.deliver",
6512            json!({"name": "ghost", "body": "hello", "from_name": "sender"}),
6513        );
6514        let resp = handle_deliver(&ctx, &req).await;
6515        assert!(resp.is_err());
6516        assert_eq!(resp.error().unwrap().code, ErrorCode::AgentNotFound);
6517        std::fs::remove_dir_all(home.root()).ok();
6518    }
6519
6520    /// Worker socket dead -> delivered=false reason=worker-unreachable + demoted event.
6521    #[tokio::test(flavor = "current_thread")]
6522    async fn handle_deliver_dead_worker_returns_unreachable() {
6523        let home = tmp_home("deliver-dead-worker");
6524        state::update_registry(&home.registry_json(), |r| {
6525            r.entries.push(RegistryEntry {
6526                name: "dead-agent".into(),
6527                short_id: "dA".into(),
6528                provider: "codex".into(),
6529                cwd: "/tmp".into(),
6530                project_root: "/tmp".into(),
6531                session_id: None,
6532                claude_short_id: None,
6533                claude_session_uuid: None,
6534                messaging_socket_path: None,
6535                codex_session_id: Some("some-uuid".into()),
6536                gemini_session_id: None,
6537                mcp_channel_id: None,
6538                cc_session_id: None,
6539                host_mode: Some("interactive".into()),
6540                status: AgentStatus::Live,
6541                last_message_at: None,
6542                created_at: "2026-06-07T00:00:00Z".into(),
6543                pid: None,
6544                pid_start_time: None,
6545                log_path: None,
6546                last_reconciled_at: None,
6547            });
6548        })
6549        .unwrap();
6550        // Gate override via thread-local - no process-global env var, no races.
6551        let _gate = with_injection_gate_allow("codex");
6552        let ctx = test_ctx_with_events(home.clone(), PathBuf::from("fno-agents-worker"));
6553        let req = Request::new(
6554            1,
6555            "agent.deliver",
6556            json!({"name": "dead-agent", "body": "hello", "from_name": "sender"}),
6557        );
6558        let resp = handle_deliver(&ctx, &req).await;
6559        drop(_gate);
6560        assert!(!resp.is_err(), "dead-worker must return Ok, not RPC error");
6561        let result = resp.result().unwrap();
6562        assert_eq!(result["delivered"], false);
6563        // Result reason must be non-empty (passthrough from inject_into_pty).
6564        let result_reason = result["reason"].as_str().unwrap_or("");
6565        assert!(
6566            !result_reason.is_empty(),
6567            "reason must not be empty on worker failure"
6568        );
6569        // Demoted event must be emitted and its reason must match the result.
6570        let events = read_events(&home);
6571        let demoted: Vec<_> = events
6572            .iter()
6573            .filter(|e| e["kind"] == "agent_deliver_demoted")
6574            .collect();
6575        assert!(
6576            !demoted.is_empty(),
6577            "agent_deliver_demoted event must be emitted on worker-unreachable"
6578        );
6579        let event_reason = demoted[0]["reason"].as_str().unwrap_or("");
6580        assert_eq!(
6581            result_reason, event_reason,
6582            "result reason must match event reason (F3 passthrough invariant)"
6583        );
6584        std::fs::remove_dir_all(home.root()).ok();
6585    }
6586
6587    /// Short-path home for deliver tests that start a real worker. Uses
6588    /// `/tmp/<tag>-<pid>-N` so the socket path stays well under the Unix
6589    /// 104-byte SUN_LEN limit (same technique as worker.rs test helpers).
6590    fn short_tmp_home(tag: &str) -> AgentsHome {
6591        use std::sync::atomic::{AtomicU32, Ordering};
6592        static CTR: AtomicU32 = AtomicU32::new(0);
6593        let n = CTR.fetch_add(1, Ordering::Relaxed);
6594        let p = PathBuf::from(format!("/tmp/abid{tag}{}{n}", std::process::id()));
6595        let home = AgentsHome::at(&p);
6596        home.ensure_root().unwrap();
6597        home
6598    }
6599
6600    /// AC4-HP (gate override): deliver to live cat worker -> PTY receives bracketed-paste
6601    /// provenance container + single CR; response delivered=true; injected event emitted.
6602    #[tokio::test(flavor = "current_thread")]
6603    async fn handle_deliver_gated_pass_injects_into_pty() {
6604        let home = short_tmp_home("inj");
6605        let short_id = "injW";
6606
6607        // Start a real cat worker.
6608        let worker_home = home.root().to_path_buf();
6609        let cfg = WorkerConfig::new(
6610            short_id,
6611            worker_home.clone(),
6612            std::env::temp_dir(),
6613            vec!["cat".to_string()],
6614        );
6615        std::thread::spawn(move || {
6616            let rt = tokio::runtime::Builder::new_current_thread()
6617                .enable_all()
6618                .build()
6619                .unwrap();
6620            rt.block_on(async {
6621                if let Err(e) = run_worker(cfg).await {
6622                    eprintln!("WORKER RUN ERROR: {e}");
6623                }
6624            });
6625        });
6626        // Wait for socket.
6627        let sock = home.worker_sock(short_id);
6628        let start = std::time::Instant::now();
6629        while !sock.exists() && start.elapsed() < Duration::from_secs(5) {
6630            tokio::time::sleep(Duration::from_millis(20)).await;
6631        }
6632        assert!(sock.exists(), "worker socket never appeared");
6633
6634        // Register the live worker in the registry.
6635        state::update_registry(&home.registry_json(), |r| {
6636            r.entries.push(RegistryEntry {
6637                name: "inject-agent".into(),
6638                short_id: short_id.into(),
6639                provider: "codex".into(),
6640                cwd: "/tmp".into(),
6641                project_root: "/tmp".into(),
6642                session_id: None,
6643                claude_short_id: None,
6644                claude_session_uuid: None,
6645                messaging_socket_path: None,
6646                codex_session_id: Some("test-uuid".into()),
6647                gemini_session_id: None,
6648                mcp_channel_id: None,
6649                cc_session_id: None,
6650                host_mode: Some("interactive".into()),
6651                status: AgentStatus::Live,
6652                last_message_at: None,
6653                created_at: "2026-06-07T00:00:00Z".into(),
6654                pid: None,
6655                pid_start_time: None,
6656                log_path: None,
6657                last_reconciled_at: None,
6658            });
6659        })
6660        .unwrap();
6661
6662        // Thread-local gate override: codex is allowed on this thread only.
6663        let _gate = with_injection_gate_allow("codex");
6664        let ctx = test_ctx_with_events(home.clone(), PathBuf::from("fno-agents-worker"));
6665        let req = Request::new(
6666            1,
6667            "agent.deliver",
6668            json!({"name": "inject-agent", "body": "test-payload", "from_name": "alice"}),
6669        );
6670        let resp = handle_deliver(&ctx, &req).await;
6671        drop(_gate);
6672
6673        assert!(
6674            !resp.is_err(),
6675            "inject must return Ok result, got: {:?}",
6676            resp.error()
6677        );
6678        let result = resp.result().unwrap();
6679        assert_eq!(result["delivered"], true);
6680        assert_eq!(result["transport"], "pty");
6681
6682        // Injected event emitted.
6683        let events = read_events(&home);
6684        assert!(
6685            events.iter().any(|e| e["kind"] == "agent_deliver_injected"),
6686            "agent_deliver_injected event must be emitted"
6687        );
6688
6689        // AC4-UI: PTY received the provenance container (cat echoes it back).
6690        // Connect to the worker socket and snapshot until we see the container tag.
6691        let mut conn = {
6692            let start = std::time::Instant::now();
6693            loop {
6694                match tokio::net::UnixStream::connect(&sock).await {
6695                    Ok(c) => break c,
6696                    Err(_) if start.elapsed() < Duration::from_secs(3) => {
6697                        tokio::time::sleep(Duration::from_millis(20)).await;
6698                    }
6699                    Err(e) => panic!("connect failed: {e}"),
6700                }
6701            }
6702        };
6703        let mut seen = false;
6704        for i in 0..50 {
6705            write_request(
6706                &mut conn,
6707                &Request::new(100 + i, "worker.snapshot", json!({})),
6708            )
6709            .await
6710            .unwrap();
6711            let r = crate::protocol::read_response(&mut conn).await.unwrap();
6712            let text = r.result().unwrap()["text"]
6713                .as_str()
6714                .unwrap_or("")
6715                .to_owned();
6716            if text.contains("cross-session-message") && text.contains("alice") {
6717                seen = true;
6718                break;
6719            }
6720            tokio::time::sleep(Duration::from_millis(20)).await;
6721        }
6722        assert!(
6723            seen,
6724            "PTY snapshot must contain the provenance container with from_name"
6725        );
6726
6727        // Shutdown worker.
6728        write_request(&mut conn, &Request::new(99, "worker.shutdown", json!({})))
6729            .await
6730            .unwrap();
6731        let _ = crate::protocol::read_response(&mut conn).await;
6732        std::fs::remove_dir_all(home.root()).ok();
6733    }
6734
6735    /// F7b: gate write->read->deliver e2e without thread-local override.
6736    ///
6737    /// Calls handle_gate_check (manual, passed, codex) to write the gate file,
6738    /// then registers a live cat worker, then calls handle_deliver with NO
6739    /// thread-local override. Asserts delivered=true + PTY received the container.
6740    /// This closes the writer->reader->deliver loop with zero hand-written gate fixtures.
6741    #[tokio::test(flavor = "current_thread")]
6742    async fn handle_gate_check_then_deliver_e2e_without_thread_local() {
6743        let home = short_tmp_home("gw2d");
6744        let short_id = "gw2W";
6745
6746        // Step 1: write the gate record via handle_gate_check (no thread-local).
6747        let ctx = test_ctx_with_events(home.clone(), PathBuf::from("fno-agents-worker"));
6748        let gate_req = Request::new(
6749            1,
6750            "agent.gate_check",
6751            json!({"provider": "codex", "record": "manual", "status": "passed", "notes": "e2e test"}),
6752        );
6753        let gate_resp = handle_gate_check(&ctx, &gate_req).await;
6754        assert!(
6755            !gate_resp.is_err(),
6756            "gate_check must succeed: {:?}",
6757            gate_resp
6758        );
6759        assert_eq!(gate_resp.result().unwrap()["status"], "passed");
6760
6761        // Verify the file was actually written (reader side).
6762        assert_eq!(
6763            injection_gate_with_home("codex", &home),
6764            InjectionGate::Passed,
6765            "gate must read back as Passed after handle_gate_check"
6766        );
6767
6768        // Step 2: start a real cat worker.
6769        let worker_home = home.root().to_path_buf();
6770        let cfg = WorkerConfig::new(
6771            short_id,
6772            worker_home.clone(),
6773            std::env::temp_dir(),
6774            vec!["cat".to_string()],
6775        );
6776        std::thread::spawn(move || {
6777            let rt = tokio::runtime::Builder::new_current_thread()
6778                .enable_all()
6779                .build()
6780                .unwrap();
6781            rt.block_on(async {
6782                if let Err(e) = run_worker(cfg).await {
6783                    eprintln!("WORKER RUN ERROR: {e}");
6784                }
6785            });
6786        });
6787        let sock = home.worker_sock(short_id);
6788        let start = std::time::Instant::now();
6789        while !sock.exists() && start.elapsed() < Duration::from_secs(5) {
6790            tokio::time::sleep(Duration::from_millis(20)).await;
6791        }
6792        assert!(sock.exists(), "worker socket never appeared");
6793
6794        // Register the live worker in the registry.
6795        state::update_registry(&home.registry_json(), |r| {
6796            r.entries.push(RegistryEntry {
6797                name: "gated-agent".into(),
6798                short_id: short_id.into(),
6799                provider: "codex".into(),
6800                cwd: "/tmp".into(),
6801                project_root: "/tmp".into(),
6802                session_id: None,
6803                claude_short_id: None,
6804                claude_session_uuid: None,
6805                messaging_socket_path: None,
6806                codex_session_id: Some("test-uuid".into()),
6807                gemini_session_id: None,
6808                mcp_channel_id: None,
6809                cc_session_id: None,
6810                host_mode: Some("interactive".into()),
6811                status: AgentStatus::Live,
6812                last_message_at: None,
6813                created_at: "2026-06-07T00:00:00Z".into(),
6814                pid: None,
6815                pid_start_time: None,
6816                log_path: None,
6817                last_reconciled_at: None,
6818            });
6819        })
6820        .unwrap();
6821
6822        // Step 3: deliver with NO thread-local override (gate must come from the file).
6823        let deliver_req = Request::new(
6824            2,
6825            "agent.deliver",
6826            json!({"name": "gated-agent", "body": "gate-e2e-payload", "from_name": "bob"}),
6827        );
6828        let resp = handle_deliver(&ctx, &deliver_req).await;
6829        assert!(!resp.is_err(), "deliver must return Ok");
6830        let result = resp.result().unwrap();
6831        assert_eq!(
6832            result["delivered"], true,
6833            "file-backed gate must allow delivery"
6834        );
6835        assert_eq!(result["transport"], "pty");
6836
6837        // PTY must contain the container.
6838        let mut conn = {
6839            let start = std::time::Instant::now();
6840            loop {
6841                match tokio::net::UnixStream::connect(&sock).await {
6842                    Ok(c) => break c,
6843                    Err(_) if start.elapsed() < Duration::from_secs(3) => {
6844                        tokio::time::sleep(Duration::from_millis(20)).await;
6845                    }
6846                    Err(e) => panic!("connect failed: {e}"),
6847                }
6848            }
6849        };
6850        let mut seen = false;
6851        for i in 0..50 {
6852            write_request(
6853                &mut conn,
6854                &Request::new(100 + i, "worker.snapshot", json!({})),
6855            )
6856            .await
6857            .unwrap();
6858            let r = crate::protocol::read_response(&mut conn).await.unwrap();
6859            let text = r.result().unwrap()["text"]
6860                .as_str()
6861                .unwrap_or("")
6862                .to_owned();
6863            if text.contains("cross-session-message") && text.contains("bob") {
6864                seen = true;
6865                break;
6866            }
6867            tokio::time::sleep(Duration::from_millis(20)).await;
6868        }
6869        assert!(
6870            seen,
6871            "PTY snapshot must contain the provenance container (gate e2e)"
6872        );
6873
6874        write_request(&mut conn, &Request::new(99, "worker.shutdown", json!({})))
6875            .await
6876            .unwrap();
6877        let _ = crate::protocol::read_response(&mut conn).await;
6878        std::fs::remove_dir_all(home.root()).ok();
6879    }
6880
6881    /// AC4-EDGE: multi-line body -> exactly one paste block, one CR, no per-line submissions.
6882    #[tokio::test(flavor = "current_thread")]
6883    async fn handle_deliver_multiline_body_single_paste_block() {
6884        let home = short_tmp_home("ml");
6885        let short_id = "mlW";
6886
6887        let worker_home = home.root().to_path_buf();
6888        let cfg = WorkerConfig::new(
6889            short_id,
6890            worker_home.clone(),
6891            std::env::temp_dir(),
6892            vec!["cat".to_string()],
6893        );
6894        std::thread::spawn(move || {
6895            let rt = tokio::runtime::Builder::new_current_thread()
6896                .enable_all()
6897                .build()
6898                .unwrap();
6899            rt.block_on(async {
6900                if let Err(e) = run_worker(cfg).await {
6901                    eprintln!("WORKER RUN ERROR: {e}");
6902                }
6903            });
6904        });
6905        let sock = home.worker_sock(short_id);
6906        let start = std::time::Instant::now();
6907        while !sock.exists() && start.elapsed() < Duration::from_secs(5) {
6908            tokio::time::sleep(Duration::from_millis(20)).await;
6909        }
6910        assert!(sock.exists(), "worker socket never appeared");
6911
6912        state::update_registry(&home.registry_json(), |r| {
6913            r.entries.push(RegistryEntry {
6914                name: "ml-agent".into(),
6915                short_id: short_id.into(),
6916                provider: "gemini".into(),
6917                cwd: "/tmp".into(),
6918                project_root: "/tmp".into(),
6919                session_id: None,
6920                claude_short_id: None,
6921                claude_session_uuid: None,
6922                messaging_socket_path: None,
6923                codex_session_id: None,
6924                gemini_session_id: Some("g-uuid".into()),
6925                mcp_channel_id: None,
6926                cc_session_id: None,
6927                host_mode: Some("interactive".into()),
6928                status: AgentStatus::Live,
6929                last_message_at: None,
6930                created_at: "2026-06-07T00:00:00Z".into(),
6931                pid: None,
6932                pid_start_time: None,
6933                log_path: None,
6934                last_reconciled_at: None,
6935            });
6936        })
6937        .unwrap();
6938
6939        let _gate = with_injection_gate_allow("gemini");
6940        let ctx = test_ctx_with_events(home.clone(), PathBuf::from("fno-agents-worker"));
6941        let req = Request::new(
6942            1,
6943            "agent.deliver",
6944            json!({"name": "ml-agent", "body": "line one\nline two\nline three\n\n", "from_name": "bob"}),
6945        );
6946        let resp = handle_deliver(&ctx, &req).await;
6947        drop(_gate);
6948
6949        assert!(!resp.is_err(), "multiline deliver must succeed");
6950        let result = resp.result().unwrap();
6951        assert_eq!(result["delivered"], true);
6952
6953        // Snapshot the PTY output and assert exactly one paste-end sequence
6954        // (ESC[201~) which bounds the bracketed paste block.
6955        let mut conn = {
6956            let start = std::time::Instant::now();
6957            loop {
6958                match tokio::net::UnixStream::connect(&sock).await {
6959                    Ok(c) => break c,
6960                    Err(_) if start.elapsed() < Duration::from_secs(3) => {
6961                        tokio::time::sleep(Duration::from_millis(20)).await;
6962                    }
6963                    Err(e) => panic!("connect failed: {e}"),
6964                }
6965            }
6966        };
6967        // Read raw bytes via read_since to count the bracketed-paste end markers.
6968        let mut cursor = 0u64;
6969        let mut raw: Vec<u8> = Vec::new();
6970        for i in 0..50 {
6971            write_request(
6972                &mut conn,
6973                &Request::new(100 + i, "worker.read_since", json!({"cursor": cursor})),
6974            )
6975            .await
6976            .unwrap();
6977            let r = crate::protocol::read_response(&mut conn).await.unwrap();
6978            let res = r.result().unwrap();
6979            cursor = res["next_offset"].as_u64().unwrap();
6980            let chunk = base64::engine::general_purpose::STANDARD
6981                .decode(res["bytes_b64"].as_str().unwrap())
6982                .unwrap();
6983            raw.extend_from_slice(&chunk);
6984            // ESC[201~ is the paste-end marker: 0x1b 0x5b 0x32 0x30 0x31 0x7e
6985            let paste_end = b"\x1b[201~";
6986            let count = raw
6987                .windows(paste_end.len())
6988                .filter(|w| *w == paste_end)
6989                .count();
6990            if count >= 1 {
6991                assert_eq!(
6992                    count, 1,
6993                    "must be exactly one bracketed-paste block, found {count}"
6994                );
6995                break;
6996            }
6997            if i == 49 {
6998                panic!("bracketed-paste end marker never appeared in PTY output");
6999            }
7000            tokio::time::sleep(Duration::from_millis(20)).await;
7001        }
7002
7003        write_request(&mut conn, &Request::new(99, "worker.shutdown", json!({})))
7004            .await
7005            .unwrap();
7006        let _ = crate::protocol::read_response(&mut conn).await;
7007        std::fs::remove_dir_all(home.root()).ok();
7008    }
7009
7010    // ── F3: deliver Err arm must pass through the real reason string ──────────
7011
7012    /// F3 (MEDIUM): inject_into_pty Err -> result reason must match the event
7013    /// reason, not be hard-coded to "worker-unreachable".
7014    #[tokio::test(flavor = "current_thread")]
7015    async fn handle_deliver_err_reason_matches_event_reason() {
7016        // Register a live codex agent with a sock path that won't exist so
7017        // inject_into_pty produces an error with a specific message.
7018        let home = tmp_home("deliver-err-passthrough");
7019        let short_id = "errPass01";
7020        state::update_registry(&home.registry_json(), |r| {
7021            r.entries.push(RegistryEntry {
7022                name: "err-agent".into(),
7023                short_id: short_id.into(),
7024                provider: "codex".into(),
7025                cwd: "/tmp".into(),
7026                project_root: "/tmp".into(),
7027                session_id: None,
7028                claude_short_id: None,
7029                claude_session_uuid: None,
7030                messaging_socket_path: None,
7031                codex_session_id: Some("some-uuid".into()),
7032                gemini_session_id: None,
7033                mcp_channel_id: None,
7034                cc_session_id: None,
7035                host_mode: Some("interactive".into()),
7036                status: AgentStatus::Live,
7037                last_message_at: None,
7038                created_at: "2026-06-07T00:00:00Z".into(),
7039                pid: None,
7040                pid_start_time: None,
7041                log_path: None,
7042                last_reconciled_at: None,
7043            });
7044        })
7045        .unwrap();
7046        // Gate must be Passed so we reach inject_into_pty.
7047        let _guard = with_injection_gate_allow("codex");
7048
7049        let ctx = test_ctx_with_events(home.clone(), PathBuf::from("fno-agents-worker"));
7050        let req = Request::new(
7051            1,
7052            "agent.deliver",
7053            json!({"name": "err-agent", "body": "hello", "from_name": "sender"}),
7054        );
7055        let resp = handle_deliver(&ctx, &req).await;
7056        assert!(
7057            !resp.is_err(),
7058            "deliver must return Ok even on inject failure"
7059        );
7060
7061        let result = resp.result().unwrap();
7062        assert_eq!(
7063            result["delivered"], false,
7064            "inject failure must produce delivered=false"
7065        );
7066
7067        // The result reason must NOT be the hard-coded "worker-unreachable" when
7068        // the failure is a socket-connect error (which is also a connectivity
7069        // error, so "worker-unreachable" is acceptable here — the key invariant
7070        // is that the event reason and result reason agree).
7071        let result_reason = result["reason"].as_str().unwrap_or("").to_string();
7072
7073        // Read back events from the file.
7074        let events = read_events(&home);
7075        let demoted: Vec<_> = events
7076            .iter()
7077            .filter(|e| e["kind"] == "agent_deliver_demoted")
7078            .collect();
7079        assert!(
7080            !demoted.is_empty(),
7081            "agent_deliver_demoted must be emitted on inject failure"
7082        );
7083        let event_reason = demoted[0]["reason"].as_str().unwrap_or("");
7084
7085        // Core invariant: event reason and result reason must agree.
7086        assert_eq!(
7087            result_reason, event_reason,
7088            "result reason {result_reason:?} must match event reason {event_reason:?}"
7089        );
7090
7091        std::fs::remove_dir_all(home.root()).ok();
7092    }
7093
7094    // ── F4: xml_attr_escape must escape single-quote ─────────────────────────
7095
7096    /// F4 (MEDIUM): xml_attr_escape must escape ' -> &#x27; for byte-parity
7097    /// with Python's html.escape(quote=True).
7098    #[test]
7099    fn xml_attr_escape_single_quote() {
7100        assert_eq!(xml_attr_escape("O'Brien"), "O&#x27;Brien");
7101        // Also verify the other escapes remain intact.
7102        assert_eq!(
7103            xml_attr_escape("a&b<c>\"d'e"),
7104            "a&amp;b&lt;c&gt;&quot;d&#x27;e"
7105        );
7106        // No escaping needed for plain text.
7107        assert_eq!(xml_attr_escape("hello"), "hello");
7108    }
7109
7110    /// F4: inject_into_pty container uses &#x27; for single-quote in from_name
7111    /// (spot-check the container the daemon builds before PTY write).
7112    #[test]
7113    fn xml_attr_escape_used_in_container_from_name() {
7114        // The container is: <cross-session-message from-name="<escaped>">
7115        // So a from_name with a single-quote must appear as &#x27; in the tag.
7116        let escaped = xml_attr_escape("O'Brien");
7117        let container_prefix = format!("<cross-session-message from-name=\"{escaped}\">");
7118        assert!(
7119            container_prefix.contains("&#x27;"),
7120            "container must use &#x27; for single-quote: {container_prefix:?}"
7121        );
7122    }
7123
7124    // ── F5: injection_gate_with_home must check doc["v"] ─────────────────────
7125
7126    /// F5 (LOW): gate file with v:2 and status=passed must return Unverified
7127    /// (conservative: unknown schema version -> treat as unverified).
7128    #[test]
7129    fn injection_gate_unknown_version_returns_unverified() {
7130        let home = tmp_home("gate-v2-unverified");
7131        let gate_path = home.injection_gate_json();
7132        // v:2 is unknown -> must be treated as Unverified even with status=passed.
7133        std::fs::write(
7134            &gate_path,
7135            r#"{"v":2,"providers":{"codex":{"status":"passed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
7136        )
7137        .unwrap();
7138        assert_eq!(
7139            injection_gate_with_home("codex", &home),
7140            InjectionGate::Unverified,
7141            "unknown gate file version (v:2) must return Unverified"
7142        );
7143        std::fs::remove_dir_all(home.root()).ok();
7144    }
7145
7146    /// F5: gate file with v:1 and status=passed must still return Passed
7147    /// (the version check must not break the normal path).
7148    #[test]
7149    fn injection_gate_v1_passed_still_returns_passed() {
7150        let home = tmp_home("gate-v1-still-passed");
7151        let gate_path = home.injection_gate_json();
7152        std::fs::write(
7153            &gate_path,
7154            r#"{"v":1,"providers":{"codex":{"status":"passed","checked_at":"2026-06-07T00:00:00Z","method":"manual","notes":""}}}"#,
7155        )
7156        .unwrap();
7157        assert_eq!(
7158            injection_gate_with_home("codex", &home),
7159            InjectionGate::Passed,
7160            "v:1 gate with status=passed must return Passed"
7161        );
7162        std::fs::remove_dir_all(home.root()).ok();
7163    }
7164}