1	//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
2	//!
3	//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
4	//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
5	//! structured outcome, return an exit code. No messaging logic lives in this
6	//! layer.
7	//!
8	//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
9	//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
10	//! (clap, for unknown/missing args).
11	// [impl->REQ-MSG-2]
12
13	use std::io::{Read, Write};
14	use std::time::Duration;
15
16	use clap::{Parser, Subcommand};
17	use spt_msg::deliver::{self, SendOutcome};
18	use spt_msg::ready::ReadyAgent;
19	use spt_msg::ring::{self, RingOutcome};
20	use spt_store::perch::{self, ParentHint};
21	use spt_store::registry;
22
23	use crate::roster;
24
25	#[derive(Parser)]
26	#[command(name = "spt", version, about = "spt-core local messaging", long_about = None)]
27	struct Cli {
28	    #[command(subcommand)]
29	    cmd: Cmd,
30	}
31
32	#[derive(Subcommand)]
33	enum Cmd {
34	    /// Send a message (body read from stdin). Fire-and-forget.
35	    Send {
36	        /// Target perch id. May be omitted when --reply-to is given.
37	        target: Option<String>,
38	        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
39	        #[arg(long)]
40	        from: Option<String>,
41	        /// Reply to this sender; sets the target and labels output REPLIED.
42	        #[arg(long)]
43	        reply_to: Option<String>,
44	        /// Spool only (deferred / hook channel) — no live wake.
45	        #[arg(long)]
46	        deferred: bool,
47	    },
48	    /// Send and block for a reply (body read from stdin; reply printed to stdout).
49	    Ring {
50	        /// Target perch id.
51	        target: String,
52	        /// Sender id (auto-detected from session if omitted).
53	        #[arg(long)]
54	        from: Option<String>,
55	        /// Seconds to wait for a reply before giving up.
56	        #[arg(long, default_value = "60")]
57	        timeout: u64,
58	    },
59	    /// Become reachable: register the perch and listen for messages (blocks).
60	    Ready {
61	        /// This agent's perch id.
62	        id: String,
63	    },
64	    /// Receive messages: drain backlog then listen, printing each to stdout.
65	    Poll {
66	        /// This agent's perch id.
67	        id: String,
68	        /// Run a single drain+receive cycle, then exit (legacy one-shot).
69	        #[arg(long)]
70	        once: bool,
71	    },
72	    /// List perches under the owlery (id, state, address, ready, alive).
73	    List,
74	    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
75	    Stop {
76	        /// Perch id to stop.
77	        id: String,
78	    },
79	    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
80	    Whoami,
81	    /// Harness-contract inbound surface (REQ-API-1): the entry points a
82	    /// harness's hooks fire to mutate on-disk SPT state.
83	    Api(crate::api::ApiArgs),
84	    /// Run the per-machine broker/brain daemon (REQ-DAEMON-3). Internal: an
85	    /// `api` invocation auto-starts this; operators rarely run it directly.
86	    #[command(hide = true)]
87	    Daemon,
88	}
89
90	/// Parse args and dispatch. Returns the process exit code.
91	pub fn run() -> i32 {
92	    let cli = Cli::parse();
93	    match cli.cmd {
94	        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
95	        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
96	        Cmd::Ready { id } => run_listen(&id, false),
97	        Cmd::Poll { id, once } => run_listen(&id, once),
98	        Cmd::List => cmd_list(),
99	        Cmd::Stop { id } => cmd_stop(&id),
100	        Cmd::Whoami => cmd_whoami(),
101	        Cmd::Api(args) => crate::api::run(args),
102	        Cmd::Daemon => cmd_daemon(),
103	    }
104	}
105
106	/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
107	/// serving the broker + seed-control channels until killed; a bind contention
108	/// (another daemon already owns this home) surfaces as a non-zero exit.
109	// [impl->REQ-DAEMON-3]
110	fn cmd_daemon() -> i32 {
111	    match spt_daemon::Daemon::run() {
112	        Ok(()) => 0,
113	        Err(e) => {
114	            eprintln!("DAEMON_EXIT: {e}");
115	            1
116	        }
117	    }
118	}
119
120	/// Read stdin to a string (message body source for send/ring).
121	fn read_stdin() -> String {
122	    let mut body = String::new();
123	    let _ = std::io::stdin().read_to_string(&mut body);
124	    body
125	}
126
127	/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
128	fn resolve_from(from: Option<String>) -> String {
129	    from.or_else(roster::detect_self_id).unwrap_or_default()
130	}
131
132	fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
133	    let is_reply = reply_to.is_some();
134	    // --reply-to provides the target when no positional target is given.
135	    let target = match target.or_else(|| reply_to.clone()) {
136	        Some(t) => t,
137	        None => {
138	            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
139	            return 2;
140	        }
141	    };
142	    let from = resolve_from(from);
143	    let body = read_stdin();
144	    let owlery = perch::owlery_dir();
145
146	    let outcome = if deferred {
147	        deliver::send_deferred(&target, &from, &body)
148	    } else {
149	        deliver::send(&target, &from, &body, &owlery)
150	    };
151
152	    match outcome {
153	        SendOutcome::Sent => {
154	            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
155	            0
156	        }
157	        SendOutcome::Queued => {
158	            let label = if deferred {
159	                "DEFERRED"
160	            } else if is_reply {
161	                "REPLIED(QUEUED)"
162	            } else {
163	                "QUEUED"
164	            };
165	            eprintln!("{}:{}", label, target);
166	            0
167	        }
168	        SendOutcome::NoPerch => {
169	            eprintln!("NO_PERCH:{} is not listening", target);
170	            1
171	        }
172	        SendOutcome::Empty => {
173	            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
174	            1
175	        }
176	    }
177	}
178
179	fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
180	    let from = resolve_from(from);
181	    if from.is_empty() {
182	        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
183	        return 1;
184	    }
185	    let body = read_stdin();
186	    let owlery = perch::owlery_dir();
187
188	    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
189	        RingOutcome::Replied(reply) => {
190	            print!("{}", reply);
191	            let _ = std::io::stdout().flush();
192	            0
193	        }
194	        RingOutcome::TimedOut => {
195	            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
196	            0
197	        }
198	        RingOutcome::Queued => {
199	            eprintln!("QUEUED:{}", target);
200	            0
201	        }
202	        RingOutcome::Delivered => {
203	            eprintln!("SENT:{} (reply will arrive on your listener)", target);
204	            0
205	        }
206	        RingOutcome::NoPerch => {
207	            eprintln!("NO_PERCH:{} is not listening", target);
208	            1
209	        }
210	        RingOutcome::Empty => {
211	            eprintln!("EMPTY_MSG: refusing to ring {} with an empty message", target);
212	            1
213	        }
214	    }
215	}
216
217	/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
218	/// until the process is killed unless `once` is set. Each received message is
219	/// printed framed to stdout; status goes to stderr.
220	///
221	/// Interim M1 model: the perch is reachable only while this process runs. M3's
222	/// spt-daemon decouples registration from the live process.
223	fn run_listen(id: &str, once: bool) -> i32 {
224	    let (agent, backlog) = match ReadyAgent::start(id) {
225	        Ok(pair) => pair,
226	        Err(e) => {
227	            eprintln!("READY_FAIL:{}: {}", id, e);
228	            return 1;
229	        }
230	    };
231	    eprintln!("READY:{}", id);
232	    for msg in backlog {
233	        emit(&msg);
234	    }
235
236	    if once {
237	        if let Some(msg) = agent.recv() {
238	            emit(&msg);
239	        }
240	        for msg in agent.drain_spool() {
241	            emit(&msg);
242	        }
243	        return 0;
244	    }
245
246	    loop {
247	        if let Some(msg) = agent.recv() {
248	            emit(&msg);
249	        }
250	        for msg in agent.drain_spool() {
251	            emit(&msg);
252	        }
253	    }
254	}
255
256	/// Print a framed message to stdout and flush (one delivery per emit).
257	fn emit(msg: &str) {
258	    print!("{}", msg);
259	    let _ = std::io::stdout().flush();
260	}
261
262	fn cmd_list() -> i32 {
263	    let perches = roster::enumerate();
264	    if perches.is_empty() {
265	        eprintln!("(no perches)");
266	        return 0;
267	    }
268	    for p in perches {
269	        println!(
270	            "{:<28} {:<14} {:<22} ready={} alive={}",
271	            p.id,
272	            p.state,
273	            p.address.as_deref().unwrap_or("-"),
274	            p.ready,
275	            p.alive
276	        );
277	    }
278	    0
279	}
280
281	fn cmd_stop(id: &str) -> i32 {
282	    let owlery = perch::owlery_dir();
283	    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
284	    // unregister the address; spool.db and info.json are preserved.
285	    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
286	    let _ = registry::unregister_address(id, &owlery);
287	    if removed {
288	        eprintln!("STOPPED:{}", id);
289	    } else {
290	        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
291	    }
292	    0
293	}
294
295	fn cmd_whoami() -> i32 {
296	    match roster::detect_self_id() {
297	        Some(id) => {
298	            println!("{}", id);
299	            0
300	        }
301	        None => {
302	            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
303	            1
304	        }
305	    }
306	}
307
308	#[cfg(test)]
309	mod tests {
310	    use super::*;
311
312	    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
313	        Cli::try_parse_from(args)
314	    }
315
316	    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
317	    #[test]
318	    fn subcommands_parse() {
319	        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
320	        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
321	        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
322	        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
323	        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
324	        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
325	        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
326	        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
327	        // The hidden daemon entry point parses (auto-started by `api`).
328	        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
329	    }
330
331	    // [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.
332	    #[test]
333	    fn ring_timeout_defaults() {
334	        match parse(&["spt", "ring", "bob"]).unwrap().cmd {
335	            Cmd::Ring { timeout, .. } => assert_eq!(timeout, 60),
336	            _ => panic!("expected Ring"),
337	        }
338	    }
339
340	    // [unit->REQ-MSG-2] send target is optional (supplied via --reply-to instead).
341	    #[test]
342	    fn send_target_optional_with_reply_to() {
343	        match parse(&["spt", "send", "--reply-to", "alice"]).unwrap().cmd {
344	            Cmd::Send { target, reply_to, .. } => {
345	                assert!(target.is_none());
346	                assert_eq!(reply_to.as_deref(), Some("alice"));
347	            }
348	            _ => panic!("expected Send"),
349	        }
350	    }
351
352	    // [unit->REQ-MSG-2] unknown subcommand / missing required arg are usage errors.
353	    #[test]
354	    fn invalid_usage_errors() {
355	        assert!(parse(&["spt", "bogus"]).is_err());
356	        assert!(parse(&["spt", "ring"]).is_err()); // missing required target
357	    }
358	}
359

//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
//!
//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
//! structured outcome, return an exit code. No messaging logic lives in this
//! layer.
//!
//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
//! (clap, for unknown/missing args).
// [impl->REQ-MSG-2]

use std::io::{Read, Write};
use std::time::Duration;

use clap::{Parser, Subcommand};
use spt_msg::deliver::{self, SendOutcome};
use spt_msg::ready::ReadyAgent;
use spt_msg::ring::{self, RingOutcome};
use spt_store::perch::{self, ParentHint};
use spt_store::registry;

use crate::roster;

#[derive(Parser)]
#[command(name = "spt", version, about = "spt-core local messaging", long_about = None)]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Send a message (body read from stdin). Fire-and-forget.
    Send {
        /// Target perch id. May be omitted when --reply-to is given.
        target: Option<String>,
        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Reply to this sender; sets the target and labels output REPLIED.
        #[arg(long)]
        reply_to: Option<String>,
        /// Spool only (deferred / hook channel) — no live wake.
        #[arg(long)]
        deferred: bool,
    },
    /// Send and block for a reply (body read from stdin; reply printed to stdout).
    Ring {
        /// Target perch id.
        target: String,
        /// Sender id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Seconds to wait for a reply before giving up.
        #[arg(long, default_value = "60")]
        timeout: u64,
    },
    /// Become reachable: register the perch and listen for messages (blocks).
    Ready {
        /// This agent's perch id.
        id: String,
    },
    /// Receive messages: drain backlog then listen, printing each to stdout.
    Poll {
        /// This agent's perch id.
        id: String,
        /// Run a single drain+receive cycle, then exit (legacy one-shot).
        #[arg(long)]
        once: bool,
    },
    /// List perches under the owlery (id, state, address, ready, alive).
    List,
    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
    Stop {
        /// Perch id to stop.
        id: String,
    },
    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),
    /// Run the per-machine broker/brain daemon (REQ-DAEMON-3). Internal: an
    /// `api` invocation auto-starts this; operators rarely run it directly.
    #[command(hide = true)]
    Daemon,
}

/// Parse args and dispatch. Returns the process exit code.
pub fn run() -> i32 {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
        Cmd::Ready { id } => run_listen(&id, false),
        Cmd::Poll { id, once } => run_listen(&id, once),
        Cmd::List => cmd_list(),
        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
        Cmd::Api(args) => crate::api::run(args),
        Cmd::Daemon => cmd_daemon(),
    }
}

/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
/// serving the broker + seed-control channels until killed; a bind contention
/// (another daemon already owns this home) surfaces as a non-zero exit.
// [impl->REQ-DAEMON-3]
fn cmd_daemon() -> i32 {
    match spt_daemon::Daemon::run() {
        Ok(()) => 0,
        Err(e) => {
            eprintln!("DAEMON_EXIT: {e}");
            1
        }
    }
}

/// Read stdin to a string (message body source for send/ring).
fn read_stdin() -> String {
    let mut body = String::new();
    let _ = std::io::stdin().read_to_string(&mut body);
    body
}

/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
fn resolve_from(from: Option<String>) -> String {
    from.or_else(roster::detect_self_id).unwrap_or_default()
}

fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
    let is_reply = reply_to.is_some();
    // --reply-to provides the target when no positional target is given.
    let target = match target.or_else(|| reply_to.clone()) {
        Some(t) => t,
        None => {
            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
            return 2;
        }
    };
    let from = resolve_from(from);
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, &body)
    } else {
        deliver::send(&target, &from, &body, &owlery)
    };

    match outcome {
        SendOutcome::Sent => {
            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
            0
        }
        SendOutcome::Queued => {
            let label = if deferred {
                "DEFERRED"
            } else if is_reply {
                "REPLIED(QUEUED)"
            } else {
                "QUEUED"
            };
            eprintln!("{}:{}", label, target);
            0
        }
        SendOutcome::NoPerch => {
            eprintln!("NO_PERCH:{} is not listening", target);
            1
        }
        SendOutcome::Empty => {
            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
            1
        }
    }
}

fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
    let from = resolve_from(from);
    if from.is_empty() {
        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
        RingOutcome::Replied(reply) => {
            print!("{}", reply);
            let _ = std::io::stdout().flush();
            0
        }
        RingOutcome::TimedOut => {
            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
            0
        }
        RingOutcome::Queued => {
            eprintln!("QUEUED:{}", target);
            0
        }
        RingOutcome::Delivered => {
            eprintln!("SENT:{} (reply will arrive on your listener)", target);
            0
        }
        RingOutcome::NoPerch => {
            eprintln!("NO_PERCH:{} is not listening", target);
            1
        }
        RingOutcome::Empty => {
            eprintln!("EMPTY_MSG: refusing to ring {} with an empty message", target);
            1
        }
    }
}

/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
/// until the process is killed unless `once` is set. Each received message is
/// printed framed to stdout; status goes to stderr.
///
/// Interim M1 model: the perch is reachable only while this process runs. M3's
/// spt-daemon decouples registration from the live process.
fn run_listen(id: &str, once: bool) -> i32 {
    let (agent, backlog) = match ReadyAgent::start(id) {
        Ok(pair) => pair,
        Err(e) => {
            eprintln!("READY_FAIL:{}: {}", id, e);
            return 1;
        }
    };
    eprintln!("READY:{}", id);
    for msg in backlog {
        emit(&msg);
    }

    if once {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
        return 0;
    }

    loop {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
    }
}

/// Print a framed message to stdout and flush (one delivery per emit).
fn emit(msg: &str) {
    print!("{}", msg);
    let _ = std::io::stdout().flush();
}

fn cmd_list() -> i32 {
    let perches = roster::enumerate();
    if perches.is_empty() {
        eprintln!("(no perches)");
        return 0;
    }
    for p in perches {
        println!(
            "{:<28} {:<14} {:<22} ready={} alive={}",
            p.id,
            p.state,
            p.address.as_deref().unwrap_or("-"),
            p.ready,
            p.alive
        );
    }
    0
}

fn cmd_stop(id: &str) -> i32 {
    let owlery = perch::owlery_dir();
    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
    // unregister the address; spool.db and info.json are preserved.
    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
    let _ = registry::unregister_address(id, &owlery);
    if removed {
        eprintln!("STOPPED:{}", id);
    } else {
        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
    }
    0
}

fn cmd_whoami() -> i32 {
    match roster::detect_self_id() {
        Some(id) => {
            println!("{}", id);
            0
        }
        None => {
            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
            1
        }
    }
}

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

    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
        Cli::try_parse_from(args)
    }

    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
    #[test]
    fn subcommands_parse() {
        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
        // The hidden daemon entry point parses (auto-started by `api`).
        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
    }

    // [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.
    #[test]
    fn ring_timeout_defaults() {
        match parse(&["spt", "ring", "bob"]).unwrap().cmd {
            Cmd::Ring { timeout, .. } => assert_eq!(timeout, 60),
            _ => panic!("expected Ring"),
        }
    }

    // [unit->REQ-MSG-2] send target is optional (supplied via --reply-to instead).
    #[test]
    fn send_target_optional_with_reply_to() {
        match parse(&["spt", "send", "--reply-to", "alice"]).unwrap().cmd {
            Cmd::Send { target, reply_to, .. } => {
                assert!(target.is_none());
                assert_eq!(reply_to.as_deref(), Some("alice"));
            }
            _ => panic!("expected Send"),
        }
    }

    // [unit->REQ-MSG-2] unknown subcommand / missing required arg are usage errors.
    #[test]
    fn invalid_usage_errors() {
        assert!(parse(&["spt", "bogus"]).is_err());
        assert!(parse(&["spt", "ring"]).is_err()); // missing required target
    }
}


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

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

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

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

use crate::config::DaemonConfig;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    /// The shared signoff wiring behind both the graceful and supervised-crash
    /// paths. The only two things that differ between them are exactly the two
    /// the 1.1 hazard turns on — the `grace` window and the `recheck` verdict — so
    /// they are the only parameters; the echo (3.3) and teardown effects are
    /// identical. Keeping one body guarantees the crash path can never drift out
    /// of the echo-before-teardown ordering the graceful path proves.
    fn signoff_with(
        &self,
        session_id: Option<&str>,
        grace: Duration,
        recheck: impl FnOnce() -> bool,
    ) -> Result<SignoffOutcome, String> {
        let mut echo_err: Option<String> = None;
        let mut teardown_err: Option<String> = None;
        let outcome = graceful_signoff::<std::convert::Infallible>(
            session_id,
            grace,
            || {
                if let Err(e) = self.fire_echo(session_id) {
                    echo_err = Some(e);
                }
                Ok(())
            },
            recheck,
            || {
                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
                if let Err(e) = std::fs::remove_file(&ready) {
                    if e.kind() != std::io::ErrorKind::NotFound {
                        teardown_err = Some(e.to_string());
                    }
                }
                Ok(())
            },
        )
        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
        if let Some(e) = echo_err {
            return Err(format!("echo failed during signoff: {e}"));
        }
        if let Some(e) = teardown_err {
            return Err(format!("teardown failed during signoff: {e}"));
        }
        Ok(outcome)
    }

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

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

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

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

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

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

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

    // A live manifest that ALSO declares an echo-commune summarizer, so
    // `fire_echo` (and thus the signoff echo step) succeeds and writes a commune.
    fn live_manifest_with_echo(commune_dir: &std::path::Path) -> Manifest {
        let dir = commune_dir.to_string_lossy().replace('\\', "/");
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session]\ncommune_dir=\"{dir}\"\n\n\
             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n\n\
             [session.echo_commune]\ncommand='{PASSTHROUGH}'\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    // [unit->REQ-DAEMON-4] liveness for the supervised Self anchors on the stable
    // harness pid (`parent_pid`), never an ephemeral poll pid (2.1): a dead owner
    // reads gone, a live owner reads present, and no recorded owner reads gone.
    #[test]
    fn harness_owner_gone_anchors_on_parent_pid() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
                .expect("live host");

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

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

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

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

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

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

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

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

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


1	//! Bounded byte-stream local streaming (M3a A4).
2	//!
3	//! [`OutputStream`] is the v1 consumer shape: the A2 drain forwards every byte
4	//! into a bounded `sync_channel`, and a consumer pulls chunks off the other end.
5	//! When the consumer falls behind, the channel fills, the drain's `send` blocks,
6	//! the reader stops draining, the PTY buffer fills, and the child blocks on
7	//! write — **end-to-end bounded backpressure, no unbounded buffer**. The off-node
8	//! hop (M4 transport) and the predictive "feels-local" layer ride on top of this
9	//! substrate later; M3a delivers the local shape only.
10	//!
11	//! **OS-neutral (Spike #4 / #5):** the stream is bytes, nothing more. It makes no
12	//! ordering promise beyond "every byte the reader forwarded, in the order it was
13	//! forwarded" — on ConPTY a repaint-on-resize legitimately reorders/duplicates
14	//! content, so consumers (e.g. the A5 digest) match on *value-set coverage*, not
15	//! byte-exact sequence.
16
17	use std::sync::mpsc::{sync_channel, Receiver};
18	use std::time::Duration;
19
20	use crate::pty::PtySession;
21	use crate::reader::Drain;
22	use crate::surface::SurfaceError;
23
24	/// A bounded stream of the child's output chunks. Dropping it unblocks a drain
25	/// that is parked on a full channel (the blocked `send` returns once the
26	/// receiver is gone), letting the pump wind down once the child is torn down.
27	pub struct OutputStream {
28	    rx: Receiver<Vec<u8>>,
29	    drain: Drain,
30	}
31
32	impl OutputStream {
33	    /// Block for the next output chunk. Returns `None` once the stream has ended
34	    /// (the drain thread exited and dropped its sender — i.e. the child's output
35	    /// reached EOF or the pump was stopped).
36	    pub fn recv(&self) -> Option<Vec<u8>> {
37	        self.rx.recv().ok()
38	    }
39
40	    /// Non-blocking poll for the next chunk, if one is already buffered.
41	    pub fn try_recv(&self) -> Option<Vec<u8>> {
42	        self.rx.try_recv().ok()
43	    }
44
45	    /// Block up to `timeout` for the next chunk.
46	    pub fn recv_timeout(&self, timeout: Duration) -> Option<Vec<u8>> {
47	        self.rx.recv_timeout(timeout).ok()
48	    }
49
50	    /// The underlying drain — live `bytes_forwarded` / `dsr_answered` counters
51	    /// and the cooperative stop flag.
52	    pub fn drain(&self) -> &Drain {
53	        &self.drain
54	    }
55
56	    /// Ask the pump to stop (cooperative — see [`Drain::request_stop`]).
57	    pub fn request_stop(&self) {
58	        self.drain.request_stop();
59	    }
60	}
61
62	impl PtySession {
63	    /// Stream this surface's output through a bounded channel of `capacity`
64	    /// chunks. `capacity == 0` is a rendezvous channel (maximum backpressure:
65	    /// every chunk blocks until the consumer takes it).
66	    ///
67	    /// Takes the master reader (once). The returned [`OutputStream`] is the sole
68	    /// consumer; chunks the consumer never takes apply backpressure rather than
69	    /// growing an unbounded buffer.
70	    //
71	    // [impl->REQ-TERM-3]
72	    pub fn byte_stream(&self, capacity: usize) -> Result<OutputStream, SurfaceError> {
73	        let (tx, rx) = sync_channel::<Vec<u8>>(capacity);
74	        let drain = self.drain(move |chunk| {
75	            // Bounded send: blocks when `capacity` chunks are unconsumed
76	            // (backpressure); drops silently once the receiver is gone so a
77	            // torn-down consumer never wedges the pump.
78	            let _ = tx.send(chunk.to_vec());
79	        })?;
80	        Ok(OutputStream { rx, drain })
81	    }
82	}
83

//! Bounded byte-stream local streaming (M3a A4).
//!
//! [`OutputStream`] is the v1 consumer shape: the A2 drain forwards every byte
//! into a bounded `sync_channel`, and a consumer pulls chunks off the other end.
//! When the consumer falls behind, the channel fills, the drain's `send` blocks,
//! the reader stops draining, the PTY buffer fills, and the child blocks on
//! write — **end-to-end bounded backpressure, no unbounded buffer**. The off-node
//! hop (M4 transport) and the predictive "feels-local" layer ride on top of this
//! substrate later; M3a delivers the local shape only.
//!
//! **OS-neutral (Spike #4 / #5):** the stream is bytes, nothing more. It makes no
//! ordering promise beyond "every byte the reader forwarded, in the order it was
//! forwarded" — on ConPTY a repaint-on-resize legitimately reorders/duplicates
//! content, so consumers (e.g. the A5 digest) match on *value-set coverage*, not
//! byte-exact sequence.

use std::sync::mpsc::{sync_channel, Receiver};
use std::time::Duration;

use crate::pty::PtySession;
use crate::reader::Drain;
use crate::surface::SurfaceError;

/// A bounded stream of the child's output chunks. Dropping it unblocks a drain
/// that is parked on a full channel (the blocked `send` returns once the
/// receiver is gone), letting the pump wind down once the child is torn down.
pub struct OutputStream {
    rx: Receiver<Vec<u8>>,
    drain: Drain,
}

impl OutputStream {
    /// Block for the next output chunk. Returns `None` once the stream has ended
    /// (the drain thread exited and dropped its sender — i.e. the child's output
    /// reached EOF or the pump was stopped).
    pub fn recv(&self) -> Option<Vec<u8>> {
        self.rx.recv().ok()
    }

    /// Non-blocking poll for the next chunk, if one is already buffered.
    pub fn try_recv(&self) -> Option<Vec<u8>> {
        self.rx.try_recv().ok()
    }

    /// Block up to `timeout` for the next chunk.
    pub fn recv_timeout(&self, timeout: Duration) -> Option<Vec<u8>> {
        self.rx.recv_timeout(timeout).ok()
    }

    /// The underlying drain — live `bytes_forwarded` / `dsr_answered` counters
    /// and the cooperative stop flag.
    pub fn drain(&self) -> &Drain {
        &self.drain
    }

    /// Ask the pump to stop (cooperative — see [`Drain::request_stop`]).
    pub fn request_stop(&self) {
        self.drain.request_stop();
    }
}

impl PtySession {
    /// Stream this surface's output through a bounded channel of `capacity`
    /// chunks. `capacity == 0` is a rendezvous channel (maximum backpressure:
    /// every chunk blocks until the consumer takes it).
    ///
    /// Takes the master reader (once). The returned [`OutputStream`] is the sole
    /// consumer; chunks the consumer never takes apply backpressure rather than
    /// growing an unbounded buffer.
    //
    // [impl->REQ-TERM-3]
    pub fn byte_stream(&self, capacity: usize) -> Result<OutputStream, SurfaceError> {
        let (tx, rx) = sync_channel::<Vec<u8>>(capacity);
        let drain = self.drain(move |chunk| {
            // Bounded send: blocks when `capacity` chunks are unconsumed
            // (backpressure); drops silently once the receiver is gone so a
            // torn-down consumer never wedges the pump.
            let _ = tx.send(chunk.to_vec());
        })?;
        Ok(OutputStream { rx, drain })
    }
}


NOTE fROM THE USER: proceed with next steps.


Big picture clear. Now: how brain connects in daemon, current digest gap, REQ-TERM-4 state, history store for Path-B. Read api startup/live/mod + traceable REQ-TERM-4 + relay.