./crates/spt/tests/brain_split.rs
./crates/spt-daemon/src/access.rs
./crates/spt-daemon/src/adapter_update.rs
./crates/spt-daemon/src/applyhost.rs
./crates/spt-daemon/src/attach.rs
./crates/spt-daemon/src/brain.rs
./crates/spt-daemon/src/brainproc.rs
./crates/spt-daemon/src/broker.rs
./crates/spt-daemon/src/codec.rs
./crates/spt-daemon/src/config.rs
./crates/spt-daemon/src/consent.rs
./crates/spt-daemon/src/daemon.rs
./crates/spt-daemon/src/deelevate.rs
./crates/spt-daemon/src/digest.rs
./crates/spt-daemon/src/digesthub.rs
./crates/spt-daemon/src/dispatch.rs
./crates/spt-daemon/src/effect.rs
./crates/spt-daemon/src/endpoint.rs
./crates/spt-daemon/src/frame.rs
./crates/spt-daemon/src/grants.rs
./crates/spt-daemon/src/lib.rs
./crates/spt-daemon/src/lifecycle.rs
./crates/spt-daemon/src/linkhost.rs
./crates/spt-daemon/src/machineid.rs
./crates/spt-daemon/src/msg.rs
./crates/spt-daemon/src/nethost.rs
./crates/spt-daemon/src/notif.rs
./crates/spt-daemon/src/notifsync.rs
./crates/spt-daemon/src/pairhost.rs
./crates/spt-daemon/src/peerloop.rs
./crates/spt-daemon/src/presence.rs
./crates/spt-daemon/src/propagate.rs
./crates/spt-daemon/src/psyrelay.rs
./crates/spt-daemon/src/reconcile.rs
./crates/spt-daemon/src/registryhost.rs
./crates/spt-daemon/src/relay.rs
./crates/spt-daemon/src/relcache.rs
./crates/spt-daemon/src/release.rs
./crates/spt-daemon/src/resthost.rs
./crates/spt-daemon/src/resting.rs
./crates/spt-daemon/src/seedmap.rs
./crates/spt-daemon/src/seedproofx.rs
./crates/spt-daemon/src/serveprobe.rs
./crates/spt-daemon/src/service.rs
./crates/spt-daemon/src/shellchan.rs
./crates/spt-daemon/src/shelldisc.rs
./crates/spt-daemon/src/shellhost.rs
./crates/spt-daemon/src/shellwake.rs
./crates/spt-daemon/src/sync.rs
./crates/spt-daemon/src/test_home.rs


<owl_messages>
<owl_message from="todlando-psyche" priority="normal"><EVENT type="echo_commune" from="todlando-psyche" timestamp="2026-06-09T19:01:37-07:00" machine="HFENDULEAM" project="spt-core" branch="main" head_sha="0c954353b256f46e45a600be7a7fb1f3768e9d30" head_subject="feat(daemon): restoration D1 — broker/brain process-split skeleton (REQ-…" note="Echo commune brief — auto-fired">&lt;live-context&gt;
**D1 DONE + CI GREEN + ON MAIN. D2 UNBLOCKED.**

origin/main @ 0c95435. Cross-OS CI run 27247580888: Linux kitsubito ✓ 4m3s, Windows hfenduleam ✓ 6m14s, traceability ✓. brain_split.rs de-risk holds on both OSes — AF_UNIX worry answered.

REQ states: REQ-HAZARD-BROKER-PROCESS-ISOLATION = [doc,impl,unit] (int→D7) · REQ-HAZARD-ROLLBACK-STATE-COMPAT = [doc] (impl/unit→D6). 226 daemon lib tests + brainproc unit + brain_split smoke all green.

**ACTIVE FOCUS — D2 (IPC verb surface + migration ledger, Q5/V6):**
Migrate broker-resident logic loops into brain child ONE PER COMMIT:
1. Define versioned verb
2. Reroute direct call over socket
3. Move loop into brain
4. Prove N-1 window

Cheapest first: net consumers (dispatch + peer pump) already connect over `broker_socket_name()` — no shared Arc. Harder: broker.serve thread, digest hub serve (broker-side), net_retry_attach (Arc-coupled).

Keep loop→verb→moves-at-commit ledger current in RESTORATION-PLAN.md D2. Watch KH-2.3: every new verb additive/defaulted. D7 N-1 gate is backstop.

**D3–D7 remain:** supervision anchor+update trigger · multi-session resume+BrainState retire · durable timing (pulse only) · rollback+two-phase record · proof E2E+fleet verify.

**Low-priority:** route multipart-truncation to legacy; reconcile ROADMAP (M8 not itemized); stale DEFERRED mesh row.

**Last commune:** 2026-06-09T18:53 — HFENDULEAM main @ 0c95435 (CI green, D2 unblocked)
&lt;/live-context&gt;
&lt;project-context&gt;
spt-core — restoration D1 COMPLETE + cross-OS CI-green + on main (0c95435). The broker/brain process boundary is restored and proven on both OSes.

State: origin/main @ 0c95435. traceable-reqs EXIT=0. REQ-HAZARD-BROKER-PROCESS-ISOLATION = [doc,impl,unit] satisfied (int→D7); REQ-HAZARD-ROLLBACK-STATE-COMPAT = [doc] (impl/unit→D6). 226 daemon lib tests + brainproc unit + brain_split process-smoke all green on both runners.

What D1 shipped: brainproc.rs (run_brain child entry + supervise_brain process-level supervisor mirroring peerloop::supervise_pump + spawn_brain_supervisor re-execing `current_exe daemon brain`); daemon.rs spawns the supervisor before the foreground seed loop; cli.rs hidden `daemon brain`; endpoint.rs brain_ready_path. NO logic moved — net consumers/digest hub/shellwake/seed all stay broker-resident.

NEXT = D2 (IPC verb surface + migration ledger, ADR-0018 Q5/V6). Migrate each broker-resident logic loop into the brain child ONE PER COMMIT: define its versioned verb → reroute the direct call over the socket → move the loop into the brain → prove the N-1 window. Keep the loop→verb→moves-at-commit ledger current in RESTORATION-PLAN.md D2. D2-close invariant: every logic loop runs in the brain; broker holds only continuity resources + the verb server. Discovery from D1: the net consumers (dispatch + peer pump) ALREADY connect over broker_socket_name() rather than a shared Arc, so they're the cheapest first migrations; the genuinely Arc-coupled pieces are the broker.serve thread, digest hub serve (broker-side), net_retry_attach. Watch KH-2.3: every new verb additive/defaulted (the D7 N-1 old-broker×new-brain compat test is the backstop).

End goal unchanged: seamless self-update with no manual fleet bounce (this milestone makes it real), then spt-claude-code on the final topology.

Plans on disk: RESTORATION-PLAN.md (D1–D7), RESTORATION-D1-PLAN.md (done). Low-priority still open: route multipart-truncation bug to legacy claude_skill_owl; reconcile ROADMAP (M8/v0.3.x not itemized).
&lt;/project-context&gt;</EVENT></owl_message>
</owl_messages>


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`, and idles — **no logic moves yet** (the net consumers,
11	//! digest hub, and shellwake stay in the broker process; D2 migrates them
12	//! loop-by-loop as each gets a versioned verb).
13	//!
14	//! What D1 proves (de-risk spike #1, the design's hardest invariant): the
15	//! broker spawns + supervises a brain child over the socket, and the broker
16	//! (with its PTY children + seed channel) survives the brain dying — the
17	//! supervisor respawns it from the executable path, which on a real update is
18	//! the swapped binary.
19	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
20
21	use std::io;
22	use std::process::{Child, Command};
23	use std::sync::atomic::{AtomicBool, Ordering};
24	use std::sync::Arc;
25	use std::thread;
26	use std::time::{Duration, Instant};
27
28	use crate::brain::{now_ms, Brain};
29	use crate::endpoint::{brain_ready_path, broker_socket_name};
30
31	/// Supervisor backoff floor after a brain death (mirrors the peer-pump
32	/// supervisor, M8 decision 23): a healthy run resets to this; a fast death
33	/// doubles, capped at [`SUPERVISE_BACKOFF_CAP`].
34	const SUPERVISE_BACKOFF_BASE: Duration = Duration::from_secs(2);
35	/// Backoff ceiling — a persistently-crashing brain stays visible and bounded,
36	/// never a hot respawn loop.
37	const SUPERVISE_BACKOFF_CAP: Duration = Duration::from_secs(60);
38	/// A run at least this long counts as healthy and resets the backoff floor.
39	const SUPERVISE_HEALTHY_RUN: Duration = Duration::from_secs(30);
40	/// Poll/sleep slice so a `stop` request (and a child exit) land promptly.
41	const TICK: Duration = Duration::from_millis(200);
42
43	/// How long the brain child retries connecting to the broker socket before
44	/// giving up (the broker may still be binding — the child is freshly spawned).
45	const BRAIN_CONNECT_TRIES: u32 = 300;
46	/// Cadence the idle brain refreshes its ready breadcrumb + probes the broker.
47	const BRAIN_HEARTBEAT: Duration = Duration::from_millis(500);
48
49	/// The `spt daemon brain` child entry. Connect to the broker over the existing
50	/// socket IPC, signal readiness, then idle — holding the connection and probing
51	/// broker liveness each heartbeat. A dropped connection (broker gone, or a
52	/// future planned snapshot+self-exit, ADR-0018 Q3) returns `Err`, exiting the
53	/// process so the broker-side supervisor respawns it from the (possibly
54	/// swapped) binary.
55	///
56	/// D1 deliberately does NOTHING else — no logic loops run here yet. It inherits
57	/// the broker's already-unelevated token as a plain child, so it runs no
58	/// de-elevation guard of its own.
59	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
60	pub fn run_brain() -> io::Result<()> {
61	    let name = broker_socket_name();
62	    let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;
63	    eprintln!("BRAIN_UP: connected to broker {name} (pid {})", std::process::id());
64	    write_ready();
65	    // Idle loop: refresh the breadcrumb and probe the broker with a cheap
66	    // `sessions` query. The query erroring is how we learn the broker is gone.
67	    loop {
68	        thread::sleep(BRAIN_HEARTBEAT);
69	        if let Err(e) = brain.sessions() {
70	            eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
71	            return Err(e);
72	        }
73	        write_ready();
74	    }
75	}
76
77	/// Connect to the broker, retrying briefly while it finishes binding.
78	fn connect_retry(name: &str, tries: u32) -> io::Result<Brain> {
79	    let mut last: Option<io::Error> = None;
80	    for _ in 0..tries {
81	        match Brain::cold_start(name, now_ms()) {
82	            Ok(b) => return Ok(b),
83	            Err(e) => {
84	                last = Some(e);
85	                thread::sleep(Duration::from_millis(10));
86	            }
87	        }
88	    }
89	    Err(last.unwrap_or_else(|| io::Error::other("brain could not connect to broker")))
90	}
91
92	/// Write the readiness breadcrumb (best-effort): the brain child's pid, which a
93	/// supervisor/test reads to confirm the brain is up and (on a pid change) that
94	/// it respawned. Liveness of the *daemon* stays the seed-control ping.
95	fn write_ready() {
96	    let _ = spt_store::atomic::atomic_write_string(
97	        &brain_ready_path(),
98	        &std::process::id().to_string(),
99	    );
100	}
101
102	/// The supervisor's next backoff after a brain death: a healthy run (≥
103	/// [`SUPERVISE_HEALTHY_RUN`]) resets to the floor `base`; a fast death doubles,
104	/// capped at [`SUPERVISE_BACKOFF_CAP`]. Pure for the unit table (parallels
105	/// `peerloop::next_backoff`).
106	fn next_backoff(prev: Duration, ran_for: Duration, base: Duration) -> Duration {
107	    if ran_for >= SUPERVISE_HEALTHY_RUN {
108	        base
109	    } else {
110	        prev.saturating_mul(2).min(SUPERVISE_BACKOFF_CAP)
111	    }
112	}
113
114	/// Supervise the brain child: spawn it, wait for it, and respawn on exit after a
115	/// capped, doubling backoff — until `stop` is raised, which kills the live child
116	/// and returns. The process-level sibling of [`crate::peerloop::supervise_pump`]
117	/// (ADR-0018 Q2: the broker is the always-up anchor that respawns the brain;
118	/// Q3: a planned update is just the brain self-exiting on a path the supervisor
119	/// already recovers from). Generic over `spawn_child` so the unit test injects a
120	/// fake; production binds [`spawn_brain_child`].
121	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
122	pub fn supervise_brain(
123	    stop: &AtomicBool,
124	    base: Duration,
125	    mut spawn_child: impl FnMut() -> io::Result<Child>,
126	) {
127	    let mut backoff = base;
128	    while !stop.load(Ordering::Relaxed) {
129	        let started = Instant::now();
130	        match spawn_child() {
131	            Ok(mut child) => loop {
132	                if stop.load(Ordering::Relaxed) {
133	                    let _ = child.kill();
134	                    let _ = child.wait();
135	                    return;
136	                }
137	                match child.try_wait() {
138	                    Ok(Some(status)) => {
139	                        eprintln!("BRAIN_EXIT: brain child exited ({status}) — respawning");
140	                        break;
141	                    }
142	                    Ok(None) => thread::sleep(TICK),
143	                    Err(e) => {
144	                        eprintln!("BRAIN_WAIT_FAIL: {e}");
145	                        break;
146	                    }
147	                }
148	            },
149	            Err(e) => eprintln!("BRAIN_SPAWN_FAIL: {e}"),
150	        }
151	        if stop.load(Ordering::Relaxed) {
152	            return;
153	        }
154	        backoff = next_backoff(backoff, started.elapsed(), base);
155	        eprintln!("BRAIN_RESTART: supervised respawn in {}s", backoff.as_secs());
156	        let mut slept = Duration::ZERO;
157	        while slept < backoff && !stop.load(Ordering::Relaxed) {
158	            thread::sleep(TICK.min(backoff - slept));
159	            slept += TICK;
160	        }
161	    }
162	}
163
164	/// Spawn the production brain child: re-exec `current_exe daemon brain` as a
165	/// plain (non-detached) managed child, so it inherits the broker's environment
166	/// — including `SPT_HOME` — and dies with no orphaning. NOT `spawn_detached`
167	/// (that path rebuilds the Windows env block and would drop `SPT_HOME`).
168	fn spawn_brain_child() -> io::Result<Child> {
169	    let exe = std::env::current_exe()?;
170	    Command::new(exe).args(["daemon", "brain"]).spawn()
171	}
172
173	/// Production wiring: run the brain supervisor on its own thread, spawned by the
174	/// broker process ([`crate::daemon::Daemon::run`]) before its foreground
175	/// seed-control loop. The brain child is supervised for the broker's lifetime.
176	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
177	pub fn spawn_brain_supervisor(stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
178	    thread::spawn(move || {
179	        supervise_brain(&stop, SUPERVISE_BACKOFF_BASE, spawn_brain_child);
180	    })
181	}
182
183	#[cfg(test)]
184	mod tests {
185	    use super::*;
186	    use std::sync::atomic::AtomicU32;
187
188	    /// A trivial, fast-exiting real child — the cross-OS no-op the supervisor
189	    /// test respawns (stands in for the brain process).
190	    fn fast_child() -> io::Result<Child> {
191	        #[cfg(windows)]
192	        {
193	            Command::new("cmd").args(["/C", "rem"]).spawn()
194	        }
195	        #[cfg(unix)]
196	        {
197	            Command::new("true").spawn()
198	        }
199	    }
200
201	    /// The supervisor respawns the brain on every exit and stops cleanly when
202	    /// `stop` is raised (ADR-0018 Q2 — the broker is the always-up anchor that
203	    /// keeps the brain alive). The injected spawner raises `stop` once it has
204	    /// been asked to spawn three times, proving respawn-on-death + clean halt.
205	    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
206	    #[test]
207	    fn supervisor_respawns_brain_until_stopped() {
208	        let stop = Arc::new(AtomicBool::new(false));
209	        let count = Arc::new(AtomicU32::new(0));
210	        let stop_c = Arc::clone(&stop);
211	        let count_c = Arc::clone(&count);
212	        // Tiny base backoff so the test is quick; real production uses seconds.
213	        supervise_brain(&stop, Duration::from_millis(5), move || {
214	            let n = count_c.fetch_add(1, Ordering::Relaxed) + 1;
215	            if n >= 3 {
216	                stop_c.store(true, Ordering::Relaxed);
217	            }
218	            fast_child()
219	        });
220	        assert!(
221	            count.load(Ordering::Relaxed) >= 3,
222	            "supervisor must respawn the brain after each exit"
223	        );
224	    }
225
226	    /// Backoff: a fast death doubles (capped), a healthy run resets to the floor.
227	    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
228	    #[test]
229	    fn backoff_doubles_on_fast_death_resets_when_healthy() {
230	        let base = Duration::from_secs(2);
231	        // Fast death: doubles.
232	        let b1 = next_backoff(base, Duration::from_millis(1), base);
233	        assert_eq!(b1, Duration::from_secs(4));
234	        let b2 = next_backoff(b1, Duration::from_millis(1), base);
235	        assert_eq!(b2, Duration::from_secs(8));
236	        // Capped.
237	        let capped = next_backoff(SUPERVISE_BACKOFF_CAP, Duration::from_millis(1), base);
238	        assert_eq!(capped, SUPERVISE_BACKOFF_CAP);
239	        // Healthy run resets to the floor.
240	        let reset = next_backoff(b2, SUPERVISE_HEALTHY_RUN, base);
241	        assert_eq!(reset, base);
242	    }
243	}
244

//! 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`, and idles — **no logic moves yet** (the net consumers,
//! digest hub, and shellwake stay in the broker process; D2 migrates them
//! loop-by-loop as each gets a versioned verb).
//!
//! 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::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};

/// 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.
const SUPERVISE_HEALTHY_RUN: Duration = Duration::from_secs(30);
/// 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);

/// The `spt daemon brain` child entry. Connect to the broker over the existing
/// socket IPC, signal readiness, then 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.
///
/// D1 deliberately does NOTHING else — no logic loops run here yet. 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() -> io::Result<()> {
    let name = broker_socket_name();
    let mut brain = connect_retry(&name, BRAIN_CONNECT_TRIES)?;
    eprintln!("BRAIN_UP: connected to broker {name} (pid {})", std::process::id());
    write_ready();
    // Idle loop: refresh the breadcrumb and probe the broker with a cheap
    // `sessions` query. The query erroring is how we learn the broker is gone.
    loop {
        thread::sleep(BRAIN_HEARTBEAT);
        if let Err(e) = brain.sessions() {
            eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
            return Err(e);
        }
        write_ready();
    }
}

/// Connect to the broker, retrying briefly while it finishes binding.
fn connect_retry(name: &str, tries: u32) -> io::Result<Brain> {
    let mut last: Option<io::Error> = None;
    for _ in 0..tries {
        match Brain::cold_start(name, now_ms()) {
            Ok(b) => return Ok(b),
            Err(e) => {
                last = Some(e);
                thread::sleep(Duration::from_millis(10));
            }
        }
    }
    Err(last.unwrap_or_else(|| io::Error::other("brain could not connect to broker")))
}

/// Write the readiness breadcrumb (best-effort): the brain child's pid, which a
/// supervisor/test reads to confirm the brain is up and (on a pid change) that
/// it respawned. Liveness of the *daemon* stays the seed-control ping.
fn write_ready() {
    let _ = spt_store::atomic::atomic_write_string(
        &brain_ready_path(),
        &std::process::id().to_string(),
    );
}

/// The supervisor's next backoff after a brain death: a healthy run (≥
/// [`SUPERVISE_HEALTHY_RUN`]) resets to the floor `base`; a fast death doubles,
/// capped at [`SUPERVISE_BACKOFF_CAP`]. Pure for the unit table (parallels
/// `peerloop::next_backoff`).
fn next_backoff(prev: Duration, ran_for: Duration, base: Duration) -> Duration {
    if ran_for >= SUPERVISE_HEALTHY_RUN {
        base
    } else {
        prev.saturating_mul(2).min(SUPERVISE_BACKOFF_CAP)
    }
}

/// Supervise the brain child: spawn it, wait for it, and respawn on exit after a
/// capped, doubling backoff — until `stop` is raised, which kills the live child
/// and returns. The process-level sibling of [`crate::peerloop::supervise_pump`]
/// (ADR-0018 Q2: the broker is the always-up anchor that respawns the brain;
/// Q3: a planned update is just the brain self-exiting on a path the supervisor
/// already recovers from). Generic over `spawn_child` so the unit test injects a
/// fake; production binds [`spawn_brain_child`].
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn supervise_brain(
    stop: &AtomicBool,
    base: Duration,
    mut spawn_child: impl FnMut() -> io::Result<Child>,
) {
    let mut backoff = base;
    while !stop.load(Ordering::Relaxed) {
        let started = Instant::now();
        match spawn_child() {
            Ok(mut child) => loop {
                if stop.load(Ordering::Relaxed) {
                    let _ = child.kill();
                    let _ = child.wait();
                    return;
                }
                match child.try_wait() {
                    Ok(Some(status)) => {
                        eprintln!("BRAIN_EXIT: brain child exited ({status}) — respawning");
                        break;
                    }
                    Ok(None) => thread::sleep(TICK),
                    Err(e) => {
                        eprintln!("BRAIN_WAIT_FAIL: {e}");
                        break;
                    }
                }
            },
            Err(e) => eprintln!("BRAIN_SPAWN_FAIL: {e}"),
        }
        if stop.load(Ordering::Relaxed) {
            return;
        }
        backoff = next_backoff(backoff, started.elapsed(), base);
        eprintln!("BRAIN_RESTART: supervised respawn in {}s", backoff.as_secs());
        let mut slept = Duration::ZERO;
        while slept < backoff && !stop.load(Ordering::Relaxed) {
            thread::sleep(TICK.min(backoff - slept));
            slept += TICK;
        }
    }
}

/// Spawn the production brain child: re-exec `current_exe daemon brain` as a
/// plain (non-detached) managed child, so it inherits the broker's environment
/// — including `SPT_HOME` — and dies with no orphaning. NOT `spawn_detached`
/// (that path rebuilds the Windows env block and would drop `SPT_HOME`).
fn spawn_brain_child() -> io::Result<Child> {
    let exe = std::env::current_exe()?;
    Command::new(exe).args(["daemon", "brain"]).spawn()
}

/// 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.
// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
pub fn spawn_brain_supervisor(stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
    thread::spawn(move || {
        supervise_brain(&stop, SUPERVISE_BACKOFF_BASE, spawn_brain_child);
    })
}

#[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)]
        {
            Command::new("cmd").args(["/C", "rem"]).spawn()
        }
        #[cfg(unix)]
        {
            Command::new("true").spawn()
        }
    }

    /// The supervisor respawns the brain on every exit and stops cleanly when
    /// `stop` is raised (ADR-0018 Q2 — the broker is the always-up anchor that
    /// keeps the brain alive). The injected spawner raises `stop` once it has
    /// been asked to spawn three times, proving respawn-on-death + clean halt.
    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    #[test]
    fn supervisor_respawns_brain_until_stopped() {
        let stop = Arc::new(AtomicBool::new(false));
        let count = Arc::new(AtomicU32::new(0));
        let stop_c = Arc::clone(&stop);
        let count_c = Arc::clone(&count);
        // Tiny base backoff so the test is quick; real production uses seconds.
        supervise_brain(&stop, Duration::from_millis(5), move || {
            let n = count_c.fetch_add(1, Ordering::Relaxed) + 1;
            if n >= 3 {
                stop_c.store(true, Ordering::Relaxed);
            }
            fast_child()
        });
        assert!(
            count.load(Ordering::Relaxed) >= 3,
            "supervisor must respawn the brain after each exit"
        );
    }

    /// Backoff: a fast death doubles (capped), a healthy run resets to the floor.
    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
    #[test]
    fn backoff_doubles_on_fast_death_resets_when_healthy() {
        let base = Duration::from_secs(2);
        // Fast death: doubles.
        let b1 = next_backoff(base, Duration::from_millis(1), base);
        assert_eq!(b1, Duration::from_secs(4));
        let b2 = next_backoff(b1, Duration::from_millis(1), base);
        assert_eq!(b2, Duration::from_secs(8));
        // Capped.
        let capped = next_backoff(SUPERVISE_BACKOFF_CAP, Duration::from_millis(1), base);
        assert_eq!(capped, SUPERVISE_BACKOFF_CAP);
        // Healthy run resets to the floor.
        let reset = next_backoff(b2, SUPERVISE_HEALTHY_RUN, base);
        assert_eq!(reset, base);
    }
}