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

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

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

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

use crate::config::DaemonConfig;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


NOTE FROM THE USER: proceed with next steps.


Now study the spt-live signoff seam + the crash-detection path in the brain/daemon.


1	//! signoff + boundary lifecycle (M2b T6) — the load-bearing ordering invariants.
2	//!
3	//! These are the sister project's most expensive bugs; we **copy the ordering**
4	//! and clean-room the wiring (the concrete echo/compose/deliver effects are the
5	//! caller's — T8). Three invariants:
6	//!
7	//! - **3.3 echo-before-signoff** ([`graceful_signoff`]): synchronously run the
8	//!   echo-commune (final context delta) *before* composing INIT_SIGNOFF; skip
9	//!   the echo only when `session_id` is missing.
10	//! - **1.1 grace-before-signoff** ([`graceful_signoff`]): the grace wait
11	//!   completes and the recheck binds `still_gone` *before* any signoff write —
12	//!   a Self that transiently recovered (binary handoff, brief stale poll) is
13	//!   **not** torn down.
14	//! - **3.2 stale-signoff sweep** ([`sweep_stale_signoff`]): on every listener
15	//!   start, sweep a leftover `<id>-signoff.md` so a prior generation's sentinel
16	//!   never reads as a live signoff and kills a fresh start.
17	//!
18	//! Plus the **boundary Self-resume commune** ([`write_resume_commune`]): M2a's
19	//! `api boundary` rebinds the rotated session; M2b authors the resume commune
20	//! the re-entry (T7) preloads.
21	//
22	// M2b implements the GRACEFUL teardown paths (spt shutdown / spt suspend /
23	// boundary). Orphan *crash* detection after an unexpected harness death needs
24	// the always-on supervisor — that orphan-watch is M3, and it reuses this exact
25	// ordering. Copy the ordering, not the CC-coupled wiring.
26	// [impl->REQ-HAZARD-ECHO-BEFORE-SIGNOFF]
27	// [impl->REQ-HAZARD-GRACE-BEFORE-SIGNOFF]
28	// [impl->REQ-HAZARD-STALE-SIGNOFF-SENTINEL]
29
30	use crate::context::{write_context, WriteOutcome, WriteSource};
31	use crate::echo::stamp_provenance;
32	use crate::ingest::context_path;
33	use spt_store::perch::ParentHint;
34	use std::path::Path;
35	use std::time::Duration;
36
37	/// Provenance tag for a Self-authored boundary commune (strong live signal —
38	/// `docs/CONTEXT-MEMORY.md` commune-source asymmetry).
39	pub const SOURCE_SELF_COMMUNE: &str = "self-commune";
40
41	/// Outcome of a graceful signoff attempt.
42	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43	pub struct SignoffOutcome {
44	    /// Whether the echo-commune ran (false ⇒ skipped: no session_id).
45	    pub echo_ran: bool,
46	    /// Whether the signoff was composed+delivered (false ⇒ the Self recovered
47	    /// across the grace wait and was spared).
48	    pub signed_off: bool,
49	}
50
51	/// Run the graceful signoff sequence with the load-bearing ordering baked in.
52	///
53	/// The concrete effects are injected so the invariant is testable independent of
54	/// the echo/compose wiring:
55	/// - `echo` — run the final-delta echo-commune (3.3);
56	/// - `recheck_still_gone` — re-evaluate liveness *after* the grace wait (1.1);
57	/// - `compose_deliver` — compose + deliver INIT_SIGNOFF.
58	///
59	/// Order is fixed: `echo` (if `session_id`) → grace wait → `recheck_still_gone`
60	/// → `compose_deliver` (only if still gone). The recheck binds `still_gone`
61	/// before any signoff write; the echo precedes the signoff compose.
62	pub fn graceful_signoff<E>(
63	    session_id: Option<&str>,
64	    grace: Duration,
65	    echo: impl FnOnce() -> Result<(), E>,
66	    recheck_still_gone: impl FnOnce() -> bool,
67	    compose_deliver: impl FnOnce() -> Result<(), E>,
68	) -> Result<SignoffOutcome, E> {
69	    // 3.3 — echo (final context delta) BEFORE any signoff compose; skip only on
70	    // a missing session_id.
71	    let echo_ran = if session_id.is_some() {
72	        echo()?;
73	        true
74	    } else {
75	        false
76	    };
77
78	    // 1.1 — grace wait, THEN bind still_gone, BEFORE any signoff write.
79	    if !grace.is_zero() {
80	        std::thread::sleep(grace);
81	    }
82	    let still_gone = recheck_still_gone();
83
84	    let signed_off = if still_gone {
85	        compose_deliver()?;
86	        true
87	    } else {
88	        // The Self recovered across the grace window — never sign off a live Self.
89	        false
90	    };
91
92	    Ok(SignoffOutcome {
93	        echo_ran,
94	        signed_off,
95	    })
96	}
97
98	/// Sweep a stale `<id>-signoff.md` from `signoff_dir` on listener start (3.2).
99	/// Returns whether one was removed. Signoff files are write-once per
100	/// generation; a leftover from a prior session must never read as a live signoff.
101	pub fn sweep_stale_signoff(signoff_dir: &Path, id: &str) -> std::io::Result<bool> {
102	    let stale = signoff_dir.join(format!("{id}-signoff.md"));
103	    match std::fs::remove_file(&stale) {
104	        Ok(()) => {
105	            eprintln!("SIGNOFF_SWEEP:{id} (removed stale sentinel)");
106	            Ok(true)
107	        }
108	        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
109	        Err(e) => Err(e),
110	    }
111	}
112
113	/// Author the **Self-resume commune** at a boundary: stamp `Source: self-commune`
114	/// provenance and write it (precedence-guarded) into the perch's context store
115	/// for the re-entry (T7) to preload. The Self authored it, so it is an llm write.
116	pub fn write_resume_commune(
117	    id: &str,
118	    hint: ParentHint<'_>,
119	    body: &str,
120	    now_ms: u64,
121	    window_ms: u64,
122	) -> std::io::Result<WriteOutcome> {
123	    let ctx = context_path(id, hint);
124	    if let Some(parent) = ctx.parent() {
125	        std::fs::create_dir_all(parent)?;
126	    }
127	    let content = stamp_provenance(SOURCE_SELF_COMMUNE, body);
128	    write_context(&ctx, &content, WriteSource::Llm, now_ms, window_ms)
129	}
130
131	#[cfg(test)]
132	mod tests {
133	    use super::*;
134	    use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
135	    use crate::test_support::with_home;
136	    use std::cell::RefCell;
137
138	    #[test] // [unit->REQ-HAZARD-ECHO-BEFORE-SIGNOFF] echo runs BEFORE the signoff compose.
139	    fn echo_precedes_signoff_compose() {
140	        let log: RefCell<Vec<&str>> = RefCell::new(Vec::new());
141	        let out = graceful_signoff::<()>(
142	            Some("sid-1"),
143	            Duration::ZERO,
144	            || {
145	                log.borrow_mut().push("echo");
146	                Ok(())
147	            },
148	            || {
149	                log.borrow_mut().push("recheck");
150	                true
151	            },
152	            || {
153	                log.borrow_mut().push("compose");
154	                Ok(())
155	            },
156	        )
157	        .unwrap();
158	        assert_eq!(out, SignoffOutcome { echo_ran: true, signed_off: true });
159	        // Ordering: echo and recheck both strictly precede compose.
160	        assert_eq!(*log.borrow(), ["echo", "recheck", "compose"]);
161	    }
162
163	    #[test] // [unit->REQ-HAZARD-GRACE-BEFORE-SIGNOFF] a recovered Self is NOT signed off.
164	    fn recovered_self_is_not_signed_off() {
165	        let composed = RefCell::new(false);
166	        let out = graceful_signoff::<()>(
167	            Some("sid-1"),
168	            Duration::ZERO,
169	            || Ok(()),
170	            || false, // recheck: the Self came back alive
171	            || {
172	                *composed.borrow_mut() = true;
173	                Ok(())
174	            },
175	        )
176	        .unwrap();
177	        assert_eq!(out, SignoffOutcome { echo_ran: true, signed_off: false });
178	        assert!(!*composed.borrow(), "must not compose signoff for a live Self");
179	    }
180
181	    #[test] // [unit->REQ-HAZARD-ECHO-BEFORE-SIGNOFF] a missing session_id skips the echo.
182	    fn missing_session_skips_echo() {
183	        let echoed = RefCell::new(false);
184	        let out = graceful_signoff::<()>(
185	            None,
186	            Duration::ZERO,
187	            || {
188	                *echoed.borrow_mut() = true;
189	                Ok(())
190	            },
191	            || true,
192	            || Ok(()),
193	        )
194	        .unwrap();
195	        assert!(!out.echo_ran && !*echoed.borrow(), "no echo without a session_id");
196	        assert!(out.signed_off, "signoff still proceeds when still gone");
197	    }
198
199	    #[test] // [unit->REQ-HAZARD-STALE-SIGNOFF-SENTINEL] a stale signoff sentinel is swept on start.
200	    fn stale_signoff_swept() {
201	        let dir = tempfile::tempdir().unwrap();
202	        let stale = dir.path().join("doyle-signoff.md");
203	        std::fs::write(&stale, "old signoff").unwrap();
204
205	        assert!(sweep_stale_signoff(dir.path(), "doyle").unwrap(), "should sweep one");
206	        assert!(!stale.exists(), "stale sentinel must be gone");
207	        // A fresh start with none present sweeps nothing, no error.
208	        assert!(!sweep_stale_signoff(dir.path(), "doyle").unwrap());
209	    }
210
211	    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] boundary writes a provenance-tagged resume commune.
212	    fn boundary_writes_resume_commune() {
213	        with_home(|_| {
214	            let o = write_resume_commune(
215	                "doyle",
216	                ParentHint::Infer,
217	                "where I left off",
218	                1000,
219	                DEFAULT_PROTECTION_WINDOW_MS,
220	            )
221	            .unwrap();
222	            assert!(matches!(o, WriteOutcome::Written { .. }));
223	            let ctx = context_path("doyle", ParentHint::Infer);
224	            let written = std::fs::read_to_string(&ctx).unwrap();
225	            assert!(written.contains("Source: self-commune"), "provenance: {written:?}");
226	            assert!(written.contains("where I left off"));
227	        });
228	    }
229	}
230

//! signoff + boundary lifecycle (M2b T6) — the load-bearing ordering invariants.
//!
//! These are the sister project's most expensive bugs; we **copy the ordering**
//! and clean-room the wiring (the concrete echo/compose/deliver effects are the
//! caller's — T8). Three invariants:
//!
//! - **3.3 echo-before-signoff** ([`graceful_signoff`]): synchronously run the
//!   echo-commune (final context delta) *before* composing INIT_SIGNOFF; skip
//!   the echo only when `session_id` is missing.
//! - **1.1 grace-before-signoff** ([`graceful_signoff`]): the grace wait
//!   completes and the recheck binds `still_gone` *before* any signoff write —
//!   a Self that transiently recovered (binary handoff, brief stale poll) is
//!   **not** torn down.
//! - **3.2 stale-signoff sweep** ([`sweep_stale_signoff`]): on every listener
//!   start, sweep a leftover `<id>-signoff.md` so a prior generation's sentinel
//!   never reads as a live signoff and kills a fresh start.
//!
//! Plus the **boundary Self-resume commune** ([`write_resume_commune`]): M2a's
//! `api boundary` rebinds the rotated session; M2b authors the resume commune
//! the re-entry (T7) preloads.
//
// M2b implements the GRACEFUL teardown paths (spt shutdown / spt suspend /
// boundary). Orphan *crash* detection after an unexpected harness death needs
// the always-on supervisor — that orphan-watch is M3, and it reuses this exact
// ordering. Copy the ordering, not the CC-coupled wiring.
// [impl->REQ-HAZARD-ECHO-BEFORE-SIGNOFF]
// [impl->REQ-HAZARD-GRACE-BEFORE-SIGNOFF]
// [impl->REQ-HAZARD-STALE-SIGNOFF-SENTINEL]

use crate::context::{write_context, WriteOutcome, WriteSource};
use crate::echo::stamp_provenance;
use crate::ingest::context_path;
use spt_store::perch::ParentHint;
use std::path::Path;
use std::time::Duration;

/// Provenance tag for a Self-authored boundary commune (strong live signal —
/// `docs/CONTEXT-MEMORY.md` commune-source asymmetry).
pub const SOURCE_SELF_COMMUNE: &str = "self-commune";

/// Outcome of a graceful signoff attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SignoffOutcome {
    /// Whether the echo-commune ran (false ⇒ skipped: no session_id).
    pub echo_ran: bool,
    /// Whether the signoff was composed+delivered (false ⇒ the Self recovered
    /// across the grace wait and was spared).
    pub signed_off: bool,
}

/// Run the graceful signoff sequence with the load-bearing ordering baked in.
///
/// The concrete effects are injected so the invariant is testable independent of
/// the echo/compose wiring:
/// - `echo` — run the final-delta echo-commune (3.3);
/// - `recheck_still_gone` — re-evaluate liveness *after* the grace wait (1.1);
/// - `compose_deliver` — compose + deliver INIT_SIGNOFF.
///
/// Order is fixed: `echo` (if `session_id`) → grace wait → `recheck_still_gone`
/// → `compose_deliver` (only if still gone). The recheck binds `still_gone`
/// before any signoff write; the echo precedes the signoff compose.
pub fn graceful_signoff<E>(
    session_id: Option<&str>,
    grace: Duration,
    echo: impl FnOnce() -> Result<(), E>,
    recheck_still_gone: impl FnOnce() -> bool,
    compose_deliver: impl FnOnce() -> Result<(), E>,
) -> Result<SignoffOutcome, E> {
    // 3.3 — echo (final context delta) BEFORE any signoff compose; skip only on
    // a missing session_id.
    let echo_ran = if session_id.is_some() {
        echo()?;
        true
    } else {
        false
    };

    // 1.1 — grace wait, THEN bind still_gone, BEFORE any signoff write.
    if !grace.is_zero() {
        std::thread::sleep(grace);
    }
    let still_gone = recheck_still_gone();

    let signed_off = if still_gone {
        compose_deliver()?;
        true
    } else {
        // The Self recovered across the grace window — never sign off a live Self.
        false
    };

    Ok(SignoffOutcome {
        echo_ran,
        signed_off,
    })
}

/// Sweep a stale `<id>-signoff.md` from `signoff_dir` on listener start (3.2).
/// Returns whether one was removed. Signoff files are write-once per
/// generation; a leftover from a prior session must never read as a live signoff.
pub fn sweep_stale_signoff(signoff_dir: &Path, id: &str) -> std::io::Result<bool> {
    let stale = signoff_dir.join(format!("{id}-signoff.md"));
    match std::fs::remove_file(&stale) {
        Ok(()) => {
            eprintln!("SIGNOFF_SWEEP:{id} (removed stale sentinel)");
            Ok(true)
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(e) => Err(e),
    }
}

/// Author the **Self-resume commune** at a boundary: stamp `Source: self-commune`
/// provenance and write it (precedence-guarded) into the perch's context store
/// for the re-entry (T7) to preload. The Self authored it, so it is an llm write.
pub fn write_resume_commune(
    id: &str,
    hint: ParentHint<'_>,
    body: &str,
    now_ms: u64,
    window_ms: u64,
) -> std::io::Result<WriteOutcome> {
    let ctx = context_path(id, hint);
    if let Some(parent) = ctx.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let content = stamp_provenance(SOURCE_SELF_COMMUNE, body);
    write_context(&ctx, &content, WriteSource::Llm, now_ms, window_ms)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
    use crate::test_support::with_home;
    use std::cell::RefCell;

    #[test] // [unit->REQ-HAZARD-ECHO-BEFORE-SIGNOFF] echo runs BEFORE the signoff compose.
    fn echo_precedes_signoff_compose() {
        let log: RefCell<Vec<&str>> = RefCell::new(Vec::new());
        let out = graceful_signoff::<()>(
            Some("sid-1"),
            Duration::ZERO,
            || {
                log.borrow_mut().push("echo");
                Ok(())
            },
            || {
                log.borrow_mut().push("recheck");
                true
            },
            || {
                log.borrow_mut().push("compose");
                Ok(())
            },
        )
        .unwrap();
        assert_eq!(out, SignoffOutcome { echo_ran: true, signed_off: true });
        // Ordering: echo and recheck both strictly precede compose.
        assert_eq!(*log.borrow(), ["echo", "recheck", "compose"]);
    }

    #[test] // [unit->REQ-HAZARD-GRACE-BEFORE-SIGNOFF] a recovered Self is NOT signed off.
    fn recovered_self_is_not_signed_off() {
        let composed = RefCell::new(false);
        let out = graceful_signoff::<()>(
            Some("sid-1"),
            Duration::ZERO,
            || Ok(()),
            || false, // recheck: the Self came back alive
            || {
                *composed.borrow_mut() = true;
                Ok(())
            },
        )
        .unwrap();
        assert_eq!(out, SignoffOutcome { echo_ran: true, signed_off: false });
        assert!(!*composed.borrow(), "must not compose signoff for a live Self");
    }

    #[test] // [unit->REQ-HAZARD-ECHO-BEFORE-SIGNOFF] a missing session_id skips the echo.
    fn missing_session_skips_echo() {
        let echoed = RefCell::new(false);
        let out = graceful_signoff::<()>(
            None,
            Duration::ZERO,
            || {
                *echoed.borrow_mut() = true;
                Ok(())
            },
            || true,
            || Ok(()),
        )
        .unwrap();
        assert!(!out.echo_ran && !*echoed.borrow(), "no echo without a session_id");
        assert!(out.signed_off, "signoff still proceeds when still gone");
    }

    #[test] // [unit->REQ-HAZARD-STALE-SIGNOFF-SENTINEL] a stale signoff sentinel is swept on start.
    fn stale_signoff_swept() {
        let dir = tempfile::tempdir().unwrap();
        let stale = dir.path().join("doyle-signoff.md");
        std::fs::write(&stale, "old signoff").unwrap();

        assert!(sweep_stale_signoff(dir.path(), "doyle").unwrap(), "should sweep one");
        assert!(!stale.exists(), "stale sentinel must be gone");
        // A fresh start with none present sweeps nothing, no error.
        assert!(!sweep_stale_signoff(dir.path(), "doyle").unwrap());
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] boundary writes a provenance-tagged resume commune.
    fn boundary_writes_resume_commune() {
        with_home(|_| {
            let o = write_resume_commune(
                "doyle",
                ParentHint::Infer,
                "where I left off",
                1000,
                DEFAULT_PROTECTION_WINDOW_MS,
            )
            .unwrap();
            assert!(matches!(o, WriteOutcome::Written { .. }));
            let ctx = context_path("doyle", ParentHint::Infer);
            let written = std::fs::read_to_string(&ctx).unwrap();
            assert!(written.contains("Source: self-commune"), "provenance: {written:?}");
            assert!(written.contains("where I left off"));
        });
    }
}


Now understand daemon supervision model — where a crash is detected.


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::time::{SystemTime, UNIX_EPOCH};
27
28	use interprocess::local_socket::Stream;
29
30	use crate::codec::{read_frame, write_frame};
31	use crate::frame::{Envelope, Role};
32	use crate::msg::{
33	    decode_bytes, encode_bytes, AppliedEvent, ErrorEvent, ExitEvent, InputReq, KillReq, OutputEvent,
34	    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_APPLIED, KIND_ERROR, KIND_EXIT, KIND_INPUT,
35	    KIND_KILL, KIND_OUTPUT, KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
36	};
37	use crate::transport::{send_hello, LocalSocketTransport};
38
39	/// Wall-clock now in epoch milliseconds — the source for `gen_start` (matches
40	/// the epoch-ms stamping used elsewhere in the tree; no date dependency).
41	pub fn now_ms() -> u64 {
42	    SystemTime::now()
43	        .duration_since(UNIX_EPOCH)
44	        .map(|d| d.as_millis() as u64)
45	        .unwrap_or(0)
46	}
47
48	/// The brain's durable state across a handoff: enough to re-attach gaplessly and
49	/// advance the generation. `gen_start_ms` is recorded for observability but is
50	/// **deliberately not authoritative on restart** — [`Brain::handoff`] sets a
51	/// fresh `gen_start` from `now()` (2.4).
52	#[derive(Debug, Clone)]
53	pub struct BrainState {
54	    /// The session the brain was driving.
55	    pub session_id: u64,
56	    /// The generation counter (incremented on each handoff).
57	    pub generation: u64,
58	    /// The next output sequence still needed (the resume cursor).
59	    pub next_seq: u64,
60	    /// The prior generation's wall-clock start (ignored as authoritative on
61	    /// handoff — kept only to demonstrate it is *not* carried forward).
62	    pub gen_start_ms: u64,
63	}
64
65	/// A demuxed broker→brain event.
66	#[derive(Debug)]
67	pub enum BrokerEvent {
68	    /// A contiguous chunk of a session's output (the cursor has advanced past it).
69	    Output { session_id: u64, seq: u64, bytes: Vec<u8> },
70	    /// A spawn succeeded.
71	    Spawned { session_id: u64, pid: Option<u32> },
72	    /// A session's child exited.
73	    Exit { session_id: u64, code: Option<i32> },
74	    /// An idempotent effect is durably applied (or was deduped — `applied_now`
75	    /// distinguishes). The exactly-once ack for a [`Brain::send_effect`] (B5).
76	    Applied { session_id: u64, op_id: u64, applied_now: bool },
77	    /// The broker reported a command error.
78	    Error { message: String },
79	    /// A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
80	    Other(Envelope),
81	}
82
83	/// A live brain attached to a broker.
84	pub struct Brain {
85	    conn: Stream,
86	    generation: u64,
87	    gen_start_ms: u64,
88	    session_id: Option<u64>,
89	    /// The next output sequence still needed (resume cursor / dedup floor).
90	    next_seq: u64,
91	}
92
93	impl Brain {
94	    /// Cold start: connect, handshake, fresh generation 0, `gen_start = now`.
95	    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
96	        let mut conn = LocalSocketTransport::connect(name)?;
97	        send_hello(&mut conn, Role::Brain)?;
98	        Ok(Brain { conn, generation: 0, gen_start_ms: now_ms, session_id: None, next_seq: 0 })
99	    }
100
101	    /// Handoff: connect, handshake, rehydrate the resume cursor + generation
102	    /// from `prior`, but stamp `gen_start = now()` (NOT `prior.gen_start_ms` —
103	    /// the 2.4 invariant), and re-subscribe to the session from the cursor for a
104	    /// gapless resume.
105	    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
106	        let mut conn = LocalSocketTransport::connect(name)?;
107	        send_hello(&mut conn, Role::Brain)?;
108	        let mut brain = Brain {
109	            conn,
110	            generation: prior.generation + 1,
111	            gen_start_ms: now_ms, // fresh — prior.gen_start_ms is intentionally discarded
112	            session_id: Some(prior.session_id),
113	            next_seq: prior.next_seq,
114	        };
115	        brain.subscribe(prior.session_id, prior.next_seq)?;
116	        Ok(brain)
117	    }
118
119	    /// The wall-clock generation start (epoch ms) — always this generation's own
120	    /// start time, never a rehydrated value.
121	    pub fn gen_start_ms(&self) -> u64 {
122	        self.gen_start_ms
123	    }
124
125	    /// The generation counter (0 on cold start, +1 per handoff).
126	    pub fn generation(&self) -> u64 {
127	        self.generation
128	    }
129
130	    /// The next output sequence still needed (the resume cursor).
131	    pub fn next_seq(&self) -> u64 {
132	        self.next_seq
133	    }
134
135	    /// The session id the brain is driving, if any.
136	    pub fn session_id(&self) -> Option<u64> {
137	        self.session_id
138	    }
139
140	    /// Snapshot durable state for a future handoff.
141	    pub fn snapshot(&self) -> BrainState {
142	        BrainState {
143	            session_id: self.session_id.expect("snapshot before a session exists"),
144	            generation: self.generation,
145	            next_seq: self.next_seq,
146	            gen_start_ms: self.gen_start_ms,
147	        }
148	    }
149
150	    /// Spawn a session under a broker PTY; the broker auto-subscribes this
151	    /// connection. Reads until the `spawned` reply (consuming any output that
152	    /// races ahead).
153	    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
154	        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
155	        loop {
156	            match self.read_event()? {
157	                BrokerEvent::Spawned { session_id, .. } => {
158	                    self.session_id = Some(session_id);
159	                    return Ok(session_id);
160	                }
161	                BrokerEvent::Error { message } => {
162	                    return Err(io::Error::other(message));
163	                }
164	                _ => continue,
165	            }
166	        }
167	    }
168
169	    /// Inject input bytes into the current session (send-keys), the **legacy
170	    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
171	    /// of an effect that originates from a durable source (a spool row, a
172	    /// sequenced injection), use [`Brain::send_effect`].
173	    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
174	        let sid = self.require_session()?;
175	        self.send(
176	            KIND_INPUT,
177	            serde_json::to_value(InputReq { session_id: sid, data_b64: encode_bytes(bytes), op_id: None })
178	                .expect("InputReq serializes"),
179	        )
180	    }
181
182	    /// Inject input **idempotently**: the write carries the durable `op_id` so a
183	    /// replay after a brain crash is deduped at the broker (exactly-once, B5).
184	    /// `op_id` must come from a durable source and be reused verbatim on replay —
185	    /// the same logical effect must always carry the same id, so the broker's
186	    /// applied-set recognizes the re-drive. The broker answers with a
187	    /// [`BrokerEvent::Applied`] for `(session, op_id)`.
188	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
189	    pub fn send_effect(&mut self, op_id: u64, bytes: &[u8]) -> io::Result<()> {
190	        let sid = self.require_session()?;
191	        self.send(
192	            KIND_INPUT,
193	            serde_json::to_value(InputReq {
194	                session_id: sid,
195	                data_b64: encode_bytes(bytes),
196	                op_id: Some(op_id),
197	            })
198	            .expect("InputReq serializes"),
199	        )
200	    }
201
202	    /// Resize the current session's PTY.
203	    pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
204	        let sid = self.require_session()?;
205	        self.send(
206	            KIND_RESIZE,
207	            serde_json::to_value(ResizeReq { session_id: sid, rows, cols })
208	                .expect("ResizeReq serializes"),
209	        )
210	    }
211
212	    /// Terminate the current session's child.
213	    pub fn kill_session(&mut self) -> io::Result<()> {
214	        let sid = self.require_session()?;
215	        self.send(
216	            KIND_KILL,
217	            serde_json::to_value(KillReq { session_id: sid }).expect("KillReq serializes"),
218	        )
219	    }
220
221	    /// Read output events until the accumulated bytes contain `needle`,
222	    /// appending all received output to `buf`. Returns the highest seq seen.
223	    /// A session `Exit` before the needle is found is an error (the child died).
224	    pub fn read_output_until(&mut self, needle: &[u8], buf: &mut Vec<u8>) -> io::Result<u64> {
225	        loop {
226	            if contains(buf, needle) {
227	                return Ok(self.next_seq.saturating_sub(1));
228	            }
229	            match self.read_event()? {
230	                BrokerEvent::Output { bytes, .. } => buf.extend_from_slice(&bytes),
231	                BrokerEvent::Exit { .. } => {
232	                    return Err(io::Error::new(
233	                        io::ErrorKind::UnexpectedEof,
234	                        "session exited before the expected output arrived",
235	                    ));
236	                }
237	                BrokerEvent::Error { message } => {
238	                    return Err(io::Error::other(message));
239	                }
240	                _ => {}
241	            }
242	        }
243	    }
244
245	    /// Read and demux one broker event, enforcing exactly-once contiguous output:
246	    /// accept the next `seq` (advancing the cursor), drop an already-seen `seq`
247	    /// (replay duplicate), and reject a forward gap (lost chunk).
248	    pub fn read_event(&mut self) -> io::Result<BrokerEvent> {
249	        loop {
250	            let env = read_frame(&mut self.conn)?;
251	            match env.kind.as_str() {
252	                KIND_OUTPUT => {
253	                    let ev: OutputEvent = serde_json::from_value(env.payload)
254	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
255	                    if ev.seq < self.next_seq {
256	                        continue; // already processed — a replay duplicate; dedup
257	                    }
258	                    if ev.seq > self.next_seq {
259	                        return Err(io::Error::new(
260	                            io::ErrorKind::InvalidData,
261	                            format!("output gap: got seq {} want {}", ev.seq, self.next_seq),
262	                        ));
263	                    }
264	                    self.next_seq = ev.seq + 1;
265	                    let bytes = decode_bytes(&ev.data_b64)
266	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
267	                    return Ok(BrokerEvent::Output { session_id: ev.session_id, seq: ev.seq, bytes });
268	                }
269	                KIND_SPAWNED => {
270	                    let ev: Spawned = serde_json::from_value(env.payload)
271	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
272	                    return Ok(BrokerEvent::Spawned { session_id: ev.session_id, pid: ev.pid });
273	                }
274	                KIND_EXIT => {
275	                    let ev: ExitEvent = serde_json::from_value(env.payload)
276	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
277	                    return Ok(BrokerEvent::Exit { session_id: ev.session_id, code: ev.code });
278	                }
279	                KIND_APPLIED => {
280	                    let ev: AppliedEvent = serde_json::from_value(env.payload)
281	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
282	                    return Ok(BrokerEvent::Applied {
283	                        session_id: ev.session_id,
284	                        op_id: ev.op_id,
285	                        applied_now: ev.applied_now,
286	                    });
287	                }
288	                KIND_ERROR => {
289	                    let ev: ErrorEvent = serde_json::from_value(env.payload)
290	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
291	                    return Ok(BrokerEvent::Error { message: ev.message });
292	                }
293	                _ => return Ok(BrokerEvent::Other(env)),
294	            }
295	        }
296	    }
297
298	    /// Re-attach to a session's output from `from_seq` (handoff resume).
299	    fn subscribe(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
300	        self.send(
301	            KIND_SUBSCRIBE,
302	            serde_json::to_value(SubscribeReq { session_id, from_seq })
303	                .expect("SubscribeReq serializes"),
304	        )
305	    }
306
307	    fn send(&mut self, kind: &str, payload: serde_json::Value) -> io::Result<()> {
308	        write_frame(&mut self.conn, &Envelope::new(kind, payload))
309	    }
310
311	    fn require_session(&self) -> io::Result<u64> {
312	        self.session_id
313	            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no active session"))
314	    }
315	}
316
317	/// Whether `haystack` contains the contiguous `needle`.
318	fn contains(haystack: &[u8], needle: &[u8]) -> bool {
319	    if needle.is_empty() {
320	        return true;
321	    }
322	    haystack.windows(needle.len()).any(|w| w == needle)
323	}
324

//! 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::time::{SystemTime, UNIX_EPOCH};

use interprocess::local_socket::Stream;

use crate::codec::{read_frame, write_frame};
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)),
            }
        }
    }

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


1	//! The thin, stateless in-session relay (B3) — the only residue of the interim
2	//! `api listen` runner that stays in the harness-hosted session (topology 1).
3	//!
4	//! Consolidation (REQ-DAEMON-1) moves the *lifecycle* — Psyche spawn, pulse,
5	//! echo, drop-ingest — into the daemon brain as scheduled in-process loops. What
6	//! remains running inside the agent's own session is just this relay: it forwards
7	//! inbound messages (the offline spool backlog + live poll deliveries) to a sink
8	//! (stdout for the real session) and holds **no state of its own**.
9	//!
10	//! Because it is stateless and every undelivered message is durable in the perch
11	//! spool, the relay is **freely killable and respawnable without losing state**:
12	//! a killed relay leaves its backlog in the spool, and a fresh relay re-drains it
13	//! on its next pump. That is the property the daemon needs — a routine that kills
14	//! the in-session relay (e.g. to swap it) never drops a message.
15	// [impl->REQ-DAEMON-1]
16
17	use std::path::{Path, PathBuf};
18	use std::sync::atomic::{AtomicBool, Ordering};
19	use std::time::Duration;
20
21	use spt_msg::listener::PollListener;
22	use spt_store::spool;
23
24	/// Poll window for the relay's blocking accept — bounds each cycle so the loop
25	/// stays responsive to a stop signal (mirrors the interim listener cadence).
26	pub const DEFAULT_RELAY_POLL: Duration = Duration::from_millis(200);
27
28	/// A stateless message relay for one perch: a poll listener bound to the id plus
29	/// the perch path it drains. It carries no message buffer — every byte it
30	/// forwards comes straight from the live poll or the durable spool.
31	pub struct Relay {
32	    id: String,
33	    perch_path: PathBuf,
34	    listener: PollListener,
35	}
36
37	impl Relay {
38	    /// Bind a relay for `id` draining `perch_path`, with the default poll window.
39	    pub fn bind(id: &str, perch_path: &Path) -> std::io::Result<Self> {
40	        Self::bind_with(id, perch_path, DEFAULT_RELAY_POLL)
41	    }
42
43	    /// Bind a relay with an explicit poll window.
44	    pub fn bind_with(id: &str, perch_path: &Path, poll: Duration) -> std::io::Result<Self> {
45	        let listener = PollListener::bind_blocking(id, poll)
46	            .map_err(|e| std::io::Error::other(e.to_string()))?;
47	        Ok(Relay { id: id.to_string(), perch_path: perch_path.to_path_buf(), listener })
48	    }
49
50	    /// The relayed endpoint id.
51	    pub fn id(&self) -> &str {
52	        &self.id
53	    }
54
55	    /// Drain the durable offline backlog (non-deferred spool rows) to `sink`.
56	    /// Idempotent across respawns: a fresh relay re-drains whatever a killed one
57	    /// left behind. Returns the number of messages forwarded.
58	    pub fn drain_backlog<F: FnMut(&str)>(&self, mut sink: F) -> usize {
59	        let msgs = spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default();
60	        let n = msgs.len();
61	        for m in &msgs {
62	            sink(m);
63	        }
64	        n
65	    }
66
67	    /// One relay cycle: forward a single live poll delivery (if one arrives in the
68	    /// poll window) then drain the offline backlog. Holds no state between calls.
69	    pub fn pump_once<F: FnMut(&str)>(&self, mut sink: F) {
70	        if let Some(msg) = self.listener.check_message_blocking() {
71	            sink(&msg);
72	        }
73	        self.drain_backlog(&mut sink);
74	    }
75
76	    /// Run the relay until `stop` is set, forwarding every delivery to `sink`.
77	    /// The blocking poll bounds each iteration, so a kill signal is honored within
78	    /// one poll window. State loss is impossible: the loop buffers nothing.
79	    pub fn run<F: FnMut(&str)>(&self, stop: &AtomicBool, mut sink: F) {
80	        // Drain whatever a predecessor relay left behind before streaming live.
81	        self.drain_backlog(&mut sink);
82	        while !stop.load(Ordering::Relaxed) {
83	            self.pump_once(&mut sink);
84	        }
85	    }
86	}
87
88	#[cfg(test)]
89	mod tests {
90	    use super::*;
91	    use crate::test_home::with_home;
92	    use spt_store::perch::{resolve_perch_path, ParentHint};
93
94	    fn perch_for(id: &str) -> PathBuf {
95	        let p = resolve_perch_path(id, ParentHint::Infer);
96	        std::fs::create_dir_all(&p).unwrap();
97	        p
98	    }
99
100	    #[test] // [unit->REQ-DAEMON-1] a respawned relay re-drains a killed relay's backlog — no loss.
101	    fn respawn_loses_no_spooled_message() {
102	        with_home(|_| {
103	            let perch = perch_for("doyle");
104	            spool::spool_message_at(&perch, "alice", "m1").unwrap();
105	            spool::spool_message_at(&perch, "alice", "m2").unwrap();
106
107	            // Relay A binds then is "killed" (dropped) WITHOUT pumping. The two
108	            // messages remain durable in the spool — the relay held no state.
109	            {
110	                let _a = Relay::bind("doyle", &perch).unwrap();
111	            }
112	            assert_eq!(spool::pending_count_non_deferred_at(&perch).unwrap(), 2);
113
114	            // Relay B (respawn) drains the full backlog — nothing was lost.
115	            let b = Relay::bind("doyle", &perch).unwrap();
116	            let mut got = Vec::new();
117	            let n = b.drain_backlog(|m| got.push(m.to_string()));
118	            assert_eq!(n, 2);
119	            assert!(got.iter().any(|m| m.contains("m1")));
120	            assert!(got.iter().any(|m| m.contains("m2")));
121	            assert_eq!(spool::pending_count_non_deferred_at(&perch).unwrap(), 0);
122	        });
123	    }
124
125	    #[test] // [unit->REQ-DAEMON-1] run() exits promptly when the stop flag is set.
126	    fn run_honors_stop() {
127	        with_home(|_| {
128	            let perch = perch_for("doyle");
129	            let relay = Relay::bind_with("doyle", &perch, Duration::from_millis(20)).unwrap();
130	            let stop = AtomicBool::new(true); // already stopped: drains once, returns.
131	            let mut got = Vec::new();
132	            spool::spool_message_at(&perch, "alice", "hi").unwrap();
133	            relay.run(&stop, |m| got.push(m.to_string()));
134	            assert_eq!(got.len(), 1, "the pre-stream backlog drain still fires");
135	        });
136	    }
137	}
138

//! The thin, stateless in-session relay (B3) — the only residue of the interim
//! `api listen` runner that stays in the harness-hosted session (topology 1).
//!
//! Consolidation (REQ-DAEMON-1) moves the *lifecycle* — Psyche spawn, pulse,
//! echo, drop-ingest — into the daemon brain as scheduled in-process loops. What
//! remains running inside the agent's own session is just this relay: it forwards
//! inbound messages (the offline spool backlog + live poll deliveries) to a sink
//! (stdout for the real session) and holds **no state of its own**.
//!
//! Because it is stateless and every undelivered message is durable in the perch
//! spool, the relay is **freely killable and respawnable without losing state**:
//! a killed relay leaves its backlog in the spool, and a fresh relay re-drains it
//! on its next pump. That is the property the daemon needs — a routine that kills
//! the in-session relay (e.g. to swap it) never drops a message.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use spt_msg::listener::PollListener;
use spt_store::spool;

/// Poll window for the relay's blocking accept — bounds each cycle so the loop
/// stays responsive to a stop signal (mirrors the interim listener cadence).
pub const DEFAULT_RELAY_POLL: Duration = Duration::from_millis(200);

/// A stateless message relay for one perch: a poll listener bound to the id plus
/// the perch path it drains. It carries no message buffer — every byte it
/// forwards comes straight from the live poll or the durable spool.
pub struct Relay {
    id: String,
    perch_path: PathBuf,
    listener: PollListener,
}

impl Relay {
    /// Bind a relay for `id` draining `perch_path`, with the default poll window.
    pub fn bind(id: &str, perch_path: &Path) -> std::io::Result<Self> {
        Self::bind_with(id, perch_path, DEFAULT_RELAY_POLL)
    }

    /// Bind a relay with an explicit poll window.
    pub fn bind_with(id: &str, perch_path: &Path, poll: Duration) -> std::io::Result<Self> {
        let listener = PollListener::bind_blocking(id, poll)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        Ok(Relay { id: id.to_string(), perch_path: perch_path.to_path_buf(), listener })
    }

    /// The relayed endpoint id.
    pub fn id(&self) -> &str {
        &self.id
    }

    /// Drain the durable offline backlog (non-deferred spool rows) to `sink`.
    /// Idempotent across respawns: a fresh relay re-drains whatever a killed one
    /// left behind. Returns the number of messages forwarded.
    pub fn drain_backlog<F: FnMut(&str)>(&self, mut sink: F) -> usize {
        let msgs = spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default();
        let n = msgs.len();
        for m in &msgs {
            sink(m);
        }
        n
    }

    /// One relay cycle: forward a single live poll delivery (if one arrives in the
    /// poll window) then drain the offline backlog. Holds no state between calls.
    pub fn pump_once<F: FnMut(&str)>(&self, mut sink: F) {
        if let Some(msg) = self.listener.check_message_blocking() {
            sink(&msg);
        }
        self.drain_backlog(&mut sink);
    }

    /// Run the relay until `stop` is set, forwarding every delivery to `sink`.
    /// The blocking poll bounds each iteration, so a kill signal is honored within
    /// one poll window. State loss is impossible: the loop buffers nothing.
    pub fn run<F: FnMut(&str)>(&self, stop: &AtomicBool, mut sink: F) {
        // Drain whatever a predecessor relay left behind before streaming live.
        self.drain_backlog(&mut sink);
        while !stop.load(Ordering::Relaxed) {
            self.pump_once(&mut sink);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use spt_store::perch::{resolve_perch_path, ParentHint};

    fn perch_for(id: &str) -> PathBuf {
        let p = resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        p
    }

    #[test] // [unit->REQ-DAEMON-1] a respawned relay re-drains a killed relay's backlog — no loss.
    fn respawn_loses_no_spooled_message() {
        with_home(|_| {
            let perch = perch_for("doyle");
            spool::spool_message_at(&perch, "alice", "m1").unwrap();
            spool::spool_message_at(&perch, "alice", "m2").unwrap();

            // Relay A binds then is "killed" (dropped) WITHOUT pumping. The two
            // messages remain durable in the spool — the relay held no state.
            {
                let _a = Relay::bind("doyle", &perch).unwrap();
            }
            assert_eq!(spool::pending_count_non_deferred_at(&perch).unwrap(), 2);

            // Relay B (respawn) drains the full backlog — nothing was lost.
            let b = Relay::bind("doyle", &perch).unwrap();
            let mut got = Vec::new();
            let n = b.drain_backlog(|m| got.push(m.to_string()));
            assert_eq!(n, 2);
            assert!(got.iter().any(|m| m.contains("m1")));
            assert!(got.iter().any(|m| m.contains("m2")));
            assert_eq!(spool::pending_count_non_deferred_at(&perch).unwrap(), 0);
        });
    }

    #[test] // [unit->REQ-DAEMON-1] run() exits promptly when the stop flag is set.
    fn run_honors_stop() {
        with_home(|_| {
            let perch = perch_for("doyle");
            let relay = Relay::bind_with("doyle", &perch, Duration::from_millis(20)).unwrap();
            let stop = AtomicBool::new(true); // already stopped: drains once, returns.
            let mut got = Vec::new();
            spool::spool_message_at(&perch, "alice", "hi").unwrap();
            relay.run(&stop, |m| got.push(m.to_string()));
            assert_eq!(got.len(), 1, "the pre-stream backlog drain still fires");
        });
    }
}