1	//! Daemon child reap (REQ-HAZARD-DAEMON-STOP-REAP / Breap): the daemon's
2	//! spt-hosted subtree — the supervised brain child and everything it spawns
3	//! (the shellwake watchers, the DETACHED live-agent Psyches) — must die WITH the
4	//! daemon on `spt daemon stop`, never linger as orphaned `claude-spt-psyche.exe`
5	//! / `spt.exe`. Today a stop orphans ~8 of them: the Psyches are spawned
6	//! detached (`spt_runtime::runtime` drops the `Child`) and the brain's livehost /
7	//! shellwake stop flags are held "for symmetry", never raised — so killing the
8	//! daemon leaves the whole brain subtree running.
9	//!
10	//! The fix reaps the brain subtree as ONE OS unit rooted at the brain — never at
11	//! the daemon — so a reap tears down the children without self-terminating the
12	//! daemon (it reaps, then returns from `Daemon::run` cleanly):
13	//!   * Windows: a Job object the brain is assigned to (its descendants inherit
14	//!     the job at creation — no per-grandchild bookkeeping, no assignment race
15	//!     for Psyches the brain spawns much later). `KILL_ON_JOB_CLOSE` is the
16	//!     crash backstop (a daemon that dies without a graceful stop still drops the
17	//!     last job handle at process exit → the OS reaps the members); the graceful
18	//!     path calls [`BrainReaper::reap`] for a deterministic, immediate kill.
19	//!   * Unix: a process group the brain leads (it `setpgid(0, 0)`s itself at spawn
20	//!     via `pre_exec`); its descendants inherit the group. The graceful path
21	//!     `kill(-pgid, SIGKILL)`s the group — the daemon is in its OWN group, so it
22	//!     is never signalled.
23	//!
24	//! Pairs with the B3 stop barrier and the supervisor stop flag the daemon raises
25	//! before reaping (so the supervisor does not respawn the brain being killed).
26	// [impl->REQ-HAZARD-DAEMON-STOP-REAP]
27
28	use std::process::Child;
29	use std::sync::Arc;
30
31	/// The reap container for the daemon's brain subtree. Created once at daemon
32	/// start; the supervisor [`enroll`](BrainReaper::enroll)s every (re)spawned brain
33	/// child, and the graceful-stop path [`reap`](BrainReaper::reap)s the subtree.
34	///
35	/// Send + Sync (a raw Job handle is a thread-safe integer; the Unix pgid is an
36	/// atomic) so the daemon foreground and the supervisor thread share one `Arc`.
37	pub struct BrainReaper {
38	    /// The Win32 Job object handle (`isize`), or `0` when the platform refused to
39	    /// create/assign one — a no-op reaper, never a failed daemon start.
40	    #[cfg(windows)]
41	    job: isize,
42	    /// The current brain's process-group id (its pid, since it leads its own
43	    /// group), or `0` before the first enroll. A respawn overwrites it — only the
44	    /// current brain + its descendants exist to reap.
45	    #[cfg(unix)]
46	    pgid: std::sync::atomic::AtomicI32,
47	}
48
49	impl BrainReaper {
50	    /// Create the reap container. Best-effort on Windows: a platform that refuses
51	    /// the Job object (e.g. already inside a job that forbids nesting) yields a
52	    /// no-op reaper rather than aborting daemon start — orphan-reaping is a
53	    /// hardening, never a prerequisite to serve.
54	    pub fn new() -> Arc<Self> {
55	        #[cfg(windows)]
56	        {
57	            Arc::new(BrainReaper {
58	                job: windows_impl::create_kill_on_close_job(),
59	            })
60	        }
61	        #[cfg(unix)]
62	        {
63	            Arc::new(BrainReaper {
64	                pgid: std::sync::atomic::AtomicI32::new(0),
65	            })
66	        }
67	    }
68
69	    /// Enroll a freshly-spawned brain `child` so it AND its descendants reap as
70	    /// one unit. Windows: assign the child to the Job (its later-spawned Psyches
71	    /// inherit it). Unix: record the child's pid as the group id (the child set
72	    /// its own group via `pre_exec` at spawn — see `spawn_brain_child`).
73	    pub fn enroll(&self, child: &Child) {
74	        #[cfg(windows)]
75	        {
76	            if self.job != 0 {
77	                windows_impl::assign_to_job(self.job, child);
78	            }
79	        }
80	        #[cfg(unix)]
81	        {
82	            self.pgid
83	                .store(child.id() as i32, std::sync::atomic::Ordering::SeqCst);
84	        }
85	    }
86
87	    /// Reap the whole brain subtree NOW — the graceful `daemon stop` path.
88	    /// Windows: `TerminateJobObject` (the daemon is not in the job → unaffected).
89	    /// Unix: `kill(-pgid, SIGKILL)` of the brain's group (the daemon is in its own
90	    /// group → unaffected). A no-op when nothing was ever enrolled.
91	    pub fn reap(&self) {
92	        #[cfg(windows)]
93	        {
94	            if self.job != 0 {
95	                windows_impl::terminate_job(self.job);
96	            }
97	        }
98	        #[cfg(unix)]
99	        {
100	            let pgid = self.pgid.load(std::sync::atomic::Ordering::SeqCst);
101	            if pgid > 0 {
102	                // Negative pid → "the process group". SIGKILL is deterministic:
103	                // the orphan hazard is about leftover processes, so guarantee the
104	                // reap rather than rely on a catchable SIGTERM the harness child
105	                // might ignore.
106	                unsafe {
107	                    libc::kill(-pgid, libc::SIGKILL);
108	                }
109	            }
110	        }
111	    }
112	}
113
114	#[cfg(windows)]
115	mod windows_impl {
116	    use std::ffi::c_void;
117	    use std::os::windows::io::AsRawHandle;
118	    use std::process::Child;
119
120	    // JOBOBJECT_EXTENDED_LIMIT_INFORMATION class + the kill-on-close limit flag.
121	    const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION: u32 = 9;
122	    const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: u32 = 0x0000_2000;
123
124	    #[repr(C)]
125	    #[derive(Default)]
126	    struct JobObjectBasicLimitInformation {
127	        per_process_user_time_limit: i64,
128	        per_job_user_time_limit: i64,
129	        limit_flags: u32,
130	        minimum_working_set_size: usize,
131	        maximum_working_set_size: usize,
132	        active_process_limit: u32,
133	        affinity: usize,
134	        priority_class: u32,
135	        scheduling_class: u32,
136	    }
137
138	    #[repr(C)]
139	    #[derive(Default)]
140	    struct IoCounters {
141	        read_operation_count: u64,
142	        write_operation_count: u64,
143	        other_operation_count: u64,
144	        read_transfer_count: u64,
145	        write_transfer_count: u64,
146	        other_transfer_count: u64,
147	    }
148
149	    #[repr(C)]
150	    #[derive(Default)]
151	    struct JobObjectExtendedLimitInformation {
152	        basic_limit_information: JobObjectBasicLimitInformation,
153	        io_info: IoCounters,
154	        process_memory_limit: usize,
155	        job_memory_limit: usize,
156	        peak_process_memory_used: usize,
157	        peak_job_memory_used: usize,
158	    }
159
160	    extern "system" {
161	        fn CreateJobObjectW(lp_job_attributes: *mut c_void, lp_name: *const u16) -> isize;
162	        fn SetInformationJobObject(
163	            h_job: isize,
164	            job_object_information_class: u32,
165	            lp_job_object_information: *const c_void,
166	            cb_job_object_information_length: u32,
167	        ) -> i32;
168	        fn AssignProcessToJobObject(h_job: isize, h_process: isize) -> i32;
169	        fn TerminateJobObject(h_job: isize, u_exit_code: u32) -> i32;
170	    }
171
172	    /// Create an unnamed Job object with `KILL_ON_JOB_CLOSE` set, so a daemon that
173	    /// dies WITHOUT a graceful stop still reaps the members when the OS closes its
174	    /// last handle at process exit. Returns `0` on any failure (no-op reaper).
175	    pub fn create_kill_on_close_job() -> isize {
176	        let job = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
177	        if job == 0 {
178	            eprintln!("REAP_JOB_CREATE_FAIL: brain subtree will not auto-reap on stop");
179	            return 0;
180	        }
181	        let mut info = JobObjectExtendedLimitInformation::default();
182	        info.basic_limit_information.limit_flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
183	        let ok = unsafe {
184	            SetInformationJobObject(
185	                job,
186	                JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
187	                &info as *const _ as *const c_void,
188	                std::mem::size_of::<JobObjectExtendedLimitInformation>() as u32,
189	            )
190	        };
191	        if ok == 0 {
192	            eprintln!("REAP_JOB_LIMIT_FAIL: kill-on-close not set; brain subtree may orphan");
193	        }
194	        job
195	    }
196
197	    /// Assign `child` to `job`. Its descendants inherit the job at creation.
198	    pub fn assign_to_job(job: isize, child: &Child) {
199	        let handle = child.as_raw_handle() as isize;
200	        let ok = unsafe { AssignProcessToJobObject(job, handle) };
201	        if ok == 0 {
202	            eprintln!("REAP_JOB_ASSIGN_FAIL: brain not enrolled; its subtree may orphan on stop");
203	        }
204	    }
205
206	    /// Terminate every process in `job` immediately.
207	    pub fn terminate_job(job: isize) {
208	        unsafe {
209	            TerminateJobObject(job, 1);
210	        }
211	    }
212	}
213
214	#[cfg(test)]
215	mod tests {
216	    use super::*;
217	    use spt_store::proc::is_process_alive;
218	    use std::path::{Path, PathBuf};
219	    use std::process::{Child, Command};
220	    use std::time::Duration;
221
222	    /// Poll until our OWN `child` is observed exited, or the bound elapses. We
223	    /// hold the `Child` handle, so `try_wait` is the correct liveness probe:
224	    /// `OpenProcess`-based checks read a Windows process we still hold a handle to
225	    /// as "alive" even after `TerminateJobObject`, and on Unix the killed child is
226	    /// a zombie until reaped — `try_wait` resolves both (and reaps the zombie).
227	    fn wait_exit(child: &mut Child) -> bool {
228	        for _ in 0..250 {
229	            if matches!(child.try_wait(), Ok(Some(_))) {
230	                return true;
231	            }
232	            std::thread::sleep(Duration::from_millis(20));
233	        }
234	        false
235	    }
236
237	    /// Poll until `pid` reads dead. For the GRANDCHILD, which we do NOT own (no
238	    /// handle held): once the reap kills the parent it closes the handle that kept
239	    /// the Windows kernel object alive, and on Unix the orphan reparents to init
240	    /// which reaps it — so `is_process_alive` reports it dead.
241	    fn wait_dead(pid: u32) -> bool {
242	        for _ in 0..250 {
243	            if !is_process_alive(pid) {
244	                return true;
245	            }
246	            std::thread::sleep(Duration::from_millis(20));
247	        }
248	        false
249	    }
250
251	    /// Where the spawned child writes its grandchild's pid (test reads it back).
252	    fn gc_pidfile() -> PathBuf {
253	        std::env::temp_dir().join(format!("spt_reap_gc_{}.pid", std::process::id()))
254	    }
255
256	    /// Poll the pid file until the child has written its grandchild's pid.
257	    fn wait_pid_file(p: &Path) -> Option<u32> {
258	        for _ in 0..250 {
259	            if let Ok(s) = std::fs::read_to_string(p) {
260	                if let Ok(pid) = s.trim().parse::<u32>() {
261	                    if pid != 0 {
262	                        return Some(pid);
263	                    }
264	                }
265	            }
266	            std::thread::sleep(Duration::from_millis(20));
267	        }
268	        None
269	    }
270
271	    // [unit->REQ-HAZARD-DAEMON-STOP-REAP] the ACTUAL orphan scenario: a reap of an
272	    // enrolled child ALSO reaps a GRANDCHILD it spawned (no breakaway) — the
273	    // daemon→brain→Psyche shape, where the Psyche is a grandchild of the daemon
274	    // that inherits the brain's job/group. reap() must kill BOTH the child and the
275	    // grandchild, proving the OS-inheritance link by exercise, not by reasoning.
276	    #[cfg(windows)]
277	    #[test]
278	    fn job_reaps_enrolled_child_and_grandchild() {
279	        let reaper = BrainReaper::new();
280	        let pidfile = gc_pidfile();
281	        let _ = std::fs::remove_file(&pidfile);
282	        // The child sleeps 1s BEFORE spawning the grandchild — the window the test
283	        // uses to enroll it into the job first, so the grandchild inherits the job.
284	        // Mirrors the real brain, which connects + signals ready long before it
285	        // ever hosts a Psyche. UseShellExecute=$false is LOAD-BEARING: Windows
286	        // PowerShell's default ShellExecute path spawns the grandchild OUTSIDE the
287	        // job (it breaks away and survives the reap); UseShellExecute=$false forces
288	        // CreateProcess, so the grandchild is a real child that inherits the job —
289	        // exactly the brain→Psyche spawn shape (null stdio + CreateProcess, no
290	        // breakaway). .Id → the pid file.
291	        let script = format!(
292	            "Start-Sleep -Milliseconds 1000; \
293	             $psi=New-Object System.Diagnostics.ProcessStartInfo; \
294	             $psi.FileName='ping'; $psi.Arguments='-n 300 127.0.0.1'; \
295	             $psi.UseShellExecute=$false; $psi.CreateNoWindow=$true; \
296	             $p=[System.Diagnostics.Process]::Start($psi); \
297	             Set-Content -LiteralPath '{}' -Value $p.Id; \
298	             Start-Sleep -Seconds 300",
299	            pidfile.display()
300	        );
301	        let mut child = Command::new("powershell")
302	            .args(["-NoProfile", "-NonInteractive", "-Command", &script])
303	            .spawn()
304	            .expect("spawn child");
305	        reaper.enroll(&child);
306	        let gc = wait_pid_file(&pidfile).expect("child reports its grandchild pid");
307	        assert!(matches!(child.try_wait(), Ok(None)), "child starts alive");
308	        assert!(is_process_alive(gc), "grandchild starts alive");
309	        reaper.reap();
310	        assert!(wait_exit(&mut child), "reap must terminate the enrolled child");
311	        assert!(wait_dead(gc), "reap must terminate the inherited grandchild");
312	        let _ = std::fs::remove_file(&pidfile);
313	    }
314
315	    #[cfg(unix)]
316	    #[test]
317	    fn group_reaps_enrolled_child_and_grandchild() {
318	        use std::os::unix::process::CommandExt;
319	        let reaper = BrainReaper::new();
320	        let pidfile = gc_pidfile();
321	        let _ = std::fs::remove_file(&pidfile);
322	        // The child leads its OWN group (setpgid in pre_exec, applied before exec)
323	        // so the backgrounded grandchild (`sleep`) inherits the group regardless of
324	        // enroll timing; `$!` is the grandchild pid → the pid file; `wait` keeps
325	        // the child alive. The reap (kill -pgid) must take BOTH.
326	        let mut cmd = Command::new("sh");
327	        cmd.arg("-c").arg(format!(
328	            "sleep 300 & echo $! > '{}'; wait",
329	            pidfile.display()
330	        ));
331	        unsafe {
332	            cmd.pre_exec(|| {
333	                libc::setpgid(0, 0);
334	                Ok(())
335	            });
336	        }
337	        let mut child = cmd.spawn().expect("spawn child");
338	        reaper.enroll(&child);
339	        let gc = wait_pid_file(&pidfile).expect("child reports its grandchild pid");
340	        assert!(matches!(child.try_wait(), Ok(None)), "child starts alive");
341	        assert!(is_process_alive(gc), "grandchild starts alive");
342	        reaper.reap();
343	        assert!(wait_exit(&mut child), "reap must terminate the enrolled child");
344	        assert!(wait_dead(gc), "reap must terminate the inherited grandchild");
345	        let _ = std::fs::remove_file(&pidfile);
346	    }
347
348	    // [unit->REQ-HAZARD-DAEMON-STOP-REAP] reap before any enroll is a clean no-op
349	    // (a daemon stopped before the brain ever spawned must not panic).
350	    #[test]
351	    fn reap_without_enroll_is_noop() {
352	        let reaper = BrainReaper::new();
353	        reaper.reap();
354	    }
355	}
356

//! Daemon child reap (REQ-HAZARD-DAEMON-STOP-REAP / Breap): the daemon's
//! spt-hosted subtree — the supervised brain child and everything it spawns
//! (the shellwake watchers, the DETACHED live-agent Psyches) — must die WITH the
//! daemon on `spt daemon stop`, never linger as orphaned `claude-spt-psyche.exe`
//! / `spt.exe`. Today a stop orphans ~8 of them: the Psyches are spawned
//! detached (`spt_runtime::runtime` drops the `Child`) and the brain's livehost /
//! shellwake stop flags are held "for symmetry", never raised — so killing the
//! daemon leaves the whole brain subtree running.
//!
//! The fix reaps the brain subtree as ONE OS unit rooted at the brain — never at
//! the daemon — so a reap tears down the children without self-terminating the
//! daemon (it reaps, then returns from `Daemon::run` cleanly):
//!   * Windows: a Job object the brain is assigned to (its descendants inherit
//!     the job at creation — no per-grandchild bookkeeping, no assignment race
//!     for Psyches the brain spawns much later). `KILL_ON_JOB_CLOSE` is the
//!     crash backstop (a daemon that dies without a graceful stop still drops the
//!     last job handle at process exit → the OS reaps the members); the graceful
//!     path calls [`BrainReaper::reap`] for a deterministic, immediate kill.
//!   * Unix: a process group the brain leads (it `setpgid(0, 0)`s itself at spawn
//!     via `pre_exec`); its descendants inherit the group. The graceful path
//!     `kill(-pgid, SIGKILL)`s the group — the daemon is in its OWN group, so it
//!     is never signalled.
//!
//! Pairs with the B3 stop barrier and the supervisor stop flag the daemon raises
//! before reaping (so the supervisor does not respawn the brain being killed).
// [impl->REQ-HAZARD-DAEMON-STOP-REAP]

use std::process::Child;
use std::sync::Arc;

/// The reap container for the daemon's brain subtree. Created once at daemon
/// start; the supervisor [`enroll`](BrainReaper::enroll)s every (re)spawned brain
/// child, and the graceful-stop path [`reap`](BrainReaper::reap)s the subtree.
///
/// Send + Sync (a raw Job handle is a thread-safe integer; the Unix pgid is an
/// atomic) so the daemon foreground and the supervisor thread share one `Arc`.
pub struct BrainReaper {
    /// The Win32 Job object handle (`isize`), or `0` when the platform refused to
    /// create/assign one — a no-op reaper, never a failed daemon start.
    #[cfg(windows)]
    job: isize,
    /// The current brain's process-group id (its pid, since it leads its own
    /// group), or `0` before the first enroll. A respawn overwrites it — only the
    /// current brain + its descendants exist to reap.
    #[cfg(unix)]
    pgid: std::sync::atomic::AtomicI32,
}

impl BrainReaper {
    /// Create the reap container. Best-effort on Windows: a platform that refuses
    /// the Job object (e.g. already inside a job that forbids nesting) yields a
    /// no-op reaper rather than aborting daemon start — orphan-reaping is a
    /// hardening, never a prerequisite to serve.
    pub fn new() -> Arc<Self> {
        #[cfg(windows)]
        {
            Arc::new(BrainReaper {
                job: windows_impl::create_kill_on_close_job(),
            })
        }
        #[cfg(unix)]
        {
            Arc::new(BrainReaper {
                pgid: std::sync::atomic::AtomicI32::new(0),
            })
        }
    }

    /// Enroll a freshly-spawned brain `child` so it AND its descendants reap as
    /// one unit. Windows: assign the child to the Job (its later-spawned Psyches
    /// inherit it). Unix: record the child's pid as the group id (the child set
    /// its own group via `pre_exec` at spawn — see `spawn_brain_child`).
    pub fn enroll(&self, child: &Child) {
        #[cfg(windows)]
        {
            if self.job != 0 {
                windows_impl::assign_to_job(self.job, child);
            }
        }
        #[cfg(unix)]
        {
            self.pgid
                .store(child.id() as i32, std::sync::atomic::Ordering::SeqCst);
        }
    }

    /// Reap the whole brain subtree NOW — the graceful `daemon stop` path.
    /// Windows: `TerminateJobObject` (the daemon is not in the job → unaffected).
    /// Unix: `kill(-pgid, SIGKILL)` of the brain's group (the daemon is in its own
    /// group → unaffected). A no-op when nothing was ever enrolled.
    pub fn reap(&self) {
        #[cfg(windows)]
        {
            if self.job != 0 {
                windows_impl::terminate_job(self.job);
            }
        }
        #[cfg(unix)]
        {
            let pgid = self.pgid.load(std::sync::atomic::Ordering::SeqCst);
            if pgid > 0 {
                // Negative pid → "the process group". SIGKILL is deterministic:
                // the orphan hazard is about leftover processes, so guarantee the
                // reap rather than rely on a catchable SIGTERM the harness child
                // might ignore.
                unsafe {
                    libc::kill(-pgid, libc::SIGKILL);
                }
            }
        }
    }
}

#[cfg(windows)]
mod windows_impl {
    use std::ffi::c_void;
    use std::os::windows::io::AsRawHandle;
    use std::process::Child;

    // JOBOBJECT_EXTENDED_LIMIT_INFORMATION class + the kill-on-close limit flag.
    const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION: u32 = 9;
    const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: u32 = 0x0000_2000;

    #[repr(C)]
    #[derive(Default)]
    struct JobObjectBasicLimitInformation {
        per_process_user_time_limit: i64,
        per_job_user_time_limit: i64,
        limit_flags: u32,
        minimum_working_set_size: usize,
        maximum_working_set_size: usize,
        active_process_limit: u32,
        affinity: usize,
        priority_class: u32,
        scheduling_class: u32,
    }

    #[repr(C)]
    #[derive(Default)]
    struct IoCounters {
        read_operation_count: u64,
        write_operation_count: u64,
        other_operation_count: u64,
        read_transfer_count: u64,
        write_transfer_count: u64,
        other_transfer_count: u64,
    }

    #[repr(C)]
    #[derive(Default)]
    struct JobObjectExtendedLimitInformation {
        basic_limit_information: JobObjectBasicLimitInformation,
        io_info: IoCounters,
        process_memory_limit: usize,
        job_memory_limit: usize,
        peak_process_memory_used: usize,
        peak_job_memory_used: usize,
    }

    extern "system" {
        fn CreateJobObjectW(lp_job_attributes: *mut c_void, lp_name: *const u16) -> isize;
        fn SetInformationJobObject(
            h_job: isize,
            job_object_information_class: u32,
            lp_job_object_information: *const c_void,
            cb_job_object_information_length: u32,
        ) -> i32;
        fn AssignProcessToJobObject(h_job: isize, h_process: isize) -> i32;
        fn TerminateJobObject(h_job: isize, u_exit_code: u32) -> i32;
    }

    /// Create an unnamed Job object with `KILL_ON_JOB_CLOSE` set, so a daemon that
    /// dies WITHOUT a graceful stop still reaps the members when the OS closes its
    /// last handle at process exit. Returns `0` on any failure (no-op reaper).
    pub fn create_kill_on_close_job() -> isize {
        let job = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
        if job == 0 {
            eprintln!("REAP_JOB_CREATE_FAIL: brain subtree will not auto-reap on stop");
            return 0;
        }
        let mut info = JobObjectExtendedLimitInformation::default();
        info.basic_limit_information.limit_flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
        let ok = unsafe {
            SetInformationJobObject(
                job,
                JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
                &info as *const _ as *const c_void,
                std::mem::size_of::<JobObjectExtendedLimitInformation>() as u32,
            )
        };
        if ok == 0 {
            eprintln!("REAP_JOB_LIMIT_FAIL: kill-on-close not set; brain subtree may orphan");
        }
        job
    }

    /// Assign `child` to `job`. Its descendants inherit the job at creation.
    pub fn assign_to_job(job: isize, child: &Child) {
        let handle = child.as_raw_handle() as isize;
        let ok = unsafe { AssignProcessToJobObject(job, handle) };
        if ok == 0 {
            eprintln!("REAP_JOB_ASSIGN_FAIL: brain not enrolled; its subtree may orphan on stop");
        }
    }

    /// Terminate every process in `job` immediately.
    pub fn terminate_job(job: isize) {
        unsafe {
            TerminateJobObject(job, 1);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_store::proc::is_process_alive;
    use std::path::{Path, PathBuf};
    use std::process::{Child, Command};
    use std::time::Duration;

    /// Poll until our OWN `child` is observed exited, or the bound elapses. We
    /// hold the `Child` handle, so `try_wait` is the correct liveness probe:
    /// `OpenProcess`-based checks read a Windows process we still hold a handle to
    /// as "alive" even after `TerminateJobObject`, and on Unix the killed child is
    /// a zombie until reaped — `try_wait` resolves both (and reaps the zombie).
    fn wait_exit(child: &mut Child) -> bool {
        for _ in 0..250 {
            if matches!(child.try_wait(), Ok(Some(_))) {
                return true;
            }
            std::thread::sleep(Duration::from_millis(20));
        }
        false
    }

    /// Poll until `pid` reads dead. For the GRANDCHILD, which we do NOT own (no
    /// handle held): once the reap kills the parent it closes the handle that kept
    /// the Windows kernel object alive, and on Unix the orphan reparents to init
    /// which reaps it — so `is_process_alive` reports it dead.
    fn wait_dead(pid: u32) -> bool {
        for _ in 0..250 {
            if !is_process_alive(pid) {
                return true;
            }
            std::thread::sleep(Duration::from_millis(20));
        }
        false
    }

    /// Where the spawned child writes its grandchild's pid (test reads it back).
    fn gc_pidfile() -> PathBuf {
        std::env::temp_dir().join(format!("spt_reap_gc_{}.pid", std::process::id()))
    }

    /// Poll the pid file until the child has written its grandchild's pid.
    fn wait_pid_file(p: &Path) -> Option<u32> {
        for _ in 0..250 {
            if let Ok(s) = std::fs::read_to_string(p) {
                if let Ok(pid) = s.trim().parse::<u32>() {
                    if pid != 0 {
                        return Some(pid);
                    }
                }
            }
            std::thread::sleep(Duration::from_millis(20));
        }
        None
    }

    // [unit->REQ-HAZARD-DAEMON-STOP-REAP] the ACTUAL orphan scenario: a reap of an
    // enrolled child ALSO reaps a GRANDCHILD it spawned (no breakaway) — the
    // daemon→brain→Psyche shape, where the Psyche is a grandchild of the daemon
    // that inherits the brain's job/group. reap() must kill BOTH the child and the
    // grandchild, proving the OS-inheritance link by exercise, not by reasoning.
    #[cfg(windows)]
    #[test]
    fn job_reaps_enrolled_child_and_grandchild() {
        let reaper = BrainReaper::new();
        let pidfile = gc_pidfile();
        let _ = std::fs::remove_file(&pidfile);
        // The child sleeps 1s BEFORE spawning the grandchild — the window the test
        // uses to enroll it into the job first, so the grandchild inherits the job.
        // Mirrors the real brain, which connects + signals ready long before it
        // ever hosts a Psyche. UseShellExecute=$false is LOAD-BEARING: Windows
        // PowerShell's default ShellExecute path spawns the grandchild OUTSIDE the
        // job (it breaks away and survives the reap); UseShellExecute=$false forces
        // CreateProcess, so the grandchild is a real child that inherits the job —
        // exactly the brain→Psyche spawn shape (null stdio + CreateProcess, no
        // breakaway). .Id → the pid file.
        let script = format!(
            "Start-Sleep -Milliseconds 1000; \
             $psi=New-Object System.Diagnostics.ProcessStartInfo; \
             $psi.FileName='ping'; $psi.Arguments='-n 300 127.0.0.1'; \
             $psi.UseShellExecute=$false; $psi.CreateNoWindow=$true; \
             $p=[System.Diagnostics.Process]::Start($psi); \
             Set-Content -LiteralPath '{}' -Value $p.Id; \
             Start-Sleep -Seconds 300",
            pidfile.display()
        );
        let mut child = Command::new("powershell")
            .args(["-NoProfile", "-NonInteractive", "-Command", &script])
            .spawn()
            .expect("spawn child");
        reaper.enroll(&child);
        let gc = wait_pid_file(&pidfile).expect("child reports its grandchild pid");
        assert!(matches!(child.try_wait(), Ok(None)), "child starts alive");
        assert!(is_process_alive(gc), "grandchild starts alive");
        reaper.reap();
        assert!(wait_exit(&mut child), "reap must terminate the enrolled child");
        assert!(wait_dead(gc), "reap must terminate the inherited grandchild");
        let _ = std::fs::remove_file(&pidfile);
    }

    #[cfg(unix)]
    #[test]
    fn group_reaps_enrolled_child_and_grandchild() {
        use std::os::unix::process::CommandExt;
        let reaper = BrainReaper::new();
        let pidfile = gc_pidfile();
        let _ = std::fs::remove_file(&pidfile);
        // The child leads its OWN group (setpgid in pre_exec, applied before exec)
        // so the backgrounded grandchild (`sleep`) inherits the group regardless of
        // enroll timing; `$!` is the grandchild pid → the pid file; `wait` keeps
        // the child alive. The reap (kill -pgid) must take BOTH.
        let mut cmd = Command::new("sh");
        cmd.arg("-c").arg(format!(
            "sleep 300 & echo $! > '{}'; wait",
            pidfile.display()
        ));
        unsafe {
            cmd.pre_exec(|| {
                libc::setpgid(0, 0);
                Ok(())
            });
        }
        let mut child = cmd.spawn().expect("spawn child");
        reaper.enroll(&child);
        let gc = wait_pid_file(&pidfile).expect("child reports its grandchild pid");
        assert!(matches!(child.try_wait(), Ok(None)), "child starts alive");
        assert!(is_process_alive(gc), "grandchild starts alive");
        reaper.reap();
        assert!(wait_exit(&mut child), "reap must terminate the enrolled child");
        assert!(wait_dead(gc), "reap must terminate the inherited grandchild");
        let _ = std::fs::remove_file(&pidfile);
    }

    // [unit->REQ-HAZARD-DAEMON-STOP-REAP] reap before any enroll is a clean no-op
    // (a daemon stopped before the brain ever spawned must not panic).
    #[test]
    fn reap_without_enroll_is_noop() {
        let reaper = BrainReaper::new();
        reaper.reap();
    }
}


don't self-clear, just end turn when you need it and i'll clear+resume


FYI: don't self-clear, just end turn when you need it and i'll clear+resume


L1 has real Windows nuances confirmed: broker (daemon-lifetime anchor) runs in the **daemon** process, so broker-spawned harnesses are **daemon children** (not brain children — `BrainReaper`'s job won't contain them); `CreateProcessWithTokenW` rejects most creation flags (breakaway may not be accepted on the elevated path); and job-nesting semantics need careful reasoning. This is too delicate for my now-high context. Capture L1 findings to memory, then re-arm self-clear for a fresh L1.


Write

---
name: v0121-l1-viewer-close-detach-findings
description: v0.12.1 L1 VIEWER-CLOSE-DETACH pre-implementation findings — Windows Job Object topology + spawn-path nuances
metadata:
  type: project
---

v0.12.1 Wave 2 **L1** (REQ-HAZARD-VIEWER-CLOSE-DETACH) — pre-impl reconnaissance done; NOT yet implemented. Branch v0.12.1-lifecycle (L0 shipped @cf5eab4). Findings to ground the fresh-context build:

**Bug:** closing the terminal tab where `spt endpoint run` was invoked kills the daemon-hosted harness. Manifests when the daemon is FRESHLY spawned by THIS `endpoint run` (first run in a session) — it inherits the terminal's Windows Job Object (KILL_ON_JOB_CLOSE, set by Windows Terminal/VS Code), so tab-close reaps the daemon + everything under it. (If the daemon was already running from a prior session it's not in this terminal's job → no repro — so the gate MUST spawn the daemon under a parent-held KILL_ON_JOB_CLOSE job.)

**TOPOLOGY (load-bearing):** the BROKER is the daemon-lifetime anchor and runs in the DAEMON process (see [[broker-is-daemon-state-anchor]]). Broker-spawned harnesses (`dispatch_spawn` → `PtySession::spawn_program_in_env`, portable-pty, broker.rs) are therefore DAEMON children, NOT brain children. So `reap.rs` `BrainReaper`'s job (which holds the BRAIN child + its descendants) does NOT contain the harnesses — a separate daemon-owned job is needed for them.

**Spawn paths to fix:**
1. `crates/spt-daemon/src/daemon.rs:~707` `detached_no_inherit`: FLAGS = DETACHED_PROCESS(0x8)|CREATE_NEW_PROCESS_GROUP(0x200)|CREATE_NO_WINDOW(0x8000000). Add CREATE_BREAKAWAY_FROM_JOB (0x0100_0000) so the daemon escapes the terminal's job. Plain `CreateProcessW` — accepts the flag.
2. `crates/spt-daemon/src/deelevate.rs:~519` elevated path uses `CreateProcessWithTokenW`, FLAGS = CREATE_NEW_CONSOLE(0x10)|CREATE_NEW_PROCESS_GROUP(0x200)|CREATE_UNICODE_ENVIRONMENT(0x400). CAUTION: `CreateProcessWithTokenW` accepts ONLY a RESTRICTED creation-flag set (DETACHED_PROCESS was historically rejected with ERROR_INVALID_PARAMETER 87 — see the comment there). CREATE_BREAKAWAY_FROM_JOB is likely NOT accepted — VERIFY empirically; if rejected, the elevated daemon can't breakaway via flags → rely on the daemon-owned-job backstop, or breakaway the harness at the broker spawn instead.

**Backstop (mirror `reap.rs` BrainReaper):** pin each broker-spawned harness into a DAEMON-OWNED Job Object. Pattern = `CreateJobObjectW` + `SetInformationJobObject`(KILL_ON_JOB_CLOSE) + `AssignProcessToJobObject(job, harness_handle)`. The harness pid is available (PtySession::process_id()); need its process HANDLE for AssignProcessToJobObject (OpenProcess by pid, or portable-pty may expose it). THINK CAREFULLY about Windows job NESTING (Win8+ nested jobs): if the daemon is still in the terminal's job (breakaway failed) a daemon-owned harness job does NOT save the harness when the terminal job is killed — breakaway on the daemon is the real fix; the daemon-owned job's primary value is clean L4 daemon-stop-ends-all reap + a hardening layer. Reconcile the backstop's exact survival guarantee before claiming it.

**Unix:** verify the daemon's session-detach already covers terminal-close (SIGHUP scope) — likely no change, add a guard test.

**GATE:** spawn daemon under a parent-held KILL_ON_JOB_CLOSE job → `endpoint run` dummy harness → close the parent job → assert harness pid STAYS ALIVE + `spt rc <id>` re-attaches + a brand-new endpoint launches. Activate REQ-HAZARD-VIEWER-CLOSE-DETACH [impl,unit,int], tag in-commit. SEAM BAR (CARGO_TARGET_DIR=target-seam). doyle code-reads per-wave + one comprehensive real-harness re-run at L2 SEAM-GREEN in his own isolated worktree. See [[v0121-l0-attach-deadlock-root]].


File created successfully at: C:\Users\decid\.ccs\instances\bigscreen\projects\C--Users-decid-Documents-projects-spt-core\memory\v0121-l1-viewer-close-detach-findings.md (file state is current in your context — no need to Read it back)

---
name: v0121-l1-viewer-close-detach-findings
description: v0.12.1 L1 VIEWER-CLOSE-DETACH pre-implementation findings — Windows Job Object topology + spawn-path nuances
metadata:
  node_type: memory
  type: project
  originSessionId: 3acfe970-b087-4764-8a3e-c2253b8bd0ae
---

v0.12.1 Wave 2 **L1** (REQ-HAZARD-VIEWER-CLOSE-DETACH) — pre-impl reconnaissance done; NOT yet implemented. Branch v0.12.1-lifecycle (L0 shipped @cf5eab4). Findings to ground the fresh-context build:

**Bug:** closing the terminal tab where `spt endpoint run` was invoked kills the daemon-hosted harness. Manifests when the daemon is FRESHLY spawned by THIS `endpoint run` (first run in a session) — it inherits the terminal's Windows Job Object (KILL_ON_JOB_CLOSE, set by Windows Terminal/VS Code), so tab-close reaps the daemon + everything under it. (If the daemon was already running from a prior session it's not in this terminal's job → no repro — so the gate MUST spawn the daemon under a parent-held KILL_ON_JOB_CLOSE job.)

**TOPOLOGY (load-bearing):** the BROKER is the daemon-lifetime anchor and runs in the DAEMON process (see [[broker-is-daemon-state-anchor]]). Broker-spawned harnesses (`dispatch_spawn` → `PtySession::spawn_program_in_env`, portable-pty, broker.rs) are therefore DAEMON children, NOT brain children. So `reap.rs` `BrainReaper`'s job (which holds the BRAIN child + its descendants) does NOT contain the harnesses — a separate daemon-owned job is needed for them.

**Spawn paths to fix:**
1. `crates/spt-daemon/src/daemon.rs:~707` `detached_no_inherit`: FLAGS = DETACHED_PROCESS(0x8)|CREATE_NEW_PROCESS_GROUP(0x200)|CREATE_NO_WINDOW(0x8000000). Add CREATE_BREAKAWAY_FROM_JOB (0x0100_0000) so the daemon escapes the terminal's job. Plain `CreateProcessW` — accepts the flag.
2. `crates/spt-daemon/src/deelevate.rs:~519` elevated path uses `CreateProcessWithTokenW`, FLAGS = CREATE_NEW_CONSOLE(0x10)|CREATE_NEW_PROCESS_GROUP(0x200)|CREATE_UNICODE_ENVIRONMENT(0x400). CAUTION: `CreateProcessWithTokenW` accepts ONLY a RESTRICTED creation-flag set (DETACHED_PROCESS was historically rejected with ERROR_INVALID_PARAMETER 87 — see the comment there). CREATE_BREAKAWAY_FROM_JOB is likely NOT accepted — VERIFY empirically; if rejected, the elevated daemon can't breakaway via flags → rely on the daemon-owned-job backstop, or breakaway the harness at the broker spawn instead.

**Backstop (mirror `reap.rs` BrainReaper):** pin each broker-spawned harness into a DAEMON-OWNED Job Object. Pattern = `CreateJobObjectW` + `SetInformationJobObject`(KILL_ON_JOB_CLOSE) + `AssignProcessToJobObject(job, harness_handle)`. The harness pid is available (PtySession::process_id()); need its process HANDLE for AssignProcessToJobObject (OpenProcess by pid, or portable-pty may expose it). THINK CAREFULLY about Windows job NESTING (Win8+ nested jobs): if the daemon is still in the terminal's job (breakaway failed) a daemon-owned harness job does NOT save the harness when the terminal job is killed — breakaway on the daemon is the real fix; the daemon-owned job's primary value is clean L4 daemon-stop-ends-all reap + a hardening layer. Reconcile the backstop's exact survival guarantee before claiming it.

**Unix:** verify the daemon's session-detach already covers terminal-close (SIGHUP scope) — likely no change, add a guard test.

**GATE:** spawn daemon under a parent-held KILL_ON_JOB_CLOSE job → `endpoint run` dummy harness → close the parent job → assert harness pid STAYS ALIVE + `spt rc <id>` re-attaches + a brand-new endpoint launches. Activate REQ-HAZARD-VIEWER-CLOSE-DETACH [impl,unit,int], tag in-commit. SEAM BAR (CARGO_TARGET_DIR=target-seam). doyle code-reads per-wave + one comprehensive real-harness re-run at L2 SEAM-GREEN in his own isolated worktree. See [[v0121-l0-attach-deadlock-root]].


Prompt: FYI: don't self-clear, just end turn when you need it and i'll clear+resume


Understood — I won't self-clear; I'll end the turn at clean boundaries and you clear+resume. I'm at exactly such a boundary: L0 shipped, L1 recon done + captured to memory. Adding the index pointer, then ending.


Edit

- [v0.12.1 L0 attach-deadlock root](v0121-l0-attach-deadlock-root.md) —

- [v0.12.1 L1 viewer-close-detach findings](v0121-l1-viewer-close-detach-findings.md) — NEXT wave recon (not yet built): tab-close kills daemon-hosted harness when daemon freshly spawned by `endpoint run` (inherits terminal's KILL_ON_JOB_CLOSE job). Broker runs in DAEMON proc so harnesses are DAEMON children (BrainReaper job won't hold them). Fix: CREATE_BREAKAWAY_FROM_JOB on daemon.rs detached_no_inherit (+ deelevate.rs — but CreateProcessWithTokenW may reject the flag) + daemon-owned Job backstop (mirror reap.rs). Gate spawns daemon under a parent KILL_ON_JOB_CLOSE job. Think job-nesting survival before claiming backstop
- [v0.12.1 L0 attach-deadlock root](v0121-l0-attach-deadlock-root.md) —


The file C:\Users\decid\.ccs\instances\bigscreen\projects\C--Users-decid-Documents-projects-spt-core\memory\MEMORY.md has been updated successfully. (file state is current in your context — no need to Read it back)

- [v0.12.1 L1 viewer-close-detach findings](v0121-l1-viewer-close-detach-findings.md) — NEXT wave recon (not yet built): tab-close kills daemon-hosted harness when daemon freshly spawned by `endpoint run` (inherits terminal's KILL_ON_JOB_CLOSE job). Broker runs in DAEMON proc so harnesses are DAEMON children (BrainReaper job won't hold them). Fix: CREATE_BREAKAWAY_FROM_JOB on daemon.rs detached_no_inherit (+ deelevate.rs — but CreateProcessWithTokenW may reject the flag) + daemon-owned Job backstop (mirror reap.rs). Gate spawns daemon under a parent KILL_ON_JOB_CLOSE job. Think job-nesting survival before claiming backstop
- [v0.12.1 L0 attach-deadlock root](v0121-l0-attach-deadlock-root.md) —

- [v0.12.0 lifecycle milestone](v012-lifecycle-milestone.md) — SHIPPED 2026-06-18 (counter 25, MINOR). Operator-mandated BLOCKING: status=online latch reconciled vs liveness (controllable-gated, relay exempt) + stop/restart teardown/reap. 9 items all GATE-PASS; CI escape (Linux /bin/sh→dash basename in reap unit) caught+fixed @6de905b; post-publish changelog corrected (resume-picker = ready-only, +un-host-reap bullet). Roots verified-at-code-first; full seam-bar/wave

- [v0.12.1 real-harness reopen](v0121-realharness-reopen.md) — v0.12.0 SHIPPED but lifecycle fixes DON'T work in the real claude-spt harness (gated on mocks/in-proc → green-but-broken). Reopened v0.12.1 (branch+JIT+5 REQs @a4a76c6). Roots: VIEWER-CLOSE-DETACH (missing CREATE_BREAKAWAY_FROM_JOB → terminal Job Object reaps daemon's harness on tab-close) + ATTACH-WEDGE (dead PTY child + undrained pump blocks 2-worker loopback runtime). BINDING: gate every lifecycle fix vs a REAL dummy-harness fixture + real daemon, NO mocks. Invariant "a view is independent from the endpoint" in CONTEXT.md. On legacy spt here = spt-core freely restartable

+- [v0.12.1 L1 viewer-close-detach findings](v0121-l1-viewer-close-detach-findings.md) — NEXT wave recon (not yet built): tab-close kills daemon-hosted harness when daemon freshly spawned by `endpoint run` (inherits terminal's KILL_ON_JOB_CLOSE job). Broker runs in DAEMON proc so harnesses are DAEMON children (BrainReaper job won't hold them). Fix: CREATE_BREAKAWAY_FROM_JOB on daemon.rs detached_no_inherit (+ deelevate.rs — but CreateProcessWithTokenW may reject the flag) + daemon-owned Job backstop (mirror reap.rs). Gate spawns daemon under a parent KILL_ON_JOB_CLOSE job. Think job-nesting survival before claiming backstop

- [v0.12.1 L0 attach-deadlock root](v0121-l0-attach-deadlock-root.md) — KEYSTONE root-caused: serve_attach receives+forwards on SAME broker IPC conn; become_controller's inline multi-frame ring replay blocks the single-threaded conn handler from reading serve's forwards → backpressure deadlock → rc 0 bytes. Only fires with non-empty ring (already-producing harness = endpoint-run). Awaiting doyle fix ruling: (a) separate forward conn vs (b) non-blocking replay

- [v0.12.1 Wave 1 progress](v0121-lifecycle-realharness.md) — dummy-harness fixture EXECUTION state (todlando). `mock-session --mode dummy` (binds+heartbeats+stays-alive) + dummy_harness_e2e.rs. Bringup PROVEN green (online+alive+psyche). 3 load-bearing fixture findings: psyche_init required for online (startup.rs:464), test must be NET-ENABLED (rc loopback needs net host), rc stdin held-open. OPEN: rc attaches but gets no PTY output — awaiting doyle ruling (Wave1=connect-only vs root-now). On-branch UNCOMMITTED. Full Wave2 keystone + repro data here too

- [changelog scope vs commit range](changelog-scope-vs-commit-range.md) — gating a release CHANGELOG: map EVERY commit in `vPREV..vNEW` to a bullet (catch misses) + check each bullet's SCOPE isn't broader than its commit (catch overstatements — a touched feature may predate the release). v0.12.0 had both errors. Post-publish fix = docs-only (CHANGELOG.md on main + gh release edit notes), no retag


Clean boundary reached. Ending turn for you to clear+resume.

## Status

**L0 keystone — SHIPPED** (committed @cf5eab4, branch `v0.12.1-lifecycle`):
- Root: replay-vs-forward IPC deadlock in `serve_attach` (received session output + sent wire forwards on the *same* broker conn; `become_controller`'s sync inline ring replay starved the forwards).
- Fix (doyle Option a): forwards on a separate `wire` conn.
- REQ activated `[impl,unit,int]`; gate-green (attach 13/13, dummy int gate, clippy `--workspace` clean, traceable EXIT=0). **doyle code-read PASS.**

**L1 (VIEWER-CLOSE-DETACH) — recon done, not built.** Captured to memory `[[v0121-l1-viewer-close-detach-findings]]`:
- Bug repros only when the daemon is freshly spawned by `endpoint run` (inherits the terminal's KILL_ON_JOB_CLOSE job).
- Broker runs in the **daemon** process → harnesses are **daemon children** (`BrainReaper`'s job won't hold them).
- Fix path: `CREATE_BREAKAWAY_FROM_JOB` on `daemon.rs` `detached_no_inherit` (+ `deelevate.rs`, but `CreateProcessWithTokenW` likely rejects the flag — verify) + daemon-owned Job backstop. Open question to settle first: job-nesting survival guarantee of the backstop.

**On resume:** read `V0.12.1-LIFECYCLE-JIT.md` (L0 marked DONE) + `[[v0121-l1-viewer-close-detach-findings]]`, build L1, then L2 (ATTACH-WEDGE). doyle gates per-wave + one comprehensive real-harness re-run at L2 SEAM-GREEN in his isolated worktree. Ping doyle L1 then L2 SEAM-GREEN.