1	//! The daemon process entrypoint + auto-start (B6).
2	//!
3	//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
4	//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
5	//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
6	//! of the process.
7	//!
8	//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
9	//! invocation calls it; if no daemon answers the seed-control ping it spawns
10	//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
11	//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
12	//! against it is a true liveness test (`listen` is the interim model's anchor;
13	//! here the seed channel plays that role for cold-start detection).
14	// [impl->REQ-DAEMON-3]
15
16	use std::io;
17	use std::process::{Command, Stdio};
18	use std::sync::Arc;
19	use std::thread;
20	use std::time::Duration;
21
22	use crate::broker::Broker;
23	use crate::endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};
24	use crate::seedmap::{self, SeedRegistry};
25
26	/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
27	const START_TIMEOUT: Duration = Duration::from_secs(5);
28	/// Poll cadence while waiting for the spawned daemon to come up.
29	const START_POLL: Duration = Duration::from_millis(25);
30
31	/// The per-machine broker/brain daemon process.
32	pub struct Daemon;
33
34	impl Daemon {
35	    /// Run the daemon: bind the broker (served on a background thread) + the
36	    /// seed-control channel (the foreground loop + liveness anchor) and serve
37	    /// until the process is killed. The body of `spt daemon`.
38	    pub fn run() -> io::Result<()> {
39	        let reg = Arc::new(SeedRegistry::new());
40
41	        // The broker hosts PTYs (B1/B2). Serve it on a background thread; a bind
42	        // failure there must not stop the seed channel (the B6 surface) — the
43	        // PTY-hosting consumers arrive in B7/B8.
44	        match Broker::bind(&broker_socket_name()) {
45	            Ok(broker) => {
46	                thread::spawn(move || {
47	                    let _ = broker.serve();
48	                });
49	            }
50	            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
51	        }
52
53	        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
54	        // is the socket ping, never this file, so a stale value is harmless.
55	        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());
56
57	        // The seed-control channel is the foreground loop. Binding it is what
58	        // arbitrates the single-daemon-per-home invariant: a second daemon racing
59	        // to start fails this bind and exits, leaving the winner serving.
60	        seedmap::serve_seed_control(&seed_socket_name(), reg)
61	    }
62	}
63
64	/// Whether a daemon is already serving the seed-control channel for this home.
65	pub fn is_running() -> bool {
66	    seedmap::ping(&seed_socket_name()).is_ok()
67	}
68
69	/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
70	/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
71	/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
72	/// wins the bind and the other exits — and this check passes either way.
73	pub fn ensure_running() -> io::Result<()> {
74	    if is_running() {
75	        return Ok(());
76	    }
77	    spawn_detached()?;
78	    let mut waited = Duration::ZERO;
79	    while waited < START_TIMEOUT {
80	        if is_running() {
81	            return Ok(());
82	        }
83	        thread::sleep(START_POLL);
84	        waited += START_POLL;
85	    }
86	    Err(io::Error::new(
87	        io::ErrorKind::TimedOut,
88	        "daemon did not come up within the start window",
89	    ))
90	}
91
92	/// Spawn `spt daemon` as a detached background process. Inherits the environment
93	/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
94	/// socket names — but drops the std handles so it outlives the launching CLI
95	/// call. The child is independent (not killed when the parent exits): on unix it
96	/// is reparented to init when the launcher exits; on Windows a child is not in
97	/// the parent's job by default.
98	///
99	/// **Windows handle-inheritance guard.** A caller may capture this process's
100	/// stdout/stderr through a pipe (`Command::output()`, a harness hook reading the
101	/// command's output). On Windows `CreateProcess` is invoked with
102	/// `bInheritHandles = TRUE`, so the long-lived daemon would inherit that pipe's
103	/// write end and hold it open forever — the capturing reader never sees EOF and
104	/// hangs. Unix is immune (Rust marks pipe fds `CLOEXEC`). So before spawning we
105	/// strip `HANDLE_FLAG_INHERIT` from this process's std handles; the daemon then
106	/// cannot inherit them, the pipe closes when *this* process exits, and the
107	/// caller's read completes. Stripping the flag does not affect this process's own
108	/// use of its std handles — only what a child inherits.
109	fn spawn_detached() -> io::Result<()> {
110	    #[cfg(windows)]
111	    block_std_handle_inheritance();
112	    let exe = std::env::current_exe()?;
113	    let mut cmd = Command::new(exe);
114	    cmd.arg("daemon")
115	        .stdin(Stdio::null())
116	        .stdout(Stdio::null())
117	        .stderr(Stdio::null());
118	    #[cfg(windows)]
119	    {
120	        use std::os::windows::process::CommandExt;
121	        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
122	        // inherited console, no popup window, its own signal group.
123	        cmd.creation_flags(0x0000_0008 | 0x0000_0200 | 0x0800_0000);
124	    }
125	    cmd.spawn()?;
126	    Ok(())
127	}
128
129	/// Clear `HANDLE_FLAG_INHERIT` on this process's std handles so a spawned child
130	/// (the daemon) cannot inherit a pipe a caller is capturing. Declared inline to
131	/// avoid a winapi dependency for these two calls.
132	#[cfg(windows)]
133	fn block_std_handle_inheritance() {
134	    extern "system" {
135	        fn GetStdHandle(nStdHandle: u32) -> isize;
136	        fn SetHandleInformation(hObject: isize, dwMask: u32, dwFlags: u32) -> i32;
137	    }
138	    const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // -10
139	    const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11
140	    const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12
141	    const HANDLE_FLAG_INHERIT: u32 = 0x1;
142	    const INVALID: isize = -1;
143	    unsafe {
144	        for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
145	            let h = GetStdHandle(which);
146	            if h != 0 && h != INVALID {
147	                // Best-effort: a console handle that rejects the call is harmless.
148	                SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0);
149	            }
150	        }
151	    }
152	}
153
154	#[cfg(test)]
155	mod tests {
156	    use super::*;
157	    use crate::seedmap::{serve_seed_control, SeedRegistry};
158	    use crate::test_home::with_home;
159
160	    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
161	    // seed-control server answers, it flips true and ensure_running() is a no-op
162	    // (the spawn branch — which would need the real `spt` binary — is not taken).
163	    #[test]
164	    fn is_running_tracks_the_seed_channel() {
165	        with_home(|_| {
166	            assert!(!is_running(), "no daemon bound yet");
167
168	            let reg = Arc::new(SeedRegistry::new());
169	            let name = seed_socket_name();
170	            let srv = Arc::clone(&reg);
171	            thread::spawn(move || {
172	                let _ = serve_seed_control(&name, srv);
173	            });
174	            for _ in 0..400 {
175	                if is_running() {
176	                    break;
177	                }
178	                thread::sleep(Duration::from_millis(5));
179	            }
180	            assert!(is_running(), "the seed channel answers → daemon is up");
181	            // ensure_running short-circuits (no real process spawn).
182	            ensure_running().expect("ensure is a no-op when already up");
183	        });
184	    }
185	}
186

//! The daemon process entrypoint + auto-start (B6).
//!
//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
//! of the process.
//!
//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
//! invocation calls it; if no daemon answers the seed-control ping it spawns
//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
//! against it is a true liveness test (`listen` is the interim model's anchor;
//! here the seed channel plays that role for cold-start detection).
// [impl->REQ-DAEMON-3]

use std::io;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use crate::broker::Broker;
use crate::endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};
use crate::seedmap::{self, SeedRegistry};

/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
const START_TIMEOUT: Duration = Duration::from_secs(5);
/// Poll cadence while waiting for the spawned daemon to come up.
const START_POLL: Duration = Duration::from_millis(25);

/// The per-machine broker/brain daemon process.
pub struct Daemon;

impl Daemon {
    /// Run the daemon: bind the broker (served on a background thread) + the
    /// seed-control channel (the foreground loop + liveness anchor) and serve
    /// until the process is killed. The body of `spt daemon`.
    pub fn run() -> io::Result<()> {
        let reg = Arc::new(SeedRegistry::new());

        // The broker hosts PTYs (B1/B2). Serve it on a background thread; a bind
        // failure there must not stop the seed channel (the B6 surface) — the
        // PTY-hosting consumers arrive in B7/B8.
        match Broker::bind(&broker_socket_name()) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
        // is the socket ping, never this file, so a stale value is harmless.
        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());

        // The seed-control channel is the foreground loop. Binding it is what
        // arbitrates the single-daemon-per-home invariant: a second daemon racing
        // to start fails this bind and exits, leaving the winner serving.
        seedmap::serve_seed_control(&seed_socket_name(), reg)
    }
}

/// Whether a daemon is already serving the seed-control channel for this home.
pub fn is_running() -> bool {
    seedmap::ping(&seed_socket_name()).is_ok()
}

/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
/// wins the bind and the other exits — and this check passes either way.
pub fn ensure_running() -> io::Result<()> {
    if is_running() {
        return Ok(());
    }
    spawn_detached()?;
    let mut waited = Duration::ZERO;
    while waited < START_TIMEOUT {
        if is_running() {
            return Ok(());
        }
        thread::sleep(START_POLL);
        waited += START_POLL;
    }
    Err(io::Error::new(
        io::ErrorKind::TimedOut,
        "daemon did not come up within the start window",
    ))
}

/// Spawn `spt daemon` as a detached background process. Inherits the environment
/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
/// socket names — but drops the std handles so it outlives the launching CLI
/// call. The child is independent (not killed when the parent exits): on unix it
/// is reparented to init when the launcher exits; on Windows a child is not in
/// the parent's job by default.
///
/// **Windows handle-inheritance guard.** A caller may capture this process's
/// stdout/stderr through a pipe (`Command::output()`, a harness hook reading the
/// command's output). On Windows `CreateProcess` is invoked with
/// `bInheritHandles = TRUE`, so the long-lived daemon would inherit that pipe's
/// write end and hold it open forever — the capturing reader never sees EOF and
/// hangs. Unix is immune (Rust marks pipe fds `CLOEXEC`). So before spawning we
/// strip `HANDLE_FLAG_INHERIT` from this process's std handles; the daemon then
/// cannot inherit them, the pipe closes when *this* process exits, and the
/// caller's read completes. Stripping the flag does not affect this process's own
/// use of its std handles — only what a child inherits.
fn spawn_detached() -> io::Result<()> {
    #[cfg(windows)]
    block_std_handle_inheritance();
    let exe = std::env::current_exe()?;
    let mut cmd = Command::new(exe);
    cmd.arg("daemon")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
        // inherited console, no popup window, its own signal group.
        cmd.creation_flags(0x0000_0008 | 0x0000_0200 | 0x0800_0000);
    }
    cmd.spawn()?;
    Ok(())
}

/// Clear `HANDLE_FLAG_INHERIT` on this process's std handles so a spawned child
/// (the daemon) cannot inherit a pipe a caller is capturing. Declared inline to
/// avoid a winapi dependency for these two calls.
#[cfg(windows)]
fn block_std_handle_inheritance() {
    extern "system" {
        fn GetStdHandle(nStdHandle: u32) -> isize;
        fn SetHandleInformation(hObject: isize, dwMask: u32, dwFlags: u32) -> i32;
    }
    const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // -10
    const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11
    const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12
    const HANDLE_FLAG_INHERIT: u32 = 0x1;
    const INVALID: isize = -1;
    unsafe {
        for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
            let h = GetStdHandle(which);
            if h != 0 && h != INVALID {
                // Best-effort: a console handle that rejects the call is harmless.
                SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::seedmap::{serve_seed_control, SeedRegistry};
    use crate::test_home::with_home;

    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
    // seed-control server answers, it flips true and ensure_running() is a no-op
    // (the spawn branch — which would need the real `spt` binary — is not taken).
    #[test]
    fn is_running_tracks_the_seed_channel() {
        with_home(|_| {
            assert!(!is_running(), "no daemon bound yet");

            let reg = Arc::new(SeedRegistry::new());
            let name = seed_socket_name();
            let srv = Arc::clone(&reg);
            thread::spawn(move || {
                let _ = serve_seed_control(&name, srv);
            });
            for _ in 0..400 {
                if is_running() {
                    break;
                }
                thread::sleep(Duration::from_millis(5));
            }
            assert!(is_running(), "the seed channel answers → daemon is up");
            // ensure_running short-circuits (no real process spawn).
            ensure_running().expect("ensure is a no-op when already up");
        });
    }
}


NOTE FROM THE USER: proceed with next steps.


Now look at how the interim graceful path checks liveness + the liveness primitives.


1	//! LiveAgent lifecycle hosting — a thin shim over the daemon's re-hosted
2	//! lifecycle ([`spt_daemon::BrainLifecycle`]).
3	//!
4	//! M2b hosted the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest +
5	//! echo, graceful signoff ordering) directly in the `api listen` runner. B3
6	//! consolidated that orchestration **down into `spt-daemon`** (REQ-DAEMON-1); the
7	//! brain now runs it as scheduled in-process loops paced by a durable config.
8	//! This module keeps the binary's call sites stable — `cmd_listen` /
9	//! `cmd_shutdown` — by forwarding to `BrainLifecycle`. The logic lives once, in
10	//! the daemon crate; the duplicate here is gone.
11	//
12	// [impl->REQ-SEAM-PSYCHE]
13	// [impl->REQ-DAEMON-1]
14
15	use spt_daemon::{BrainLifecycle, TickReport};
16	use spt_live::{Ingested, PsycheHandle, SignoffOutcome};
17	use spt_runtime::Manifest;
18	use spt_store::perch::{self, ParentHint};
19
20	/// The LiveAgent lifecycle host — a thin newtype over the daemon's
21	/// [`BrainLifecycle`]. Built from a live-capable manifest; `None` for a plain
22	/// relay endpoint (no Psyche).
23	pub struct LiveHost(BrainLifecycle);
24
25	impl LiveHost {
26	    /// Build a host for `id` from a live-capable manifest; `None` if the manifest
27	    /// is absent or declares no Psyche.
28	    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<LiveHost> {
29	        BrainLifecycle::new_opt(manifest, id).map(LiveHost)
30	    }
31
32	    /// Sweep a stale signoff sentinel on listener start (3.2).
33	    pub fn sweep_on_start(&self) {
34	        self.0.sweep_on_start();
35	    }
36
37	    /// Spawn the Psyche companion (T2).
38	    pub fn spawn_psyche(
39	        &self,
40	        session_id: Option<&str>,
41	        event: Option<&str>,
42	    ) -> Result<PsycheHandle, String> {
43	        self.0.spawn_psyche(session_id, event)
44	    }
45
46	    /// One pulse tick (T5): ingest drops (T4) then fire a pending echo (T3).
47	    /// Returns the ingested drops and whether an echo fired (the historical shape
48	    /// the `api listen` runner consumes).
49	    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<(Vec<Ingested>, bool), String> {
50	        let TickReport { ingested, echo_fired } = self.0.pulse_tick(session_id)?;
51	        Ok((ingested, echo_fired))
52	    }
53
54	    /// Execute the graceful signoff (T6): echo-commune before teardown (3.3),
55	    /// grace recheck before any signoff write (1.1).
56	    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
57	        self.0.graceful_signoff(session_id)
58	    }
59	}
60
61	/// `api shutdown <id>` — the graceful live-agent signoff path. Builds the live
62	/// host from the manifest and runs the echo-before-teardown ordering (T6). A
63	/// non-live manifest (no Psyche) is a plain soft-stop: just drop the `ready`
64	/// marker. Auth-gated by the dispatcher.
65	pub fn cmd_shutdown(id: &str, manifest: Option<&Manifest>) -> i32 {
66	    let session_id = perch_session_id(id);
67	    match LiveHost::new_opt(manifest, id) {
68	        Some(host) => match host.graceful_signoff(session_id.as_deref()) {
69	            Ok(outcome) => {
70	                eprintln!(
71	                    "SHUTDOWN:{id} echo_ran={} signed_off={}",
72	                    outcome.echo_ran, outcome.signed_off
73	                );
74	                0
75	            }
76	            Err(e) => {
77	                eprintln!("SHUTDOWN_FAIL:{id}: {e}");
78	                super::EXIT_REFUSED
79	            }
80	        },
81	        None => {
82	            // Non-live endpoint: soft-stop only (remove the online marker).
83	            let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
84	            eprintln!("SHUTDOWN:{id} (non-live soft-stop)");
85	            0
86	        }
87	    }
88	}
89
90	/// The bound session id from the perch's info.json, if any.
91	fn perch_session_id(id: &str) -> Option<String> {
92	    let path = perch::resolve_perch_path(id, ParentHint::Infer);
93	    spt_store::info::read_info(&path)
94	        .map(|i| i.session_id)
95	        .filter(|s| !s.is_empty())
96	}
97

//! LiveAgent lifecycle hosting — a thin shim over the daemon's re-hosted
//! lifecycle ([`spt_daemon::BrainLifecycle`]).
//!
//! M2b hosted the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest +
//! echo, graceful signoff ordering) directly in the `api listen` runner. B3
//! consolidated that orchestration **down into `spt-daemon`** (REQ-DAEMON-1); the
//! brain now runs it as scheduled in-process loops paced by a durable config.
//! This module keeps the binary's call sites stable — `cmd_listen` /
//! `cmd_shutdown` — by forwarding to `BrainLifecycle`. The logic lives once, in
//! the daemon crate; the duplicate here is gone.
//
// [impl->REQ-SEAM-PSYCHE]
// [impl->REQ-DAEMON-1]

use spt_daemon::{BrainLifecycle, TickReport};
use spt_live::{Ingested, PsycheHandle, SignoffOutcome};
use spt_runtime::Manifest;
use spt_store::perch::{self, ParentHint};

/// The LiveAgent lifecycle host — a thin newtype over the daemon's
/// [`BrainLifecycle`]. Built from a live-capable manifest; `None` for a plain
/// relay endpoint (no Psyche).
pub struct LiveHost(BrainLifecycle);

impl LiveHost {
    /// Build a host for `id` from a live-capable manifest; `None` if the manifest
    /// is absent or declares no Psyche.
    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<LiveHost> {
        BrainLifecycle::new_opt(manifest, id).map(LiveHost)
    }

    /// Sweep a stale signoff sentinel on listener start (3.2).
    pub fn sweep_on_start(&self) {
        self.0.sweep_on_start();
    }

    /// Spawn the Psyche companion (T2).
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        self.0.spawn_psyche(session_id, event)
    }

    /// One pulse tick (T5): ingest drops (T4) then fire a pending echo (T3).
    /// Returns the ingested drops and whether an echo fired (the historical shape
    /// the `api listen` runner consumes).
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<(Vec<Ingested>, bool), String> {
        let TickReport { ingested, echo_fired } = self.0.pulse_tick(session_id)?;
        Ok((ingested, echo_fired))
    }

    /// Execute the graceful signoff (T6): echo-commune before teardown (3.3),
    /// grace recheck before any signoff write (1.1).
    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
        self.0.graceful_signoff(session_id)
    }
}

/// `api shutdown <id>` — the graceful live-agent signoff path. Builds the live
/// host from the manifest and runs the echo-before-teardown ordering (T6). A
/// non-live manifest (no Psyche) is a plain soft-stop: just drop the `ready`
/// marker. Auth-gated by the dispatcher.
pub fn cmd_shutdown(id: &str, manifest: Option<&Manifest>) -> i32 {
    let session_id = perch_session_id(id);
    match LiveHost::new_opt(manifest, id) {
        Some(host) => match host.graceful_signoff(session_id.as_deref()) {
            Ok(outcome) => {
                eprintln!(
                    "SHUTDOWN:{id} echo_ran={} signed_off={}",
                    outcome.echo_ran, outcome.signed_off
                );
                0
            }
            Err(e) => {
                eprintln!("SHUTDOWN_FAIL:{id}: {e}");
                super::EXIT_REFUSED
            }
        },
        None => {
            // Non-live endpoint: soft-stop only (remove the online marker).
            let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
            eprintln!("SHUTDOWN:{id} (non-live soft-stop)");
            0
        }
    }
}

/// The bound session id from the perch's info.json, if any.
fn perch_session_id(id: &str) -> Option<String> {
    let path = perch::resolve_perch_path(id, ParentHint::Infer);
    spt_store::info::read_info(&path)
        .map(|i| i.session_id)
        .filter(|s| !s.is_empty())
}


Bash

find crates/spt-store/src -name "liveness.rs" -o -name "proc.rs" | head && echo "===LIVENESS===" && cat crates/spt-store/src/liveness.rs 2>/dev/null | head -120

Show liveness module


crates/spt-store/src/liveness.rs
crates/spt-store/src/proc.rs
===LIVENESS===
//! **The** single liveness resolver — daemon-authoritative for daemon-hosted
//! perches, per-pid for everything else (KNOWN-HAZARDS 2.5,
//! REQ-HAZARD-DAEMON-HOSTED-LIVENESS).
//!
//! ## Why one resolver
//! The sister project, and the spt-core **interim** (M1/M2a/M2b) model, evaluate
//! a perch's liveness from its owning process pid: `is_process_alive(info.pid)`,
//! with `clean_stale_entries` dropping dead-pid registry rows. That is correct
//! while every perch has a *dedicated* per-process owner.
//!
//! Under ADR-0004 the daemon hosts the Psyche (and any spt-hosted Self) as a
//! **loop inside the daemon**, not a separate process. Such a perch holds no
//! dedicated pid; its `claude`/summarizer subprocess is **ephemeral** (spawned
//! per pulse/commune, then exits). If liveness keyed on that pid, killing the
//! summarizer would read the whole perch "offline"; if it keyed on the daemon's
//! pid, every hosted endpoint would share one pid and `clean_stale_entries`
//! could never distinguish a torn-down endpoint from a live one (2.5).
//!
//! So for **daemon-hosted** perches liveness is the daemon-managed `status` field
//! on `info.json` ([`crate::info::set_status`]); for **external/interim** perches
//! it stays the per-pid probe. Both decisions live **here**, behind one resolver
//! (mirroring how [`crate::registry::resolve_address`] centralizes stale-clean),
//! so the daemon swap is localized and the per-pid assumption never leaks into a
//! new call site. The three call sites — `deliver::is_online`,
//! `ring::from_has_live_perch`, `roster::enumerate` (perch path) and
//! `registry::clean_stale_entries` (registry row) — all route through this module.
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]

use std::path::Path;

use serde_json::Value;

use crate::perch::{self, ParentHint};
use crate::proc::is_process_alive;

/// Daemon-managed `status` value meaning the hosted endpoint is live.
pub const STATUS_ONLINE: &str = "online";
/// Daemon-managed `status` value meaning the hosted endpoint is torn down.
pub const STATUS_OFFLINE: &str = "offline";

/// Torn-read retry budget (mirrors [`crate::info`]): a reader may hit the file
/// mid-replace; retry a few times, then degrade rather than panic.
const READ_ATTEMPTS: u32 = 5;

/// One tolerant read of a perch's `info.json` as a raw value — schema-minimal
/// records (`{"pid": N}` from wrapper-owned listeners) parse where the full
/// [`crate::info::InfoJson`] shape would not. `None` on persistent absence/corruption.
fn read_raw(perch_path: &Path) -> Option<Value> {
    let path = perch_path.join("info.json");
    for _ in 0..READ_ATTEMPTS {
        if let Ok(content) = std::fs::read_to_string(&path) {
            if let Ok(val) = serde_json::from_str::<Value>(&content) {
                return Some(val);
            }
        }
    }
    None
}

/// The daemon-managed `status` of a perch, if it is daemon-hosted. `Some(_)` ⇒
/// daemon-hosted (liveness is authoritative via status); `None` ⇒ external /
/// interim (liveness is the per-pid probe).
fn read_status(perch_path: &Path) -> Option<String> {
    read_raw(perch_path)?
        .get("status")
        .and_then(|v| v.as_str())
        .map(str::to_string)
}

/// Liveness for a perch by its **directory**. THE resolver for the path-aware
/// call sites (`is_online`, `from_has_live_perch`, roster).
///
/// - **Daemon-hosted** (`status` present): live iff `status == "online"`. The
///   `info.pid` (an ephemeral summarizer pid, or the shared daemon pid) is
///   **never** consulted — killing the summarizer does not flip the perch.
/// - **External/interim** (`status` absent): the per-pid probe, preserving the
///   exact interim semantics — a numeric pid is probed; a `"BUSY"` sentinel or
///   unparseable/absent pid reads alive (the listener is up between polls).
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
pub fn is_perch_alive(perch_path: &Path) -> bool {
    match read_raw(perch_path) {
        Some(val) => {
            if let Some(status) = val.get("status").and_then(|v| v.as_str()) {
                return status == STATUS_ONLINE; // daemon-authoritative; never pid
            }
            match val.get("pid").and_then(|v| v.as_u64()) {
                Some(pid) => is_process_alive(pid as u32),
                None => true, // BUSY sentinel / unparseable pid — interim parity
            }
        }
        None => true, // unreadable info.json — interim parity (read_pid None ⇒ alive)
    }
}

/// Liveness for a **registry row**: its id plus the pid it was registered under,
/// resolved within `owlery` (the registry's own root). Daemon-hosted rows key on
/// the endpoint `status`; external rows pid-probe the **row's** pid exactly as
/// the interim `clean_stale_entries` did — so the daemon swap touches only the
/// hosted rows. An offline daemon-hosted endpoint is cleaned even though the
/// (shared) daemon pid is still alive; a still-online one survives a dead
/// ephemeral pid.
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
pub fn is_registry_entry_alive(id: &str, registry_pid: u32, owlery: &Path) -> bool {
    let perch_path = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
    match read_status(&perch_path) {
        Some(status) => status == STATUS_ONLINE, // daemon-hosted: authoritative
        None => is_process_alive(registry_pid),  // external/interim: pid-probe the row
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::info::{set_status, write_info, InfoJson};

    /// A pid that is never a real allocation (above Linux pid_max ~4.2M and
    /// Windows ranges; positive as i32). Deterministic — avoids pid-reuse flake.
    const DEAD_PID: u32 = 2_000_000_000;

    fn perch() -> tempfile::TempDir {


1	//! Durable daemon configuration (B3) — the configurable pulse period that
2	//! replaces the interim 5s constant.
3	//!
4	//! In the no-daemon interim model the pulse cadence was a hard-coded
5	//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
6	//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
7	//! period is **durable and configurable**, sourced from a small JSON file under
8	//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
9	//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
10	//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
11	//! from config, never a constant.
12	// [impl->REQ-DAEMON-1]
13
14	use std::path::{Path, PathBuf};
15	use std::time::Duration;
16
17	use serde::{Deserialize, Serialize};
18
19	/// The interim default pulse period carried forward as the config default. The
20	/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
21	/// override it per machine.
22	pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);
23
24	/// Default drop-file precedence protection window (epoch-ms), mirroring the
25	/// interim `spt_live` guard. Surfaced here so the one config source owns it.
26	pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;
27
28	/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
29	/// partial file still loads (forward/back-compat) — a missing field takes the
30	/// default. Stored as plain integers (ms) to keep the file human-editable.
31	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32	struct DaemonConfigFile {
33	    #[serde(default, skip_serializing_if = "Option::is_none")]
34	    pulse_period_ms: Option<u64>,
35	    #[serde(default, skip_serializing_if = "Option::is_none")]
36	    protection_window_ms: Option<u64>,
37	}
38
39	/// Resolved daemon configuration. The brain's scheduled loops read their cadence
40	/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
41	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42	pub struct DaemonConfig {
43	    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
44	    pub pulse_period: Duration,
45	    /// The drop-file precedence protection window in epoch-ms (6.5).
46	    pub protection_window_ms: u64,
47	}
48
49	impl Default for DaemonConfig {
50	    fn default() -> Self {
51	        DaemonConfig {
52	            pulse_period: DEFAULT_PULSE_PERIOD,
53	            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
54	        }
55	    }
56	}
57
58	impl DaemonConfig {
59	    /// The canonical config path: `<spt_home>/daemon.json`.
60	    pub fn default_path() -> PathBuf {
61	        spt_store::perch::spt_home().join("daemon.json")
62	    }
63
64	    /// Load the daemon config from the canonical path, falling back to the
65	    /// default if the file is absent or unreadable.
66	    pub fn load() -> Self {
67	        Self::load_from(&Self::default_path())
68	    }
69
70	    /// Load from an explicit path. A missing file → default; a present-but-partial
71	    /// file fills only the named fields and defaults the rest; a malformed file
72	    /// degrades to the default (a broken config must never wedge the daemon).
73	    pub fn load_from(path: &Path) -> Self {
74	        let Ok(raw) = std::fs::read_to_string(path) else {
75	            return Self::default();
76	        };
77	        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
78	            Ok(f) => f,
79	            Err(_) => return Self::default(),
80	        };
81	        Self::from_file(file)
82	    }
83
84	    fn from_file(file: DaemonConfigFile) -> Self {
85	        let d = DaemonConfig::default();
86	        DaemonConfig {
87	            pulse_period: file
88	                .pulse_period_ms
89	                .map(Duration::from_millis)
90	                .unwrap_or(d.pulse_period),
91	            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
92	        }
93	    }
94
95	    /// Persist this config to an explicit path (for `spt` to write the knob).
96	    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
97	        let file = DaemonConfigFile {
98	            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
99	            protection_window_ms: Some(self.protection_window_ms),
100	        };
101	        let json = serde_json::to_string_pretty(&file)
102	            .map_err(|e| std::io::Error::other(e.to_string()))?;
103	        std::fs::write(path, json)
104	    }
105	}
106
107	#[cfg(test)]
108	mod tests {
109	    use super::*;
110
111	    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
112	    fn missing_file_is_default() {
113	        let dir = tempfile::tempdir().unwrap();
114	        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
115	        assert_eq!(cfg, DaemonConfig::default());
116	        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
117	    }
118
119	    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
120	    fn pulse_period_is_config_driven() {
121	        let dir = tempfile::tempdir().unwrap();
122	        let path = dir.path().join("daemon.json");
123	        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();
124
125	        let cfg = DaemonConfig::load_from(&path);
126	        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
127	        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
128	        // The unspecified field keeps its default.
129	        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
130	    }
131
132	    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
133	    fn malformed_file_degrades_to_default() {
134	        let dir = tempfile::tempdir().unwrap();
135	        let path = dir.path().join("daemon.json");
136	        std::fs::write(&path, "not json {{{").unwrap();
137	        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
138	    }
139
140	    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
141	    fn save_load_roundtrip() {
142	        let dir = tempfile::tempdir().unwrap();
143	        let path = dir.path().join("daemon.json");
144	        let cfg = DaemonConfig { pulse_period: Duration::from_millis(750), protection_window_ms: 99 };
145	        cfg.save_to(&path).unwrap();
146	        assert_eq!(DaemonConfig::load_from(&path), cfg);
147	    }
148	}
149

//! Durable daemon configuration (B3) — the configurable pulse period that
//! replaces the interim 5s constant.
//!
//! In the no-daemon interim model the pulse cadence was a hard-coded
//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
//! period is **durable and configurable**, sourced from a small JSON file under
//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
//! from config, never a constant.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// The interim default pulse period carried forward as the config default. The
/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
/// override it per machine.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
/// partial file still loads (forward/back-compat) — a missing field takes the
/// default. Stored as plain integers (ms) to keep the file human-editable.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct DaemonConfigFile {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
}

/// Resolved daemon configuration. The brain's scheduled loops read their cadence
/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DaemonConfig {
    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
    pub pulse_period: Duration,
    /// The drop-file precedence protection window in epoch-ms (6.5).
    pub protection_window_ms: u64,
}

impl Default for DaemonConfig {
    fn default() -> Self {
        DaemonConfig {
            pulse_period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

impl DaemonConfig {
    /// The canonical config path: `<spt_home>/daemon.json`.
    pub fn default_path() -> PathBuf {
        spt_store::perch::spt_home().join("daemon.json")
    }

    /// Load the daemon config from the canonical path, falling back to the
    /// default if the file is absent or unreadable.
    pub fn load() -> Self {
        Self::load_from(&Self::default_path())
    }

    /// Load from an explicit path. A missing file → default; a present-but-partial
    /// file fills only the named fields and defaults the rest; a malformed file
    /// degrades to the default (a broken config must never wedge the daemon).
    pub fn load_from(path: &Path) -> Self {
        let Ok(raw) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
            Ok(f) => f,
            Err(_) => return Self::default(),
        };
        Self::from_file(file)
    }

    fn from_file(file: DaemonConfigFile) -> Self {
        let d = DaemonConfig::default();
        DaemonConfig {
            pulse_period: file
                .pulse_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.pulse_period),
            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
        }
    }

    /// Persist this config to an explicit path (for `spt` to write the knob).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        let file = DaemonConfigFile {
            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
            protection_window_ms: Some(self.protection_window_ms),
        };
        let json = serde_json::to_string_pretty(&file)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        std::fs::write(path, json)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
    fn missing_file_is_default() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
        assert_eq!(cfg, DaemonConfig::default());
        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
    }

    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
    fn pulse_period_is_config_driven() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();

        let cfg = DaemonConfig::load_from(&path);
        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
        // The unspecified field keeps its default.
        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
    }

    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
    fn malformed_file_degrades_to_default() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, "not json {{{").unwrap();
        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
    }

    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
    fn save_load_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        let cfg = DaemonConfig { pulse_period: Duration::from_millis(750), protection_window_ms: 99 };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }
}


Check REQ-DAEMON-4 + the hazard reqs in registry.


grep -n -A6 'REQ-DAEMON-4\|REQ-HAZARD-ECHO-BEFORE-SIGNOFF\|REQ-HAZARD-GRACE-BEFORE-SIGNOFF\|REQ-HAZARD-STALE-SIGNOFF-SENTINEL\|REQ-DAEMON-1' traceable-reqs.toml | head -80

Inspect relevant reqs in registry


41:id = "REQ-DAEMON-1"
42-title = "One per-machine spt-daemon owning all per-machine state"
43-required_stages = ["impl", "unit"]  # activated M3b-B3: interim listen/pulse/Psyche loops consolidated into the brain (lifecycle/config/relay); int via the B9 daemon E2E
44-[[requirements]]
45-id = "REQ-DAEMON-2"
46-title = "Broker/brain split for seamless self-update"
47-required_stages = ["impl", "unit"]
--
53:id = "REQ-DAEMON-4"
54-title = "Honor every KNOWN-HAZARDS invariant"
55-required_stages = []
56-
57-# ───────────────────────────── Storage ──────────────────────────────────────
58-# git-KV BranchStore substrate (ADR-0011, 2026-06-02; implement M4, optional M3c)
59-[[requirements]]
--
408:id = "REQ-HAZARD-GRACE-BEFORE-SIGNOFF"
409-title = "Grace-period wait completes before composing INIT_SIGNOFF (1.1)"
410-required_stages = ["impl", "unit"]
411-[[requirements]]
412-id = "REQ-HAZARD-INFO-JSON-TORN-READ"
413-title = "State-file reads tolerate concurrent writes (1.2)"
414-required_stages = ["impl", "unit"]  # activated M0: spt-store info.json atomic write + tolerant read (T12)
--
448:id = "REQ-HAZARD-STALE-SIGNOFF-SENTINEL"
449-title = "Stale signoff sentinel does not kill a fresh start (3.2)"
450-required_stages = ["impl", "unit"]
451-[[requirements]]
452:id = "REQ-HAZARD-ECHO-BEFORE-SIGNOFF"
453-title = "Echo-commune fires before INIT_SIGNOFF on orphan teardown (3.3)"
454-required_stages = ["impl", "unit"]
455-[[requirements]]
456-id = "REQ-HAZARD-ENVELOPE-DECODE-ORDER"
457-title = "Envelope decode order, ampersand decoded last (4.1)"
458-required_stages = ["impl", "unit"]  # activated M0: spt-proto envelope codec


grep -rn 'grace\|GRACE' crates/spt-live/src crates/spt-daemon/src docs/KNOWN-HAZARDS.md | grep -iv 'graceful_signoff\|//' | head -40

Search for grace constants/usages


crates/spt-live/src/signoff.rs:64:    grace: Duration,
crates/spt-live/src/signoff.rs:79:    if !grace.is_zero() {
crates/spt-live/src/signoff.rs:80:        std::thread::sleep(grace);
docs/KNOWN-HAZARDS.md:11:### 1.1 Phantom INIT_SIGNOFF after grace period
docs/KNOWN-HAZARDS.md:12:- **Failure:** orphan teardown enqueues INIT_SIGNOFF before the grace-period recheck; a transient Self recovery (binary handoff, brief stale poll) makes the recheck pass-as-alive, but the signoff was already spooled and drains on the next iteration → teardown despite a live Self.
docs/KNOWN-HAZARDS.md:13:- **Invariant:** grace-period wait MUST complete *before* composing/delivering INIT_SIGNOFF; the recheck must bind `still_gone` before any envelope write.
docs/KNOWN-HAZARDS.md:14:- **spt-core mapping:** in-daemon now (no separate wrapper), but the ordering invariant is identical — orphan/teardown logic must re-evaluate liveness after the grace wait, not before enqueue.
docs/KNOWN-HAZARDS.md:15:- **Sister cite:** `src/live/wrapper/orphan.rs:201-259` (sleep@209 precedes compose@231-251); tests T-grace-recovery:576, T-still-gone-recheck:618.
docs/KNOWN-HAZARDS.md:19:- **Invariant:** consult liveness via the supervisor (`is_wrapper_alive`-equivalent) before any grace gate; reads of state files must tolerate concurrent writes (atomic write + rename, or read-retry).
docs/KNOWN-HAZARDS.md:148:- **Invariant:** anchor liveness on the stable parent/harness PID; minimal info.json for supervisor-owned perches; mtime grace window (≥60s) masks transient mismatches.
docs/KNOWN-HAZARDS.md:149:- **spt-core mapping:** broker-held handles supersede PID polling for spt-hosted sessions; keep the grace window for harness-hosted.


1	# Known Hazards
2
3	Hard-won edge cases harvested from the sister project (`claude_skill_owl`, ~80 commits / 12+ phases / multiple production incidents). Per ADR-0001, this is a **test checklist for the spt-core rebuild** — the clean-room rebuild must re-satisfy each invariant rather than re-discover the bug.
4
5	**Architecture-translation note.** The sister project runs poll listeners and Psyche wrappers as *separate processes*. spt-core consolidates both into the one `spt-daemon` (brain), with a stable broker beneath it (ADR-0004). Many hazards below were inter-process races in the sister project; in spt-core some become intra-daemon concerns (potentially easier) while others move to the daemon↔broker IPC boundary or the network boundary (potentially new failure surface). Each entry notes the mapping where it differs. Citations point at sister-project paths for reference, not at spt-core.
6
7	---
8
9	## 1. Race conditions & ordering
10
11	### 1.1 Phantom INIT_SIGNOFF after grace period
12	- **Failure:** orphan teardown enqueues INIT_SIGNOFF before the grace-period recheck; a transient Self recovery (binary handoff, brief stale poll) makes the recheck pass-as-alive, but the signoff was already spooled and drains on the next iteration → teardown despite a live Self.
13	- **Invariant:** grace-period wait MUST complete *before* composing/delivering INIT_SIGNOFF; the recheck must bind `still_gone` before any envelope write.
14	- **spt-core mapping:** in-daemon now (no separate wrapper), but the ordering invariant is identical — orphan/teardown logic must re-evaluate liveness after the grace wait, not before enqueue.
15	- **Sister cite:** `src/live/wrapper/orphan.rs:201-259` (sleep@209 precedes compose@231-251); tests T-grace-recovery:576, T-still-gone-recheck:618.
16
17	### 1.2 Poll-rewrite race & info.json mid-write reads
18	- **Failure:** `info.json` written by the wrapper mid-iteration while a list/classify command reads it → torn read, misclassification.
19	- **Invariant:** consult liveness via the supervisor (`is_wrapper_alive`-equivalent) before any grace gate; reads of state files must tolerate concurrent writes (atomic write + rename, or read-retry).
20	- **spt-core mapping:** the daemon owns both writer and reader → use in-process locking/snapshotting instead of racing on disk. Cross-node registry reads remain eventually-consistent and must tolerate staleness.
21	- **Sister cite:** `src/common/list_filter.rs:100-150`; `src/owl/poll.rs:141`.
22
23	### 1.3 Stale `index.lock` wedge from prior git crash
24	- **Failure:** crashed git leaves a 0-byte `index.lock` in a psyche tracked worktree; every later commit blocks forever.
25	- **Invariant:** on daemon boot, sweep seed + all agent/project worktrees for stale locks (0 bytes, mtime > 60s) and remove; leave live locks alone.
26	- **spt-core mapping:** cross-node Psyche sync (ADR-0002/0003) replaces git-repo sync, so the *git* lock may disappear — but any equivalent lockfile in the new sync mechanism needs the same stale-sweep on boot.
27	- **Sister cite:** CHANGELOG v1.11.20 "Stale `index.lock`"; `src/common/git.rs`.
28
29	### 1.4 Deferred spool rows must not leak to the event stream
30	- **Failure:** a hook spools a deferred (spool-only, no TCP wake) notice; startup `drain_all` flushes ALL rows including deferred → event emitted at wrong time/priority.
31	- **Invariant:** startup drain (and idle/timeout TCP-wake sites) use `drain_non_deferred` only; deferred rows are picked up by their intended consumer via `peek`. All drain sites must agree on which rows they flush.
32	- **spt-core mapping:** carries directly — the daemon's spool-drain has the same deferred-vs-immediate distinction.
33	- **Sister cite:** `src/owl/poll.rs:276-316`; `spool::drain_non_deferred_with_metadata`.
34
35	### 1.5 Worker (working-perch) lifecycle path consistency
36	- **Failure:** subagent-start creates the perch at one path layout; later hooks read it at another → not found; stop-hook scan misses nested perches.
37	- **Invariant:** all Worker/Psyche child-perch path composition routes through one central resolver; no divergent path construction across hooks.
38	- **spt-core mapping:** `Worker` is a day-one endpoint type; the daemon owns the registry, so perch location is a registry lookup, not ad-hoc path math. Single source of truth for instance→location.
39	- **Sister cite:** `src/owl/hook_subagent_start.rs:122-168`; `hook_subagent_stop.rs:15-55`.
40
41	---
42
43	## 2. Identity & session-binding
44
45	### 2.1 Parent PID over ephemeral poll PID
46	- **Failure:** orphan check polls an ephemeral listener PID; it dies and is recycled (esp. Windows); a foreign process with the recycled PID reads as alive → false-positive teardown (or false-negative).
47	- **Invariant:** prefer the stable harness-session PID (`parent_pid`) over any ephemeral process PID for liveness; minimal `info.json` for supervisor-owned perches to avoid stale leaks.
48	- **spt-core mapping:** session binding (parent-process-tree anchor) still applies for harness-hosted topology. For spt-hosted sessions the broker holds the child directly → liveness is the broker's held-handle state, more reliable than PID polling.
49	- **Sister cite:** `src/live/wrapper/orphan.rs:141-161`; CHANGELOG v1.11.20.
50
51	### 2.2 Stdin session_id precedence over env
52	- **Failure:** subagent inherits a stale `OWL_SESSION_ID` env across `/clear`; hook gets two session_ids (fresh stdin, stale env) → wrong-agent binding.
53	- **Invariant:** stdin-provided session_id wins; env is fallback only.
54	- **spt-core mapping:** the harness-contract subcommand surface must define the same precedence for whatever identity fields hooks pass in.
55	- **Sister cite:** CHANGELOG v1.35.1 "IN-05"; `hook_subagent_start.rs:40-51`.
56
57	### 2.3 Binary-handoff argv schema must stay backward-compatible
58	- **Failure:** old binary spawns new binary with old argv arity; clap rejects before state rehydration → wrapper dies unlogged.
59	- **Invariant:** every newly-added handoff positional has a default; state-file rehydration happens *after* argv parse; defaults survive intermediate versions.
60	- **spt-core mapping:** CRITICAL — self-update (ADR-0004) makes handoff routine. The broker↔brain IPC and any brain-relaunch argv must be versioned and forward/backward tolerant (a newer brain talks to an older broker). This is the single most update-frequency-sensitive invariant.

# Known Hazards

Hard-won edge cases harvested from the sister project (`claude_skill_owl`, ~80 commits / 12+ phases / multiple production incidents). Per ADR-0001, this is a **test checklist for the spt-core rebuild** — the clean-room rebuild must re-satisfy each invariant rather than re-discover the bug.

**Architecture-translation note.** The sister project runs poll listeners and Psyche wrappers as *separate processes*. spt-core consolidates both into the one `spt-daemon` (brain), with a stable broker beneath it (ADR-0004). Many hazards below were inter-process races in the sister project; in spt-core some become intra-daemon concerns (potentially easier) while others move to the daemon↔broker IPC boundary or the network boundary (potentially new failure surface). Each entry notes the mapping where it differs. Citations point at sister-project paths for reference, not at spt-core.

---

## 1. Race conditions & ordering

### 1.1 Phantom INIT_SIGNOFF after grace period
- **Failure:** orphan teardown enqueues INIT_SIGNOFF before the grace-period recheck; a transient Self recovery (binary handoff, brief stale poll) makes the recheck pass-as-alive, but the signoff was already spooled and drains on the next iteration → teardown despite a live Self.
- **Invariant:** grace-period wait MUST complete *before* composing/delivering INIT_SIGNOFF; the recheck must bind `still_gone` before any envelope write.
- **spt-core mapping:** in-daemon now (no separate wrapper), but the ordering invariant is identical — orphan/teardown logic must re-evaluate liveness after the grace wait, not before enqueue.
- **Sister cite:** `src/live/wrapper/orphan.rs:201-259` (sleep@209 precedes compose@231-251); tests T-grace-recovery:576, T-still-gone-recheck:618.

### 1.2 Poll-rewrite race & info.json mid-write reads
- **Failure:** `info.json` written by the wrapper mid-iteration while a list/classify command reads it → torn read, misclassification.
- **Invariant:** consult liveness via the supervisor (`is_wrapper_alive`-equivalent) before any grace gate; reads of state files must tolerate concurrent writes (atomic write + rename, or read-retry).
- **spt-core mapping:** the daemon owns both writer and reader → use in-process locking/snapshotting instead of racing on disk. Cross-node registry reads remain eventually-consistent and must tolerate staleness.
- **Sister cite:** `src/common/list_filter.rs:100-150`; `src/owl/poll.rs:141`.

### 1.3 Stale `index.lock` wedge from prior git crash
- **Failure:** crashed git leaves a 0-byte `index.lock` in a psyche tracked worktree; every later commit blocks forever.
- **Invariant:** on daemon boot, sweep seed + all agent/project worktrees for stale locks (0 bytes, mtime > 60s) and remove; leave live locks alone.
- **spt-core mapping:** cross-node Psyche sync (ADR-0002/0003) replaces git-repo sync, so the *git* lock may disappear — but any equivalent lockfile in the new sync mechanism needs the same stale-sweep on boot.
- **Sister cite:** CHANGELOG v1.11.20 "Stale `index.lock`"; `src/common/git.rs`.

### 1.4 Deferred spool rows must not leak to the event stream
- **Failure:** a hook spools a deferred (spool-only, no TCP wake) notice; startup `drain_all` flushes ALL rows including deferred → event emitted at wrong time/priority.
- **Invariant:** startup drain (and idle/timeout TCP-wake sites) use `drain_non_deferred` only; deferred rows are picked up by their intended consumer via `peek`. All drain sites must agree on which rows they flush.
- **spt-core mapping:** carries directly — the daemon's spool-drain has the same deferred-vs-immediate distinction.
- **Sister cite:** `src/owl/poll.rs:276-316`; `spool::drain_non_deferred_with_metadata`.

### 1.5 Worker (working-perch) lifecycle path consistency
- **Failure:** subagent-start creates the perch at one path layout; later hooks read it at another → not found; stop-hook scan misses nested perches.
- **Invariant:** all Worker/Psyche child-perch path composition routes through one central resolver; no divergent path construction across hooks.
- **spt-core mapping:** `Worker` is a day-one endpoint type; the daemon owns the registry, so perch location is a registry lookup, not ad-hoc path math. Single source of truth for instance→location.
- **Sister cite:** `src/owl/hook_subagent_start.rs:122-168`; `hook_subagent_stop.rs:15-55`.

---

## 2. Identity & session-binding

### 2.1 Parent PID over ephemeral poll PID
- **Failure:** orphan check polls an ephemeral listener PID; it dies and is recycled (esp. Windows); a foreign process with the recycled PID reads as alive → false-positive teardown (or false-negative).
- **Invariant:** prefer the stable harness-session PID (`parent_pid`) over any ephemeral process PID for liveness; minimal `info.json` for supervisor-owned perches to avoid stale leaks.
- **spt-core mapping:** session binding (parent-process-tree anchor) still applies for harness-hosted topology. For spt-hosted sessions the broker holds the child directly → liveness is the broker's held-handle state, more reliable than PID polling.
- **Sister cite:** `src/live/wrapper/orphan.rs:141-161`; CHANGELOG v1.11.20.

### 2.2 Stdin session_id precedence over env
- **Failure:** subagent inherits a stale `OWL_SESSION_ID` env across `/clear`; hook gets two session_ids (fresh stdin, stale env) → wrong-agent binding.
- **Invariant:** stdin-provided session_id wins; env is fallback only.
- **spt-core mapping:** the harness-contract subcommand surface must define the same precedence for whatever identity fields hooks pass in.
- **Sister cite:** CHANGELOG v1.35.1 "IN-05"; `hook_subagent_start.rs:40-51`.

### 2.3 Binary-handoff argv schema must stay backward-compatible
- **Failure:** old binary spawns new binary with old argv arity; clap rejects before state rehydration → wrapper dies unlogged.
- **Invariant:** every newly-added handoff positional has a default; state-file rehydration happens *after* argv parse; defaults survive intermediate versions.
- **spt-core mapping:** CRITICAL — self-update (ADR-0004) makes handoff routine. The broker↔brain IPC and any brain-relaunch argv must be versioned and forward/backward tolerant (a newer brain talks to an older broker). This is the single most update-frequency-sensitive invariant.


grep -n 'pub struct InfoJson\|pub fn \|parent_pid\|pub pid\|PidValue\|session_id' crates/spt-store/src/info.rs | head -50

Inspect info.json struct API


3://! Adapted from sister `types.rs` (`InfoJson` / `PidValue`) per ADR-0001:
4://! - [`PidValue`] (untagged numeric-or-`"BUSY"`) is copied verbatim — the
27:/// integer or a bare string. Copied verbatim (sister `PidValue`).
30:pub enum PidValue {
38:pub struct InfoJson {
44:    pub pid: PidValue,
46:    pub session_id: String,
50:    pub parent_pid: Option<u32>,
66:    pub fn new(id: &str, started: &str, pid: u32, session_id: &str, state: &str) -> InfoJson {
70:            pid: PidValue::Numeric(pid),
71:            session_id: session_id.to_string(),
73:            parent_pid: None,
85:pub fn set_status(perch_path: &Path, status: &str) -> std::io::Result<()> {
99:pub fn write_info(perch_path: &Path, info: &InfoJson) -> std::io::Result<()> {
110:pub fn read_info(perch_path: &Path) -> Option<InfoJson> {
128:pub fn read_pid(perch_path: &Path) -> Option<u32> {
163:        // parent_pid omitted (None) → absent from JSON.
165:        assert!(!raw.contains("parent_pid"), "None field must be omitted: {raw}");
177:            pid: PidValue::Busy("BUSY".to_string()),
178:            session_id: "sid".to_string(),
180:            parent_pid: None,
185:        assert_eq!(read_info(d.path()).unwrap().pid, PidValue::Busy("BUSY".to_string()));


Check `is_process_alive` export + proc API.


grep -n 'pub fn is_process_alive\|pub mod proc' crates/spt-store/src/proc.rs crates/spt-store/src/lib.rs

Confirm is_process_alive visibility


crates/spt-store/src/proc.rs:9:pub fn is_process_alive(pid: u32) -> bool {
crates/spt-store/src/lib.rs:19:pub mod proc;


Plan clear. Design locked:

**B7 orphan-watch** = supervised-crash signoff path. Differs from graceful only in two hazard-load-bearing spots: real grace (1.1) + real liveness recheck (anchored on `parent_pid`, 2.1). Echo-before-signoff (3.3) + stale-sweep (3.2) carried in. Factor shared signoff wiring; crash path re-probes the harness owner so a recovered Self (binary handoff) is spared.


ToolSearch

Question: select:TaskCreate


Question: select:TaskCreate


TaskCreate

config: add crash_grace knob

Add crash_grace Duration to DaemonConfig + crash_grace_ms to file struct + DEFAULT_CRASH_GRACE const. The grace window (hazard 1.1) for the orphan-watch recheck.


Task #1 created successfully: config: add crash_grace knob

config: add crash_grace knob


lifecycle: supervised-crash signoff + orphan-watch loop

Refactor graceful_signoff to share wiring; add supervised_crash_signoff (real grace + real recheck), harness_owner_gone (parent_pid probe, 2.1), run_orphan_watch (poll owner pid, sweep-on-start 3.2, fire crash signoff on death). Tests: same ordering as graceful; recovered Self spared.


Task #2 created successfully: lifecycle: supervised-crash signoff + orphan-watch loop


Activate REQ-DAEMON-4 + tag evidence

Set REQ-DAEMON-4 required_stages=["impl","unit"] in traceable-reqs.toml with B7 note; tag impl/unit evidence in lifecycle.rs.


Task #3 created successfully: Activate REQ-DAEMON-4 + tag evidence

Activate REQ-DAEMON-4 + tag evidence


Gate green + commit

cargo test --workspace, clippy -D warnings, traceable-reqs check. Then commit with Co-Authored-By trailer.


Task #4 created successfully: Gate green + commit

Gate green + commit


Task 1 — config knob.


Edit

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
/// waits after observing a hosted Self's harness owner gone before it rechecks
/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
/// — a binary handoff relaunch or a brief stale poll — without delaying a real
/// teardown for long. Configurable per machine via `crash_grace_ms`.
pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\config.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
/// waits after observing a hosted Self's harness owner gone before it rechecks
/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
/// — a binary handoff relaunch or a brief stale poll — without delaying a real
/// teardown for long. Configurable per machine via `crash_grace_ms`.
pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);

//! Durable daemon configuration (B3) — the configurable pulse period that
//! replaces the interim 5s constant.
//!
//! In the no-daemon interim model the pulse cadence was a hard-coded
//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
//! period is **durable and configurable**, sourced from a small JSON file under
//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
//! from config, never a constant.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// The interim default pulse period carried forward as the config default. The
/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
/// override it per machine.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
/// partial file still loads (forward/back-compat) — a missing field takes the
/// default. Stored as plain integers (ms) to keep the file human-editable.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct DaemonConfigFile {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
}

/// Resolved daemon configuration. The brain's scheduled loops read their cadence
/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DaemonConfig {
    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
    pub pulse_period: Duration,
    /// The drop-file precedence protection window in epoch-ms (6.5).
    pub protection_window_ms: u64,
}

impl Default for DaemonConfig {
    fn default() -> Self {
        DaemonConfig {
            pulse_period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

impl DaemonConfig {
    /// The canonical config path: `<spt_home>/daemon.json`.
    pub fn default_path() -> PathBuf {
        spt_store::perch::spt_home().join("daemon.json")
    }

    /// Load the daemon config from the canonical path, falling back to the
    /// default if the file is absent or unreadable.
    pub fn load() -> Self {
        Self::load_from(&Self::default_path())
    }

    /// Load from an explicit path. A missing file → default; a present-but-partial
    /// file fills only the named fields and defaults the rest; a malformed file
    /// degrades to the default (a broken config must never wedge the daemon).
    pub fn load_from(path: &Path) -> Self {
        let Ok(raw) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
            Ok(f) => f,
            Err(_) => return Self::default(),
        };
        Self::from_file(file)
    }

    fn from_file(file: DaemonConfigFile) -> Self {
        let d = DaemonConfig::default();
        DaemonConfig {
            pulse_period: file
                .pulse_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.pulse_period),
            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
        }
    }

    /// Persist this config to an explicit path (for `spt` to write the knob).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        let file = DaemonConfigFile {
            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
            protection_window_ms: Some(self.protection_window_ms),
        };
        let json = serde_json::to_string_pretty(&file)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        std::fs::write(path, json)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
    fn missing_file_is_default() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
        assert_eq!(cfg, DaemonConfig::default());
        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
    }

    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
    fn pulse_period_is_config_driven() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();

        let cfg = DaemonConfig::load_from(&path);
        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
        // The unspecified field keeps its default.
        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
    }

    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
    fn malformed_file_degrades_to_default() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, "not json {{{").unwrap();
        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
    }

    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
    fn save_load_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        let cfg = DaemonConfig { pulse_period: Duration::from_millis(750), protection_window_ms: 99 };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }
}

+/// Default orphan-watch grace window (B7): how long the supervised-crash teardown

+/// waits after observing a hosted Self's harness owner gone before it rechecks

+/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery

+/// — a binary handoff relaunch or a brief stale poll — without delaying a real

+/// teardown for long. Configurable per machine via `crash_grace_ms`.

+pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);


    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
}

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    crash_grace_ms: Option<u64>,
}


    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    crash_grace_ms: Option<u64>,
}

//! Durable daemon configuration (B3) — the configurable pulse period that
//! replaces the interim 5s constant.
//!
//! In the no-daemon interim model the pulse cadence was a hard-coded
//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
//! period is **durable and configurable**, sourced from a small JSON file under
//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
//! from config, never a constant.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// The interim default pulse period carried forward as the config default. The
/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
/// override it per machine.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
/// waits after observing a hosted Self's harness owner gone before it rechecks
/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
/// — a binary handoff relaunch or a brief stale poll — without delaying a real
/// teardown for long. Configurable per machine via `crash_grace_ms`.
pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);

/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
/// partial file still loads (forward/back-compat) — a missing field takes the
/// default. Stored as plain integers (ms) to keep the file human-editable.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct DaemonConfigFile {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
}

/// Resolved daemon configuration. The brain's scheduled loops read their cadence
/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DaemonConfig {
    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
    pub pulse_period: Duration,
    /// The drop-file precedence protection window in epoch-ms (6.5).
    pub protection_window_ms: u64,
}

impl Default for DaemonConfig {
    fn default() -> Self {
        DaemonConfig {
            pulse_period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

impl DaemonConfig {
    /// The canonical config path: `<spt_home>/daemon.json`.
    pub fn default_path() -> PathBuf {
        spt_store::perch::spt_home().join("daemon.json")
    }

    /// Load the daemon config from the canonical path, falling back to the
    /// default if the file is absent or unreadable.
    pub fn load() -> Self {
        Self::load_from(&Self::default_path())
    }

    /// Load from an explicit path. A missing file → default; a present-but-partial
    /// file fills only the named fields and defaults the rest; a malformed file
    /// degrades to the default (a broken config must never wedge the daemon).
    pub fn load_from(path: &Path) -> Self {
        let Ok(raw) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
            Ok(f) => f,
            Err(_) => return Self::default(),
        };
        Self::from_file(file)
    }

    fn from_file(file: DaemonConfigFile) -> Self {
        let d = DaemonConfig::default();
        DaemonConfig {
            pulse_period: file
                .pulse_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.pulse_period),
            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
        }
    }

    /// Persist this config to an explicit path (for `spt` to write the knob).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        let file = DaemonConfigFile {
            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
            protection_window_ms: Some(self.protection_window_ms),
        };
        let json = serde_json::to_string_pretty(&file)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        std::fs::write(path, json)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
    fn missing_file_is_default() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
        assert_eq!(cfg, DaemonConfig::default());
        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
    }

    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
    fn pulse_period_is_config_driven() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();

        let cfg = DaemonConfig::load_from(&path);
        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
        // The unspecified field keeps its default.
        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
    }

    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
    fn malformed_file_degrades_to_default() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, "not json {{{").unwrap();
        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
    }

    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
    fn save_load_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        let cfg = DaemonConfig { pulse_period: Duration::from_millis(750), protection_window_ms: 99 };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }
}

+    #[serde(default, skip_serializing_if = "Option::is_none")]


    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
    pub pulse_period: Duration,
    /// The drop-file precedence protection window in epoch-ms (6.5).
    pub protection_window_ms: u64,
}

    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
    pub pulse_period: Duration,
    /// The drop-file precedence protection window in epoch-ms (6.5).
    pub protection_window_ms: u64,
    /// The orphan-watch grace window (B7): the recovery wait before a
    /// supervised-crash teardown rechecks liveness and signs off (1.1).
    pub crash_grace: Duration,
}


    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
    pub pulse_period: Duration,
    /// The drop-file precedence protection window in epoch-ms (6.5).
    pub protection_window_ms: u64,
    /// The orphan-watch grace window (B7): the recovery wait before a
    /// supervised-crash teardown rechecks liveness and signs off (1.1).
    pub crash_grace: Duration,
}

//! Durable daemon configuration (B3) — the configurable pulse period that
//! replaces the interim 5s constant.
//!
//! In the no-daemon interim model the pulse cadence was a hard-coded
//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
//! period is **durable and configurable**, sourced from a small JSON file under
//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
//! from config, never a constant.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// The interim default pulse period carried forward as the config default. The
/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
/// override it per machine.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
/// waits after observing a hosted Self's harness owner gone before it rechecks
/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
/// — a binary handoff relaunch or a brief stale poll — without delaying a real
/// teardown for long. Configurable per machine via `crash_grace_ms`.
pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);

/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
/// partial file still loads (forward/back-compat) — a missing field takes the
/// default. Stored as plain integers (ms) to keep the file human-editable.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct DaemonConfigFile {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    crash_grace_ms: Option<u64>,
}

/// Resolved daemon configuration. The brain's scheduled loops read their cadence
/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DaemonConfig {
    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
    pub pulse_period: Duration,
    /// The drop-file precedence protection window in epoch-ms (6.5).
    pub protection_window_ms: u64,
}

impl Default for DaemonConfig {
    fn default() -> Self {
        DaemonConfig {
            pulse_period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

impl DaemonConfig {
    /// The canonical config path: `<spt_home>/daemon.json`.
    pub fn default_path() -> PathBuf {
        spt_store::perch::spt_home().join("daemon.json")
    }

    /// Load the daemon config from the canonical path, falling back to the
    /// default if the file is absent or unreadable.
    pub fn load() -> Self {
        Self::load_from(&Self::default_path())
    }

    /// Load from an explicit path. A missing file → default; a present-but-partial
    /// file fills only the named fields and defaults the rest; a malformed file
    /// degrades to the default (a broken config must never wedge the daemon).
    pub fn load_from(path: &Path) -> Self {
        let Ok(raw) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
            Ok(f) => f,
            Err(_) => return Self::default(),
        };
        Self::from_file(file)
    }

    fn from_file(file: DaemonConfigFile) -> Self {
        let d = DaemonConfig::default();
        DaemonConfig {
            pulse_period: file
                .pulse_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.pulse_period),
            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
        }
    }

    /// Persist this config to an explicit path (for `spt` to write the knob).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        let file = DaemonConfigFile {
            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
            protection_window_ms: Some(self.protection_window_ms),
        };
        let json = serde_json::to_string_pretty(&file)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        std::fs::write(path, json)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
    fn missing_file_is_default() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
        assert_eq!(cfg, DaemonConfig::default());
        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
    }

    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
    fn pulse_period_is_config_driven() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();

        let cfg = DaemonConfig::load_from(&path);
        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
        // The unspecified field keeps its default.
        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
    }

    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
    fn malformed_file_degrades_to_default() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, "not json {{{").unwrap();
        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
    }

    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
    fn save_load_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        let cfg = DaemonConfig { pulse_period: Duration::from_millis(750), protection_window_ms: 99 };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }
}

+    /// The orphan-watch grace window (B7): the recovery wait before a

+    /// supervised-crash teardown rechecks liveness and signs off (1.1).


        DaemonConfig {
            pulse_period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

        DaemonConfig {
            pulse_period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
            crash_grace: DEFAULT_CRASH_GRACE,
        }
    }
}


        DaemonConfig {
            pulse_period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
            crash_grace: DEFAULT_CRASH_GRACE,
        }
    }
}

//! Durable daemon configuration (B3) — the configurable pulse period that
//! replaces the interim 5s constant.
//!
//! In the no-daemon interim model the pulse cadence was a hard-coded
//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
//! period is **durable and configurable**, sourced from a small JSON file under
//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
//! from config, never a constant.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// The interim default pulse period carried forward as the config default. The
/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
/// override it per machine.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
/// waits after observing a hosted Self's harness owner gone before it rechecks
/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
/// — a binary handoff relaunch or a brief stale poll — without delaying a real
/// teardown for long. Configurable per machine via `crash_grace_ms`.
pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);

/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
/// partial file still loads (forward/back-compat) — a missing field takes the
/// default. Stored as plain integers (ms) to keep the file human-editable.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct DaemonConfigFile {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    crash_grace_ms: Option<u64>,
}

/// Resolved daemon configuration. The brain's scheduled loops read their cadence
/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DaemonConfig {
    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
    pub pulse_period: Duration,
    /// The drop-file precedence protection window in epoch-ms (6.5).
    pub protection_window_ms: u64,
    /// The orphan-watch grace window (B7): the recovery wait before a
    /// supervised-crash teardown rechecks liveness and signs off (1.1).
    pub crash_grace: Duration,
}

impl Default for DaemonConfig {
    fn default() -> Self {
        DaemonConfig {
            pulse_period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

impl DaemonConfig {
    /// The canonical config path: `<spt_home>/daemon.json`.
    pub fn default_path() -> PathBuf {
        spt_store::perch::spt_home().join("daemon.json")
    }

    /// Load the daemon config from the canonical path, falling back to the
    /// default if the file is absent or unreadable.
    pub fn load() -> Self {
        Self::load_from(&Self::default_path())
    }

    /// Load from an explicit path. A missing file → default; a present-but-partial
    /// file fills only the named fields and defaults the rest; a malformed file
    /// degrades to the default (a broken config must never wedge the daemon).
    pub fn load_from(path: &Path) -> Self {
        let Ok(raw) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
            Ok(f) => f,
            Err(_) => return Self::default(),
        };
        Self::from_file(file)
    }

    fn from_file(file: DaemonConfigFile) -> Self {
        let d = DaemonConfig::default();
        DaemonConfig {
            pulse_period: file
                .pulse_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.pulse_period),
            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
        }
    }

    /// Persist this config to an explicit path (for `spt` to write the knob).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        let file = DaemonConfigFile {
            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
            protection_window_ms: Some(self.protection_window_ms),
        };
        let json = serde_json::to_string_pretty(&file)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        std::fs::write(path, json)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
    fn missing_file_is_default() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
        assert_eq!(cfg, DaemonConfig::default());
        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
    }

    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
    fn pulse_period_is_config_driven() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();

        let cfg = DaemonConfig::load_from(&path);
        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
        // The unspecified field keeps its default.
        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
    }

    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
    fn malformed_file_degrades_to_default() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, "not json {{{").unwrap();
        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
    }

    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
    fn save_load_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        let cfg = DaemonConfig { pulse_period: Duration::from_millis(750), protection_window_ms: 99 };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }
}

+            crash_grace: DEFAULT_CRASH_GRACE,


            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
        }
    }

            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
            crash_grace: file
                .crash_grace_ms
                .map(Duration::from_millis)
                .unwrap_or(d.crash_grace),
        }
    }