180	    // so this is a no-op now and forward-correct when daemon-hosted sessions land
181	    // (the live-agent adapter). A failure here is non-fatal — the brain still
182	    // serves; the next heartbeat probes broker liveness. NOTE: this is the PTY
183	    // message-DELIVERY axis; the orthogonal live-agent **Psyche** lifecycle (the
184	    // run_pulse_loop production caller) is hosted by `livehost::spawn_live_host`
185	    // below, keyed on the live ENDPOINT not the broker session (CONTEXT:168/177).
186	    match brain.resume_sessions() {
187	        Ok(ids) if !ids.is_empty() => {
188	            eprintln!(
189	                "BRAIN_RESUMED: re-attached {} session(s): {ids:?}",
190	                ids.len()
191	            )
192	        }
193	        Ok(_) => {}
194	        Err(e) => eprintln!("BRAIN_RESUME_NONFATAL: {e} — no sessions resumed, continuing"),
195	    }
196
197	    // Signal READY only now — AFTER session resume (restoration D6-1b, ADR-0018
198	    // Q7): "ready" is the design's "re-attached all sessions + resumed loops",
199	    // not merely "socket connected". The supervisor's readiness gate (D6-2)
200	    // promotes the trial binary on this stamp; promoting on a bare post-connect
201	    // breadcrumb would accept a brain that connected but never rebuilt
202	    // continuity. Today resume is a near-noop (no daemon-hosted sessions), so
203	    // this is forward-correct: the gate is defined on the real readiness
204	    // semantic the live-agent adapter inherits.
205	    write_ready(generation);
206
207	    // Shell wake-watchers (restoration D2-2): the offline half of the shell
208	    // online/offline exclusivity — a reconcile loop that runs each offline
209	    // instance's `wake_command` as a supervised watcher child. Relocated from
210	    // the broker process to the brain child: shellwake is brain-owned logic
211	    // (ADR-0018 Q5 exception), a pure disk-reconciler that only reaches the
212	    // broker over the same socket IPC for the remote-wake dial. Its boot sweep
213	    // doubles as orphan cleanup, so a brain respawn re-reconciles the watcher
214	    // children from disk — exactly the Q5 "re-reconciled on brain start"
215	    // posture. The stop flag is held for symmetry, never raised (the watchers
216	    // die with the brain process and the next brain re-sweeps them).
217	    // [impl->REQ-SHELL-2]
218	    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
219	    let _ = crate::shellwake::spawn_wake_host(Arc::new(AtomicBool::new(false)));
220
221	    // Live-agent Psyche host (M11-W0.1, REQ-DAEMON-1): the production caller
222	    // `run_pulse_loop` lacked. One thread sweeps `livehost::reconcile_once` at
223	    // boot (the rehydrate the no-op above can't do) and every tick — re-hosting
224	    // every ONLINE live endpoint from disk (the harness-endpoint mirror of the
225	    // shell waker). Online-only is staged behind the interim `api listen`
226	    // (status-absent perches are skipped — no double-host) until W0.2 makes the
227	    // brain the first-host. The stop flag is held for symmetry; the threads die
228	    // with the brain and the next brain rehydrates from disk (CONTEXT:194).
229	    // [impl->REQ-DAEMON-1]
230	    let _ = crate::livehost::spawn_live_host(Arc::new(AtomicBool::new(false)), reason);
231
232	    let mut consumers_started = false;
233	    loop {
234	        thread::sleep(BRAIN_HEARTBEAT);
235	        // `net-status` is both the net-capability probe and the broker-liveness
236	        // probe: an `Err` is the broker gone (or a future planned
237	        // snapshot+self-exit, Q3) — exit for a supervised respawn.
238	        let status = match brain.net_status() {
239	            Ok(s) => s,
240	            Err(e) => {
241	                eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
242	                return Err(e);
243	            }
244	        };
245	        if consumer_gate(status.enabled, consumers_started) {
246	            let hex = status.node_id_hex.clone().unwrap_or_default();
247	            spawn_net_consumers(&hex);
248	            consumers_started = true;
249	            eprintln!(
250	                "BRAIN_NET_CONSUMERS_UP: net enabled (node {hex}) — dispatcher + peer pump started"
251	            );
252	        }
253	        write_ready(generation);
254	    }
255	}
256
257	/// Whether to spawn the net consumers this heartbeat: only once the broker
258	/// reports a net endpoint up (`net_enabled`) AND we have not already started
259	/// them. Pure for the unit table — the spawn-exactly-once + net-gating
260	/// semantics that keep a single dispatcher/peer-pump set running.
261	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
262	fn consumer_gate(net_enabled: bool, already_started: bool) -> bool {
263	    net_enabled && !already_started
264	}
265
266	/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump
267	/// (D9-1) — against the broker's socket. **Relocated from the broker process to
268	/// the brain child (restoration D2-1):** both are standalone IPC clients
269	/// (`Brain::cold_start`, no shared `Arc<Broker>`), so they belong with the
270	/// restartable brain. Each mints its own registry + stop flag and connects
271	/// independently; when the brain process exits these in-process threads die with
272	/// it, and the respawned brain re-spawns them from the current binary. Mirrors
273	/// the wiring the broker boot used to do, `node_hex` now sourced from the
274	/// `net-status` reply rather than the broker's identity load.
275	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
276	// [impl->REQ-DAEMON-9]
277	fn spawn_net_consumers(node_hex: &str) {
278	    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_hex));
279	    let stop = Arc::new(AtomicBool::new(false));
280	    let _ = crate::dispatch::spawn_dispatcher(
281	        broker_socket_name(),
282	        Arc::clone(&registry),
283	        Arc::clone(&stop),
284	    );
285	    let cfg = crate::config::DaemonConfig::load();
286	    if cfg.peer_pump {
287	        let _ = crate::pump::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);
288	    }
289	}

    // so this is a no-op now and forward-correct when daemon-hosted sessions land
    // (the live-agent adapter). A failure here is non-fatal — the brain still
    // serves; the next heartbeat probes broker liveness. NOTE: this is the PTY
    // message-DELIVERY axis; the orthogonal live-agent **Psyche** lifecycle (the
    // run_pulse_loop production caller) is hosted by `livehost::spawn_live_host`
    // below, keyed on the live ENDPOINT not the broker session (CONTEXT:168/177).
    match brain.resume_sessions() {
        Ok(ids) if !ids.is_empty() => {
            eprintln!(
                "BRAIN_RESUMED: re-attached {} session(s): {ids:?}",
                ids.len()
            )
        }
        Ok(_) => {}
        Err(e) => eprintln!("BRAIN_RESUME_NONFATAL: {e} — no sessions resumed, continuing"),
    }

    // Signal READY only now — AFTER session resume (restoration D6-1b, ADR-0018
    // Q7): "ready" is the design's "re-attached all sessions + resumed loops",
    // not merely "socket connected". The supervisor's readiness gate (D6-2)
    // promotes the trial binary on this stamp; promoting on a bare post-connect
    // breadcrumb would accept a brain that connected but never rebuilt
    // continuity. Today resume is a near-noop (no daemon-hosted sessions), so
    // this is forward-correct: the gate is defined on the real readiness
    // semantic the live-agent adapter inherits.
    write_ready(generation);

    // Shell wake-watchers (restoration D2-2): the offline half of the shell
    // online/offline exclusivity — a reconcile loop that runs each offline
    // instance's `wake_command` as a supervised watcher child. Relocated from
    // the broker process to the brain child: shellwake is brain-owned logic
    // (ADR-0018 Q5 exception), a pure disk-reconciler that only reaches the
    // broker over the same socket IPC for the remote-wake dial. Its boot sweep
    // doubles as orphan cleanup, so a brain respawn re-reconciles the watcher
    // children from disk — exactly the Q5 "re-reconciled on brain start"
    // posture. The stop flag is held for symmetry, never raised (the watchers
    // die with the brain process and the next brain re-sweeps them).
    // [impl->REQ-SHELL-2]
    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    let _ = crate::shellwake::spawn_wake_host(Arc::new(AtomicBool::new(false)));

    // Live-agent Psyche host (M11-W0.1, REQ-DAEMON-1): the production caller
    // `run_pulse_loop` lacked. One thread sweeps `livehost::reconcile_once` at
    // boot (the rehydrate the no-op above can't do) and every tick — re-hosting
    // every ONLINE live endpoint from disk (the harness-endpoint mirror of the
    // shell waker). Online-only is staged behind the interim `api listen`
    // (status-absent perches are skipped — no double-host) until W0.2 makes the
    // brain the first-host. The stop flag is held for symmetry; the threads die
    // with the brain and the next brain rehydrates from disk (CONTEXT:194).
    // [impl->REQ-DAEMON-1]
    let _ = crate::livehost::spawn_live_host(Arc::new(AtomicBool::new(false)), reason);

    let mut consumers_started = false;
    loop {
        thread::sleep(BRAIN_HEARTBEAT);
        // `net-status` is both the net-capability probe and the broker-liveness
        // probe: an `Err` is the broker gone (or a future planned
        // snapshot+self-exit, Q3) — exit for a supervised respawn.
        let status = match brain.net_status() {
            Ok(s) => s,
            Err(e) => {
                eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
                return Err(e);
            }
        };
        if consumer_gate(status.enabled, consumers_started) {
            let hex = status.node_id_hex.clone().unwrap_or_default();
            spawn_net_consumers(&hex);
            consumers_started = true;
            eprintln!(
                "BRAIN_NET_CONSUMERS_UP: net enabled (node {hex}) — dispatcher + peer pump started"
            );
        }
        write_ready(generation);
    }
}

/// Whether to spawn the net consumers this heartbeat: only once the broker
/// reports a net endpoint up (`net_enabled`) AND we have not already started
/// them. Pure for the unit table — the spawn-exactly-once + net-gating
/// semantics that keep a single dispatcher/peer-pump set running.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
fn consumer_gate(net_enabled: bool, already_started: bool) -> bool {
    net_enabled && !already_started
}

/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump
/// (D9-1) — against the broker's socket. **Relocated from the broker process to
/// the brain child (restoration D2-1):** both are standalone IPC clients
/// (`Brain::cold_start`, no shared `Arc<Broker>`), so they belong with the
/// restartable brain. Each mints its own registry + stop flag and connects
/// independently; when the brain process exits these in-process threads die with
/// it, and the respawned brain re-spawns them from the current binary. Mirrors
/// the wiring the broker boot used to do, `node_hex` now sourced from the
/// `net-status` reply rather than the broker's identity load.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
// [impl->REQ-DAEMON-9]
fn spawn_net_consumers(node_hex: &str) {
    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_hex));
    let stop = Arc::new(AtomicBool::new(false));
    let _ = crate::dispatch::spawn_dispatcher(
        broker_socket_name(),
        Arc::clone(&registry),
        Arc::clone(&stop),
    );
    let cfg = crate::config::DaemonConfig::load();
    if cfg.peer_pump {
        let _ = crate::pump::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);
    }
}


Prompt: proceed with next steps. JIT plan v0.8.1, delegate W/T planning and execution to todlando, and see it through to release publish. inform perri when it's done


Operator: JIT plan v0.8.1, delegate planning+execution to todlando, drive to release, ping perri. Grounding more before I write the delegation.


1	//! The brain's live-agent Psyche host (M11-W0.1, REQ-DAEMON-1) — re-host the
2	//! daemon-hosted LiveAgent lifecycle from disk and reconcile the host-set.
3	//!
4	//! This is the production caller `run_pulse_loop` lacked: the brain instantiates
5	//! a [`BrainLifecycle`] per hosted **live** endpoint and drives its config-paced
6	//! pulse loop on a supervised thread. It is the **harness-endpoint mirror** of
7	//! [`crate::shellwake::reconcile_once`] (which re-hosts offline shells from
8	//! disk): same shape — enumerate Self perches, resolve each manifest from its
9	//! `info.json` `adapter` option, hold a stop flag per supervised thread, and run
10	//! one bounded reconcile sweep at boot and every tick. Where the shell waker
11	//! hosts **offline** shells, this hosts **online** live endpoints — `status` is
12	//! daemon-authoritative liveness (CONTEXT:194 / KH 2.5), so re-hosting the
13	//! online ones re-hosts exactly what was live before a brain (re)start, and
14	//! never resurrects a gracefully-departed (offline) agent.
15	//!
16	//! **Online-only is staged behind the interim.** The interim `spt api listen`
17	//! process still first-hosts its live agents today; those perches are
18	//! `status`-ABSENT (not "online"), so this scan ignores them — no double-host in
19	//! the W0.1→W0.2 window. W0.2 retires the interim and makes the brain the
20	//! first-host (it sets `status=online` on a live `listen`/`bind`); from then this
21	//! reconcile reacts within one tick, the same way the shell waker reacts to a
22	//! CLI-process status flip. The full bind→online→brain-restart→re-host arc is
23	//! proven through the real brain process by the W0.4 production int.
24	// [impl->REQ-DAEMON-1]
25
26	use std::collections::HashMap;
27	use std::path::Path;
28	use std::sync::atomic::{AtomicBool, Ordering};
29	use std::sync::{Arc, Mutex};
30	use std::thread::JoinHandle;
31	use std::time::Duration;
32
33	use spt_runtime::registry::AdapterRecord;
34	use spt_runtime::Manifest;
35	use spt_store::liveness::STATUS_ONLINE;
36	use spt_store::perch::{self, ParentHint};
37
38	use crate::brainproc::StartReason;
39	use crate::config::DaemonConfig;
40	use crate::lifecycle::BrainLifecycle;
41
42	/// The `InfoJson::state` tag of a live agent — only these get a daemon-hosted
43	/// Psyche; a `ready_agent` (messaging-only) never does.
44	const LIVE_AGENT_STATE: &str = "live_agent";
45
46	/// The reconcile cadence (ms): a lifecycle flip lands in a CLI process, so the
47	/// daemon loop is the invariant holder — a flip is at most one tick stale
48	/// (mirrors [`crate::shellwake::RECONCILE_INTERVAL_MS`]).
49	pub const LIVE_RECONCILE_INTERVAL_MS: u64 = 5_000;
50
51	/// One brain-hosted live lifecycle: its config-paced pulse-driver thread plus the
52	/// held stop flag (tripped to un-host on an offline-transition).
53	struct HostedLife {
54	    stop: Arc<AtomicBool>,
55	    thread: JoinHandle<()>,
56	}
57
58	/// The brain's hosted live-endpoint table, keyed by endpoint id. Shared between
59	/// the reconcile loop and anything that must stop a host. Mirror of
60	/// [`crate::shellwake::WakeSet`].
61	#[derive(Default)]
62	pub struct LiveSet {
63	    inner: Mutex<HashMap<String, HostedLife>>,
64	}
65
66	impl LiveSet {
67	    pub fn new() -> Self {
68	        Self::default()
69	    }
70
71	    /// How many lifecycles are hosted (finished driver threads pruned first).
72	    pub fn len(&self) -> usize {
73	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
74	        map.retain(|_, h| !h.thread.is_finished());
75	        map.len()
76	    }
77
78	    pub fn is_empty(&self) -> bool {
79	        self.len() == 0
80	    }
81
82	    /// Is `id` currently hosted (driver thread alive)? Prunes a finished entry so
83	    /// a crashed driver is re-hosted next sweep.
84	    fn contains(&self, id: &str) -> bool {
85	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
86	        match map.get(id) {
87	            Some(h) if !h.thread.is_finished() => true,
88	            Some(_) => {
89	                map.remove(id);
90	                false
91	            }
92	            None => false,
93	        }
94	    }
95
96	    fn insert(&self, id: &str, handle: HostedLife) {
97	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
98	        map.insert(id.to_string(), handle);
99	    }
100
101	    /// The currently-tabled ids (finished pruned) — the stop-side reconcile input.
102	    fn ids(&self) -> Vec<String> {
103	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
104	        map.retain(|_, h| !h.thread.is_finished());
105	        map.keys().cloned().collect()
106	    }
107
108	    /// Un-host one lifecycle: trip its stop flag, join the driver, de-table. The
109	    /// pulse loop checks the flag each sleep-slice, so the join is prompt. We do
110	    /// NOT re-stamp `offline` — the perch already left the online set (graceful
111	    /// signoff cleared it, or the perch is gone); a brain *crash* deliberately
112	    /// leaves `status=online` so the next brain rehydrates it (CONTEXT:194).
113	    fn stop_host(&self, id: &str) {
114	        let handle = {
115	            let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
116	            map.remove(id)
117	        };
118	        if let Some(h) = handle {
119	            h.stop.store(true, Ordering::SeqCst);
120	            let _ = h.thread.join();
121	        }
122	    }
123	}
124
125	/// One reconcile sweep — the brain's live host-set invariant holder, run at boot
126	/// and every [`LIVE_RECONCILE_INTERVAL_MS`]. The complete set-diff against the
127	/// on-disk **online** live endpoints (mirror of [`crate::shellwake::reconcile_once`]):
128	///
129	/// - **un-host** (tabled ∧ ¬online) — stop the driver + de-table (exit guard: a
130	///   signed-off / removed endpoint must not keep pulsing — thread + dead-agent leak).
131	/// - **host** (online ∧ ¬tabled) — spawn the Psyche + start the driver.
132	/// - **leave-alone** (online ∧ tabled).
133	///
134	/// `status` is daemon-authoritative (CONTEXT:194); interim `listen` perches are
135	/// `status`-ABSENT so they are never hosted here (no double-host) until W0.2.
136	// [impl->REQ-DAEMON-1]
137	pub fn reconcile_once(
138	    owlery: &Path,
139	    registered: &[(AdapterRecord, Manifest)],
140	    adapters_dir: &Path,
141	    set: &Arc<LiveSet>,
142	    cfg: &DaemonConfig,
143	    reason: StartReason,
144	) {
145	    // Stop side first: hosted endpoints that DEFINITIVELY left the online set —
146	    // trip the flag, join, de-table. Un-host on a definitive departure ONLY, never
147	    // on a TRANSIENT read failure (M11-W0.4 (ii)): a momentarily-unreadable
148	    // info.json must not stop+respawn the driver (Psyche churn on a disk blip). The
149	    // discriminator is the perch DIR:
150	    //   - dir gone        → torn down (teardown removed the perch) → un-host;
151	    //   - dir present, read_info Some(status≠online) → signed-off/offline → un-host;
152	    //   - dir present, read_info None (transient/torn read) → LEAVE the driver, retry.
153	    // [impl->REQ-DAEMON-1]
154	    for id in set.ids() {
155	        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
156	        let unhost = if !perch.exists() {
157	            true // the perch dir is gone — torn down
158	        } else {
159	            match spt_store::info::read_info(&perch) {
160	                Some(info) => info.status.as_deref() != Some(STATUS_ONLINE),
161	                None => false, // dir present but unreadable: transient → keep hosting
162	            }
163	        };
164	        if unhost {
165	            set.stop_host(&id);
166	        }
167	    }
168
169	    // Start side: every online live endpoint not yet hosted.
170	    for id in perch::list_self_perch_ids(owlery) {
171	        if set.contains(&id) {
172	            continue; // already hosted (leave-alone)
173	        }
174	        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
175	        let Some(info) = spt_store::info::read_info(&perch) else {
176	            continue;
177	        };
178	        if info.state != LIVE_AGENT_STATE {
179	            continue; // not a live agent (ready_agent etc.)
180	        }
181	        if info.status.as_deref() != Some(STATUS_ONLINE) {
182	            continue; // interim (status-absent) or signed-off (offline)
183	        }
184	        let Some(adapter) = info.adapter.as_deref() else {
185	            continue; // adapterless endpoint — never live-capable
186	        };
187	        let Ok(manifest) = spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter)
188	        else {
189	            continue; // deregistered adapter: nothing to host
190	        };
191	        let Some(lifecycle) = BrainLifecycle::with_config(&manifest, &id, cfg.clone()) else {
192	            continue; // manifest declares no [session.psyche_init]
193	        };
194	        host_one(set, &id, lifecycle, info.session_id, reason);
195	    }
196	}
197
198	/// Host one live endpoint: sweep a stale signoff (3.2), spawn the Psyche (the
199	/// daemon online-stamp makes liveness daemon-authoritative), then start the
200	/// config-paced pulse driver on its own supervised thread with a held stop flag.
201	/// A Psyche-spawn failure tables nothing — the next sweep retries.
202	// [impl->REQ-DAEMON-1]
203	fn host_one(
204	    set: &Arc<LiveSet>,
205	    id: &str,
206	    lifecycle: BrainLifecycle,
207	    session_id: String,
208	    reason: StartReason,
209	) {
210	    lifecycle.sweep_on_start();
211	    match lifecycle.spawn_psyche(Some(&session_id), None) {
212	        Ok(h) => eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid),
213	        Err(e) => {
214	            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
215	            return; // no Psyche → no driver; retried next reconcile
216	        }
217	    }
218	    let stop = Arc::new(AtomicBool::new(false));
219	    let handle = {
220	        let stop = Arc::clone(&stop);
221	        std::thread::spawn(move || {
222	            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
223	        })
224	    };
225	    set.insert(id, HostedLife { stop, thread: handle });
226	}
227
228	/// Spawn the brain's live host: one thread sweeping [`reconcile_once`] at boot
229	/// and every [`LIVE_RECONCILE_INTERVAL_MS`] until `stop`. The registered set +
230	/// config are re-read each sweep (adapter / pulse-period changes land between
231	/// ticks). Mirror of [`crate::shellwake::spawn_wake_host`]; the stop flag is held
232	/// for symmetry — the threads die with the brain process and the next brain
233	/// rehydrates from disk.
234	// [impl->REQ-DAEMON-1]
235	pub fn spawn_live_host(stop: Arc<AtomicBool>, reason: StartReason) -> JoinHandle<()> {
236	    std::thread::spawn(move || {
237	        let set = Arc::new(LiveSet::new());
238	        while !stop.load(Ordering::SeqCst) {
239	            let owlery = perch::owlery_dir();
240	            let adapters_dir = perch::adapters_dir();
241	            let registered = spt_runtime::registry::registered(&adapters_dir);
242	            let cfg = DaemonConfig::load();
243	            reconcile_once(&owlery, &registered, &adapters_dir, &set, &cfg, reason);
244	            // Sleep in slices so a stop lands promptly.
245	            let mut left = LIVE_RECONCILE_INTERVAL_MS;
246	            while left > 0 && !stop.load(Ordering::SeqCst) {
247	                let step = left.min(100);
248	                std::thread::sleep(Duration::from_millis(step));
249	                left -= step;
250	            }
251	        }
252	    })
253	}
254
255	#[cfg(test)]
256	mod tests {
257	    use super::*;
258	    use crate::test_home::with_home;
259	    use std::time::{Duration, Instant};
260
261	    // A no-op Psyche summarizer command: spawns + exits 0 (we never depend on its
262	    // output — the daemon online-stamp is what hosts it, not the pid).
263	    #[cfg(windows)]
264	    const NOOP_CMD: &str = "cmd /C rem";
265	    #[cfg(unix)]
266	    const NOOP_CMD: &str = "true";
267
268	    /// Register a live-capable adapter `name` (declares `[session.psyche_init]` +
269	    /// a watched commune/signoff dir) so `resolve_option_in` finds it from disk.
270	    fn register_live_adapter(name: &str, drops: &Path) {
271	        let dir = drops.to_string_lossy().replace('\\', "/");
272	        let toml = format!(
273	            "[adapter]\nname=\"{name}\"\nkind=\"harness\"\nversion=\"1\"\n\
274	             min_spt_core_version=\"0\"\n\n\
275	             [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
276	             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
277	        );
278	        let src = perch::spt_home().join("srcs").join(name);
279	        std::fs::create_dir_all(&src).unwrap();
280	        std::fs::write(src.join("manifest.toml"), toml).unwrap();
281	        spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
282	    }
283
284	    /// Seed a Self **live** perch on disk: `state=live_agent`, the given status,
285	    /// and the adapter option that resolves the manifest.
286	    fn seed_live_perch(id: &str, adapter: &str, status: &str) {
287	        let perch = perch::resolve_perch_path(id, ParentHint::Infer);
288	        std::fs::create_dir_all(&perch).unwrap();
289	        let mut rec = spt_store::info::InfoJson::new(id, "t", std::process::id(), "sid-1", "live_agent");
290	        rec.adapter = Some(adapter.to_string());
291	        spt_store::info::write_info(&perch, &rec).unwrap();
292	        spt_store::info::set_status(&perch, status).unwrap();
293	    }
294
295	    /// A fast-pulse config so the driver loop ticks quickly in tests.
296	    fn fast_cfg() -> DaemonConfig {
297	        DaemonConfig {
298	            pulse_period: Duration::from_millis(15),
299	            ..DaemonConfig::default()
300	        }
301	    }
302
303	    fn registered_now() -> Vec<(AdapterRecord, Manifest)> {
304	        spt_runtime::registry::registered(&perch::adapters_dir())
305	    }
306
307	    /// Poll a predicate up to `bound`, sleeping 20ms between checks.
308	    fn wait_until(bound: Duration, mut pred: impl FnMut() -> bool) -> bool {
309	        let deadline = Instant::now() + bound;
310	        while Instant::now() < deadline {
311	            if pred() {
312	                return true;
313	            }
314	            std::thread::sleep(Duration::from_millis(20));
315	        }
316	        pred()
317	    }
318
319	    // [unit->REQ-DAEMON-1] (a) FIRST-HOST scan: an online live endpoint on disk is
320	    // hosted by the reconcile — the Psyche is spawned AND the config-paced pulse
321	    // driver actually runs (it stamps last_active_ms on the Self perch each tick).
322	    #[test]
323	    fn reconcile_hosts_an_online_live_endpoint_and_drives_its_pulse() {
324	        with_home(|_| {
325	            let drops = perch::spt_home().join("drops");
326	            std::fs::create_dir_all(&drops).unwrap();
327	            register_live_adapter("mock", &drops);
328	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
329
330	            let set = Arc::new(LiveSet::new());
331	            let cfg = fast_cfg();
332	            reconcile_once(
333	                &perch::owlery_dir(),
334	                &registered_now(),
335	                &perch::adapters_dir(),
336	                &set,
337	                &cfg,
338	                StartReason::Cold,
339	            );
340	            assert_eq!(set.len(), 1, "the online live endpoint is hosted");
341
342	            // The driver pulse runs: an Active perch stamps last_active_ms each tick.
343	            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
344	            let ran = wait_until(Duration::from_secs(3), || {
345	                spt_store::info::read_info(&self_perch)
346	                    .and_then(|i| i.last_active_ms)
347	                    .is_some()
348	            });
349	            assert!(ran, "the pulse driver loop ticks (stamps last_active_ms)");
350
351	            // Teardown so the test process doesn't leak the driver thread.
352	            set.stop_host("doyle");
353	        });
354	    }
355
356	    // [unit->REQ-DAEMON-1] (b) ENTRY GUARD: a never-hosted OFFLINE live endpoint is
357	    // skipped — online-only. (Also covers the interim status-absent case shape.)
358	    #[test]
359	    fn reconcile_skips_an_offline_live_endpoint() {
360	        with_home(|_| {
361	            let drops = perch::spt_home().join("drops");
362	            std::fs::create_dir_all(&drops).unwrap();
363	            register_live_adapter("mock", &drops);
364	            seed_live_perch("doyle", "mock", spt_store::liveness::STATUS_OFFLINE);
365
366	            let set = Arc::new(LiveSet::new());
367	            reconcile_once(
368	                &perch::owlery_dir(),
369	                &registered_now(),
370	                &perch::adapters_dir(),
371	                &set,
372	                &fast_cfg(),
373	                StartReason::Cold,
374	            );
375	            assert!(set.is_empty(), "an offline live endpoint is never hosted");
376	        });
377	    }
378
379	    // [unit->REQ-DAEMON-1] (c) NO DOUBLE-HOST: a second reconcile over an
380	    // already-tabled endpoint leaves exactly one host (the contains-guard).
381	    #[test]
382	    fn reconcile_does_not_double_host() {
383	        with_home(|_| {
384	            let drops = perch::spt_home().join("drops");
385	            std::fs::create_dir_all(&drops).unwrap();
386	            register_live_adapter("mock", &drops);
387	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
388
389	            let set = Arc::new(LiveSet::new());
390	            let cfg = fast_cfg();
391	            let run = || {
392	                reconcile_once(
393	                    &perch::owlery_dir(),
394	                    &registered_now(),
395	                    &perch::adapters_dir(),
396	                    &set,
397	                    &cfg,
398	                    StartReason::Cold,
399	                )
400	            };
401	            run();
402	            run();
403	            assert_eq!(set.len(), 1, "the same endpoint is hosted exactly once");
404	            set.stop_host("doyle");
405	        });
406	    }
407
408	    // [unit->REQ-DAEMON-1] (d) REHYDRATE: a FRESH LiveSet (a restarted brain) over a
409	    // perch left `status=online` by a prior brain re-hosts it — distinct from (a)
410	    // in that the table starts empty against an already-online perch (the
411	    // brain-restart path), not a freshly-flipped one.
412	    #[test]
413	    fn fresh_brain_rehydrates_an_online_live_endpoint() {
414	        with_home(|_| {
415	            let drops = perch::spt_home().join("drops");
416	            std::fs::create_dir_all(&drops).unwrap();
417	            register_live_adapter("mock", &drops);
418	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
419
420	            // First brain hosts it.
421	            let first = Arc::new(LiveSet::new());
422	            let cfg = fast_cfg();
423	            reconcile_once(
424	                &perch::owlery_dir(),
425	                &registered_now(),
426	                &perch::adapters_dir(),
427	                &first,
428	                &cfg,
429	                StartReason::Cold,
430	            );
431	            assert_eq!(first.len(), 1);
432	            // Brain "restart": drop the table (threads die with the process), the
433	            // perch stays online on disk.
434	            first.stop_host("doyle");
435	            drop(first);
436
437	            // Fresh brain rehydrates from the still-online perch.
438	            let second = Arc::new(LiveSet::new());
439	            reconcile_once(
440	                &perch::owlery_dir(),
441	                &registered_now(),
442	                &perch::adapters_dir(),
443	                &second,
444	                &cfg,
445	                StartReason::Crash,
446	            );
447	            assert_eq!(second.len(), 1, "a fresh brain re-hosts the online endpoint");
448	            set_then_teardown(&second);
449	        });
450	    }
451
452	    fn set_then_teardown(set: &Arc<LiveSet>) {
453	        set.stop_host("doyle");
454	    }
455
456	    // [unit->REQ-DAEMON-1] (e) EXIT GUARD / UN-HOST: a hosted endpoint that flips to
457	    // status=offline (graceful signoff) is un-hosted by the next reconcile — the
458	    // driver thread is stopped + de-tabled (no forever-pulsing dead agent).
459	    #[test]
460	    fn reconcile_unhosts_on_offline_transition() {
461	        with_home(|_| {
462	            let drops = perch::spt_home().join("drops");
463	            std::fs::create_dir_all(&drops).unwrap();
464	            register_live_adapter("mock", &drops);
465	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
466
467	            let set = Arc::new(LiveSet::new());
468	            // FAST pulse: the driver HAMMERS set_last_active every tick while the
469	            // test marks the perch offline — the un-host-UNDER-CONTENTION proof. The
470	            // W0.4 mutate_info sentinel lock makes status reliably stay offline, so
471	            // un-host reliably fires; the earlier long-pulse+settle workaround is
472	            // removed (the lock, not timing, is what makes this deterministic).
473	            let cfg = fast_cfg();
474	            let run = |reason| {
475	                reconcile_once(
476	                    &perch::owlery_dir(),
477	                    &registered_now(),
478	                    &perch::adapters_dir(),
479	                    &set,
480	                    &cfg,
481	                    reason,
482	                )
483	            };
484	            run(StartReason::Cold);
485	            assert_eq!(set.len(), 1, "hosted while online");
486
487	            // Graceful signoff flips the Self perch offline WHILE the driver pulses.
488	            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
489	            spt_store::info::set_status(&self_perch, spt_store::liveness::STATUS_OFFLINE).unwrap();
490
491	            run(StartReason::Cold);
492	            assert!(
493	                set.is_empty(),
494	                "an offline-transitioned endpoint is un-hosted (driver stopped + de-tabled)"
495	            );
496	        });
497	    }
498
499	    // [unit->REQ-DAEMON-1] (f) READ-ERROR discrimination (M11-W0.4 (ii)): a hosted
500	    // endpoint whose info.json becomes momentarily UNREADABLE while its perch DIR
501	    // remains is NOT un-hosted (transient → keep the driver, no Psyche stop/respawn
502	    // churn on a disk blip); only a GONE perch dir (torn down) un-hosts.
503	    #[test]
504	    fn reconcile_keeps_driver_on_transient_read_fail_unhosts_on_dir_gone() {
505	        with_home(|_| {
506	            let drops = perch::spt_home().join("drops");
507	            std::fs::create_dir_all(&drops).unwrap();
508	            register_live_adapter("mock", &drops);
509	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
510
511	            let set = Arc::new(LiveSet::new());
512	            let cfg = fast_cfg();
513	            let run = || {
514	                reconcile_once(
515	                    &perch::owlery_dir(),
516	                    &registered_now(),
517	                    &perch::adapters_dir(),
518	                    &set,
519	                    &cfg,
520	                    StartReason::Cold,
521	                )
522	            };
523	            run();
524	            assert_eq!(set.len(), 1, "hosted while online");
525
526	            // TRANSIENT read failure: info.json gone, but the perch DIR remains.
527	            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
528	            std::fs::remove_file(self_perch.join("info.json")).unwrap();
529	            run();
530	            assert_eq!(
531	                set.len(),
532	                1,
533	                "a transient read failure (dir present) must NOT un-host — driver kept"
534	            );
535
536	            // DEFINITIVE: the perch dir is gone (torn down) → un-host. The fast-pulse
537	            // driver holds perch files open between pulses; on Windows that
538	            // transiently blocks dir removal (os err 32) — retry past the µs window
539	            // (production teardown stops the host first, so this race is test-only).
540	            let mut removed = false;
541	            for _ in 0..100 {
542	                if std::fs::remove_dir_all(&self_perch).is_ok() {
543	                    removed = true;
544	                    break;
545	                }
546	                std::thread::sleep(std::time::Duration::from_millis(20));
547	            }
548	            assert!(removed, "could not remove the perch dir for the torn-down case");
549	            run();
550	            assert!(set.is_empty(), "a gone perch dir un-hosts the driver");
551	        });
552	    }
553	}
554

//! The brain's live-agent Psyche host (M11-W0.1, REQ-DAEMON-1) — re-host the
//! daemon-hosted LiveAgent lifecycle from disk and reconcile the host-set.
//!
//! This is the production caller `run_pulse_loop` lacked: the brain instantiates
//! a [`BrainLifecycle`] per hosted **live** endpoint and drives its config-paced
//! pulse loop on a supervised thread. It is the **harness-endpoint mirror** of
//! [`crate::shellwake::reconcile_once`] (which re-hosts offline shells from
//! disk): same shape — enumerate Self perches, resolve each manifest from its
//! `info.json` `adapter` option, hold a stop flag per supervised thread, and run
//! one bounded reconcile sweep at boot and every tick. Where the shell waker
//! hosts **offline** shells, this hosts **online** live endpoints — `status` is
//! daemon-authoritative liveness (CONTEXT:194 / KH 2.5), so re-hosting the
//! online ones re-hosts exactly what was live before a brain (re)start, and
//! never resurrects a gracefully-departed (offline) agent.
//!
//! **Online-only is staged behind the interim.** The interim `spt api listen`
//! process still first-hosts its live agents today; those perches are
//! `status`-ABSENT (not "online"), so this scan ignores them — no double-host in
//! the W0.1→W0.2 window. W0.2 retires the interim and makes the brain the
//! first-host (it sets `status=online` on a live `listen`/`bind`); from then this
//! reconcile reacts within one tick, the same way the shell waker reacts to a
//! CLI-process status flip. The full bind→online→brain-restart→re-host arc is
//! proven through the real brain process by the W0.4 production int.
// [impl->REQ-DAEMON-1]

use std::collections::HashMap;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;

use spt_runtime::registry::AdapterRecord;
use spt_runtime::Manifest;
use spt_store::liveness::STATUS_ONLINE;
use spt_store::perch::{self, ParentHint};

use crate::brainproc::StartReason;
use crate::config::DaemonConfig;
use crate::lifecycle::BrainLifecycle;

/// The `InfoJson::state` tag of a live agent — only these get a daemon-hosted
/// Psyche; a `ready_agent` (messaging-only) never does.
const LIVE_AGENT_STATE: &str = "live_agent";

/// The reconcile cadence (ms): a lifecycle flip lands in a CLI process, so the
/// daemon loop is the invariant holder — a flip is at most one tick stale
/// (mirrors [`crate::shellwake::RECONCILE_INTERVAL_MS`]).
pub const LIVE_RECONCILE_INTERVAL_MS: u64 = 5_000;

/// One brain-hosted live lifecycle: its config-paced pulse-driver thread plus the
/// held stop flag (tripped to un-host on an offline-transition).
struct HostedLife {
    stop: Arc<AtomicBool>,
    thread: JoinHandle<()>,
}

/// The brain's hosted live-endpoint table, keyed by endpoint id. Shared between
/// the reconcile loop and anything that must stop a host. Mirror of
/// [`crate::shellwake::WakeSet`].
#[derive(Default)]
pub struct LiveSet {
    inner: Mutex<HashMap<String, HostedLife>>,
}

impl LiveSet {
    pub fn new() -> Self {
        Self::default()
    }

    /// How many lifecycles are hosted (finished driver threads pruned first).
    pub fn len(&self) -> usize {
        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        map.retain(|_, h| !h.thread.is_finished());
        map.len()
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Is `id` currently hosted (driver thread alive)? Prunes a finished entry so
    /// a crashed driver is re-hosted next sweep.
    fn contains(&self, id: &str) -> bool {
        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        match map.get(id) {
            Some(h) if !h.thread.is_finished() => true,
            Some(_) => {
                map.remove(id);
                false
            }
            None => false,
        }
    }

    fn insert(&self, id: &str, handle: HostedLife) {
        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        map.insert(id.to_string(), handle);
    }

    /// The currently-tabled ids (finished pruned) — the stop-side reconcile input.
    fn ids(&self) -> Vec<String> {
        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        map.retain(|_, h| !h.thread.is_finished());
        map.keys().cloned().collect()
    }

    /// Un-host one lifecycle: trip its stop flag, join the driver, de-table. The
    /// pulse loop checks the flag each sleep-slice, so the join is prompt. We do
    /// NOT re-stamp `offline` — the perch already left the online set (graceful
    /// signoff cleared it, or the perch is gone); a brain *crash* deliberately
    /// leaves `status=online` so the next brain rehydrates it (CONTEXT:194).
    fn stop_host(&self, id: &str) {
        let handle = {
            let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
            map.remove(id)
        };
        if let Some(h) = handle {
            h.stop.store(true, Ordering::SeqCst);
            let _ = h.thread.join();
        }
    }
}

/// One reconcile sweep — the brain's live host-set invariant holder, run at boot
/// and every [`LIVE_RECONCILE_INTERVAL_MS`]. The complete set-diff against the
/// on-disk **online** live endpoints (mirror of [`crate::shellwake::reconcile_once`]):
///
/// - **un-host** (tabled ∧ ¬online) — stop the driver + de-table (exit guard: a
///   signed-off / removed endpoint must not keep pulsing — thread + dead-agent leak).
/// - **host** (online ∧ ¬tabled) — spawn the Psyche + start the driver.
/// - **leave-alone** (online ∧ tabled).
///
/// `status` is daemon-authoritative (CONTEXT:194); interim `listen` perches are
/// `status`-ABSENT so they are never hosted here (no double-host) until W0.2.
// [impl->REQ-DAEMON-1]
pub fn reconcile_once(
    owlery: &Path,
    registered: &[(AdapterRecord, Manifest)],
    adapters_dir: &Path,
    set: &Arc<LiveSet>,
    cfg: &DaemonConfig,
    reason: StartReason,
) {
    // Stop side first: hosted endpoints that DEFINITIVELY left the online set —
    // trip the flag, join, de-table. Un-host on a definitive departure ONLY, never
    // on a TRANSIENT read failure (M11-W0.4 (ii)): a momentarily-unreadable
    // info.json must not stop+respawn the driver (Psyche churn on a disk blip). The
    // discriminator is the perch DIR:
    //   - dir gone        → torn down (teardown removed the perch) → un-host;
    //   - dir present, read_info Some(status≠online) → signed-off/offline → un-host;
    //   - dir present, read_info None (transient/torn read) → LEAVE the driver, retry.
    // [impl->REQ-DAEMON-1]
    for id in set.ids() {
        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
        let unhost = if !perch.exists() {
            true // the perch dir is gone — torn down
        } else {
            match spt_store::info::read_info(&perch) {
                Some(info) => info.status.as_deref() != Some(STATUS_ONLINE),
                None => false, // dir present but unreadable: transient → keep hosting
            }
        };
        if unhost {
            set.stop_host(&id);
        }
    }

    // Start side: every online live endpoint not yet hosted.
    for id in perch::list_self_perch_ids(owlery) {
        if set.contains(&id) {
            continue; // already hosted (leave-alone)
        }
        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
        let Some(info) = spt_store::info::read_info(&perch) else {
            continue;
        };
        if info.state != LIVE_AGENT_STATE {
            continue; // not a live agent (ready_agent etc.)
        }
        if info.status.as_deref() != Some(STATUS_ONLINE) {
            continue; // interim (status-absent) or signed-off (offline)
        }
        let Some(adapter) = info.adapter.as_deref() else {
            continue; // adapterless endpoint — never live-capable
        };
        let Ok(manifest) = spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter)
        else {
            continue; // deregistered adapter: nothing to host
        };
        let Some(lifecycle) = BrainLifecycle::with_config(&manifest, &id, cfg.clone()) else {
            continue; // manifest declares no [session.psyche_init]
        };
        host_one(set, &id, lifecycle, info.session_id, reason);
    }
}

/// Host one live endpoint: sweep a stale signoff (3.2), spawn the Psyche (the
/// daemon online-stamp makes liveness daemon-authoritative), then start the
/// config-paced pulse driver on its own supervised thread with a held stop flag.
/// A Psyche-spawn failure tables nothing — the next sweep retries.
// [impl->REQ-DAEMON-1]
fn host_one(
    set: &Arc<LiveSet>,
    id: &str,
    lifecycle: BrainLifecycle,
    session_id: String,
    reason: StartReason,
) {
    lifecycle.sweep_on_start();
    match lifecycle.spawn_psyche(Some(&session_id), None) {
        Ok(h) => eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid),
        Err(e) => {
            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
            return; // no Psyche → no driver; retried next reconcile
        }
    }
    let stop = Arc::new(AtomicBool::new(false));
    let handle = {
        let stop = Arc::clone(&stop);
        std::thread::spawn(move || {
            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
        })
    };
    set.insert(id, HostedLife { stop, thread: handle });
}

/// Spawn the brain's live host: one thread sweeping [`reconcile_once`] at boot
/// and every [`LIVE_RECONCILE_INTERVAL_MS`] until `stop`. The registered set +
/// config are re-read each sweep (adapter / pulse-period changes land between
/// ticks). Mirror of [`crate::shellwake::spawn_wake_host`]; the stop flag is held
/// for symmetry — the threads die with the brain process and the next brain
/// rehydrates from disk.
// [impl->REQ-DAEMON-1]
pub fn spawn_live_host(stop: Arc<AtomicBool>, reason: StartReason) -> JoinHandle<()> {
    std::thread::spawn(move || {
        let set = Arc::new(LiveSet::new());
        while !stop.load(Ordering::SeqCst) {
            let owlery = perch::owlery_dir();
            let adapters_dir = perch::adapters_dir();
            let registered = spt_runtime::registry::registered(&adapters_dir);
            let cfg = DaemonConfig::load();
            reconcile_once(&owlery, &registered, &adapters_dir, &set, &cfg, reason);
            // Sleep in slices so a stop lands promptly.
            let mut left = LIVE_RECONCILE_INTERVAL_MS;
            while left > 0 && !stop.load(Ordering::SeqCst) {
                let step = left.min(100);
                std::thread::sleep(Duration::from_millis(step));
                left -= step;
            }
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use std::time::{Duration, Instant};

    // A no-op Psyche summarizer command: spawns + exits 0 (we never depend on its
    // output — the daemon online-stamp is what hosts it, not the pid).
    #[cfg(windows)]
    const NOOP_CMD: &str = "cmd /C rem";
    #[cfg(unix)]
    const NOOP_CMD: &str = "true";

    /// Register a live-capable adapter `name` (declares `[session.psyche_init]` +
    /// a watched commune/signoff dir) so `resolve_option_in` finds it from disk.
    fn register_live_adapter(name: &str, drops: &Path) {
        let dir = drops.to_string_lossy().replace('\\', "/");
        let toml = format!(
            "[adapter]\nname=\"{name}\"\nkind=\"harness\"\nversion=\"1\"\n\
             min_spt_core_version=\"0\"\n\n\
             [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
        );
        let src = perch::spt_home().join("srcs").join(name);
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(src.join("manifest.toml"), toml).unwrap();
        spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
    }

    /// Seed a Self **live** perch on disk: `state=live_agent`, the given status,
    /// and the adapter option that resolves the manifest.
    fn seed_live_perch(id: &str, adapter: &str, status: &str) {
        let perch = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&perch).unwrap();
        let mut rec = spt_store::info::InfoJson::new(id, "t", std::process::id(), "sid-1", "live_agent");
        rec.adapter = Some(adapter.to_string());
        spt_store::info::write_info(&perch, &rec).unwrap();
        spt_store::info::set_status(&perch, status).unwrap();
    }

    /// A fast-pulse config so the driver loop ticks quickly in tests.
    fn fast_cfg() -> DaemonConfig {
        DaemonConfig {
            pulse_period: Duration::from_millis(15),
            ..DaemonConfig::default()
        }
    }

    fn registered_now() -> Vec<(AdapterRecord, Manifest)> {
        spt_runtime::registry::registered(&perch::adapters_dir())
    }

    /// Poll a predicate up to `bound`, sleeping 20ms between checks.
    fn wait_until(bound: Duration, mut pred: impl FnMut() -> bool) -> bool {
        let deadline = Instant::now() + bound;
        while Instant::now() < deadline {
            if pred() {
                return true;
            }
            std::thread::sleep(Duration::from_millis(20));
        }
        pred()
    }

    // [unit->REQ-DAEMON-1] (a) FIRST-HOST scan: an online live endpoint on disk is
    // hosted by the reconcile — the Psyche is spawned AND the config-paced pulse
    // driver actually runs (it stamps last_active_ms on the Self perch each tick).
    #[test]
    fn reconcile_hosts_an_online_live_endpoint_and_drives_its_pulse() {
        with_home(|_| {
            let drops = perch::spt_home().join("drops");
            std::fs::create_dir_all(&drops).unwrap();
            register_live_adapter("mock", &drops);
            seed_live_perch("doyle", "mock", STATUS_ONLINE);

            let set = Arc::new(LiveSet::new());
            let cfg = fast_cfg();
            reconcile_once(
                &perch::owlery_dir(),
                &registered_now(),
                &perch::adapters_dir(),
                &set,
                &cfg,
                StartReason::Cold,
            );
            assert_eq!(set.len(), 1, "the online live endpoint is hosted");

            // The driver pulse runs: an Active perch stamps last_active_ms each tick.
            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
            let ran = wait_until(Duration::from_secs(3), || {
                spt_store::info::read_info(&self_perch)
                    .and_then(|i| i.last_active_ms)
                    .is_some()
            });
            assert!(ran, "the pulse driver loop ticks (stamps last_active_ms)");

            // Teardown so the test process doesn't leak the driver thread.
            set.stop_host("doyle");
        });
    }

    // [unit->REQ-DAEMON-1] (b) ENTRY GUARD: a never-hosted OFFLINE live endpoint is
    // skipped — online-only. (Also covers the interim status-absent case shape.)
    #[test]
    fn reconcile_skips_an_offline_live_endpoint() {
        with_home(|_| {
            let drops = perch::spt_home().join("drops");
            std::fs::create_dir_all(&drops).unwrap();
            register_live_adapter("mock", &drops);
            seed_live_perch("doyle", "mock", spt_store::liveness::STATUS_OFFLINE);

            let set = Arc::new(LiveSet::new());
            reconcile_once(
                &perch::owlery_dir(),
                &registered_now(),
                &perch::adapters_dir(),
                &set,
                &fast_cfg(),
                StartReason::Cold,
            );
            assert!(set.is_empty(), "an offline live endpoint is never hosted");
        });
    }

    // [unit->REQ-DAEMON-1] (c) NO DOUBLE-HOST: a second reconcile over an
    // already-tabled endpoint leaves exactly one host (the contains-guard).
    #[test]
    fn reconcile_does_not_double_host() {
        with_home(|_| {
            let drops = perch::spt_home().join("drops");
            std::fs::create_dir_all(&drops).unwrap();
            register_live_adapter("mock", &drops);
            seed_live_perch("doyle", "mock", STATUS_ONLINE);

            let set = Arc::new(LiveSet::new());
            let cfg = fast_cfg();
            let run = || {
                reconcile_once(
                    &perch::owlery_dir(),
                    &registered_now(),
                    &perch::adapters_dir(),
                    &set,
                    &cfg,
                    StartReason::Cold,
                )
            };
            run();
            run();
            assert_eq!(set.len(), 1, "the same endpoint is hosted exactly once");
            set.stop_host("doyle");
        });
    }

    // [unit->REQ-DAEMON-1] (d) REHYDRATE: a FRESH LiveSet (a restarted brain) over a
    // perch left `status=online` by a prior brain re-hosts it — distinct from (a)
    // in that the table starts empty against an already-online perch (the
    // brain-restart path), not a freshly-flipped one.
    #[test]
    fn fresh_brain_rehydrates_an_online_live_endpoint() {
        with_home(|_| {
            let drops = perch::spt_home().join("drops");
            std::fs::create_dir_all(&drops).unwrap();
            register_live_adapter("mock", &drops);
            seed_live_perch("doyle", "mock", STATUS_ONLINE);

            // First brain hosts it.
            let first = Arc::new(LiveSet::new());
            let cfg = fast_cfg();
            reconcile_once(
                &perch::owlery_dir(),
                &registered_now(),
                &perch::adapters_dir(),
                &first,
                &cfg,
                StartReason::Cold,
            );
            assert_eq!(first.len(), 1);
            // Brain "restart": drop the table (threads die with the process), the
            // perch stays online on disk.
            first.stop_host("doyle");
            drop(first);

            // Fresh brain rehydrates from the still-online perch.
            let second = Arc::new(LiveSet::new());
            reconcile_once(
                &perch::owlery_dir(),
                &registered_now(),
                &perch::adapters_dir(),
                &second,
                &cfg,
                StartReason::Crash,
            );
            assert_eq!(second.len(), 1, "a fresh brain re-hosts the online endpoint");
            set_then_teardown(&second);
        });
    }

    fn set_then_teardown(set: &Arc<LiveSet>) {
        set.stop_host("doyle");
    }

    // [unit->REQ-DAEMON-1] (e) EXIT GUARD / UN-HOST: a hosted endpoint that flips to
    // status=offline (graceful signoff) is un-hosted by the next reconcile — the
    // driver thread is stopped + de-tabled (no forever-pulsing dead agent).
    #[test]
    fn reconcile_unhosts_on_offline_transition() {
        with_home(|_| {
            let drops = perch::spt_home().join("drops");
            std::fs::create_dir_all(&drops).unwrap();
            register_live_adapter("mock", &drops);
            seed_live_perch("doyle", "mock", STATUS_ONLINE);

            let set = Arc::new(LiveSet::new());
            // FAST pulse: the driver HAMMERS set_last_active every tick while the
            // test marks the perch offline — the un-host-UNDER-CONTENTION proof. The
            // W0.4 mutate_info sentinel lock makes status reliably stay offline, so
            // un-host reliably fires; the earlier long-pulse+settle workaround is
            // removed (the lock, not timing, is what makes this deterministic).
            let cfg = fast_cfg();
            let run = |reason| {
                reconcile_once(
                    &perch::owlery_dir(),
                    &registered_now(),
                    &perch::adapters_dir(),
                    &set,
                    &cfg,
                    reason,
                )
            };
            run(StartReason::Cold);
            assert_eq!(set.len(), 1, "hosted while online");

            // Graceful signoff flips the Self perch offline WHILE the driver pulses.
            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
            spt_store::info::set_status(&self_perch, spt_store::liveness::STATUS_OFFLINE).unwrap();

            run(StartReason::Cold);
            assert!(
                set.is_empty(),
                "an offline-transitioned endpoint is un-hosted (driver stopped + de-tabled)"
            );
        });
    }

    // [unit->REQ-DAEMON-1] (f) READ-ERROR discrimination (M11-W0.4 (ii)): a hosted
    // endpoint whose info.json becomes momentarily UNREADABLE while its perch DIR
    // remains is NOT un-hosted (transient → keep the driver, no Psyche stop/respawn
    // churn on a disk blip); only a GONE perch dir (torn down) un-hosts.
    #[test]
    fn reconcile_keeps_driver_on_transient_read_fail_unhosts_on_dir_gone() {
        with_home(|_| {
            let drops = perch::spt_home().join("drops");
            std::fs::create_dir_all(&drops).unwrap();
            register_live_adapter("mock", &drops);
            seed_live_perch("doyle", "mock", STATUS_ONLINE);

            let set = Arc::new(LiveSet::new());
            let cfg = fast_cfg();
            let run = || {
                reconcile_once(
                    &perch::owlery_dir(),
                    &registered_now(),
                    &perch::adapters_dir(),
                    &set,
                    &cfg,
                    StartReason::Cold,
                )
            };
            run();
            assert_eq!(set.len(), 1, "hosted while online");

            // TRANSIENT read failure: info.json gone, but the perch DIR remains.
            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
            std::fs::remove_file(self_perch.join("info.json")).unwrap();
            run();
            assert_eq!(
                set.len(),
                1,
                "a transient read failure (dir present) must NOT un-host — driver kept"
            );

            // DEFINITIVE: the perch dir is gone (torn down) → un-host. The fast-pulse
            // driver holds perch files open between pulses; on Windows that
            // transiently blocks dir removal (os err 32) — retry past the µs window
            // (production teardown stops the host first, so this race is test-only).
            let mut removed = false;
            for _ in 0..100 {
                if std::fs::remove_dir_all(&self_perch).is_ok() {
                    removed = true;
                    break;
                }
                std::thread::sleep(std::time::Duration::from_millis(20));
            }
            assert!(removed, "could not remove the perch dir for the torn-down case");
            run();
            assert!(set.is_empty(), "a gone perch dir un-hosts the driver");
        });
    }
}


100	            "crash" => StartReason::Crash,
101	            _ => StartReason::Cold,
102	        }
103	    }
104	}
105
106	/// The broker→supervisor **planned-restart signal** (ADR-0018 D3-3, Q3). The
107	/// broker's `KIND_BRAIN_RESTART` handler raises it; the supervisor consumes it on
108	/// its next wait tick, kills the current brain child, and respawns it with
109	/// `start-reason=update` onto the (freshly-swapped) binary. Shared (`Arc`)
110	/// between the broker serve path and the supervisor — both run in the broker
111	/// process. This is the seam that makes a routine `apply` actually run new code,
112	/// the pillar the in-process-collapse regression silently unrealized.
113	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
114	#[derive(Default)]
115	pub struct BrainRestart {
116	    requested: AtomicBool,
117	}
118
119	impl BrainRestart {
120	    /// A fresh signal with no pending request.
121	    pub fn new() -> Self {
122	        Self::default()
123	    }
124
125	    /// Request a planned restart (broker handler side) — idempotent: coalesces
126	    /// with any already-pending request into one cycle.
127	    pub fn request(&self) {
128	        self.requested.store(true, Ordering::SeqCst);
129	    }
130
131	    /// Consume a pending request (supervisor side): true at most once per
132	    /// `request`, clearing it so the next cycle starts fresh.
133	    pub fn take(&self) -> bool {
134	        self.requested.swap(false, Ordering::SeqCst)
135	    }
136	}
137
138	/// The `spt daemon brain` child entry. Connect to the broker over the existing
139	/// socket IPC, signal readiness, host the brain-side logic loops, and idle —
140	/// holding the connection and probing broker liveness each heartbeat. A dropped
141	/// connection (broker gone, or a future planned snapshot+self-exit, ADR-0018 Q3)
142	/// returns `Err`, exiting the process so the broker-side supervisor respawns it
143	/// from the (possibly swapped) binary.
144	///
145	/// **D2-1 — net consumers run here.** The inbound dispatcher + outbound peer
146	/// pump are pure IPC clients (each `Brain::cold_start`s its own connection; no
147	/// shared `Arc<Broker>`), so they belong with the restartable brain, not the
148	/// broker — a brain respawn re-spawns them from the swapped binary. They are
149	/// meaningful only once the broker-owned NetHost is up, so we gate on the
150	/// existing `net-status` verb (which also serves as the broker-liveness probe)
151	/// and start them exactly once, on the first `enabled` reply. That single gate
152	/// also covers the boot-race self-heal (REQ-DAEMON-9): the broker retries the
153	/// NetHost bind in the background and `net-status` flips `enabled` when it
154	/// attaches, at which point the brain starts the consumers — no extra signal.
155	///
156	/// It inherits the broker's already-unelevated token as a plain child, so it
157	/// runs no de-elevation guard of its own.
158	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
159	pub fn run_brain(generation: u64, reason: StartReason) -> io::Result<()> {
160	    let name = broker_socket_name();
161	    let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;
162	    // The broker hands {generation, start-reason} at spawn (ADR-0018 V2): the
163	    // generation is broker-owned custody of the KH-2.4 counter (no longer the
164	    // brain→brain BrainState.generation, which retires in D4), and the reason is
165	    // Q4's update-vs-crash discriminator that D5 will use to decide whether to
166	    // preserve or reset phase-significant loop timing. D3-2 surfaces them; D5
167	    // consumes the reason. `gen_start` stays fresh-now per KH-2.4 — only the
168	    // *counter* is rehydrated, never the wall-clock stamp.
169	    eprintln!(
170	        "BRAIN_UP: connected to broker {name} (pid {}, generation {generation}, start-reason {})",
171	        std::process::id(),
172	        reason.as_arg(),
173	    );
174
175	    // Multi-session cold-start resume (restoration D4-2, ADR-0018 Q6): rebuild
176	    // continuity from the broker — re-attach EVERY hosted session in resume mode
177	    // from its delivered cursor, the production replacement for the retired
178	    // brain→brain `BrainState` handoff. Today the supervised daemon brain hosts no
179	    // PTY sessions (net-consumers + shellwake re-derive from disk / `net-status`),
180	    // so this is a no-op now and forward-correct when daemon-hosted sessions land
181	    // (the live-agent adapter). A failure here is non-fatal — the brain still
182	    // serves; the next heartbeat probes broker liveness. NOTE: this is the PTY
183	    // message-DELIVERY axis; the orthogonal live-agent **Psyche** lifecycle (the
184	    // run_pulse_loop production caller) is hosted by `livehost::spawn_live_host`

            "crash" => StartReason::Crash,
            _ => StartReason::Cold,
        }
    }
}

/// The broker→supervisor **planned-restart signal** (ADR-0018 D3-3, Q3). The
/// broker's `KIND_BRAIN_RESTART` handler raises it; the supervisor consumes it on
/// its next wait tick, kills the current brain child, and respawns it with
/// `start-reason=update` onto the (freshly-swapped) binary. Shared (`Arc`)
/// between the broker serve path and the supervisor — both run in the broker
/// process. This is the seam that makes a routine `apply` actually run new code,
/// the pillar the in-process-collapse regression silently unrealized.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
#[derive(Default)]
pub struct BrainRestart {
    requested: AtomicBool,
}

impl BrainRestart {
    /// A fresh signal with no pending request.
    pub fn new() -> Self {
        Self::default()
    }

    /// Request a planned restart (broker handler side) — idempotent: coalesces
    /// with any already-pending request into one cycle.
    pub fn request(&self) {
        self.requested.store(true, Ordering::SeqCst);
    }

    /// Consume a pending request (supervisor side): true at most once per
    /// `request`, clearing it so the next cycle starts fresh.
    pub fn take(&self) -> bool {
        self.requested.swap(false, Ordering::SeqCst)
    }
}

/// The `spt daemon brain` child entry. Connect to the broker over the existing
/// socket IPC, signal readiness, host the brain-side logic loops, and idle —
/// holding the connection and probing broker liveness each heartbeat. A dropped
/// connection (broker gone, or a future planned snapshot+self-exit, ADR-0018 Q3)
/// returns `Err`, exiting the process so the broker-side supervisor respawns it
/// from the (possibly swapped) binary.
///
/// **D2-1 — net consumers run here.** The inbound dispatcher + outbound peer
/// pump are pure IPC clients (each `Brain::cold_start`s its own connection; no
/// shared `Arc<Broker>`), so they belong with the restartable brain, not the
/// broker — a brain respawn re-spawns them from the swapped binary. They are
/// meaningful only once the broker-owned NetHost is up, so we gate on the
/// existing `net-status` verb (which also serves as the broker-liveness probe)
/// and start them exactly once, on the first `enabled` reply. That single gate
/// also covers the boot-race self-heal (REQ-DAEMON-9): the broker retries the
/// NetHost bind in the background and `net-status` flips `enabled` when it
/// attaches, at which point the brain starts the consumers — no extra signal.
///
/// It inherits the broker's already-unelevated token as a plain child, so it
/// runs no de-elevation guard of its own.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn run_brain(generation: u64, reason: StartReason) -> io::Result<()> {
    let name = broker_socket_name();
    let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;
    // The broker hands {generation, start-reason} at spawn (ADR-0018 V2): the
    // generation is broker-owned custody of the KH-2.4 counter (no longer the
    // brain→brain BrainState.generation, which retires in D4), and the reason is
    // Q4's update-vs-crash discriminator that D5 will use to decide whether to
    // preserve or reset phase-significant loop timing. D3-2 surfaces them; D5
    // consumes the reason. `gen_start` stays fresh-now per KH-2.4 — only the
    // *counter* is rehydrated, never the wall-clock stamp.
    eprintln!(
        "BRAIN_UP: connected to broker {name} (pid {}, generation {generation}, start-reason {})",
        std::process::id(),
        reason.as_arg(),
    );

    // Multi-session cold-start resume (restoration D4-2, ADR-0018 Q6): rebuild
    // continuity from the broker — re-attach EVERY hosted session in resume mode
    // from its delivered cursor, the production replacement for the retired
    // brain→brain `BrainState` handoff. Today the supervised daemon brain hosts no
    // PTY sessions (net-consumers + shellwake re-derive from disk / `net-status`),
    // so this is a no-op now and forward-correct when daemon-hosted sessions land
    // (the live-agent adapter). A failure here is non-fatal — the brain still
    // serves; the next heartbeat probes broker liveness. NOTE: this is the PTY
    // message-DELIVERY axis; the orthogonal live-agent **Psyche** lifecycle (the
    // run_pulse_loop production caller) is hosted by `livehost::spawn_live_host`


Architecture clear. Key: `spawn_live_host` (livehost.rs:230) IS unconditional + pre-heartbeat, BUT `run_brain` only reaches it if `connect_retry` to the **broker** socket succeeds (brainproc.rs:161). On a net-less/unpaired node the broker boot-race (NetHost bind starving the IPC-serve) likely exhausts brain connect → brain never reaches :230 → no Psyche. Same root as "peer pump STALLED".

Grounding the broker boot ordering + REQ registry so todlando's brief is precise.


101:title = "post-spawn / api bind seam with boot nonce"
139:title = "The api subcommand surface (bind/listen/poll/state/worker/boundary/...)"
157:title = "spt-hosted startup: spawn-session then api bind (no file)"
187:title = "Gateway type acceptance: a Gateway-typed perch binds (api bind --type, open type system — un-hardcode the live_agent default), advertises/addressable like any endpoint, owns shells (owner validation not agent-family-gated), subscribes to digests, and is the user-msg identity gate's user-backed origin (REQ-MSG-5); in-tree mock-gateway fixture (R-DOCS-2 pattern, no downstream adapter code). Cross-node WAN Gateway-origin (registry endpoint_type trust) tracked by REQ-MSG-6"
188:required_stages = ["doc", "impl", "unit"]  # activated 2026-06-13: M9-T5 (CONTEXT.md §Gateway; api bind --type un-hardcode at establish_perch; gateway-owns-shell + local user-backed-origin E2E via mock-gateway fixture; the WAN fail-closed row tested at the receive_wan funnel)
210:[Omitted long matching line]
255:[Omitted long matching line]
287:[Omitted long matching line]
306:[Omitted long matching line]
318:[Omitted long matching line]
330:required_stages = ["impl", "unit"]  # activated M4-D2f: SubnetStore::create_subnet names the subnet at creation (sole seed-holder, epoch 1; "naming moves to link-start" ADR-0006); the name rides every ceremony (joiner types it, transcript binds it) and reaches the joiner via D2f seed transfer. unit = wire create_new_then_join_learns_named_subnet. CLI naming prompt is later (daemon wiring, D4+) — delivered by M7 REQ-SUBNET-2 (guided `spt subnet join`); int = two-host at D9.
389:required_stages = ["impl", "unit"]  # activated M3c-C1: release.rs Ed25519 verify-before-handoff (signature + key trust + SHA-256 artifact binding); update::plan_verified is the front door that produces a plan ONLY for a verified release, so an unverified binary never reaches apply_brain_only
445:title = "Thread-spanning digest across session boundaries: a per-endpoint session ledger (`<perch>/sessions.log`) appended at first bind and by `api boundary` on `/clear`|`/compact` session rotation, the digest enumerating the last K sessions so its rolling window bridges a boundary, and a distinctive in-timeline boundary marker (DigestEntry::Boundary). The digest follows the live-agent thread, not a single session."
463:[Omitted long matching line]
467:[Omitted long matching line]
468:required_stages = ["impl", "unit", "int"]  # activated M12-W1: impl = rc::run_attach pump (single-Brain + stdin-thread + seq-dedup + ctrl-b detach) + cli Cmd::Rc; unit = detach-keybind semantics + prefix-spans-chunks + op-minter; int (CROSS-NODE face) = spt_hosted_bringup_then_cross_node_attach_drives_the_pty + loopback_self_dial_is_refused (pins the local-transport choice). LOCAL face int (loopback-conn B1) lands W1.5 with the "both transports → one pump" assertion (doyle 2026-06-14)
480:[Omitted long matching line]
484:[Omitted long matching line]
618:required_stages = []  # rule 5: superseded in spirit by daemon-authoritative liveness (REQ-HAZARD-DAEMON-HOSTED-LIVENESS, int-covered M3b); binds if a per-pid path ever returns (none in the daemon model)
622:required_stages = []  # rule 5: binds at the adapter api surface for harnesses that pass session ids on stdin — no such path exists in spt-core; re-assessed M5-D3e: the whole shell api surface passes identity as argv (--link token, positional shell-id), no stdin id seam grew through D3
631:# activation on REQ-HAZARD-BROKER-PROCESS-ISOLATION, doyle D7 vet call 4);
780:id = "REQ-HAZARD-BROKER-PROCESS-ISOLATION"
789:id = "REQ-HAZARD-BRAIN-RESPAWN-PATH"
800:[Omitted long matching line]
804:title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
835:title = "Shell hosting machinery: shell perch under the owner (type/owner/adapter_name/status/alias), broker-launched binary + api bind local-link handshake, the three channels (command durable, text+file durable + progress-queryable, sensory REST-only never spooled + dropped-unless-owner-live), owner exclusivity (CONTEXT Shell model)"
845:title = "The daemon always runs unelevated in the invoking user's universe, regardless of which command spawns it: an elevated spawner de-elevates (Windows: UAC linked token via CreateProcessWithTokenW; Linux: drop to SUDO_UID/SUDO_GID + the invoker's HOME) — an elevated daemon's pipes deny unelevated clients (every later spt reads not-running→spawn→bind Access-denied) and a sudo'd daemon roots the user's state universe (5.7)"
872:[Omitted long matching line]
923:required_stages = ["impl", "unit"]  # impl = cmd_daemon_run guards: Windows elevated→refuse (exit 2) before Daemon::run() (the hazard-tagged in-process respawn stays as untouched defense-in-depth, now unreached via the CLI); a conflict warning when a managed service is already active before binding inline. Daemon::run() foreground loop itself unchanged. unit = the pure run-mode decision (elevated×windows→refuse vs foreground) + `daemon run`/`daemon start` no longer share an alias.
927:required_stages = ["impl", "unit"]  # impl = ensure_running consults service::platform_service(): detected→service.start() else spawn_detached(), then the existing bounded bind-wait. unit = the plan_start router already proves the detected→ServiceStart vs ManualSpawn branch (shared with REQ-DAEMON-6); ensure_running's no-op-when-already-running short-circuit preserved.
929:id = "REQ-DAEMON-9"
930:[Omitted long matching line]
931:[Omitted long matching line]
941:[Omitted long matching line]
976:[Omitted long matching line]
984:[Omitted long matching line]


Found 1 file
crates\spt-daemon\src\broker.rs


778	# --- Added 2026-06-09 from grill-with-docs (ADR-0018: broker/brain in-process-collapse regression); rule 3: register before satisfying, rule 5: inactive until the broker/brain split restoration milestone (next milestone) ---
779	[[requirements]]
780	id = "REQ-HAZARD-BROKER-PROCESS-ISOLATION"
781	title = "Broker and brain are separate processes: the broker runs as its own long-lived per-machine process that survives every brain restart, so a routine (brain-only) self-update restarts the brain onto the swapped binary while every hosted endpoint (PTY child, live QUIC conn, listening socket) stays untouched at the PROCESS level. The in-process-thread broker (daemon.rs:165-170) is a regression that silently unrealizes REQ-UPD-3 — apply degrades to an in-process Brain::handoff no-op and new code does not run until an unrelated restart (KNOWN-HAZARDS 6.7). Evidence must prove process-level survival (SPIKE-01/03 productionized as int: PTY child + live QUIC survive a brain-PROCESS restart onto a swapped binary), re-pointing the regression-masked in-process int tags currently on REQ-DAEMON-2 / REQ-UPD-3 (ADR-0018)."
782	required_stages = ["doc", "impl", "unit", "int"]  # activated restoration D1 (ADR-0018 Q2/Q3): doc = KNOWN-HAZARDS 6.7 + ADR-0018 + design doc; impl = brainproc.rs (brain child entry + broker-side process supervisor) + daemon.rs spawn-brain-supervisor + the hidden `daemon brain` CLI entry; unit = brainproc supervisor respawn/backoff table + the real-binary brain_split.rs process-level smoke (broker survives a brain kill + respawns it). int ACTIVATED restoration D7 = the productionized SPIKE-01/03 process-level survival E2E (crates/spt/tests/brain_survive.rs: a PTY child + a live QUIC conn + held-AND-functional output survive a brain-PROCESS restart onto a swapped binary, exe_hash-proven new bytes — D7-1) + the new-brain x old-broker N-1 verb-surface gate (crates/spt/tests/n1_pairing.rs + ci.yml n1-gate, pinned old broker 0c95435 — D7-2). The D7-1 E2E also re-points the regression-masked in-process int tags off REQ-DAEMON-2 / REQ-UPD-3 (below)
783	[[requirements]]
784	id = "REQ-HAZARD-ROLLBACK-STATE-COMPAT"
785	title = "A brain must not irreversibly migrate durable state before update ready-promotion: the readiness-gated auto-rollback (ADR-0018 Q7) spawns the N-1 binary against durable state the new brain may have written, so every pre-ready write must stay N-1-readable (schema migrations gated behind ready-promotion, or written N-1-tolerant/additive). Else the first in-place schema migration silently bricks rollback (KNOWN-HAZARDS 6.8). Free now — a 2026-06-09 audit confirmed zero state-migration code exists; unmintable retroactively once a migration ships."
786	required_stages = ["doc", "impl", "unit"]  # doc activated restoration D1 (KNOWN-HAZARDS 6.8 + ADR-0018 + design doc V1). impl/unit activated restoration D6-3 (the one D6 toml activation): impl = rollback_compat.rs (the pre-ready durable-file registry PRE_READY_DURABLE_FILES — the enumerated guard surface) + the two-phase AppliedRecord (relcache.rs, additive durable state); unit = the rollback_compat tripwire (every current pre-ready file shape is additive / N-1-readable; a non-additive pre-ready change trips the assert). The two-phase record + the generation-stamped brain.ready are additive → N-1-safe by construction (KH 6.8 D6 guard note)
787	# --- Added 2026-06-11 (v0.4.2): the v0.4.1 fleet-roll Linux brain-respawn blocker (ADR-0018 Q3 amendment); rule 3 register-before-satisfy, rule 5 activate per task ---
788	[[requirements]]
789	id = "REQ-HAZARD-BRAIN-RESPAWN-PATH"
790	title = "The broker respawns the brain onto the APPLIED bytes, not the renamed old binary: the candidate-binary default is the canonical exe path captured ONCE at broker start, never a per-spawn std::env::current_exe() — on Linux current_exe (readlink /proc/self/exe) is inode-tracking and follows the `apply` rename (spt -> spt.old-N), so a resident broker would respawn the brain onto OLD bytes while recording `applied` (Windows GetModuleFileName is path-at-start, so Windows was green; ADR-0018 Q3 silently assumed path-string semantics). Backstop: promotion gates on bytes — a trial promotes only if brain.ready exe_hash == the staged artifact hash for this platform, else auto-rollback + loud notif (readiness != new-bytes was the false-success that recorded applied:8 over a v0.4.0 brain on kitsubito, 2026-06-11). KNOWN-HAZARDS 6.11."
791	required_stages = ["doc", "impl", "unit", "int"]  # activated v0.4.2 (2026-06-11): minted from the v0.4.1 fleet-roll blocker. doc = KNOWN-HAZARDS 6.11 + ADR-0018 Q3 amendment; int = brain_respawn_rename.rs (real broker process, in-place rename P->P.old-N + new bytes at P, respawn must run B not A — fails Linux pre-fix). Stages: impl = brainproc.rs canonical-exe capture at spawn_brain_supervisor + promotion bytes-gate in supervise_brain Promoted arm (TrialEnv ready_exe_hash/staged_artifact_hash); unit = spawn-path selection (captured-default-for-None vs per-spawn) + promotion-gate truth table (match->promote, mismatch->rollback+notif, either-absent->loud PROMOTE_BYTES_UNVERIFIED degrade-to-readiness); int = the brain_survive rename-under-supervisor sibling (real in-place rename P->P.old-N + new bytes at P through the production None selection, assert respawned exe_hash == new bytes; fails Linux pre-fix)
792	# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---
793	[[requirements]]
794	id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
795	title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
796	required_stages = ["impl", "unit"]  # activated M4-D7.5: the stdout-captured turn driver (spt-live turn.rs, D6b pull-forward) + the intent parser's structural strip (spt-live outbound.rs) + the daemon relay's re-stamp/constrained-routing boundary (spt-daemon psyrelay.rs, anti-spoof negative test). int = D9 two-host
797	[[requirements]]
798	id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
799	title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"

# --- Added 2026-06-09 from grill-with-docs (ADR-0018: broker/brain in-process-collapse regression); rule 3: register before satisfying, rule 5: inactive until the broker/brain split restoration milestone (next milestone) ---
[[requirements]]
id = "REQ-HAZARD-BROKER-PROCESS-ISOLATION"
title = "Broker and brain are separate processes: the broker runs as its own long-lived per-machine process that survives every brain restart, so a routine (brain-only) self-update restarts the brain onto the swapped binary while every hosted endpoint (PTY child, live QUIC conn, listening socket) stays untouched at the PROCESS level. The in-process-thread broker (daemon.rs:165-170) is a regression that silently unrealizes REQ-UPD-3 — apply degrades to an in-process Brain::handoff no-op and new code does not run until an unrelated restart (KNOWN-HAZARDS 6.7). Evidence must prove process-level survival (SPIKE-01/03 productionized as int: PTY child + live QUIC survive a brain-PROCESS restart onto a swapped binary), re-pointing the regression-masked in-process int tags currently on REQ-DAEMON-2 / REQ-UPD-3 (ADR-0018)."
required_stages = ["doc", "impl", "unit", "int"]  # activated restoration D1 (ADR-0018 Q2/Q3): doc = KNOWN-HAZARDS 6.7 + ADR-0018 + design doc; impl = brainproc.rs (brain child entry + broker-side process supervisor) + daemon.rs spawn-brain-supervisor + the hidden `daemon brain` CLI entry; unit = brainproc supervisor respawn/backoff table + the real-binary brain_split.rs process-level smoke (broker survives a brain kill + respawns it). int ACTIVATED restoration D7 = the productionized SPIKE-01/03 process-level survival E2E (crates/spt/tests/brain_survive.rs: a PTY child + a live QUIC conn + held-AND-functional output survive a brain-PROCESS restart onto a swapped binary, exe_hash-proven new bytes — D7-1) + the new-brain x old-broker N-1 verb-surface gate (crates/spt/tests/n1_pairing.rs + ci.yml n1-gate, pinned old broker 0c95435 — D7-2). The D7-1 E2E also re-points the regression-masked in-process int tags off REQ-DAEMON-2 / REQ-UPD-3 (below)
[[requirements]]
id = "REQ-HAZARD-ROLLBACK-STATE-COMPAT"
title = "A brain must not irreversibly migrate durable state before update ready-promotion: the readiness-gated auto-rollback (ADR-0018 Q7) spawns the N-1 binary against durable state the new brain may have written, so every pre-ready write must stay N-1-readable (schema migrations gated behind ready-promotion, or written N-1-tolerant/additive). Else the first in-place schema migration silently bricks rollback (KNOWN-HAZARDS 6.8). Free now — a 2026-06-09 audit confirmed zero state-migration code exists; unmintable retroactively once a migration ships."
required_stages = ["doc", "impl", "unit"]  # doc activated restoration D1 (KNOWN-HAZARDS 6.8 + ADR-0018 + design doc V1). impl/unit activated restoration D6-3 (the one D6 toml activation): impl = rollback_compat.rs (the pre-ready durable-file registry PRE_READY_DURABLE_FILES — the enumerated guard surface) + the two-phase AppliedRecord (relcache.rs, additive durable state); unit = the rollback_compat tripwire (every current pre-ready file shape is additive / N-1-readable; a non-additive pre-ready change trips the assert). The two-phase record + the generation-stamped brain.ready are additive → N-1-safe by construction (KH 6.8 D6 guard note)
# --- Added 2026-06-11 (v0.4.2): the v0.4.1 fleet-roll Linux brain-respawn blocker (ADR-0018 Q3 amendment); rule 3 register-before-satisfy, rule 5 activate per task ---
[[requirements]]
id = "REQ-HAZARD-BRAIN-RESPAWN-PATH"
title = "The broker respawns the brain onto the APPLIED bytes, not the renamed old binary: the candidate-binary default is the canonical exe path captured ONCE at broker start, never a per-spawn std::env::current_exe() — on Linux current_exe (readlink /proc/self/exe) is inode-tracking and follows the `apply` rename (spt -> spt.old-N), so a resident broker would respawn the brain onto OLD bytes while recording `applied` (Windows GetModuleFileName is path-at-start, so Windows was green; ADR-0018 Q3 silently assumed path-string semantics). Backstop: promotion gates on bytes — a trial promotes only if brain.ready exe_hash == the staged artifact hash for this platform, else auto-rollback + loud notif (readiness != new-bytes was the false-success that recorded applied:8 over a v0.4.0 brain on kitsubito, 2026-06-11). KNOWN-HAZARDS 6.11."
required_stages = ["doc", "impl", "unit", "int"]  # activated v0.4.2 (2026-06-11): minted from the v0.4.1 fleet-roll blocker. doc = KNOWN-HAZARDS 6.11 + ADR-0018 Q3 amendment; int = brain_respawn_rename.rs (real broker process, in-place rename P->P.old-N + new bytes at P, respawn must run B not A — fails Linux pre-fix). Stages: impl = brainproc.rs canonical-exe capture at spawn_brain_supervisor + promotion bytes-gate in supervise_brain Promoted arm (TrialEnv ready_exe_hash/staged_artifact_hash); unit = spawn-path selection (captured-default-for-None vs per-spawn) + promotion-gate truth table (match->promote, mismatch->rollback+notif, either-absent->loud PROMOTE_BYTES_UNVERIFIED degrade-to-readiness); int = the brain_survive rename-under-supervisor sibling (real in-place rename P->P.old-N + new bytes at P through the production None selection, assert respawned exe_hash == new bytes; fails Linux pre-fix)
# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---
[[requirements]]
id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
required_stages = ["impl", "unit"]  # activated M4-D7.5: the stdout-captured turn driver (spt-live turn.rs, D6b pull-forward) + the intent parser's structural strip (spt-live outbound.rs) + the daemon relay's re-stamp/constrained-routing boundary (spt-daemon psyrelay.rs, anti-spoof negative test). int = D9 two-host
[[requirements]]
id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"


929	id = "REQ-DAEMON-9"
930	title = "Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump (which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart — kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network (`Wants=/After=network-online.target`) as belt-and-suspenders."
931	required_stages = ["impl", "unit"]  # activate v0.3.1. impl = Broker.net → OnceLock<NetHost> + attach_net(); Daemon::run factors spawn_net_consumers(), fast-path attaches at boot, slow-path spawns a background NetHost::start retry supervisor (capped backoff) that attaches + spawns consumers on success (NET_BIND_RETRY/NET_ATTACHED logs); cmd_daemon_status renders net-less as 'no connection' + treats a heartbeat older than daemon start as 'no tick yet' not a giant age; install.sh unit gains network-online ordering. unit = backoff ladder caps + OnceLock single-attach (consumers spawn exactly once) + the status net-less/heartbeat-age render classifier.
932	[[requirements]]
933	id = "REQ-HAZARD-EPOCH-RESET"
934	title = "Advertisement-epoch reset strands a node: peers' higher last-seen epoch drops the reset node's fresh advertisements as Stale until the counter outruns history. Common case (full reinstall/re-pair) is mitigated by REQ-SUBNET-7's ceremony eviction (peer-side epoch memory dies with the deleted row — acceptance-verified); the residual narrow slice (epoch file lost, identity kept) is documented, guard deferred to a field hit (4.11)"
935	required_stages = []  # rule 5 + M8 decision 24: documented hazard (KNOWN-HAZARDS 4.11 at D5), guard waits for a field hit of the narrow slice; the re-pair eviction path is tested under REQ-SUBNET-7
936
937	# ── Subnet full-mesh: membership seed-proof + roster-only relay (ADR-0017, 2026-06-08 grill) ──
938	# Minted inactive (rule 5: no required_stages until the mesh milestone executes). Design: ADR-0017 + SUBNET-MESH-PLAN.md + CONTEXT §Pairing & trust.
939	[[requirements]]
940	id = "REQ-MESH-1"
941	title = "Membership proof (seed-proof): symmetric current-epoch seed-knowledge replaces is_trusted at EVERY inbound gate (registry apply, WAN receive, sync, notif, connection accept). MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch); mutual channel-bound challenge-response at connect (transcript binds both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, role); verified once per connection, cached on the broker ConnEntry, kept warm via QUIC keep-alive so re-proof is restart/partition/rotation-only. Exact-epoch match (re-seed is the sole N-1 exception). SECURITY INVARIANTS: channel-bound (no cross-connection replay), mutual, accepts a member it never paired (the mesh property)."
942	required_stages = ["impl", "unit", "int"]  # Mesh-D1 activated impl+unit (codec: MK derivation + mutual channel-bound transcript/verify + frame codec). Mesh-D2 enriches impl (connect-time mutual proof in nethost dial+accept, ConnEntry proven-subnet set, QUIC keep-alive) and activates int (a member conn establishes only when both prove; a non-prover / no-shared-subnet is dropped). D5 enriches impl (gate swap is_trusted→is_member at the five inbound sites). unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay/mutual-fail; Hello-frame round-trip/reject; keep-alive < idle; D5 adds gate admits-unpaired-member / rejects-non-member.

id = "REQ-DAEMON-9"
title = "Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump (which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart — kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network (`Wants=/After=network-online.target`) as belt-and-suspenders."
required_stages = ["impl", "unit"]  # activate v0.3.1. impl = Broker.net → OnceLock<NetHost> + attach_net(); Daemon::run factors spawn_net_consumers(), fast-path attaches at boot, slow-path spawns a background NetHost::start retry supervisor (capped backoff) that attaches + spawns consumers on success (NET_BIND_RETRY/NET_ATTACHED logs); cmd_daemon_status renders net-less as 'no connection' + treats a heartbeat older than daemon start as 'no tick yet' not a giant age; install.sh unit gains network-online ordering. unit = backoff ladder caps + OnceLock single-attach (consumers spawn exactly once) + the status net-less/heartbeat-age render classifier.
[[requirements]]
id = "REQ-HAZARD-EPOCH-RESET"
title = "Advertisement-epoch reset strands a node: peers' higher last-seen epoch drops the reset node's fresh advertisements as Stale until the counter outruns history. Common case (full reinstall/re-pair) is mitigated by REQ-SUBNET-7's ceremony eviction (peer-side epoch memory dies with the deleted row — acceptance-verified); the residual narrow slice (epoch file lost, identity kept) is documented, guard deferred to a field hit (4.11)"
required_stages = []  # rule 5 + M8 decision 24: documented hazard (KNOWN-HAZARDS 4.11 at D5), guard waits for a field hit of the narrow slice; the re-pair eviction path is tested under REQ-SUBNET-7

# ── Subnet full-mesh: membership seed-proof + roster-only relay (ADR-0017, 2026-06-08 grill) ──
# Minted inactive (rule 5: no required_stages until the mesh milestone executes). Design: ADR-0017 + SUBNET-MESH-PLAN.md + CONTEXT §Pairing & trust.
[[requirements]]
id = "REQ-MESH-1"
title = "Membership proof (seed-proof): symmetric current-epoch seed-knowledge replaces is_trusted at EVERY inbound gate (registry apply, WAN receive, sync, notif, connection accept). MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch); mutual channel-bound challenge-response at connect (transcript binds both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, role); verified once per connection, cached on the broker ConnEntry, kept warm via QUIC keep-alive so re-proof is restart/partition/rotation-only. Exact-epoch match (re-seed is the sole N-1 exception). SECURITY INVARIANTS: channel-bound (no cross-connection replay), mutual, accepts a member it never paired (the mesh property)."
required_stages = ["impl", "unit", "int"]  # Mesh-D1 activated impl+unit (codec: MK derivation + mutual channel-bound transcript/verify + frame codec). Mesh-D2 enriches impl (connect-time mutual proof in nethost dial+accept, ConnEntry proven-subnet set, QUIC keep-alive) and activates int (a member conn establishes only when both prove; a non-prover / no-shared-subnet is dropped). D5 enriches impl (gate swap is_trusted→is_member at the five inbound sites). unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay/mutual-fail; Hello-frame round-trip/reject; keep-alive < idle; D5 adds gate admits-unpaired-member / rejects-non-member.


5://! the spawned child (both via [`spt_term::PtySession`]), and the accepted
65:use crate::nethost::{NetHost, NET_EFFECT_SESSION};
519:    /// when net is still being brought up (REQ-DAEMON-9 boot-race self-heal):
520:    /// a `OnceLock` so the background net-retry can ATTACH the host after bind
523:    net: std::sync::OnceLock<NetHost>,
527:    /// with no supervisor) — the verb is then accepted but unhonored. Set once by
535:    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
536:        Self::bind_in(name, default_journal_path())
541:    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
542:        Self::bind_in_with_net(name, journal_path, None)
546:    /// host (not its config) keeps a net bind failure separable from a listener
547:    /// bind failure — the daemon degrades to a net-less broker rather than
550:    pub fn bind_in_with_net(
553:        net: Option<NetHost>,
555:        Self::bind_in_with_net_cap(name, journal_path, net, DEFAULT_LOG_CHUNKS)
558:    /// [`bind_in_with_net`](Self::bind_in_with_net) with an explicit output-log
559:    /// ring cap (the per-session eviction bound). Production binds at
560:    /// [`DEFAULT_LOG_CHUNKS`]; the restoration D4-2 resume harness binds a **tiny**
564:    pub fn bind_in_with_net_cap(
567:        net: Option<NetHost>,
575:            listener: LocalSocketTransport::bind(name)?,
588:    /// `OnceLock` (the first wins). A broker without it accepts the verb but
596:    /// the boot-race self-heal path calls this once the background retry brings
601:    pub fn attach_net(&self, host: NetHost) -> bool {
610:    /// The broker-owned network host, if one is attached (at bind or via the
611:    /// REQ-DAEMON-9 self-heal `attach_net`). Lock-free.
612:    pub fn net(&self) -> Option<&NetHost> {
622:    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
624:            let conn = self.listener.accept()?;
634:    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
635:        let conn = self.listener.accept()?;
1081:    /// supervisor is wired (a bare test broker has none → accepted-but-unhonored).
1145:                     (broker restarted); retry with a fresh op_id",
1183:    /// REQ-SUBNET-2) on the broker-owned endpoint — it binds and pins the
1211:                retry_after_secs: None,
1216:                let (class, retry) = match &fail {
1219:                    JoinFail::RateLimited(retry) => ("rate-limited", *retry),
1227:                    retry_after_secs: retry,
1278:                     (broker restarted); retry with a fresh op_id",


Now I have the full picture. Critical finding: broker IPC listener binds **independent** of net (broker.rs:575 — degrades to net-less, REQ-DAEMON-9 self-heals net in background). So the brain's `connect_retry` to broker should succeed even net-less. Yet no Psyche. doyle's note nails the real gap: **the broker→brain-child→`run_brain`→`spawn_live_host`→`reconcile_once` path has NEVER been E2E-tested with a real detached daemon** — establish→online ints use in-process daemon; the real-brain test hand-writes the perch, bypassing brain-boot. Bug lives in that untested path.

Setting up tracking + checking todlando reachability and branch state.