1	//! The brain **process** + its broker-side supervisor (ADR-0018 Q2/Q3 — the
2	//! broker/brain process-isolation restoration, D1 skeleton).
3	//!
4	//! ADR-0004 specified the broker/brain split as two **processes**; production
5	//! drifted to an in-process thread (`daemon.rs`), so `spt update apply` swaps
6	//! the binary on disk but never restarts the running code (the regression
7	//! ADR-0018 corrects). D1 restores the process boundary as a *true minimal
8	//! skeleton*: `spt daemon run` is the broker process and spawns a supervised
9	//! `spt daemon brain` child. The child connects over the existing broker socket
10	//! IPC, signals `ready`, hosts the brain-side logic loops, and idles. D2
11	//! migrates the broker-resident loops here one per commit; **D2-1 moved the net
12	//! consumers** (inbound dispatcher + outbound peer pump — pure IPC clients, no
13	//! shared `Arc<Broker>`) into the brain, gated on the existing `net-status`
14	//! verb. The digest hub serve + seed-control stay broker-side continuity
15	//! resources (ADR-0018 Q2/Q5); later D2 commits move the digest parse + psyche
16	//! loops.
17	//!
18	//! What D1 proves (de-risk spike #1, the design's hardest invariant): the
19	//! broker spawns + supervises a brain child over the socket, and the broker
20	//! (with its PTY children + seed channel) survives the brain dying — the
21	//! supervisor respawns it from the executable path, which on a real update is
22	//! the swapped binary.
23	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
24
25	use std::io;
26	use std::path::{Path, PathBuf};
27	use std::process::{Child, Command};
28	use std::sync::atomic::{AtomicBool, Ordering};
29	use std::sync::Arc;
30	use std::thread;
31	use std::time::{Duration, Instant};
32
33	use crate::brain::{now_ms, Brain};
34	use crate::endpoint::{brain_ready_path, broker_socket_name};
35	use crate::relcache::{AppliedRecord, ReleaseCache};
36
37	/// Supervisor backoff floor after a brain death (mirrors the peer-pump
38	/// supervisor, M8 decision 23): a healthy run resets to this; a fast death
39	/// doubles, capped at [`SUPERVISE_BACKOFF_CAP`].
40	const SUPERVISE_BACKOFF_BASE: Duration = Duration::from_secs(2);
41	/// Backoff ceiling — a persistently-crashing brain stays visible and bounded,
42	/// never a hot respawn loop.
43	const SUPERVISE_BACKOFF_CAP: Duration = Duration::from_secs(60);
44	/// A run at least this long counts as healthy and resets the backoff floor.
45	/// Reused as the per-attempt **trial window** (ADR-0018 Q7, D6-2): a candidate
46	/// brain has this long to signal `ready` for its generation before the window
47	/// elapses (the "up but useless" alive-never-ready rollback path).
48	const SUPERVISE_HEALTHY_RUN: Duration = Duration::from_secs(30);
49	/// The readiness-trial retry budget (ADR-0018 Q7, D6-2): a candidate that
50	/// **exits before** signaling ready is respawned this many consecutive times
51	/// (a transient first-boot hiccup should not abandon a good binary) before the
52	/// supervisor auto-rolls-back to the last-known-good binary. Matched to the
53	/// capped-backoff cadence; either this budget OR an alive-but-never-ready
54	/// window-expiry triggers rollback.
55	const TRIAL_PREREADY_BUDGET: u32 = 3;
56	/// Poll/sleep slice so a `stop` request (and a child exit) land promptly.
57	const TICK: Duration = Duration::from_millis(200);
58
59	/// How long the brain child retries connecting to the broker socket before
60	/// giving up (the broker may still be binding — the child is freshly spawned).
61	const BRAIN_CONNECT_TRIES: u32 = 300;
62	/// Cadence the idle brain refreshes its ready breadcrumb + probes the broker.
63	const BRAIN_HEARTBEAT: Duration = Duration::from_millis(500);
64
65	/// Why the broker spawned this brain — the Q4 update-vs-crash discriminator the
66	/// broker stamps at spawn time (ADR-0018 V2). One channel, three payloads: the
67	/// broker *observes* every respawn, so it alone is authoritative (strictly more
68	/// reliable than a brain→brain frame, which retires in D4).
69	///
70	/// Parsing is **lenient** (KH-2.3 forward-compat): an unrecognized value — a
71	/// reason a *future* broker adds that this brain predates — defaults to `Cold`,
72	/// never a parse failure that would brick the brain before it rehydrates.
73	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
74	#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75	pub enum StartReason {
76	    /// The broker's first brain spawn this broker lifetime.
77	    Cold,
78	    /// A supervised respawn after an unexpected brain exit.
79	    Crash,
80	    /// A respawn the broker itself initiated for a binary swap (wired in D3-3).
81	    Update,
82	}
83
84	impl StartReason {
85	    /// The argv token the broker passes to `spt daemon brain --start-reason`.
86	    pub fn as_arg(self) -> &'static str {
87	        match self {
88	            StartReason::Cold => "cold",
89	            StartReason::Crash => "crash",
90	            StartReason::Update => "update",
91	        }
92	    }
93
94	    /// Parse the `--start-reason` argv value, lenient: an unknown token (a
95	    /// newer broker's reason an older brain predates) defaults to `Cold` rather
96	    /// than failing the parse (KH-2.3 — defaults survive intermediate versions).
97	    pub fn from_arg(s: &str) -> Self {
98	        match s {
99	            "update" => StartReason::Update,
100	            "crash" => StartReason::Crash,
101	            _ => StartReason::Cold,
102	        }
103	    }
104	}
105
106	/// The broker→supervisor **planned-restart signal** (ADR-0018 D3-3, Q3). The
107	/// broker's `KIND_BRAIN_RESTART` handler raises it; the supervisor consumes it on
108	/// its next wait tick, kills the current brain child, and respawns it with
109	/// `start-reason=update` onto the (freshly-swapped) binary. Shared (`Arc`)
110	/// between the broker serve path and the supervisor — both run in the broker
111	/// process. This is the seam that makes a routine `apply` actually run new code,
112	/// the pillar the in-process-collapse regression silently unrealized.
113	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
114	#[derive(Default)]
115	pub struct BrainRestart {
116	    requested: AtomicBool,
117	}
118
119	impl BrainRestart {
120	    /// A fresh signal with no pending request.

//! The brain **process** + its broker-side supervisor (ADR-0018 Q2/Q3 — the
//! broker/brain process-isolation restoration, D1 skeleton).
//!
//! ADR-0004 specified the broker/brain split as two **processes**; production
//! drifted to an in-process thread (`daemon.rs`), so `spt update apply` swaps
//! the binary on disk but never restarts the running code (the regression
//! ADR-0018 corrects). D1 restores the process boundary as a *true minimal
//! skeleton*: `spt daemon run` is the broker process and spawns a supervised
//! `spt daemon brain` child. The child connects over the existing broker socket
//! IPC, signals `ready`, hosts the brain-side logic loops, and idles. D2
//! migrates the broker-resident loops here one per commit; **D2-1 moved the net
//! consumers** (inbound dispatcher + outbound peer pump — pure IPC clients, no
//! shared `Arc<Broker>`) into the brain, gated on the existing `net-status`
//! verb. The digest hub serve + seed-control stay broker-side continuity
//! resources (ADR-0018 Q2/Q5); later D2 commits move the digest parse + psyche
//! loops.
//!
//! What D1 proves (de-risk spike #1, the design's hardest invariant): the
//! broker spawns + supervises a brain child over the socket, and the broker
//! (with its PTY children + seed channel) survives the brain dying — the
//! supervisor respawns it from the executable path, which on a real update is
//! the swapped binary.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]

use std::io;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use crate::brain::{now_ms, Brain};
use crate::endpoint::{brain_ready_path, broker_socket_name};
use crate::relcache::{AppliedRecord, ReleaseCache};

/// Supervisor backoff floor after a brain death (mirrors the peer-pump
/// supervisor, M8 decision 23): a healthy run resets to this; a fast death
/// doubles, capped at [`SUPERVISE_BACKOFF_CAP`].
const SUPERVISE_BACKOFF_BASE: Duration = Duration::from_secs(2);
/// Backoff ceiling — a persistently-crashing brain stays visible and bounded,
/// never a hot respawn loop.
const SUPERVISE_BACKOFF_CAP: Duration = Duration::from_secs(60);
/// A run at least this long counts as healthy and resets the backoff floor.
/// Reused as the per-attempt **trial window** (ADR-0018 Q7, D6-2): a candidate
/// brain has this long to signal `ready` for its generation before the window
/// elapses (the "up but useless" alive-never-ready rollback path).
const SUPERVISE_HEALTHY_RUN: Duration = Duration::from_secs(30);
/// The readiness-trial retry budget (ADR-0018 Q7, D6-2): a candidate that
/// **exits before** signaling ready is respawned this many consecutive times
/// (a transient first-boot hiccup should not abandon a good binary) before the
/// supervisor auto-rolls-back to the last-known-good binary. Matched to the
/// capped-backoff cadence; either this budget OR an alive-but-never-ready
/// window-expiry triggers rollback.
const TRIAL_PREREADY_BUDGET: u32 = 3;
/// Poll/sleep slice so a `stop` request (and a child exit) land promptly.
const TICK: Duration = Duration::from_millis(200);

/// How long the brain child retries connecting to the broker socket before
/// giving up (the broker may still be binding — the child is freshly spawned).
const BRAIN_CONNECT_TRIES: u32 = 300;
/// Cadence the idle brain refreshes its ready breadcrumb + probes the broker.
const BRAIN_HEARTBEAT: Duration = Duration::from_millis(500);

/// Why the broker spawned this brain — the Q4 update-vs-crash discriminator the
/// broker stamps at spawn time (ADR-0018 V2). One channel, three payloads: the
/// broker *observes* every respawn, so it alone is authoritative (strictly more
/// reliable than a brain→brain frame, which retires in D4).
///
/// Parsing is **lenient** (KH-2.3 forward-compat): an unrecognized value — a
/// reason a *future* broker adds that this brain predates — defaults to `Cold`,
/// never a parse failure that would brick the brain before it rehydrates.
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StartReason {
    /// The broker's first brain spawn this broker lifetime.
    Cold,
    /// A supervised respawn after an unexpected brain exit.
    Crash,
    /// A respawn the broker itself initiated for a binary swap (wired in D3-3).
    Update,
}

impl StartReason {
    /// The argv token the broker passes to `spt daemon brain --start-reason`.
    pub fn as_arg(self) -> &'static str {
        match self {
            StartReason::Cold => "cold",
            StartReason::Crash => "crash",
            StartReason::Update => "update",
        }
    }

    /// Parse the `--start-reason` argv value, lenient: an unknown token (a
    /// newer broker's reason an older brain predates) defaults to `Cold` rather
    /// than failing the parse (KH-2.3 — defaults survive intermediate versions).
    pub fn from_arg(s: &str) -> Self {
        match s {
            "update" => StartReason::Update,
            "crash" => StartReason::Crash,
            _ => StartReason::Cold,
        }
    }
}

/// The broker→supervisor **planned-restart signal** (ADR-0018 D3-3, Q3). The
/// broker's `KIND_BRAIN_RESTART` handler raises it; the supervisor consumes it on
/// its next wait tick, kills the current brain child, and respawns it with
/// `start-reason=update` onto the (freshly-swapped) binary. Shared (`Arc`)
/// between the broker serve path and the supervisor — both run in the broker
/// process. This is the seam that makes a routine `apply` actually run new code,
/// the pillar the in-process-collapse regression silently unrealized.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
#[derive(Default)]
pub struct BrainRestart {
    requested: AtomicBool,
}

impl BrainRestart {
    /// A fresh signal with no pending request.


`is_process_alive` + `reap_if_child` exist in spt-store::proc — useful for the int test. No existing job-object/process-group machinery. Read the brain supervisor (daemon-side brain Child handle + stop flag).


120	    /// A fresh signal with no pending request.
121	    pub fn new() -> Self {
122	        Self::default()
123	    }
124
125	    /// Request a planned restart (broker handler side) — idempotent: coalesces
126	    /// with any already-pending request into one cycle.
127	    pub fn request(&self) {
128	        self.requested.store(true, Ordering::SeqCst);
129	    }
130
131	    /// Consume a pending request (supervisor side): true at most once per
132	    /// `request`, clearing it so the next cycle starts fresh.
133	    pub fn take(&self) -> bool {
134	        self.requested.swap(false, Ordering::SeqCst)
135	    }
136	}
137
138	/// The `spt daemon brain` child entry. Connect to the broker over the existing
139	/// socket IPC, signal readiness, host the brain-side logic loops, and idle —
140	/// holding the connection and probing broker liveness each heartbeat. A dropped
141	/// connection (broker gone, or a future planned snapshot+self-exit, ADR-0018 Q3)
142	/// returns `Err`, exiting the process so the broker-side supervisor respawns it
143	/// from the (possibly swapped) binary.
144	///
145	/// **D2-1 — net consumers run here.** The inbound dispatcher + outbound peer
146	/// pump are pure IPC clients (each `Brain::cold_start`s its own connection; no
147	/// shared `Arc<Broker>`), so they belong with the restartable brain, not the
148	/// broker — a brain respawn re-spawns them from the swapped binary. They are
149	/// meaningful only once the broker-owned NetHost is up, so we gate on the
150	/// existing `net-status` verb (which also serves as the broker-liveness probe)
151	/// and start them exactly once, on the first `enabled` reply. That single gate
152	/// also covers the boot-race self-heal (REQ-DAEMON-9): the broker retries the
153	/// NetHost bind in the background and `net-status` flips `enabled` when it
154	/// attaches, at which point the brain starts the consumers — no extra signal.
155	///
156	/// It inherits the broker's already-unelevated token as a plain child, so it
157	/// runs no de-elevation guard of its own.
158	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
159	pub fn run_brain(generation: u64, reason: StartReason) -> io::Result<()> {
160	    let name = broker_socket_name();
161	    let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;
162	    // The broker hands {generation, start-reason} at spawn (ADR-0018 V2): the
163	    // generation is broker-owned custody of the KH-2.4 counter (no longer the
164	    // brain→brain BrainState.generation, which retires in D4), and the reason is
165	    // Q4's update-vs-crash discriminator that D5 will use to decide whether to
166	    // preserve or reset phase-significant loop timing. D3-2 surfaces them; D5
167	    // consumes the reason. `gen_start` stays fresh-now per KH-2.4 — only the
168	    // *counter* is rehydrated, never the wall-clock stamp.
169	    eprintln!(
170	        "BRAIN_UP: connected to broker {name} (pid {}, generation {generation}, start-reason {})",
171	        std::process::id(),
172	        reason.as_arg(),
173	    );
174
175	    // Multi-session cold-start resume (restoration D4-2, ADR-0018 Q6): rebuild
176	    // continuity from the broker — re-attach EVERY hosted session in resume mode
177	    // from its delivered cursor, the production replacement for the retired
178	    // brain→brain `BrainState` handoff. Today the supervised daemon brain hosts no
179	    // PTY sessions (net-consumers + shellwake re-derive from disk / `net-status`),
180	    // so this is a no-op now and forward-correct when daemon-hosted sessions land
181	    // (the live-agent adapter). A failure here is non-fatal — the brain still
182	    // serves; the next heartbeat probes broker liveness. NOTE: this is the PTY
183	    // message-DELIVERY axis; the orthogonal live-agent **Psyche** lifecycle (the
184	    // run_pulse_loop production caller) is hosted by `livehost::spawn_live_host`
185	    // below, keyed on the live ENDPOINT not the broker session (CONTEXT:168/177).
186	    match brain.resume_sessions() {
187	        Ok(ids) if !ids.is_empty() => {
188	            eprintln!(
189	                "BRAIN_RESUMED: re-attached {} session(s): {ids:?}",
190	                ids.len()
191	            )
192	        }
193	        Ok(_) => {}
194	        Err(e) => eprintln!("BRAIN_RESUME_NONFATAL: {e} — no sessions resumed, continuing"),
195	    }
196
197	    // Signal READY only now — AFTER session resume (restoration D6-1b, ADR-0018
198	    // Q7): "ready" is the design's "re-attached all sessions + resumed loops",
199	    // not merely "socket connected". The supervisor's readiness gate (D6-2)
200	    // promotes the trial binary on this stamp; promoting on a bare post-connect
201	    // breadcrumb would accept a brain that connected but never rebuilt
202	    // continuity. Today resume is a near-noop (no daemon-hosted sessions), so
203	    // this is forward-correct: the gate is defined on the real readiness
204	    // semantic the live-agent adapter inherits.
205	    write_ready(generation);
206
207	    // Shell wake-watchers (restoration D2-2): the offline half of the shell
208	    // online/offline exclusivity — a reconcile loop that runs each offline
209	    // instance's `wake_command` as a supervised watcher child. Relocated from

    /// A fresh signal with no pending request.
    pub fn new() -> Self {
        Self::default()
    }

    /// Request a planned restart (broker handler side) — idempotent: coalesces
    /// with any already-pending request into one cycle.
    pub fn request(&self) {
        self.requested.store(true, Ordering::SeqCst);
    }

    /// Consume a pending request (supervisor side): true at most once per
    /// `request`, clearing it so the next cycle starts fresh.
    pub fn take(&self) -> bool {
        self.requested.swap(false, Ordering::SeqCst)
    }
}

/// The `spt daemon brain` child entry. Connect to the broker over the existing
/// socket IPC, signal readiness, host the brain-side logic loops, and idle —
/// holding the connection and probing broker liveness each heartbeat. A dropped
/// connection (broker gone, or a future planned snapshot+self-exit, ADR-0018 Q3)
/// returns `Err`, exiting the process so the broker-side supervisor respawns it
/// from the (possibly swapped) binary.
///
/// **D2-1 — net consumers run here.** The inbound dispatcher + outbound peer
/// pump are pure IPC clients (each `Brain::cold_start`s its own connection; no
/// shared `Arc<Broker>`), so they belong with the restartable brain, not the
/// broker — a brain respawn re-spawns them from the swapped binary. They are
/// meaningful only once the broker-owned NetHost is up, so we gate on the
/// existing `net-status` verb (which also serves as the broker-liveness probe)
/// and start them exactly once, on the first `enabled` reply. That single gate
/// also covers the boot-race self-heal (REQ-DAEMON-9): the broker retries the
/// NetHost bind in the background and `net-status` flips `enabled` when it
/// attaches, at which point the brain starts the consumers — no extra signal.
///
/// It inherits the broker's already-unelevated token as a plain child, so it
/// runs no de-elevation guard of its own.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn run_brain(generation: u64, reason: StartReason) -> io::Result<()> {
    let name = broker_socket_name();
    let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;
    // The broker hands {generation, start-reason} at spawn (ADR-0018 V2): the
    // generation is broker-owned custody of the KH-2.4 counter (no longer the
    // brain→brain BrainState.generation, which retires in D4), and the reason is
    // Q4's update-vs-crash discriminator that D5 will use to decide whether to
    // preserve or reset phase-significant loop timing. D3-2 surfaces them; D5
    // consumes the reason. `gen_start` stays fresh-now per KH-2.4 — only the
    // *counter* is rehydrated, never the wall-clock stamp.
    eprintln!(
        "BRAIN_UP: connected to broker {name} (pid {}, generation {generation}, start-reason {})",
        std::process::id(),
        reason.as_arg(),
    );

    // Multi-session cold-start resume (restoration D4-2, ADR-0018 Q6): rebuild
    // continuity from the broker — re-attach EVERY hosted session in resume mode
    // from its delivered cursor, the production replacement for the retired
    // brain→brain `BrainState` handoff. Today the supervised daemon brain hosts no
    // PTY sessions (net-consumers + shellwake re-derive from disk / `net-status`),
    // so this is a no-op now and forward-correct when daemon-hosted sessions land
    // (the live-agent adapter). A failure here is non-fatal — the brain still
    // serves; the next heartbeat probes broker liveness. NOTE: this is the PTY
    // message-DELIVERY axis; the orthogonal live-agent **Psyche** lifecycle (the
    // run_pulse_loop production caller) is hosted by `livehost::spawn_live_host`
    // below, keyed on the live ENDPOINT not the broker session (CONTEXT:168/177).
    match brain.resume_sessions() {
        Ok(ids) if !ids.is_empty() => {
            eprintln!(
                "BRAIN_RESUMED: re-attached {} session(s): {ids:?}",
                ids.len()
            )
        }
        Ok(_) => {}
        Err(e) => eprintln!("BRAIN_RESUME_NONFATAL: {e} — no sessions resumed, continuing"),
    }

    // Signal READY only now — AFTER session resume (restoration D6-1b, ADR-0018
    // Q7): "ready" is the design's "re-attached all sessions + resumed loops",
    // not merely "socket connected". The supervisor's readiness gate (D6-2)
    // promotes the trial binary on this stamp; promoting on a bare post-connect
    // breadcrumb would accept a brain that connected but never rebuilt
    // continuity. Today resume is a near-noop (no daemon-hosted sessions), so
    // this is forward-correct: the gate is defined on the real readiness
    // semantic the live-agent adapter inherits.
    write_ready(generation);

    // Shell wake-watchers (restoration D2-2): the offline half of the shell
    // online/offline exclusivity — a reconcile loop that runs each offline
    // instance's `wake_command` as a supervised watcher child. Relocated from


Grep

fn spawn_brain_supervisor|fn supervise|\.kill\(\)|child\.kill|brain.*\.spawn|Command::new|fn write_ready|fn connect_retry


292:fn connect_retry(name: &str, tries: u32) -> io::Result<Brain> {
321:fn write_ready(generation: u64) {
644:pub fn supervise_brain(
701:                            let _ = child.kill();
718:                                    let _ = child.kill();
758:                            let _ = child.kill();
774:                            let _ = child.kill();
784:                            let _ = child.kill();
934:    let mut cmd = Command::new(exe);
973:pub fn spawn_brain_supervisor(stop: Arc<AtomicBool>) -> (thread::JoinHandle<()>, Arc<BrainRestart>) {
1011:            Command::new("cmd").args(["/C", "rem"]).spawn()
1015:            Command::new("true").spawn()
1026:            Command::new("cmd")
1033:            Command::new("sleep").arg("30").spawn()
1401:            Command::new("kill")
1409:            Command::new("tasklist")


920	            Some(c) => Ok(c.to_path_buf()),
921	            None => std::env::current_exe(),
922	        },
923	    }
924	}
925
926	// [impl->REQ-HAZARD-BRAIN-RESPAWN-PATH]
927	fn spawn_brain_child(
928	    generation: u64,
929	    reason: StartReason,
930	    binary: Option<&Path>,
931	    canonical: Option<&Path>,
932	) -> io::Result<Child> {
933	    let exe = select_brain_exe(binary, canonical)?;
934	    let mut cmd = Command::new(exe);
935	    cmd.args(brain_child_args(generation, reason));
936	    // The brain is a background logic process — it must never flash a console
937	    // window. On Windows a plain console-subsystem spawn pops one whenever the
938	    // broker has a console (a `spt daemon run` in a terminal, or any test that
939	    // spawns the daemon); CREATE_NO_WINDOW suppresses it without detaching, so
940	    // the plain-child SPT_HOME inheritance (above) is preserved.
941	    #[cfg(windows)]
942	    {
943	        use std::os::windows::process::CommandExt;
944	        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
945	        cmd.creation_flags(CREATE_NO_WINDOW);
946	    }
947	    cmd.spawn()
948	}
949
950	/// The versioned brain-spawn argv (KH-2.3): `daemon brain --generation <N>
951	/// --start-reason <reason>`. Both flags are **additive + defaulted** on the
952	/// brain entry, so an older broker that spawns this brain with the *bare* old
953	/// argv (no flags) still parses — the brain falls back to generation 0 /
954	/// `Cold`, the new-brain × old-broker N-1 steady state. Pure for the unit table.
955	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
956	fn brain_child_args(generation: u64, reason: StartReason) -> Vec<String> {
957	    vec![
958	        "daemon".to_string(),
959	        "brain".to_string(),
960	        "--generation".to_string(),
961	        generation.to_string(),
962	        "--start-reason".to_string(),
963	        reason.as_arg().to_string(),
964	    ]
965	}
966
967	/// Production wiring: run the brain supervisor on its own thread, spawned by the
968	/// broker process ([`crate::daemon::Daemon::run`]) before its foreground
969	/// seed-control loop. The brain child is supervised for the broker's lifetime.
970	/// Returns the shared [`BrainRestart`] signal so the caller can hand a clone to
971	/// the broker, wiring the `KIND_BRAIN_RESTART` verb to this supervisor (D3-3).
972	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
973	pub fn spawn_brain_supervisor(stop: Arc<AtomicBool>) -> (thread::JoinHandle<()>, Arc<BrainRestart>) {
974	    let restart = Arc::new(BrainRestart::new());
975	    let restart_sup = Arc::clone(&restart);
976	    // KH 6.11: capture the canonical brain binary path ONCE here, at broker
977	    // start, before any `apply` can rename it under us. Linux current_exe()
978	    // (readlink /proc/self/exe) is inode-tracking and would later follow the
979	    // apply-rename (spt → spt.old-N) to the OLD bytes; capturing at t=0 pins the
980	    // canonical path string, matching Windows' path-at-start semantics. The one
981	    // true race — an apply landing in the microseconds between broker exec and
982	    // this line — is vanishingly rare and backstopped by the promotion
983	    // bytes-gate (a wrong-bytes trial rolls back loudly, never promotes).
984	    let canonical_exe = std::env::current_exe().ok();
985	    let handle = thread::spawn(move || {
986	        let env = ProductionTrialEnv;
987	        supervise_brain(
988	            &stop,
989	            &restart_sup,
990	            SUPERVISE_BACKOFF_BASE,
991	            &env,
992	            SUPERVISE_HEALTHY_RUN,
993	            |generation, reason, binary| {
994	                spawn_brain_child(generation, reason, binary, canonical_exe.as_deref())
995	            },
996	        );
997	    });
998	    (handle, restart)
999	}
1000
1001	#[cfg(test)]
1002	mod tests {
1003	    use super::*;
1004	    use std::sync::atomic::AtomicU32;
1005
1006	    /// A trivial, fast-exiting real child — the cross-OS no-op the supervisor
1007	    /// test respawns (stands in for the brain process).
1008	    fn fast_child() -> io::Result<Child> {
1009	        #[cfg(windows)]

            Some(c) => Ok(c.to_path_buf()),
            None => std::env::current_exe(),
        },
    }
}

// [impl->REQ-HAZARD-BRAIN-RESPAWN-PATH]
fn spawn_brain_child(
    generation: u64,
    reason: StartReason,
    binary: Option<&Path>,
    canonical: Option<&Path>,
) -> io::Result<Child> {
    let exe = select_brain_exe(binary, canonical)?;
    let mut cmd = Command::new(exe);
    cmd.args(brain_child_args(generation, reason));
    // The brain is a background logic process — it must never flash a console
    // window. On Windows a plain console-subsystem spawn pops one whenever the
    // broker has a console (a `spt daemon run` in a terminal, or any test that
    // spawns the daemon); CREATE_NO_WINDOW suppresses it without detaching, so
    // the plain-child SPT_HOME inheritance (above) is preserved.
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        cmd.creation_flags(CREATE_NO_WINDOW);
    }
    cmd.spawn()
}

/// The versioned brain-spawn argv (KH-2.3): `daemon brain --generation <N>
/// --start-reason <reason>`. Both flags are **additive + defaulted** on the
/// brain entry, so an older broker that spawns this brain with the *bare* old
/// argv (no flags) still parses — the brain falls back to generation 0 /
/// `Cold`, the new-brain × old-broker N-1 steady state. Pure for the unit table.
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
fn brain_child_args(generation: u64, reason: StartReason) -> Vec<String> {
    vec![
        "daemon".to_string(),
        "brain".to_string(),
        "--generation".to_string(),
        generation.to_string(),
        "--start-reason".to_string(),
        reason.as_arg().to_string(),
    ]
}

/// Production wiring: run the brain supervisor on its own thread, spawned by the
/// broker process ([`crate::daemon::Daemon::run`]) before its foreground
/// seed-control loop. The brain child is supervised for the broker's lifetime.
/// Returns the shared [`BrainRestart`] signal so the caller can hand a clone to
/// the broker, wiring the `KIND_BRAIN_RESTART` verb to this supervisor (D3-3).
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn spawn_brain_supervisor(stop: Arc<AtomicBool>) -> (thread::JoinHandle<()>, Arc<BrainRestart>) {
    let restart = Arc::new(BrainRestart::new());
    let restart_sup = Arc::clone(&restart);
    // KH 6.11: capture the canonical brain binary path ONCE here, at broker
    // start, before any `apply` can rename it under us. Linux current_exe()
    // (readlink /proc/self/exe) is inode-tracking and would later follow the
    // apply-rename (spt → spt.old-N) to the OLD bytes; capturing at t=0 pins the
    // canonical path string, matching Windows' path-at-start semantics. The one
    // true race — an apply landing in the microseconds between broker exec and
    // this line — is vanishingly rare and backstopped by the promotion
    // bytes-gate (a wrong-bytes trial rolls back loudly, never promotes).
    let canonical_exe = std::env::current_exe().ok();
    let handle = thread::spawn(move || {
        let env = ProductionTrialEnv;
        supervise_brain(
            &stop,
            &restart_sup,
            SUPERVISE_BACKOFF_BASE,
            &env,
            SUPERVISE_HEALTHY_RUN,
            |generation, reason, binary| {
                spawn_brain_child(generation, reason, binary, canonical_exe.as_deref())
            },
        );
    });
    (handle, restart)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::AtomicU32;

    /// A trivial, fast-exiting real child — the cross-OS no-op the supervisor
    /// test respawns (stands in for the brain process).
    fn fast_child() -> io::Result<Child> {
        #[cfg(windows)]


Complete picture now. Both REQs are **impl + unit** (no full int) per the registry. Design crystallized:

**B3 (barrier):** client-side wait-for-ping-fail in `seedmap::request_stop` — after STOPPING ack, poll `ping()` until it errors (bounded), then return. Deterministic, in-process testable.

**Breap (reap):** brain-rooted reap container, no daemon self-kill:
- Windows: Job object (kill-on-close backstop); assign each spawned brain → psyches auto-inherit; stop → `TerminateJobObject` (daemon not in job).
- Unix: brain spawned `pre_exec(setpgid(0,0))` = own group; supervisor records pgid; stop → `kill(-pgid, SIGKILL)`.
- Raise supervisor stop flag before reap (prevents respawn race) — doyle's "raise the stop flag".

Check Windows-crate availability (avoid hand-rolling JOBOBJECT struct) + how int tests start/stop daemon.


1	[package]
2	name = "spt-daemon"
3	version.workspace = true
4	edition = "2021"
5	publish = false
6	description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."
7
8	[dependencies]
9	# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
10	# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
11	# crate's position in the workspace layering. Tasks B1–B8 consume them.
12	spt-proto = { path = "../spt-proto" }
13	spt-store = { path = "../spt-store" }
14	spt-msg = { path = "../spt-msg" }
15	spt-runtime = { path = "../spt-runtime" }
16	spt-term = { path = "../spt-term" }
17	spt-live = { path = "../spt-live" }
18	# D4 (ADR-0004 §B): the broker owns the live iroh endpoint + QUIC connections so
19	# they survive a brain restart. spt-net provides the *mechanism* (NetEndpoint);
20	# the broker hosts the *process* (nethost::NetHost). Default features keep the
21	# `net` transport on — the daemon is the reference consumer.
22	spt-net = { path = "../spt-net" }
23	# The broker is otherwise sync/thread-based; iroh requires tokio, so the broker
24	# hosts a dedicated runtime on named worker threads (QUIC objects never leave it).
25	tokio = { version = "1", features = ["rt-multi-thread"] }
26	serde = { version = "1", features = ["derive"] }
27	serde_json = { version = "1", features = ["preserve_order"] }
28	# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
29	# a base64 string in the payload rather than forking the codec for binary.
30	base64 = "0.22"
31	# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
32	# behind one API — the OS-split substrate the DaemonTransport trait wraps,
33	# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
34	interprocess = "2"
35	# Self-update release signing (C1, REQ-UPD-2): pure-Rust Ed25519 — no C/asm
36	# build deps, clean cross-compile on win+linux (clean-room posture, ADR-0004 §D).
37	# `rand_core` lets a release-tooling caller mint a keypair; tests use the
38	# deterministic `from_bytes` ctor (no RNG).
39	ed25519-dalek = "2"
40	# SHA-256 binds the signed release metadata to the actual binary bytes
41	# (artifact digest) so the signature covers the binary, not just the manifest.
42	sha2 = "0.10"
43	# Per-frame link auth on the same-node shell channels (CONTEXT local-link
44	# authentication): HMAC-SHA256 over the token-derived link key (M5-D3b).
45	hmac = "0.12"
46
47	# De-elevated daemon spawn (KNOWN-HAZARDS 5.7, REQ-HAZARD-ELEVATED-DAEMON-SPAWN):
48	# the sudo-invoker drop (geteuid/setuid/setgroups/getpwuid_r). Matches the
49	# workspace's existing libc usage (spt, spt-msg, spt-store); the Windows half
50	# uses raw advapi32 externs (the daemon.rs detached_no_inherit posture).
51	[target.'cfg(unix)'.dependencies]
52	libc = "0.2"
53
54	[dev-dependencies]
55	tempfile = "3"
56	# Integration tests are separate crates and do not inherit the lib's deps, so the
57	# digest int-test re-declares the two it touches directly: the manifest registry
58	# (the mock adapter's `[history]` seam) and the perch-path/info resolution the
59	# on-demand projection reads.
60	spt-runtime = { path = "../spt-runtime" }
61	spt-store = { path = "../spt-store" }
62	# The D4a net-broker int-test constructs identities + relay policies directly.
63	spt-proto = { path = "../spt-proto" }
64	spt-net = { path = "../spt-net" }
65	# The D5a wanmsg int-test asserts spool formatting through the spt-msg surface.
66	spt-msg = { path = "../spt-msg" }
67

[package]
name = "spt-daemon"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."

[dependencies]
# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
# crate's position in the workspace layering. Tasks B1–B8 consume them.
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-term = { path = "../spt-term" }
spt-live = { path = "../spt-live" }
# D4 (ADR-0004 §B): the broker owns the live iroh endpoint + QUIC connections so
# they survive a brain restart. spt-net provides the *mechanism* (NetEndpoint);
# the broker hosts the *process* (nethost::NetHost). Default features keep the
# `net` transport on — the daemon is the reference consumer.
spt-net = { path = "../spt-net" }
# The broker is otherwise sync/thread-based; iroh requires tokio, so the broker
# hosts a dedicated runtime on named worker threads (QUIC objects never leave it).
tokio = { version = "1", features = ["rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
# a base64 string in the payload rather than forking the codec for binary.
base64 = "0.22"
# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
# behind one API — the OS-split substrate the DaemonTransport trait wraps,
# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
interprocess = "2"
# Self-update release signing (C1, REQ-UPD-2): pure-Rust Ed25519 — no C/asm
# build deps, clean cross-compile on win+linux (clean-room posture, ADR-0004 §D).
# `rand_core` lets a release-tooling caller mint a keypair; tests use the
# deterministic `from_bytes` ctor (no RNG).
ed25519-dalek = "2"
# SHA-256 binds the signed release metadata to the actual binary bytes
# (artifact digest) so the signature covers the binary, not just the manifest.
sha2 = "0.10"
# Per-frame link auth on the same-node shell channels (CONTEXT local-link
# authentication): HMAC-SHA256 over the token-derived link key (M5-D3b).
hmac = "0.12"

# De-elevated daemon spawn (KNOWN-HAZARDS 5.7, REQ-HAZARD-ELEVATED-DAEMON-SPAWN):
# the sudo-invoker drop (geteuid/setuid/setgroups/getpwuid_r). Matches the
# workspace's existing libc usage (spt, spt-msg, spt-store); the Windows half
# uses raw advapi32 externs (the daemon.rs detached_no_inherit posture).
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[dev-dependencies]
tempfile = "3"
# Integration tests are separate crates and do not inherit the lib's deps, so the
# digest int-test re-declares the two it touches directly: the manifest registry
# (the mock adapter's `[history]` seam) and the perch-path/info resolution the
# on-demand projection reads.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }
# The D4a net-broker int-test constructs identities + relay policies directly.
spt-proto = { path = "../spt-proto" }
spt-net = { path = "../spt-net" }
# The D5a wanmsg int-test asserts spool formatting through the spt-msg surface.
spt-msg = { path = "../spt-msg" }


No matches found


<task-notification>
<task-id>b0flbbkzx</task-id>
<tool-use-id>toolu_01FPZAgUm6ci2tXnEyxcCmis</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\1e7c582d-c03a-4db0-bd84-c7b3b7959ea0\tasks\b0flbbkzx.output</output-file>
<status>completed</status>
<summary>Background command "Find stop/brain-pid usage in lifecycle tests" completed (exit code 0)</summary>
</task-notification>


No windows-sys dep — hand-roll job FFI like proc.rs (libc for unix). Check the daemon start/stop test harness + real-brain lifecycle test so my changes don't break the seam sweep.


Bash

grep -rn "daemon stop\|request_stop\|is_running\|\"stop\"\|brain.*pid\|BRAIN_UP\|kill()" crates/spt-daemon/tests/daemon_lifecycle_real_brain.rs crates/spt-daemon/tests/daemon_e2e.rs 2>/dev/null | head -50

Find stop/brain-pid usage in lifecycle tests


Command running in background with ID: b0flbbkzx. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\1e7c582d-c03a-4db0-bd84-c7b3b7959ea0\tasks\b0flbbkzx.output. You will be notified when it completes. To check interim output, use Read on that file path.


Prompt:
<task-notification>
<task-id>b0flbbkzx</task-id>
<tool-use-id>toolu_01FPZAgUm6ci2tXnEyxcCmis</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\1e7c582d-c03a-4db0-bd84-c7b3b7959ea0\tasks\b0flbbkzx.output</output-file>
<status>completed</status>
<summary>Background command "Find stop/brain-pid usage in lifecycle tests" completed (exit code 0)</summary>
</task-notification>


cat "C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\1e7c582d-c03a-4db0-bd84-c7b3b7959ea0\tasks\b0flbbkzx.output"; echo "=== how daemon spawned in real-brain test ==="; grep -rn "Command::new\|daemon\", \"run\|spawn()\|\.arg" crates/spt-daemon/tests/daemon_lifecycle_real_brain.rs | head -25

Read test usage


=== how daemon spawned in real-brain test ===
137:    let _ = std::process::Command::new("taskkill")
138:        .args(["/PID", &pid.to_string(), "/F", "/T"])
141:    let _ = std::process::Command::new("kill")
142:        .args(["-9", &pid.to_string()])


Tests kill the daemon via `taskkill /F /T` (Windows, tree-kill) / `kill -9 <pid>` (Unix, daemon only → brain self-exits on broker-loss). Neither relies on the brain sharing the daemon's group/job, so my changes are compatible. Let me confirm a graceful-`daemon stop` e2e exists + find where seam tests live.


grep -rln '"daemon", "stop"\|daemon stop\|request_stop\|args(\["daemon", "stop"\]\|"stop"' crates/spt-daemon/tests crates/spt/tests 2>/dev/null; echo "=== stop in src ==="; grep -rn "request_stop\|cmd_daemon_stop\|fn.*daemon.*stop" crates/spt/src crates/spt-daemon/src 2>/dev/null | grep -iv "test" | head

Locate graceful daemon-stop test + CLI handler


crates/spt/tests/brain_split.rs
=== stop in src ===
crates/spt/src/cli.rs:1146:            DaemonCmd::Stop => cmd_daemon_stop(),
crates/spt/src/cli.rs:1908:fn cmd_daemon_stop() -> i32 {
crates/spt-daemon/src/daemon.rs:383:pub fn request_stop() -> io::Result<()> {
crates/spt-daemon/src/daemon.rs:384:    seedmap::request_stop(&seed_socket_name())
crates/spt-daemon/src/daemon.rs:504:            request_stop()?;
crates/spt-daemon/src/lib.rs:179:    active_service_conflict, ensure_running, is_running, request_stop, start_daemon, stop_daemon,
crates/spt-daemon/src/seedmap.rs:240:pub fn request_stop(name: &str) -> io::Result<()> {
crates/spt-daemon/src/seedmap.rs:317:            request_stop(&name).expect("stop acked");


1908	fn cmd_daemon_stop() -> i32 {
1909	    match spt_daemon::stop_daemon() {
1910	        Ok(spt_daemon::StopOutcome::NotRunning) => {
1911	            eprintln!("DAEMON_NOT_RUNNING (already stopped)");
1912	            0
1913	        }
1914	        Ok(spt_daemon::StopOutcome::ServiceStopped(label)) => {
1915	            eprintln!("DAEMON_STOPPED (via {label})");
1916	            0
1917	        }
1918	        Ok(spt_daemon::StopOutcome::IpcStopped) => {
1919	            eprintln!("DAEMON_STOPPED");
1920	            0
1921	        }
1922	        Err(e) => {
1923	            eprintln!("DAEMON_STOP_FAIL: {e}");
1924	            1
1925	        }
1926	    }
1927	}
1928
1929	/// The connection + peer-pump status lines (REQ-DAEMON-5/-9). Pure for tests.
1930	/// A net-LESS broker (`net_up == false`) surfaces "no connection (retrying)" +
1931	/// a pump waiting on the network — NOT a STALLED alarm read off a stale
1932	/// pre-boot heartbeat (the kitsubito 2026-06-08 boot-race misreport, where the
1933	/// heartbeat age exceeded uptime). With net up, the heartbeat drives
1934	/// live/stalled as before (a genuine wedge still reads STALLED honestly).
1935	// [impl->REQ-DAEMON-9]
1936	fn render_connection_lines(
1937	    net_up: bool,
1938	    heartbeat: Option<u64>,
1939	    now: u64,
1940	    stale_after: u64,
1941	) -> String {
1942	    if !net_up {
1943	        return "connection: no connection — net endpoint not bound (retrying in background)\n\
1944	                peer pump: waiting on network\n"
1945	            .to_string();
1946	    }
1947	    match heartbeat {

fn cmd_daemon_stop() -> i32 {
    match spt_daemon::stop_daemon() {
        Ok(spt_daemon::StopOutcome::NotRunning) => {
            eprintln!("DAEMON_NOT_RUNNING (already stopped)");
            0
        }
        Ok(spt_daemon::StopOutcome::ServiceStopped(label)) => {
            eprintln!("DAEMON_STOPPED (via {label})");
            0
        }
        Ok(spt_daemon::StopOutcome::IpcStopped) => {
            eprintln!("DAEMON_STOPPED");
            0
        }
        Err(e) => {
            eprintln!("DAEMON_STOP_FAIL: {e}");
            1
        }
    }
}

/// The connection + peer-pump status lines (REQ-DAEMON-5/-9). Pure for tests.
/// A net-LESS broker (`net_up == false`) surfaces "no connection (retrying)" +
/// a pump waiting on the network — NOT a STALLED alarm read off a stale
/// pre-boot heartbeat (the kitsubito 2026-06-08 boot-race misreport, where the
/// heartbeat age exceeded uptime). With net up, the heartbeat drives
/// live/stalled as before (a genuine wedge still reads STALLED honestly).
// [impl->REQ-DAEMON-9]
fn render_connection_lines(
    net_up: bool,
    heartbeat: Option<u64>,
    now: u64,
    stale_after: u64,
) -> String {
    if !net_up {
        return "connection: no connection — net endpoint not bound (retrying in background)\n\
                peer pump: waiting on network\n"
            .to_string();
    }
    match heartbeat {


485	            Ok(StartOutcome::Spawned)
486	        }
487	    }
488	}
489
490	/// Service-aware `spt daemon stop` (REQ-DAEMON-6): a registered + active
491	/// service is stopped through its manager (so it does not auto-restart-fight);
492	/// a manual daemon takes the graceful IPC stop; nothing running is already
493	/// stopped. Idempotent.
494	// [impl->REQ-DAEMON-6]
495	pub fn stop_daemon() -> io::Result<StopOutcome> {
496	    let svc = crate::service::platform_service();
497	    match crate::service::plan_stop(is_running(), svc.detected(), svc.is_active()) {
498	        crate::service::StopPlan::NotRunning => Ok(StopOutcome::NotRunning),
499	        crate::service::StopPlan::ServiceStop => {
500	            svc.stop()?;
501	            Ok(StopOutcome::ServiceStopped(svc.label()))
502	        }
503	        crate::service::StopPlan::IpcStop => {
504	            request_stop()?;
505	            Ok(StopOutcome::IpcStopped)
506	        }
507	    }
508	}
509
510	/// The label of a managed service that is BOTH registered and active — a
511	/// conflict for an inline `daemon run` that is about to take the same socket
512	/// (REQ-DAEMON-7). `None` when no manager would fight.
513	// [impl->REQ-DAEMON-7]
514	pub fn active_service_conflict() -> Option<&'static str> {
515	    let svc = crate::service::platform_service();
516	    (svc.detected() && svc.is_active()).then(|| svc.label())
517	}
518
519	/// Spawn `spt daemon` as a detached background process. Inherits the environment
520	/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
521	/// socket names — but drops the std handles so it outlives the launching CLI
522	/// call. The child is independent (not killed when the parent exits): on unix it
523	/// is reparented to init when the launcher exits; on Windows a child is not in
524	/// the parent's job by default.

            Ok(StartOutcome::Spawned)
        }
    }
}

/// Service-aware `spt daemon stop` (REQ-DAEMON-6): a registered + active
/// service is stopped through its manager (so it does not auto-restart-fight);
/// a manual daemon takes the graceful IPC stop; nothing running is already
/// stopped. Idempotent.
// [impl->REQ-DAEMON-6]
pub fn stop_daemon() -> io::Result<StopOutcome> {
    let svc = crate::service::platform_service();
    match crate::service::plan_stop(is_running(), svc.detected(), svc.is_active()) {
        crate::service::StopPlan::NotRunning => Ok(StopOutcome::NotRunning),
        crate::service::StopPlan::ServiceStop => {
            svc.stop()?;
            Ok(StopOutcome::ServiceStopped(svc.label()))
        }
        crate::service::StopPlan::IpcStop => {
            request_stop()?;
            Ok(StopOutcome::IpcStopped)
        }
    }
}

/// The label of a managed service that is BOTH registered and active — a
/// conflict for an inline `daemon run` that is about to take the same socket
/// (REQ-DAEMON-7). `None` when no manager would fight.
// [impl->REQ-DAEMON-7]
pub fn active_service_conflict() -> Option<&'static str> {
    let svc = crate::service::platform_service();
    (svc.detected() && svc.is_active()).then(|| svc.label())
}

/// Spawn `spt daemon` as a detached background process. Inherits the environment
/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
/// socket names — but drops the std handles so it outlives the launching CLI
/// call. The child is independent (not killed when the parent exits): on unix it
/// is reparented to init when the launcher exits; on Windows a child is not in
/// the parent's job by default.


299	    fn a_seed(pid: u32, sid: &str) -> Seed {
300	        Seed {
301	            parent_pid: pid,
302	            session_id: sid.into(),
303	            cwd: None,
304	            created: "0".into(),
305	        }
306	    }
307
308	    // [unit->REQ-CLI-2] the stop op is acked and ends the serve loop: a
309	    // post-stop ping finds no listener (graceful teardown, not a kill).
310	    #[test]
311	    fn stop_op_acks_then_serve_returns() {
312	        with_home(|_| {
313	            let reg = Arc::new(SeedRegistry::new());
314	            start(Arc::clone(&reg));
315	            let name = seed_socket_name();
316
317	            request_stop(&name).expect("stop acked");
318	            // The serve loop returns and drops the listener; the socket may
319	            // take a beat to vanish on some platforms — poll briefly.
320	            for _ in 0..400 {
321	                if ping(&name).is_err() {
322	                    return;
323	                }
324	                std::thread::sleep(Duration::from_millis(5));
325	            }
326	            panic!("seed daemon still answering after stop");
327	        });
328	    }
329
330	    // [unit->REQ-START-3] a seed PUT over IPC is TAKEn once: the second take
331	    // misses (consume-once), proving the in-memory map replaces the file bridge.
332	    #[test]
333	    fn put_then_take_consumes_once() {
334	        with_home(|_| {
335	            let reg = Arc::new(SeedRegistry::new());
336	            start(Arc::clone(&reg));
337	            let name = seed_socket_name();
338
339	            put_seed(&name, &a_seed(4321, "sid-xyz")).unwrap();
340	            let got = take_seed(&name, 4321).unwrap().expect("first take hits");
341	            assert_eq!(got.session_id, "sid-xyz");
342	            assert_eq!(got.parent_pid, 4321);
343	            // Consumed: a second take for the same pid misses.
344	            assert!(
345	                take_seed(&name, 4321).unwrap().is_none(),
346	                "take is consume-once"
347	            );
348	        });
349	    }
350
351	    // [unit->REQ-START-3] taking a pid the daemon never saw is a clean miss.
352	    #[test]
353	    fn take_absent_is_none() {
354	        with_home(|_| {
355	            let reg = Arc::new(SeedRegistry::new());
356	            start(Arc::clone(&reg));
357	            assert!(take_seed(&seed_socket_name(), 999).unwrap().is_none());
358	        });

    fn a_seed(pid: u32, sid: &str) -> Seed {
        Seed {
            parent_pid: pid,
            session_id: sid.into(),
            cwd: None,
            created: "0".into(),
        }
    }

    // [unit->REQ-CLI-2] the stop op is acked and ends the serve loop: a
    // post-stop ping finds no listener (graceful teardown, not a kill).
    #[test]
    fn stop_op_acks_then_serve_returns() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            let name = seed_socket_name();

            request_stop(&name).expect("stop acked");
            // The serve loop returns and drops the listener; the socket may
            // take a beat to vanish on some platforms — poll briefly.
            for _ in 0..400 {
                if ping(&name).is_err() {
                    return;
                }
                std::thread::sleep(Duration::from_millis(5));
            }
            panic!("seed daemon still answering after stop");
        });
    }

    // [unit->REQ-START-3] a seed PUT over IPC is TAKEn once: the second take
    // misses (consume-once), proving the in-memory map replaces the file bridge.
    #[test]
    fn put_then_take_consumes_once() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            let name = seed_socket_name();

            put_seed(&name, &a_seed(4321, "sid-xyz")).unwrap();
            let got = take_seed(&name, 4321).unwrap().expect("first take hits");
            assert_eq!(got.session_id, "sid-xyz");
            assert_eq!(got.parent_pid, 4321);
            // Consumed: a second take for the same pid misses.
            assert!(
                take_seed(&name, 4321).unwrap().is_none(),
                "take is consume-once"
            );
        });
    }

    // [unit->REQ-START-3] taking a pid the daemon never saw is a clean miss.
    #[test]
    fn take_absent_is_none() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            assert!(take_seed(&seed_socket_name(), 999).unwrap().is_none());
        });


Found existing `stop_op_acks_then_serve_returns` unit — it already polls ping after `request_stop` (proving serve returns). B3 moves that barrier *into* `request_stop` so the caller gets the immediate post-condition. Confirmed compatible.

Let me read `supervise_brain` signature + confirm `spawn_brain_supervisor` callers before wiring the reaper.


644	pub fn supervise_brain(
645	    stop: &AtomicBool,
646	    restart: &BrainRestart,
647	    base: Duration,
648	    env: &dyn TrialEnv,
649	    window: Duration,
650	    mut spawn_child: impl FnMut(u64, StartReason, Option<&Path>) -> io::Result<Child>,
651	) {
652	    let mut backoff = base;
653	    // Broker-owned generation custody (ADR-0018 V2, KH-2.4): the counter lives in
654	    // the never-restarting supervisor and increments on EVERY spawn (cold or
655	    // respawn), so the broker — which observes every respawn — is the
656	    // authoritative source, not the outgoing brain. The first spawn is `Cold`;
657	    // every supervised respawn here is a `Crash` (an unexpected exit the
658	    // supervisor recovers from). A *planned* update respawn stamps `Update`
659	    // instead — wired in D3-3, where the apply trigger drives this loop.
660	    let mut generation: u64 = 0;
661	    let mut reason = StartReason::Cold;
662	    // Consecutive pre-ready exits of the CURRENT trial candidate (A8 budget K).
663	    // In-memory: a broker restart resets it — acceptable, the trial restarts.
664	    let mut pre_ready_exits: u32 = 0;
665	    while !stop.load(Ordering::Relaxed) {
666	        let started = Instant::now();
667	        // Record-driven binary selection + trial latch (A8). The latch is the
668	        // unpromoted `AppliedPending` record, NOT the start reason; a `RolledBack`
669	        // record selects the last-known-good binary (and is never a trial — it is
670	        // the recovery steady state). A missing/corrupt record degrades to "no
671	        // trial, default binary" — the supervisor never panics on the record.
672	        let record = env.applied_state();
673	        let binary: Option<PathBuf> = match &record {
674	            Some(AppliedRecord::RolledBack { rollback_binary, .. }) => {
675	                Some(PathBuf::from(rollback_binary))
676	            }
677	            _ => None,
678	        };
679	        let is_trial = matches!(record, Some(AppliedRecord::AppliedPending { .. }));
680	        // A9: clear `brain.ready` immediately before a trial spawn — the prior
681	        // child is already waited-on dead (below), so the only stamp that can
682	        // exist post-clear is THIS trial child's. Closes the cross-lifetime hole
683	        // that same-generation equality alone cannot (a stale gen-N file from a
684	        // pre-reboot lifetime matching a fresh gen-N trial).
685	        if is_trial {
686	            env.clear_ready();
687	        }
688	        // Did this cycle end because of a planned update restart (Q3) rather than
689	        // an unexpected crash? The broker — observing the cycle it initiated — is
690	        // the authoritative source of that distinction (V2).
691	        let mut planned = false;
692	        match spawn_child(generation, reason, binary.as_deref()) {
693	            Ok(mut child) => {
694	                // The trial gate runs first for a candidate on trial; on
695	                // promotion it falls through to the normal wait loop for the SAME
696	                // child. A non-trial spawn skips straight to normal supervision.
697	                let mut supervise = !is_trial;
698	                if is_trial {
699	                    match run_trial(&mut child, generation, window, env, stop) {
700	                        TrialStep::Stopped => {
701	                            let _ = child.kill();
702	                            let _ = child.wait();
703	                            return;
704	                        }
705	                        TrialStep::Promoted => {
706	                            // Bytes-gate (KH 6.11): readiness != new-bytes. Promote
707	                            // only if the candidate is running the staged bytes; a
708	                            // ready-but-wrong-bytes candidate (e.g. a Linux respawn
709	                            // onto the renamed old binary) fails the trial → kill +
710	                            // rollback, never a falsely-`applied` record.
711	                            let version = match &record {
712	                                Some(AppliedRecord::AppliedPending { version, .. }) => Some(*version),
713	                                _ => None,

pub fn supervise_brain(
    stop: &AtomicBool,
    restart: &BrainRestart,
    base: Duration,
    env: &dyn TrialEnv,
    window: Duration,
    mut spawn_child: impl FnMut(u64, StartReason, Option<&Path>) -> io::Result<Child>,
) {
    let mut backoff = base;
    // Broker-owned generation custody (ADR-0018 V2, KH-2.4): the counter lives in
    // the never-restarting supervisor and increments on EVERY spawn (cold or
    // respawn), so the broker — which observes every respawn — is the
    // authoritative source, not the outgoing brain. The first spawn is `Cold`;
    // every supervised respawn here is a `Crash` (an unexpected exit the
    // supervisor recovers from). A *planned* update respawn stamps `Update`
    // instead — wired in D3-3, where the apply trigger drives this loop.
    let mut generation: u64 = 0;
    let mut reason = StartReason::Cold;
    // Consecutive pre-ready exits of the CURRENT trial candidate (A8 budget K).
    // In-memory: a broker restart resets it — acceptable, the trial restarts.
    let mut pre_ready_exits: u32 = 0;
    while !stop.load(Ordering::Relaxed) {
        let started = Instant::now();
        // Record-driven binary selection + trial latch (A8). The latch is the
        // unpromoted `AppliedPending` record, NOT the start reason; a `RolledBack`
        // record selects the last-known-good binary (and is never a trial — it is
        // the recovery steady state). A missing/corrupt record degrades to "no
        // trial, default binary" — the supervisor never panics on the record.
        let record = env.applied_state();
        let binary: Option<PathBuf> = match &record {
            Some(AppliedRecord::RolledBack { rollback_binary, .. }) => {
                Some(PathBuf::from(rollback_binary))
            }
            _ => None,
        };
        let is_trial = matches!(record, Some(AppliedRecord::AppliedPending { .. }));
        // A9: clear `brain.ready` immediately before a trial spawn — the prior
        // child is already waited-on dead (below), so the only stamp that can
        // exist post-clear is THIS trial child's. Closes the cross-lifetime hole
        // that same-generation equality alone cannot (a stale gen-N file from a
        // pre-reboot lifetime matching a fresh gen-N trial).
        if is_trial {
            env.clear_ready();
        }
        // Did this cycle end because of a planned update restart (Q3) rather than
        // an unexpected crash? The broker — observing the cycle it initiated — is
        // the authoritative source of that distinction (V2).
        let mut planned = false;
        match spawn_child(generation, reason, binary.as_deref()) {
            Ok(mut child) => {
                // The trial gate runs first for a candidate on trial; on
                // promotion it falls through to the normal wait loop for the SAME
                // child. A non-trial spawn skips straight to normal supervision.
                let mut supervise = !is_trial;
                if is_trial {
                    match run_trial(&mut child, generation, window, env, stop) {
                        TrialStep::Stopped => {
                            let _ = child.kill();
                            let _ = child.wait();
                            return;
                        }
                        TrialStep::Promoted => {
                            // Bytes-gate (KH 6.11): readiness != new-bytes. Promote
                            // only if the candidate is running the staged bytes; a
                            // ready-but-wrong-bytes candidate (e.g. a Linux respawn
                            // onto the renamed old binary) fails the trial → kill +
                            // rollback, never a falsely-`applied` record.
                            let version = match &record {
                                Some(AppliedRecord::AppliedPending { version, .. }) => Some(*version),
                                _ => None,


grep -rn "spawn_brain_supervisor" crates/ ; echo "=== libc use in brainproc ==="; grep -n "use libc\|libc::" crates/spt-daemon/src/brainproc.rs | head; echo "=== mod reap / lib mods ==="; grep -n "^mod \|^pub mod " crates/spt-daemon/src/lib.rs | head -40

Find supervisor callers + lib mod list


crates/spt-daemon/src/brainproc.rs:903:/// (`spawn_brain_supervisor`). The `None` default uses it — **never** a
crates/spt-daemon/src/brainproc.rs:973:pub fn spawn_brain_supervisor(stop: Arc<AtomicBool>) -> (thread::JoinHandle<()>, Arc<BrainRestart>) {
crates/spt-daemon/src/daemon.rs:274:            let (_handle, restart) = crate::brainproc::spawn_brain_supervisor(stop);
=== libc use in brainproc ===
=== mod reap / lib mods ===
106:pub mod access;
107:pub mod adapter_update;
108:pub mod applyhost;
109:pub mod attach;
110:pub mod brain;
111:pub mod brainproc;
112:pub mod broker;
113:pub mod codec;
114:pub mod config;
115:pub mod consent;
116:pub mod daemon;
117:pub mod deadline;
118:pub mod deelevate;
119:pub mod digest;
120:pub mod digesthub;
121:pub mod dispatch;
122:pub mod drivehub;
123:pub mod effect;
124:pub mod endpoint;
125:pub mod frame;
126:pub mod grants;
127:pub mod harnesshost;
128:pub mod lifecycle;
129:pub mod linkhost;
130:pub mod livehost;
131:pub mod machineid;
132:pub mod msg;
133:pub mod nethost;
134:pub mod notif;
135:pub mod notifsync;
136:pub mod pairhost;
137:pub mod presence;
138:pub mod propagate;
139:pub mod psyrelay;
140:pub mod pump;
141:pub mod reconcile;
142:pub mod registryhost;
143:pub mod relay;
144:pub mod relcache;
145:pub mod release;


Single caller (daemon.rs:274), supervisor closure is `FnMut(...) -> io::Result<Child>` — clean to wrap with `enroll`. All confirmed. Activating REQs + implementing.

First, activate both REQs in the registry (rule 3: registry-first).


Edit

id = "REQ-HAZARD-DAEMON-STOP-BARRIER"
title = "B3: `spt daemon stop` then an immediate `spt daemon start` does NOT race — stop fully completes before it returns. Today request_stop (seedmap.rs:240-255) returns on the KIND_STOPPING ack (sent seedmap.rs:174-176) BEFORE the seed socket unbinds, so a following is_running ping (daemon.rs:375) wins the exit window and start reports ALREADY_RUNNING (operator: daemon stop → STOPPED then start → ALREADY_RUNNING). FIX: unbind/stop-gate the seed socket BEFORE acking KIND_STOPPING, OR request_stop waits for a ping-to-fail before returning. Unit: stop then immediate is_running()==false. (v0.12.0)"
required_stages = []  # registry-first; activate [impl,unit] when built — wave 3 (with Breap). impl = stop barrier (socket unbind before ack OR wait-ping-fail); unit = post-stop is_running false immediately

id = "REQ-HAZARD-DAEMON-STOP-BARRIER"
title = "B3: `spt daemon stop` then an immediate `spt daemon start` does NOT race — stop fully completes before it returns. Today request_stop (seedmap.rs:240-255) returns on the KIND_STOPPING ack (sent seedmap.rs:174-176) BEFORE the seed socket unbinds, so a following is_running ping (daemon.rs:375) wins the exit window and start reports ALREADY_RUNNING (operator: daemon stop → STOPPED then start → ALREADY_RUNNING). FIX: unbind/stop-gate the seed socket BEFORE acking KIND_STOPPING, OR request_stop waits for a ping-to-fail before returning. Unit: stop then immediate is_running()==false. (v0.12.0)"
required_stages = ["impl", "unit"]  # activated v0.12.0 wave 3 (with Breap): impl = request_stop ack-then-barrier (polls ping to failure before returning, seedmap.rs); unit = request_stop_barrier_holds_until_no_listener (immediate post-return ping is Err, no poll)


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


584	[[requirements]]
585	id = "REQ-HAZARD-DAEMON-STOP-BARRIER"
586	title = "B3: `spt daemon stop` then an immediate `spt daemon start` does NOT race — stop fully completes before it returns. Today request_stop (seedmap.rs:240-255) returns on the KIND_STOPPING ack (sent seedmap.rs:174-176) BEFORE the seed socket unbinds, so a following is_running ping (daemon.rs:375) wins the exit window and start reports ALREADY_RUNNING (operator: daemon stop → STOPPED then start → ALREADY_RUNNING). FIX: unbind/stop-gate the seed socket BEFORE acking KIND_STOPPING, OR request_stop waits for a ping-to-fail before returning. Unit: stop then immediate is_running()==false. (v0.12.0)"
587	required_stages = []  # registry-first; activate [impl,unit] when built — wave 3 (with Breap). impl = stop barrier (socket unbind before ack OR wait-ping-fail); unit = post-stop is_running false immediately
588	[[requirements]]
589	id = "REQ-HAZARD-DAEMON-STOP-REAP"
590	title = "Breap: `spt daemon stop` REAPS the spt-hosted children it spawned — no orphaned psyche/harness processes. Today a stop leaves ~8 orphaned claude-spt-psyche.exe + spt.exe: Psyches are spawned DETACHED (runtime.rs:342-356, the Child is dropped — 'Detached' ~349) and the livehost stop flag Arc<AtomicBool> is NEVER raised (brainproc.rs:227-230 holds it 'for symmetry'). FIX: on stop, raise the livehost stop flag AND kill the spawned psyche/spt-hosted children — via a Windows job object / Unix process-group so the children die with the daemon (not detached-immortal). Folds with B3 (both the stop path). (v0.12.0)"
591	required_stages = []  # registry-first; activate [impl,unit] when built — wave 3 (with B3). impl = raise livehost stop flag on daemon stop + job-object/process-group child reap; unit = stop terminates the spawned children
592	[[requirements]]
593	id = "REQ-HAZARD-LIVEHOST-BOOT-LIVENESS-GATE"
594	title = "B5: `spt daemon start` does NOT revive phantom Psyches for dead-but-online-latched perches. Today reconcile_once (livehost.rs:285) spawns a Psyche per status=online live_agent perch at boot WITHOUT verifying the harness child / {id}-psyche is actually alive — so a Cold start after an unclean stop revives N psyches for N dead-but-latched perches (3 psyches for 3 dead perches). FIX: gate the boot psyche-spawn on real child-liveness — a perch with NO live broker session (the B2 reconcile signal) is marked OFFLINE at boot instead of hosted, so a dead-harness perch is never revived. Shares the B2 reconcile loop (this is its boot-gate arm); composes with B2's honest latch. Also closes wall-a's psyche_host_error gap (residency-confirm does not run at boot tick-1, livehost.rs:395-441 / 257-263). (v0.12.0)"
595	required_stages = []  # registry-first; activate [impl,unit] when built — wave 4 (after B3+Breap). Shares the B2 reconcile: boot-gate arm = a no-live-session online perch is marked offline at boot, not psyche-revived
596	[[requirements]]
597	id = "REQ-HAZARD-BRAIN-RESTART-LIFECYCLE-REHYDRATE"

[[requirements]]
id = "REQ-HAZARD-DAEMON-STOP-BARRIER"
title = "B3: `spt daemon stop` then an immediate `spt daemon start` does NOT race — stop fully completes before it returns. Today request_stop (seedmap.rs:240-255) returns on the KIND_STOPPING ack (sent seedmap.rs:174-176) BEFORE the seed socket unbinds, so a following is_running ping (daemon.rs:375) wins the exit window and start reports ALREADY_RUNNING (operator: daemon stop → STOPPED then start → ALREADY_RUNNING). FIX: unbind/stop-gate the seed socket BEFORE acking KIND_STOPPING, OR request_stop waits for a ping-to-fail before returning. Unit: stop then immediate is_running()==false. (v0.12.0)"
required_stages = []  # registry-first; activate [impl,unit] when built — wave 3 (with Breap). impl = stop barrier (socket unbind before ack OR wait-ping-fail); unit = post-stop is_running false immediately
[[requirements]]
id = "REQ-HAZARD-DAEMON-STOP-REAP"
title = "Breap: `spt daemon stop` REAPS the spt-hosted children it spawned — no orphaned psyche/harness processes. Today a stop leaves ~8 orphaned claude-spt-psyche.exe + spt.exe: Psyches are spawned DETACHED (runtime.rs:342-356, the Child is dropped — 'Detached' ~349) and the livehost stop flag Arc<AtomicBool> is NEVER raised (brainproc.rs:227-230 holds it 'for symmetry'). FIX: on stop, raise the livehost stop flag AND kill the spawned psyche/spt-hosted children — via a Windows job object / Unix process-group so the children die with the daemon (not detached-immortal). Folds with B3 (both the stop path). (v0.12.0)"
required_stages = []  # registry-first; activate [impl,unit] when built — wave 3 (with B3). impl = raise livehost stop flag on daemon stop + job-object/process-group child reap; unit = stop terminates the spawned children
[[requirements]]
id = "REQ-HAZARD-LIVEHOST-BOOT-LIVENESS-GATE"
title = "B5: `spt daemon start` does NOT revive phantom Psyches for dead-but-online-latched perches. Today reconcile_once (livehost.rs:285) spawns a Psyche per status=online live_agent perch at boot WITHOUT verifying the harness child / {id}-psyche is actually alive — so a Cold start after an unclean stop revives N psyches for N dead-but-latched perches (3 psyches for 3 dead perches). FIX: gate the boot psyche-spawn on real child-liveness — a perch with NO live broker session (the B2 reconcile signal) is marked OFFLINE at boot instead of hosted, so a dead-harness perch is never revived. Shares the B2 reconcile loop (this is its boot-gate arm); composes with B2's honest latch. Also closes wall-a's psyche_host_error gap (residency-confirm does not run at boot tick-1, livehost.rs:395-441 / 257-263). (v0.12.0)"
required_stages = []  # registry-first; activate [impl,unit] when built — wave 4 (after B3+Breap). Shares the B2 reconcile: boot-gate arm = a no-live-session online perch is marked offline at boot, not psyche-revived
[[requirements]]
id = "REQ-HAZARD-BRAIN-RESTART-LIFECYCLE-REHYDRATE"


required_stages = []  # registry-first; activate [impl,unit] when built — wave 3 (with Breap). impl = stop barrier (socket unbind before ack OR wait-ping-fail); unit = post-stop is_running false immediately

required_stages = ["impl", "unit"]  # activated v0.12.0 wave 3 (with Breap): impl = request_stop ack-then-barrier (polls ping to failure before returning, seedmap.rs); unit = request_stop_barrier_holds_until_no_listener (immediate post-return ping is Err, no poll)


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

title = "B3: `spt daemon stop` then an immediate `spt daemon start` does NOT race — stop fully completes before it returns. Today request_stop (seedmap.rs:240-255) returns on the KIND_STOPPING ack (sent seedmap.rs:174-176) BEFORE the seed socket unbinds, so a following is_running ping (daemon.rs:375) wins the exit window and start reports ALREADY_RUNNING (operator: daemon stop → STOPPED then start → ALREADY_RUNNING). FIX: unbind/stop-gate the seed socket BEFORE acking KIND_STOPPING, OR request_stop waits for a ping-to-fail before returning. Unit: stop then immediate is_running()==false. (v0.12.0)"

-required_stages = []  # registry-first; activate [impl,unit] when built — wave 3 (with Breap). impl = stop barrier (socket unbind before ack OR wait-ping-fail); unit = post-stop is_running false immediately

+required_stages = ["impl", "unit"]  # activated v0.12.0 wave 3 (with Breap): impl = request_stop ack-then-barrier (polls ping to failure before returning, seedmap.rs); unit = request_stop_barrier_holds_until_no_listener (immediate post-return ping is Err, no poll)

title = "Breap: `spt daemon stop` REAPS the spt-hosted children it spawned — no orphaned psyche/harness processes. Today a stop leaves ~8 orphaned claude-spt-psyche.exe + spt.exe: Psyches are spawned DETACHED (runtime.rs:342-356, the Child is dropped — 'Detached' ~349) and the livehost stop flag Arc<AtomicBool> is NEVER raised (brainproc.rs:227-230 holds it 'for symmetry'). FIX: on stop, raise the livehost stop flag AND kill the spawned psyche/spt-hosted children — via a Windows job object / Unix process-group so the children die with the daemon (not detached-immortal). Folds with B3 (both the stop path). (v0.12.0)"


required_stages = []  # registry-first; activate [impl,unit] when built — wave 3 (with B3). impl = raise livehost stop flag on daemon stop + job-object/process-group child reap; unit = stop terminates the spawned children

required_stages = ["impl", "unit"]  # activated v0.12.0 wave 3 (with B3): impl = BrainReaper (Windows kill-on-job-close Job / Unix per-brain process-group) enrolled per brain (re)spawn + reaped on the graceful daemon-stop path (supervisor stop flag raised first, no respawn race); unit = reap.rs job/group reaps an enrolled child


-required_stages = []  # registry-first; activate [impl,unit] when built — wave 3 (with B3). impl = raise livehost stop flag on daemon stop + job-object/process-group child reap; unit = stop terminates the spawned children

+required_stages = ["impl", "unit"]  # activated v0.12.0 wave 3 (with B3): impl = BrainReaper (Windows kill-on-job-close Job / Unix per-brain process-group) enrolled per brain (re)spawn + reaped on the graceful daemon-stop path (supervisor stop flag raised first, no respawn race); unit = reap.rs job/group reaps an enrolled child

title = "B5: `spt daemon start` does NOT revive phantom Psyches for dead-but-online-latched perches. Today reconcile_once (livehost.rs:285) spawns a Psyche per status=online live_agent perch at boot WITHOUT verifying the harness child / {id}-psyche is actually alive — so a Cold start after an unclean stop revives N psyches for N dead-but-latched perches (3 psyches for 3 dead perches). FIX: gate the boot psyche-spawn on real child-liveness — a perch with NO live broker session (the B2 reconcile signal) is marked OFFLINE at boot instead of hosted, so a dead-harness perch is never revived. Shares the B2 reconcile loop (this is its boot-gate arm); composes with B2's honest latch. Also closes wall-a's psyche_host_error gap (residency-confirm does not run at boot tick-1, livehost.rs:395-441 / 257-263). (v0.12.0)"


Now the reap module (Breap impl + units):


Write

//! 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::process::Command;
    use std::time::Duration;

    /// Poll until `pid` reads dead (zombie counts as dead — `is_process_alive`
    /// handles it) or the bound elapses.
    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
    }

    // [unit->REQ-HAZARD-DAEMON-STOP-REAP] an enrolled child is terminated by the
    // reap — the mechanism that makes the brain (and the Psyches it spawns, which
    // inherit the same job/group) die with the daemon on stop instead of
    // orphaning. The child is a long-lived sleeper rooted in its own container;
    // reap() must kill it.
    #[cfg(windows)]
    #[test]
    fn job_reaps_enrolled_child() {
        let reaper = BrainReaper::new();
        // A real long-running child (ping's -n is a 1s/iteration sleeper).
        let mut child = Command::new("cmd")
            .args(["/C", "ping -n 60 127.0.0.1 >NUL"])
            .spawn()
            .expect("spawn sleeper");
        reaper.enroll(&child);
        let pid = child.id();
        assert!(is_process_alive(pid), "enrolled child starts alive");
        reaper.reap();
        assert!(wait_dead(pid), "reap must terminate the enrolled child");
        let _ = child.wait();
    }

    #[cfg(unix)]
    #[test]
    fn group_reaps_enrolled_child() {
        use std::os::unix::process::CommandExt;
        let reaper = BrainReaper::new();
        let mut cmd = Command::new("sh");
        cmd.args(["-c", "sleep 60"]);
        // Mirror the production brain spawn: the child leads its OWN group so the
        // reap (kill -pgid) hits the child subtree, never the test runner.
        unsafe {
            cmd.pre_exec(|| {
                libc::setpgid(0, 0);
                Ok(())
            });
        }
        let mut child = cmd.spawn().expect("spawn sleeper");
        reaper.enroll(&child);
        let pid = child.id();
        assert!(is_process_alive(pid), "enrolled child starts alive");
        reaper.reap();
        assert!(wait_dead(pid), "reap must terminate the enrolled child's group");
        let _ = child.wait();
    }

    // [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();
    }
}