1	//! Restoration D7-1 (ADR-0018 V5) — **process-level survival across a binary
2	//! swap**: a hosted PTY child + a live QUIC connection survive a brain-**PROCESS**
3	//! restart that lands on a **swapped on-disk binary**, with the broker holding
4	//! both endpoints untouched throughout. This is the `int` E2E that re-points the
5	//! regression-masked REQ-DAEMON-2 / REQ-UPD-3 evidence off the in-process
6	//! `brain_swap.rs` shape onto a real process boundary.
7	//!
8	//! ## The proof is a TRIANGLE (doyle D7 vet, call 1) — this is leg (b)
9	//!
10	//! No single test proves the whole claim; three legs do, and this file is honest
11	//! only when read alongside the other two:
12	//!
13	//! - **Leg (a) — the real-broker-process leg: `crates/spt/tests/brain_split.rs`.**
14	//!   The REAL `spt daemon run` broker *process* survives a brain kill and respawns
15	//!   it. That is where the broker's own process-packaging is proven.
16	//! - **Leg (b) — the survival leg: THIS test.** A real brain *child process* +
17	//!   a real on-disk binary swap + real broker-**held** endpoints (a PTY child and
18	//!   a live QUIC conn). The broker is modeled **in-process** — faithful, not a
19	//!   weakening: the broker is the never-restarting anchor *by design*, so an
20	//!   in-process [`Broker`] runs the **same Broker code holding the same
21	//!   resources** the real broker would across a brain swap. The claim under test
22	//!   is the brain process + the swapped binary + broker-held endpoint survival —
23	//!   all real here. (Bonus: the in-process broker gives the test direct access to
24	//!   its session pid / count and QUIC conn count for the assertions.)
25	//! - **Leg (c) — the field leg: D7-4.** The real daemon, a real `apply`, real
26	//!   hardware — the `exe_hash` flips and matches the released artifact.
27	//!
28	//! DEFERRED upgrade: once the live-agent adapter makes daemon-hosted sessions a
29	//! production reality, a full-stack single-process E2E (the real `spt daemon run`
30	//! hosting *and* surviving) becomes reachable — tracked in `docs/DEFERRED.md`,
31	//! NOT D7 work.
32	//!
33	//! ## What makes this STRONGER than `brain_swap.rs`
34	//!
35	//! `brain_swap.rs` (REQ-UPD-3's prior `int`) relaunches the brain via an
36	//! **in-process closure** — same process image, no disk involved. This test runs
37	//! the **real `spt daemon brain` child process** the production
38	//! [`supervise_brain`] spawns, swaps the on-disk binary it respawns from, and
39	//! proves the post-respawn brain is running the **new bytes** via the D7-1
40	//! `exe_hash` breadcrumb (`exe_hash == fixture-B != fixture-A`). It is also the
41	//! **first process-level exercise of the D4 cursor-of-record resume with N≥1
42	//! hosted sessions** (the supervised child re-attaches the test-established PTY
43	//! session on cold start).
44	//!
45	//! The swap is modeled as a **path flip** between two distinct on-disk fixtures
46	//! (B = A + appended padding — PE and ELF both tolerate trailing data, so behavior
47	//! is identical but the hash flips). This is faithful to the production swap (the
48	//! supervisor respawns from whatever the binary *selection* resolves to) and
49	//! sidesteps the Windows running-exe lock (you cannot overwrite a live `.exe`).
50	//!
51	//! **Held AND functional (doyle F1).** Proving the endpoints are *held* (pid /
52	//! session_count / conn_count unchanged) is not proving they are *alive* — a
53	//! zombie conn or wedged PTY would pass. So after the swap the test also drives a
54	//! marker through the held PTY child and watches the broker's output seq advance:
55	//! the full round-trip (input → child → echo → broker ring) works through the
56	//! swapped-in brain.
57	// [int->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
58	// [int->REQ-UPD-3]
59	// [int->REQ-DAEMON-2]
60
61	use std::path::PathBuf;
62	use std::process::Command;
63	use std::sync::atomic::{AtomicBool, Ordering};
64	use std::sync::{Arc, Mutex};
65	use std::thread;
66	use std::time::{Duration, Instant};
67
68	use spt_daemon::brain::Brain;
69	use spt_daemon::brainproc::{supervise_brain, BrainRestart, NoTrialEnv, StartReason};
70	use spt_daemon::codec::write_frame;
71	use spt_daemon::endpoint::broker_socket_name;
72	use spt_daemon::frame::{Envelope, Role};
73	use spt_daemon::msg::{encode_bytes, InputReq, SpawnReq, KIND_INPUT};
74	use spt_daemon::nethost::{NetConfig, NetHost};
75	use spt_daemon::transport::{send_hello, LocalSocketTransport};
76	use spt_daemon::Broker;
77	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
78	use spt_proto::identity::Identity;
79
80	mod common;
81	use common::CommandNoWindowExt;
82
83	/// A long-lived echo child to host under a broker PTY (the endpoint REQ-UPD-3
84	/// forbids us from disturbing across the swap). `cat`/`findstr` idle on stdin.
85	fn echo_spawn_req() -> SpawnReq {
86	    #[cfg(unix)]
87	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
88	    #[cfg(windows)]
89	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
90	    SpawnReq {
91	        program,
92	        args,
93	        rows: 24,
94	        cols: 80,
95	        endpoint: String::new(),
96	    }
97	}
98
99	/// A hermetic net config (mirrors `netbroker.rs`): relays disabled, no discovery,
100	/// loopback-only sockets — no real interface touched, no firewall prompt.
101	fn hermetic(identity: Identity) -> NetConfig {
102	    NetConfig {
103	        identity,
104	        relay: RelayPolicy::Disabled,
105	        discovery: LocalDiscovery::Off,
106	        scope: BindScope::Loopback,
107	        membership: None,
108	    }
109	}
110
111	/// Bind a broker (serving on a background thread) that owns a hermetic QUIC
112	/// endpoint for `identity`, with its effect journal under `dir`.
113	fn net_broker(name: &str, identity: Identity, dir: &std::path::Path) -> Arc<Broker> {
114	    let host = NetHost::start(hermetic(identity)).expect("net host start");
115	    let broker = Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host))
116	        .expect("bind broker with net");
117	    let serve = Arc::clone(&broker);
118	    thread::spawn(move || {
119	        let _ = serve.serve();
120	    });
121	    broker
122	}
123
124	fn connect_retry(name: &str) -> Brain {
125	    for _ in 0..400 {
126	        if let Ok(b) = Brain::cold_start(name, 1) {
127	            return b;
128	        }
129	        thread::sleep(Duration::from_millis(10));
130	    }
131	    panic!("brain could not connect to {name}");
132	}
133
134	/// The three breadcrumb fields out of a `brain.ready` body (parsed inline so this
135	/// test depends on no private brainproc helper). `None` until the file exists and
136	/// carries a parseable pid.
137	fn read_ready(path: &std::path::Path) -> Option<(u32, u64, Option<String>)> {
138	    let body = std::fs::read_to_string(path).ok()?;
139	    let v: serde_json::Value = serde_json::from_str(&body).ok()?;
140	    let pid = v.get("pid")?.as_u64()? as u32;
141	    let generation = v.get("generation").and_then(|g| g.as_u64()).unwrap_or(0);
142	    let exe_hash = v
143	        .get("exe_hash")
144	        .and_then(|h| h.as_str())
145	        .map(str::to_string);
146	    Some((pid, generation, exe_hash))
147	}
148
149	/// Poll `brain.ready` until it holds a pid (optionally one DIFFERENT from `was`),
150	/// returning `(pid, generation, exe_hash)`. `None` if it never appears in time.
151	fn wait_ready(
152	    path: &std::path::Path,
153	    was: Option<u32>,
154	    budget: Duration,
155	) -> Option<(u32, u64, Option<String>)> {
156	    let deadline = Instant::now() + budget;
157	    while Instant::now() < deadline {
158	        if let Some((pid, gen, hash)) = read_ready(path) {
159	            if was != Some(pid) {
160	                return Some((pid, gen, hash));
161	            }
162	        }
163	        thread::sleep(Duration::from_millis(50));
164	    }
165	    None
166	}
167
168	/// Lowercase-hex SHA-256 of a file (the same digest `brain.ready.exe_hash`
169	/// carries; recomputed independently here to assert the breadcrumb matches the
170	/// fixture the supervisor actually spawned).
171	fn file_hash(path: &std::path::Path) -> String {
172	    use sha2::{Digest, Sha256};
173	    let bytes = std::fs::read(path).expect("read fixture for hashing");
174	    let mut h = Sha256::new();
175	    h.update(&bytes);
176	    let digest = h.finalize();
177	    let mut s = String::with_capacity(digest.len() * 2);
178	    for b in digest {
179	        s.push_str(&format!("{b:02x}"));
180	    }
181	    s
182	}
183
184	#[test]
185	fn pty_and_quic_survive_brain_process_restart_onto_swapped_binary() {
186	    // The in-process broker + brain children both resolve `broker_socket_name()`
187	    // from SPT_HOME, so the supervised child connects to OUR broker. (The
188	    // established daemon-test pattern — daemon_e2e.rs et al. set it the same way.)
189	    let home = tempfile::tempdir().expect("tempdir");
190	    // Single-test binary by design — this is the SOLE `#[test]` here, so it owns
191	    // the process-global SPT_HOME on its own thread (cross-binary tests are
192	    // separate processes). A SECOND `#[test]` added to this file MUST bring the
193	    // env-lock discipline (a shared mutex / serialized home), or it will race this
194	    // one — the D6 env-race lesson (see the spt-daemon test_home pattern).
195	    std::env::set_var("SPT_HOME", home.path());
196
197	    // ── Two fixtures: A, and B = A + appended padding (the on-disk "swap"). ──
198	    // Both are the real `spt` binary, so `daemon brain` runs; B differs only by
199	    // trailing bytes (behavior identical, exe_hash flips).
200	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
201	    let suffix = spt_bin.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
202	    let fixture_a = home.path().join(format!("brain-A{suffix}"));
203	    let fixture_b = home.path().join(format!("brain-B{suffix}"));
204	    std::fs::copy(&spt_bin, &fixture_a).expect("stage fixture A");
205	    std::fs::copy(&spt_bin, &fixture_b).expect("stage fixture B");
206	    {
207	        use std::io::Write;
208	        let mut f = std::fs::OpenOptions::new()
209	            .append(true)
210	            .open(&fixture_b)
211	            .expect("open fixture B to append padding");
212	        f.write_all(&[0u8; 4096]).expect("append padding to fixture B");
213	    }
214	    let hash_a = file_hash(&fixture_a);
215	    let hash_b = file_hash(&fixture_b);
216	    assert_ne!(hash_a, hash_b, "the swap must flip the binary hash");
217
218	    // ── The broker (leg-b stable kernel) + a loopback QUIC peer. ──
219	    let broker = net_broker(&broker_socket_name(), Identity::generate(), &home.path().join("a"));
220	    let peer_name = format!("spt-d7-peer-{}.sock", std::process::id());
221	    let _peer = net_broker(&peer_name, Identity::generate(), &home.path().join("b"));
222
223	    // ── Establish the two endpoints ONCE via a test-driver brain, then drop it
224	    //    HARD (the D4 hard-kill posture — the outgoing side is gone before the
225	    //    supervised child resumes). ──
226	    let (sid, child_pid) = {
227	        let mut driver = connect_retry(&broker_socket_name());
228
229	        // (1) A hosted PTY child the broker holds.
230	        let sid = driver.spawn_session(echo_spawn_req()).expect("spawn session");
231	        let child_pid = broker.session_pid(sid).expect("hosted child has a pid");
232
233	        // (2) A live QUIC connection the broker holds: dial the loopback peer.
234	        let peer_addr = {
235	            let mut peer_brain = connect_retry(&peer_name);
236	            peer_brain.net_status().expect("peer status").addr
237	        };
238	        driver.net_dial(peer_addr, None).expect("dial the loopback peer");
239
240	        // Both endpoints are now broker-held.
241	        assert_eq!(broker.session_count(), 1, "one hosted session established");
242	        assert_eq!(broker.net_conn_count(), 1, "one live QUIC conn established");
243	        (sid, child_pid)
244	        // driver dropped here — HARD detach (no graceful handoff frame).
245	    };
246
247	    // ── Start the production supervisor, spawning the REAL `spt daemon brain`
248	    //    child from the currently-selected fixture (A). NoTrialEnv = the
249	    //    pre-D6 respawn loop (no readiness gate); the swap is driven by the
250	    //    planned-restart signal, exactly as a real `apply` drives it (D3-3). ──
251	    let ready_path = home.path().join("brain.ready");
252	    let stop = Arc::new(AtomicBool::new(false));
253	    let restart = Arc::new(BrainRestart::new());
254	    let selected = Arc::new(Mutex::new(fixture_a.clone()));
255	    let home_path = home.path().to_path_buf();
256
257	    let sup = {
258	        let stop = Arc::clone(&stop);
259	        let restart = Arc::clone(&restart);
260	        let selected = Arc::clone(&selected);
261	        thread::spawn(move || {
262	            supervise_brain(
263	                &stop,
264	                &restart,
265	                Duration::from_millis(50),
266	                &NoTrialEnv,
267	                Duration::from_secs(30),
268	                move |gen, reason: StartReason, _binary| {
269	                    let exe = selected.lock().unwrap().clone();
270	                    Command::new(&exe)
271	                        .no_window()
272	                        .args([
273	                            "daemon",
274	                            "brain",
275	                            "--generation",
276	                            &gen.to_string(),
277	                            "--start-reason",
278	                            reason.as_arg(),
279	                        ])
280	                        .env("SPT_HOME", &home_path)
281	                        .spawn()
282	                },
283	            );
284	        })
285	    };
286
287	    // ── Generation 0: the real child came up from fixture A, resumed the hosted
288	    //    session (the first N≥1 cursor-of-record resume), and stamped exe_hash A. ──
289	    let (pid0, _gen0, hash0) = wait_ready(&ready_path, None, Duration::from_secs(45))
290	        .unwrap_or_else(|| teardown_panic(&stop, "gen-0 brain never signalled ready"));
291	    assert_eq!(
292	        hash0.as_deref(),
293	        Some(hash_a.as_str()),
294	        "gen-0 brain.ready exe_hash must be fixture A"
295	    );
296	    assert_eq!(broker.session_count(), 1, "session survived the driver drop + resume");
297	    assert_eq!(
298	        broker.session_pid(sid),
299	        Some(child_pid),
300	        "the PTY child's pid is unchanged through the resume"
301	    );
302	    assert_eq!(broker.net_conn_count(), 1, "the QUIC conn survived the driver drop");
303
304	    // ── SWAP: flip the selected binary to B, then trigger a planned restart
305	    //    (what `apply` does: swap on disk, signal the brain to cycle). ──
306	    *selected.lock().unwrap() = fixture_b.clone();
307	    restart.request();
308
309	    // ── Generation 1: the supervisor respawned the brain PROCESS onto the
310	    //    SWAPPED binary. New pid, exe_hash == B != A (NEW BYTES RAN). ──
311	    let (pid1, _gen1, hash1) = wait_ready(&ready_path, Some(pid0), Duration::from_secs(45))
312	        .unwrap_or_else(|| teardown_panic(&stop, "gen-1 brain never respawned onto the swap"));
313	    assert_ne!(pid0, pid1, "the respawned brain is a fresh process");
314	    assert_eq!(
315	        hash1.as_deref(),
316	        Some(hash_b.as_str()),
317	        "post-swap exe_hash must be fixture B — the supervisor ran the NEW bytes, \
318	         not a relaunch of the old (the property brain_swap.rs cannot prove)"
319	    );
320	    assert_ne!(
321	        hash1.as_deref(),
322	        Some(hash_a.as_str()),
323	        "post-swap exe_hash must differ from the pre-swap binary"
324	    );
325
326	    // ── THE SURVIVAL INVARIANT: both broker-held endpoints are untouched across
327	    //    the brain-PROCESS swap. ──
328	    assert_eq!(
329	        broker.session_pid(sid),
330	        Some(child_pid),
331	        "REQ-UPD-3: the hosted PTY child's pid is unchanged across the brain-process swap"
332	    );
333	    assert_eq!(broker.session_count(), 1, "exactly one hosted session throughout");
334	    assert_eq!(
335	        broker.net_conn_count(),
336	        1,
337	        "the live QUIC conn the broker holds survived the brain-process swap (SPIKE-03 in production topology)"
338	    );
339
340	    // ── FUNCTIONAL survival (doyle F1, plan choreography 5(d)): "held" is not
341	    //    "alive". Drive a marker through the held PTY child POST-swap and watch the
342	    //    broker-side output seq advance — proving the full round-trip (input →
343	    //    child → echo → broker ring) works through the swapped-in brain, not that
344	    //    a zombie handle merely lingers. The input rides the KIND_INPUT effect verb
345	    //    on a FRESH raw connection, which does NOT steal the supervised brain's
346	    //    output subscription (it is not an attach). ──
347	    let seq_before = broker
348	        .session_output_seq(sid)
349	        .unwrap_or_else(|| teardown_panic(&stop, "hosted session vanished before the functional probe"));
350	    {
351	        let mut raw = LocalSocketTransport::connect(&broker_socket_name())
352	            .unwrap_or_else(|_| teardown_panic(&stop, "could not open a raw input connection"));
353	        send_hello(&mut raw, Role::Brain)
354	            .unwrap_or_else(|_| teardown_panic(&stop, "raw hello failed"));
355	        let input = InputReq {
356	            session_id: sid,
357	            data_b64: encode_bytes(b"D7-1 POST-SWAP FUNCTIONAL PROBE\r"),
358	            op_id: None,
359	        };
360	        write_frame(
361	            &mut raw,
362	            &Envelope::new(KIND_INPUT, serde_json::to_value(input).expect("InputReq serializes")),
363	        )
364	        .unwrap_or_else(|_| teardown_panic(&stop, "could not write the marker input frame"));
365	    }
366	    // The echo child (cat/findstr) echoes the marker → output appended → next_seq
367	    // advances. Poll the broker's own log high-water.
368	    let mut advanced = false;
369	    let deadline = Instant::now() + Duration::from_secs(15);
370	    while Instant::now() < deadline {
371	        if broker.session_output_seq(sid).map(|s| s > seq_before).unwrap_or(false) {
372	            advanced = true;
373	            break;
374	        }
375	        thread::sleep(Duration::from_millis(50));
376	    }
377	    if !advanced {
378	        teardown_panic(
379	            &stop,
380	            "the held PTY child produced no output for a post-swap marker — endpoint held but DEAD",
381	        );
382	    }
383
384	    // ── Teardown: stop the supervisor (kills the live brain child), reap. ──
385	    stop.store(true, Ordering::SeqCst);
386	    let _ = sup.join();
387	    // The hosted child dies with the broker drop at end-of-scope.
388	    let _ = broker;
389	}
390
391	/// Raise `stop` (so the supervisor thread kills any live brain child and exits)
392	/// and panic with `msg` — keeps a failing assertion from orphaning a brain.
393	fn teardown_panic(stop: &AtomicBool, msg: &str) -> ! {
394	    stop.store(true, Ordering::SeqCst);
395	    panic!("{msg}");
396	}
397

//! Restoration D7-1 (ADR-0018 V5) — **process-level survival across a binary
//! swap**: a hosted PTY child + a live QUIC connection survive a brain-**PROCESS**
//! restart that lands on a **swapped on-disk binary**, with the broker holding
//! both endpoints untouched throughout. This is the `int` E2E that re-points the
//! regression-masked REQ-DAEMON-2 / REQ-UPD-3 evidence off the in-process
//! `brain_swap.rs` shape onto a real process boundary.
//!
//! ## The proof is a TRIANGLE (doyle D7 vet, call 1) — this is leg (b)
//!
//! No single test proves the whole claim; three legs do, and this file is honest
//! only when read alongside the other two:
//!
//! - **Leg (a) — the real-broker-process leg: `crates/spt/tests/brain_split.rs`.**
//!   The REAL `spt daemon run` broker *process* survives a brain kill and respawns
//!   it. That is where the broker's own process-packaging is proven.
//! - **Leg (b) — the survival leg: THIS test.** A real brain *child process* +
//!   a real on-disk binary swap + real broker-**held** endpoints (a PTY child and
//!   a live QUIC conn). The broker is modeled **in-process** — faithful, not a
//!   weakening: the broker is the never-restarting anchor *by design*, so an
//!   in-process [`Broker`] runs the **same Broker code holding the same
//!   resources** the real broker would across a brain swap. The claim under test
//!   is the brain process + the swapped binary + broker-held endpoint survival —
//!   all real here. (Bonus: the in-process broker gives the test direct access to
//!   its session pid / count and QUIC conn count for the assertions.)
//! - **Leg (c) — the field leg: D7-4.** The real daemon, a real `apply`, real
//!   hardware — the `exe_hash` flips and matches the released artifact.
//!
//! DEFERRED upgrade: once the live-agent adapter makes daemon-hosted sessions a
//! production reality, a full-stack single-process E2E (the real `spt daemon run`
//! hosting *and* surviving) becomes reachable — tracked in `docs/DEFERRED.md`,
//! NOT D7 work.
//!
//! ## What makes this STRONGER than `brain_swap.rs`
//!
//! `brain_swap.rs` (REQ-UPD-3's prior `int`) relaunches the brain via an
//! **in-process closure** — same process image, no disk involved. This test runs
//! the **real `spt daemon brain` child process** the production
//! [`supervise_brain`] spawns, swaps the on-disk binary it respawns from, and
//! proves the post-respawn brain is running the **new bytes** via the D7-1
//! `exe_hash` breadcrumb (`exe_hash == fixture-B != fixture-A`). It is also the
//! **first process-level exercise of the D4 cursor-of-record resume with N≥1
//! hosted sessions** (the supervised child re-attaches the test-established PTY
//! session on cold start).
//!
//! The swap is modeled as a **path flip** between two distinct on-disk fixtures
//! (B = A + appended padding — PE and ELF both tolerate trailing data, so behavior
//! is identical but the hash flips). This is faithful to the production swap (the
//! supervisor respawns from whatever the binary *selection* resolves to) and
//! sidesteps the Windows running-exe lock (you cannot overwrite a live `.exe`).
//!
//! **Held AND functional (doyle F1).** Proving the endpoints are *held* (pid /
//! session_count / conn_count unchanged) is not proving they are *alive* — a
//! zombie conn or wedged PTY would pass. So after the swap the test also drives a
//! marker through the held PTY child and watches the broker's output seq advance:
//! the full round-trip (input → child → echo → broker ring) works through the
//! swapped-in brain.
// [int->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
// [int->REQ-UPD-3]
// [int->REQ-DAEMON-2]

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

use spt_daemon::brain::Brain;
use spt_daemon::brainproc::{supervise_brain, BrainRestart, NoTrialEnv, StartReason};
use spt_daemon::codec::write_frame;
use spt_daemon::endpoint::broker_socket_name;
use spt_daemon::frame::{Envelope, Role};
use spt_daemon::msg::{encode_bytes, InputReq, SpawnReq, KIND_INPUT};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::transport::{send_hello, LocalSocketTransport};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_proto::identity::Identity;

mod common;
use common::CommandNoWindowExt;

/// A long-lived echo child to host under a broker PTY (the endpoint REQ-UPD-3
/// forbids us from disturbing across the swap). `cat`/`findstr` idle on stdin.
fn echo_spawn_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq {
        program,
        args,
        rows: 24,
        cols: 80,
        endpoint: String::new(),
    }
}

/// A hermetic net config (mirrors `netbroker.rs`): relays disabled, no discovery,
/// loopback-only sockets — no real interface touched, no firewall prompt.
fn hermetic(identity: Identity) -> NetConfig {
    NetConfig {
        identity,
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
        membership: None,
    }
}

/// Bind a broker (serving on a background thread) that owns a hermetic QUIC
/// endpoint for `identity`, with its effect journal under `dir`.
fn net_broker(name: &str, identity: Identity, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic(identity)).expect("net host start");
    let broker = Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host))
        .expect("bind broker with net");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..400 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect to {name}");
}

/// The three breadcrumb fields out of a `brain.ready` body (parsed inline so this
/// test depends on no private brainproc helper). `None` until the file exists and
/// carries a parseable pid.
fn read_ready(path: &std::path::Path) -> Option<(u32, u64, Option<String>)> {
    let body = std::fs::read_to_string(path).ok()?;
    let v: serde_json::Value = serde_json::from_str(&body).ok()?;
    let pid = v.get("pid")?.as_u64()? as u32;
    let generation = v.get("generation").and_then(|g| g.as_u64()).unwrap_or(0);
    let exe_hash = v
        .get("exe_hash")
        .and_then(|h| h.as_str())
        .map(str::to_string);
    Some((pid, generation, exe_hash))
}

/// Poll `brain.ready` until it holds a pid (optionally one DIFFERENT from `was`),
/// returning `(pid, generation, exe_hash)`. `None` if it never appears in time.
fn wait_ready(
    path: &std::path::Path,
    was: Option<u32>,
    budget: Duration,
) -> Option<(u32, u64, Option<String>)> {
    let deadline = Instant::now() + budget;
    while Instant::now() < deadline {
        if let Some((pid, gen, hash)) = read_ready(path) {
            if was != Some(pid) {
                return Some((pid, gen, hash));
            }
        }
        thread::sleep(Duration::from_millis(50));
    }
    None
}

/// Lowercase-hex SHA-256 of a file (the same digest `brain.ready.exe_hash`
/// carries; recomputed independently here to assert the breadcrumb matches the
/// fixture the supervisor actually spawned).
fn file_hash(path: &std::path::Path) -> String {
    use sha2::{Digest, Sha256};
    let bytes = std::fs::read(path).expect("read fixture for hashing");
    let mut h = Sha256::new();
    h.update(&bytes);
    let digest = h.finalize();
    let mut s = String::with_capacity(digest.len() * 2);
    for b in digest {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

#[test]
fn pty_and_quic_survive_brain_process_restart_onto_swapped_binary() {
    // The in-process broker + brain children both resolve `broker_socket_name()`
    // from SPT_HOME, so the supervised child connects to OUR broker. (The
    // established daemon-test pattern — daemon_e2e.rs et al. set it the same way.)
    let home = tempfile::tempdir().expect("tempdir");
    // Single-test binary by design — this is the SOLE `#[test]` here, so it owns
    // the process-global SPT_HOME on its own thread (cross-binary tests are
    // separate processes). A SECOND `#[test]` added to this file MUST bring the
    // env-lock discipline (a shared mutex / serialized home), or it will race this
    // one — the D6 env-race lesson (see the spt-daemon test_home pattern).
    std::env::set_var("SPT_HOME", home.path());

    // ── Two fixtures: A, and B = A + appended padding (the on-disk "swap"). ──
    // Both are the real `spt` binary, so `daemon brain` runs; B differs only by
    // trailing bytes (behavior identical, exe_hash flips).
    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let suffix = spt_bin.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
    let fixture_a = home.path().join(format!("brain-A{suffix}"));
    let fixture_b = home.path().join(format!("brain-B{suffix}"));
    std::fs::copy(&spt_bin, &fixture_a).expect("stage fixture A");
    std::fs::copy(&spt_bin, &fixture_b).expect("stage fixture B");
    {
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(&fixture_b)
            .expect("open fixture B to append padding");
        f.write_all(&[0u8; 4096]).expect("append padding to fixture B");
    }
    let hash_a = file_hash(&fixture_a);
    let hash_b = file_hash(&fixture_b);
    assert_ne!(hash_a, hash_b, "the swap must flip the binary hash");

    // ── The broker (leg-b stable kernel) + a loopback QUIC peer. ──
    let broker = net_broker(&broker_socket_name(), Identity::generate(), &home.path().join("a"));
    let peer_name = format!("spt-d7-peer-{}.sock", std::process::id());
    let _peer = net_broker(&peer_name, Identity::generate(), &home.path().join("b"));

    // ── Establish the two endpoints ONCE via a test-driver brain, then drop it
    //    HARD (the D4 hard-kill posture — the outgoing side is gone before the
    //    supervised child resumes). ──
    let (sid, child_pid) = {
        let mut driver = connect_retry(&broker_socket_name());

        // (1) A hosted PTY child the broker holds.
        let sid = driver.spawn_session(echo_spawn_req()).expect("spawn session");
        let child_pid = broker.session_pid(sid).expect("hosted child has a pid");

        // (2) A live QUIC connection the broker holds: dial the loopback peer.
        let peer_addr = {
            let mut peer_brain = connect_retry(&peer_name);
            peer_brain.net_status().expect("peer status").addr
        };
        driver.net_dial(peer_addr, None).expect("dial the loopback peer");

        // Both endpoints are now broker-held.
        assert_eq!(broker.session_count(), 1, "one hosted session established");
        assert_eq!(broker.net_conn_count(), 1, "one live QUIC conn established");
        (sid, child_pid)
        // driver dropped here — HARD detach (no graceful handoff frame).
    };

    // ── Start the production supervisor, spawning the REAL `spt daemon brain`
    //    child from the currently-selected fixture (A). NoTrialEnv = the
    //    pre-D6 respawn loop (no readiness gate); the swap is driven by the
    //    planned-restart signal, exactly as a real `apply` drives it (D3-3). ──
    let ready_path = home.path().join("brain.ready");
    let stop = Arc::new(AtomicBool::new(false));
    let restart = Arc::new(BrainRestart::new());
    let selected = Arc::new(Mutex::new(fixture_a.clone()));
    let home_path = home.path().to_path_buf();

    let sup = {
        let stop = Arc::clone(&stop);
        let restart = Arc::clone(&restart);
        let selected = Arc::clone(&selected);
        thread::spawn(move || {
            supervise_brain(
                &stop,
                &restart,
                Duration::from_millis(50),
                &NoTrialEnv,
                Duration::from_secs(30),
                move |gen, reason: StartReason, _binary| {
                    let exe = selected.lock().unwrap().clone();
                    Command::new(&exe)
                        .no_window()
                        .args([
                            "daemon",
                            "brain",
                            "--generation",
                            &gen.to_string(),
                            "--start-reason",
                            reason.as_arg(),
                        ])
                        .env("SPT_HOME", &home_path)
                        .spawn()
                },
            );
        })
    };

    // ── Generation 0: the real child came up from fixture A, resumed the hosted
    //    session (the first N≥1 cursor-of-record resume), and stamped exe_hash A. ──
    let (pid0, _gen0, hash0) = wait_ready(&ready_path, None, Duration::from_secs(45))
        .unwrap_or_else(|| teardown_panic(&stop, "gen-0 brain never signalled ready"));
    assert_eq!(
        hash0.as_deref(),
        Some(hash_a.as_str()),
        "gen-0 brain.ready exe_hash must be fixture A"
    );
    assert_eq!(broker.session_count(), 1, "session survived the driver drop + resume");
    assert_eq!(
        broker.session_pid(sid),
        Some(child_pid),
        "the PTY child's pid is unchanged through the resume"
    );
    assert_eq!(broker.net_conn_count(), 1, "the QUIC conn survived the driver drop");

    // ── SWAP: flip the selected binary to B, then trigger a planned restart
    //    (what `apply` does: swap on disk, signal the brain to cycle). ──
    *selected.lock().unwrap() = fixture_b.clone();
    restart.request();

    // ── Generation 1: the supervisor respawned the brain PROCESS onto the
    //    SWAPPED binary. New pid, exe_hash == B != A (NEW BYTES RAN). ──
    let (pid1, _gen1, hash1) = wait_ready(&ready_path, Some(pid0), Duration::from_secs(45))
        .unwrap_or_else(|| teardown_panic(&stop, "gen-1 brain never respawned onto the swap"));
    assert_ne!(pid0, pid1, "the respawned brain is a fresh process");
    assert_eq!(
        hash1.as_deref(),
        Some(hash_b.as_str()),
        "post-swap exe_hash must be fixture B — the supervisor ran the NEW bytes, \
         not a relaunch of the old (the property brain_swap.rs cannot prove)"
    );
    assert_ne!(
        hash1.as_deref(),
        Some(hash_a.as_str()),
        "post-swap exe_hash must differ from the pre-swap binary"
    );

    // ── THE SURVIVAL INVARIANT: both broker-held endpoints are untouched across
    //    the brain-PROCESS swap. ──
    assert_eq!(
        broker.session_pid(sid),
        Some(child_pid),
        "REQ-UPD-3: the hosted PTY child's pid is unchanged across the brain-process swap"
    );
    assert_eq!(broker.session_count(), 1, "exactly one hosted session throughout");
    assert_eq!(
        broker.net_conn_count(),
        1,
        "the live QUIC conn the broker holds survived the brain-process swap (SPIKE-03 in production topology)"
    );

    // ── FUNCTIONAL survival (doyle F1, plan choreography 5(d)): "held" is not
    //    "alive". Drive a marker through the held PTY child POST-swap and watch the
    //    broker-side output seq advance — proving the full round-trip (input →
    //    child → echo → broker ring) works through the swapped-in brain, not that
    //    a zombie handle merely lingers. The input rides the KIND_INPUT effect verb
    //    on a FRESH raw connection, which does NOT steal the supervised brain's
    //    output subscription (it is not an attach). ──
    let seq_before = broker
        .session_output_seq(sid)
        .unwrap_or_else(|| teardown_panic(&stop, "hosted session vanished before the functional probe"));
    {
        let mut raw = LocalSocketTransport::connect(&broker_socket_name())
            .unwrap_or_else(|_| teardown_panic(&stop, "could not open a raw input connection"));
        send_hello(&mut raw, Role::Brain)
            .unwrap_or_else(|_| teardown_panic(&stop, "raw hello failed"));
        let input = InputReq {
            session_id: sid,
            data_b64: encode_bytes(b"D7-1 POST-SWAP FUNCTIONAL PROBE\r"),
            op_id: None,
        };
        write_frame(
            &mut raw,
            &Envelope::new(KIND_INPUT, serde_json::to_value(input).expect("InputReq serializes")),
        )
        .unwrap_or_else(|_| teardown_panic(&stop, "could not write the marker input frame"));
    }
    // The echo child (cat/findstr) echoes the marker → output appended → next_seq
    // advances. Poll the broker's own log high-water.
    let mut advanced = false;
    let deadline = Instant::now() + Duration::from_secs(15);
    while Instant::now() < deadline {
        if broker.session_output_seq(sid).map(|s| s > seq_before).unwrap_or(false) {
            advanced = true;
            break;
        }
        thread::sleep(Duration::from_millis(50));
    }
    if !advanced {
        teardown_panic(
            &stop,
            "the held PTY child produced no output for a post-swap marker — endpoint held but DEAD",
        );
    }

    // ── Teardown: stop the supervisor (kills the live brain child), reap. ──
    stop.store(true, Ordering::SeqCst);
    let _ = sup.join();
    // The hosted child dies with the broker drop at end-of-scope.
    let _ = broker;
}

/// Raise `stop` (so the supervisor thread kills any live brain child and exits)
/// and panic with `msg` — keeps a failing assertion from orphaning a brain.
fn teardown_panic(stop: &AtomicBool, msg: &str) -> ! {
    stop.store(true, Ordering::SeqCst);
    panic!("{msg}");
}


deployah is authorized to publish when it's ready. just message them


Harness understood. T4's faithful reproduction (current_exe-follows-rename) needs a real broker *process* whose `current_exe` is the renamed binary — heavier than the path-flip. Checking the gate-suite result + reading `brain_split.rs` (leg-a, the real-`daemon run` process pattern) to model it.


Bash

echo "=== gate suite (b76180bn2) tail ==="; tail -20 "C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\c570fe63-c413-4a17-bb14-ee75e02a53bd\tasks\b76180bn2.output" 2>/dev/null
echo "=== brain_split.rs head ==="; sed -n '1,60p' crates/spt/tests/brain_split.rs

Gate result + brain_split.rs pattern


=== gate suite (b76180bn2) tail ===
=== build workspace ===
   Compiling spt v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 32.04s
=== test workspace ===
=== brain_split.rs head ===
//! Restoration D1 process-level smoke (ADR-0018 Q2/Q3) — the broker/brain
//! process boundary, proven against the REAL `spt daemon run` binary.
//!
//! This is the de-risk spike #1 in production topology: the broker is the
//! always-up per-machine anchor that spawns + supervises a `spt daemon brain`
//! child, and **the broker survives the brain dying** — the supervisor respawns
//! the brain, which reconnects to the still-serving broker socket. A respawned
//! brain (a NEW pid in `brain.ready`) can only appear if the broker stayed alive
//! to accept its connection, so the respawn IS the broker-survival proof.
//!
//! D1 is a true minimal skeleton: the brain child connects, signals ready, and
//! idles; the logic loops stay broker-side until D2. So this test asserts only
//! the boundary — come-up, survive-brain-kill, respawn — not any logic.
//!
//! The full PTY-child + live-QUIC survival across a swap is the D7 `int` E2E
//! (REQ-UPD-3 re-point); here the process-level boundary is `unit`-tagged.
// [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]

use std::path::PathBuf;
use std::process::{Child, Command};
use std::thread;
use std::time::{Duration, Instant};

mod common;
use common::CommandNoWindowExt;

/// Best-effort cross-platform kill of a pid (mirrors the other E2E helpers).
fn kill_pid(pid: u32) {
    #[cfg(windows)]
    let _ = Command::new("taskkill")
        .no_window()
        .args(["/PID", &pid.to_string(), "/F", "/T"])
        .output();
    #[cfg(unix)]
    let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
}

/// Read the `pid` out of a `brain.ready` body. As of restoration D6-1b the
/// breadcrumb is self-describing JSON `{pid, generation}` (was bare-pid text);
/// this is the only reader, migrated in the same commit.
fn ready_pid(body: &str) -> Option<u32> {
    serde_json::from_str::<serde_json::Value>(body)
        .ok()?
        .get("pid")?
        .as_u64()
        .map(|p| p as u32)
}

/// Poll `brain.ready` for a pid, up to `budget`. `None` if it never appears.
fn wait_for_ready_pid(path: &PathBuf, budget: Duration) -> Option<u32> {
    let deadline = Instant::now() + budget;
    while Instant::now() < deadline {
        if let Ok(s) = std::fs::read_to_string(path) {
            if let Some(pid) = ready_pid(&s) {
                return Some(pid);
            }
        }
        thread::sleep(Duration::from_millis(50));
    }
    None


Gate suite still running the workspace tests (background). Reading how `brain_split.rs` launches the real `spt daemon run` from a copied binary (the pattern where `current_exe` = the copied binary, enabling a faithful rename test).


66	    let deadline = Instant::now() + budget;
67	    while Instant::now() < deadline {
68	        if let Ok(s) = std::fs::read_to_string(path) {
69	            if let Some(pid) = ready_pid(&s) {
70	                if pid != was {
71	                    return Some(pid);
72	                }
73	            }
74	        }
75	        thread::sleep(Duration::from_millis(50));
76	    }
77	    None
78	}
79
80	/// Whether a daemon answers the seed-control channel for `home`, asked the way
81	/// any user would — `spt daemon status` (read-only; its liveness line is the
82	/// seed-socket ping, `cli.rs::cmd_daemon_status`). True iff stdout reports
83	/// `daemon: running` (never a substring of the `daemon: not running` line, so
84	/// the match is unambiguous).
85	fn daemon_running(spt_bin: &PathBuf, home: &std::path::Path) -> bool {
86	    let out = Command::new(spt_bin)
87	        .no_window()
88	        .args(["daemon", "status"])
89	        .env("SPT_HOME", home)
90	        .output()
91	        .expect("run spt daemon status");
92	    String::from_utf8_lossy(&out.stdout).contains("daemon: running")
93	}
94
95	/// D3-1 (ADR-0018 Q2) — the seed-control lock + liveness is the broker's, so it
96	/// is the **always-up anchor**: a brain restart must never perturb it. The
97	/// single-daemon-per-`SPT_HOME` ping (`is_running`, what `ensure_running` /
98	/// `daemon stop` target) lives in the broker's foreground loop
99	/// (`daemon.rs::Daemon::run`), NOT the brain — so killing the brain and watching
100	/// the supervisor respawn it must leave `daemon: running` **continuously** true.
101	/// If the seed anchor ever rode the brain, this is the test that would catch it
102	/// flapping. External `ensure_running`/`is_running`/`daemon stop` behavior is
103	/// unchanged — this only proves the owner process is the survivor.
104	// [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
105	#[test]
106	fn seed_anchor_survives_brain_cycle() {
107	    let home = tempfile::tempdir().unwrap();
108	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
109	    let ready = home.path().join("brain.ready");
110
111	    let mut broker: Child = Command::new(&spt_bin)
112	        .no_window()
113	        .args(["daemon", "run"])
114	        .env("SPT_HOME", home.path())
115	        .spawn()
116	        .expect("spawn spt daemon run");
117
118	    // Come-up: a brain child is ready AND the seed anchor answers.
119	    let pid1 = wait_for_ready_pid(&ready, Duration::from_secs(30)).unwrap_or_else(|| {
120	        let _ = broker.kill();
121	        panic!("brain child never signalled ready");
122	    });
123	    assert!(
124	        daemon_running(&spt_bin, home.path()),
125	        "seed anchor must answer once the daemon is up"
126	    );
127
128	    // Kill the brain; while the supervisor respawns it, the seed anchor (in the
129	    // never-restarting broker) must stay continuously up. Sample across the whole
130	    // respawn gap — a single not-running read is a failure (the anchor flapped).
131	    kill_pid(pid1);
132	    let deadline = Instant::now() + Duration::from_secs(30);
133	    let mut pid2 = None;
134	    while Instant::now() < deadline {
135	        assert!(
136	            daemon_running(&spt_bin, home.path()),
137	            "seed anchor dropped during a brain respawn — it must not ride the brain"
138	        );
139	        if let Ok(s) = std::fs::read_to_string(&ready) {
140	            if let Some(pid) = ready_pid(&s) {
141	                if pid != pid1 {
142	                    pid2 = Some(pid);
143	                    break;
144	                }
145	            }
146	        }
147	        thread::sleep(Duration::from_millis(100));
148	    }
149	    let pid2 = pid2.unwrap_or_else(|| {
150	        kill_pid(pid1);
151	        let _ = broker.kill();
152	        panic!("supervisor did not respawn the brain");
153	    });
154	    assert_ne!(pid1, pid2, "the respawned brain is a fresh process");
155	    assert!(
156	        daemon_running(&spt_bin, home.path()),
157	        "seed anchor must still answer after the brain respawned"
158	    );
159	    assert!(
160	        matches!(broker.try_wait(), Ok(None)),
161	        "broker must survive the brain cycle (it is the anchor)"
162	    );
163
164	    kill_pid(pid2);
165	    let _ = broker.kill();
166	    let _ = broker.wait();
167	}
168
169	#[test]
170	fn broker_survives_brain_kill_and_respawns_it() {
171	    let home = tempfile::tempdir().unwrap();
172	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
173	    let ready = home.path().join("brain.ready");
174
175	    // Start the REAL broker process (`spt daemon run`) on an isolated home.
176	    let mut broker: Child = Command::new(&spt_bin)
177	        .no_window()
178	        .args(["daemon", "run"])
179	        .env("SPT_HOME", home.path())
180	        .spawn()
181	        .expect("spawn spt daemon run");
182
183	    // ── 1. come-up: the broker spawned a brain child that signalled ready ──────
184	    let pid1 = wait_for_ready_pid(&ready, Duration::from_secs(30)).unwrap_or_else(|| {
185	        let _ = broker.kill();

    let deadline = Instant::now() + budget;
    while Instant::now() < deadline {
        if let Ok(s) = std::fs::read_to_string(path) {
            if let Some(pid) = ready_pid(&s) {
                if pid != was {
                    return Some(pid);
                }
            }
        }
        thread::sleep(Duration::from_millis(50));
    }
    None
}

/// Whether a daemon answers the seed-control channel for `home`, asked the way
/// any user would — `spt daemon status` (read-only; its liveness line is the
/// seed-socket ping, `cli.rs::cmd_daemon_status`). True iff stdout reports
/// `daemon: running` (never a substring of the `daemon: not running` line, so
/// the match is unambiguous).
fn daemon_running(spt_bin: &PathBuf, home: &std::path::Path) -> bool {
    let out = Command::new(spt_bin)
        .no_window()
        .args(["daemon", "status"])
        .env("SPT_HOME", home)
        .output()
        .expect("run spt daemon status");
    String::from_utf8_lossy(&out.stdout).contains("daemon: running")
}

/// D3-1 (ADR-0018 Q2) — the seed-control lock + liveness is the broker's, so it
/// is the **always-up anchor**: a brain restart must never perturb it. The
/// single-daemon-per-`SPT_HOME` ping (`is_running`, what `ensure_running` /
/// `daemon stop` target) lives in the broker's foreground loop
/// (`daemon.rs::Daemon::run`), NOT the brain — so killing the brain and watching
/// the supervisor respawn it must leave `daemon: running` **continuously** true.
/// If the seed anchor ever rode the brain, this is the test that would catch it
/// flapping. External `ensure_running`/`is_running`/`daemon stop` behavior is
/// unchanged — this only proves the owner process is the survivor.
// [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
#[test]
fn seed_anchor_survives_brain_cycle() {
    let home = tempfile::tempdir().unwrap();
    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let ready = home.path().join("brain.ready");

    let mut broker: Child = Command::new(&spt_bin)
        .no_window()
        .args(["daemon", "run"])
        .env("SPT_HOME", home.path())
        .spawn()
        .expect("spawn spt daemon run");

    // Come-up: a brain child is ready AND the seed anchor answers.
    let pid1 = wait_for_ready_pid(&ready, Duration::from_secs(30)).unwrap_or_else(|| {
        let _ = broker.kill();
        panic!("brain child never signalled ready");
    });
    assert!(
        daemon_running(&spt_bin, home.path()),
        "seed anchor must answer once the daemon is up"
    );

    // Kill the brain; while the supervisor respawns it, the seed anchor (in the
    // never-restarting broker) must stay continuously up. Sample across the whole
    // respawn gap — a single not-running read is a failure (the anchor flapped).
    kill_pid(pid1);
    let deadline = Instant::now() + Duration::from_secs(30);
    let mut pid2 = None;
    while Instant::now() < deadline {
        assert!(
            daemon_running(&spt_bin, home.path()),
            "seed anchor dropped during a brain respawn — it must not ride the brain"
        );
        if let Ok(s) = std::fs::read_to_string(&ready) {
            if let Some(pid) = ready_pid(&s) {
                if pid != pid1 {
                    pid2 = Some(pid);
                    break;
                }
            }
        }
        thread::sleep(Duration::from_millis(100));
    }
    let pid2 = pid2.unwrap_or_else(|| {
        kill_pid(pid1);
        let _ = broker.kill();
        panic!("supervisor did not respawn the brain");
    });
    assert_ne!(pid1, pid2, "the respawned brain is a fresh process");
    assert!(
        daemon_running(&spt_bin, home.path()),
        "seed anchor must still answer after the brain respawned"
    );
    assert!(
        matches!(broker.try_wait(), Ok(None)),
        "broker must survive the brain cycle (it is the anchor)"
    );

    kill_pid(pid2);
    let _ = broker.kill();
    let _ = broker.wait();
}

#[test]
fn broker_survives_brain_kill_and_respawns_it() {
    let home = tempfile::tempdir().unwrap();
    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let ready = home.path().join("brain.ready");

    // Start the REAL broker process (`spt daemon run`) on an isolated home.
    let mut broker: Child = Command::new(&spt_bin)
        .no_window()
        .args(["daemon", "run"])
        .env("SPT_HOME", home.path())
        .spawn()
        .expect("spawn spt daemon run");

    // ── 1. come-up: the broker spawned a brain child that signalled ready ──────
    let pid1 = wait_for_ready_pid(&ready, Duration::from_secs(30)).unwrap_or_else(|| {
        let _ = broker.kill();


Now I see the faithful T4: launch the **real broker process from a canonical path P** (so its `current_exe` *is* P), then do the real apply choreography — rename P→P.old-N, write new bytes at P — and kill the brain. The crash-respawn must land on the applied bytes at P. **Pre-fix on Linux** the broker's per-spawn `current_exe()` follows the rename to P.old-N (bytes A) → assert fails; **post-fix** it spawns the captured P (bytes B). Windows green either way (never followed the rename). Writing T4 while the gate suite finishes.


Write

//! v0.4.2 / KH 6.11 (`REQ-HAZARD-BRAIN-RESPAWN-PATH`) — the brain respawns onto
//! the **applied bytes** after a real IN-PLACE rename, not the renamed old binary.
//!
//! ## Why this is a NEW test, not a tweak to `brain_survive.rs`
//!
//! `brain_survive.rs` models the binary swap as a **path flip** between two
//! distinct fixtures through an injected spawn closure — it never renames a
//! *running* binary, so it cannot exercise the production respawn resolution
//! (`std::env::current_exe()` in the resident broker). That gap let the v0.4.1
//! Linux brain-respawn bug ship green: on Linux `current_exe()` is
//! `readlink(/proc/self/exe)`, **inode-tracking**, so it FOLLOWS the `apply`
//! rename (`spt` → `spt.old-N`) and the resident broker respawned the brain onto
//! the OLD bytes while recording `applied`. Windows `GetModuleFileName` returns
//! the path string captured at process start, so it never followed the rename.
//!
//! ## The faithful reproduction
//!
//! This test runs the **real `spt daemon run` broker process** launched from a
//! canonical path `P` (so the broker's own `current_exe` *is* `P`), performs the
//! real on-disk apply choreography (**rename running `P` → `P.old-N`, write the
//! new bytes at `P`**), then triggers a brain respawn. The respawned brain must
//! run the bytes now at `P` (`exe_hash == B`), proving the broker respawns from
//! the canonical path captured at start — NOT a per-spawn `current_exe()` that
//! would, on Linux, follow the rename back to the old bytes.
//!
//! **Platform-honest regression guard.** PRE-fix this FAILS on Linux (the
//! respawn lands on `P.old-N`, `exe_hash == A`) and passes on Windows (which
//! never followed the rename). POST-fix it passes on both. So on CI it is green
//! on both runners only because the fix is in; a regression re-arms the Linux red.
// [int->REQ-HAZARD-BRAIN-RESPAWN-PATH]

use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::thread;
use std::time::{Duration, Instant};

mod common;
use common::CommandNoWindowExt;

/// `(pid, exe_hash)` out of a `brain.ready` body; `None` until it carries a pid.
fn read_ready(path: &Path) -> Option<(u32, Option<String>)> {
    let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
    let pid = v.get("pid")?.as_u64()? as u32;
    let exe_hash = v
        .get("exe_hash")
        .and_then(|h| h.as_str())
        .map(str::to_string);
    Some((pid, exe_hash))
}

/// Poll `brain.ready` until it holds a pid different from `was`.
fn wait_ready(path: &Path, was: Option<u32>, budget: Duration) -> Option<(u32, Option<String>)> {
    let deadline = Instant::now() + budget;
    while Instant::now() < deadline {
        if let Some((pid, hash)) = read_ready(path) {
            if was != Some(pid) {
                return Some((pid, hash));
            }
        }
        thread::sleep(Duration::from_millis(50));
    }
    None
}

/// Lowercase-hex SHA-256 of a file — the digest `brain.ready.exe_hash` carries.
fn file_hash(path: &Path) -> String {
    use sha2::{Digest, Sha256};
    let bytes = std::fs::read(path).expect("read file for hashing");
    let mut h = Sha256::new();
    h.update(&bytes);
    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}

fn kill_pid(pid: u32) {
    #[cfg(windows)]
    let _ = Command::new("taskkill")
        .no_window()
        .args(["/PID", &pid.to_string(), "/F", "/T"])
        .output();
    #[cfg(unix)]
    let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
}

#[test]
fn brain_respawns_onto_applied_bytes_after_in_place_rename() {
    let home = tempfile::tempdir().expect("tempdir");
    let bindir = home.path().join("bin");
    std::fs::create_dir_all(&bindir).expect("create bindir");
    let real = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let suffix = real
        .extension()
        .map(|e| format!(".{}", e.to_string_lossy()))
        .unwrap_or_default();

    // The canonical install path P the broker runs from — so the broker's own
    // current_exe IS P (the resident-broker condition the bug needs).
    let canonical = bindir.join(format!("spt{suffix}"));
    std::fs::copy(&real, &canonical).expect("install bytes A at canonical P");
    let hash_a = file_hash(&canonical);

    // The REAL broker process, launched FROM P.
    let ready = home.path().join("brain.ready");
    let mut broker: Child = Command::new(&canonical)
        .no_window()
        .args(["daemon", "run"])
        .env("SPT_HOME", home.path())
        .spawn()
        .expect("spawn the real broker from canonical P");

    // gen-0 brain came up from P (bytes A).
    let (pid0, hash0) = wait_ready(&ready, None, Duration::from_secs(45)).unwrap_or_else(|| {
        let _ = broker.kill();
        panic!("gen-0 brain never signalled ready");
    });
    assert_eq!(
        hash0.as_deref(),
        Some(hash_a.as_str()),
        "gen-0 brain runs bytes A (the canonical install)"
    );

    // ── The real apply choreography on disk: rename the running canonical binary
    //    P -> P.old-8, then write the NEW bytes (B = A + padding) at P. Exactly
    //    what `spt update apply` does (rename-then-replace). ──
    let old_n = bindir.join(format!("spt.old-8{suffix}"));
    std::fs::rename(&canonical, &old_n).expect("rename running P -> P.old-8 (the apply rename)");
    std::fs::copy(&real, &canonical).expect("write fresh bytes at P");
    {
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(&canonical)
            .expect("open the new P to append padding");
        f.write_all(&[0u8; 4096]).expect("append padding -> bytes B");
    }
    let hash_b = file_hash(&canonical);
    assert_ne!(hash_a, hash_b, "the swap must flip the binary hash");

    // ── Trigger a brain respawn (kill the brain; the supervisor respawns it). The
    //    respawn must land on the APPLIED bytes now at P (the canonical path the
    //    broker captured at start), NOT the renamed P.old-8. Pre-fix on Linux the
    //    broker's per-spawn current_exe() (readlink /proc/self/exe) follows the
    //    rename to P.old-8 and respawns bytes A → this assert fails; post-fix it
    //    spawns the captured P (bytes B). Windows never followed the rename. ──
    kill_pid(pid0);
    let (pid1, hash1) = wait_ready(&ready, Some(pid0), Duration::from_secs(45)).unwrap_or_else(|| {
        kill_pid(pid0);
        let _ = broker.kill();
        panic!("supervisor never respawned the brain after the rename");
    });
    assert_ne!(pid0, pid1, "the respawned brain is a fresh process");
    assert_eq!(
        hash1.as_deref(),
        Some(hash_b.as_str()),
        "post-rename respawn must run the APPLIED bytes at the canonical path (B), \
         not the renamed old binary (A) — KH 6.11 / REQ-HAZARD-BRAIN-RESPAWN-PATH"
    );

    // Teardown.
    kill_pid(pid1);
    let _ = broker.kill();
    let _ = broker.wait();
}


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

//! v0.4.2 / KH 6.11 (`REQ-HAZARD-BRAIN-RESPAWN-PATH`) — the brain respawns onto
//! the **applied bytes** after a real IN-PLACE rename, not the renamed old binary.
//!
//! ## Why this is a NEW test, not a tweak to `brain_survive.rs`
//!
//! `brain_survive.rs` models the binary swap as a **path flip** between two
//! distinct fixtures through an injected spawn closure — it never renames a
//! *running* binary, so it cannot exercise the production respawn resolution
//! (`std::env::current_exe()` in the resident broker). That gap let the v0.4.1
//! Linux brain-respawn bug ship green: on Linux `current_exe()` is
//! `readlink(/proc/self/exe)`, **inode-tracking**, so it FOLLOWS the `apply`
//! rename (`spt` → `spt.old-N`) and the resident broker respawned the brain onto
//! the OLD bytes while recording `applied`. Windows `GetModuleFileName` returns
//! the path string captured at process start, so it never followed the rename.
//!
//! ## The faithful reproduction
//!
//! This test runs the **real `spt daemon run` broker process** launched from a
//! canonical path `P` (so the broker's own `current_exe` *is* `P`), performs the
//! real on-disk apply choreography (**rename running `P` → `P.old-N`, write the
//! new bytes at `P`**), then triggers a brain respawn. The respawned brain must
//! run the bytes now at `P` (`exe_hash == B`), proving the broker respawns from
//! the canonical path captured at start — NOT a per-spawn `current_exe()` that
//! would, on Linux, follow the rename back to the old bytes.
//!
//! **Platform-honest regression guard.** PRE-fix this FAILS on Linux (the
//! respawn lands on `P.old-N`, `exe_hash == A`) and passes on Windows (which
//! never followed the rename). POST-fix it passes on both. So on CI it is green
//! on both runners only because the fix is in; a regression re-arms the Linux red.
// [int->REQ-HAZARD-BRAIN-RESPAWN-PATH]

use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::thread;
use std::time::{Duration, Instant};

mod common;
use common::CommandNoWindowExt;

/// `(pid, exe_hash)` out of a `brain.ready` body; `None` until it carries a pid.
fn read_ready(path: &Path) -> Option<(u32, Option<String>)> {
    let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
    let pid = v.get("pid")?.as_u64()? as u32;
    let exe_hash = v
        .get("exe_hash")
        .and_then(|h| h.as_str())
        .map(str::to_string);
    Some((pid, exe_hash))
}

/// Poll `brain.ready` until it holds a pid different from `was`.
fn wait_ready(path: &Path, was: Option<u32>, budget: Duration) -> Option<(u32, Option<String>)> {
    let deadline = Instant::now() + budget;
    while Instant::now() < deadline {
        if let Some((pid, hash)) = read_ready(path) {
            if was != Some(pid) {
                return Some((pid, hash));
            }
        }
        thread::sleep(Duration::from_millis(50));
    }
    None
}

/// Lowercase-hex SHA-256 of a file — the digest `brain.ready.exe_hash` carries.
fn file_hash(path: &Path) -> String {
    use sha2::{Digest, Sha256};
    let bytes = std::fs::read(path).expect("read file for hashing");
    let mut h = Sha256::new();
    h.update(&bytes);
    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}

fn kill_pid(pid: u32) {
    #[cfg(windows)]
    let _ = Command::new("taskkill")
        .no_window()
        .args(["/PID", &pid.to_string(), "/F", "/T"])
        .output();
    #[cfg(unix)]
    let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
}

#[test]
fn brain_respawns_onto_applied_bytes_after_in_place_rename() {
    let home = tempfile::tempdir().expect("tempdir");
    let bindir = home.path().join("bin");
    std::fs::create_dir_all(&bindir).expect("create bindir");
    let real = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let suffix = real
        .extension()
        .map(|e| format!(".{}", e.to_string_lossy()))
        .unwrap_or_default();

    // The canonical install path P the broker runs from — so the broker's own
    // current_exe IS P (the resident-broker condition the bug needs).
    let canonical = bindir.join(format!("spt{suffix}"));
    std::fs::copy(&real, &canonical).expect("install bytes A at canonical P");
    let hash_a = file_hash(&canonical);

    // The REAL broker process, launched FROM P.
    let ready = home.path().join("brain.ready");
    let mut broker: Child = Command::new(&canonical)
        .no_window()
        .args(["daemon", "run"])
        .env("SPT_HOME", home.path())
        .spawn()
        .expect("spawn the real broker from canonical P");

    // gen-0 brain came up from P (bytes A).
    let (pid0, hash0) = wait_ready(&ready, None, Duration::from_secs(45)).unwrap_or_else(|| {
        let _ = broker.kill();
        panic!("gen-0 brain never signalled ready");
    });
    assert_eq!(
        hash0.as_deref(),
        Some(hash_a.as_str()),
        "gen-0 brain runs bytes A (the canonical install)"
    );

    // ── The real apply choreography on disk: rename the running canonical binary
    //    P -> P.old-8, then write the NEW bytes (B = A + padding) at P. Exactly
    //    what `spt update apply` does (rename-then-replace). ──
    let old_n = bindir.join(format!("spt.old-8{suffix}"));
    std::fs::rename(&canonical, &old_n).expect("rename running P -> P.old-8 (the apply rename)");
    std::fs::copy(&real, &canonical).expect("write fresh bytes at P");
    {
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(&canonical)
            .expect("open the new P to append padding");
        f.write_all(&[0u8; 4096]).expect("append padding -> bytes B");
    }
    let hash_b = file_hash(&canonical);
    assert_ne!(hash_a, hash_b, "the swap must flip the binary hash");

    // ── Trigger a brain respawn (kill the brain; the supervisor respawns it). The
    //    respawn must land on the APPLIED bytes now at P (the canonical path the
    //    broker captured at start), NOT the renamed P.old-8. Pre-fix on Linux the
    //    broker's per-spawn current_exe() (readlink /proc/self/exe) follows the
    //    rename to P.old-8 and respawns bytes A → this assert fails; post-fix it
    //    spawns the captured P (bytes B). Windows never followed the rename. ──
    kill_pid(pid0);
    let (pid1, hash1) = wait_ready(&ready, Some(pid0), Duration::from_secs(45)).unwrap_or_else(|| {
        kill_pid(pid0);
        let _ = broker.kill();
        panic!("supervisor never respawned the brain after the rename");
    });
    assert_ne!(pid0, pid1, "the respawned brain is a fresh process");
    assert_eq!(
        hash1.as_deref(),
        Some(hash_b.as_str()),
        "post-rename respawn must run the APPLIED bytes at the canonical path (B), \
         not the renamed old binary (A) — KH 6.11 / REQ-HAZARD-BRAIN-RESPAWN-PATH"
    );

    // Teardown.
    kill_pid(pid1);
    let _ = broker.kill();
    let _ = broker.wait();
}


T4 written. Checking the T2+T3 gate suite result.


1	=== build workspace ===
2	   Compiling spt v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
3	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 32.04s
4	=== test workspace ===
5

=== build workspace ===
   Compiling spt v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 32.04s
=== test workspace ===


Gate suite still running workspace tests (heavy E2Es — minutes). Waiting for completion; will commit T2+T3 on green, then run T4 + final gates.

**Progress so far this build:**
- T1 (doc) committed `8aa309e` — REQ minted, KH 6.11, ADR-0018 Q3 amendment
- T2 (impl) — Half 1 (canonical capture + `select_brain_exe`) + Half 2 (promotion bytes-gate + `TrialEnv` methods + `ReleaseCache::staged_artifact_hash`) ✅ daemon build/clippy green
- T3 (unit) — 4 tests (path selection, gate truth table, wrong-bytes→rollback, match→promote) ✅ 276+ daemon tests green
- T4 (int) written — real-broker-process in-place-rename regression guard, awaiting run
- doyle's LOUD-degrade addition folded (`PROMOTE_BYTES_UNVERIFIED`)

Awaiting the workspace gate suite.


<task-notification>
<task-id>b76180bn2</task-id>
<tool-use-id>toolu_01WtFwtEfQozUBhWVu8Z1Cij</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\c570fe63-c413-4a17-bb14-ee75e02a53bd\tasks\b76180bn2.output</output-file>
<status>completed</status>
<summary>Background command "Full workspace gate suite (T2+T3)" completed (exit code 0)</summary>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


1	=== build workspace ===
2	   Compiling spt v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
3	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 32.04s
4	=== test workspace ===
5	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
6	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
7	test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
8	test result: ok. 109 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 29.62s
9	test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.54s
10	test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.16s
11	test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.91s
12	test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.22s
13	test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
14	test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
15	test-rc:0
16	=== clippy workspace ===
17	    Checking xtask v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
18	    Checking spt v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
19	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 8.62s
20	=== no-default ===
21	   Compiling spt v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
22	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.39s
23	=== traceable ===
24	TR:0
25	=== xtask check ===
26	     Running `target\debug\xtask.exe check`
27	xtask check: OK
28

=== build workspace ===
   Compiling spt v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 32.04s
=== test workspace ===
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 109 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 29.62s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.54s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.16s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.91s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.22s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test-rc:0
=== clippy workspace ===
    Checking xtask v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Checking spt v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 8.62s
=== no-default ===
   Compiling spt v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.39s
=== traceable ===
TR:0
=== xtask check ===
     Running `target\debug\xtask.exe check`
xtask check: OK


T2+T3 gate suite ALL GREEN (build · workspace test · clippy · no-default · traceable TR:0 · xtask OK). Committing T2+T3 (impl+unit), holding the T4 file for its own commit.


Bash

git add crates/spt-daemon/src/brainproc.rs crates/spt-daemon/src/relcache.rs traceable-reqs.toml && git commit -q -F - <<'EOF'
fix(daemon): respawn brain from canonical exe + bytes-gate promotion (REQ-HAZARD-BRAIN-RESPAWN-PATH)

Half 1 (KH 6.11): spawn_brain_supervisor captures the canonical exe path once at
broker start; spawn_brain_child's None default uses it via select_brain_exe,
never a per-spawn current_exe() — which on Linux (readlink /proc/self/exe,
inode-tracking) follows the apply-rename (spt -> spt.old-N) and respawns OLD
bytes. Falls back to current_exe() only if the t=0 capture failed (degrade-safe).

Half 2: promotion bytes-gate in supervise_brain's Promoted arm — a trial promotes
only if the candidate's brain.ready exe_hash == the staged artifact hash for this
platform (ReleaseCache::staged_artifact_hash); a mismatch kills + rolls back +
fires the loud notif (readiness != new-bytes was the v0.4.1 kitsubito
false-success that recorded applied:8 over a v0.4.0 brain). Either hash absent
degrades to readiness-only (N-1-safe) and emits PROMOTE_BYTES_UNVERIFIED so a
disarmed gate stays field-diagnosable. TrialEnv gains ready_exe_hash +
staged_artifact_hash.

T3 unit: select_brain_exe selection table, bytes_gate truth table, and two
supervise_brain integration tests (wrong-bytes -> rollback, match -> promote).

required_stages -> [doc, impl, unit] (int lands with T4, rule 5).

[impl->REQ-HAZARD-BRAIN-RESPAWN-PATH]
[unit->REQ-HAZARD-BRAIN-RESPAWN-PATH]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
git --no-pager log -1 --oneline

Commit T2+T3 (impl+unit)


be626bf fix(daemon): respawn brain from canonical exe + bytes-gate promotion (REQ-HAZARD-BRAIN-RESPAWN-PATH)


T2+T3 committed `be626bf`. **T4** — running the new int E2E (real broker process, in-place rename). On Windows it should pass (never had the bug + fix in); it's the Linux runner that gates pre/post-fix.


cargo test -p spt --test brain_respawn_rename 2>&1 | tail -25; echo "T4-EXIT:${PIPESTATUS[0]}"

Run T4 int E2E


   Compiling hyper-rustls v0.27.9
   Compiling hickory-net v0.26.0-beta.4
   Compiling tokio-websockets v0.13.2
   Compiling reqwest v0.13.4
   Compiling hickory-resolver v0.26.0-beta.4
   Compiling noq v0.18.0
   Compiling iroh-relay v0.98.0
   Compiling iroh v0.98.2
   Compiling spt-net v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
   Compiling spt-daemon v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
   Compiling spt v0.4.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 28.73s
     Running tests\brain_respawn_rename.rs (target\debug\deps\brain_respawn_rename-2e736140efd062b3.exe)

running 1 test
BRAIN_UP: connected to broker spt-daemon-broker-54da5aad2d93b71d.sock (pid 35980, generation 0, start-reason cold)
BRAIN_NET_CONSUMERS_UP: net enabled (node 2d5b00f31fe71e92b9c2b7d2fb83fb5e891bc6973feb094cc4a657c2bee1a8a3) — dispatcher + peer pump started
PUMP_IPC_READER: spawned
BRAIN_EXIT: brain child exited (exit code: 1) — respawning
BRAIN_RESTART: supervised respawn in 4s
BRAIN_UP: connected to broker spt-daemon-broker-54da5aad2d93b71d.sock (pid 48124, generation 1, start-reason crash)
test brain_respawns_onto_applied_bytes_after_in_place_rename ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 13.24s

T4-EXIT:0


T4 GREEN on Windows — gen-0 (pid 35980, bytes A) → kill → gen-1 (pid 48124) respawned onto bytes B after the in-place rename. Bumping required_stages to include `int`, then committing T4 + final full gates.


Edit

required_stages = ["doc", "impl", "unit"]  # activated v0.4.2 (2026-06-11): minted from the v0.4.1 fleet-roll blocker. doc = KNOWN-HAZARDS 6.11 + ADR-0018 Q3 amendment. int activates with T4 (rule 5):

required_stages = ["doc", "impl", "unit", "int"]  # activated v0.4.2 (2026-06-11): minted from the v0.4.1 fleet-roll blocker. doc = KNOWN-HAZARDS 6.11 + ADR-0018 Q3 amendment; int = brain_respawn_rename.rs (real broker process, in-place rename P->P.old-N + new bytes at P, respawn must run B not A — fails Linux pre-fix). Stages:


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

title = "The broker respawns the brain onto the APPLIED bytes, not the renamed old binary: the candidate-binary default is the canonical exe path captured ONCE at broker start, never a per-spawn std::env::current_exe() — on Linux current_exe (readlink /proc/self/exe) is inode-tracking and follows the `apply` rename (spt -> spt.old-N), so a resident broker would respawn the brain onto OLD bytes while recording `applied` (Windows GetModuleFileName is path-at-start, so Windows was green; ADR-0018 Q3 silently assumed path-string semantics). Backstop: promotion gates on bytes — a trial promotes only if brain.ready exe_hash == the staged artifact hash for this platform, else auto-rollback + loud notif (readiness != new-bytes was the false-success that recorded applied:8 over a v0.4.0 brain on kitsubito, 2026-06-11). KNOWN-HAZARDS 6.11."

-required_stages = ["doc", "impl", "unit"]  # activated v0.4.2 (2026-06-11): minted from the v0.4.1 fleet-roll blocker. doc = KNOWN-HAZARDS 6.11 + ADR-0018 Q3 amendment. int activates with T4 (rule 5): impl = brainproc.rs canonical-exe capture at spawn_brain_supervisor + promotion bytes-gate in supervise_brain Promoted arm (TrialEnv ready_exe_hash/staged_artifact_hash); unit = spawn-path selection (captured-default-for-None vs per-spawn) + promotion-gate truth table (match->promote, mismatch->rollback+notif, either-absent->loud PROMOTE_BYTES_UNVERIFIED degrade-to-readiness); int = the brain_survive rename-under-supervisor sibling (real in-place rename P->P.old-N + new bytes at P through the production None selection, assert respawned exe_hash == new bytes; fails Linux pre-fix)

+required_stages = ["doc", "impl", "unit", "int"]  # activated v0.4.2 (2026-06-11): minted from the v0.4.1 fleet-roll blocker. doc = KNOWN-HAZARDS 6.11 + ADR-0018 Q3 amendment; int = brain_respawn_rename.rs (real broker process, in-place rename P->P.old-N + new bytes at P, respawn must run B not A — fails Linux pre-fix). Stages: impl = brainproc.rs canonical-exe capture at spawn_brain_supervisor + promotion bytes-gate in supervise_brain Promoted arm (TrialEnv ready_exe_hash/staged_artifact_hash); unit = spawn-path selection (captured-default-for-None vs per-spawn) + promotion-gate truth table (match->promote, mismatch->rollback+notif, either-absent->loud PROMOTE_BYTES_UNVERIFIED degrade-to-readiness); int = the brain_survive rename-under-supervisor sibling (real in-place rename P->P.old-N + new bytes at P through the production None selection, assert respawned exe_hash == new bytes; fails Linux pre-fix)

# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---


Bash

traceable-reqs check >/dev/null 2>&1; echo "TR:$?"; traceable-reqs check 2>&1 | grep -i "BRAIN-RESPAWN" | head -3

traceable check with int activated


TR:0
  [OK] REQ-HAZARD-BRAIN-RESPAWN-PATH  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int


REQ fully covered `[doc,impl,unit,int]`. Committing T4.


git add crates/spt/tests/brain_respawn_rename.rs traceable-reqs.toml && git commit -q -F - <<'EOF'
test(daemon): in-place-rename respawn int E2E (REQ-HAZARD-BRAIN-RESPAWN-PATH int)

A real `spt daemon run` broker launched from a canonical path P, the real apply
choreography (rename running P -> P.old-8, write new bytes at P), then a brain
respawn that must run the APPLIED bytes at P (exe_hash == B), not the renamed old
binary. Closes the brain_survive.rs path-flip gap that never exercised the
production current_exe respawn resolution. Platform-honest: fails on Linux
pre-fix (per-spawn current_exe follows the rename to P.old-8 -> bytes A), green on
Windows (never followed the rename) and on Linux post-fix.

required_stages -> [doc, impl, unit, int] (int activated).

[int->REQ-HAZARD-BRAIN-RESPAWN-PATH]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
git --no-pager log -1 --oneline

Commit T4 (int)


warning: in the working copy of 'crates/spt/tests/brain_respawn_rename.rs', LF will be replaced by CRLF the next time Git touches it
f69e0cd test(daemon): in-place-rename respawn int E2E (REQ-HAZARD-BRAIN-RESPAWN-PATH int)


T4 committed `f69e0cd`. **T5 (release)** — bump 0.4.1→0.4.2 + CHANGELOG + regen.


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

# spt-core cargo workspace (M0). Acyclically-layered crates per R-ARCH-1:
#   spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime} -> spt-live -> spt-daemon -> spt
# M0 builds the bottom two: spt-proto (wire) + spt-store (persistence).
# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
# M2a adds spt-runtime (harness contract: manifest schema + AgentRuntime) — fourth layer.
# M2b adds spt-live (live-agent lifecycle executor) — fifth layer, daemon-bound (not public SDK).
# M3a adds spt-term (session-surface mechanism: ConPTY/forkpty PTY layer) — sibling of spt-runtime, above spt-msg.
# M3b adds spt-daemon (broker/brain supervisor) — seventh layer, above spt-live, below spt; internal (not public SDK).
# M4 adds spt-net (P2P WAN/LAN transport) — sibling of spt-term/spt-runtime, above spt-msg; internal, default-on `net` feature.
[workspace]
resolver = "2"
members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-net", "crates/spt-term", "crates/spt-runtime", "crates/spt-live", "crates/spt-daemon", "crates/spt", "adapters/mock", "crates/xtask"]

# M6-D6 (grill decision 9): ONE shared version for every crate and the binary
# — the product version (v0.1.0 tag, `spt --version`). The wire-protocol
# version stays its own counter (REQ-ARCH-3) and the SignedRelease `version`
# is the separate monotonic release counter; neither conflates with this.
[workspace.package]
version = "0.4.1"


Edit

## [Unreleased]

## [0.4.2] - 2026-06-11

### Fixed

- **(Linux) An update now takes effect immediately — no manual restart needed.** On Linux, applying an update replaced the program on disk but the background service kept running the *previous* version until you restarted it by hand, so a fix could sit installed-but-inactive without you realizing the update hadn't truly taken hold. The service now relaunches its worker onto the freshly applied version on its own — the seamless behavior Windows already had. As an added safeguard on every platform, an update that somehow comes up running the wrong version is now detected and rolled back automatically instead of being recorded as applied.

## [0.4.1] - 2026-06-11


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

## [Unreleased]

## [0.4.2] - 2026-06-11

### Fixed

- **(Linux) An update now takes effect immediately — no manual restart needed.** On Linux, applying an update replaced the program on disk but the background service kept running the *previous* version until you restarted it by hand, so a fix could sit installed-but-inactive without you realizing the update hadn't truly taken hold. The service now relaunches its worker onto the freshly applied version on its own — the seamless behavior Windows already had. As an added safeguard on every platform, an update that somehow comes up running the wrong version is now detected and rolled back automatically instead of being recorded as applied.

## [0.4.1] - 2026-06-11

+- **(Linux) An update now takes effect immediately — no manual restart needed.** On Linux, applying an update replaced the program on disk but the background service kept running the *previous* version until you restarted it by hand, so a fix could sit installed-but-inactive without you realizing the update hadn't truly taken hold. The service now relaunches its worker onto the freshly applied version on its own — the seamless behavior Windows already had. As an added safeguard on every platform, an update that somehow comes up running the wrong version is now detected and rolled back automatically instead of being recorded as applied.


Bash

cargo run -p xtask -- gen 2>&1 | tail -3; echo "GEN:${PIPESTATUS[0]}"

Regen docs for 0.4.2


    Finished `dev` profile [unoptimized + debuginfo] target(s) in 14.83s
     Running `target\debug\xtask.exe gen`
xtask gen: wrote C:\Users\decid\Documents\projects\spt-core\docs-site/src/cli/reference.md
GEN:0


Now the full final gate suite (everything at 0.4.2, including the new int E2E).


echo "=== build ==="; cargo build --workspace 2>&1 | tail -2 && \
echo "=== test ===" && cargo test --workspace 2>&1 | grep -iE "FAILED|error\[|^error|test result: ok\. [0-9]+ passed" | tail -25; echo "test-rc:${PIPESTATUS[0]}" && \
echo "=== clippy ===" && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -2 && \
echo "=== no-default ===" && cargo build --workspace --no-default-features 2>&1 | tail -2 && \
echo "=== traceable ===" && traceable-reqs check >/dev/null 2>&1; echo "TR:$?" && \
echo "=== xtask check ===" && cargo run -p xtask -- check 2>&1 | tail -2; echo "ALL-DONE"

Full final gate suite (T1-T5 @ 0.4.2)


Command running in background with ID: b9p8a64al. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\c570fe63-c413-4a17-bb14-ee75e02a53bd\tasks\b9p8a64al.output. You will be notified when it completes. To check interim output, use Read on that file path.


Final gate suite running. Commits so far: T1 `8aa309e` (doc), T2+T3 `be626bf` (impl+unit), T4 `f69e0cd` (int), T5 pending commit (bump+changelog). On local green → push → CI both runners → ping doyle pre-merge (the Linux runner is where T4 truly gates the fix). Waiting.


<task-notification>
<task-id>b9p8a64al</task-id>
<tool-use-id>toolu_01ELkLG12Q2SGAkBeuPq2m4B</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\c570fe63-c413-4a17-bb14-ee75e02a53bd\tasks\b9p8a64al.output</output-file>
<status>completed</status>
<summary>Background command "Full final gate suite (T1-T5 @ 0.4.2)" completed (exit code 0)</summary>
</task-notification>


1	=== build ===
2	   Compiling xtask v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
3	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 28.02s
4	=== test ===
5	test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s
6	test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s
7	test reconcile::tests::failed_turn_preserves_everything ... ok
8	test result: ok. 49 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.10s
9	test result: ok. 28 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.13s
10	test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s
11	test result: ok. 120 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.22s
12	test result: ok. 55 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
13	test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
14	test result: ok. 151 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.13s
15	test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
16	test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.07s
17	test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
18	test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
19	test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.01s
20	test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
21	test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
22	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
23	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
24	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
25	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
26	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
27	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
28	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
29	test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
30	test-rc:0
31	=== clippy ===
32	    Checking xtask v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
33	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 10.27s
34	=== no-default ===
35	   Compiling spt v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
36	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.14s
37	=== traceable ===
38	TR:0
39	=== xtask check ===
40	     Running `target\debug\xtask.exe check`
41	xtask check: OK
42	ALL-DONE
43

=== build ===
   Compiling xtask v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 28.02s
=== test ===
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s
test reconcile::tests::failed_turn_preserves_everything ... ok
test result: ok. 49 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.10s
test result: ok. 28 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.13s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s
test result: ok. 120 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.22s
test result: ok. 55 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 151 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.13s
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.07s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.01s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test-rc:0
=== clippy ===
    Checking xtask v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 10.27s
=== no-default ===
   Compiling spt v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.14s
=== traceable ===
TR:0
=== xtask check ===
     Running `target\debug\xtask.exe check`
xtask check: OK
ALL-DONE


All green at 0.4.2 (build · test · clippy · no-default · traceable TR:0 · xtask OK). Committing T5 + pushing for CI.


git add -A && git commit -q -F - <<'EOF'
release: v0.4.2 — bump + changelog (Linux brain-respawn-path fix)

- workspace version 0.4.1 -> 0.4.2
- CHANGELOG [0.4.2]: Linux update-takes-effect-immediately + auto-rollback on a
  wrong-version respawn (user-clean, becomes the GitHub release body)
- regen docs

Release counter (SignedRelease --version) = 9. Carries the fix for
REQ-HAZARD-BRAIN-RESPAWN-PATH (KH 6.11): the v0.4.1 Linux blocker where a
brain-only apply respawned the brain onto old bytes while recording applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
echo "=== pushing ==="; git push -u origin fix/v0.4.2-brain-respawn-path 2>&1 | tail -4

Commit T5 + push branch


warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs-site/src/cli/reference.md', LF will be replaced by CRLF the next time Git touches it
=== pushing ===
remote:
branch 'fix/v0.4.2-brain-respawn-path' set up to track 'origin/fix/v0.4.2-brain-respawn-path'.
To https://github.com/SaberMage/spt-core.git
 * [new branch]      fix/v0.4.2-brain-respawn-path -> fix/v0.4.2-brain-respawn-path


Pushed. Opening PR → CI both runners (the kitsubito Linux runner is where the int E2E truly gates the fix).


gh pr create --base main --head fix/v0.4.2-brain-respawn-path --title "v0.4.2 — Linux brain-respawn-path fix (REQ-HAZARD-BRAIN-RESPAWN-PATH)" --body "$(cat <<'EOF'
Fixes the v0.4.1 fleet-roll Linux blocker: a brain-only `apply` respawned the brain onto OLD bytes while recording `applied` (kitsubito ran v0.4.0 under an `applied:8` record). Root cause: the resident broker respawned the brain via per-spawn `std::env::current_exe()`, which on Linux (`readlink /proc/self/exe`, inode-tracking) follows the apply-rename `spt`→`spt.old-N`. Windows was green (path-at-start semantics). `REQ-HAZARD-BRAIN-RESPAWN-PATH` / KNOWN-HAZARDS 6.11 / ADR-0018 Q3 amendment.

## Fix (two halves)
- **Half 1** — `spawn_brain_supervisor` captures the canonical exe path once at broker start; `spawn_brain_child`'s `None` default uses it (`select_brain_exe`), never per-spawn `current_exe()`. Gives Linux the path-at-start semantics Windows had.
- **Half 2** — promotion **bytes-gate**: a trial promotes only if `brain.ready` `exe_hash` == the staged artifact hash for this platform; mismatch → kill + auto-rollback + loud notif. Either hash absent → readiness-only degrade (N-1-safe) with a `PROMOTE_BYTES_UNVERIFIED` line.

## Commits (T1→T5, rule-5 staged)
- `8aa309e` doc — mint REQ, KH 6.11, ADR-0018 Q3 amendment
- `be626bf` impl+unit — both halves + `select_brain_exe`/`bytes_gate` truth tables + integration tests
- `f69e0cd` int — `brain_respawn_rename.rs`: real broker process, **real in-place rename** P→P.old-N, respawn must run B (closes the `brain_survive.rs` path-flip gap; **fails Linux pre-fix**)
- release bump 0.4.1→0.4.2 + CHANGELOG

## Gates (local, Windows)
build · test · clippy -D · build --no-default-features · traceable-reqs check EXIT=0 (`REQ-HAZARD-BRAIN-RESPAWN-PATH` [doc,impl,unit,int] all +) · xtask check — all green.

## After merge (deployah, counter 9)
tag `v0.4.2` → sign+publish `--version 9`. Then the one-time manual Linux broker bounce (kitsubito + enlyzeam) to load the fixed broker, then a debug-channel rollout to observe Linux seamless no-bounce → restoration close.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)" 2>&1 | tail -3

Open v0.4.2 PR


PR #5 open → CI both runners. Watching (kitsubito Linux runs the int E2E that gates the fix).


sleep 12; gh run list --branch fix/v0.4.2-brain-respawn-path --limit 3 2>&1 | head -5

Find v0.4.2 CI run


queued		v0.4.2 — Linux brain-respawn-path fix (REQ-HAZARD-BRAIN-RESPAWN-PATH)	ci	fix/v0.4.2-brain-respawn-path	pull_request	27340244549	13s	2026-06-11T10:22:24Z


gh run watch 27340244549 --exit-status 2>&1 | tail -30; echo "CI-EXIT:$?"

Watch v0.4.2 CI to completion


Command running in background with ID: bn5x1hht0. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\c570fe63-c413-4a17-bb14-ee75e02a53bd\tasks\bn5x1hht0.output. You will be notified when it completes. To check interim output, use Read on that file path.


CI watching. Updating memory with the v0.4.2 build state while CI runs.


Edit

**STATUS 2026-06-11:** v0.4.1 PUBLIC (counter 8). hfenduleam rolled GREEN (no-bounce + wedge-heal proven, Windows leg of restoration DONE). kitsubito left as-is (v0.4.0 brain, unwedged, applied:8 record left uncorrected = appendix evidence). enlyzeam untouched. **V042-PLAN.md written**, sent to doyle for SIGN-OFF (operator delegated post-plan greenlight to doyle). Operator chose **debug-channel rollout** for the final Linux seamless observation. Next: doyle signs → mint REQ + build (T1 doc → T2 impl → T3 unit → T4 int → T5 release) → deployah publishes counter 9 → kitsubito+enlyzeam one bounce each → debug rollout → restoration close.

**STATUS 2026-06-11 (build done):** v0.4.1 PUBLIC (counter 8). hfenduleam rolled GREEN (Windows leg DONE). kitsubito left as-is (v0.4.0 brain, applied:8 uncorrected = appendix evidence). **V042-PLAN.md doyle-SIGNED.** v0.4.2 fix BUILT on branch fix/v0.4.2-brain-respawn-path, **PR #5** open, CI both runners running. Commits: T1 doc 8aa309e (mint REQ + KH 6.11 + ADR-0018 Q3 amend), T2+T3 impl+unit be626bf (Half1 canonical capture + select_brain_exe; Half2 bytes-gate + ReleaseCache::staged_artifact_hash + TrialEnv ready_exe_hash/staged_artifact_hash; PROMOTE_BYTES_UNVERIFIED loud degrade per doyle), T4 int f69e0cd (brain_respawn_rename.rs — real broker proc, in-place rename P→P.old-N, respawn must run B; fails Linux pre-fix, closes brain_survive path-flip gap), T5 release (bump 0.4.2 + changelog). Local gates all green; traceable REQ [doc,impl,unit,int] all +. Operator chose **debug-channel rollout** for final Linux observation. NEXT: CI green → ping doyle pre-merge → merge → deployah publishes **counter 9** → kitsubito+enlyzeam one bounce each (final) → debug rollout → restoration close.