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

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

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

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

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

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

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

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

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

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

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

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

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

/// The broker process kernel. Owns the listening socket and the table of hosted
/// sessions; holds no logic state (ADR-0004 §B).
pub struct Broker {
    listener: LocalSocketTransport,
    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
    next_id: AtomicU64,
    log_cap_chunks: usize,
}

impl Broker {
    /// Bind the broker's listening endpoint at the namespaced `name`.
    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
        Ok(Arc::new(Broker {
            listener: LocalSocketTransport::bind(name)?,
            sessions: Arc::new(Mutex::new(HashMap::new())),
            next_id: AtomicU64::new(1),
            log_cap_chunks: DEFAULT_LOG_CHUNKS,
        }))
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    /// Inject input bytes into a session's PTY.
    fn dispatch_input(&self, env: Envelope) -> Result<(), String> {
        let req: InputReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad input payload: {e}"))?;
        let bytes = decode_bytes(&req.data_b64).map_err(|e| format!("bad input base64: {e}"))?;
        let sessions = self.sessions.lock().unwrap();
        let h = sessions
            .get(&req.session_id)
            .ok_or_else(|| format!("no such session {}", req.session_id))?;
        h.session.write_input(&bytes).map_err(|e| format!("write_input failed: {e}"))
    }

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

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

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

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


NOTE fROM THE USER: proceedwithnextsteps.


Full picture clear. Now check: spt binary deps, REQ-DAEMON-1 state, store paths API, handoff test pattern.


1	[package]
2	name = "spt"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."
7
8	[[bin]]
9	name = "spt"
10	path = "src/main.rs"
11
12	[dependencies]
13	spt-proto = { path = "../spt-proto" }
14	spt-store = { path = "../spt-store" }
15	spt-msg = { path = "../spt-msg" }
16	spt-runtime = { path = "../spt-runtime" }
17	spt-live = { path = "../spt-live" }
18	spt-daemon = { path = "../spt-daemon" }
19	clap = { version = "4", features = ["derive"] }
20
21	[dev-dependencies]
22	tempfile = "3"
23	toml = "0.8"
24

[package]
name = "spt"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"


Bash

grep -n "REQ-DAEMON-1\|REQ-DAEMON-2\|REQ-DAEMON-3" "C:/Users/decid/Documents/projects/spt-core/traceable-reqs.toml"

Find DAEMON reqs in registry


41:id = "REQ-DAEMON-1"
45:id = "REQ-DAEMON-2"
49:id = "REQ-DAEMON-3"


ls "C:/Users/decid/Documents/projects/spt-core/crates/spt-daemon/tests" 2>/dev/null; echo "---store src---"; ls "C:/Users/decid/Documents/projects/spt-core/crates/spt-store/src"

List daemon tests + store src


broker.rs
handoff.rs
---store src---
atomic.rs
db.rs
info.rs
lib.rs
perch.rs
proc.rs
registry.rs
seed.rs
spool.rs


30	[[requirements]]
31	id = "REQ-ARCH-3"
32	title = "Wire-protocol version independent of crate semver, N-1 compat window"
33	required_stages = ["impl", "unit"]  # activated M0: spt-proto wire version + compat window (T6)
34	[[requirements]]
35	id = "REQ-ARCH-4"
36	title = "Copy-verbatim the commodity layer from the sister project"
37	required_stages = ["impl", "unit"]  # activated M0: spt-proto EVENT grammar (T2)
38
39	# ───────────────────────────── Daemon ───────────────────────────────────────
40	[[requirements]]
41	id = "REQ-DAEMON-1"
42	title = "One per-machine spt-daemon owning all per-machine state"
43	required_stages = []
44	[[requirements]]
45	id = "REQ-DAEMON-2"
46	title = "Broker/brain split for seamless self-update"
47	required_stages = ["impl", "unit"]
48	[[requirements]]
49	id = "REQ-DAEMON-3"
50	title = "Any api invocation auto-starts the daemon if absent"
51	required_stages = []
52	[[requirements]]
53	id = "REQ-DAEMON-4"
54	title = "Honor every KNOWN-HAZARDS invariant"
55	required_stages = []
56
57	# ───────────────────────────── Harness contract — manifest seams ────────────
58	[[requirements]]
59	id = "REQ-MANIFEST-1"
60	title = "Per-adapter manifest with adapter_name and min_spt_core_version"
61	required_stages = ["doc", "impl", "unit"]
62	[[requirements]]
63	id = "REQ-SEAM-SPAWN"
64	title = "spawn-session seam"
65	required_stages = ["impl", "unit"]
66	[[requirements]]
67	id = "REQ-SEAM-POSTSPAWN"
68	title = "post-spawn / api bind seam with boot nonce"
69	required_stages = ["impl", "unit"]
70	[[requirements]]
71	id = "REQ-SEAM-PSYCHE"
72	title = "spawn-psyche seam (fresh + resume templates)"
73	required_stages = ["impl", "unit", "int"]
74	[[requirements]]

[[requirements]]
id = "REQ-ARCH-3"
title = "Wire-protocol version independent of crate semver, N-1 compat window"
required_stages = ["impl", "unit"]  # activated M0: spt-proto wire version + compat window (T6)
[[requirements]]
id = "REQ-ARCH-4"
title = "Copy-verbatim the commodity layer from the sister project"
required_stages = ["impl", "unit"]  # activated M0: spt-proto EVENT grammar (T2)

# ───────────────────────────── Daemon ───────────────────────────────────────
[[requirements]]
id = "REQ-DAEMON-1"
title = "One per-machine spt-daemon owning all per-machine state"
required_stages = []
[[requirements]]
id = "REQ-DAEMON-2"
title = "Broker/brain split for seamless self-update"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-DAEMON-3"
title = "Any api invocation auto-starts the daemon if absent"
required_stages = []
[[requirements]]
id = "REQ-DAEMON-4"
title = "Honor every KNOWN-HAZARDS invariant"
required_stages = []

# ───────────────────────────── Harness contract — manifest seams ────────────
[[requirements]]
id = "REQ-MANIFEST-1"
title = "Per-adapter manifest with adapter_name and min_spt_core_version"
required_stages = ["doc", "impl", "unit"]
[[requirements]]
id = "REQ-SEAM-SPAWN"
title = "spawn-session seam"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-SEAM-POSTSPAWN"
title = "post-spawn / api bind seam with boot nonce"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-SEAM-PSYCHE"
title = "spawn-psyche seam (fresh + resume templates)"
required_stages = ["impl", "unit", "int"]
[[requirements]]


grep -n "pub fn\|HOME\|home\|spt_home\|base_dir\|root" "C:/Users/decid/Documents/projects/spt-core/crates/spt-store/src/perch.rs" | head -40

Find home/paths API in perch.rs


24:/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
25:/// default (`%LOCALAPPDATA%\spt-core` on Windows, `$HOME/.spt-core` on Unix).
29:/// spt runtime's `spt/` home**, so a dev build never collides with a running
32:/// override (`$SPT_HOME`) is the relocation knob either way.
34:pub fn spt_home() -> PathBuf {
35:    if let Ok(over) = std::env::var("SPT_HOME") {
40:    platform_default_spt_home()
44:fn platform_default_spt_home() -> PathBuf {
55:fn platform_default_spt_home() -> PathBuf {
56:    let home = std::env::var("HOME").expect("HOME must be set on Unix");
57:    PathBuf::from(home).join(".spt-core")
60:/// The owlery directory holding all perches: `<spt_home>/owlery/`.
62:pub fn owlery_dir() -> PathBuf {
63:    spt_home().join("owlery")
85:    pub fn classify(id: &str) -> PerchKind {
96:    pub fn nests_under_parent(self) -> bool {
130:pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
165:pub fn resolve_info_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
170:pub fn resolve_spool_db(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
175:pub fn resolve_ready_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
180:pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
192:pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
199:pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
204:pub fn info_file_at(perch_path: &Path) -> PathBuf {
222:            let prev = std::env::var("SPT_HOME").ok();
223:            std::env::set_var("SPT_HOME", tmp);
230:                Some(v) => std::env::set_var("SPT_HOME", v),
231:                None => std::env::remove_var("SPT_HOME"),
298:    // one resolver — spool.db / info.json / ready / inbox share the perch root.
303:        let root = resolve_perch_path("dunsen-w7", ParentHint::Explicit("dunsen"));
304:        assert_eq!(resolve_spool_db("dunsen-w7", ParentHint::Explicit("dunsen")), root.join("spool.db"));
305:        assert_eq!(resolve_info_file("dunsen-w7", ParentHint::Explicit("dunsen")), root.join("info.json"));
306:        assert_eq!(resolve_ready_file("dunsen-w7", ParentHint::Explicit("dunsen")), root.join("ready"));
307:        assert_eq!(resolve_inbox_dir("dunsen-w7", ParentHint::Explicit("dunsen")), root.join("inbox"));
310:    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] spt_home is pure: honors SPT_HOME,
313:    fn spt_home_is_pure_and_honors_env() {
317:        assert_eq!(spt_home(), target);
318:        assert!(!target.exists(), "spt_home must not create the dir");


1	//! B2 regression — the milestone's load-bearing proof: a brain can be killed and
2	//! restarted repeatedly while the broker and its hosted child survive untouched,
3	//! and each new brain re-attaches to the output stream **gaplessly** with a fresh
4	//! `gen_start` (Spike #1 made real; KNOWN-HAZARDS 2.3 / 2.4).
5	//!
6	//! Shape: one echo child runs under a broker PTY for the whole test. Across 12
7	//! handoffs, each brain (a) re-attaches from its predecessor's resume cursor and
8	//! must find — in the *replayed* buffer — the marker its predecessor injected
9	//! and then died before reading (proving the broker buffered output produced
10	//! while no brain was attached), (b) injects the next marker for its successor,
11	//! then (c) "crashes" by dropping its connection. Throughout, the child's pid is
12	//! unchanged and exactly one session is hosted; `gen_start` advances every
13	//! handoff and the generation counter increments.
14	//!
15	//! A real-process SIGKILL of the brain was proven in Spike #1; here the brain is
16	//! a library driven in-thread and a dropped connection *is* the crash boundary
17	//! the broker must tolerate (subscriber vanishes; child + log persist).
18	// [int->REQ-HAZARD-GEN-START-NOW]
19	// [int->REQ-DAEMON-2]
20
21	use std::sync::atomic::{AtomicU32, Ordering};
22	use std::sync::Arc;
23	use std::thread;
24	use std::time::Duration;
25
26	use spt_daemon::brain::{Brain, BrainState, BrokerEvent};
27	use spt_daemon::msg::SpawnReq;
28	use spt_daemon::Broker;
29
30	static SEQ: AtomicU32 = AtomicU32::new(0);
31	fn unique_name() -> String {
32	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
33	    format!("spt-daemon-b2-{}-{}.sock", std::process::id(), n)
34	}
35
36	fn echo_spawn_req() -> SpawnReq {
37	    #[cfg(unix)]
38	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
39	    #[cfg(windows)]
40	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
41	    SpawnReq { program, args, rows: 24, cols: 80 }
42	}
43
44	fn cold_retry(name: &str, now_ms: u64) -> Brain {
45	    for _ in 0..300 {
46	        if let Ok(b) = Brain::cold_start(name, now_ms) {
47	            return b;
48	        }
49	        thread::sleep(Duration::from_millis(10));
50	    }
51	    panic!("brain cold-start could not connect");
52	}
53
54	fn handoff_retry(name: &str, prior: BrainState, now_ms: u64) -> Brain {
55	    for _ in 0..300 {
56	        if let Ok(b) = Brain::handoff(name, prior.clone(), now_ms) {
57	            return b;
58	        }
59	        thread::sleep(Duration::from_millis(10));
60	    }
61	    panic!("brain handoff could not connect");
62	}
63
64	#[test]
65	fn brain_restart_survives_gaplessly_with_fresh_gen_start() {
66	    const RESTARTS: u64 = 12; // >= 10 (B2 acceptance)
67	    let name = unique_name();
68	    let broker = Broker::bind(&name).expect("bind broker");
69
70	    // The broker accepts connections forever on a detached thread; it outlives
71	    // every brain (that is the whole point).
72	    let serve_broker = Arc::clone(&broker);
73	    thread::spawn(move || {
74	        let _ = serve_broker.serve();
75	    });
76
77	    // Generation 0: cold start, spawn the long-lived child, inject MARKER_1 for
78	    // the first handed-off brain to discover in the replay buffer, then crash.
79	    let mut now = 1_000_000u64;
80	    let mut brain = cold_retry(&name, now);
81	    let sid = brain.spawn_session(echo_spawn_req()).expect("spawn session");
82	    let child_pid = broker.session_pid(sid);
83	    assert_eq!(brain.generation(), 0);
84
85	    let mut gen_starts = vec![brain.gen_start_ms()];
86	    let mut generations = vec![brain.generation()];
87
88	    brain.send_input(b"MARKER_1_DONE\r").expect("inject first marker");
89	    let mut state = brain.snapshot();
90	    drop(brain); // crash
91
92	    let mut buf: Vec<u8> = Vec::new();
93	    for k in 1..=RESTARTS {
94	        now += 1; // advance wall clock so a fresh gen_start is strictly greater
95	        let mut brain = handoff_retry(&name, state.clone(), now);
96
97	        // 2.4: gen_start is now(), NOT the stale value rehydrated from state.
98	        assert_eq!(brain.gen_start_ms(), now, "gen_start must be now(), not rehydrated");
99	        assert_ne!(
100	            brain.gen_start_ms(),
101	            state.gen_start_ms,
102	            "gen_start must not carry the prior generation's stale value"
103	        );
104	        assert_eq!(brain.generation(), state.generation + 1, "generation increments");
105	        gen_starts.push(brain.gen_start_ms());
106	        generations.push(brain.generation());
107
108	        // The child and its PTY survived the restart untouched.
109	        assert_eq!(broker.session_count(), 1, "exactly one session across restarts");
110	        assert_eq!(broker.session_pid(sid), child_pid, "child pid stable across restart");
111
112	        // Gapless resume: the marker the previous brain injected (and died before
113	        // reading) must arrive via the replayed buffer.
114	        let marker = format!("MARKER_{k}_DONE");
115	        brain
116	            .read_output_until(marker.as_bytes(), &mut buf)
117	            .unwrap_or_else(|e| panic!("gapless resume must replay {marker}: {e}"));
118
119	        // Hand the next marker to our successor, then crash without reading it.
120	        if k < RESTARTS {
121	            brain
122	                .send_input(format!("MARKER_{}_DONE\r", k + 1).as_bytes())
123	                .expect("inject next marker");
124	        }
125	        state = brain.snapshot();
126	        drop(brain);
127	    }
128
129	    // gen_start strictly advanced every handoff; generations are 0,1,2,...
130	    for w in gen_starts.windows(2) {
131	        assert!(w[1] > w[0], "gen_start must advance each handoff: {gen_starts:?}");
132	    }
133	    for (i, g) in generations.iter().enumerate() {
134	        assert_eq!(*g, i as u64, "generation counter contiguous: {generations:?}");
135	    }
136
137	    // Final generation: kill the child and confirm the exit event + reaping.
138	    now += 1;
139	    let mut brain = handoff_retry(&name, state, now);
140	    brain.kill_session().expect("kill session");
141	    loop {
142	        if let BrokerEvent::Exit { session_id, .. } = brain.read_event().expect("read until exit") {
143	            assert_eq!(session_id, sid);
144	            break;
145	        }
146	    }
147	    drop(brain);
148
149	    // The dead session is reaped from the broker table.
150	    let mut reaped = false;
151	    for _ in 0..200 {
152	        if broker.session_count() == 0 {
153	            reaped = true;
154	            break;
155	        }
156	        thread::sleep(Duration::from_millis(10));
157	    }
158	    assert!(reaped, "child exit must reap the session from the broker table");
159	}
160

//! B2 regression — the milestone's load-bearing proof: a brain can be killed and
//! restarted repeatedly while the broker and its hosted child survive untouched,
//! and each new brain re-attaches to the output stream **gaplessly** with a fresh
//! `gen_start` (Spike #1 made real; KNOWN-HAZARDS 2.3 / 2.4).
//!
//! Shape: one echo child runs under a broker PTY for the whole test. Across 12
//! handoffs, each brain (a) re-attaches from its predecessor's resume cursor and
//! must find — in the *replayed* buffer — the marker its predecessor injected
//! and then died before reading (proving the broker buffered output produced
//! while no brain was attached), (b) injects the next marker for its successor,
//! then (c) "crashes" by dropping its connection. Throughout, the child's pid is
//! unchanged and exactly one session is hosted; `gen_start` advances every
//! handoff and the generation counter increments.
//!
//! A real-process SIGKILL of the brain was proven in Spike #1; here the brain is
//! a library driven in-thread and a dropped connection *is* the crash boundary
//! the broker must tolerate (subscriber vanishes; child + log persist).
// [int->REQ-HAZARD-GEN-START-NOW]
// [int->REQ-DAEMON-2]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::{Brain, BrainState, BrokerEvent};
use spt_daemon::msg::SpawnReq;
use spt_daemon::Broker;

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b2-{}-{}.sock", std::process::id(), n)
}

fn echo_spawn_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

fn cold_retry(name: &str, now_ms: u64) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, now_ms) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain cold-start could not connect");
}

fn handoff_retry(name: &str, prior: BrainState, now_ms: u64) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::handoff(name, prior.clone(), now_ms) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain handoff could not connect");
}

#[test]
fn brain_restart_survives_gaplessly_with_fresh_gen_start() {
    const RESTARTS: u64 = 12; // >= 10 (B2 acceptance)
    let name = unique_name();
    let broker = Broker::bind(&name).expect("bind broker");

    // The broker accepts connections forever on a detached thread; it outlives
    // every brain (that is the whole point).
    let serve_broker = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve_broker.serve();
    });

    // Generation 0: cold start, spawn the long-lived child, inject MARKER_1 for
    // the first handed-off brain to discover in the replay buffer, then crash.
    let mut now = 1_000_000u64;
    let mut brain = cold_retry(&name, now);
    let sid = brain.spawn_session(echo_spawn_req()).expect("spawn session");
    let child_pid = broker.session_pid(sid);
    assert_eq!(brain.generation(), 0);

    let mut gen_starts = vec![brain.gen_start_ms()];
    let mut generations = vec![brain.generation()];

    brain.send_input(b"MARKER_1_DONE\r").expect("inject first marker");
    let mut state = brain.snapshot();
    drop(brain); // crash

    let mut buf: Vec<u8> = Vec::new();
    for k in 1..=RESTARTS {
        now += 1; // advance wall clock so a fresh gen_start is strictly greater
        let mut brain = handoff_retry(&name, state.clone(), now);

        // 2.4: gen_start is now(), NOT the stale value rehydrated from state.
        assert_eq!(brain.gen_start_ms(), now, "gen_start must be now(), not rehydrated");
        assert_ne!(
            brain.gen_start_ms(),
            state.gen_start_ms,
            "gen_start must not carry the prior generation's stale value"
        );
        assert_eq!(brain.generation(), state.generation + 1, "generation increments");
        gen_starts.push(brain.gen_start_ms());
        generations.push(brain.generation());

        // The child and its PTY survived the restart untouched.
        assert_eq!(broker.session_count(), 1, "exactly one session across restarts");
        assert_eq!(broker.session_pid(sid), child_pid, "child pid stable across restart");

        // Gapless resume: the marker the previous brain injected (and died before
        // reading) must arrive via the replayed buffer.
        let marker = format!("MARKER_{k}_DONE");
        brain
            .read_output_until(marker.as_bytes(), &mut buf)
            .unwrap_or_else(|e| panic!("gapless resume must replay {marker}: {e}"));

        // Hand the next marker to our successor, then crash without reading it.
        if k < RESTARTS {
            brain
                .send_input(format!("MARKER_{}_DONE\r", k + 1).as_bytes())
                .expect("inject next marker");
        }
        state = brain.snapshot();
        drop(brain);
    }

    // gen_start strictly advanced every handoff; generations are 0,1,2,...
    for w in gen_starts.windows(2) {
        assert!(w[1] > w[0], "gen_start must advance each handoff: {gen_starts:?}");
    }
    for (i, g) in generations.iter().enumerate() {
        assert_eq!(*g, i as u64, "generation counter contiguous: {generations:?}");
    }

    // Final generation: kill the child and confirm the exit event + reaping.
    now += 1;
    let mut brain = handoff_retry(&name, state, now);
    brain.kill_session().expect("kill session");
    loop {
        if let BrokerEvent::Exit { session_id, .. } = brain.read_event().expect("read until exit") {
            assert_eq!(session_id, sid);
            break;
        }
    }
    drop(brain);

    // The dead session is reaped from the broker table.
    let mut reaped = false;
    for _ in 0..200 {
        if broker.session_count() == 0 {
            reaped = true;
            break;
        }
        thread::sleep(Duration::from_millis(10));
    }
    assert!(reaped, "child exit must reap the session from the broker table");
}


1	//! Shared test scaffolding for spt-live unit tests.
2	//!
3	//! **One** process-wide `SPT_HOME` lock for the whole crate. Per the cross-
4	//! module env-race gotcha, a per-module lock is *not* enough: every module's
5	//! `SPT_HOME`-touching test races on the single global env var, so they must all
6	//! serialize on the same `Mutex`.
7
8	use std::path::Path;
9	use std::sync::Mutex;
10
11	static HOME_LOCK: Mutex<()> = Mutex::new(());
12
13	/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
14	/// every other `SPT_HOME` test in this binary and restored afterward.
15	pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
16	    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
17	    let prev = std::env::var("SPT_HOME").ok();
18	    let tmp = tempfile::tempdir().unwrap();
19	    std::env::set_var("SPT_HOME", tmp.path());
20	    body(tmp.path());
21	    match prev {
22	        Some(v) => std::env::set_var("SPT_HOME", v),
23	        None => std::env::remove_var("SPT_HOME"),
24	    }
25	}
26

//! Shared test scaffolding for spt-live unit tests.
//!
//! **One** process-wide `SPT_HOME` lock for the whole crate. Per the cross-
//! module env-race gotcha, a per-module lock is *not* enough: every module's
//! `SPT_HOME`-touching test races on the single global env var, so they must all
//! serialize on the same `Mutex`.

use std::path::Path;
use std::sync::Mutex;

static HOME_LOCK: Mutex<()> = Mutex::new(());

/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
/// every other `SPT_HOME` test in this binary and restored afterward.
pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let prev = std::env::var("SPT_HOME").ok();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());
    body(tmp.path());
    match prev {
        Some(v) => std::env::set_var("SPT_HOME", v),
        None => std::env::remove_var("SPT_HOME"),
    }
}


Design clear. Check spool API + manifest fixture patterns for tests.


grep -n "pub fn" "C:/Users/decid/Documents/projects/spt-core/crates/spt-store/src/spool.rs" | head -40

spool public API


41:pub fn open_spool_at(perch_path: &Path) -> rusqlite::Result<Connection> {
89:pub fn spool_message_at(perch_path: &Path, from_id: &str, body: &str) -> rusqlite::Result<()> {
102:pub fn spool_message_deferred_at(perch_path: &Path, from_id: &str, body: &str) -> rusqlite::Result<()> {
127:pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
134:pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
164:pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<String>> {
183:pub fn peek_all_at(perch_path: &Path) -> rusqlite::Result<Vec<(i64, String, String, i64)>> {
203:pub fn pending_count_at(perch_path: &Path) -> rusqlite::Result<usize> {
216:pub fn pending_count_non_deferred_at(perch_path: &Path) -> rusqlite::Result<usize> {
229:pub fn mark_delivered_at(perch_path: &Path, msg_ids: &[i64]) -> rusqlite::Result<()> {


cd "C:/Users/decid/Documents/projects/spt-core"; grep -rn "psyche_init" --include=*.toml --include=*.rs crates/spt-runtime/ crates/spt-live/ | grep -i "fixture\|test\|mock\|\.toml" | head; echo "---fixtures---"; find crates -name "*.toml" -path "*fixture*" -o -name "*.toml" -path "*manifest*" 2>/dev/null | head -20; echo "---manifest test builders---"; grep -rln "psyche_init" crates/spt-live/src crates/spt-runtime/src | head

Find manifest fixtures + psyche_init test setup


---fixtures---
---manifest test builders---
crates/spt-live/src/psyche.rs
crates/spt-runtime/src/manifest.rs


cd "C:/Users/decid/Documents/projects/spt-core"; grep -n "psyche_init\|fn manifest\|Manifest\|toml::from_str\|from_toml\|psyche_init =\|\[session\]\|commune_dir" crates/spt-live/src/psyche.rs | head -40

Manifest construction in psyche tests


3://! The Psyche is spawned from the `[session.psyche_init]` template (fresh) or
12://! `ManifestRuntime` so the Psyche's own harness hooks bail instead of spawning
29:pub const PSYCHE_INIT_ROLE: &str = "psyche_init";
104:/// Shared spawn+bind core for the Psyche roles (`psyche_init` — fresh; and
158:    use spt_runtime::{Manifest, ManifestRuntime};
174:    // A psyche_init template that records the recursion-guard env value into a
182:    fn psyche_manifest() -> Manifest {
185:             [session.psyche_init]\ncommand='{GUARD_CMD}'\ncwd=\"{{psyche_dir}}\"\n\
188:        Manifest::from_toml_str(&toml).unwrap()
194:            let rt = ManifestRuntime::new(psyche_manifest());


155	mod tests {
156	    use super::*;
157	    use crate::test_support::with_home;
158	    use spt_runtime::{Manifest, ManifestRuntime};
159	    use std::time::{Duration, Instant};
160
161	    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.
162	    fn prompt_includes_time_and_event() {
163	        let p = compose_psyche_prompt("2026-06-01T00:00:00Z", Some("MSG from alice: hi"));
164	        assert!(p.contains("time: 2026-06-01T00:00:00Z"));
165	        assert!(p.contains("MSG from alice: hi"));
166	    }
167
168	    #[test] // [unit->REQ-SEAM-PSYCHE] no event ⇒ an explicit "(none)" envelope.
169	    fn prompt_without_event() {
170	        let p = compose_psyche_prompt("t0", None);
171	        assert!(p.contains("incoming event: (none)"));
172	    }
173
174	    // A psyche_init template that records the recursion-guard env value into a
175	    // marker file in its cwd (the nested perch dir) — proves both that the perch
176	    // is the cwd and that the guard env reached the child.
177	    #[cfg(windows)]
178	    const GUARD_CMD: &str = "cmd /C echo %SPT_GUARD% > guard.txt";
179	    #[cfg(unix)]
180	    const GUARD_CMD: &str = "sh -c \"echo $SPT_GUARD > guard.txt\"";
181
182	    fn psyche_manifest() -> Manifest {
183	        let toml = format!(
184	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
185	             [session.psyche_init]\ncommand='{GUARD_CMD}'\ncwd=\"{{psyche_dir}}\"\n\
186	             recursion_guard_env=\"SPT_GUARD\"\nkeys=[\"psyche_dir\",\"psyche_prompt\"]\n"
187	        );
188	        Manifest::from_toml_str(&toml).unwrap()
189	    }
190
191	    #[test] // [unit->REQ-SEAM-PSYCHE] spawning binds a nested psyche perch + sets the guard env.
192	    fn spawn_binds_nested_perch_and_sets_guard() {
193	        with_home(|_| {
194	            let rt = ManifestRuntime::new(psyche_manifest());
195	            let h = spawn_psyche(&rt, "doyle", &BTreeMap::new(), Some("pulse"), "t0")
196	                .expect("spawn psyche");
197
198	            // Nested layout: owlery/doyle/nested/doyle-psyche/.
199	            assert_eq!(h.id, "doyle-psyche");
200	            assert_eq!(
201	                h.perch_path,
202	                resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"))
203	            );
204
205	            // info.json records the psyche endpoint type + the spawned pid.
206	            let rec = spt_store::info::read_info(&h.perch_path).expect("info.json");
207	            assert_eq!(rec.state, "psyche");
208	            assert_eq!(rec.id, "doyle-psyche");
209
210	            // The detached child writes the guard value into its cwd; poll for it.
211	            let marker = h.perch_path.join("guard.txt");
212	            let start = Instant::now();
213	            let contents = loop {
214	                if let Ok(s) = std::fs::read_to_string(&marker) {
215	                    if !s.trim().is_empty() {
216	                        break s;
217	                    }
218	                }
219	                assert!(
220	                    start.elapsed() < Duration::from_secs(5),
221	                    "psyche child never wrote its guard marker"
222	                );
223	                std::thread::sleep(Duration::from_millis(20));
224	            };
225	            assert!(contents.contains('1'), "guard env not set on child: {contents:?}");
226	        });
227	    }
228	}
229

mod tests {
    use super::*;
    use crate::test_support::with_home;
    use spt_runtime::{Manifest, ManifestRuntime};
    use std::time::{Duration, Instant};

    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.
    fn prompt_includes_time_and_event() {
        let p = compose_psyche_prompt("2026-06-01T00:00:00Z", Some("MSG from alice: hi"));
        assert!(p.contains("time: 2026-06-01T00:00:00Z"));
        assert!(p.contains("MSG from alice: hi"));
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] no event ⇒ an explicit "(none)" envelope.
    fn prompt_without_event() {
        let p = compose_psyche_prompt("t0", None);
        assert!(p.contains("incoming event: (none)"));
    }

    // A psyche_init template that records the recursion-guard env value into a
    // marker file in its cwd (the nested perch dir) — proves both that the perch
    // is the cwd and that the guard env reached the child.
    #[cfg(windows)]
    const GUARD_CMD: &str = "cmd /C echo %SPT_GUARD% > guard.txt";
    #[cfg(unix)]
    const GUARD_CMD: &str = "sh -c \"echo $SPT_GUARD > guard.txt\"";

    fn psyche_manifest() -> Manifest {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_init]\ncommand='{GUARD_CMD}'\ncwd=\"{{psyche_dir}}\"\n\
             recursion_guard_env=\"SPT_GUARD\"\nkeys=[\"psyche_dir\",\"psyche_prompt\"]\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] spawning binds a nested psyche perch + sets the guard env.
    fn spawn_binds_nested_perch_and_sets_guard() {
        with_home(|_| {
            let rt = ManifestRuntime::new(psyche_manifest());
            let h = spawn_psyche(&rt, "doyle", &BTreeMap::new(), Some("pulse"), "t0")
                .expect("spawn psyche");

            // Nested layout: owlery/doyle/nested/doyle-psyche/.
            assert_eq!(h.id, "doyle-psyche");
            assert_eq!(
                h.perch_path,
                resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"))
            );

            // info.json records the psyche endpoint type + the spawned pid.
            let rec = spt_store::info::read_info(&h.perch_path).expect("info.json");
            assert_eq!(rec.state, "psyche");
            assert_eq!(rec.id, "doyle-psyche");

            // The detached child writes the guard value into its cwd; poll for it.
            let marker = h.perch_path.join("guard.txt");
            let start = Instant::now();
            let contents = loop {
                if let Ok(s) = std::fs::read_to_string(&marker) {
                    if !s.trim().is_empty() {
                        break s;
                    }
                }
                assert!(
                    start.elapsed() < Duration::from_secs(5),
                    "psyche child never wrote its guard marker"
                );
                std::thread::sleep(Duration::from_millis(20));
            };
            assert!(contents.contains('1'), "guard env not set on child: {contents:?}");
        });
    }
}


All inputs gathered. Plan set. Track + build B3.


Agent

Track B3 tasks

Prompt: Do nothing. This is a placeholder. Return "ok".


<owl_reachability_notice>
You have an active owl perch. Launch this Agent with run_in_background: true so you stay reachable for incoming messages while it works.
</owl_reachability_notice>


ok

agentId: a36e1f4b258fc1e1a (use SendMessage with to: 'a36e1f4b258fc1e1a' to continue this agent)
<usage>subagent_tokens: 26146
tool_uses: 0
duration_ms: 1171</usage>

Prompt: Do nothing. This is a placeholder. Return "ok".

ok


Skip subagent overhead. Build directly. Write config.rs.


Write

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

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

use serde::{Deserialize, Serialize};

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


<owl_messages>
<owl_message from="doyle-w333" priority="info">[WORKING_PERCH_NOTICE] Subagent working perch created.
Perch: doyle-w333
Agent type: general-purpose
To message it: $OWL send doyle-w333 doyle</owl_message>
</owl_messages>

[OWL SYSTEM] Informational owl messages above — no acknowledgment needed. Continue your current task.


File created successfully at: C:/Users/decid/Documents/projects/spt-core/crates/spt-daemon/src/config.rs (file state is current in your context — no need to Read it back)

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

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

use serde::{Deserialize, Serialize};

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Now relay.rs — thin stateless in-session relay.


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


File created successfully at: C:/Users/decid/Documents/projects/spt-core/crates/spt-daemon/src/relay.rs (file state is current in your context — no need to Read it back)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Need a test_home helper module + verify PollListener signatures.


Bash

cd "C:/Users/decid/Documents/projects/spt-core"; grep -n "pub fn bind_blocking\|pub fn check_message_blocking\|impl PollListener\|-> Result\|pub struct PollListener" crates/spt-msg/src/listener.rs | head

PollListener API signatures


32:pub struct PollListener {
79:impl PollListener {
82:    pub fn bind(id: &str) -> Result<Self, String> {
88:    pub fn bind_blocking(id: &str, timeout: Duration) -> Result<Self, String> {
93:    fn bind_inner(id: &str, timeout_ms: Option<i32>) -> Result<Self, String> {
139:    pub fn check_message_blocking(&self) -> Option<String> {


Write

//! Shared `SPT_HOME` isolation for spt-daemon unit tests (mirrors the spt-live
//! `test_support` discipline).
//!
//! Every `SPT_HOME`-touching test in this binary races on the single global env
//! var, so they all serialize on one process-wide `Mutex`.

use std::path::Path;
use std::sync::Mutex;

static HOME_LOCK: Mutex<()> = Mutex::new(());

/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
/// every other `SPT_HOME` test in this binary and restored afterward.
pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let prev = std::env::var("SPT_HOME").ok();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());
    body(tmp.path());
    match prev {
        Some(v) => std::env::set_var("SPT_HOME", v),
        None => std::env::remove_var("SPT_HOME"),
    }
}


File created successfully at: C:/Users/decid/Documents/projects/spt-core/crates/spt-daemon/src/test_home.rs (file state is current in your context — no need to Read it back)

//! Shared `SPT_HOME` isolation for spt-daemon unit tests (mirrors the spt-live
//! `test_support` discipline).
//!
//! Every `SPT_HOME`-touching test in this binary races on the single global env
//! var, so they all serialize on one process-wide `Mutex`.

use std::path::Path;
use std::sync::Mutex;

static HOME_LOCK: Mutex<()> = Mutex::new(());

/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
/// every other `SPT_HOME` test in this binary and restored afterward.
pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let prev = std::env::var("SPT_HOME").ok();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());
    body(tmp.path());
    match prev {
        Some(v) => std::env::set_var("SPT_HOME", v),
        None => std::env::remove_var("SPT_HOME"),
    }
}


Now lifecycle.rs — the re-hosted brain lifecycle.