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	use spt_store::proc::is_process_alive;
30
31	use crate::config::DaemonConfig;
32
33	/// Bound for a bounded history/echo invocation in the host (carried from M2b).
34	const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);
35
36	/// Wall-clock now in epoch millis — precedence-marker stamp for ingested writes.
37	fn now_ms() -> u64 {
38	    SystemTime::now()
39	        .duration_since(UNIX_EPOCH)
40	        .map(|d| d.as_millis() as u64)
41	        .unwrap_or(0)
42	}
43
44	/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
45	fn now_stamp() -> String {
46	    format!("epoch-ms:{}", now_ms())
47	}
48
49	/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
50	#[derive(Debug, Clone, Default)]
51	pub struct TickReport {
52	    pub ingested: Vec<Ingested>,
53	    pub echo_fired: bool,
54	}
55
56	/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
57	/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
58	pub struct BrainLifecycle {
59	    runtime: ManifestRuntime,
60	    id: String,
61	    manifest: Manifest,
62	    commune_dir: Option<PathBuf>,
63	    signoff_dir: Option<PathBuf>,
64	    cfg: DaemonConfig,
65	}
66
67	impl BrainLifecycle {
68	    /// Build a host for `id` from a live-capable manifest (declares
69	    /// `[session.psyche_init]`); `None` for an absent manifest or a plain relay
70	    /// endpoint. Loads the durable [`DaemonConfig`] from disk.
71	    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
72	        Self::with_config(manifest?, id, DaemonConfig::load())
73	    }
74
75	    /// Build a host from a live-capable manifest with an explicit config (the
76	    /// testable seam). `None` if the manifest declares no Psyche.
77	    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
78	        manifest.session.psyche_init.as_ref()?;
79	        Some(BrainLifecycle {
80	            runtime: ManifestRuntime::new(manifest.clone()),
81	            id: id.to_string(),
82	            commune_dir: manifest.session.commune_dir.as_ref().map(PathBuf::from),
83	            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
84	            manifest: manifest.clone(),
85	            cfg,
86	        })
87	    }
88
89	    /// The configured pulse period (config-driven, not a constant).
90	    pub fn pulse_period(&self) -> Duration {
91	        self.cfg.pulse_period
92	    }
93
94	    /// Base substitution keys spt-core fills for the lifecycle templates.
95	    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
96	        let mut k = BTreeMap::new();
97	        k.insert("id".to_string(), self.id.clone());
98	        if let Some(sid) = session_id {
99	            k.insert("session_id".to_string(), sid.to_string());
100	        }
101	        k
102	    }
103
104	    /// The unique watched drop dirs (commune + signoff; usually the same dir).
105	    fn drop_dirs(&self) -> Vec<PathBuf> {
106	        let mut dirs: Vec<PathBuf> = Vec::new();
107	        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
108	            .into_iter()
109	            .flatten()
110	        {
111	            if !dirs.contains(d) {
112	                dirs.push(d.clone());
113	            }
114	        }
115	        dirs
116	    }
117
118	    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
119	    pub fn sweep_on_start(&self) {
120	        if let Some(dir) = &self.signoff_dir {
121	            let _ = sweep_stale_signoff(dir, &self.id);
122	        }
123	    }
124
125	    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
126	    /// spawn is event-driven; `None` at cold start.
127	    ///
128	    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
129	    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
130	    /// after binding, mark the perch `online` — from here liveness reads the
131	    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
132	    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
133	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
134	    pub fn spawn_psyche(
135	        &self,
136	        session_id: Option<&str>,
137	        event: Option<&str>,
138	    ) -> Result<PsycheHandle, String> {
139	        let handle =
140	            spawn_psyche(&self.runtime, &self.id, &self.base_keys(session_id), event, &now_stamp())
141	                .map_err(|e| e.to_string())?;
142	        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
143	            .map_err(|e| format!("mark psyche online: {e}"))?;
144	        Ok(handle)
145	    }
146
147	    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
148	    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
149	    /// the single liveness resolver reports the perch dead regardless of any pid
150	    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
151	    /// (perch already gone) is not an error.
152	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
153	    pub fn mark_offline(perch_path: &std::path::Path) {
154	        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
155	    }
156
157	    /// One pulse tick (T5): ingest drops from the watched dirs (T4), then if the
158	    /// echo-gate is armed fire exactly one echo-commune (T3).
159	    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
160	        let now = now_ms();
161	        let mut ingested = Vec::new();
162	        for dir in self.drop_dirs() {
163	            let mut got =
164	                ingest_drops(&dir, &self.id, ParentHint::Infer, now, self.cfg.protection_window_ms)
165	                    .map_err(|e| e.to_string())?;
166	            ingested.append(&mut got);
167	        }
168	        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
169	            self.fire_echo(session_id)?;
170	            true
171	        } else {
172	            false
173	        };
174	        Ok(TickReport { ingested, echo_fired })
175	    }
176
177	    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
178	    /// each tick (so a caller — or a test — observes cadence and can decide to
179	    /// stop). The period comes from the durable config, never a constant. The
180	    /// loop holds no state beyond the cursor on disk, so it is freely
181	    /// killable + respawnable: a fresh loop re-reads the drop dirs and gate.
182	    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
183	        &self,
184	        session_id: Option<&str>,
185	        stop: &AtomicBool,
186	        mut on_tick: F,
187	    ) {
188	        while !stop.load(Ordering::Relaxed) {
189	            match self.pulse_tick(session_id) {
190	                Ok(report) => on_tick(&report),
191	                Err(_) => { /* a transient tick error must not kill the loop */ }
192	            }
193	            // Sleep the configured period in small slices so a stop is honored
194	            // promptly rather than after a full (possibly multi-second) period.
195	            sleep_interruptible(self.cfg.pulse_period, stop);
196	        }
197	    }
198
199	    /// Fetch history per the manifest `[history]` strategy (empty if none).
200	    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
201	        let Some(h) = &self.manifest.history else {
202	            return Vec::new();
203	        };
204	        fetch_history(h, &self.base_keys(session_id), &self.id, ParentHint::Infer, LIVE_BOUNDED_TIMEOUT)
205	            .unwrap_or_default()
206	    }
207
208	    /// Run the echo-commune into the commune dir (T3). No-op (Ok) without a dir.
209	    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
210	        let Some(dir) = &self.commune_dir else {
211	            return Ok(());
212	        };
213	        let history = self.history(session_id);
214	        run_echo_commune(
215	            &self.runtime,
216	            &self.id,
217	            &self.base_keys(session_id),
218	            &history,
219	            dir,
220	            LIVE_BOUNDED_TIMEOUT,
221	        )
222	        .map(|_| ())
223	        .map_err(|e| e.to_string())
224	    }
225
226	    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
227	    /// grace recheck before any signoff write (1.1). The graceful path is an
228	    /// **intentional** shutdown, so it uses zero grace + an always-gone recheck —
229	    /// the Self is leaving on purpose, there is nothing to recover. The
230	    /// supervised-crash path ([`supervised_crash_signoff`](Self::supervised_crash_signoff))
231	    /// supplies the real grace + liveness recheck for an *un*intentional death.
232	    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
233	        self.signoff_with(session_id, Duration::ZERO, || true)
234	    }
235
236	    /// Execute the **supervised-crash** signoff (B7, orphan-watch) — the
237	    /// counterpart of [`graceful_signoff`](Self::graceful_signoff) for an
238	    /// *unexpected* harness death. It runs the identical load-bearing ordering —
239	    /// echo-commune (3.3) → grace wait (1.1) → liveness recheck → teardown — but
240	    /// with the **real** grace window from config and the **real** recheck
241	    /// `still_gone`. A Self that recovered across the grace window (a binary
242	    /// handoff relaunch, a brief stale poll) makes `still_gone` false and is
243	    /// **spared** — the phantom-INIT_SIGNOFF bug (1.1).
244	    ///
245	    /// `still_gone` is the post-grace liveness verdict (true ⇒ the harness owner
246	    /// is still gone ⇒ tear down). [`run_orphan_watch`](Self::run_orphan_watch)
247	    /// supplies the production recheck ([`harness_owner_gone`](Self::harness_owner_gone));
248	    /// it is injected here so the ordering is testable without a real pid.
249	    // [impl->REQ-DAEMON-4]
250	    // [impl->REQ-HAZARD-GRACE-BEFORE-SIGNOFF]
251	    // [impl->REQ-HAZARD-ECHO-BEFORE-SIGNOFF]
252	    pub fn supervised_crash_signoff(
253	        &self,
254	        session_id: Option<&str>,
255	        still_gone: impl FnOnce() -> bool,
256	    ) -> Result<SignoffOutcome, String> {
257	        self.signoff_with(session_id, self.cfg.crash_grace, still_gone)
258	    }
259
260	    /// The shared signoff wiring behind both the graceful and supervised-crash
261	    /// paths. The only two things that differ between them are exactly the two
262	    /// the 1.1 hazard turns on — the `grace` window and the `recheck` verdict — so
263	    /// they are the only parameters; the echo (3.3) and teardown effects are
264	    /// identical. Keeping one body guarantees the crash path can never drift out
265	    /// of the echo-before-teardown ordering the graceful path proves.
266	    fn signoff_with(
267	        &self,
268	        session_id: Option<&str>,
269	        grace: Duration,
270	        recheck: impl FnOnce() -> bool,
271	    ) -> Result<SignoffOutcome, String> {
272	        let mut echo_err: Option<String> = None;
273	        let mut teardown_err: Option<String> = None;
274	        let outcome = graceful_signoff::<std::convert::Infallible>(
275	            session_id,
276	            grace,
277	            || {
278	                if let Err(e) = self.fire_echo(session_id) {
279	                    echo_err = Some(e);
280	                }
281	                Ok(())
282	            },
283	            recheck,
284	            || {
285	                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
286	                if let Err(e) = std::fs::remove_file(&ready) {
287	                    if e.kind() != std::io::ErrorKind::NotFound {
288	                        teardown_err = Some(e.to_string());
289	                    }
290	                }
291	                Ok(())
292	            },
293	        )
294	        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
295	        if let Some(e) = echo_err {
296	            return Err(format!("echo failed during signoff: {e}"));
297	        }
298	        if let Some(e) = teardown_err {
299	            return Err(format!("teardown failed during signoff: {e}"));
300	        }
301	        Ok(outcome)
302	    }
303
304	    /// Whether the supervised Self's **harness owner** is gone. Anchors liveness on
305	    /// the stable harness-session pid (`info.json` `parent_pid`), never an
306	    /// ephemeral poll pid (KNOWN-HAZARDS 2.1): a recycled ephemeral pid could read
307	    /// a foreign process as the Self and mask a real death, or flip a live Self
308	    /// dead. Re-reads the perch fresh so a Self that **rebound** under a new
309	    /// harness (a recovery) — recording a fresh, live `parent_pid` — reads alive.
310	    /// A perch with no recorded owner is treated as gone (nothing supervises it).
311	    // [impl->REQ-DAEMON-4]
312	    pub fn harness_owner_gone(&self) -> bool {
313	        let path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
314	        match spt_store::info::read_info(&path).and_then(|i| i.parent_pid) {
315	            Some(pid) => !is_process_alive(pid),
316	            None => true,
317	        }
318	    }
319
320	    /// Supervise a harness-hosted Self and carry the graceful ordering into the
321	    /// **supervised-crash** path the always-on daemon now enables (B7). On start it
322	    /// sweeps any stale signoff sentinel (3.2) so a prior generation's leftover
323	    /// never reads as a live signoff. It then polls the harness owner pid each
324	    /// cycle (anchored on `parent_pid`, 2.1); on the first observed death it runs
325	    /// [`supervised_crash_signoff`](Self::supervised_crash_signoff) with the real
326	    /// grace + a fresh liveness recheck ([`harness_owner_gone`](Self::harness_owner_gone)),
327	    /// so a transient recovery across the grace window is spared (1.1).
328	    ///
329	    /// Returns the signoff outcome once the watch fires, or `None` if `stop` was
330	    /// set before any death was observed. `owner_pid` is the supervised harness
331	    /// pid; the poll cadence reuses the configured `pulse_period` (the same
332	    /// scheduled-supervision clock as the pulse loop).
333	    // [impl->REQ-DAEMON-4]
334	    // [impl->REQ-HAZARD-STALE-SIGNOFF-SENTINEL]
335	    pub fn run_orphan_watch(
336	        &self,
337	        session_id: Option<&str>,
338	        owner_pid: u32,
339	        stop: &AtomicBool,
340	    ) -> Option<Result<SignoffOutcome, String>> {
341	        // 3.2 — sweep a stale signoff sentinel before supervising, exactly as a
342	        // listener start does, so a prior crash's sentinel can't self-trigger.
343	        self.sweep_on_start();
344	        while !stop.load(Ordering::Relaxed) {
345	            if !is_process_alive(owner_pid) {
346	                // Observed gone — run the crash teardown. The recheck re-reads the
347	                // perch's CURRENT owner, so a Self that rebound across the grace
348	                // window (binary handoff) is spared (1.1).
349	                return Some(self.supervised_crash_signoff(session_id, || self.harness_owner_gone()));
350	            }
351	            sleep_interruptible(self.cfg.pulse_period, stop);
352	        }
353	        None
354	    }
355	}
356
357	/// Sleep `period`, waking early if `stop` is set (10ms slices). Keeps the pulse
358	/// loop responsive to a kill without busy-spinning.
359	fn sleep_interruptible(period: Duration, stop: &AtomicBool) {
360	    const SLICE: Duration = Duration::from_millis(10);
361	    let mut left = period;
362	    while left > Duration::ZERO {
363	        if stop.load(Ordering::Relaxed) {
364	            return;
365	        }
366	        let nap = left.min(SLICE);
367	        std::thread::sleep(nap);
368	        left = left.saturating_sub(nap);
369	    }
370	}
371
372	#[cfg(test)]
373	mod tests {
374	    use super::*;
375	    use crate::test_home::with_home;
376	    use std::sync::atomic::AtomicUsize;
377
378	    // A live-capable manifest: a Psyche init template + a commune dir to watch.
379	    // The Psyche command is a trivial no-op (we never spawn it in these tests;
380	    // we exercise the pulse/ingest loop, which does not launch the Psyche).
381	    #[cfg(windows)]
382	    const NOOP_CMD: &str = "cmd /C rem";
383	    #[cfg(unix)]
384	    const NOOP_CMD: &str = "true";
385
386	    fn live_manifest(commune_dir: &std::path::Path) -> Manifest {
387	        let dir = commune_dir.to_string_lossy().replace('\\', "/");
388	        let toml = format!(
389	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
390	             [session]\ncommune_dir=\"{dir}\"\n\n\
391	             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
392	        );
393	        Manifest::from_toml_str(&toml).unwrap()
394	    }
395
396	    // A stdin→stdout passthrough so the echo-commune summarizer succeeds and
397	    // writes a real drop file — lets the signoff tests observe that the echo
398	    // (3.3) actually ran before any teardown.
399	    #[cfg(windows)]
400	    const PASSTHROUGH: &str = "sort";
401	    #[cfg(unix)]
402	    const PASSTHROUGH: &str = "cat";
403
404	    // A live manifest that ALSO declares an echo-commune summarizer, so
405	    // `fire_echo` (and thus the signoff echo step) succeeds and writes a commune.
406	    fn live_manifest_with_echo(commune_dir: &std::path::Path) -> Manifest {
407	        let dir = commune_dir.to_string_lossy().replace('\\', "/");
408	        let toml = format!(
409	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
410	             [session]\ncommune_dir=\"{dir}\"\n\n\
411	             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n\n\
412	             [session.echo_commune]\ncommand='{PASSTHROUGH}'\n"
413	        );
414	        Manifest::from_toml_str(&toml).unwrap()
415	    }
416
417	    /// Create the perch dir + a `ready` marker for `id`, recording `parent_pid`
418	    /// as the supervised harness owner. Returns the perch path.
419	    fn seed_supervised_perch(id: &str, parent_pid: u32) -> std::path::PathBuf {
420	        use spt_store::info::{write_info, InfoJson};
421	        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
422	        std::fs::create_dir_all(&perch_path).unwrap();
423	        let mut info = InfoJson::new(id, "epoch-ms:0", parent_pid, "sid-1", "ready");
424	        info.parent_pid = Some(parent_pid);
425	        write_info(&perch_path, &info).unwrap();
426	        let ready = perch::resolve_ready_file(id, ParentHint::Infer);
427	        if let Some(p) = ready.parent() {
428	            std::fs::create_dir_all(p).unwrap();
429	        }
430	        std::fs::write(&ready, "").unwrap();
431	        perch_path
432	    }
433
434	    // A pid that is never a real allocation (above Linux pid_max / Windows
435	    // ranges; positive as i32) — a deterministic stand-in for a dead owner.
436	    const DEAD_PID: u32 = 2_000_000_000;
437
438	    #[test] // [unit->REQ-DAEMON-1] a daemon-hosted pulse tick ingests a commune drop.
439	    fn pulse_tick_ingests_drop() {
440	        with_home(|_| {
441	            let drops = tempfile::tempdir().unwrap();
442	            let manifest = live_manifest(drops.path());
443	            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
444	                .expect("live host");
445
446	            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();
447	            let report = host.pulse_tick(Some("sid-1")).expect("tick");
448	            assert_eq!(report.ingested.len(), 1, "the drop was folded in");
449	            assert!(!report.echo_fired, "no echo gate armed");
450	            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
451	        });
452	    }
453
454	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted Psyche derives
455	    // liveness from the daemon `status`, not its summarizer pid: spawning marks
456	    // the perch `online`, so a dead summarizer subprocess does NOT flip it
457	    // offline; `mark_offline` is the only thing that does.
458	    #[test]
459	    fn daemon_hosted_psyche_liveness_is_status_not_pid() {
460	        use spt_store::info::{read_info, write_info};
461	        use spt_store::liveness::{is_perch_alive, STATUS_ONLINE};
462
463	        // A pid that is never a real allocation — stands in for the exited
464	        // summarizer subprocess (deterministic; no spawn-reap timing flake).
465	        const DEAD_PID: u32 = 2_000_000_000;
466
467	        with_home(|_| {
468	            let drops = tempfile::tempdir().unwrap();
469	            let manifest = live_manifest(drops.path());
470	            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
471	                .expect("live host");
472
473	            // Spawn the Psyche — the host binds the nested perch and marks it online.
474	            let handle = host.spawn_psyche(Some("sid-1"), Some("pulse")).expect("spawn psyche");
475	            assert_eq!(
476	                read_info(&handle.perch_path).expect("info.json").status.as_deref(),
477	                Some(STATUS_ONLINE),
478	                "the daemon marked the hosted Psyche online"
479	            );
480
481	            // Simulate the summarizer subprocess exiting: its recorded pid is now
482	            // dead, but the daemon `status` stays online.
483	            let mut rec = read_info(&handle.perch_path).unwrap();
484	            rec.pid = spt_store::info::PidValue::Numeric(DEAD_PID);
485	            write_info(&handle.perch_path, &rec).unwrap();
486	            assert!(
487	                is_perch_alive(&handle.perch_path),
488	                "dead summarizer pid must NOT flip a daemon-hosted perch offline"
489	            );
490
491	            // Only an explicit teardown marks it offline.
492	            BrainLifecycle::mark_offline(&handle.perch_path);
493	            assert!(!is_perch_alive(&handle.perch_path), "mark_offline flips it offline");
494	        });
495	    }
496
497	    #[test] // [unit->REQ-DAEMON-1] the scheduled loop is config-paced + killable.
498	    fn pulse_loop_is_config_paced_and_killable() {
499	        with_home(|_| {
500	            let drops = tempfile::tempdir().unwrap();
501	            let manifest = live_manifest(drops.path());
502	            // Fast period so the test is quick; proves the period is config-driven.
503	            let cfg = DaemonConfig { pulse_period: Duration::from_millis(15), ..DaemonConfig::default() };
504	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
505	            assert_eq!(host.pulse_period(), Duration::from_millis(15));
506
507	            let stop = AtomicBool::new(false);
508	            let ticks = AtomicUsize::new(0);
509	            // Stop the loop from inside on_tick after a few ticks — proves the
510	            // loop actually schedules repeated ticks and honors the stop flag.
511	            host.run_pulse_loop(None, &stop, |_| {
512	                if ticks.fetch_add(1, Ordering::Relaxed) + 1 >= 3 {
513	                    stop.store(true, Ordering::Relaxed);
514	                }
515	            });
516	            assert!(ticks.load(Ordering::Relaxed) >= 3, "loop ran repeated ticks");
517	        });
518	    }
519
520	    // [unit->REQ-DAEMON-4] the supervised-crash path runs the SAME ordering as a
521	    // graceful teardown: the echo-commune (3.3) fires and the perch is torn down
522	    // when the post-grace recheck confirms the harness owner is still gone.
523	    #[test]
524	    fn crash_signoff_tears_down_when_still_gone() {
525	        with_home(|_| {
526	            let drops = tempfile::tempdir().unwrap();
527	            let manifest = live_manifest_with_echo(drops.path());
528	            // A short crash_grace keeps the test quick (the grace wait is real).
529	            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
530	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
531
532	            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
533	            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
534	            std::fs::write(&ready, "").unwrap();
535
536	            // still_gone = true: the Self did not recover across the grace window.
537	            let outcome = host.supervised_crash_signoff(Some("sid-1"), || true).expect("signoff");
538	            assert!(outcome.echo_ran, "echo-before-signoff (3.3) must have run");
539	            assert!(outcome.signed_off, "still-gone Self is torn down");
540	            assert!(
541	                drops.path().join("doyle-commune.md").exists(),
542	                "the echo wrote a final-delta commune before teardown"
543	            );
544	            assert!(!ready.exists(), "teardown removed the ready marker");
545	        });
546	    }
547
548	    // [unit->REQ-HAZARD-GRACE-BEFORE-SIGNOFF] a Self that recovers across the
549	    // grace window is NOT signed off — the phantom-INIT_SIGNOFF bug (1.1). The
550	    // echo still fires (it precedes the recheck), but no teardown happens.
551	    #[test]
552	    fn crash_signoff_spares_recovered_self() {
553	        with_home(|_| {
554	            let drops = tempfile::tempdir().unwrap();
555	            let manifest = live_manifest_with_echo(drops.path());
556	            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
557	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
558
559	            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
560	            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
561	            std::fs::write(&ready, "").unwrap();
562
563	            // still_gone = false: the Self came back alive during the grace wait.
564	            let outcome = host.supervised_crash_signoff(Some("sid-1"), || false).expect("signoff");
565	            assert!(outcome.echo_ran, "echo precedes the recheck, so it still ran");
566	            assert!(!outcome.signed_off, "a recovered Self must never be signed off");
567	            assert!(ready.exists(), "the live Self's ready marker is left intact");
568	        });
569	    }
570
571	    // [unit->REQ-DAEMON-4] liveness for the supervised Self anchors on the stable
572	    // harness pid (`parent_pid`), never an ephemeral poll pid (2.1): a dead owner
573	    // reads gone, a live owner reads present, and no recorded owner reads gone.
574	    #[test]
575	    fn harness_owner_gone_anchors_on_parent_pid() {
576	        with_home(|_| {
577	            let drops = tempfile::tempdir().unwrap();
578	            let manifest = live_manifest(drops.path());
579	            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
580	                .expect("live host");
581
582	            // No perch yet → no recorded owner → treated as gone.
583	            assert!(host.harness_owner_gone(), "an unsupervised perch reads gone");
584
585	            // A live owner (this very process) reads present.
586	            seed_supervised_perch("doyle", std::process::id());
587	            assert!(!host.harness_owner_gone(), "a live parent_pid reads present");
588
589	            // A dead owner reads gone.
590	            seed_supervised_perch("doyle", DEAD_PID);
591	            assert!(host.harness_owner_gone(), "a dead parent_pid reads gone");
592	        });
593	    }
594
595	    // [unit->REQ-DAEMON-4] the orphan-watch loop fires the crash signoff on an
596	    // observed owner death, sweeping a stale sentinel first (3.2) and tearing the
597	    // perch down once the recheck confirms the dead owner.
598	    #[test]
599	    fn orphan_watch_fires_on_dead_owner() {
600	        with_home(|_| {
601	            let drops = tempfile::tempdir().unwrap();
602	            let manifest = live_manifest_with_echo(drops.path());
603	            let cfg = DaemonConfig {
604	                pulse_period: Duration::from_millis(10),
605	                crash_grace: Duration::from_millis(10),
606	                ..DaemonConfig::default()
607	            };
608	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
609
610	            // Supervise a perch whose harness owner is already dead.
611	            seed_supervised_perch("doyle", DEAD_PID);
612	            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
613	            assert!(ready.exists(), "precondition: ready marker present");
614
615	            let stop = AtomicBool::new(false);
616	            let outcome = host
617	                .run_orphan_watch(Some("sid-1"), DEAD_PID, &stop)
618	                .expect("watch fired")
619	                .expect("signoff ok");
620	            assert!(outcome.echo_ran && outcome.signed_off, "dead owner → echo + teardown");
621	            assert!(!ready.exists(), "the orphan-watch tore the perch down");
622	        });
623	    }
624
625	    // [unit->REQ-DAEMON-4] the orphan-watch honors the stop flag and never fires
626	    // while the supervised owner is alive (no false-positive teardown).
627	    #[test]
628	    fn orphan_watch_honors_stop_for_live_owner() {
629	        with_home(|_| {
630	            let drops = tempfile::tempdir().unwrap();
631	            let manifest = live_manifest_with_echo(drops.path());
632	            let cfg = DaemonConfig { pulse_period: Duration::from_millis(10), ..DaemonConfig::default() };
633	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
634
635	            seed_supervised_perch("doyle", std::process::id());
636	            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
637
638	            // Owner is this (alive) process; stop is already set → loop exits with
639	            // no signoff and leaves the perch untouched.
640	            let stop = AtomicBool::new(true);
641	            let outcome = host.run_orphan_watch(Some("sid-1"), std::process::id(), &stop);
642	            assert!(outcome.is_none(), "a live owner under stop never signs off");
643	            assert!(ready.exists(), "a live Self's perch is left intact");
644	        });
645	    }
646	}
647

//! 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 spt_store::proc::is_process_alive;

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 graceful path is an
    /// **intentional** shutdown, so it uses zero grace + an always-gone recheck —
    /// the Self is leaving on purpose, there is nothing to recover. The
    /// supervised-crash path ([`supervised_crash_signoff`](Self::supervised_crash_signoff))
    /// supplies the real grace + liveness recheck for an *un*intentional death.
    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
        self.signoff_with(session_id, Duration::ZERO, || true)
    }

    /// Execute the **supervised-crash** signoff (B7, orphan-watch) — the
    /// counterpart of [`graceful_signoff`](Self::graceful_signoff) for an
    /// *unexpected* harness death. It runs the identical load-bearing ordering —
    /// echo-commune (3.3) → grace wait (1.1) → liveness recheck → teardown — but
    /// with the **real** grace window from config and the **real** recheck
    /// `still_gone`. A Self that recovered across the grace window (a binary
    /// handoff relaunch, a brief stale poll) makes `still_gone` false and is
    /// **spared** — the phantom-INIT_SIGNOFF bug (1.1).
    ///
    /// `still_gone` is the post-grace liveness verdict (true ⇒ the harness owner
    /// is still gone ⇒ tear down). [`run_orphan_watch`](Self::run_orphan_watch)
    /// supplies the production recheck ([`harness_owner_gone`](Self::harness_owner_gone));
    /// it is injected here so the ordering is testable without a real pid.
    // [impl->REQ-DAEMON-4]
    // [impl->REQ-HAZARD-GRACE-BEFORE-SIGNOFF]
    // [impl->REQ-HAZARD-ECHO-BEFORE-SIGNOFF]
    pub fn supervised_crash_signoff(
        &self,
        session_id: Option<&str>,
        still_gone: impl FnOnce() -> bool,
    ) -> Result<SignoffOutcome, String> {
        self.signoff_with(session_id, self.cfg.crash_grace, still_gone)
    }

    /// The shared signoff wiring behind both the graceful and supervised-crash
    /// paths. The only two things that differ between them are exactly the two
    /// the 1.1 hazard turns on — the `grace` window and the `recheck` verdict — so
    /// they are the only parameters; the echo (3.3) and teardown effects are
    /// identical. Keeping one body guarantees the crash path can never drift out
    /// of the echo-before-teardown ordering the graceful path proves.
    fn signoff_with(
        &self,
        session_id: Option<&str>,
        grace: Duration,
        recheck: impl FnOnce() -> bool,
    ) -> 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,
            grace,
            || {
                if let Err(e) = self.fire_echo(session_id) {
                    echo_err = Some(e);
                }
                Ok(())
            },
            recheck,
            || {
                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)
    }

    /// Whether the supervised Self's **harness owner** is gone. Anchors liveness on
    /// the stable harness-session pid (`info.json` `parent_pid`), never an
    /// ephemeral poll pid (KNOWN-HAZARDS 2.1): a recycled ephemeral pid could read
    /// a foreign process as the Self and mask a real death, or flip a live Self
    /// dead. Re-reads the perch fresh so a Self that **rebound** under a new
    /// harness (a recovery) — recording a fresh, live `parent_pid` — reads alive.
    /// A perch with no recorded owner is treated as gone (nothing supervises it).
    // [impl->REQ-DAEMON-4]
    pub fn harness_owner_gone(&self) -> bool {
        let path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
        match spt_store::info::read_info(&path).and_then(|i| i.parent_pid) {
            Some(pid) => !is_process_alive(pid),
            None => true,
        }
    }

    /// Supervise a harness-hosted Self and carry the graceful ordering into the
    /// **supervised-crash** path the always-on daemon now enables (B7). On start it
    /// sweeps any stale signoff sentinel (3.2) so a prior generation's leftover
    /// never reads as a live signoff. It then polls the harness owner pid each
    /// cycle (anchored on `parent_pid`, 2.1); on the first observed death it runs
    /// [`supervised_crash_signoff`](Self::supervised_crash_signoff) with the real
    /// grace + a fresh liveness recheck ([`harness_owner_gone`](Self::harness_owner_gone)),
    /// so a transient recovery across the grace window is spared (1.1).
    ///
    /// Returns the signoff outcome once the watch fires, or `None` if `stop` was
    /// set before any death was observed. `owner_pid` is the supervised harness
    /// pid; the poll cadence reuses the configured `pulse_period` (the same
    /// scheduled-supervision clock as the pulse loop).
    // [impl->REQ-DAEMON-4]
    // [impl->REQ-HAZARD-STALE-SIGNOFF-SENTINEL]
    pub fn run_orphan_watch(
        &self,
        session_id: Option<&str>,
        owner_pid: u32,
        stop: &AtomicBool,
    ) -> Option<Result<SignoffOutcome, String>> {
        // 3.2 — sweep a stale signoff sentinel before supervising, exactly as a
        // listener start does, so a prior crash's sentinel can't self-trigger.
        self.sweep_on_start();
        while !stop.load(Ordering::Relaxed) {
            if !is_process_alive(owner_pid) {
                // Observed gone — run the crash teardown. The recheck re-reads the
                // perch's CURRENT owner, so a Self that rebound across the grace
                // window (binary handoff) is spared (1.1).
                return Some(self.supervised_crash_signoff(session_id, || self.harness_owner_gone()));
            }
            sleep_interruptible(self.cfg.pulse_period, stop);
        }
        None
    }
}

/// 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()
    }

    // A stdin→stdout passthrough so the echo-commune summarizer succeeds and
    // writes a real drop file — lets the signoff tests observe that the echo
    // (3.3) actually ran before any teardown.
    #[cfg(windows)]
    const PASSTHROUGH: &str = "sort";
    #[cfg(unix)]
    const PASSTHROUGH: &str = "cat";

    // A live manifest that ALSO declares an echo-commune summarizer, so
    // `fire_echo` (and thus the signoff echo step) succeeds and writes a commune.
    fn live_manifest_with_echo(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\n\
             [session.echo_commune]\ncommand='{PASSTHROUGH}'\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    /// Create the perch dir + a `ready` marker for `id`, recording `parent_pid`
    /// as the supervised harness owner. Returns the perch path.
    fn seed_supervised_perch(id: &str, parent_pid: u32) -> std::path::PathBuf {
        use spt_store::info::{write_info, InfoJson};
        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&perch_path).unwrap();
        let mut info = InfoJson::new(id, "epoch-ms:0", parent_pid, "sid-1", "ready");
        info.parent_pid = Some(parent_pid);
        write_info(&perch_path, &info).unwrap();
        let ready = perch::resolve_ready_file(id, ParentHint::Infer);
        if let Some(p) = ready.parent() {
            std::fs::create_dir_all(p).unwrap();
        }
        std::fs::write(&ready, "").unwrap();
        perch_path
    }

    // A pid that is never a real allocation (above Linux pid_max / Windows
    // ranges; positive as i32) — a deterministic stand-in for a dead owner.
    const DEAD_PID: u32 = 2_000_000_000;

    #[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");
        });
    }

    // [unit->REQ-DAEMON-4] the supervised-crash path runs the SAME ordering as a
    // graceful teardown: the echo-commune (3.3) fires and the perch is torn down
    // when the post-grace recheck confirms the harness owner is still gone.
    #[test]
    fn crash_signoff_tears_down_when_still_gone() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            // A short crash_grace keeps the test quick (the grace wait is real).
            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
            std::fs::write(&ready, "").unwrap();

            // still_gone = true: the Self did not recover across the grace window.
            let outcome = host.supervised_crash_signoff(Some("sid-1"), || true).expect("signoff");
            assert!(outcome.echo_ran, "echo-before-signoff (3.3) must have run");
            assert!(outcome.signed_off, "still-gone Self is torn down");
            assert!(
                drops.path().join("doyle-commune.md").exists(),
                "the echo wrote a final-delta commune before teardown"
            );
            assert!(!ready.exists(), "teardown removed the ready marker");
        });
    }

    // [unit->REQ-HAZARD-GRACE-BEFORE-SIGNOFF] a Self that recovers across the
    // grace window is NOT signed off — the phantom-INIT_SIGNOFF bug (1.1). The
    // echo still fires (it precedes the recheck), but no teardown happens.
    #[test]
    fn crash_signoff_spares_recovered_self() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
            std::fs::write(&ready, "").unwrap();

            // still_gone = false: the Self came back alive during the grace wait.
            let outcome = host.supervised_crash_signoff(Some("sid-1"), || false).expect("signoff");
            assert!(outcome.echo_ran, "echo precedes the recheck, so it still ran");
            assert!(!outcome.signed_off, "a recovered Self must never be signed off");
            assert!(ready.exists(), "the live Self's ready marker is left intact");
        });
    }

    // [unit->REQ-DAEMON-4] liveness for the supervised Self anchors on the stable
    // harness pid (`parent_pid`), never an ephemeral poll pid (2.1): a dead owner
    // reads gone, a live owner reads present, and no recorded owner reads gone.
    #[test]
    fn harness_owner_gone_anchors_on_parent_pid() {
        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");

            // No perch yet → no recorded owner → treated as gone.
            assert!(host.harness_owner_gone(), "an unsupervised perch reads gone");

            // A live owner (this very process) reads present.
            seed_supervised_perch("doyle", std::process::id());
            assert!(!host.harness_owner_gone(), "a live parent_pid reads present");

            // A dead owner reads gone.
            seed_supervised_perch("doyle", DEAD_PID);
            assert!(host.harness_owner_gone(), "a dead parent_pid reads gone");
        });
    }

    // [unit->REQ-DAEMON-4] the orphan-watch loop fires the crash signoff on an
    // observed owner death, sweeping a stale sentinel first (3.2) and tearing the
    // perch down once the recheck confirms the dead owner.
    #[test]
    fn orphan_watch_fires_on_dead_owner() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            let cfg = DaemonConfig {
                pulse_period: Duration::from_millis(10),
                crash_grace: Duration::from_millis(10),
                ..DaemonConfig::default()
            };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            // Supervise a perch whose harness owner is already dead.
            seed_supervised_perch("doyle", DEAD_PID);
            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
            assert!(ready.exists(), "precondition: ready marker present");

            let stop = AtomicBool::new(false);
            let outcome = host
                .run_orphan_watch(Some("sid-1"), DEAD_PID, &stop)
                .expect("watch fired")
                .expect("signoff ok");
            assert!(outcome.echo_ran && outcome.signed_off, "dead owner → echo + teardown");
            assert!(!ready.exists(), "the orphan-watch tore the perch down");
        });
    }

    // [unit->REQ-DAEMON-4] the orphan-watch honors the stop flag and never fires
    // while the supervised owner is alive (no false-positive teardown).
    #[test]
    fn orphan_watch_honors_stop_for_live_owner() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            let cfg = DaemonConfig { pulse_period: Duration::from_millis(10), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            seed_supervised_perch("doyle", std::process::id());
            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);

            // Owner is this (alive) process; stop is already set → loop exits with
            // no signoff and leaves the perch untouched.
            let stop = AtomicBool::new(true);
            let outcome = host.run_orphan_watch(Some("sid-1"), std::process::id(), &stop);
            assert!(outcome.is_none(), "a live owner under stop never signs off");
            assert!(ready.exists(), "a live Self's perch is left intact");
        });
    }
}


NOTE FROM THEUSER: proceed with next steps.


Read brain.rs + daemon.rs to see how Brain (PTY) and BrainLifecycle compose for the E2E.


1	//! The brain (B2) — the restartable logic half that attaches to the broker.
2	//!
3	//! The brain holds all the volatile logic; it may be killed and restarted at
4	//! any moment (a routine self-update swaps it; a crash kills it). It connects to
5	//! the stable [`crate::broker::Broker`] over the versioned IPC, drives sessions
6	//! (spawn / input / resize / kill), and consumes their output. Two invariants
7	//! this type encodes:
8	//!
9	//! - **`gen_start = now()` on cold-start *and* handoff (KNOWN-HAZARDS 2.4).**
10	//!   The wall-clock generation stamp is taken fresh on every start. A handed-off
11	//!   brain rehydrates its resume cursor and generation *counter* from prior
12	//!   state, but its `gen_start` is `now()`, **never** the stale value from that
13	//!   state — a rehydrated `gen_start` would fire time-based discriminators on
14	//!   the new generation. [`Brain::handoff`] takes the prior state by value yet
15	//!   ignores its `gen_start_ms`.
16	//! - **Gapless, exactly-once resume.** The brain tracks `next_seq` — the next
17	//!   output sequence it still needs. On handoff it `subscribe`s from that cursor;
18	//!   the broker replays buffered output from there. [`Brain::read_event`] accepts
19	//!   only the contiguous next `seq` (advancing the cursor), silently drops an
20	//!   already-seen `seq` (a replay duplicate), and treats a forward gap as a hard
21	//!   error — so a lost chunk can never pass silently.
22	// [impl->REQ-HAZARD-GEN-START-NOW]
23	// [impl->REQ-DAEMON-2]
24
25	use std::io;
26	use std::sync::atomic::{AtomicBool, Ordering};
27	use std::time::{SystemTime, UNIX_EPOCH};
28
29	use interprocess::local_socket::Stream;
30
31	use crate::codec::{read_frame, write_frame};
32	use crate::digest::{ingest_chunk, DigestEngine};
33	use crate::digesthub::DigestHub;
34	use crate::frame::{Envelope, Role};
35	use crate::msg::{
36	    decode_bytes, encode_bytes, AppliedEvent, ErrorEvent, ExitEvent, InputReq, KillReq, OutputEvent,
37	    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_APPLIED, KIND_ERROR, KIND_EXIT, KIND_INPUT,
38	    KIND_KILL, KIND_OUTPUT, KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
39	};
40	use crate::transport::{send_hello, LocalSocketTransport};
41
42	/// Wall-clock now in epoch milliseconds — the source for `gen_start` (matches
43	/// the epoch-ms stamping used elsewhere in the tree; no date dependency).
44	pub fn now_ms() -> u64 {
45	    SystemTime::now()
46	        .duration_since(UNIX_EPOCH)
47	        .map(|d| d.as_millis() as u64)
48	        .unwrap_or(0)
49	}
50
51	/// The brain's durable state across a handoff: enough to re-attach gaplessly and
52	/// advance the generation. `gen_start_ms` is recorded for observability but is
53	/// **deliberately not authoritative on restart** — [`Brain::handoff`] sets a
54	/// fresh `gen_start` from `now()` (2.4).
55	#[derive(Debug, Clone)]
56	pub struct BrainState {
57	    /// The session the brain was driving.
58	    pub session_id: u64,
59	    /// The generation counter (incremented on each handoff).
60	    pub generation: u64,
61	    /// The next output sequence still needed (the resume cursor).
62	    pub next_seq: u64,
63	    /// The prior generation's wall-clock start (ignored as authoritative on
64	    /// handoff — kept only to demonstrate it is *not* carried forward).
65	    pub gen_start_ms: u64,
66	}
67
68	/// A demuxed broker→brain event.
69	#[derive(Debug)]
70	pub enum BrokerEvent {
71	    /// A contiguous chunk of a session's output (the cursor has advanced past it).
72	    Output { session_id: u64, seq: u64, bytes: Vec<u8> },
73	    /// A spawn succeeded.
74	    Spawned { session_id: u64, pid: Option<u32> },
75	    /// A session's child exited.
76	    Exit { session_id: u64, code: Option<i32> },
77	    /// An idempotent effect is durably applied (or was deduped — `applied_now`
78	    /// distinguishes). The exactly-once ack for a [`Brain::send_effect`] (B5).
79	    Applied { session_id: u64, op_id: u64, applied_now: bool },
80	    /// The broker reported a command error.
81	    Error { message: String },
82	    /// A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
83	    Other(Envelope),
84	}
85
86	/// A live brain attached to a broker.
87	pub struct Brain {
88	    conn: Stream,
89	    generation: u64,
90	    gen_start_ms: u64,
91	    session_id: Option<u64>,
92	    /// The next output sequence still needed (resume cursor / dedup floor).
93	    next_seq: u64,
94	}
95
96	impl Brain {
97	    /// Cold start: connect, handshake, fresh generation 0, `gen_start = now`.
98	    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
99	        let mut conn = LocalSocketTransport::connect(name)?;
100	        send_hello(&mut conn, Role::Brain)?;
101	        Ok(Brain { conn, generation: 0, gen_start_ms: now_ms, session_id: None, next_seq: 0 })
102	    }
103
104	    /// Handoff: connect, handshake, rehydrate the resume cursor + generation
105	    /// from `prior`, but stamp `gen_start = now()` (NOT `prior.gen_start_ms` —
106	    /// the 2.4 invariant), and re-subscribe to the session from the cursor for a
107	    /// gapless resume.
108	    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
109	        let mut conn = LocalSocketTransport::connect(name)?;
110	        send_hello(&mut conn, Role::Brain)?;
111	        let mut brain = Brain {
112	            conn,
113	            generation: prior.generation + 1,
114	            gen_start_ms: now_ms, // fresh — prior.gen_start_ms is intentionally discarded
115	            session_id: Some(prior.session_id),
116	            next_seq: prior.next_seq,
117	        };
118	        brain.subscribe(prior.session_id, prior.next_seq)?;
119	        Ok(brain)
120	    }
121
122	    /// The wall-clock generation start (epoch ms) — always this generation's own
123	    /// start time, never a rehydrated value.
124	    pub fn gen_start_ms(&self) -> u64 {
125	        self.gen_start_ms
126	    }
127
128	    /// The generation counter (0 on cold start, +1 per handoff).
129	    pub fn generation(&self) -> u64 {
130	        self.generation
131	    }
132
133	    /// The next output sequence still needed (the resume cursor).
134	    pub fn next_seq(&self) -> u64 {
135	        self.next_seq
136	    }
137
138	    /// The session id the brain is driving, if any.
139	    pub fn session_id(&self) -> Option<u64> {
140	        self.session_id
141	    }
142
143	    /// Snapshot durable state for a future handoff.
144	    pub fn snapshot(&self) -> BrainState {
145	        BrainState {
146	            session_id: self.session_id.expect("snapshot before a session exists"),
147	            generation: self.generation,
148	            next_seq: self.next_seq,
149	            gen_start_ms: self.gen_start_ms,
150	        }
151	    }
152
153	    /// Spawn a session under a broker PTY; the broker auto-subscribes this
154	    /// connection. Reads until the `spawned` reply (consuming any output that
155	    /// races ahead).
156	    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
157	        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
158	        loop {
159	            match self.read_event()? {
160	                BrokerEvent::Spawned { session_id, .. } => {
161	                    self.session_id = Some(session_id);
162	                    return Ok(session_id);
163	                }
164	                BrokerEvent::Error { message } => {
165	                    return Err(io::Error::other(message));
166	                }
167	                _ => continue,
168	            }
169	        }
170	    }
171
172	    /// Inject input bytes into the current session (send-keys), the **legacy
173	    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
174	    /// of an effect that originates from a durable source (a spool row, a
175	    /// sequenced injection), use [`Brain::send_effect`].
176	    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
177	        let sid = self.require_session()?;
178	        self.send(
179	            KIND_INPUT,
180	            serde_json::to_value(InputReq { session_id: sid, data_b64: encode_bytes(bytes), op_id: None })
181	                .expect("InputReq serializes"),
182	        )
183	    }
184
185	    /// Inject input **idempotently**: the write carries the durable `op_id` so a
186	    /// replay after a brain crash is deduped at the broker (exactly-once, B5).
187	    /// `op_id` must come from a durable source and be reused verbatim on replay —
188	    /// the same logical effect must always carry the same id, so the broker's
189	    /// applied-set recognizes the re-drive. The broker answers with a
190	    /// [`BrokerEvent::Applied`] for `(session, op_id)`.
191	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
192	    pub fn send_effect(&mut self, op_id: u64, bytes: &[u8]) -> io::Result<()> {
193	        let sid = self.require_session()?;
194	        self.send(
195	            KIND_INPUT,
196	            serde_json::to_value(InputReq {
197	                session_id: sid,
198	                data_b64: encode_bytes(bytes),
199	                op_id: Some(op_id),
200	            })
201	            .expect("InputReq serializes"),
202	        )
203	    }
204
205	    /// Resize the current session's PTY.
206	    pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
207	        let sid = self.require_session()?;
208	        self.send(
209	            KIND_RESIZE,
210	            serde_json::to_value(ResizeReq { session_id: sid, rows, cols })
211	                .expect("ResizeReq serializes"),
212	        )
213	    }
214
215	    /// Terminate the current session's child.
216	    pub fn kill_session(&mut self) -> io::Result<()> {
217	        let sid = self.require_session()?;
218	        self.send(
219	            KIND_KILL,
220	            serde_json::to_value(KillReq { session_id: sid }).expect("KillReq serializes"),
221	        )
222	    }
223
224	    /// Read output events until the accumulated bytes contain `needle`,
225	    /// appending all received output to `buf`. Returns the highest seq seen.
226	    /// A session `Exit` before the needle is found is an error (the child died).
227	    pub fn read_output_until(&mut self, needle: &[u8], buf: &mut Vec<u8>) -> io::Result<u64> {
228	        loop {
229	            if contains(buf, needle) {
230	                return Ok(self.next_seq.saturating_sub(1));
231	            }
232	            match self.read_event()? {
233	                BrokerEvent::Output { bytes, .. } => buf.extend_from_slice(&bytes),
234	                BrokerEvent::Exit { .. } => {
235	                    return Err(io::Error::new(
236	                        io::ErrorKind::UnexpectedEof,
237	                        "session exited before the expected output arrived",
238	                    ));
239	                }
240	                BrokerEvent::Error { message } => {
241	                    return Err(io::Error::other(message));
242	                }
243	                _ => {}
244	            }
245	        }
246	    }
247
248	    /// Read and demux one broker event, enforcing exactly-once contiguous output:
249	    /// accept the next `seq` (advancing the cursor), drop an already-seen `seq`
250	    /// (replay duplicate), and reject a forward gap (lost chunk).
251	    pub fn read_event(&mut self) -> io::Result<BrokerEvent> {
252	        loop {
253	            let env = read_frame(&mut self.conn)?;
254	            match env.kind.as_str() {
255	                KIND_OUTPUT => {
256	                    let ev: OutputEvent = serde_json::from_value(env.payload)
257	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
258	                    if ev.seq < self.next_seq {
259	                        continue; // already processed — a replay duplicate; dedup
260	                    }
261	                    if ev.seq > self.next_seq {
262	                        return Err(io::Error::new(
263	                            io::ErrorKind::InvalidData,
264	                            format!("output gap: got seq {} want {}", ev.seq, self.next_seq),
265	                        ));
266	                    }
267	                    self.next_seq = ev.seq + 1;
268	                    let bytes = decode_bytes(&ev.data_b64)
269	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
270	                    return Ok(BrokerEvent::Output { session_id: ev.session_id, seq: ev.seq, bytes });
271	                }
272	                KIND_SPAWNED => {
273	                    let ev: Spawned = serde_json::from_value(env.payload)
274	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
275	                    return Ok(BrokerEvent::Spawned { session_id: ev.session_id, pid: ev.pid });
276	                }
277	                KIND_EXIT => {
278	                    let ev: ExitEvent = serde_json::from_value(env.payload)
279	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
280	                    return Ok(BrokerEvent::Exit { session_id: ev.session_id, code: ev.code });
281	                }
282	                KIND_APPLIED => {
283	                    let ev: AppliedEvent = serde_json::from_value(env.payload)
284	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
285	                    return Ok(BrokerEvent::Applied {
286	                        session_id: ev.session_id,
287	                        op_id: ev.op_id,
288	                        applied_now: ev.applied_now,
289	                    });
290	                }
291	                KIND_ERROR => {
292	                    let ev: ErrorEvent = serde_json::from_value(env.payload)
293	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
294	                    return Ok(BrokerEvent::Error { message: ev.message });
295	                }
296	                _ => return Ok(BrokerEvent::Other(env)),
297	            }
298	        }
299	    }
300
301	    /// Attach this (freshly-connected) brain to an **existing** broker session's
302	    /// output from `from_seq`, e.g. a dedicated digest-feed subscriber that did
303	    /// not spawn the session itself. Sets the resume cursor + session, then
304	    /// subscribes (the broker replays from `from_seq`, then streams live). Unlike
305	    /// [`Brain::handoff`] this does not touch the generation — it is a *second*
306	    /// reader of a session another brain drives, not a logic handoff.
307	    pub fn attach(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
308	        self.session_id = Some(session_id);
309	        self.next_seq = from_seq;
310	        self.subscribe(session_id, from_seq)
311	    }
312
313	    /// Run the **PTY-digest feed** for `endpoint_id` (B8): read this session's
314	    /// output events and fold each chunk through `engine`, publishing every change
315	    /// to `hub` (and persisting completed turns when the engine opted in) via
316	    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is
317	    /// logic, so it lives here, never in the broker (ADR-0004 §B). Returns when
318	    /// the session exits, `stop` is set (checked between events), or the broker
319	    /// connection drops. A non-output event other than `exit` is ignored (the
320	    /// feed only cares about the byte stream).
321	    // [impl->REQ-TERM-4]
322	    pub fn run_digest_feed(
323	        &mut self,
324	        engine: &mut DigestEngine,
325	        hub: &DigestHub,
326	        endpoint_id: &str,
327	        stop: &AtomicBool,
328	    ) -> io::Result<()> {
329	        loop {
330	            if stop.load(Ordering::Relaxed) {
331	                return Ok(());
332	            }
333	            match self.read_event()? {
334	                BrokerEvent::Output { bytes, .. } => {
335	                    ingest_chunk(engine, hub, endpoint_id, &bytes)
336	                }
337	                BrokerEvent::Exit { .. } => return Ok(()),
338	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
339	                _ => {}
340	            }
341	        }
342	    }
343
344	    /// Re-attach to a session's output from `from_seq` (handoff resume).
345	    fn subscribe(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
346	        self.send(
347	            KIND_SUBSCRIBE,
348	            serde_json::to_value(SubscribeReq { session_id, from_seq })
349	                .expect("SubscribeReq serializes"),
350	        )
351	    }
352
353	    fn send(&mut self, kind: &str, payload: serde_json::Value) -> io::Result<()> {
354	        write_frame(&mut self.conn, &Envelope::new(kind, payload))
355	    }
356
357	    fn require_session(&self) -> io::Result<u64> {
358	        self.session_id
359	            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no active session"))
360	    }
361	}
362
363	/// Whether `haystack` contains the contiguous `needle`.
364	fn contains(haystack: &[u8], needle: &[u8]) -> bool {
365	    if needle.is_empty() {
366	        return true;
367	    }
368	    haystack.windows(needle.len()).any(|w| w == needle)
369	}
370

//! The brain (B2) — the restartable logic half that attaches to the broker.
//!
//! The brain holds all the volatile logic; it may be killed and restarted at
//! any moment (a routine self-update swaps it; a crash kills it). It connects to
//! the stable [`crate::broker::Broker`] over the versioned IPC, drives sessions
//! (spawn / input / resize / kill), and consumes their output. Two invariants
//! this type encodes:
//!
//! - **`gen_start = now()` on cold-start *and* handoff (KNOWN-HAZARDS 2.4).**
//!   The wall-clock generation stamp is taken fresh on every start. A handed-off
//!   brain rehydrates its resume cursor and generation *counter* from prior
//!   state, but its `gen_start` is `now()`, **never** the stale value from that
//!   state — a rehydrated `gen_start` would fire time-based discriminators on
//!   the new generation. [`Brain::handoff`] takes the prior state by value yet
//!   ignores its `gen_start_ms`.
//! - **Gapless, exactly-once resume.** The brain tracks `next_seq` — the next
//!   output sequence it still needs. On handoff it `subscribe`s from that cursor;
//!   the broker replays buffered output from there. [`Brain::read_event`] accepts
//!   only the contiguous next `seq` (advancing the cursor), silently drops an
//!   already-seen `seq` (a replay duplicate), and treats a forward gap as a hard
//!   error — so a lost chunk can never pass silently.
// [impl->REQ-HAZARD-GEN-START-NOW]
// [impl->REQ-DAEMON-2]

use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use interprocess::local_socket::Stream;

use crate::codec::{read_frame, write_frame};
use crate::digest::{ingest_chunk, DigestEngine};
use crate::digesthub::DigestHub;
use crate::frame::{Envelope, Role};
use crate::msg::{
    decode_bytes, encode_bytes, AppliedEvent, ErrorEvent, ExitEvent, InputReq, KillReq, OutputEvent,
    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_APPLIED, KIND_ERROR, KIND_EXIT, KIND_INPUT,
    KIND_KILL, KIND_OUTPUT, KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
};
use crate::transport::{send_hello, LocalSocketTransport};

/// Wall-clock now in epoch milliseconds — the source for `gen_start` (matches
/// the epoch-ms stamping used elsewhere in the tree; no date dependency).
pub fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// The brain's durable state across a handoff: enough to re-attach gaplessly and
/// advance the generation. `gen_start_ms` is recorded for observability but is
/// **deliberately not authoritative on restart** — [`Brain::handoff`] sets a
/// fresh `gen_start` from `now()` (2.4).
#[derive(Debug, Clone)]
pub struct BrainState {
    /// The session the brain was driving.
    pub session_id: u64,
    /// The generation counter (incremented on each handoff).
    pub generation: u64,
    /// The next output sequence still needed (the resume cursor).
    pub next_seq: u64,
    /// The prior generation's wall-clock start (ignored as authoritative on
    /// handoff — kept only to demonstrate it is *not* carried forward).
    pub gen_start_ms: u64,
}

/// A demuxed broker→brain event.
#[derive(Debug)]
pub enum BrokerEvent {
    /// A contiguous chunk of a session's output (the cursor has advanced past it).
    Output { session_id: u64, seq: u64, bytes: Vec<u8> },
    /// A spawn succeeded.
    Spawned { session_id: u64, pid: Option<u32> },
    /// A session's child exited.
    Exit { session_id: u64, code: Option<i32> },
    /// An idempotent effect is durably applied (or was deduped — `applied_now`
    /// distinguishes). The exactly-once ack for a [`Brain::send_effect`] (B5).
    Applied { session_id: u64, op_id: u64, applied_now: bool },
    /// The broker reported a command error.
    Error { message: String },
    /// A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
    Other(Envelope),
}

/// A live brain attached to a broker.
pub struct Brain {
    conn: Stream,
    generation: u64,
    gen_start_ms: u64,
    session_id: Option<u64>,
    /// The next output sequence still needed (resume cursor / dedup floor).
    next_seq: u64,
}

impl Brain {
    /// Cold start: connect, handshake, fresh generation 0, `gen_start = now`.
    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
        let mut conn = LocalSocketTransport::connect(name)?;
        send_hello(&mut conn, Role::Brain)?;
        Ok(Brain { conn, generation: 0, gen_start_ms: now_ms, session_id: None, next_seq: 0 })
    }

    /// Handoff: connect, handshake, rehydrate the resume cursor + generation
    /// from `prior`, but stamp `gen_start = now()` (NOT `prior.gen_start_ms` —
    /// the 2.4 invariant), and re-subscribe to the session from the cursor for a
    /// gapless resume.
    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
        let mut conn = LocalSocketTransport::connect(name)?;
        send_hello(&mut conn, Role::Brain)?;
        let mut brain = Brain {
            conn,
            generation: prior.generation + 1,
            gen_start_ms: now_ms, // fresh — prior.gen_start_ms is intentionally discarded
            session_id: Some(prior.session_id),
            next_seq: prior.next_seq,
        };
        brain.subscribe(prior.session_id, prior.next_seq)?;
        Ok(brain)
    }

    /// The wall-clock generation start (epoch ms) — always this generation's own
    /// start time, never a rehydrated value.
    pub fn gen_start_ms(&self) -> u64 {
        self.gen_start_ms
    }

    /// The generation counter (0 on cold start, +1 per handoff).
    pub fn generation(&self) -> u64 {
        self.generation
    }

    /// The next output sequence still needed (the resume cursor).
    pub fn next_seq(&self) -> u64 {
        self.next_seq
    }

    /// The session id the brain is driving, if any.
    pub fn session_id(&self) -> Option<u64> {
        self.session_id
    }

    /// Snapshot durable state for a future handoff.
    pub fn snapshot(&self) -> BrainState {
        BrainState {
            session_id: self.session_id.expect("snapshot before a session exists"),
            generation: self.generation,
            next_seq: self.next_seq,
            gen_start_ms: self.gen_start_ms,
        }
    }

    /// Spawn a session under a broker PTY; the broker auto-subscribes this
    /// connection. Reads until the `spawned` reply (consuming any output that
    /// races ahead).
    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
        loop {
            match self.read_event()? {
                BrokerEvent::Spawned { session_id, .. } => {
                    self.session_id = Some(session_id);
                    return Ok(session_id);
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => continue,
            }
        }
    }

    /// Inject input bytes into the current session (send-keys), the **legacy
    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
    /// of an effect that originates from a durable source (a spool row, a
    /// sequenced injection), use [`Brain::send_effect`].
    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_INPUT,
            serde_json::to_value(InputReq { session_id: sid, data_b64: encode_bytes(bytes), op_id: None })
                .expect("InputReq serializes"),
        )
    }

    /// Inject input **idempotently**: the write carries the durable `op_id` so a
    /// replay after a brain crash is deduped at the broker (exactly-once, B5).
    /// `op_id` must come from a durable source and be reused verbatim on replay —
    /// the same logical effect must always carry the same id, so the broker's
    /// applied-set recognizes the re-drive. The broker answers with a
    /// [`BrokerEvent::Applied`] for `(session, op_id)`.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    pub fn send_effect(&mut self, op_id: u64, bytes: &[u8]) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_INPUT,
            serde_json::to_value(InputReq {
                session_id: sid,
                data_b64: encode_bytes(bytes),
                op_id: Some(op_id),
            })
            .expect("InputReq serializes"),
        )
    }

    /// Resize the current session's PTY.
    pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_RESIZE,
            serde_json::to_value(ResizeReq { session_id: sid, rows, cols })
                .expect("ResizeReq serializes"),
        )
    }

    /// Terminate the current session's child.
    pub fn kill_session(&mut self) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_KILL,
            serde_json::to_value(KillReq { session_id: sid }).expect("KillReq serializes"),
        )
    }

    /// Read output events until the accumulated bytes contain `needle`,
    /// appending all received output to `buf`. Returns the highest seq seen.
    /// A session `Exit` before the needle is found is an error (the child died).
    pub fn read_output_until(&mut self, needle: &[u8], buf: &mut Vec<u8>) -> io::Result<u64> {
        loop {
            if contains(buf, needle) {
                return Ok(self.next_seq.saturating_sub(1));
            }
            match self.read_event()? {
                BrokerEvent::Output { bytes, .. } => buf.extend_from_slice(&bytes),
                BrokerEvent::Exit { .. } => {
                    return Err(io::Error::new(
                        io::ErrorKind::UnexpectedEof,
                        "session exited before the expected output arrived",
                    ));
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => {}
            }
        }
    }

    /// Read and demux one broker event, enforcing exactly-once contiguous output:
    /// accept the next `seq` (advancing the cursor), drop an already-seen `seq`
    /// (replay duplicate), and reject a forward gap (lost chunk).
    pub fn read_event(&mut self) -> io::Result<BrokerEvent> {
        loop {
            let env = read_frame(&mut self.conn)?;
            match env.kind.as_str() {
                KIND_OUTPUT => {
                    let ev: OutputEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    if ev.seq < self.next_seq {
                        continue; // already processed — a replay duplicate; dedup
                    }
                    if ev.seq > self.next_seq {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            format!("output gap: got seq {} want {}", ev.seq, self.next_seq),
                        ));
                    }
                    self.next_seq = ev.seq + 1;
                    let bytes = decode_bytes(&ev.data_b64)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Output { session_id: ev.session_id, seq: ev.seq, bytes });
                }
                KIND_SPAWNED => {
                    let ev: Spawned = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Spawned { session_id: ev.session_id, pid: ev.pid });
                }
                KIND_EXIT => {
                    let ev: ExitEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Exit { session_id: ev.session_id, code: ev.code });
                }
                KIND_APPLIED => {
                    let ev: AppliedEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Applied {
                        session_id: ev.session_id,
                        op_id: ev.op_id,
                        applied_now: ev.applied_now,
                    });
                }
                KIND_ERROR => {
                    let ev: ErrorEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Error { message: ev.message });
                }
                _ => return Ok(BrokerEvent::Other(env)),
            }
        }
    }

    /// Attach this (freshly-connected) brain to an **existing** broker session's
    /// output from `from_seq`, e.g. a dedicated digest-feed subscriber that did
    /// not spawn the session itself. Sets the resume cursor + session, then
    /// subscribes (the broker replays from `from_seq`, then streams live). Unlike
    /// [`Brain::handoff`] this does not touch the generation — it is a *second*
    /// reader of a session another brain drives, not a logic handoff.
    pub fn attach(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
        self.session_id = Some(session_id);
        self.next_seq = from_seq;
        self.subscribe(session_id, from_seq)
    }

    /// Run the **PTY-digest feed** for `endpoint_id` (B8): read this session's
    /// output events and fold each chunk through `engine`, publishing every change
    /// to `hub` (and persisting completed turns when the engine opted in) via
    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is
    /// logic, so it lives here, never in the broker (ADR-0004 §B). Returns when
    /// the session exits, `stop` is set (checked between events), or the broker
    /// connection drops. A non-output event other than `exit` is ignored (the
    /// feed only cares about the byte stream).
    // [impl->REQ-TERM-4]
    pub fn run_digest_feed(
        &mut self,
        engine: &mut DigestEngine,
        hub: &DigestHub,
        endpoint_id: &str,
        stop: &AtomicBool,
    ) -> io::Result<()> {
        loop {
            if stop.load(Ordering::Relaxed) {
                return Ok(());
            }
            match self.read_event()? {
                BrokerEvent::Output { bytes, .. } => {
                    ingest_chunk(engine, hub, endpoint_id, &bytes)
                }
                BrokerEvent::Exit { .. } => return Ok(()),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => {}
            }
        }
    }

    /// Re-attach to a session's output from `from_seq` (handoff resume).
    fn subscribe(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
        self.send(
            KIND_SUBSCRIBE,
            serde_json::to_value(SubscribeReq { session_id, from_seq })
                .expect("SubscribeReq serializes"),
        )
    }

    fn send(&mut self, kind: &str, payload: serde_json::Value) -> io::Result<()> {
        write_frame(&mut self.conn, &Envelope::new(kind, payload))
    }

    fn require_session(&self) -> io::Result<u64> {
        self.session_id
            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no active session"))
    }
}

/// Whether `haystack` contains the contiguous `needle`.
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
    if needle.is_empty() {
        return true;
    }
    haystack.windows(needle.len()).any(|w| w == needle)
}


Clear picture now. `Brain`=PTY/IPC restart half; `BrainLifecycle`=spt-live re-host (Psyche/pulse/signoff). B9 E2E weaves both. Need int tags for DAEMON-1, DAEMON-2, DAEMON-4, DAEMON-HOSTED-LIVENESS. Check how integration tests isolate SPT_HOME.