1	//! The brain's LiveAgent lifecycle host (B3) — the consolidated re-host of the
2	//! interim `api listen` runner's live half.
3	//!
4	//! In the no-daemon interim model the long-lived `spt api listen` process hosted
5	//! the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest, echo fires,
6	//! graceful signoff) directly in the agent's session. Consolidation
7	//! (REQ-DAEMON-1) moves that orchestration **down into `spt-daemon`** so the
8	//! brain runs it as scheduled in-process loops, with a durable, configurable
9	//! pulse period ([`crate::config::DaemonConfig`]) instead of the interim 5s
10	//! constant. The `spt` binary's `LiveHost` becomes a thin shim over this type —
11	//! a re-host, not a rewrite: the heavy lifting stays in the already-pure
12	//! [`spt_live`] seams ([`spawn_psyche`], [`ingest_drops`], [`run_echo_commune`],
13	//! [`graceful_signoff`], [`sweep_stale_signoff`]); the ordering invariants
14	//! (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-sweep 3.2) live
15	//! where they always have.
16	// [impl->REQ-DAEMON-1]
17
18	use std::collections::BTreeMap;
19	use std::path::PathBuf;
20	use std::sync::atomic::{AtomicBool, Ordering};
21	use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23	use spt_live::{
24	    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
25	    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
26	};
27	use spt_runtime::{Manifest, ManifestRuntime};
28	use spt_store::perch::{self, ParentHint};
29
30	use crate::config::DaemonConfig;
31
32	/// Bound for a bounded history/echo invocation in the host (carried from M2b).
33	const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);
34
35	/// Wall-clock now in epoch millis — precedence-marker stamp for ingested writes.
36	fn now_ms() -> u64 {
37	    SystemTime::now()
38	        .duration_since(UNIX_EPOCH)
39	        .map(|d| d.as_millis() as u64)
40	        .unwrap_or(0)
41	}
42
43	/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
44	fn now_stamp() -> String {
45	    format!("epoch-ms:{}", now_ms())
46	}
47
48	/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
49	#[derive(Debug, Clone, Default)]
50	pub struct TickReport {
51	    pub ingested: Vec<Ingested>,
52	    pub echo_fired: bool,
53	}
54
55	/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
56	/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
57	pub struct BrainLifecycle {
58	    runtime: ManifestRuntime,
59	    id: String,
60	    manifest: Manifest,
61	    commune_dir: Option<PathBuf>,
62	    signoff_dir: Option<PathBuf>,
63	    cfg: DaemonConfig,
64	}
65
66	impl BrainLifecycle {
67	    /// Build a host for `id` from a live-capable manifest (declares
68	    /// `[session.psyche_init]`); `None` for an absent manifest or a plain relay
69	    /// endpoint. Loads the durable [`DaemonConfig`] from disk.
70	    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
71	        Self::with_config(manifest?, id, DaemonConfig::load())
72	    }
73
74	    /// Build a host from a live-capable manifest with an explicit config (the
75	    /// testable seam). `None` if the manifest declares no Psyche.
76	    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
77	        manifest.session.psyche_init.as_ref()?;
78	        Some(BrainLifecycle {
79	            runtime: ManifestRuntime::new(manifest.clone()),
80	            id: id.to_string(),
81	            commune_dir: manifest.session.commune_dir.as_ref().map(PathBuf::from),
82	            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
83	            manifest: manifest.clone(),
84	            cfg,
85	        })
86	    }
87
88	    /// The configured pulse period (config-driven, not a constant).
89	    pub fn pulse_period(&self) -> Duration {
90	        self.cfg.pulse_period
91	    }
92
93	    /// Base substitution keys spt-core fills for the lifecycle templates.
94	    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
95	        let mut k = BTreeMap::new();
96	        k.insert("id".to_string(), self.id.clone());
97	        if let Some(sid) = session_id {
98	            k.insert("session_id".to_string(), sid.to_string());
99	        }
100	        k
101	    }
102
103	    /// The unique watched drop dirs (commune + signoff; usually the same dir).
104	    fn drop_dirs(&self) -> Vec<PathBuf> {
105	        let mut dirs: Vec<PathBuf> = Vec::new();
106	        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
107	            .into_iter()
108	            .flatten()
109	        {
110	            if !dirs.contains(d) {
111	                dirs.push(d.clone());
112	            }
113	        }
114	        dirs
115	    }
116
117	    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
118	    pub fn sweep_on_start(&self) {
119	        if let Some(dir) = &self.signoff_dir {
120	            let _ = sweep_stale_signoff(dir, &self.id);
121	        }
122	    }
123
124	    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
125	    /// spawn is event-driven; `None` at cold start.
126	    ///
127	    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
128	    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
129	    /// after binding, mark the perch `online` — from here liveness reads the
130	    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
131	    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
132	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
133	    pub fn spawn_psyche(
134	        &self,
135	        session_id: Option<&str>,
136	        event: Option<&str>,
137	    ) -> Result<PsycheHandle, String> {
138	        let handle =
139	            spawn_psyche(&self.runtime, &self.id, &self.base_keys(session_id), event, &now_stamp())
140	                .map_err(|e| e.to_string())?;
141	        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
142	            .map_err(|e| format!("mark psyche online: {e}"))?;
143	        Ok(handle)
144	    }
145
146	    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
147	    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
148	    /// the single liveness resolver reports the perch dead regardless of any pid
149	    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
150	    /// (perch already gone) is not an error.
151	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
152	    pub fn mark_offline(perch_path: &std::path::Path) {
153	        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
154	    }
155
156	    /// One pulse tick (T5): ingest drops from the watched dirs (T4), then if the
157	    /// echo-gate is armed fire exactly one echo-commune (T3).
158	    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
159	        let now = now_ms();
160	        let mut ingested = Vec::new();
161	        for dir in self.drop_dirs() {
162	            let mut got =
163	                ingest_drops(&dir, &self.id, ParentHint::Infer, now, self.cfg.protection_window_ms)
164	                    .map_err(|e| e.to_string())?;
165	            ingested.append(&mut got);
166	        }
167	        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
168	            self.fire_echo(session_id)?;
169	            true
170	        } else {
171	            false
172	        };
173	        Ok(TickReport { ingested, echo_fired })
174	    }
175
176	    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
177	    /// each tick (so a caller — or a test — observes cadence and can decide to
178	    /// stop). The period comes from the durable config, never a constant. The
179	    /// loop holds no state beyond the cursor on disk, so it is freely
180	    /// killable + respawnable: a fresh loop re-reads the drop dirs and gate.
181	    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
182	        &self,
183	        session_id: Option<&str>,
184	        stop: &AtomicBool,
185	        mut on_tick: F,
186	    ) {
187	        while !stop.load(Ordering::Relaxed) {
188	            match self.pulse_tick(session_id) {
189	                Ok(report) => on_tick(&report),
190	                Err(_) => { /* a transient tick error must not kill the loop */ }
191	            }
192	            // Sleep the configured period in small slices so a stop is honored
193	            // promptly rather than after a full (possibly multi-second) period.
194	            sleep_interruptible(self.cfg.pulse_period, stop);
195	        }
196	    }
197
198	    /// Fetch history per the manifest `[history]` strategy (empty if none).
199	    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
200	        let Some(h) = &self.manifest.history else {
201	            return Vec::new();
202	        };
203	        fetch_history(h, &self.base_keys(session_id), &self.id, ParentHint::Infer, LIVE_BOUNDED_TIMEOUT)
204	            .unwrap_or_default()
205	    }
206
207	    /// Run the echo-commune into the commune dir (T3). No-op (Ok) without a dir.
208	    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
209	        let Some(dir) = &self.commune_dir else {
210	            return Ok(());
211	        };
212	        let history = self.history(session_id);
213	        run_echo_commune(
214	            &self.runtime,
215	            &self.id,
216	            &self.base_keys(session_id),
217	            &history,
218	            dir,
219	            LIVE_BOUNDED_TIMEOUT,
220	        )
221	        .map(|_| ())
222	        .map_err(|e| e.to_string())
223	    }
224
225	    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
226	    /// grace recheck before any signoff write (1.1). The interim graceful path
227	    /// uses zero grace + always-gone recheck (an intentional shutdown); the
228	    /// daemon's orphan-watch (B7) supplies the real grace/liveness recheck.
229	    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
230	        let mut echo_err: Option<String> = None;
231	        let mut teardown_err: Option<String> = None;
232	        let outcome = graceful_signoff::<std::convert::Infallible>(
233	            session_id,
234	            Duration::ZERO,
235	            || {
236	                if let Err(e) = self.fire_echo(session_id) {
237	                    echo_err = Some(e);
238	                }
239	                Ok(())
240	            },
241	            || true, // graceful, intentional shutdown — the Self is leaving on purpose
242	            || {
243	                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
244	                if let Err(e) = std::fs::remove_file(&ready) {
245	                    if e.kind() != std::io::ErrorKind::NotFound {
246	                        teardown_err = Some(e.to_string());
247	                    }
248	                }
249	                Ok(())
250	            },
251	        )
252	        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
253	        if let Some(e) = echo_err {
254	            return Err(format!("echo failed during signoff: {e}"));
255	        }
256	        if let Some(e) = teardown_err {
257	            return Err(format!("teardown failed during signoff: {e}"));
258	        }
259	        Ok(outcome)
260	    }
261	}
262
263	/// Sleep `period`, waking early if `stop` is set (10ms slices). Keeps the pulse
264	/// loop responsive to a kill without busy-spinning.
265	fn sleep_interruptible(period: Duration, stop: &AtomicBool) {
266	    const SLICE: Duration = Duration::from_millis(10);
267	    let mut left = period;
268	    while left > Duration::ZERO {
269	        if stop.load(Ordering::Relaxed) {
270	            return;
271	        }
272	        let nap = left.min(SLICE);
273	        std::thread::sleep(nap);
274	        left = left.saturating_sub(nap);
275	    }
276	}
277
278	#[cfg(test)]
279	mod tests {
280	    use super::*;
281	    use crate::test_home::with_home;
282	    use std::sync::atomic::AtomicUsize;
283
284	    // A live-capable manifest: a Psyche init template + a commune dir to watch.
285	    // The Psyche command is a trivial no-op (we never spawn it in these tests;
286	    // we exercise the pulse/ingest loop, which does not launch the Psyche).
287	    #[cfg(windows)]
288	    const NOOP_CMD: &str = "cmd /C rem";
289	    #[cfg(unix)]
290	    const NOOP_CMD: &str = "true";
291
292	    fn live_manifest(commune_dir: &std::path::Path) -> Manifest {
293	        let dir = commune_dir.to_string_lossy().replace('\\', "/");
294	        let toml = format!(
295	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
296	             [session]\ncommune_dir=\"{dir}\"\n\n\
297	             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
298	        );
299	        Manifest::from_toml_str(&toml).unwrap()
300	    }
301
302	    #[test] // [unit->REQ-DAEMON-1] a daemon-hosted pulse tick ingests a commune drop.
303	    fn pulse_tick_ingests_drop() {
304	        with_home(|_| {
305	            let drops = tempfile::tempdir().unwrap();
306	            let manifest = live_manifest(drops.path());
307	            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
308	                .expect("live host");
309
310	            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();
311	            let report = host.pulse_tick(Some("sid-1")).expect("tick");
312	            assert_eq!(report.ingested.len(), 1, "the drop was folded in");
313	            assert!(!report.echo_fired, "no echo gate armed");
314	            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
315	        });
316	    }
317
318	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted Psyche derives
319	    // liveness from the daemon `status`, not its summarizer pid: spawning marks
320	    // the perch `online`, so a dead summarizer subprocess does NOT flip it
321	    // offline; `mark_offline` is the only thing that does.
322	    #[test]
323	    fn daemon_hosted_psyche_liveness_is_status_not_pid() {
324	        use spt_store::info::{read_info, write_info};
325	        use spt_store::liveness::{is_perch_alive, STATUS_ONLINE};
326
327	        // A pid that is never a real allocation — stands in for the exited
328	        // summarizer subprocess (deterministic; no spawn-reap timing flake).
329	        const DEAD_PID: u32 = 2_000_000_000;
330
331	        with_home(|_| {
332	            let drops = tempfile::tempdir().unwrap();
333	            let manifest = live_manifest(drops.path());
334	            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
335	                .expect("live host");
336
337	            // Spawn the Psyche — the host binds the nested perch and marks it online.
338	            let handle = host.spawn_psyche(Some("sid-1"), Some("pulse")).expect("spawn psyche");
339	            assert_eq!(
340	                read_info(&handle.perch_path).expect("info.json").status.as_deref(),
341	                Some(STATUS_ONLINE),
342	                "the daemon marked the hosted Psyche online"
343	            );
344
345	            // Simulate the summarizer subprocess exiting: its recorded pid is now
346	            // dead, but the daemon `status` stays online.
347	            let mut rec = read_info(&handle.perch_path).unwrap();
348	            rec.pid = spt_store::info::PidValue::Numeric(DEAD_PID);
349	            write_info(&handle.perch_path, &rec).unwrap();
350	            assert!(
351	                is_perch_alive(&handle.perch_path),
352	                "dead summarizer pid must NOT flip a daemon-hosted perch offline"
353	            );
354
355	            // Only an explicit teardown marks it offline.
356	            BrainLifecycle::mark_offline(&handle.perch_path);
357	            assert!(!is_perch_alive(&handle.perch_path), "mark_offline flips it offline");
358	        });
359	    }
360
361	    #[test] // [unit->REQ-DAEMON-1] the scheduled loop is config-paced + killable.
362	    fn pulse_loop_is_config_paced_and_killable() {
363	        with_home(|_| {
364	            let drops = tempfile::tempdir().unwrap();
365	            let manifest = live_manifest(drops.path());
366	            // Fast period so the test is quick; proves the period is config-driven.
367	            let cfg = DaemonConfig { pulse_period: Duration::from_millis(15), ..DaemonConfig::default() };
368	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
369	            assert_eq!(host.pulse_period(), Duration::from_millis(15));
370
371	            let stop = AtomicBool::new(false);
372	            let ticks = AtomicUsize::new(0);
373	            // Stop the loop from inside on_tick after a few ticks — proves the
374	            // loop actually schedules repeated ticks and honors the stop flag.
375	            host.run_pulse_loop(None, &stop, |_| {
376	                if ticks.fetch_add(1, Ordering::Relaxed) + 1 >= 3 {
377	                    stop.store(true, Ordering::Relaxed);
378	                }
379	            });
380	            assert!(ticks.load(Ordering::Relaxed) >= 3, "loop ran repeated ticks");
381	        });
382	    }
383	}
384

//! The brain's LiveAgent lifecycle host (B3) — the consolidated re-host of the
//! interim `api listen` runner's live half.
//!
//! In the no-daemon interim model the long-lived `spt api listen` process hosted
//! the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest, echo fires,
//! graceful signoff) directly in the agent's session. Consolidation
//! (REQ-DAEMON-1) moves that orchestration **down into `spt-daemon`** so the
//! brain runs it as scheduled in-process loops, with a durable, configurable
//! pulse period ([`crate::config::DaemonConfig`]) instead of the interim 5s
//! constant. The `spt` binary's `LiveHost` becomes a thin shim over this type —
//! a re-host, not a rewrite: the heavy lifting stays in the already-pure
//! [`spt_live`] seams ([`spawn_psyche`], [`ingest_drops`], [`run_echo_commune`],
//! [`graceful_signoff`], [`sweep_stale_signoff`]); the ordering invariants
//! (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-sweep 3.2) live
//! where they always have.
// [impl->REQ-DAEMON-1]

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use spt_live::{
    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
};
use spt_runtime::{Manifest, ManifestRuntime};
use spt_store::perch::{self, ParentHint};

use crate::config::DaemonConfig;

/// Bound for a bounded history/echo invocation in the host (carried from M2b).
const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);

/// Wall-clock now in epoch millis — precedence-marker stamp for ingested writes.
fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
fn now_stamp() -> String {
    format!("epoch-ms:{}", now_ms())
}

/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
#[derive(Debug, Clone, Default)]
pub struct TickReport {
    pub ingested: Vec<Ingested>,
    pub echo_fired: bool,
}

/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
pub struct BrainLifecycle {
    runtime: ManifestRuntime,
    id: String,
    manifest: Manifest,
    commune_dir: Option<PathBuf>,
    signoff_dir: Option<PathBuf>,
    cfg: DaemonConfig,
}

impl BrainLifecycle {
    /// Build a host for `id` from a live-capable manifest (declares
    /// `[session.psyche_init]`); `None` for an absent manifest or a plain relay
    /// endpoint. Loads the durable [`DaemonConfig`] from disk.
    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
        Self::with_config(manifest?, id, DaemonConfig::load())
    }

    /// Build a host from a live-capable manifest with an explicit config (the
    /// testable seam). `None` if the manifest declares no Psyche.
    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
        manifest.session.psyche_init.as_ref()?;
        Some(BrainLifecycle {
            runtime: ManifestRuntime::new(manifest.clone()),
            id: id.to_string(),
            commune_dir: manifest.session.commune_dir.as_ref().map(PathBuf::from),
            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
            manifest: manifest.clone(),
            cfg,
        })
    }

    /// The configured pulse period (config-driven, not a constant).
    pub fn pulse_period(&self) -> Duration {
        self.cfg.pulse_period
    }

    /// Base substitution keys spt-core fills for the lifecycle templates.
    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
        let mut k = BTreeMap::new();
        k.insert("id".to_string(), self.id.clone());
        if let Some(sid) = session_id {
            k.insert("session_id".to_string(), sid.to_string());
        }
        k
    }

    /// The unique watched drop dirs (commune + signoff; usually the same dir).
    fn drop_dirs(&self) -> Vec<PathBuf> {
        let mut dirs: Vec<PathBuf> = Vec::new();
        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
            .into_iter()
            .flatten()
        {
            if !dirs.contains(d) {
                dirs.push(d.clone());
            }
        }
        dirs
    }

    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
    pub fn sweep_on_start(&self) {
        if let Some(dir) = &self.signoff_dir {
            let _ = sweep_stale_signoff(dir, &self.id);
        }
    }

    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
    /// spawn is event-driven; `None` at cold start.
    ///
    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
    /// after binding, mark the perch `online` — from here liveness reads the
    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        let handle =
            spawn_psyche(&self.runtime, &self.id, &self.base_keys(session_id), event, &now_stamp())
                .map_err(|e| e.to_string())?;
        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
            .map_err(|e| format!("mark psyche online: {e}"))?;
        Ok(handle)
    }

    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
    /// the single liveness resolver reports the perch dead regardless of any pid
    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
    /// (perch already gone) is not an error.
    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
    pub fn mark_offline(perch_path: &std::path::Path) {
        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
    }

    /// One pulse tick (T5): ingest drops from the watched dirs (T4), then if the
    /// echo-gate is armed fire exactly one echo-commune (T3).
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
        let now = now_ms();
        let mut ingested = Vec::new();
        for dir in self.drop_dirs() {
            let mut got =
                ingest_drops(&dir, &self.id, ParentHint::Infer, now, self.cfg.protection_window_ms)
                    .map_err(|e| e.to_string())?;
            ingested.append(&mut got);
        }
        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
            self.fire_echo(session_id)?;
            true
        } else {
            false
        };
        Ok(TickReport { ingested, echo_fired })
    }

    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
    /// each tick (so a caller — or a test — observes cadence and can decide to
    /// stop). The period comes from the durable config, never a constant. The
    /// loop holds no state beyond the cursor on disk, so it is freely
    /// killable + respawnable: a fresh loop re-reads the drop dirs and gate.
    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
        &self,
        session_id: Option<&str>,
        stop: &AtomicBool,
        mut on_tick: F,
    ) {
        while !stop.load(Ordering::Relaxed) {
            match self.pulse_tick(session_id) {
                Ok(report) => on_tick(&report),
                Err(_) => { /* a transient tick error must not kill the loop */ }
            }
            // Sleep the configured period in small slices so a stop is honored
            // promptly rather than after a full (possibly multi-second) period.
            sleep_interruptible(self.cfg.pulse_period, stop);
        }
    }

    /// Fetch history per the manifest `[history]` strategy (empty if none).
    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
        let Some(h) = &self.manifest.history else {
            return Vec::new();
        };
        fetch_history(h, &self.base_keys(session_id), &self.id, ParentHint::Infer, LIVE_BOUNDED_TIMEOUT)
            .unwrap_or_default()
    }

    /// Run the echo-commune into the commune dir (T3). No-op (Ok) without a dir.
    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
        let Some(dir) = &self.commune_dir else {
            return Ok(());
        };
        let history = self.history(session_id);
        run_echo_commune(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            &history,
            dir,
            LIVE_BOUNDED_TIMEOUT,
        )
        .map(|_| ())
        .map_err(|e| e.to_string())
    }

    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
    /// grace recheck before any signoff write (1.1). The interim graceful path
    /// uses zero grace + always-gone recheck (an intentional shutdown); the
    /// daemon's orphan-watch (B7) supplies the real grace/liveness recheck.
    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
        let mut echo_err: Option<String> = None;
        let mut teardown_err: Option<String> = None;
        let outcome = graceful_signoff::<std::convert::Infallible>(
            session_id,
            Duration::ZERO,
            || {
                if let Err(e) = self.fire_echo(session_id) {
                    echo_err = Some(e);
                }
                Ok(())
            },
            || true, // graceful, intentional shutdown — the Self is leaving on purpose
            || {
                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
                if let Err(e) = std::fs::remove_file(&ready) {
                    if e.kind() != std::io::ErrorKind::NotFound {
                        teardown_err = Some(e.to_string());
                    }
                }
                Ok(())
            },
        )
        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
        if let Some(e) = echo_err {
            return Err(format!("echo failed during signoff: {e}"));
        }
        if let Some(e) = teardown_err {
            return Err(format!("teardown failed during signoff: {e}"));
        }
        Ok(outcome)
    }
}

/// Sleep `period`, waking early if `stop` is set (10ms slices). Keeps the pulse
/// loop responsive to a kill without busy-spinning.
fn sleep_interruptible(period: Duration, stop: &AtomicBool) {
    const SLICE: Duration = Duration::from_millis(10);
    let mut left = period;
    while left > Duration::ZERO {
        if stop.load(Ordering::Relaxed) {
            return;
        }
        let nap = left.min(SLICE);
        std::thread::sleep(nap);
        left = left.saturating_sub(nap);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use std::sync::atomic::AtomicUsize;

    // A live-capable manifest: a Psyche init template + a commune dir to watch.
    // The Psyche command is a trivial no-op (we never spawn it in these tests;
    // we exercise the pulse/ingest loop, which does not launch the Psyche).
    #[cfg(windows)]
    const NOOP_CMD: &str = "cmd /C rem";
    #[cfg(unix)]
    const NOOP_CMD: &str = "true";

    fn live_manifest(commune_dir: &std::path::Path) -> Manifest {
        let dir = commune_dir.to_string_lossy().replace('\\', "/");
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session]\ncommune_dir=\"{dir}\"\n\n\
             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    #[test] // [unit->REQ-DAEMON-1] a daemon-hosted pulse tick ingests a commune drop.
    fn pulse_tick_ingests_drop() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
                .expect("live host");

            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();
            let report = host.pulse_tick(Some("sid-1")).expect("tick");
            assert_eq!(report.ingested.len(), 1, "the drop was folded in");
            assert!(!report.echo_fired, "no echo gate armed");
            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
        });
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted Psyche derives
    // liveness from the daemon `status`, not its summarizer pid: spawning marks
    // the perch `online`, so a dead summarizer subprocess does NOT flip it
    // offline; `mark_offline` is the only thing that does.
    #[test]
    fn daemon_hosted_psyche_liveness_is_status_not_pid() {
        use spt_store::info::{read_info, write_info};
        use spt_store::liveness::{is_perch_alive, STATUS_ONLINE};

        // A pid that is never a real allocation — stands in for the exited
        // summarizer subprocess (deterministic; no spawn-reap timing flake).
        const DEAD_PID: u32 = 2_000_000_000;

        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
                .expect("live host");

            // Spawn the Psyche — the host binds the nested perch and marks it online.
            let handle = host.spawn_psyche(Some("sid-1"), Some("pulse")).expect("spawn psyche");
            assert_eq!(
                read_info(&handle.perch_path).expect("info.json").status.as_deref(),
                Some(STATUS_ONLINE),
                "the daemon marked the hosted Psyche online"
            );

            // Simulate the summarizer subprocess exiting: its recorded pid is now
            // dead, but the daemon `status` stays online.
            let mut rec = read_info(&handle.perch_path).unwrap();
            rec.pid = spt_store::info::PidValue::Numeric(DEAD_PID);
            write_info(&handle.perch_path, &rec).unwrap();
            assert!(
                is_perch_alive(&handle.perch_path),
                "dead summarizer pid must NOT flip a daemon-hosted perch offline"
            );

            // Only an explicit teardown marks it offline.
            BrainLifecycle::mark_offline(&handle.perch_path);
            assert!(!is_perch_alive(&handle.perch_path), "mark_offline flips it offline");
        });
    }

    #[test] // [unit->REQ-DAEMON-1] the scheduled loop is config-paced + killable.
    fn pulse_loop_is_config_paced_and_killable() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            // Fast period so the test is quick; proves the period is config-driven.
            let cfg = DaemonConfig { pulse_period: Duration::from_millis(15), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
            assert_eq!(host.pulse_period(), Duration::from_millis(15));

            let stop = AtomicBool::new(false);
            let ticks = AtomicUsize::new(0);
            // Stop the loop from inside on_tick after a few ticks — proves the
            // loop actually schedules repeated ticks and honors the stop flag.
            host.run_pulse_loop(None, &stop, |_| {
                if ticks.fetch_add(1, Ordering::Relaxed) + 1 >= 3 {
                    stop.store(true, Ordering::Relaxed);
                }
            });
            assert!(ticks.load(Ordering::Relaxed) >= 3, "loop ran repeated ticks");
        });
    }
}


NOTE FROM THE USER: proceed with nextsteps.


Now read broker.rs + the daemon transport to understand the process/serve model — is there a runnable daemon yet?


1	//! The broker kernel (B1 + B2) — the stable half of the broker/brain split.
2	//!
3	//! Per ADR-0004 §B the broker holds **only** the resources a live consumer
4	//! would lose continuity on if the logic restarted: the PTY master+writer and
5	//! the spawned child (both via [`spt_term::PtySession`]), and the accepted
6	//! client / listening sockets. It runs **no logic** — no routing, registry,
7	//! manifest parse, or lifecycle. It is a relay: spawn a child under a PTY,
8	//! forward its output to the brain, inject the brain's input, report exit.
9	//!
10	//! ## Gapless handoff (B2 — the load-bearing invariant)
11	//!
12	//! A brain may be killed and restarted at any moment (a routine self-update, a
13	//! crash). The broker — and every hosted child — must survive that untouched,
14	//! and the new brain must re-attach to the output stream **gaplessly and
15	//! exactly-once** (Spike #1 made real). Two mechanisms:
16	//!
17	//! - **Sequenced, buffered output.** Each session's output is appended to an
18	//!   [`OutputLog`]: every chunk gets a monotonic per-session `seq` and is held
19	//!   in a bounded ring. The current subscriber (a brain connection) receives
20	//!   chunks live; all subscriber writes happen *under the log lock*, so frames
21	//!   reach the brain in strict `seq` order with no interleave.
22	//! - **Resume from a cursor.** A reconnecting brain sends `subscribe` with its
23	//!   last-seen `seq + 1`; the broker replays the ring from there, then streams
24	//!   live. The brain dedups by `seq`. Output produced *while no brain was
25	//!   attached* sits in the ring and is replayed on the next subscribe — so a
26	//!   brain restart loses nothing.
27	//!
28	//! Sessions are therefore **broker-owned and outlive any one connection**: a
29	//! brain disconnect only detaches the subscriber; the child, its PTY, and the
30	//! output log persist. A session leaves the table only when its child exits
31	//! (reaped by the exit waiter) or is explicitly killed.
32	// [impl->REQ-DAEMON-2]
33
34	use std::collections::{HashMap, VecDeque};
35	use std::io;
36	use std::sync::atomic::{AtomicU64, Ordering};
37	use std::sync::{Arc, Mutex};
38	use std::thread::{self, JoinHandle};
39
40	use interprocess::local_socket::{prelude::*, SendHalf, Stream};
41	use spt_term::{Drain, PtySession, SessionSurface, SurfaceSize};
42
43	use crate::codec::{read_frame, write_frame};
44	use crate::effect::{EffectJournal, EffectKind, Outcome};
45	use crate::frame::{Envelope, Role};
46	use crate::msg::{
47	    applied_envelope, decode_bytes, output_envelope, ErrorEvent, ExitEvent, InputReq, KillReq,
48	    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL,
49	    KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
50	};
51	use crate::transport::{recv_hello, DaemonTransport, LocalSocketTransport};
52
53	/// The shared, serialized send side of one brain connection. Output frames,
54	/// replay frames, command acks, and exit/error events all write through this.
55	type SharedSend = Arc<Mutex<SendHalf>>;
56
57	/// Default bounded output-log depth (chunks). Large enough that a brain restart
58	/// window never evicts unreplayed output in practice; the bound is what keeps a
59	/// disconnected session from buffering without limit.
60	const DEFAULT_LOG_CHUNKS: usize = 4096;
61
62	/// A bounded, sequenced output log for one session — the gapless-resume buffer.
63	///
64	/// The session's single drain thread is the only producer ([`OutputLog::append`]);
65	/// a brain connection attaches as the subscriber ([`OutputLog::attach`]). Every
66	/// send to the subscriber happens while the enclosing `Mutex<OutputLog>` is
67	/// held, so the live stream and a replay can never interleave out of `seq`
68	/// order.
69	struct OutputLog {
70	    session_id: u64,
71	    next_seq: u64,
72	    ring: VecDeque<(u64, Vec<u8>)>,
73	    cap_chunks: usize,
74	    subscriber: Option<SharedSend>,
75	}
76
77	impl OutputLog {
78	    fn new(session_id: u64, cap_chunks: usize) -> Self {
79	        OutputLog {
80	            session_id,
81	            next_seq: 0,
82	            ring: VecDeque::new(),
83	            cap_chunks,
84	            subscriber: None,
85	        }
86	    }
87
88	    /// Append a freshly-produced output chunk: assign the next `seq`, retain it
89	    /// in the bounded ring, and live-send it to the current subscriber (if any).
90	    /// Called only from the session's drain thread.
91	    fn append(&mut self, bytes: &[u8]) {
92	        let seq = self.next_seq;
93	        self.next_seq += 1;
94	        self.ring.push_back((seq, bytes.to_vec()));
95	        while self.ring.len() > self.cap_chunks {
96	            self.ring.pop_front();
97	        }
98	        if let Some(sub) = &self.subscriber {
99	            let frame = output_envelope(self.session_id, seq, bytes);
100	            if let Ok(mut s) = sub.lock() {
101	                let _ = write_frame(&mut *s, &frame);
102	            }
103	        }
104	    }
105
106	    /// Attach `sub` as the subscriber and replay every retained chunk with
107	    /// `seq >= from_seq` before live streaming resumes. Replaces any prior
108	    /// subscriber (a dead brain).
109	    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
110	        self.subscriber = Some(Arc::clone(&sub));
111	        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
112	            let frame = output_envelope(self.session_id, *seq, bytes);
113	            if let Ok(mut s) = sub.lock() {
114	                let _ = write_frame(&mut *s, &frame);
115	            }
116	        }
117	    }
118
119	    /// Detach `sub` *iff* it is the current subscriber. A no-op if a newer brain
120	    /// has already taken over (so a late disconnect can't unsubscribe the live
121	    /// brain).
122	    fn detach_if(&mut self, sub: &SharedSend) {
123	        if let Some(cur) = &self.subscriber {
124	            if Arc::ptr_eq(cur, sub) {
125	                self.subscriber = None;
126	            }
127	        }
128	    }
129	}
130
131	/// A child the broker hosts under a PTY — the §B-owned resources for one
132	/// session, plus its gapless-resume output log.
133	struct HostedSession {
134	    /// The PTY + child (PTY master/writer + spawned child process).
135	    session: Arc<PtySession>,
136	    /// The output drain pump (kept so teardown can stop it).
137	    #[allow(dead_code)] // held for ownership/teardown; not read directly
138	    drain: Drain,
139	    /// The sequenced, buffered output + current subscriber.
140	    log: Arc<Mutex<OutputLog>>,
141	}
142
143	/// The broker process kernel. Owns the listening socket, the table of hosted
144	/// sessions, and the broker-owned idempotency anchor (B5); holds no logic state
145	/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable
146	/// recovery anchor a brain crash must not lose, so it lives in the broker, not
147	/// the brain (Spike #6 constraint 1).
148	pub struct Broker {
149	    listener: LocalSocketTransport,
150	    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
151	    next_id: AtomicU64,
152	    log_cap_chunks: usize,
153	    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
154	    journal: Arc<EffectJournal>,
155	}
156
157	impl Broker {
158	    /// Bind the broker's listening endpoint at the namespaced `name`, with the
159	    /// effect journal at the default path (`<spt_home>/daemon-effects.log`).
160	    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
161	        Self::bind_in(name, default_journal_path())
162	    }
163
164	    /// Bind with an explicit effect-journal path — the testable seam (tests point
165	    /// the broker-owned anchor at a temp file rather than the shared home).
166	    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
167	        Ok(Arc::new(Broker {
168	            listener: LocalSocketTransport::bind(name)?,
169	            sessions: Arc::new(Mutex::new(HashMap::new())),
170	            next_id: AtomicU64::new(1),
171	            log_cap_chunks: DEFAULT_LOG_CHUNKS,
172	            journal: Arc::new(EffectJournal::open(journal_path)?),
173	        }))
174	    }
175
176	    /// The broker-owned exactly-once anchor (for introspection / tests).
177	    pub fn journal(&self) -> &Arc<EffectJournal> {
178	        &self.journal
179	    }
180
181	    /// Accept connections forever, handling each on its own thread. The broker
182	    /// is the long-lived process; this is its main loop.
183	    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
184	        loop {
185	            let conn = self.listener.accept()?;
186	            let me = Arc::clone(self);
187	            thread::spawn(move || {
188	                let _ = me.handle_conn(conn);
189	            });
190	        }
191	    }
192
193	    /// Accept exactly one connection and handle it on a spawned thread, returning
194	    /// that thread's handle. The single-brain entry point the tests exercise.
195	    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
196	        let conn = self.listener.accept()?;
197	        let me = Arc::clone(self);
198	        Ok(thread::spawn(move || {
199	            let _ = me.handle_conn(conn);
200	        }))
201	    }
202
203	    /// Number of currently hosted sessions (for tests / introspection).
204	    pub fn session_count(&self) -> usize {
205	        self.sessions.lock().unwrap().len()
206	    }
207
208	    /// The child pid of a hosted session, if present (tests assert it is stable
209	    /// across brain restarts).
210	    pub fn session_pid(&self, id: u64) -> Option<u32> {
211	        self.sessions.lock().unwrap().get(&id).and_then(|h| h.session.process_id())
212	    }
213
214	    /// Drive one brain connection: handshake, then dispatch its command frames
215	    /// until it disconnects. On disconnect, detach this connection from any
216	    /// sessions it subscribed to — the sessions themselves persist (B2: a brain
217	    /// restart must not disturb the hosted children).
218	    fn handle_conn(self: &Arc<Self>, conn: Stream) -> io::Result<()> {
219	        let (mut recv, send) = conn.split();
220	        let send: SharedSend = Arc::new(Mutex::new(send));
221
222	        // Version handshake: this connection must be a brain (2.3).
223	        recv_hello(&mut recv, Role::Brain)?;
224
225	        let mut my_subs: Vec<u64> = Vec::new();
226
227	        loop {
228	            let env = match read_frame(&mut recv) {
229	                Ok(e) => e,
230	                // Any read failure (EOF included) ends the connection.
231	                Err(_) => break,
232	            };
233	            match env.kind.as_str() {
234	                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
235	                    Ok(id) => my_subs.push(id),
236	                    Err(msg) => send_error(&send, &msg),
237	                },
238	                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
239	                    Ok(id) => my_subs.push(id),
240	                    Err(msg) => send_error(&send, &msg),
241	                },
242	                KIND_INPUT => {
243	                    if let Err(msg) = self.dispatch_input(env, &send) {
244	                        send_error(&send, &msg);
245	                    }
246	                }
247	                KIND_RESIZE => {
248	                    if let Err(msg) = self.dispatch_resize(env) {
249	                        send_error(&send, &msg);
250	                    }
251	                }
252	                KIND_KILL => {
253	                    if let Err(msg) = self.dispatch_kill(env) {
254	                        send_error(&send, &msg);
255	                    }
256	                }
257	                // An unknown kind is tolerated (forward-compat): report, don't die.
258	                other => send_error(&send, &format!("unknown command kind {other:?}")),
259	            }
260	        }
261
262	        // Brain gone: detach as subscriber but leave the sessions running.
263	        let sessions = self.sessions.lock().unwrap();
264	        for id in &my_subs {
265	            if let Some(h) = sessions.get(id) {
266	                h.log.lock().unwrap().detach_if(&send);
267	            }
268	        }
269	        Ok(())
270	    }
271
272	    /// Spawn a child under a fresh PTY, register it, auto-subscribe the spawning
273	    /// connection, start its output drain and exit waiter, and reply `spawned`.
274	    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
275	        let req: SpawnReq = serde_json::from_value(env.payload)
276	            .map_err(|e| format!("bad spawn payload: {e}"))?;
277	        let session = PtySession::spawn_program(
278	            &req.program,
279	            &req.args,
280	            SurfaceSize::new(req.rows, req.cols),
281	        )
282	        .map_err(|e| format!("spawn failed: {e}"))?;
283	        let session = Arc::new(session);
284	        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
285	        let pid = session.process_id();
286
287	        // The output log, with the spawning connection pre-attached so no chunk
288	        // is missed before an explicit subscribe.
289	        let log = Arc::new(Mutex::new(OutputLog::new(id, self.log_cap_chunks)));
290	        log.lock().unwrap().attach(Arc::clone(send), 0);
291
292	        // Output drain → appends to the log (which live-sends to the subscriber).
293	        let log_drain = Arc::clone(&log);
294	        let drain = session
295	            .drain(move |chunk| {
296	                log_drain.lock().unwrap().append(chunk);
297	            })
298	            .map_err(|e| format!("drain failed: {e}"))?;
299
300	        // Exit waiter → one `exit` frame when the child is reaped, then the dead
301	        // session is removed from the table. Runs on its own thread; `wait`
302	        // holds the child lock but `kill` uses a split killer.
303	        let sessions_exit = Arc::clone(&self.sessions);
304	        let log_exit = Arc::clone(&log);
305	        let waiter = Arc::clone(&session);
306	        thread::spawn(move || {
307	            if let Ok(status) = waiter.wait() {
308	                let ev = ExitEvent { session_id: id, code: Some(status.exit_code() as i32) };
309	                let frame = Envelope::new(
310	                    KIND_EXIT,
311	                    serde_json::to_value(ev).expect("ExitEvent serializes"),
312	                );
313	                // Send to whoever is currently subscribed (under the log lock).
314	                if let Some(sub) = log_exit.lock().unwrap().subscriber.clone() {
315	                    if let Ok(mut s) = sub.lock() {
316	                        let _ = write_frame(&mut *s, &frame);
317	                    }
318	                }
319	            }
320	            // Reap the dead session from the table.
321	            sessions_exit.lock().unwrap().remove(&id);
322	        });
323
324	        self.sessions.lock().unwrap().insert(id, HostedSession { session, drain, log });
325
326	        let resp = Spawned { session_id: id, pid };
327	        let frame =
328	            Envelope::new(KIND_SPAWNED, serde_json::to_value(resp).expect("Spawned serializes"));
329	        send_frame(send, &frame);
330	        Ok(id)
331	    }
332
333	    /// Re-attach a (handed-off) brain to a session's output, replaying from
334	    /// `from_seq` for gapless, exactly-once resume.
335	    fn dispatch_subscribe(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
336	        let req: SubscribeReq = serde_json::from_value(env.payload)
337	            .map_err(|e| format!("bad subscribe payload: {e}"))?;
338	        // Clone the log Arc out from under the sessions lock, then replay under
339	        // the log lock — never hold both locks at once.
340	        let log = {
341	            let sessions = self.sessions.lock().unwrap();
342	            let h = sessions
343	                .get(&req.session_id)
344	                .ok_or_else(|| format!("no such session {}", req.session_id))?;
345	            Arc::clone(&h.log)
346	        };
347	        log.lock().unwrap().attach(Arc::clone(send), req.from_seq);
348	        Ok(req.session_id)
349	    }
350
351	    /// Inject input bytes into a session's PTY.
352	    ///
353	    /// With an `op_id` the write is routed through the broker-owned
354	    /// [`EffectJournal`] for exactly-once delivery: a replay of the same
355	    /// `(session_id, op_id)` after a brain crash is deduped (the PTY is not
356	    /// written twice), and either outcome — fresh apply or dedup — is acked back
357	    /// with an [`crate::msg::KIND_APPLIED`] frame so the brain can retire the
358	    /// effect (B5 / KNOWN-HAZARDS 7.2). Without an `op_id` it is the legacy
359	    /// direct write (no dedup, no ack).
360	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
361	    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
362	        let req: InputReq = serde_json::from_value(env.payload)
363	            .map_err(|e| format!("bad input payload: {e}"))?;
364	        let bytes = decode_bytes(&req.data_b64).map_err(|e| format!("bad input base64: {e}"))?;
365	        // Clone the session Arc out from under the sessions lock so the PTY write
366	        // + fsync happen without holding the table lock (mirrors dispatch_subscribe).
367	        let session = {
368	            let sessions = self.sessions.lock().unwrap();
369	            let h = sessions
370	                .get(&req.session_id)
371	                .ok_or_else(|| format!("no such session {}", req.session_id))?;
372	            Arc::clone(&h.session)
373	        };
374	        match req.op_id {
375	            Some(op) => {
376	                let key = (req.session_id, op);
377	                let outcome = self
378	                    .journal
379	                    .apply_once(key, EffectKind::PtyWrite, || {
380	                        session.write_input(&bytes).map_err(|e| io::Error::other(e.to_string()))
381	                    })
382	                    .map_err(|e| format!("write_input failed: {e}"))?;
383	                // Ack exactly-once: both Applied and Deduped mean "durably landed".
384	                send_frame(send, &applied_envelope(req.session_id, op, outcome == Outcome::Applied));
385	                Ok(())
386	            }
387	            None => session.write_input(&bytes).map_err(|e| format!("write_input failed: {e}")),
388	        }
389	    }
390
391	    /// Resize a session's PTY.
392	    fn dispatch_resize(&self, env: Envelope) -> Result<(), String> {
393	        let req: ResizeReq = serde_json::from_value(env.payload)
394	            .map_err(|e| format!("bad resize payload: {e}"))?;
395	        let sessions = self.sessions.lock().unwrap();
396	        let h = sessions
397	            .get(&req.session_id)
398	            .ok_or_else(|| format!("no such session {}", req.session_id))?;
399	        h.session
400	            .resize(SurfaceSize::new(req.rows, req.cols))
401	            .map_err(|e| format!("resize failed: {e}"))
402	    }
403
404	    /// Terminate a session's child. The exit waiter emits the `exit` frame and
405	    /// reaps the session once the child is dead.
406	    fn dispatch_kill(&self, env: Envelope) -> Result<(), String> {
407	        let req: KillReq = serde_json::from_value(env.payload)
408	            .map_err(|e| format!("bad kill payload: {e}"))?;
409	        let sessions = self.sessions.lock().unwrap();
410	        let h = sessions
411	            .get(&req.session_id)
412	            .ok_or_else(|| format!("no such session {}", req.session_id))?;
413	        h.session.kill().map_err(|e| format!("kill failed: {e}"))
414	    }
415	}
416
417	/// The default broker-owned effect-journal path: `<spt_home>/daemon-effects.log`.
418	fn default_journal_path() -> std::path::PathBuf {
419	    spt_store::perch::spt_home().join("daemon-effects.log")
420	}
421
422	/// Write a frame to the brain, ignoring a broken-pipe failure (brain gone).
423	fn send_frame(send: &SharedSend, frame: &Envelope) {
424	    if let Ok(mut s) = send.lock() {
425	        let _ = write_frame(&mut *s, frame);
426	    }
427	}
428
429	/// Send an `error` event to the brain (best-effort).
430	fn send_error(send: &SharedSend, message: &str) {
431	    let frame = Envelope::new(
432	        KIND_ERROR,
433	        serde_json::to_value(ErrorEvent { message: message.to_string() })
434	            .expect("ErrorEvent serializes"),
435	    );
436	    send_frame(send, &frame);
437	}
438

//! The broker kernel (B1 + B2) — the stable half of the broker/brain split.
//!
//! Per ADR-0004 §B the broker holds **only** the resources a live consumer
//! would lose continuity on if the logic restarted: the PTY master+writer and
//! the spawned child (both via [`spt_term::PtySession`]), and the accepted
//! client / listening sockets. It runs **no logic** — no routing, registry,
//! manifest parse, or lifecycle. It is a relay: spawn a child under a PTY,
//! forward its output to the brain, inject the brain's input, report exit.
//!
//! ## Gapless handoff (B2 — the load-bearing invariant)
//!
//! A brain may be killed and restarted at any moment (a routine self-update, a
//! crash). The broker — and every hosted child — must survive that untouched,
//! and the new brain must re-attach to the output stream **gaplessly and
//! exactly-once** (Spike #1 made real). Two mechanisms:
//!
//! - **Sequenced, buffered output.** Each session's output is appended to an
//!   [`OutputLog`]: every chunk gets a monotonic per-session `seq` and is held
//!   in a bounded ring. The current subscriber (a brain connection) receives
//!   chunks live; all subscriber writes happen *under the log lock*, so frames
//!   reach the brain in strict `seq` order with no interleave.
//! - **Resume from a cursor.** A reconnecting brain sends `subscribe` with its
//!   last-seen `seq + 1`; the broker replays the ring from there, then streams
//!   live. The brain dedups by `seq`. Output produced *while no brain was
//!   attached* sits in the ring and is replayed on the next subscribe — so a
//!   brain restart loses nothing.
//!
//! Sessions are therefore **broker-owned and outlive any one connection**: a
//! brain disconnect only detaches the subscriber; the child, its PTY, and the
//! output log persist. A session leaves the table only when its child exits
//! (reaped by the exit waiter) or is explicitly killed.
// [impl->REQ-DAEMON-2]

use std::collections::{HashMap, VecDeque};
use std::io;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};

use interprocess::local_socket::{prelude::*, SendHalf, Stream};
use spt_term::{Drain, PtySession, SessionSurface, SurfaceSize};

use crate::codec::{read_frame, write_frame};
use crate::effect::{EffectJournal, EffectKind, Outcome};
use crate::frame::{Envelope, Role};
use crate::msg::{
    applied_envelope, decode_bytes, output_envelope, ErrorEvent, ExitEvent, InputReq, KillReq,
    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL,
    KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
};
use crate::transport::{recv_hello, DaemonTransport, LocalSocketTransport};

/// The shared, serialized send side of one brain connection. Output frames,
/// replay frames, command acks, and exit/error events all write through this.
type SharedSend = Arc<Mutex<SendHalf>>;

/// Default bounded output-log depth (chunks). Large enough that a brain restart
/// window never evicts unreplayed output in practice; the bound is what keeps a
/// disconnected session from buffering without limit.
const DEFAULT_LOG_CHUNKS: usize = 4096;

/// A bounded, sequenced output log for one session — the gapless-resume buffer.
///
/// The session's single drain thread is the only producer ([`OutputLog::append`]);
/// a brain connection attaches as the subscriber ([`OutputLog::attach`]). Every
/// send to the subscriber happens while the enclosing `Mutex<OutputLog>` is
/// held, so the live stream and a replay can never interleave out of `seq`
/// order.
struct OutputLog {
    session_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    subscriber: Option<SharedSend>,
}

impl OutputLog {
    fn new(session_id: u64, cap_chunks: usize) -> Self {
        OutputLog {
            session_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            subscriber: None,
        }
    }

    /// Append a freshly-produced output chunk: assign the next `seq`, retain it
    /// in the bounded ring, and live-send it to the current subscriber (if any).
    /// Called only from the session's drain thread.
    fn append(&mut self, bytes: &[u8]) {
        let seq = self.next_seq;
        self.next_seq += 1;
        self.ring.push_back((seq, bytes.to_vec()));
        while self.ring.len() > self.cap_chunks {
            self.ring.pop_front();
        }
        if let Some(sub) = &self.subscriber {
            let frame = output_envelope(self.session_id, seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Attach `sub` as the subscriber and replay every retained chunk with
    /// `seq >= from_seq` before live streaming resumes. Replaces any prior
    /// subscriber (a dead brain).
    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
        self.subscriber = Some(Arc::clone(&sub));
        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
            let frame = output_envelope(self.session_id, *seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Detach `sub` *iff* it is the current subscriber. A no-op if a newer brain
    /// has already taken over (so a late disconnect can't unsubscribe the live
    /// brain).
    fn detach_if(&mut self, sub: &SharedSend) {
        if let Some(cur) = &self.subscriber {
            if Arc::ptr_eq(cur, sub) {
                self.subscriber = None;
            }
        }
    }
}

/// A child the broker hosts under a PTY — the §B-owned resources for one
/// session, plus its gapless-resume output log.
struct HostedSession {
    /// The PTY + child (PTY master/writer + spawned child process).
    session: Arc<PtySession>,
    /// The output drain pump (kept so teardown can stop it).
    #[allow(dead_code)] // held for ownership/teardown; not read directly
    drain: Drain,
    /// The sequenced, buffered output + current subscriber.
    log: Arc<Mutex<OutputLog>>,
}

/// The broker process kernel. Owns the listening socket, the table of hosted
/// sessions, and the broker-owned idempotency anchor (B5); holds no logic state
/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable
/// recovery anchor a brain crash must not lose, so it lives in the broker, not
/// the brain (Spike #6 constraint 1).
pub struct Broker {
    listener: LocalSocketTransport,
    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
    next_id: AtomicU64,
    log_cap_chunks: usize,
    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
    journal: Arc<EffectJournal>,
}

impl Broker {
    /// Bind the broker's listening endpoint at the namespaced `name`, with the
    /// effect journal at the default path (`<spt_home>/daemon-effects.log`).
    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
        Self::bind_in(name, default_journal_path())
    }

    /// Bind with an explicit effect-journal path — the testable seam (tests point
    /// the broker-owned anchor at a temp file rather than the shared home).
    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
        Ok(Arc::new(Broker {
            listener: LocalSocketTransport::bind(name)?,
            sessions: Arc::new(Mutex::new(HashMap::new())),
            next_id: AtomicU64::new(1),
            log_cap_chunks: DEFAULT_LOG_CHUNKS,
            journal: Arc::new(EffectJournal::open(journal_path)?),
        }))
    }

    /// The broker-owned exactly-once anchor (for introspection / tests).
    pub fn journal(&self) -> &Arc<EffectJournal> {
        &self.journal
    }

    /// Accept connections forever, handling each on its own thread. The broker
    /// is the long-lived process; this is its main loop.
    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
        loop {
            let conn = self.listener.accept()?;
            let me = Arc::clone(self);
            thread::spawn(move || {
                let _ = me.handle_conn(conn);
            });
        }
    }

    /// Accept exactly one connection and handle it on a spawned thread, returning
    /// that thread's handle. The single-brain entry point the tests exercise.
    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
        let conn = self.listener.accept()?;
        let me = Arc::clone(self);
        Ok(thread::spawn(move || {
            let _ = me.handle_conn(conn);
        }))
    }

    /// Number of currently hosted sessions (for tests / introspection).
    pub fn session_count(&self) -> usize {
        self.sessions.lock().unwrap().len()
    }

    /// The child pid of a hosted session, if present (tests assert it is stable
    /// across brain restarts).
    pub fn session_pid(&self, id: u64) -> Option<u32> {
        self.sessions.lock().unwrap().get(&id).and_then(|h| h.session.process_id())
    }

    /// Drive one brain connection: handshake, then dispatch its command frames
    /// until it disconnects. On disconnect, detach this connection from any
    /// sessions it subscribed to — the sessions themselves persist (B2: a brain
    /// restart must not disturb the hosted children).
    fn handle_conn(self: &Arc<Self>, conn: Stream) -> io::Result<()> {
        let (mut recv, send) = conn.split();
        let send: SharedSend = Arc::new(Mutex::new(send));

        // Version handshake: this connection must be a brain (2.3).
        recv_hello(&mut recv, Role::Brain)?;

        let mut my_subs: Vec<u64> = Vec::new();

        loop {
            let env = match read_frame(&mut recv) {
                Ok(e) => e,
                // Any read failure (EOF included) ends the connection.
                Err(_) => break,
            };
            match env.kind.as_str() {
                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_INPUT => {
                    if let Err(msg) = self.dispatch_input(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_RESIZE => {
                    if let Err(msg) = self.dispatch_resize(env) {
                        send_error(&send, &msg);
                    }
                }
                KIND_KILL => {
                    if let Err(msg) = self.dispatch_kill(env) {
                        send_error(&send, &msg);
                    }
                }
                // An unknown kind is tolerated (forward-compat): report, don't die.
                other => send_error(&send, &format!("unknown command kind {other:?}")),
            }
        }

        // Brain gone: detach as subscriber but leave the sessions running.
        let sessions = self.sessions.lock().unwrap();
        for id in &my_subs {
            if let Some(h) = sessions.get(id) {
                h.log.lock().unwrap().detach_if(&send);
            }
        }
        Ok(())
    }

    /// Spawn a child under a fresh PTY, register it, auto-subscribe the spawning
    /// connection, start its output drain and exit waiter, and reply `spawned`.
    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
        let req: SpawnReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad spawn payload: {e}"))?;
        let session = PtySession::spawn_program(
            &req.program,
            &req.args,
            SurfaceSize::new(req.rows, req.cols),
        )
        .map_err(|e| format!("spawn failed: {e}"))?;
        let session = Arc::new(session);
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let pid = session.process_id();

        // The output log, with the spawning connection pre-attached so no chunk
        // is missed before an explicit subscribe.
        let log = Arc::new(Mutex::new(OutputLog::new(id, self.log_cap_chunks)));
        log.lock().unwrap().attach(Arc::clone(send), 0);

        // Output drain → appends to the log (which live-sends to the subscriber).
        let log_drain = Arc::clone(&log);
        let drain = session
            .drain(move |chunk| {
                log_drain.lock().unwrap().append(chunk);
            })
            .map_err(|e| format!("drain failed: {e}"))?;

        // Exit waiter → one `exit` frame when the child is reaped, then the dead
        // session is removed from the table. Runs on its own thread; `wait`
        // holds the child lock but `kill` uses a split killer.
        let sessions_exit = Arc::clone(&self.sessions);
        let log_exit = Arc::clone(&log);
        let waiter = Arc::clone(&session);
        thread::spawn(move || {
            if let Ok(status) = waiter.wait() {
                let ev = ExitEvent { session_id: id, code: Some(status.exit_code() as i32) };
                let frame = Envelope::new(
                    KIND_EXIT,
                    serde_json::to_value(ev).expect("ExitEvent serializes"),
                );
                // Send to whoever is currently subscribed (under the log lock).
                if let Some(sub) = log_exit.lock().unwrap().subscriber.clone() {
                    if let Ok(mut s) = sub.lock() {
                        let _ = write_frame(&mut *s, &frame);
                    }
                }
            }
            // Reap the dead session from the table.
            sessions_exit.lock().unwrap().remove(&id);
        });

        self.sessions.lock().unwrap().insert(id, HostedSession { session, drain, log });

        let resp = Spawned { session_id: id, pid };
        let frame =
            Envelope::new(KIND_SPAWNED, serde_json::to_value(resp).expect("Spawned serializes"));
        send_frame(send, &frame);
        Ok(id)
    }

    /// Re-attach a (handed-off) brain to a session's output, replaying from
    /// `from_seq` for gapless, exactly-once resume.
    fn dispatch_subscribe(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
        let req: SubscribeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad subscribe payload: {e}"))?;
        // Clone the log Arc out from under the sessions lock, then replay under
        // the log lock — never hold both locks at once.
        let log = {
            let sessions = self.sessions.lock().unwrap();
            let h = sessions
                .get(&req.session_id)
                .ok_or_else(|| format!("no such session {}", req.session_id))?;
            Arc::clone(&h.log)
        };
        log.lock().unwrap().attach(Arc::clone(send), req.from_seq);
        Ok(req.session_id)
    }

    /// Inject input bytes into a session's PTY.
    ///
    /// With an `op_id` the write is routed through the broker-owned
    /// [`EffectJournal`] for exactly-once delivery: a replay of the same
    /// `(session_id, op_id)` after a brain crash is deduped (the PTY is not
    /// written twice), and either outcome — fresh apply or dedup — is acked back
    /// with an [`crate::msg::KIND_APPLIED`] frame so the brain can retire the
    /// effect (B5 / KNOWN-HAZARDS 7.2). Without an `op_id` it is the legacy
    /// direct write (no dedup, no ack).
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: InputReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad input payload: {e}"))?;
        let bytes = decode_bytes(&req.data_b64).map_err(|e| format!("bad input base64: {e}"))?;
        // Clone the session Arc out from under the sessions lock so the PTY write
        // + fsync happen without holding the table lock (mirrors dispatch_subscribe).
        let session = {
            let sessions = self.sessions.lock().unwrap();
            let h = sessions
                .get(&req.session_id)
                .ok_or_else(|| format!("no such session {}", req.session_id))?;
            Arc::clone(&h.session)
        };
        match req.op_id {
            Some(op) => {
                let key = (req.session_id, op);
                let outcome = self
                    .journal
                    .apply_once(key, EffectKind::PtyWrite, || {
                        session.write_input(&bytes).map_err(|e| io::Error::other(e.to_string()))
                    })
                    .map_err(|e| format!("write_input failed: {e}"))?;
                // Ack exactly-once: both Applied and Deduped mean "durably landed".
                send_frame(send, &applied_envelope(req.session_id, op, outcome == Outcome::Applied));
                Ok(())
            }
            None => session.write_input(&bytes).map_err(|e| format!("write_input failed: {e}")),
        }
    }

    /// Resize a session's PTY.
    fn dispatch_resize(&self, env: Envelope) -> Result<(), String> {
        let req: ResizeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad resize payload: {e}"))?;
        let sessions = self.sessions.lock().unwrap();
        let h = sessions
            .get(&req.session_id)
            .ok_or_else(|| format!("no such session {}", req.session_id))?;
        h.session
            .resize(SurfaceSize::new(req.rows, req.cols))
            .map_err(|e| format!("resize failed: {e}"))
    }

    /// Terminate a session's child. The exit waiter emits the `exit` frame and
    /// reaps the session once the child is dead.
    fn dispatch_kill(&self, env: Envelope) -> Result<(), String> {
        let req: KillReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad kill payload: {e}"))?;
        let sessions = self.sessions.lock().unwrap();
        let h = sessions
            .get(&req.session_id)
            .ok_or_else(|| format!("no such session {}", req.session_id))?;
        h.session.kill().map_err(|e| format!("kill failed: {e}"))
    }
}

/// The default broker-owned effect-journal path: `<spt_home>/daemon-effects.log`.
fn default_journal_path() -> std::path::PathBuf {
    spt_store::perch::spt_home().join("daemon-effects.log")
}

/// Write a frame to the brain, ignoring a broken-pipe failure (brain gone).
fn send_frame(send: &SharedSend, frame: &Envelope) {
    if let Ok(mut s) = send.lock() {
        let _ = write_frame(&mut *s, frame);
    }
}

/// Send an `error` event to the brain (best-effort).
fn send_error(send: &SharedSend, message: &str) {
    let frame = Envelope::new(
        KIND_ERROR,
        serde_json::to_value(ErrorEvent { message: message.to_string() })
            .expect("ErrorEvent serializes"),
    );
    send_frame(send, &frame);
}


1	//! The OS-split local-IPC carrier: named pipe (Windows) / Unix-domain socket
2	//! (unix), behind one [`DaemonTransport`] trait.
3	//!
4	//! This mirrors how `spt-term`'s `SessionSurface` abstracts ConPTY vs forkpty:
5	//! the broker and brain talk through one trait, and the platform split lives in
6	//! a single implementation ([`LocalSocketTransport`], over the `interprocess`
7	//! crate's local sockets — namespaced names work on both Windows and Linux).
8	//! The byte framing ([`crate::codec`]) and the version handshake
9	//! ([`crate::frame`]) ride on top, transport-agnostic.
10	// [impl->REQ-DAEMON-2]
11
12	use std::io::{self, Read, Write};
13
14	use interprocess::local_socket::{
15	    prelude::*, GenericNamespaced, ListenerOptions, Stream,
16	};
17
18	use crate::codec::{read_frame, write_frame};
19	use crate::frame::{accept_hello, Envelope, HandshakeError, Hello, Role};
20
21	/// A bidirectional IPC connection between the broker and a brain. Anything that
22	/// is both [`Read`] and [`Write`] qualifies, so tests can substitute an
23	/// in-memory or socket-pair duplex for the real local socket.
24	pub trait IpcConn: Read + Write {}
25	impl<T: Read + Write> IpcConn for T {}
26
27	/// A listening IPC endpoint plus its dialer. The broker `bind`s one and
28	/// `accept`s brains; a brain `connect`s. Abstracted as a trait so B1/B2 can
29	/// drive the broker over a stub transport in tests without a real socket.
30	pub trait DaemonTransport {
31	    /// The accepted/connected connection type.
32	    type Conn: IpcConn;
33
34	    /// Accept the next inbound connection (broker side). Blocks.
35	    fn accept(&self) -> io::Result<Self::Conn>;
36	}
37
38	/// The production transport: `interprocess` local sockets. A namespaced name
39	/// resolves to a Windows named pipe or a Linux abstract-namespace UDS — the one
40	/// OS split, contained here.
41	pub struct LocalSocketTransport {
42	    listener: interprocess::local_socket::Listener,
43	}
44
45	impl LocalSocketTransport {
46	    /// Bind a listening endpoint under the namespaced `name` (broker side).
47	    pub fn bind(name: &str) -> io::Result<Self> {
48	        let ns = name.to_ns_name::<GenericNamespaced>()?;
49	        let listener = ListenerOptions::new().name(ns).create_sync()?;
50	        Ok(LocalSocketTransport { listener })
51	    }
52
53	    /// Connect to an existing endpoint under `name` (brain side).
54	    pub fn connect(name: &str) -> io::Result<Stream> {
55	        let ns = name.to_ns_name::<GenericNamespaced>()?;
56	        Stream::connect(ns)
57	    }
58	}
59
60	impl DaemonTransport for LocalSocketTransport {
61	    type Conn = Stream;
62
63	    fn accept(&self) -> io::Result<Self::Conn> {
64	        self.listener.accept()
65	    }
66	}
67
68	/// Brain side of the connect handshake: announce ourselves as `role` by sending
69	/// a `hello` frame. The broker answers with [`recv_hello`] + [`accept_hello`].
70	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
71	pub fn send_hello<W: Write>(w: &mut W, role: Role) -> io::Result<()> {
72	    let hello = Hello::current(role);
73	    let payload =
74	        serde_json::to_value(hello).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
75	    write_frame(w, &Envelope::new("hello", payload))
76	}
77
78	/// Broker side: read the peer's first frame, require it to be a `hello`, and
79	/// run the version-tolerance policy ([`accept_hello`]) against `expected_peer`.
80	/// An incompatible peer surfaces a typed [`HandshakeError`] (never a silent
81	/// misparse — the 2.3 failure mode is a peer dying unlogged on a mismatch).
82	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
83	pub fn recv_hello<R: Read>(r: &mut R, expected_peer: Role) -> io::Result<Hello> {
84	    let env = read_frame(r)?;
85	    if env.kind != "hello" {
86	        return Err(io::Error::new(
87	            io::ErrorKind::InvalidData,
88	            format!("expected hello frame, got kind {:?}", env.kind),
89	        ));
90	    }
91	    let hello: Hello = serde_json::from_value(env.payload)
92	        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
93	    accept_hello(&hello, expected_peer)
94	        .map_err(|e: HandshakeError| io::Error::new(io::ErrorKind::InvalidData, e))?;
95	    Ok(hello)
96	}
97
98	#[cfg(test)]
99	mod tests {
100	    use super::*;
101	    use serde_json::json;
102	    use std::sync::atomic::{AtomicU32, Ordering};
103
104	    // Unique namespaced socket name per test, so parallel test threads on one
105	    // machine never collide on the same pipe/UDS name.
106	    static SEQ: AtomicU32 = AtomicU32::new(0);
107	    fn unique_name() -> String {
108	        let n = SEQ.fetch_add(1, Ordering::Relaxed);
109	        format!("spt-daemon-test-{}-{}.sock", std::process::id(), n)
110	    }
111
112	    // [unit->REQ-DAEMON-2] a real local socket carries a hello handshake then a
113	    // frame, end to end, on whatever OS the test runs on (named pipe / UDS).
114	    #[test]
115	    fn local_socket_handshake_and_frame_round_trip() {
116	        let name = unique_name();
117	        let broker = LocalSocketTransport::bind(&name).unwrap();
118
119	        let client_name = name.clone();
120	        let client = std::thread::spawn(move || {
121	            let mut conn = LocalSocketTransport::connect(&client_name).unwrap();
122	            send_hello(&mut conn, Role::Brain).unwrap();
123	            write_frame(&mut conn, &Envelope::new("ping", json!({ "seq": 1 }))).unwrap();
124	            // Read the broker's reply.
125	            read_frame(&mut conn).unwrap()
126	        });
127
128	        let mut server = broker.accept().unwrap();
129	        let hello = recv_hello(&mut server, Role::Brain).unwrap();
130	        assert_eq!(hello.role, Role::Brain);
131	        let ping = read_frame(&mut server).unwrap();
132	        assert_eq!(ping.kind, "ping");
133	        write_frame(&mut server, &Envelope::new("pong", json!({ "seq": 1 }))).unwrap();
134
135	        let reply = client.join().unwrap();
136	        assert_eq!(reply.kind, "pong");
137	    }
138
139	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT] a brain announcing the wrong role
140	    // is refused at the handshake, over the real socket.
141	    #[test]
142	    fn local_socket_rejects_wrong_role() {
143	        let name = unique_name();
144	        let broker = LocalSocketTransport::bind(&name).unwrap();
145
146	        let client_name = name.clone();
147	        let client = std::thread::spawn(move || {
148	            let mut conn = LocalSocketTransport::connect(&client_name).unwrap();
149	            // Announce as Broker on a connection where the broker expects a Brain.
150	            send_hello(&mut conn, Role::Broker).unwrap();
151	        });
152
153	        let mut server = broker.accept().unwrap();
154	        let err = recv_hello(&mut server, Role::Brain).unwrap_err();
155	        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
156	        client.join().unwrap();
157	    }
158	}
159

//! The OS-split local-IPC carrier: named pipe (Windows) / Unix-domain socket
//! (unix), behind one [`DaemonTransport`] trait.
//!
//! This mirrors how `spt-term`'s `SessionSurface` abstracts ConPTY vs forkpty:
//! the broker and brain talk through one trait, and the platform split lives in
//! a single implementation ([`LocalSocketTransport`], over the `interprocess`
//! crate's local sockets — namespaced names work on both Windows and Linux).
//! The byte framing ([`crate::codec`]) and the version handshake
//! ([`crate::frame`]) ride on top, transport-agnostic.
// [impl->REQ-DAEMON-2]

use std::io::{self, Read, Write};

use interprocess::local_socket::{
    prelude::*, GenericNamespaced, ListenerOptions, Stream,
};

use crate::codec::{read_frame, write_frame};
use crate::frame::{accept_hello, Envelope, HandshakeError, Hello, Role};

/// A bidirectional IPC connection between the broker and a brain. Anything that
/// is both [`Read`] and [`Write`] qualifies, so tests can substitute an
/// in-memory or socket-pair duplex for the real local socket.
pub trait IpcConn: Read + Write {}
impl<T: Read + Write> IpcConn for T {}

/// A listening IPC endpoint plus its dialer. The broker `bind`s one and
/// `accept`s brains; a brain `connect`s. Abstracted as a trait so B1/B2 can
/// drive the broker over a stub transport in tests without a real socket.
pub trait DaemonTransport {
    /// The accepted/connected connection type.
    type Conn: IpcConn;

    /// Accept the next inbound connection (broker side). Blocks.
    fn accept(&self) -> io::Result<Self::Conn>;
}

/// The production transport: `interprocess` local sockets. A namespaced name
/// resolves to a Windows named pipe or a Linux abstract-namespace UDS — the one
/// OS split, contained here.
pub struct LocalSocketTransport {
    listener: interprocess::local_socket::Listener,
}

impl LocalSocketTransport {
    /// Bind a listening endpoint under the namespaced `name` (broker side).
    pub fn bind(name: &str) -> io::Result<Self> {
        let ns = name.to_ns_name::<GenericNamespaced>()?;
        let listener = ListenerOptions::new().name(ns).create_sync()?;
        Ok(LocalSocketTransport { listener })
    }

    /// Connect to an existing endpoint under `name` (brain side).
    pub fn connect(name: &str) -> io::Result<Stream> {
        let ns = name.to_ns_name::<GenericNamespaced>()?;
        Stream::connect(ns)
    }
}

impl DaemonTransport for LocalSocketTransport {
    type Conn = Stream;

    fn accept(&self) -> io::Result<Self::Conn> {
        self.listener.accept()
    }
}

/// Brain side of the connect handshake: announce ourselves as `role` by sending
/// a `hello` frame. The broker answers with [`recv_hello`] + [`accept_hello`].
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
pub fn send_hello<W: Write>(w: &mut W, role: Role) -> io::Result<()> {
    let hello = Hello::current(role);
    let payload =
        serde_json::to_value(hello).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    write_frame(w, &Envelope::new("hello", payload))
}

/// Broker side: read the peer's first frame, require it to be a `hello`, and
/// run the version-tolerance policy ([`accept_hello`]) against `expected_peer`.
/// An incompatible peer surfaces a typed [`HandshakeError`] (never a silent
/// misparse — the 2.3 failure mode is a peer dying unlogged on a mismatch).
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
pub fn recv_hello<R: Read>(r: &mut R, expected_peer: Role) -> io::Result<Hello> {
    let env = read_frame(r)?;
    if env.kind != "hello" {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("expected hello frame, got kind {:?}", env.kind),
        ));
    }
    let hello: Hello = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    accept_hello(&hello, expected_peer)
        .map_err(|e: HandshakeError| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(hello)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::sync::atomic::{AtomicU32, Ordering};

    // Unique namespaced socket name per test, so parallel test threads on one
    // machine never collide on the same pipe/UDS name.
    static SEQ: AtomicU32 = AtomicU32::new(0);
    fn unique_name() -> String {
        let n = SEQ.fetch_add(1, Ordering::Relaxed);
        format!("spt-daemon-test-{}-{}.sock", std::process::id(), n)
    }

    // [unit->REQ-DAEMON-2] a real local socket carries a hello handshake then a
    // frame, end to end, on whatever OS the test runs on (named pipe / UDS).
    #[test]
    fn local_socket_handshake_and_frame_round_trip() {
        let name = unique_name();
        let broker = LocalSocketTransport::bind(&name).unwrap();

        let client_name = name.clone();
        let client = std::thread::spawn(move || {
            let mut conn = LocalSocketTransport::connect(&client_name).unwrap();
            send_hello(&mut conn, Role::Brain).unwrap();
            write_frame(&mut conn, &Envelope::new("ping", json!({ "seq": 1 }))).unwrap();
            // Read the broker's reply.
            read_frame(&mut conn).unwrap()
        });

        let mut server = broker.accept().unwrap();
        let hello = recv_hello(&mut server, Role::Brain).unwrap();
        assert_eq!(hello.role, Role::Brain);
        let ping = read_frame(&mut server).unwrap();
        assert_eq!(ping.kind, "ping");
        write_frame(&mut server, &Envelope::new("pong", json!({ "seq": 1 }))).unwrap();

        let reply = client.join().unwrap();
        assert_eq!(reply.kind, "pong");
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT] a brain announcing the wrong role
    // is refused at the handshake, over the real socket.
    #[test]
    fn local_socket_rejects_wrong_role() {
        let name = unique_name();
        let broker = LocalSocketTransport::bind(&name).unwrap();

        let client_name = name.clone();
        let client = std::thread::spawn(move || {
            let mut conn = LocalSocketTransport::connect(&client_name).unwrap();
            // Announce as Broker on a connection where the broker expects a Brain.
            send_hello(&mut conn, Role::Broker).unwrap();
        });

        let mut server = broker.accept().unwrap();
        let err = recv_hello(&mut server, Role::Brain).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
        client.join().unwrap();
    }
}


Full picture clear. Daemon crate has broker(server)/brain(client)/lifecycle/relay/effect — but **no runnable daemon process entrypoint** wired into `spt`, and seed still file-based. B6 = stand up daemon process + auto-start + in-memory seed map over IPC.

Check req registry state + hazard 5.1 + any existing daemon socket-name convention.


Bash

grep -n -A6 'REQ-DAEMON-3\|REQ-START-3\|REQ-DAEMON-2\]' traceable-reqs.toml | head -80


49:id = "REQ-DAEMON-3"
50-title = "Any api invocation auto-starts the daemon if absent"
51-required_stages = []
52-[[requirements]]
53-id = "REQ-DAEMON-4"
54-title = "Honor every KNOWN-HAZARDS invariant"
55-required_stages = []
--
130:id = "REQ-START-3"
131-title = "spt-hosted startup: spawn-session then api bind (no file)"
132-required_stages = []
133-[[requirements]]
134-id = "REQ-START-4"
135-title = "Adapter-injected env aliases (SPT/OWL/LIVE)"
136-required_stages = ["impl", "unit"]


Grep

socket name|daemon.*sock|broker.*name|fn daemon_socket|DAEMON_SOCK|endpoint name


Found 10 files
PRD.md
CONTEXT.md
crates\spt-daemon\tests\idempotent.rs
crates\spt-daemon\src\broker.rs
crates\spt-daemon\tests\broker.rs
crates\spt-daemon\tests\handoff.rs
crates\spt-daemon\Cargo.toml
crates\spt-daemon\src\transport.rs
M3a-PLAN.md
docs\adr\0002-bake-networking-into-core-collapse-spt-node.md


1	//! The broker-owned idempotency anchor (B5) — exactly-once side effects across a
2	//! brain restart (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`]; Spike #6).
3	//!
4	//! ## The hazard
5	//!
6	//! The brain restarts freely (ADR-0004): a routine self-update swaps it, a crash
7	//! kills it. Effects it drives cross the broker↔brain boundary — a PTY write, a
8	//! spool row, a registry update. A brain that crashes *around* one of those
9	//! effects must, on restart, neither duplicate it (a re-injected keystroke, a
10	//! double-written row) nor drop it (a lost delivery).
11	//!
12	//! ## Spike #6's two binding constraints, instantiated here
13	//!
14	//! Spike #6 proved durable-ID + dedup-at-effect is exactly-once across a crash at
15	//! any protocol point, and left two constraints for the real boundaries:
16	//!
17	//! 1. **The recovery anchor is broker-owned, not brain-owned** — it *is* the
18	//!    thing that survives the crash being recovered from; a brain-local journal
19	//!    would die with the brain.
20	//! 2. **Dedup lives at the effect boundary, keyed by the durable ID** — not at
21	//!    intake. Only an applied-set check *gating the effect* closes the
22	//!    effect-applied-but-not-acked window.
23	//!
24	//! ## Where the applied-set lives for the M3b hazard (the spike's open question)
25	//!
26	//! Spike #6 explicitly deferred to B5: *"a PTY write cannot be checked-then-
27	//! applied atomically against a durable set the way a file append can. M3b must
28	//! map each boundary onto this pattern and decide where the durable applied-set
29	//! lives."* For the M3b hazard — **a brain crash, with the broker surviving** —
30	//! the answer is the broker's process: [`EffectJournal::apply_once`] checks the
31	//! applied-set, runs the effect, and records it **all under one lock in the
32	//! broker**. A brain crash cannot interrupt that sequence (it is the brain, not
33	//! the broker, that dies), so the effect-applied-but-not-recorded window — the
34	//! one that would cause a duplicate on replay — **cannot occur for a brain
35	//! crash**. The brain re-driving an effect from its durable source after restart
36	//! therefore hits an already-populated applied-set and is deduped (no dup); the
37	//! re-drive itself is what re-attempts an un-applied effect (no drop).
38	//!
39	//! The applied-set is additionally journaled to disk (`PENDING`/`DONE` lines,
40	//! fsync'd) so it is rebuilt on a broker *restart* — a forward-looking durability
41	//! for whole-daemon self-update (ADR-0004's explicit future polish). The
42	//! exactly-once *guarantee* this module makes is scoped to the M3b hazard (brain
43	//! crash); the disk journal is the substrate the M3c broker-restart story will
44	//! build on, not a present claim of exactly-once across a broker crash (which
45	//! reintroduces the effect-vs-marker ordering gap the spike named).
46	// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
47
48	use std::collections::HashSet;
49	use std::fs::{File, OpenOptions};
50	use std::io::{self, BufRead, BufReader, Write};
51	use std::path::{Path, PathBuf};
52	use std::sync::Mutex;
53
54	/// Which boundary an effect crosses. Tagged in the journal so the one anchor
55	/// covers all three named side-effect crossings (spool / PTY / registry) — the
56	/// hazard requires exactly-once at *every* crossing, not just the PTY write.
57	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58	pub enum EffectKind {
59	    /// A write into a session's PTY (the genuine broker↔brain crossing today).
60	    PtyWrite,

//! The broker-owned idempotency anchor (B5) — exactly-once side effects across a
//! brain restart (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`]; Spike #6).
//!
//! ## The hazard
//!
//! The brain restarts freely (ADR-0004): a routine self-update swaps it, a crash
//! kills it. Effects it drives cross the broker↔brain boundary — a PTY write, a
//! spool row, a registry update. A brain that crashes *around* one of those
//! effects must, on restart, neither duplicate it (a re-injected keystroke, a
//! double-written row) nor drop it (a lost delivery).
//!
//! ## Spike #6's two binding constraints, instantiated here
//!
//! Spike #6 proved durable-ID + dedup-at-effect is exactly-once across a crash at
//! any protocol point, and left two constraints for the real boundaries:
//!
//! 1. **The recovery anchor is broker-owned, not brain-owned** — it *is* the
//!    thing that survives the crash being recovered from; a brain-local journal
//!    would die with the brain.
//! 2. **Dedup lives at the effect boundary, keyed by the durable ID** — not at
//!    intake. Only an applied-set check *gating the effect* closes the
//!    effect-applied-but-not-acked window.
//!
//! ## Where the applied-set lives for the M3b hazard (the spike's open question)
//!
//! Spike #6 explicitly deferred to B5: *"a PTY write cannot be checked-then-
//! applied atomically against a durable set the way a file append can. M3b must
//! map each boundary onto this pattern and decide where the durable applied-set
//! lives."* For the M3b hazard — **a brain crash, with the broker surviving** —
//! the answer is the broker's process: [`EffectJournal::apply_once`] checks the
//! applied-set, runs the effect, and records it **all under one lock in the
//! broker**. A brain crash cannot interrupt that sequence (it is the brain, not
//! the broker, that dies), so the effect-applied-but-not-recorded window — the
//! one that would cause a duplicate on replay — **cannot occur for a brain
//! crash**. The brain re-driving an effect from its durable source after restart
//! therefore hits an already-populated applied-set and is deduped (no dup); the
//! re-drive itself is what re-attempts an un-applied effect (no drop).
//!
//! The applied-set is additionally journaled to disk (`PENDING`/`DONE` lines,
//! fsync'd) so it is rebuilt on a broker *restart* — a forward-looking durability
//! for whole-daemon self-update (ADR-0004's explicit future polish). The
//! exactly-once *guarantee* this module makes is scoped to the M3b hazard (brain
//! crash); the disk journal is the substrate the M3c broker-restart story will
//! build on, not a present claim of exactly-once across a broker crash (which
//! reintroduces the effect-vs-marker ordering gap the spike named).
// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]

use std::collections::HashSet;
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;

/// Which boundary an effect crosses. Tagged in the journal so the one anchor
/// covers all three named side-effect crossings (spool / PTY / registry) — the
/// hazard requires exactly-once at *every* crossing, not just the PTY write.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffectKind {
    /// A write into a session's PTY (the genuine broker↔brain crossing today).
    PtyWrite,


crates\spt-daemon\tests\broker.rs:35:fn unique_name() -> String {
crates\spt-daemon\tests\broker.rs:54:    let name = unique_name();
crates\spt-daemon\tests\broker.rs:55:    let broker = Broker::bind(&name).expect("bind broker");
crates\spt-daemon\tests\broker.rs:60:        let handle = server_broker.serve_once().expect("accept brain");
crates\spt-daemon\tests\handoff.rs:31:fn unique_name() -> String {
crates\spt-daemon\tests\handoff.rs:46:        if let Ok(b) = Brain::cold_start(name, now_ms) {
crates\spt-daemon\tests\handoff.rs:56:        if let Ok(b) = Brain::handoff(name, prior.clone(), now_ms) {
crates\spt-daemon\tests\handoff.rs:67:    let name = unique_name();
crates\spt-daemon\tests\handoff.rs:68:    let broker = Broker::bind(&name).expect("bind broker");
crates\spt-daemon\tests\handoff.rs:74:        let _ = serve_broker.serve();
crates\spt-daemon\tests\idempotent.rs:39:fn unique_name() -> String {
crates\spt-daemon\tests\idempotent.rs:54:        if let Ok(b) = Brain::cold_start(name, now_ms) {
crates\spt-daemon\tests\idempotent.rs:64:        if let Ok(b) = Brain::handoff(name, prior.clone(), now_ms) {
crates\spt-daemon\tests\idempotent.rs:139:    let name = unique_name();
crates\spt-daemon\tests\idempotent.rs:143:    let broker = Broker::bind_in(&name, journal_path).expect("bind broker");
crates\spt-daemon\tests\idempotent.rs:147:        let _ = serve.serve();


Design locked. Lowest-risk path: B6 adds a **seed-control channel + auto-start** as a self-contained surface, leaving the load-bearing broker/handoff (B2) code untouched. The daemon *process* binds both broker + seed-control sockets.

Read codec + frame (Hello) to reuse correctly.