<system-reminder>[Truncated: PARTIAL view — showing lines 1-1139 of 1863 total (34752 tokens, cap 25000). Call Read with offset=1140 limit=1139 for the next page, or Grep to find a specific section. Do NOT answer from this page alone if the answer may be further in the file.]</system-reminder>

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.
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
210	    // the broker process to the brain child: shellwake is brain-owned logic
211	    // (ADR-0018 Q5 exception), a pure disk-reconciler that only reaches the
212	    // broker over the same socket IPC for the remote-wake dial. Its boot sweep
213	    // doubles as orphan cleanup, so a brain respawn re-reconciles the watcher
214	    // children from disk — exactly the Q5 "re-reconciled on brain start"
215	    // posture. The stop flag is held for symmetry, never raised (the watchers
216	    // die with the brain process and the next brain re-sweeps them).
217	    // [impl->REQ-SHELL-2]
218	    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
219	    let _ = crate::shellwake::spawn_wake_host(Arc::new(AtomicBool::new(false)));
220
221	    // Live-agent Psyche host (M11-W0.1, REQ-DAEMON-1): the production caller
222	    // `run_pulse_loop` lacked. One thread sweeps `livehost::reconcile_once` at
223	    // boot (the rehydrate the no-op above can't do) and every tick — re-hosting
224	    // every ONLINE live endpoint from disk (the harness-endpoint mirror of the
225	    // shell waker). Online-only is staged behind the interim `api listen`
226	    // (status-absent perches are skipped — no double-host) until W0.2 makes the
227	    // brain the first-host. The stop flag is held for symmetry; the threads die
228	    // with the brain and the next brain rehydrates from disk (CONTEXT:194).
229	    // [impl->REQ-DAEMON-1]
230	    let _ = crate::livehost::spawn_live_host(Arc::new(AtomicBool::new(false)), reason);
231
232	    let mut consumers_started = false;
233	    loop {
234	        thread::sleep(BRAIN_HEARTBEAT);
235	        // `net-status` is both the net-capability probe and the broker-liveness
236	        // probe: an `Err` is the broker gone (or a future planned
237	        // snapshot+self-exit, Q3) — exit for a supervised respawn.
238	        let status = match brain.net_status() {
239	            Ok(s) => s,
240	            Err(e) => {
241	                eprintln!("BRAIN_BROKER_LOST: {e} — exiting for supervised respawn");
242	                return Err(e);
243	            }
244	        };
245	        if consumer_gate(status.enabled, consumers_started) {
246	            let hex = status.node_id_hex.clone().unwrap_or_default();
247	            spawn_net_consumers(&hex);
248	            consumers_started = true;
249	            eprintln!(
250	                "BRAIN_NET_CONSUMERS_UP: net enabled (node {hex}) — dispatcher + peer pump started"
251	            );
252	        }
253	        write_ready(generation);
254	    }
255	}
256
257	/// Whether to spawn the net consumers this heartbeat: only once the broker
258	/// reports a net endpoint up (`net_enabled`) AND we have not already started
259	/// them. Pure for the unit table — the spawn-exactly-once + net-gating
260	/// semantics that keep a single dispatcher/peer-pump set running.
261	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
262	fn consumer_gate(net_enabled: bool, already_started: bool) -> bool {
263	    net_enabled && !already_started
264	}
265
266	/// Spawn the net consumers — the inbound dispatcher + the outbound peer pump
267	/// (D9-1) — against the broker's socket. **Relocated from the broker process to
268	/// the brain child (restoration D2-1):** both are standalone IPC clients
269	/// (`Brain::cold_start`, no shared `Arc<Broker>`), so they belong with the
270	/// restartable brain. Each mints its own registry + stop flag and connects
271	/// independently; when the brain process exits these in-process threads die with
272	/// it, and the respawned brain re-spawns them from the current binary. Mirrors
273	/// the wiring the broker boot used to do, `node_hex` now sourced from the
274	/// `net-status` reply rather than the broker's identity load.
275	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
276	// [impl->REQ-DAEMON-9]
277	fn spawn_net_consumers(node_hex: &str) {
278	    let registry = Arc::new(crate::registryhost::RegistryHost::new(node_hex));
279	    let stop = Arc::new(AtomicBool::new(false));
280	    let _ = crate::dispatch::spawn_dispatcher(
281	        broker_socket_name(),
282	        Arc::clone(&registry),
283	        Arc::clone(&stop),
284	    );
285	    let cfg = crate::config::DaemonConfig::load();
286	    if cfg.peer_pump {
287	        let _ = crate::pump::spawn_peer_pump(broker_socket_name(), registry, cfg, stop);
288	    }
289	}
290
291	/// Connect to the broker, retrying briefly while it finishes binding.
292	fn connect_retry(name: &str, tries: u32) -> io::Result<Brain> {
293	    let mut last: Option<io::Error> = None;
294	    for _ in 0..tries {
295	        match Brain::cold_start(name, now_ms()) {
296	            Ok(b) => return Ok(b),
297	            Err(e) => {
298	                last = Some(e);
299	                thread::sleep(Duration::from_millis(10));
300	            }
301	        }
302	    }
303	    Err(last.unwrap_or_else(|| io::Error::other("brain could not connect to broker")))
304	}
305
306	/// Write the readiness breadcrumb (best-effort), restoration D6-1b + D7-1: a
307	/// self-describing JSON `{pid, generation, exe_hash}`. A supervisor/test reads
308	/// the `pid` to confirm the brain is up and (on a change) that it respawned; the
309	/// `generation` is the **readiness gate's** key (ADR-0018 Q7, D6-2) — the
310	/// supervisor that spawned generation N promotes only on a ready stamp **== N**,
311	/// so a stale stamp from a prior generation cannot falsely promote a brain that
312	/// never booted. **`exe_hash` (D7-1)** is the lowercase-hex SHA-256 of *this
313	/// brain's own executable* — the "which bytes are resident" diagnostic: it proves
314	/// a respawn landed on the **swapped** binary (the D7-1 survival E2E asserts it
315	/// flips across a swap; the D7-4 field run asserts it matches the released
316	/// artifact's hash — the fact the v0.3.2/enlyzeam incident lacked). It is an
317	/// **additive / N-1-readable** field (an N-1 brain ignores it; absence parses to
318	/// `None`) registered in the D6-3 [`crate::rollback_compat::PRE_READY_DURABLE_FILES`]
319	/// tripwire. Liveness of the *daemon* stays the seed-control ping, never this file.
320	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
321	fn write_ready(generation: u64) {
322	    let mut obj = serde_json::json!({ "pid": std::process::id(), "generation": generation });
323	    // Best-effort: omit `exe_hash` if the self-read fails (additive — a reader
324	    // tolerates its absence). Never fail the ready write over a diagnostic field.
325	    if let Some(hash) = current_exe_hash() {
326	        obj["exe_hash"] = serde_json::Value::String(hash);
327	    }
328	    let _ = spt_store::atomic::atomic_write_string(&brain_ready_path(), &obj.to_string());
329	}
330
331	/// Lowercase-hex SHA-256 of the running process's own executable file
332	/// (best-effort; `None` if `current_exe` or the file read fails — degrades safe,
333	/// the breadcrumb simply omits the field). Restoration D7-1: the `exe_hash`
334	/// breadcrumb that proves *which bytes* a brain generation is running. One exe
335	/// read + hash per brain start — negligible, and it pays rent far beyond the
336	/// D7-1 test (the resident-bytes diagnostic the enlyzeam regression lacked).
337	fn current_exe_hash() -> Option<String> {
338	    let exe = std::env::current_exe().ok()?;
339	    let bytes = std::fs::read(exe).ok()?;
340	    Some(crate::release::sha256_hex(&bytes))
341	}
342
343	/// Parse the `generation` stamp out of a `brain.ready` body — pure, so the
344	/// staleness gate (D6-2) is unit-tested with explicit values, no wall-clock or
345	/// real process. `None` for an absent/garbled/legacy-bare-pid body (the gate
346	/// reads that as not-ready — fail-safe). Restoration D6-1b.
347	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
348	pub fn parse_ready_generation(body: &str) -> Option<u64> {
349	    serde_json::from_str::<serde_json::Value>(body)
350	        .ok()?
351	        .get("generation")?
352	        .as_u64()
353	}
354
355	/// Parse the `exe_hash` breadcrumb out of a `brain.ready` body — pure (D7-1).
356	/// `None` for an absent field / garbled body (a legacy N-1 stamp carries none, so
357	/// `None` is the N-1-readable answer, not an error). The D7-1 survival E2E reads
358	/// this to prove a respawn ran the swapped binary's bytes.
359	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
360	pub fn parse_ready_exe_hash(body: &str) -> Option<String> {
361	    serde_json::from_str::<serde_json::Value>(body)
362	        .ok()?
363	        .get("exe_hash")?
364	        .as_str()
365	        .map(str::to_string)
366	}
367
368	/// Read the current `brain.ready` generation stamp from `path`, `None` if the
369	/// file is absent or its body has no parseable generation (fail-safe to
370	/// not-ready). The supervisor's "is generation N ready?" gate (D6-2) reads
371	/// [`brain_ready_path`]; the path is a parameter here so the unit harness points
372	/// it at a temp file. Restoration D6-1b.
373	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
374	pub fn ready_generation_at(path: &std::path::Path) -> Option<u64> {
375	    parse_ready_generation(&std::fs::read_to_string(path).ok()?)
376	}
377
378	/// The supervisor's next backoff after a brain death: a healthy run (≥
379	/// [`SUPERVISE_HEALTHY_RUN`]) resets to the floor `base`; a fast death doubles,
380	/// capped at [`SUPERVISE_BACKOFF_CAP`]. Pure for the unit table (parallels
381	/// `pump::next_backoff`).
382	fn next_backoff(prev: Duration, ran_for: Duration, base: Duration) -> Duration {
383	    if ran_for >= SUPERVISE_HEALTHY_RUN {
384	        base
385	    } else {
386	        prev.saturating_mul(2).min(SUPERVISE_BACKOFF_CAP)
387	    }
388	}
389
390	/// The supervisor's rollback context (ADR-0018 Q7, restoration D6-2): the
391	/// durable two-phase applied record, the generation-stamped readiness probe,
392	/// and the promote/rollback effects — all **injected** (exactly as `spawn_child`
393	/// is) so the unit harness drives the readiness-gated state machine
394	/// deterministically with a scripted ready/no-ready + a capturing notif sink,
395	/// while production binds the real `spt_home` + relcache + notif sender
396	/// ([`ProductionTrialEnv`]). Every method is **best-effort / fail-safe**: a
397	/// missing record reads as "no pending trial", an absent ready file as
398	/// "not ready" — the broker's supervisor thread never panics (a panic there
399	/// orphans the brain).
400	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
401	pub trait TrialEnv {
402	    /// The current two-phase applied record, or `None` for no pending trial
403	    /// (absent/corrupt degrades to `None`).
404	    fn applied_state(&self) -> Option<AppliedRecord>;
405	    /// The generation stamped in `brain.ready`, or `None` if absent/garbled
406	    /// (the readiness gate reads `None` as not-ready — fail-safe).
407	    fn ready_generation(&self) -> Option<u64>;
408	    /// Delete `brain.ready` immediately before a trial spawn (**A9** — close the
409	    /// cross-lifetime staleness hole: the in-memory generation resets to 0 on a
410	    /// broker restart, so a stale gen-0 file would otherwise match a fresh gen-0
411	    /// trial and falsely promote a brain that never booted).
412	    fn clear_ready(&self);
413	    /// Promote the candidate: it reached ready. Persist `Applied{version}` plus
414	    /// the legacy `applied.json`/`last-outcome=applied` convergence records
415	    /// (relocated here from `apply`, where they were the optimistic enlyzeam
416	    /// write — promotion is the *real* moment the new code is accepted).
417	    fn record_promoted(&self, version: u64);
418	    /// Correct the record: the candidate failed readiness. Persist
419	    /// `RolledBack{…}` and fire the loud, resurfacing rollback notif.
420	    fn record_rolled_back(&self, quarantine_version: u64, running_version: u64, rollback_binary: &str);
421	    /// The `exe_hash` the candidate stamped in `brain.ready` (the bytes it is
422	    /// actually running), or `None` if absent/garbled — degrades the promotion
423	    /// bytes-gate to readiness-only (KH 6.11, N-1-safe).
424	    fn ready_exe_hash(&self) -> Option<String>;
425	    /// The staged artifact's SHA-256 for THIS platform at `version` (the bytes
426	    /// the candidate SHOULD be running), or `None` if no staged metadata /
427	    /// version mismatch / platform absent — degrades the gate to readiness-only.
428	    fn staged_artifact_hash(&self, version: u64) -> Option<String>;
429	}
430
431	/// The no-trial environment: every probe degrades to "nothing pending / not
432	/// ready" and every effect is a no-op. Used where there is no readiness gate to
433	/// drive — the supervisor then behaves exactly as the pre-D6 respawn loop.
434	pub struct NoTrialEnv;
435
436	impl TrialEnv for NoTrialEnv {
437	    fn applied_state(&self) -> Option<AppliedRecord> {
438	        None
439	    }
440	    fn ready_generation(&self) -> Option<u64> {
441	        None
442	    }
443	    fn clear_ready(&self) {}
444	    fn record_promoted(&self, _version: u64) {}
445	    fn record_rolled_back(&self, _q: u64, _r: u64, _bin: &str) {}
446	    fn ready_exe_hash(&self) -> Option<String> {
447	        None
448	    }
449	    fn staged_artifact_hash(&self, _version: u64) -> Option<String> {
450	        None
451	    }
452	}
453
454	/// Production [`TrialEnv`]: reads/writes the canonical `spt_home` durable files
455	/// (the `releases` relcache + the `brain.ready` breadcrumb) and fires the
456	/// rollback notif through the real producer front door. Constructed in the
457	/// broker process where `spt_home()` is stable.
458	pub struct ProductionTrialEnv;
459
460	impl ProductionTrialEnv {
461	    fn cache(&self) -> ReleaseCache {
462	        ReleaseCache::open(&spt_store::perch::spt_home().join("releases"))
463	    }
464	}
465
466	impl TrialEnv for ProductionTrialEnv {
467	    fn applied_state(&self) -> Option<AppliedRecord> {
468	        self.cache().applied_state()
469	    }
470	    fn ready_generation(&self) -> Option<u64> {
471	        ready_generation_at(&brain_ready_path())
472	    }
473	    fn clear_ready(&self) {
474	        let _ = std::fs::remove_file(brain_ready_path());
475	    }
476	    fn record_promoted(&self, version: u64) {
477	        let cache = self.cache();
478	        let _ = cache.record_applied_state(&AppliedRecord::Applied { version });
479	        // The legacy convergence records (M8 `xtask debug-converge`) are written
480	        // HERE — at the real promotion — not optimistically at swap (D6-1).
481	        let _ = cache.record_applied(version);
482	        let _ = cache.record_last_outcome("applied");
483	    }
484	    fn record_rolled_back(&self, quarantine_version: u64, running_version: u64, rollback_binary: &str) {
485	        let cache = self.cache();
486	        let _ = cache.record_applied_state(&AppliedRecord::RolledBack {
487	            quarantine_version,
488	            running_version,
489	            rollback_binary: rollback_binary.to_string(),
490	        });
491	        let _ = cache.record_last_outcome(&format!("rolled-back:{quarantine_version}"));
492	        fire_rollback_notif(quarantine_version, running_version);
493	    }
494	    // [impl->REQ-HAZARD-BRAIN-RESPAWN-PATH]
495	    fn ready_exe_hash(&self) -> Option<String> {
496	        let body = std::fs::read_to_string(brain_ready_path()).ok()?;
497	        parse_ready_exe_hash(&body)
498	    }
499	    // [impl->REQ-HAZARD-BRAIN-RESPAWN-PATH]
500	    fn staged_artifact_hash(&self, version: u64) -> Option<String> {
501	        self.cache().staged_artifact_hash(version)
502	    }
503	}
504
505	/// Fire the loud, resurfacing auto-rollback notif on every member subnet
506	/// (restoration D6-2). Best-effort: a notif failure must never derail the
507	/// supervisor (the rollback already happened — the daemon is healthy on the old
508	/// binary). Reuses the one producer front door so the row rides first-fire AND
509	/// boundary-resurface (an undismissed rollback row re-surfaces until the
510	/// operator dismisses it — "loud, not fire-once").
511	///
512	/// **Called only with the brain DEAD.** Both rollback paths reap the candidate
513	/// before `record_rolled_back` (PreReadyExit = already exited; WindowElapsedAlive
514	/// = the A11 kill+wait precedes `rollback`), so no brain-side pump can be
515	/// advertising and the [`spt_store::epoch::EpochSource`] single-writer assumption
516	/// (epoch.rs) holds at this `EpochSource::load`. **Do NOT fire broker-side notifs
517	/// while a brain is live:** concurrent `EpochSource` read-modify-write across the
518	/// two processes mints duplicate epochs (KH 4.8 lost-update). The forward fix for
519	/// any future broker-side live-notif is cross-process serialization (a file
520	/// lock), not relaxing this rule.
521	fn fire_rollback_notif(quarantine_version: u64, running_version: u64) {
522	    let Ok(store) = spt_store::notif::NotifStore::open() else {
523	        eprintln!("ROLLBACK_NOTIF_DROP: notif store unavailable");
524	        return;
525	    };
526	    let mut epochs = spt_store::epoch::EpochSource::load();
527	    let policy = crate::notif::NotifSurfacePolicy::load();
528	    let node_hex = crate::presence::local_node_hex();
529	    let owlery = spt_store::perch::owlery_dir();
530	    for (subnet, _) in &policy.subnets {
531	        if let Err(e) = crate::notif::produce_rollback_notif(
532	            quarantine_version,
533	            running_version,
534	            &store,
535	            &node_hex,
536	            &mut epochs,
537	            subnet,
538	            &policy,
539	            &owlery,
540	            now_ms(),
541	        ) {
542	            eprintln!("ROLLBACK_NOTIF_FAIL:{subnet}:{e}");
543	        }
544	    }
545	}
546
547	/// Sleep out a capped backoff in `TICK` slices so a `stop` request lands
548	/// promptly mid-backoff (saturating — never an underflow panic).
549	fn sleep_backoff(backoff: Duration, stop: &AtomicBool) {
550	    let mut slept = Duration::ZERO;
551	    while slept < backoff && !stop.load(Ordering::Relaxed) {
552	        thread::sleep(TICK.min(backoff.saturating_sub(slept)));
553	        slept += TICK;
554	    }
555	}
556
557	/// The verdict of one trial-candidate's readiness watch (D6-2). The latch is
558	/// the unpromoted `AppliedPending` record (**A8**), not the start reason — so a
559	/// pre-ready crash respawn (`reason=Crash`) and a reboot-mid-trial cold spawn
560	/// (`reason=Cold`) are *both* trials.
561	enum TrialStep {
562	    /// The candidate stamped `brain.ready` for THIS generation within the
563	    /// window — the swap is accepted. The same child stays alive and falls
564	    /// through to normal supervision (a later crash is a normal respawn).
565	    Promoted,
566	    /// The candidate **exited before** signaling ready (a failed boot) — counts
567	    /// toward the [`TRIAL_PREREADY_BUDGET`]. The child is already reaped.
568	    PreReadyExit,
569	    /// The window elapsed with the child **alive but never ready** ("up but
570	    /// useless"). The caller MUST kill it before spawning the rollback binary
571	    /// (**A11** — never two live brains).
572	    WindowElapsedAlive,
573	    /// `stop` was raised during the trial — the caller kills + returns.
574	    Stopped,
575	}
576
577	/// Watch a freshly-spawned trial candidate for readiness within `window`
578	/// (ADR-0018 Q7, D6-2). Polls in `TICK` slices (so a daemon stop stays prompt,
579	/// A11/note-ii) for a `brain.ready` stamp **== this spawn's `generation`** — a
580	/// stale stamp from another generation never satisfies the gate (the single
581	/// load-bearing correctness invariant; the A9 clear-before-spawn closes the
582	/// cross-lifetime exact-generation hole). Returns the moment readiness is seen,
583	/// the child exits pre-ready, the window elapses, or `stop` is raised.
584	fn run_trial(
585	    child: &mut Child,
586	    generation: u64,
587	    window: Duration,
588	    env: &dyn TrialEnv,
589	    stop: &AtomicBool,
590	) -> TrialStep {
591	    let deadline = Instant::now() + window;
592	    loop {
593	        if stop.load(Ordering::Relaxed) {
594	            return TrialStep::Stopped;
595	        }
596	        // Readiness FIRST: a candidate that stamped ready then exited is still
597	        // promoted (it reached ready — a later exit is a normal respawn).
598	        if env.ready_generation() == Some(generation) {
599	            return TrialStep::Promoted;
600	        }
601	        match child.try_wait() {
602	            Ok(Some(_)) => return TrialStep::PreReadyExit,
603	            Ok(None) => {}
604	            // A wait error: treat as a pre-ready exit (fail toward retry, never
605	            // a panic in the supervisor thread).
606	            Err(e) => {
607	                eprintln!("BRAIN_TRIAL_WAIT_FAIL: {e}");
608	                return TrialStep::PreReadyExit;
609	            }
610	        }
611	        if Instant::now() >= deadline {
612	            return TrialStep::WindowElapsedAlive;
613	        }
614	        thread::sleep(TICK);
615	    }
616	}
617
618	/// Supervise the brain child: spawn it, wait for it, and respawn on exit after a
619	/// capped, doubling backoff — until `stop` is raised, which kills the live child
620	/// and returns. The process-level sibling of [`crate::pump::supervise_pump`]
621	/// (ADR-0018 Q2: the broker is the always-up anchor that respawns the brain;
622	/// Q3: a planned update is just the brain self-exiting on a path the supervisor
623	/// already recovers from).
624	///
625	/// **Readiness-gated promotion + auto-rollback (ADR-0018 Q7, D6-2).** When the
626	/// injected `env` reports an unpromoted `AppliedPending` record, the spawn is a
627	/// **trial** (the latch is the *record*, **A8** — regardless of start reason):
628	/// the supervisor clears `brain.ready` (**A9**), spawns the candidate, and waits
629	/// for it to stamp ready for ITS generation within `window`. It **promotes** on
630	/// ready (writes `Applied`, the candidate is now the accepted binary and a later
631	/// crash is a normal respawn); it **rolls back** to the last-known-good binary
632	/// on budget exhaustion — [`TRIAL_PREREADY_BUDGET`] consecutive pre-ready exits
633	/// **or** one alive-but-never-ready window-expiry (which **kills** the candidate
634	/// first, **A11** — never two live brains). Binary selection is **record-driven**
635	/// (a `RolledBack` record spawns the good `.old-N` binary, surviving a reboot for
636	/// free), so no file is renamed at the failure instant. Every hosted endpoint is
637	/// untouched throughout — the broker holds them across the whole cycle.
638	///
639	/// Generic over `spawn_child` (which receives the **selected binary** — `None` =
640	/// the default `current_exe`, `Some(path)` = the rollback binary) and `env`, so
641	/// the unit harness injects fakes; production binds [`spawn_brain_child`] +
642	/// [`ProductionTrialEnv`].
643	// [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
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,
714	                            };
715	                            if let Some(version) = version {
716	                                if let BytesGate::Mismatch = bytes_gate(env, version) {
717	                                    eprintln!("BRAIN_PROMOTE_REJECTED: candidate v{version} ready but running the WRONG bytes (generation {generation}) — rolling back (KH 6.11)");
718	                                    let _ = child.kill();
719	                                    let _ = child.wait();
720	                                    rollback(env, &record);
721	                                    pre_ready_exits = 0;
722	                                    generation += 1;
723	                                    reason = StartReason::Crash;
724	                                    backoff = base;
725	                                    continue;
726	                                }
727	                                eprintln!("BRAIN_PROMOTED: candidate v{version} signalled ready (generation {generation}) — swap accepted");
728	                                env.record_promoted(version);
729	                            }
730	                            pre_ready_exits = 0;
731	                            backoff = base;
732	                            supervise = true; // keep supervising the now-accepted child
733	                        }
734	                        TrialStep::PreReadyExit => {
735	                            pre_ready_exits += 1;
736	                            eprintln!("BRAIN_TRIAL_EXIT: candidate exited pre-ready ({pre_ready_exits}/{TRIAL_PREREADY_BUDGET})");
737	                            if pre_ready_exits >= TRIAL_PREREADY_BUDGET {
738	                                rollback(env, &record);
739	                                pre_ready_exits = 0;
740	                                // The rollback binary is known-good — respawn it
741	                                // promptly, no crash-penalty backoff (mirror the
742	                                // WindowElapsedAlive path); only the under-budget
743	                                // candidate retries keep the doubling backoff.
744	                                backoff = base;
745	                            } else {
746	                                backoff = next_backoff(backoff, started.elapsed(), base);
747	                            }
748	                            generation += 1;
749	                            reason = StartReason::Crash;
750	                            sleep_backoff(backoff, stop);
751	                            continue;
752	                        }
753	                        TrialStep::WindowElapsedAlive => {
754	                            // A11: KILL the alive-but-never-ready candidate BEFORE
755	                            // spawning the rollback binary — two live brains would
756	                            // break every single-brain seed/socket/IPC assumption.
757	                            eprintln!("BRAIN_TRIAL_TIMEOUT: candidate alive but never ready — killing before rollback");
758	                            let _ = child.kill();
759	                            let _ = child.wait();
760	                            rollback(env, &record);
761	                            pre_ready_exits = 0;
762	                            generation += 1;
763	                            // The rollback binary is known-good — respawn promptly,
764	                            // no crash-penalty backoff.
765	                            reason = StartReason::Crash;
766	                            backoff = base;
767	                            continue;
768	                        }
769	                    }
770	                }
771	                if supervise {
772	                    loop {
773	                        if stop.load(Ordering::Relaxed) {
774	                            let _ = child.kill();
775	                            let _ = child.wait();
776	                            return;
777	                        }
778	                        // A planned brain-restart request (apply swapped the
779	                        // binary, ADR-0018 D3-3): drop this brain so the respawn
780	                        // lands on the new executable. A deliberate kill, NOT a
781	                        // crash — stamp it `update`.
782	                        if restart.take() {
783	                            eprintln!("BRAIN_UPDATE_RESTART: planned restart — cycling brain onto the swapped binary");
784	                            let _ = child.kill();
785	                            let _ = child.wait();
786	                            planned = true;
787	                            break;
788	                        }
789	                        match child.try_wait() {
790	                            Ok(Some(status)) => {
791	                                eprintln!("BRAIN_EXIT: brain child exited ({status}) — respawning");
792	                                break;
793	                            }
794	                            Ok(None) => thread::sleep(TICK),
795	                            Err(e) => {
796	                                eprintln!("BRAIN_WAIT_FAIL: {e}");
797	                                break;
798	                            }
799	                        }
800	                    }
801	                }
802	            }
803	            Err(e) => eprintln!("BRAIN_SPAWN_FAIL: {e}"),
804	        }
805	        if stop.load(Ordering::Relaxed) {
806	            return;
807	        }
808	        // Next spawn: advance the generation (every start, KH-2.4). A planned
809	        // update respawns promptly with `start-reason=update` (it is not a crash,
810	        // so the crash backoff does not apply); a crash recovery keeps the capped
811	        // doubling backoff and stamps `crash`.
812	        generation += 1;
813	        if planned {
814	            reason = StartReason::Update;
815	            backoff = base;
816	            eprintln!("BRAIN_RESTART: planned update respawn (generation {generation})");
817	            continue;
818	        }
819	        reason = StartReason::Crash;
820	        backoff = next_backoff(backoff, started.elapsed(), base);
821	        eprintln!("BRAIN_RESTART: supervised respawn in {}s", backoff.as_secs());
822	        sleep_backoff(backoff, stop);
823	    }
824	}
825
826	/// Effect the rollback for an exhausted trial (D6-2): from the candidate's
827	/// `AppliedPending` record, write `RolledBack{quarantine=N, running, rollback_binary}`
828	/// and fire the loud notif (via `env`). `running` is the candidate's
829	/// `prior_version` (the version actually running before the swap, captured by
830	/// `apply`) — NOT `N-1`, which is wrong on a skip-version update (applied v5 →
831	/// candidate v8: the `.old-8` aside holds v5 bytes, so `running` is v5, not v7).
832	/// It degrades to `N-1` only for a pre-D6-2b record that lacks `prior_version`.
833	/// A record that is not `AppliedPending` (already rolled back, or raced away) is
834	/// a no-op — never a double rollback.
835	///
836	/// **Rollback is SELECTION, not an update (open-call-4).** The recovery never
837	/// routes through the update engine / `apply` / `verify_metadata`: it just makes
838	/// the supervisor *spawn the `.old-N` binary*. So the anti-downgrade monotonic
839	/// version floor (`REQ-HAZARD-UPDATE-ROLLBACK` — *attack* protection that refuses
840	/// a version *downgrade*) **never sees** this recovery, and the two concerns stay
841	/// cleanly separate. Do NOT "fix" `verify_metadata` to permit recovery
842	/// downgrades — selection-not-apply is the mechanism, by design.
843	fn rollback(env: &dyn TrialEnv, record: &Option<AppliedRecord>) {
844	    if let Some(AppliedRecord::AppliedPending {
845	        version,
846	        rollback_binary,
847	        prior_version,
848	        ..
849	    }) = record
850	    {
851	        let running = prior_version.unwrap_or(version.saturating_sub(1));
852	        eprintln!("BRAIN_ROLLBACK: update v{version} failed readiness — rolling back to v{running} ({rollback_binary}), quarantining v{version}");
853	        env.record_rolled_back(*version, running, rollback_binary);
854	    }
855	}
856
857	/// Promotion bytes-gate verdict (KH 6.11, `REQ-HAZARD-BRAIN-RESPAWN-PATH`).
858	enum BytesGate {
859	    /// Candidate is running the staged bytes — promote.
860	    Match,
861	    /// Candidate is ready but running the WRONG bytes — fail the trial, roll back.
862	    Mismatch,
863	    /// Either hash absent (pre-metadata release / missing breadcrumb) — degrade
864	    /// to readiness-only promotion (N-1-safe).
865	    Unverified,
866	}
867
868	/// Compare the just-ready candidate's stamped `exe_hash` against the staged
869	/// artifact hash for `version` on this platform (KH 6.11). Readiness alone is
870	/// NOT proof the new bytes run — on Linux a brain respawned onto the renamed
871	/// old binary still signals ready (the v0.4.1 kitsubito false-success). On
872	/// either hash absent the gate degrades to readiness-only but emits
873	/// `PROMOTE_BYTES_UNVERIFIED` so a disarmed gate stays field-diagnosable.
874	// [impl->REQ-HAZARD-BRAIN-RESPAWN-PATH]
875	fn bytes_gate(env: &dyn TrialEnv, version: u64) -> BytesGate {
876	    match (env.staged_artifact_hash(version), env.ready_exe_hash()) {
877	        (Some(expected), Some(actual)) if expected == actual => BytesGate::Match,
878	        (Some(_), Some(_)) => BytesGate::Mismatch,
879	        (expected, _) => {
880	            let which = if expected.is_none() {
881	                "artifact-hash-absent"
882	            } else {
883	                "ready-hash-absent"
884	            };
885	            eprintln!("PROMOTE_BYTES_UNVERIFIED:{version}:{which}");
886	            BytesGate::Unverified
887	        }
888	    }
889	}
890
891	/// Spawn the production brain child: re-exec `daemon brain` as a plain
892	/// (non-detached) managed child, so it inherits the broker's environment —
893	/// including `SPT_HOME` — and dies with no orphaning. NOT `spawn_detached`
894	/// (that path rebuilds the Windows env block and would drop `SPT_HOME`). The
895	/// broker stamps `{generation, start-reason}` onto the argv (ADR-0018 V2).
896	///
897	/// `binary` is the **record-driven selection** (D6-2): `None` spawns the
898	/// accepted / candidate binary (`canonical`, below), `Some(path)` spawns the
899	/// rollback binary (the `.old-N` last-known-good) — the supervisor chooses, no
900	/// file is renamed at the failure instant.
901	///
902	/// `canonical` is the canonical exe path captured ONCE at broker start
903	/// (`spawn_brain_supervisor`). The `None` default uses it — **never** a
904	/// per-spawn `std::env::current_exe()` — because on Linux `current_exe()`
905	/// (`readlink /proc/self/exe`) is inode-tracking and follows the `apply` rename
906	/// (`spt` → `spt.old-N`), which would respawn the brain onto the OLD bytes
907	/// (KH 6.11). Falls back to `current_exe()` only if the t=0 capture failed
908	/// (degrade-safe — restores pre-fix behavior rather than failing to spawn).
909	/// Resolve which binary the brain spawns (KH 6.11). `Some(rollback)` wins (the
910	/// record-driven `.old-N` selection); else the `canonical` path captured at
911	/// broker start; else — only if that capture failed — degrade to a per-spawn
912	/// `current_exe()`. The point of the fix: NEVER a per-spawn `current_exe()`
913	/// when a canonical path is available, because on Linux `current_exe()` follows
914	/// the `apply` rename to the old binary.
915	// [impl->REQ-HAZARD-BRAIN-RESPAWN-PATH]
916	fn select_brain_exe(binary: Option<&Path>, canonical: Option<&Path>) -> io::Result<PathBuf> {
917	    match binary {
918	        Some(p) => Ok(p.to_path_buf()),
919	        None => match canonical {
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)]
1010	        {
1011	            Command::new("cmd").args(["/C", "rem"]).spawn()
1012	        }
1013	        #[cfg(unix)]
1014	        {
1015	            Command::new("true").spawn()
1016	        }
1017	    }
1018
1019	    /// A long-lived real child the supervisor must KILL to end its cycle — used
1020	    /// by the planned-restart test so the restart signal (not a child exit) is
1021	    /// what cycles the brain. Killed by the supervisor; never runs to completion.
1022	    fn long_child() -> io::Result<Child> {
1023	        #[cfg(windows)]
1024	        {
1025	            // ping -n 30 loopback ≈ 29 s of blocking; the supervisor kills it.
1026	            Command::new("cmd")
1027	                .args(["/C", "ping", "-n", "30", "127.0.0.1"])
1028	                .stdout(std::process::Stdio::null())
1029	                .spawn()
1030	        }
1031	        #[cfg(unix)]
1032	        {
1033	            Command::new("sleep").arg("30").spawn()
1034	        }
1035	    }
1036
1037	    /// The supervisor respawns the brain on every exit and stops cleanly when
1038	    /// `stop` is raised (ADR-0018 Q2 — the broker is the always-up anchor that
1039	    /// keeps the brain alive). The injected spawner raises `stop` once it has
1040	    /// been asked to spawn three times, proving respawn-on-death + clean halt.
1041	    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
1042	    #[test]
1043	    fn supervisor_respawns_brain_until_stopped() {
1044	        let stop = Arc::new(AtomicBool::new(false));
1045	        let count = Arc::new(AtomicU32::new(0));
1046	        let stop_c = Arc::clone(&stop);
1047	        let count_c = Arc::clone(&count);
1048	        // Tiny base backoff so the test is quick; real production uses seconds.
1049	        let restart = BrainRestart::new();
1050	        supervise_brain(
1051	            &stop,
1052	            &restart,
1053	            Duration::from_millis(5),
1054	            &NoTrialEnv,
1055	            Duration::from_secs(30),
1056	            move |_gen, _reason, _binary| {
1057	                let n = count_c.fetch_add(1, Ordering::Relaxed) + 1;
1058	                if n >= 3 {
1059	                    stop_c.store(true, Ordering::Relaxed);
1060	                }
1061	                fast_child()
1062	            },
1063	        );
1064	        assert!(
1065	            count.load(Ordering::Relaxed) >= 3,
1066	            "supervisor must respawn the brain after each exit"
1067	        );
1068	    }
1069
1070	    /// The planned-restart path (D3-3, Q3): a `BrainRestart::request` (the
1071	    /// broker's `KIND_BRAIN_RESTART` handler) makes the supervisor cycle the
1072	    /// brain and stamp the next spawn `Update` — distinguishing an intentional
1073	    /// update cycle from a crash (V2, broker-authoritative). A long-lived child
1074	    /// is used so the restart, not an exit, is what ends the cycle.
1075	    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
1076	    // [unit->REQ-UPD-3]
1077	    #[test]
1078	    fn planned_restart_respawns_with_update_reason() {
1079	        let stop = Arc::new(AtomicBool::new(false));
1080	        let restart = Arc::new(BrainRestart::new());
1081	        let stop_c = Arc::clone(&stop);
1082	        let restart_c = Arc::clone(&restart);
1083	        let seen = Arc::new(std::sync::Mutex::new(Vec::<(u64, StartReason)>::new()));
1084	        let seen_c = Arc::clone(&seen);
1085	        supervise_brain(
1086	            &stop,
1087	            &restart,
1088	            Duration::from_millis(5),
1089	            &NoTrialEnv,
1090	            Duration::from_secs(30),
1091	            move |generation, reason, _binary| {
1092	                seen_c.lock().unwrap().push((generation, reason));
1093	                // After the first (cold) spawn, request a planned restart; after
1094	                // the second (which must be the update respawn), stop.
1095	                if generation == 0 {
1096	                    restart_c.request();
1097	                } else {
1098	                    stop_c.store(true, Ordering::Relaxed);
1099	                }
1100	                long_child()
1101	            },
1102	        );
1103	        let v = seen.lock().unwrap();
1104	        assert_eq!(
1105	            v.as_slice(),
1106	            &[(0, StartReason::Cold), (1, StartReason::Update)],
1107	            "a planned restart respawns generation+1 with reason Update, not Crash"
1108	        );
1109	    }
1110
1111	    /// Broker-owned generation custody (D3-2, ADR-0018 V2 / KH-2.4): the
1112	    /// supervisor increments the generation on EVERY spawn and stamps the
1113	    /// start-reason — the first spawn `Cold`, every supervised respawn `Crash`.
1114	    /// The injected spawner records each `(generation, reason)` the broker would
1115	    /// hand the brain, proving the counter is broker-owned and monotonic and the
1116	    /// crash-vs-cold discriminator is correct (the `Update` reason is D3-3's).
1117	    // [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
1118	    #[test]
1119	    fn supervisor_owns_generation_and_stamps_start_reason() {
1120	        let stop = Arc::new(AtomicBool::new(false));
1121	        let stop_c = Arc::clone(&stop);
1122	        let seen = Arc::new(std::sync::Mutex::new(Vec::<(u64, StartReason)>::new()));
1123	        let seen_c = Arc::clone(&seen);
1124	        let restart = BrainRestart::new();
1125	        supervise_brain(
1126	            &stop,
1127	            &restart,
1128	            Duration::from_millis(5),
1129	            &NoTrialEnv,
1130	            Duration::from_secs(30),
1131	            move |generation, reason, _binary| {
1132	                let mut v = seen_c.lock().unwrap();
1133	                v.push((generation, reason));
1134	                if v.len() >= 3 {
1135	                    stop_c.store(true, Ordering::Relaxed);
1136	                }
1137	                fast_child()
1138	            },
1139	        );


1	//! The brain's live-agent Psyche host (M11-W0.1, REQ-DAEMON-1) — re-host the
2	//! daemon-hosted LiveAgent lifecycle from disk and reconcile the host-set.
3	//!
4	//! This is the production caller `run_pulse_loop` lacked: the brain instantiates
5	//! a [`BrainLifecycle`] per hosted **live** endpoint and drives its config-paced
6	//! pulse loop on a supervised thread. It is the **harness-endpoint mirror** of
7	//! [`crate::shellwake::reconcile_once`] (which re-hosts offline shells from
8	//! disk): same shape — enumerate Self perches, resolve each manifest from its
9	//! `info.json` `adapter` option, hold a stop flag per supervised thread, and run
10	//! one bounded reconcile sweep at boot and every tick. Where the shell waker
11	//! hosts **offline** shells, this hosts **online** live endpoints — `status` is
12	//! daemon-authoritative liveness (CONTEXT:194 / KH 2.5), so re-hosting the
13	//! online ones re-hosts exactly what was live before a brain (re)start, and
14	//! never resurrects a gracefully-departed (offline) agent.
15	//!
16	//! **Online-only is staged behind the interim.** The interim `spt api listen`
17	//! process still first-hosts its live agents today; those perches are
18	//! `status`-ABSENT (not "online"), so this scan ignores them — no double-host in
19	//! the W0.1→W0.2 window. W0.2 retires the interim and makes the brain the
20	//! first-host (it sets `status=online` on a live `listen`/`bind`); from then this
21	//! reconcile reacts within one tick, the same way the shell waker reacts to a
22	//! CLI-process status flip. The full bind→online→brain-restart→re-host arc is
23	//! proven through the real brain process by the W0.4 production int.
24	// [impl->REQ-DAEMON-1]
25
26	use std::collections::HashMap;
27	use std::path::Path;
28	use std::sync::atomic::{AtomicBool, Ordering};
29	use std::sync::{Arc, Mutex};
30	use std::thread::JoinHandle;
31	use std::time::Duration;
32
33	use spt_runtime::registry::AdapterRecord;
34	use spt_runtime::Manifest;
35	use spt_store::liveness::STATUS_ONLINE;
36	use spt_store::perch::{self, ParentHint};
37
38	use crate::brainproc::StartReason;
39	use crate::config::DaemonConfig;
40	use crate::lifecycle::BrainLifecycle;
41
42	/// The `InfoJson::state` tag of a live agent — only these get a daemon-hosted
43	/// Psyche; a `ready_agent` (messaging-only) never does.
44	const LIVE_AGENT_STATE: &str = "live_agent";
45
46	/// The reconcile cadence (ms): a lifecycle flip lands in a CLI process, so the
47	/// daemon loop is the invariant holder — a flip is at most one tick stale
48	/// (mirrors [`crate::shellwake::RECONCILE_INTERVAL_MS`]).
49	pub const LIVE_RECONCILE_INTERVAL_MS: u64 = 5_000;
50
51	/// One brain-hosted live lifecycle: its config-paced pulse-driver thread plus the
52	/// held stop flag (tripped to un-host on an offline-transition).
53	struct HostedLife {
54	    stop: Arc<AtomicBool>,
55	    thread: JoinHandle<()>,
56	}
57
58	/// The brain's hosted live-endpoint table, keyed by endpoint id. Shared between
59	/// the reconcile loop and anything that must stop a host. Mirror of
60	/// [`crate::shellwake::WakeSet`].
61	#[derive(Default)]
62	pub struct LiveSet {
63	    inner: Mutex<HashMap<String, HostedLife>>,
64	}
65
66	impl LiveSet {
67	    pub fn new() -> Self {
68	        Self::default()
69	    }
70
71	    /// How many lifecycles are hosted (finished driver threads pruned first).
72	    pub fn len(&self) -> usize {
73	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
74	        map.retain(|_, h| !h.thread.is_finished());
75	        map.len()
76	    }
77
78	    pub fn is_empty(&self) -> bool {
79	        self.len() == 0
80	    }
81
82	    /// Is `id` currently hosted (driver thread alive)? Prunes a finished entry so
83	    /// a crashed driver is re-hosted next sweep.
84	    fn contains(&self, id: &str) -> bool {
85	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
86	        match map.get(id) {
87	            Some(h) if !h.thread.is_finished() => true,
88	            Some(_) => {
89	                map.remove(id);
90	                false
91	            }
92	            None => false,
93	        }
94	    }
95
96	    fn insert(&self, id: &str, handle: HostedLife) {
97	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
98	        map.insert(id.to_string(), handle);
99	    }
100
101	    /// The currently-tabled ids (finished pruned) — the stop-side reconcile input.
102	    fn ids(&self) -> Vec<String> {
103	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
104	        map.retain(|_, h| !h.thread.is_finished());
105	        map.keys().cloned().collect()
106	    }
107
108	    /// Un-host one lifecycle: trip its stop flag, join the driver, de-table. The
109	    /// pulse loop checks the flag each sleep-slice, so the join is prompt. We do
110	    /// NOT re-stamp `offline` — the perch already left the online set (graceful
111	    /// signoff cleared it, or the perch is gone); a brain *crash* deliberately
112	    /// leaves `status=online` so the next brain rehydrates it (CONTEXT:194).
113	    fn stop_host(&self, id: &str) {
114	        let handle = {
115	            let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
116	            map.remove(id)
117	        };
118	        if let Some(h) = handle {
119	            h.stop.store(true, Ordering::SeqCst);
120	            let _ = h.thread.join();
121	        }
122	    }
123	}
124
125	/// One reconcile sweep — the brain's live host-set invariant holder, run at boot
126	/// and every [`LIVE_RECONCILE_INTERVAL_MS`]. The complete set-diff against the
127	/// on-disk **online** live endpoints (mirror of [`crate::shellwake::reconcile_once`]):
128	///
129	/// - **un-host** (tabled ∧ ¬online) — stop the driver + de-table (exit guard: a
130	///   signed-off / removed endpoint must not keep pulsing — thread + dead-agent leak).
131	/// - **host** (online ∧ ¬tabled) — spawn the Psyche + start the driver.
132	/// - **leave-alone** (online ∧ tabled).
133	///
134	/// `status` is daemon-authoritative (CONTEXT:194); interim `listen` perches are
135	/// `status`-ABSENT so they are never hosted here (no double-host) until W0.2.
136	// [impl->REQ-DAEMON-1]
137	pub fn reconcile_once(
138	    owlery: &Path,
139	    registered: &[(AdapterRecord, Manifest)],
140	    adapters_dir: &Path,
141	    set: &Arc<LiveSet>,
142	    cfg: &DaemonConfig,
143	    reason: StartReason,
144	) {
145	    // Stop side first: hosted endpoints that DEFINITIVELY left the online set —
146	    // trip the flag, join, de-table. Un-host on a definitive departure ONLY, never
147	    // on a TRANSIENT read failure (M11-W0.4 (ii)): a momentarily-unreadable
148	    // info.json must not stop+respawn the driver (Psyche churn on a disk blip). The
149	    // discriminator is the perch DIR:
150	    //   - dir gone        → torn down (teardown removed the perch) → un-host;
151	    //   - dir present, read_info Some(status≠online) → signed-off/offline → un-host;
152	    //   - dir present, read_info None (transient/torn read) → LEAVE the driver, retry.
153	    // [impl->REQ-DAEMON-1]
154	    for id in set.ids() {
155	        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
156	        let unhost = if !perch.exists() {
157	            true // the perch dir is gone — torn down
158	        } else {
159	            match spt_store::info::read_info(&perch) {
160	                Some(info) => info.status.as_deref() != Some(STATUS_ONLINE),
161	                None => false, // dir present but unreadable: transient → keep hosting
162	            }
163	        };
164	        if unhost {
165	            set.stop_host(&id);
166	        }
167	    }
168
169	    // Start side: every online live endpoint not yet hosted.
170	    for id in perch::list_self_perch_ids(owlery) {
171	        if set.contains(&id) {
172	            continue; // already hosted (leave-alone)
173	        }
174	        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
175	        let Some(info) = spt_store::info::read_info(&perch) else {
176	            continue;
177	        };
178	        if info.state != LIVE_AGENT_STATE {
179	            continue; // not a live agent (ready_agent etc.)
180	        }
181	        if info.status.as_deref() != Some(STATUS_ONLINE) {
182	            continue; // interim (status-absent) or signed-off (offline)
183	        }
184	        let Some(adapter) = info.adapter.as_deref() else {
185	            continue; // adapterless endpoint — never live-capable
186	        };
187	        let Ok(manifest) = spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter)
188	        else {
189	            continue; // deregistered adapter: nothing to host
190	        };
191	        // [impl->REQ-INSTALL-11] resolve the Psyche role program against the
192	        // adapter install dir — the registry record's precise `source_dir`, the
193	        // same dir Feature E's api seam uses (mod.rs::resolve_ctx_manifest). The
194	        // daemon-host site is the heir to the retired in-process spawn's
195	        // install-dir resolution: without it the daemon-hosted Psyche resolves a
196	        // copy-mode psyche binary only via PATH, not its install dir.
197	        let parent = adapter.split(':').next().unwrap_or(adapter);
198	        let install_dir = registered
199	            .iter()
200	            .find(|(rec, _)| rec.name == parent)
201	            .map(|(rec, _)| std::path::PathBuf::from(&rec.source_dir));
202	        let Some(lifecycle) =
203	            BrainLifecycle::with_config_in(&manifest, &id, cfg.clone(), install_dir.as_deref())
204	        else {
205	            continue; // manifest declares no [session.psyche_init]
206	        };
207	        host_one(set, &id, lifecycle, info.session_id, reason);
208	    }
209	}
210
211	/// Host one live endpoint: sweep a stale signoff (3.2), spawn the Psyche (the
212	/// daemon online-stamp makes liveness daemon-authoritative), then start the
213	/// config-paced pulse driver on its own supervised thread with a held stop flag.
214	/// A Psyche-spawn failure tables nothing — the next sweep retries.
215	// [impl->REQ-DAEMON-1]
216	fn host_one(
217	    set: &Arc<LiveSet>,
218	    id: &str,
219	    lifecycle: BrainLifecycle,
220	    session_id: String,
221	    reason: StartReason,
222	) {
223	    lifecycle.sweep_on_start();
224	    match lifecycle.spawn_psyche(Some(&session_id), None) {
225	        Ok(h) => eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid),
226	        Err(e) => {
227	            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
228	            return; // no Psyche → no driver; retried next reconcile
229	        }
230	    }
231	    let stop = Arc::new(AtomicBool::new(false));
232	    let handle = {
233	        let stop = Arc::clone(&stop);
234	        std::thread::spawn(move || {
235	            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
236	        })
237	    };
238	    set.insert(id, HostedLife { stop, thread: handle });
239	}
240
241	/// Spawn the brain's live host: one thread sweeping [`reconcile_once`] at boot
242	/// and every [`LIVE_RECONCILE_INTERVAL_MS`] until `stop`. The registered set +
243	/// config are re-read each sweep (adapter / pulse-period changes land between
244	/// ticks). Mirror of [`crate::shellwake::spawn_wake_host`]; the stop flag is held
245	/// for symmetry — the threads die with the brain process and the next brain
246	/// rehydrates from disk.
247	// [impl->REQ-DAEMON-1]
248	pub fn spawn_live_host(stop: Arc<AtomicBool>, reason: StartReason) -> JoinHandle<()> {
249	    std::thread::spawn(move || {
250	        let set = Arc::new(LiveSet::new());
251	        while !stop.load(Ordering::SeqCst) {
252	            let owlery = perch::owlery_dir();
253	            let adapters_dir = perch::adapters_dir();
254	            let registered = spt_runtime::registry::registered(&adapters_dir);
255	            let cfg = DaemonConfig::load();
256	            reconcile_once(&owlery, &registered, &adapters_dir, &set, &cfg, reason);
257	            // Sleep in slices so a stop lands promptly.
258	            let mut left = LIVE_RECONCILE_INTERVAL_MS;
259	            while left > 0 && !stop.load(Ordering::SeqCst) {
260	                let step = left.min(100);
261	                std::thread::sleep(Duration::from_millis(step));
262	                left -= step;
263	            }
264	        }
265	    })
266	}
267
268	#[cfg(test)]
269	mod tests {
270	    use super::*;
271	    use crate::test_home::with_home;
272	    use std::time::{Duration, Instant};
273
274	    // A no-op Psyche summarizer command: spawns + exits 0 (we never depend on its
275	    // output — the daemon online-stamp is what hosts it, not the pid).
276	    #[cfg(windows)]
277	    const NOOP_CMD: &str = "cmd /C rem";
278	    #[cfg(unix)]
279	    const NOOP_CMD: &str = "true";
280
281	    /// Register a live-capable adapter `name` (declares `[session.psyche_init]` +
282	    /// a watched commune/signoff dir) so `resolve_option_in` finds it from disk.
283	    fn register_live_adapter(name: &str, drops: &Path) {
284	        let dir = drops.to_string_lossy().replace('\\', "/");
285	        let toml = format!(
286	            "[adapter]\nname=\"{name}\"\nkind=\"harness\"\nversion=\"1\"\n\
287	             min_spt_core_version=\"0\"\n\n\
288	             [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
289	             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
290	        );
291	        let src = perch::spt_home().join("srcs").join(name);
292	        std::fs::create_dir_all(&src).unwrap();
293	        std::fs::write(src.join("manifest.toml"), toml).unwrap();
294	        spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
295	    }
296
297	    /// Seed a Self **live** perch on disk: `state=live_agent`, the given status,
298	    /// and the adapter option that resolves the manifest.
299	    fn seed_live_perch(id: &str, adapter: &str, status: &str) {
300	        let perch = perch::resolve_perch_path(id, ParentHint::Infer);
301	        std::fs::create_dir_all(&perch).unwrap();
302	        let mut rec = spt_store::info::InfoJson::new(id, "t", std::process::id(), "sid-1", "live_agent");
303	        rec.adapter = Some(adapter.to_string());
304	        spt_store::info::write_info(&perch, &rec).unwrap();
305	        spt_store::info::set_status(&perch, status).unwrap();
306	    }
307
308	    /// A fast-pulse config so the driver loop ticks quickly in tests.
309	    fn fast_cfg() -> DaemonConfig {
310	        DaemonConfig {
311	            pulse_period: Duration::from_millis(15),
312	            ..DaemonConfig::default()
313	        }
314	    }
315
316	    fn registered_now() -> Vec<(AdapterRecord, Manifest)> {
317	        spt_runtime::registry::registered(&perch::adapters_dir())
318	    }
319
320	    /// Poll a predicate up to `bound`, sleeping 20ms between checks.
321	    fn wait_until(bound: Duration, mut pred: impl FnMut() -> bool) -> bool {
322	        let deadline = Instant::now() + bound;
323	        while Instant::now() < deadline {
324	            if pred() {
325	                return true;
326	            }
327	            std::thread::sleep(Duration::from_millis(20));
328	        }
329	        pred()
330	    }
331
332	    // [unit->REQ-DAEMON-1] (a) FIRST-HOST scan: an online live endpoint on disk is
333	    // hosted by the reconcile — the Psyche is spawned AND the config-paced pulse
334	    // driver actually runs (it stamps last_active_ms on the Self perch each tick).
335	    #[test]
336	    fn reconcile_hosts_an_online_live_endpoint_and_drives_its_pulse() {
337	        with_home(|_| {
338	            let drops = perch::spt_home().join("drops");
339	            std::fs::create_dir_all(&drops).unwrap();
340	            register_live_adapter("mock", &drops);
341	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
342
343	            let set = Arc::new(LiveSet::new());
344	            let cfg = fast_cfg();
345	            reconcile_once(
346	                &perch::owlery_dir(),
347	                &registered_now(),
348	                &perch::adapters_dir(),
349	                &set,
350	                &cfg,
351	                StartReason::Cold,
352	            );
353	            assert_eq!(set.len(), 1, "the online live endpoint is hosted");
354
355	            // The driver pulse runs: an Active perch stamps last_active_ms each tick.
356	            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
357	            let ran = wait_until(Duration::from_secs(3), || {
358	                spt_store::info::read_info(&self_perch)
359	                    .and_then(|i| i.last_active_ms)
360	                    .is_some()
361	            });
362	            assert!(ran, "the pulse driver loop ticks (stamps last_active_ms)");
363
364	            // Teardown so the test process doesn't leak the driver thread.
365	            set.stop_host("doyle");
366	        });
367	    }
368
369	    // [unit->REQ-DAEMON-1] (b) ENTRY GUARD: a never-hosted OFFLINE live endpoint is
370	    // skipped — online-only. (Also covers the interim status-absent case shape.)
371	    #[test]
372	    fn reconcile_skips_an_offline_live_endpoint() {
373	        with_home(|_| {
374	            let drops = perch::spt_home().join("drops");
375	            std::fs::create_dir_all(&drops).unwrap();
376	            register_live_adapter("mock", &drops);
377	            seed_live_perch("doyle", "mock", spt_store::liveness::STATUS_OFFLINE);
378
379	            let set = Arc::new(LiveSet::new());
380	            reconcile_once(
381	                &perch::owlery_dir(),
382	                &registered_now(),
383	                &perch::adapters_dir(),
384	                &set,
385	                &fast_cfg(),
386	                StartReason::Cold,
387	            );
388	            assert!(set.is_empty(), "an offline live endpoint is never hosted");
389	        });
390	    }
391
392	    // [unit->REQ-DAEMON-1] (c) NO DOUBLE-HOST: a second reconcile over an
393	    // already-tabled endpoint leaves exactly one host (the contains-guard).
394	    #[test]
395	    fn reconcile_does_not_double_host() {
396	        with_home(|_| {
397	            let drops = perch::spt_home().join("drops");
398	            std::fs::create_dir_all(&drops).unwrap();
399	            register_live_adapter("mock", &drops);
400	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
401
402	            let set = Arc::new(LiveSet::new());
403	            let cfg = fast_cfg();
404	            let run = || {
405	                reconcile_once(
406	                    &perch::owlery_dir(),
407	                    &registered_now(),
408	                    &perch::adapters_dir(),
409	                    &set,
410	                    &cfg,
411	                    StartReason::Cold,
412	                )
413	            };
414	            run();
415	            run();
416	            assert_eq!(set.len(), 1, "the same endpoint is hosted exactly once");
417	            set.stop_host("doyle");
418	        });
419	    }
420
421	    // [unit->REQ-DAEMON-1] (d) REHYDRATE: a FRESH LiveSet (a restarted brain) over a
422	    // perch left `status=online` by a prior brain re-hosts it — distinct from (a)
423	    // in that the table starts empty against an already-online perch (the
424	    // brain-restart path), not a freshly-flipped one.
425	    #[test]
426	    fn fresh_brain_rehydrates_an_online_live_endpoint() {
427	        with_home(|_| {
428	            let drops = perch::spt_home().join("drops");
429	            std::fs::create_dir_all(&drops).unwrap();
430	            register_live_adapter("mock", &drops);
431	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
432
433	            // First brain hosts it.
434	            let first = Arc::new(LiveSet::new());
435	            let cfg = fast_cfg();
436	            reconcile_once(
437	                &perch::owlery_dir(),
438	                &registered_now(),
439	                &perch::adapters_dir(),
440	                &first,
441	                &cfg,
442	                StartReason::Cold,
443	            );
444	            assert_eq!(first.len(), 1);
445	            // Brain "restart": drop the table (threads die with the process), the
446	            // perch stays online on disk.
447	            first.stop_host("doyle");
448	            drop(first);
449
450	            // Fresh brain rehydrates from the still-online perch.
451	            let second = Arc::new(LiveSet::new());
452	            reconcile_once(
453	                &perch::owlery_dir(),
454	                &registered_now(),
455	                &perch::adapters_dir(),
456	                &second,
457	                &cfg,
458	                StartReason::Crash,
459	            );
460	            assert_eq!(second.len(), 1, "a fresh brain re-hosts the online endpoint");
461	            set_then_teardown(&second);
462	        });
463	    }
464
465	    fn set_then_teardown(set: &Arc<LiveSet>) {
466	        set.stop_host("doyle");
467	    }
468
469	    // [unit->REQ-DAEMON-1] (e) EXIT GUARD / UN-HOST: a hosted endpoint that flips to
470	    // status=offline (graceful signoff) is un-hosted by the next reconcile — the
471	    // driver thread is stopped + de-tabled (no forever-pulsing dead agent).
472	    #[test]
473	    fn reconcile_unhosts_on_offline_transition() {
474	        with_home(|_| {
475	            let drops = perch::spt_home().join("drops");
476	            std::fs::create_dir_all(&drops).unwrap();
477	            register_live_adapter("mock", &drops);
478	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
479
480	            let set = Arc::new(LiveSet::new());
481	            // FAST pulse: the driver HAMMERS set_last_active every tick while the
482	            // test marks the perch offline — the un-host-UNDER-CONTENTION proof. The
483	            // W0.4 mutate_info sentinel lock makes status reliably stay offline, so
484	            // un-host reliably fires; the earlier long-pulse+settle workaround is
485	            // removed (the lock, not timing, is what makes this deterministic).
486	            let cfg = fast_cfg();
487	            let run = |reason| {
488	                reconcile_once(
489	                    &perch::owlery_dir(),
490	                    &registered_now(),
491	                    &perch::adapters_dir(),
492	                    &set,
493	                    &cfg,
494	                    reason,
495	                )
496	            };
497	            run(StartReason::Cold);
498	            assert_eq!(set.len(), 1, "hosted while online");
499
500	            // Graceful signoff flips the Self perch offline WHILE the driver pulses.
501	            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
502	            spt_store::info::set_status(&self_perch, spt_store::liveness::STATUS_OFFLINE).unwrap();
503
504	            run(StartReason::Cold);
505	            assert!(
506	                set.is_empty(),
507	                "an offline-transitioned endpoint is un-hosted (driver stopped + de-tabled)"
508	            );
509	        });
510	    }
511
512	    // [unit->REQ-DAEMON-1] (f) READ-ERROR discrimination (M11-W0.4 (ii)): a hosted
513	    // endpoint whose info.json becomes momentarily UNREADABLE while its perch DIR
514	    // remains is NOT un-hosted (transient → keep the driver, no Psyche stop/respawn
515	    // churn on a disk blip); only a GONE perch dir (torn down) un-hosts.
516	    #[test]
517	    fn reconcile_keeps_driver_on_transient_read_fail_unhosts_on_dir_gone() {
518	        with_home(|_| {
519	            let drops = perch::spt_home().join("drops");
520	            std::fs::create_dir_all(&drops).unwrap();
521	            register_live_adapter("mock", &drops);
522	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
523
524	            let set = Arc::new(LiveSet::new());
525	            let cfg = fast_cfg();
526	            let run = || {
527	                reconcile_once(
528	                    &perch::owlery_dir(),
529	                    &registered_now(),
530	                    &perch::adapters_dir(),
531	                    &set,
532	                    &cfg,
533	                    StartReason::Cold,
534	                )
535	            };
536	            run();
537	            assert_eq!(set.len(), 1, "hosted while online");
538
539	            // TRANSIENT read failure: info.json gone, but the perch DIR remains.
540	            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
541	            std::fs::remove_file(self_perch.join("info.json")).unwrap();
542	            run();
543	            assert_eq!(
544	                set.len(),
545	                1,
546	                "a transient read failure (dir present) must NOT un-host — driver kept"
547	            );
548
549	            // DEFINITIVE: the perch dir is gone (torn down) → un-host. The fast-pulse
550	            // driver holds perch files open between pulses; on Windows that
551	            // transiently blocks dir removal (os err 32) — retry past the µs window
552	            // (production teardown stops the host first, so this race is test-only).
553	            let mut removed = false;
554	            for _ in 0..100 {
555	                if std::fs::remove_dir_all(&self_perch).is_ok() {
556	                    removed = true;
557	                    break;
558	                }
559	                std::thread::sleep(std::time::Duration::from_millis(20));
560	            }
561	            assert!(removed, "could not remove the perch dir for the torn-down case");
562	            run();
563	            assert!(set.is_empty(), "a gone perch dir un-hosts the driver");
564	        });
565	    }
566	}
567


Now let me look at the spt binary's daemon subcommand parsing and the existing E2E test patterns.


crates\spt\tests\notify_shell_e2e.rs
crates\spt\tests\release_verify_e2e.rs
crates\spt\tests\n1_pairing.rs
crates\spt\tests\brain_split.rs
crates\spt\tests\n1_compat.rs
crates\spt\tests\quickstart_e2e.rs
crates\spt\tests\brain_respawn_rename.rs
crates\spt\tests\gateway_e2e.rs
crates\spt\tests\shell_sleepwake_e2e.rs
crates\spt\tests\brain_survive.rs
crates\spt\tests\poll_envelope_e2e.rs
crates\spt\tests\shell_e2e.rs
crates\spt\tests\contract_e2e.rs
crates\spt\tests\drive_e2e.rs
crates\spt\tests\gateway_owner_shell_e2e.rs
crates\spt\tests\live_bind_firsthost_e2e.rs
crates\spt\tests\live_firsthost_e2e.rs
crates\spt\tests\shell_actgate_e2e.rs
crates\spt\tests\tunnel_e2e.rs
crates\spt\tests\oneliner_e2e.rs


Found 8 files
crates\spt\src\cli.rs
crates\spt\src\api\mod.rs
crates\spt\src\api\reporting.rs
crates\spt\src\api\live.rs
crates\spt\src\api\startup.rs
crates\spt\src\wansend.rs
crates\spt\src\rc.rs
crates\spt\src\main.rs


1	//! M11-W0.2 first-host int (REQ-DAEMON-1) — the REAL establish-marks-online path,
2	//! end-to-end through the actual `spt` binary (no synthetic-online seed like the
3	//! W0.1 units):
4	//!
5	//! a live-capable `spt api listen --once` BINDS the Self perch and marks it
6	//! `status=online` (the W0.2 first-host handoff — the Psyche no longer spawns in
7	//! this process) → the brain's `livehost::reconcile_once` then HOSTS the Psyche
8	//! lifecycle off that real online status → a graceful offline transition un-hosts
9	//! it. This proves the cutover wiring: the spt process marks online, the brain
10	//! reconcile reacts, exactly the shellwake CLI-flips / brain-reconciles pattern.
11	//
12	// [int->REQ-DAEMON-1]
13
14	use std::path::PathBuf;
15	use std::process::{Command, Output};
16	use std::sync::Arc;
17	use std::time::Duration;
18
19	mod common;
20	use common::CommandNoWindowExt;
21
22	use spt_daemon::brainproc::StartReason;
23	use spt_daemon::config::DaemonConfig;
24	use spt_daemon::livehost::{self, LiveSet};
25	use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
26	use spt_store::perch::{self, ParentHint};
27
28	// A no-op Psyche summarizer: spawns + exits 0 (the daemon online-stamp hosts it,
29	// not the pid).
30	#[cfg(windows)]
31	const NOOP: &str = "cmd /C rem";
32	#[cfg(unix)]
33	const NOOP: &str = "true";
34
35	/// In-process seed daemon so `api seed`/`api listen` succeed without a REAL
36	/// detached `spt daemon` (shell_e2e's pattern).
37	fn start_inproc_daemon() {
38	    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
39	    let name = spt_daemon::seed_socket_name();
40	    std::thread::spawn(move || {
41	        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
42	    });
43	    for _ in 0..400 {
44	        if spt_daemon::is_running() {
45	            return;
46	        }
47	        std::thread::sleep(Duration::from_millis(5));
48	    }
49	    panic!("in-process seed daemon did not come up");
50	}
51
52	/// `Command::output()` with a deadline, run off-thread (bounds a hang into a
53	/// clean failure).
54	fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
55	    let (tx, rx) = std::sync::mpsc::channel();
56	    std::thread::spawn(move || {
57	        let _ = tx.send(cmd.output());
58	    });
59	    rx.recv_timeout(deadline)
60	        .expect("captured spt call must complete")
61	        .expect("run spt")
62	}
63
64	#[test]
65	fn live_listen_marks_online_and_brain_reconcile_hosts() {
66	    // One test in this file: a plain set_var is safe; every child inherits the home.
67	    let home = tempfile::tempdir().unwrap();
68	    std::env::set_var("SPT_HOME", home.path());
69	    start_inproc_daemon();
70
71	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
72
73	    // A live-capable adapter (declares [session.psyche_init]) + register it so the
74	    // brain reconcile can resolve the manifest from disk by the perch's adapter.
75	    let drops = home.path().join("drops");
76	    std::fs::create_dir_all(&drops).unwrap();
77	    let dir = drops.to_string_lossy().replace('\\', "/");
78	    let manifest_toml = format!(
79	        "[adapter]\nname=\"mocklive\"\nkind=\"harness\"\nversion=\"1\"\n\
80	         min_spt_core_version=\"0\"\n\n\
81	         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
82	         [session.psyche_init]\ncommand='{NOOP}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
83	    );
84	    let manifest_path = home.path().join("mocklive.toml");
85	    std::fs::write(&manifest_path, &manifest_toml).unwrap();
86	    let src = perch::spt_home().join("srcs").join("mocklive");
87	    std::fs::create_dir_all(&src).unwrap();
88	    std::fs::write(src.join("manifest.toml"), &manifest_toml).unwrap();
89	    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
90
91	    let pid = std::process::id().to_string();
92	    let mp = manifest_path.to_string_lossy().to_string();
93	    let spt = |args: &[&str]| {
94	        let mut cmd = Command::new(&spt_bin);
95	        cmd.no_window().args(args).env("SPT_HOME", home.path());
96	        output_bounded(cmd, Duration::from_secs(60))
97	    };
98
99	    // ── seed (keyed by THIS process's pid — alive, passes the recycle guard) ──
100	    let out = spt(&[
101	        "api", "--adapter", "mocklive", "seed", "--pid", &pid, "--session-id", "sid-1",
102	    ]);
103	    assert!(out.status.success(), "seed: {}", String::from_utf8_lossy(&out.stderr));
104
105	    // ── the REAL live listen --once: binds the Self perch + marks it online
106	    // (the W0.2 establish-marks-online), drains, exits. The Psyche does NOT spawn
107	    // in this process anymore.
108	    let out = spt(&[
109	        "api", "--adapter", "mocklive", "--manifest", &mp, "listen", "agent7",
110	        "--parent-pid", &pid, "--once",
111	    ]);
112	    assert!(out.status.success(), "listen: {}", String::from_utf8_lossy(&out.stderr));
113
114	    // The real establish-marks-online: the perch carries status=online, written by
115	    // cmd_listen (NOT hand-seeded).
116	    let self_perch = perch::resolve_perch_path("agent7", ParentHint::Infer);
117	    let status = spt_store::info::read_info(&self_perch).and_then(|i| i.status);
118	    assert_eq!(
119	        status.as_deref(),
120	        Some(STATUS_ONLINE),
121	        "a live `api listen` marks the perch online (the first-host handoff)"
122	    );
123
124	    // ── the brain reconcile hosts off that REAL online status ──
125	    let set = Arc::new(LiveSet::new());
126	    // FAST pulse: the driver hammers set_last_active every tick. The W0.4
127	    // mutate_info sentinel lock makes the offline flip below survive that
128	    // contention, so no long-pulse+settle workaround is needed (the lock, not
129	    // timing, makes the un-host deterministic).
130	    let cfg = DaemonConfig {
131	        pulse_period: Duration::from_millis(15),
132	        ..DaemonConfig::default()
133	    };
134	    let registered = spt_runtime::registry::registered(&perch::adapters_dir());
135	    livehost::reconcile_once(
136	        &perch::owlery_dir(),
137	        &registered,
138	        &perch::adapters_dir(),
139	        &set,
140	        &cfg,
141	        StartReason::Cold,
142	    );
143	    assert_eq!(
144	        set.len(),
145	        1,
146	        "the brain reconcile hosts the live-listen-onlined endpoint"
147	    );
148
149	    // ── a graceful offline transition un-hosts it (the exit guard, end-to-end) ──
150	    // No settle: the W0.4 sentinel lock makes the offline flip survive the driver's
151	    // concurrent per-tick set_last_active (un-host fires under contention).
152	    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();
153	    livehost::reconcile_once(
154	        &perch::owlery_dir(),
155	        &registered,
156	        &perch::adapters_dir(),
157	        &set,
158	        &cfg,
159	        StartReason::Cold,
160	    );
161	    assert!(set.is_empty(), "an offline transition un-hosts the lifecycle");
162
163	    // Reap any REAL daemon that auto-started against this throwaway home (hygiene).
164	    if let Ok(p) = std::fs::read_to_string(home.path().join("daemon.pid")) {
165	        if let Ok(p) = p.trim().parse::<u32>() {
166	            #[cfg(windows)]
167	            let _ = Command::new("taskkill")
168	                .no_window()
169	                .args(["/PID", &p.to_string(), "/F"])
170	                .output();
171	            #[cfg(unix)]
172	            let _ = Command::new("kill").args(["-9", &p.to_string()]).output();
173	        }
174	    }
175	}
176