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	        cwd: None,
97	    }
98	}
99
100	/// A hermetic net config (mirrors `netbroker.rs`): relays disabled, no discovery,
101	/// loopback-only sockets — no real interface touched, no firewall prompt.
102	fn hermetic(identity: Identity) -> NetConfig {
103	    NetConfig {
104	        identity,
105	        relay: RelayPolicy::Disabled,
106	        discovery: LocalDiscovery::Off,
107	        scope: BindScope::Loopback,
108	        membership: None,
109	    }
110	}
111
112	/// Bind a broker (serving on a background thread) that owns a hermetic QUIC
113	/// endpoint for `identity`, with its effect journal under `dir`.
114	fn net_broker(name: &str, identity: Identity, dir: &std::path::Path) -> Arc<Broker> {
115	    let host = NetHost::start(hermetic(identity)).expect("net host start");
116	    let broker = Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host))
117	        .expect("bind broker with net");
118	    let serve = Arc::clone(&broker);
119	    thread::spawn(move || {
120	        let _ = serve.serve();
121	    });
122	    broker
123	}
124
125	fn connect_retry(name: &str) -> Brain {
126	    for _ in 0..400 {
127	        if let Ok(b) = Brain::cold_start(name, 1) {
128	            return b;
129	        }
130	        thread::sleep(Duration::from_millis(10));
131	    }
132	    panic!("brain could not connect to {name}");
133	}
134
135	/// The three breadcrumb fields out of a `brain.ready` body (parsed inline so this
136	/// test depends on no private brainproc helper). `None` until the file exists and
137	/// carries a parseable pid.
138	fn read_ready(path: &std::path::Path) -> Option<(u32, u64, Option<String>)> {
139	    let body = std::fs::read_to_string(path).ok()?;
140	    let v: serde_json::Value = serde_json::from_str(&body).ok()?;
141	    let pid = v.get("pid")?.as_u64()? as u32;
142	    let generation = v.get("generation").and_then(|g| g.as_u64()).unwrap_or(0);
143	    let exe_hash = v
144	        .get("exe_hash")
145	        .and_then(|h| h.as_str())
146	        .map(str::to_string);
147	    Some((pid, generation, exe_hash))
148	}
149
150	/// Poll `brain.ready` until it holds a pid (optionally one DIFFERENT from `was`),
151	/// returning `(pid, generation, exe_hash)`. `None` if it never appears in time.
152	fn wait_ready(
153	    path: &std::path::Path,
154	    was: Option<u32>,
155	    budget: Duration,
156	) -> Option<(u32, u64, Option<String>)> {
157	    let deadline = Instant::now() + budget;
158	    while Instant::now() < deadline {
159	        if let Some((pid, gen, hash)) = read_ready(path) {
160	            if was != Some(pid) {
161	                return Some((pid, gen, hash));
162	            }
163	        }
164	        thread::sleep(Duration::from_millis(50));
165	    }
166	    None
167	}
168
169	/// Lowercase-hex SHA-256 of a file (the same digest `brain.ready.exe_hash`
170	/// carries; recomputed independently here to assert the breadcrumb matches the
171	/// fixture the supervisor actually spawned).
172	fn file_hash(path: &std::path::Path) -> String {
173	    use sha2::{Digest, Sha256};
174	    let bytes = std::fs::read(path).expect("read fixture for hashing");
175	    let mut h = Sha256::new();
176	    h.update(&bytes);
177	    let digest = h.finalize();
178	    let mut s = String::with_capacity(digest.len() * 2);
179	    for b in digest {
180	        s.push_str(&format!("{b:02x}"));
181	    }
182	    s
183	}
184
185	#[test]
186	fn pty_and_quic_survive_brain_process_restart_onto_swapped_binary() {
187	    // The in-process broker + brain children both resolve `broker_socket_name()`
188	    // from SPT_HOME, so the supervised child connects to OUR broker. (The
189	    // established daemon-test pattern — daemon_e2e.rs et al. set it the same way.)
190	    let home = tempfile::tempdir().expect("tempdir");
191	    // Single-test binary by design — this is the SOLE `#[test]` here, so it owns
192	    // the process-global SPT_HOME on its own thread (cross-binary tests are
193	    // separate processes). A SECOND `#[test]` added to this file MUST bring the
194	    // env-lock discipline (a shared mutex / serialized home), or it will race this
195	    // one — the D6 env-race lesson (see the spt-daemon test_home pattern).
196	    std::env::set_var("SPT_HOME", home.path());
197
198	    // ── Two fixtures: A, and B = A + appended padding (the on-disk "swap"). ──
199	    // Both are the real `spt` binary, so `daemon brain` runs; B differs only by
200	    // trailing bytes (behavior identical, exe_hash flips).
201	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
202	    let suffix = spt_bin.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
203	    let fixture_a = home.path().join(format!("brain-A{suffix}"));
204	    let fixture_b = home.path().join(format!("brain-B{suffix}"));
205	    std::fs::copy(&spt_bin, &fixture_a).expect("stage fixture A");
206	    std::fs::copy(&spt_bin, &fixture_b).expect("stage fixture B");
207	    {
208	        use std::io::Write;
209	        let mut f = std::fs::OpenOptions::new()
210	            .append(true)
211	            .open(&fixture_b)
212	            .expect("open fixture B to append padding");
213	        f.write_all(&[0u8; 4096]).expect("append padding to fixture B");
214	    }
215	    let hash_a = file_hash(&fixture_a);
216	    let hash_b = file_hash(&fixture_b);
217	    assert_ne!(hash_a, hash_b, "the swap must flip the binary hash");
218
219	    // ── The broker (leg-b stable kernel) + a loopback QUIC peer. ──
220	    let broker = net_broker(&broker_socket_name(), Identity::generate(), &home.path().join("a"));
221	    let peer_name = format!("spt-d7-peer-{}.sock", std::process::id());
222	    let _peer = net_broker(&peer_name, Identity::generate(), &home.path().join("b"));
223
224	    // ── Establish the two endpoints ONCE via a test-driver brain, then drop it
225	    //    HARD (the D4 hard-kill posture — the outgoing side is gone before the
226	    //    supervised child resumes). ──
227	    let (sid, child_pid) = {
228	        let mut driver = connect_retry(&broker_socket_name());
229
230	        // (1) A hosted PTY child the broker holds.
231	        let sid = driver.spawn_session(echo_spawn_req()).expect("spawn session");
232	        let child_pid = broker.session_pid(sid).expect("hosted child has a pid");
233
234	        // (2) A live QUIC connection the broker holds: dial the loopback peer.
235	        let peer_addr = {
236	            let mut peer_brain = connect_retry(&peer_name);
237	            peer_brain.net_status().expect("peer status").addr
238	        };
239	        driver.net_dial(peer_addr, None).expect("dial the loopback peer");
240
241	        // Both endpoints are now broker-held.
242	        assert_eq!(broker.session_count(), 1, "one hosted session established");
243	        assert_eq!(broker.net_conn_count(), 1, "one live QUIC conn established");
244	        (sid, child_pid)
245	        // driver dropped here — HARD detach (no graceful handoff frame).
246	    };
247
248	    // ── Start the production supervisor, spawning the REAL `spt daemon brain`
249	    //    child from the currently-selected fixture (A). NoTrialEnv = the
250	    //    pre-D6 respawn loop (no readiness gate); the swap is driven by the
251	    //    planned-restart signal, exactly as a real `apply` drives it (D3-3). ──
252	    let ready_path = home.path().join("brain.ready");
253	    let stop = Arc::new(AtomicBool::new(false));
254	    let restart = Arc::new(BrainRestart::new());
255	    let selected = Arc::new(Mutex::new(fixture_a.clone()));
256	    let home_path = home.path().to_path_buf();
257
258	    let sup = {
259	        let stop = Arc::clone(&stop);
260	        let restart = Arc::clone(&restart);
261	        let selected = Arc::clone(&selected);
262	        thread::spawn(move || {
263	            supervise_brain(
264	                &stop,
265	                &restart,
266	                Duration::from_millis(50),
267	                &NoTrialEnv,
268	                Duration::from_secs(30),
269	                move |gen, reason: StartReason, _binary| {
270	                    let exe = selected.lock().unwrap().clone();
271	                    Command::new(&exe)
272	                        .no_window()
273	                        .args([
274	                            "daemon",
275	                            "brain",
276	                            "--generation",
277	                            &gen.to_string(),
278	                            "--start-reason",
279	                            reason.as_arg(),
280	                        ])
281	                        .env("SPT_HOME", &home_path)
282	                        .spawn()
283	                },
284	            );
285	        })
286	    };
287
288	    // ── Generation 0: the real child came up from fixture A, resumed the hosted
289	    //    session (the first N≥1 cursor-of-record resume), and stamped exe_hash A. ──
290	    let (pid0, _gen0, hash0) = wait_ready(&ready_path, None, Duration::from_secs(45))
291	        .unwrap_or_else(|| teardown_panic(&stop, "gen-0 brain never signalled ready"));
292	    assert_eq!(
293	        hash0.as_deref(),
294	        Some(hash_a.as_str()),
295	        "gen-0 brain.ready exe_hash must be fixture A"
296	    );
297	    assert_eq!(broker.session_count(), 1, "session survived the driver drop + resume");
298	    assert_eq!(
299	        broker.session_pid(sid),
300	        Some(child_pid),
301	        "the PTY child's pid is unchanged through the resume"
302	    );
303	    assert_eq!(broker.net_conn_count(), 1, "the QUIC conn survived the driver drop");
304
305	    // ── SWAP: flip the selected binary to B, then trigger a planned restart
306	    //    (what `apply` does: swap on disk, signal the brain to cycle). ──
307	    *selected.lock().unwrap() = fixture_b.clone();
308	    restart.request();
309
310	    // ── Generation 1: the supervisor respawned the brain PROCESS onto the
311	    //    SWAPPED binary. New pid, exe_hash == B != A (NEW BYTES RAN). ──
312	    let (pid1, _gen1, hash1) = wait_ready(&ready_path, Some(pid0), Duration::from_secs(45))
313	        .unwrap_or_else(|| teardown_panic(&stop, "gen-1 brain never respawned onto the swap"));
314	    assert_ne!(pid0, pid1, "the respawned brain is a fresh process");
315	    assert_eq!(
316	        hash1.as_deref(),
317	        Some(hash_b.as_str()),
318	        "post-swap exe_hash must be fixture B — the supervisor ran the NEW bytes, \
319	         not a relaunch of the old (the property brain_swap.rs cannot prove)"
320	    );
321	    assert_ne!(
322	        hash1.as_deref(),
323	        Some(hash_a.as_str()),
324	        "post-swap exe_hash must differ from the pre-swap binary"
325	    );
326
327	    // ── THE SURVIVAL INVARIANT: both broker-held endpoints are untouched across
328	    //    the brain-PROCESS swap. ──
329	    assert_eq!(
330	        broker.session_pid(sid),
331	        Some(child_pid),
332	        "REQ-UPD-3: the hosted PTY child's pid is unchanged across the brain-process swap"
333	    );
334	    assert_eq!(broker.session_count(), 1, "exactly one hosted session throughout");
335	    assert_eq!(
336	        broker.net_conn_count(),
337	        1,
338	        "the live QUIC conn the broker holds survived the brain-process swap (SPIKE-03 in production topology)"
339	    );
340
341	    // ── FUNCTIONAL survival (doyle F1, plan choreography 5(d)): "held" is not
342	    //    "alive". Drive a marker through the held PTY child POST-swap and watch the
343	    //    broker-side output seq advance — proving the full round-trip (input →
344	    //    child → echo → broker ring) works through the swapped-in brain, not that
345	    //    a zombie handle merely lingers. The input rides the KIND_INPUT effect verb
346	    //    on a FRESH raw connection, which does NOT steal the supervised brain's
347	    //    output subscription (it is not an attach). ──
348	    let seq_before = broker
349	        .session_output_seq(sid)
350	        .unwrap_or_else(|| teardown_panic(&stop, "hosted session vanished before the functional probe"));
351	    {
352	        let mut raw = LocalSocketTransport::connect(&broker_socket_name())
353	            .unwrap_or_else(|_| teardown_panic(&stop, "could not open a raw input connection"));
354	        send_hello(&mut raw, Role::Brain)
355	            .unwrap_or_else(|_| teardown_panic(&stop, "raw hello failed"));
356	        let input = InputReq {
357	            session_id: sid,
358	            data_b64: encode_bytes(b"D7-1 POST-SWAP FUNCTIONAL PROBE\r"),
359	            op_id: None,
360	        };
361	        write_frame(
362	            &mut raw,
363	            &Envelope::new(KIND_INPUT, serde_json::to_value(input).expect("InputReq serializes")),
364	        )
365	        .unwrap_or_else(|_| teardown_panic(&stop, "could not write the marker input frame"));
366	    }
367	    // The echo child (cat/findstr) echoes the marker → output appended → next_seq
368	    // advances. Poll the broker's own log high-water.
369	    let mut advanced = false;
370	    let deadline = Instant::now() + Duration::from_secs(15);
371	    while Instant::now() < deadline {
372	        if broker.session_output_seq(sid).map(|s| s > seq_before).unwrap_or(false) {
373	            advanced = true;
374	            break;
375	        }
376	        thread::sleep(Duration::from_millis(50));
377	    }
378	    if !advanced {
379	        teardown_panic(
380	            &stop,
381	            "the held PTY child produced no output for a post-swap marker — endpoint held but DEAD",
382	        );
383	    }
384
385	    // ── Teardown: stop the supervisor (kills the live brain child), reap. ──
386	    stop.store(true, Ordering::SeqCst);
387	    let _ = sup.join();
388	    // The hosted child dies with the broker drop at end-of-scope.
389	    let _ = broker;
390	}
391
392	/// Raise `stop` (so the supervisor thread kills any live brain child and exits)
393	/// and panic with `msg` — keeps a failing assertion from orphaning a brain.
394	fn teardown_panic(stop: &AtomicBool, msg: &str) -> ! {
395	    stop.store(true, Ordering::SeqCst);
396	    panic!("{msg}");
397	}
398


This `brain_survive.rs` test is exactly the pattern I need — it launches `spt daemon brain` as a real child process via `supervise_brain`. But I need to study how the REAL `spt daemon run` broker entry works, plus how `api seed`/`api listen` mark online without `--once`. Let me look at `brain_split.rs` (the real broker-process leg) and the daemon CLI parsing.


1	//! Restoration D1 process-level smoke (ADR-0018 Q2/Q3) — the broker/brain
2	//! process boundary, proven against the REAL `spt daemon run` binary.
3	//!
4	//! This is the de-risk spike #1 in production topology: the broker is the
5	//! always-up per-machine anchor that spawns + supervises a `spt daemon brain`
6	//! child, and **the broker survives the brain dying** — the supervisor respawns
7	//! the brain, which reconnects to the still-serving broker socket. A respawned
8	//! brain (a NEW pid in `brain.ready`) can only appear if the broker stayed alive
9	//! to accept its connection, so the respawn IS the broker-survival proof.
10	//!
11	//! D1 is a true minimal skeleton: the brain child connects, signals ready, and
12	//! idles; the logic loops stay broker-side until D2. So this test asserts only
13	//! the boundary — come-up, survive-brain-kill, respawn — not any logic.
14	//!
15	//! The full PTY-child + live-QUIC survival across a swap is the D7 `int` E2E
16	//! (REQ-UPD-3 re-point); here the process-level boundary is `unit`-tagged.
17	// [unit->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
18
19	use std::path::PathBuf;
20	use std::process::{Child, Command};
21	use std::thread;
22	use std::time::{Duration, Instant};
23
24	mod common;
25	use common::CommandNoWindowExt;
26
27	/// Best-effort cross-platform kill of a pid (mirrors the other E2E helpers).
28	fn kill_pid(pid: u32) {
29	    #[cfg(windows)]
30	    let _ = Command::new("taskkill")
31	        .no_window()
32	        .args(["/PID", &pid.to_string(), "/F", "/T"])
33	        .output();
34	    #[cfg(unix)]
35	    let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
36	}
37
38	/// Read the `pid` out of a `brain.ready` body. As of restoration D6-1b the
39	/// breadcrumb is self-describing JSON `{pid, generation}` (was bare-pid text);
40	/// this is the only reader, migrated in the same commit.
41	fn ready_pid(body: &str) -> Option<u32> {
42	    serde_json::from_str::<serde_json::Value>(body)
43	        .ok()?
44	        .get("pid")?
45	        .as_u64()
46	        .map(|p| p as u32)
47	}
48
49	/// Poll `brain.ready` for a pid, up to `budget`. `None` if it never appears.
50	fn wait_for_ready_pid(path: &PathBuf, budget: Duration) -> Option<u32> {
51	    let deadline = Instant::now() + budget;
52	    while Instant::now() < deadline {
53	        if let Ok(s) = std::fs::read_to_string(path) {
54	            if let Some(pid) = ready_pid(&s) {
55	                return Some(pid);
56	            }
57	        }
58	        thread::sleep(Duration::from_millis(50));
59	    }
60	    None
61	}
62
63	/// Poll `brain.ready` until it holds a pid DIFFERENT from `was` (a respawn),
64	/// up to `budget`. `None` if it never changes.
65	fn wait_for_new_pid(path: &PathBuf, was: u32, budget: Duration) -> Option<u32> {
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();
186	        panic!("brain child never signalled ready (no brain.ready breadcrumb)");
187	    });
188	    assert!(
189	        matches!(broker.try_wait(), Ok(None)),
190	        "broker must still be running after the brain came up"
191	    );
192
193	    // ── 2. survive-brain-kill + respawn: kill the brain, expect a NEW pid ──────
194	    kill_pid(pid1);
195	    let pid2 = wait_for_new_pid(&ready, pid1, Duration::from_secs(30)).unwrap_or_else(|| {
196	        kill_pid(pid1);
197	        let _ = broker.kill();
198	        panic!("supervisor did not respawn the brain after it was killed");
199	    });
200	    assert_ne!(pid1, pid2, "the respawned brain is a fresh process");
201
202	    // The respawn proves broker survival (a new brain could only connect to a
203	    // still-serving broker socket); assert the broker process itself too.
204	    assert!(
205	        matches!(broker.try_wait(), Ok(None)),
206	        "broker must survive the brain death (it is the always-up anchor)"
207	    );
208
209	    // ── teardown ───────────────────────────────────────────────────────────────
210	    kill_pid(pid2);
211	    let _ = broker.kill();
212	    let _ = broker.wait();
213	}
214


Error: File content (417.4KB) exceeds maximum allowed size (256KB). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.


`brain_split.rs` shows the exact entry: `spt daemon run` spawns the brain child, `brain.ready` breadcrumb appears. Now I have the broker-process pattern. Let me examine how `api seed`/`api listen` work without `--once` (need a persistent online perch), and how `spawn_psyche` resolves the install dir. Let me check the CLI daemon parsing and `api listen`.


Grep

fn cmd_listen|fn cmd_seed|listen|--once|fn run_daemon|daemon.*brain|Daemon::run


5://! echo, graceful signoff ordering) directly in the `api listen` runner. B3
8://! This module keeps the binary's call sites stable — `cmd_listen` /


78:    /// endpoint. Loads the durable [`DaemonConfig`] from disk. `install_dir` is
80:    /// before `PATH` (REQ-INSTALL-11).
84:        install_dir: Option<&Path>,
86:        Self::with_config_in(manifest?, id, DaemonConfig::load(), install_dir)
97:    /// program tokens resolve there before `PATH` (REQ-INSTALL-11).
98:    // [impl->REQ-INSTALL-11]
103:        install_dir: Option<&Path>,
116:        // [impl->REQ-INSTALL-11] pin the adapter install dir so the psyche role's
118:        let runtime = match install_dir {
119:            Some(dir) => ManifestRuntime::with_install_dir(manifest.clone(), dir),
178:    pub fn spawn_psyche(


170	    /// spawn is event-driven; `None` at cold start.
171	    ///
172	    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
173	    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
174	    /// after binding, mark the perch `online` — from here liveness reads the
175	    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
176	    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
177	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
178	    pub fn spawn_psyche(
179	        &self,
180	        session_id: Option<&str>,
181	        event: Option<&str>,
182	    ) -> Result<PsycheHandle, String> {
183	        let handle = spawn_psyche(
184	            &self.runtime,
185	            &self.id,
186	            &self.base_keys(session_id),
187	            event,
188	            &now_stamp(),
189	        )
190	        .map_err(|e| e.to_string())?;
191	        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
192	            .map_err(|e| format!("mark psyche online: {e}"))?;
193	        Ok(handle)
194	    }
195
196	    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
197	    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
198	    /// the single liveness resolver reports the perch dead regardless of any pid
199	    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
200	    /// (perch already gone) is not an error.
201	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
202	    pub fn mark_offline(perch_path: &std::path::Path) {
203	        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
204	    }
205
206	    /// Stamp this endpoint's `last_active_ms` recency marker (REQ-UPD-4) — the
207	    /// signal the consent gate resolves "most-recently-active live session"
208	    /// against ([`crate::consent::most_recently_active`]). Best-effort: a hosted
209	    /// perch that has not been bound yet (no `info.json`) is silently skipped, so
210	    /// a pre-bind tick never errors. Stamps the Self perch for this brain's id.
211	    // [impl->REQ-UPD-4]
212	    pub fn touch_active(&self) {
213	        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
214	        let _ = spt_store::info::set_last_active(&perch_path, now_ms());
215	    }
216
217	    /// One pulse tick (T5): refresh the activity stamp (REQ-UPD-4), ingest drops
218	    /// from the watched dirs (T4), then if the echo-gate is armed fire exactly one
219	    /// echo-commune (T3).
220	    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
221	        let now = now_ms();
222	        // Recency follows the resting model (D9-2 — the D8 gap closed): only
223	        // an ACTIVE (or pre-D9-2 recordless) instance refreshes
224	        // `last_active_ms` — a resting seat ticking its pulse must never win
225	        // a "most-recently-active" resolution (REQ-UPD-4's consent target,
226	        // the notif first-fire). A dormant seat instead checks the opt-in
227	        // auto-suspend threshold (counted from its dormancy-onset anchor);
228	        // a suspended seat just keeps ingesting.
229	        // [impl->REQ-INST-3]
230	        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
231	        match crate::resting::read_rest(&perch_path).map(|r| r.state) {
232	            None | Some(crate::resting::RestState::Active) => self.touch_active(),
233	            Some(crate::resting::RestState::Dormant) => {
234	                let _ = self.rest_event(
235	                    session_id,
236	                    crate::resting::RestEvent::AutoSuspendTick { now_ms: now },
237	                );
238	            }
239	            Some(crate::resting::RestState::Suspended) => {}
240	        }
241	        let mut ingested = Vec::new();
242	        for dir in self.drop_dirs() {
243	            let mut got = ingest_drops(
244	                &dir,
245	                &self.id,
246	                &self.project_id,
247	                now,
248	                self.cfg.protection_window_ms,
249	            )
250	            .map_err(|e| e.to_string())?;
251	            ingested.append(&mut got);
252	        }
253	        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
254	            self.fire_echo(session_id)?;
255	            true
256	        } else {
257	            false
258	        };
259	        Ok(TickReport {
260	            ingested,
261	            echo_fired,
262	        })
263	    }
264
265	    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
266	    /// each tick (so a caller — or a test — observes cadence and can decide to
267	    /// stop). The period comes from the durable config, never a constant.
268	    ///
269	    /// **Restoration D5-2 (ADR-0018 Q4/V4): the cadence is a disk-anchored
270	    /// absolute-deadline grid, not a phase-relative flat sleep.** The old loop
271	    /// `pulse_tick`-then-`sleep(pulse_period)` silently re-phased the grid to the
272	    /// restart instant on every restart. Now the loop rehydrates a
273	    /// [`DeadlineAnchor`](crate::deadline::DeadlineAnchor) keyed by the session
274	    /// (the daemon pulse uses `"pulse"`; the live-agent adapter's per-agent
275	    /// drivers pass each agent's id — amd 6, no cross-clobber) and applies the
276	    /// [`StartReason`] rule: an `Update` restart **preserves the grid phase**
277	    /// (lands mid-grid), a `Crash`/`Cold` start re-bases it to now. The fire
278	    /// deadline is derived functionally — **no per-fire write**.
279	    ///
280	    /// The loop still holds no work-state beyond the cursor on disk (a fresh loop
281	    /// re-reads the drop dirs and gate), so it is freely killable + respawnable;
282	    /// D5 makes its *phase* durable too.
283	    // [impl->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
284	    // [impl->REQ-DAEMON-1]
285	    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
286	        &self,
287	        session_id: Option<&str>,
288	        stop: &AtomicBool,
289	        reason: StartReason,


crates\spt-runtime\src\runtime.rs:57:    /// The filled command was empty (no program token).
crates\spt-runtime\src\runtime.rs:119:/// Split a filled command line into program + args, honoring double quotes so a
crates\spt-runtime\src\runtime.rs:212:    /// bare program token resolves here before `PATH` so a `.spt` that ships its
crates\spt-runtime\src\runtime.rs:213:    /// binary is self-contained (REQ-INSTALL-11). `None` keeps PATH-only.
crates\spt-runtime\src\runtime.rs:214:    install_dir: Option<PathBuf>,
crates\spt-runtime\src\runtime.rs:221:            install_dir: None,
crates\spt-runtime\src\runtime.rs:225:    /// Build with the adapter install dir pinned so role program tokens resolve
crates\spt-runtime\src\runtime.rs:226:    /// against it before `PATH` (REQ-INSTALL-11).
crates\spt-runtime\src\runtime.rs:228:    pub fn with_install_dir(manifest: Manifest, install_dir: impl Into<PathBuf>) -> Self {
crates\spt-runtime\src\runtime.rs:231:            install_dir: Some(install_dir.into()),
crates\spt-runtime\src\runtime.rs:258:        let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;
crates\spt-runtime\src\runtime.rs:259:        // [impl->REQ-INSTALL-11] resolve a bare program token against the adapter
crates\spt-runtime\src\runtime.rs:260:        // install dir before PATH so a `.spt` that ships its binary (e.g. the
crates\spt-runtime\src\runtime.rs:262:        let program = match &self.install_dir {
crates\spt-runtime\src\runtime.rs:263:            Some(dir) => resolve_program_in_dir(program, dir),
crates\spt-runtime\src\runtime.rs:264:            None => program.clone(),
crates\spt-runtime\src\runtime.rs:266:        let mut cmd = Command::new(&program);
crates\spt-runtime\src\runtime.rs:419:/// Resolve a command template's **program token** (first whitespace-split token)
crates\spt-runtime\src\runtime.rs:420:/// against an adapter's install dir before falling back to `PATH`. A `.spt`
crates\spt-runtime\src\runtime.rs:424:/// `source_dir`. A bare program name (`claude-spt-digest`) would otherwise miss
crates\spt-runtime\src\runtime.rs:425:/// the shipped binary and resolve only from `PATH`; resolving against the install
crates\spt-runtime\src\runtime.rs:428:/// Returns `<install_dir>/<program>` (absolute) when that file exists — on
crates\spt-runtime\src\runtime.rs:430:/// unchanged (the `PATH` fallback). Pure and side-effect-free apart from the
crates\spt-runtime\src\runtime.rs:434:pub fn resolve_program_in_dir(program: &str, install_dir: &Path) -> String {
crates\spt-runtime\src\runtime.rs:435:    let direct = install_dir.join(program);
crates\spt-runtime\src\runtime.rs:441:        if Path::new(program).extension().is_none() {
crates\spt-runtime\src\runtime.rs:442:            let exe = install_dir.join(format!("{program}.exe"));
crates\spt-runtime\src\runtime.rs:448:    program.to_string()
crates\spt-runtime\src\runtime.rs:451:/// As [`run_bounded_command`], but when `install_dir` is `Some`, the template's
crates\spt-runtime\src\runtime.rs:452:/// program token is first resolved against that adapter install dir
crates\spt-runtime\src\runtime.rs:453:/// ([`resolve_program_in_dir`]) so a bare program name binds to the adapter's
crates\spt-runtime\src\runtime.rs:454:/// shipped binary before `PATH` (REQ-INSTALL-11). `None` preserves the legacy
crates\spt-runtime\src\runtime.rs:455:/// PATH-only resolution for callers without an install dir.
crates\spt-runtime\src\runtime.rs:463:    install_dir: Option<&Path>,
crates\spt-runtime\src\runtime.rs:467:    let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;
crates\spt-runtime\src\runtime.rs:468:    // [impl->REQ-INSTALL-11] adapter install dir wins over PATH for a bare token.
crates\spt-runtime\src\runtime.rs:469:    let program = match install_dir {
crates\spt-runtime\src\runtime.rs:470:        Some(dir) => resolve_program_in_dir(program, dir),
crates\spt-runtime\src\runtime.rs:471:        None => program.clone(),
crates\spt-runtime\src\runtime.rs:473:    let mut cmd = Command::new(&program);
crates\spt-runtime\src\runtime.rs:610:    // [unit->REQ-INSTALL-11] a program present in the install dir resolves to the
crates\spt-runtime\src\runtime.rs:611:    // absolute path there — the adapter's shipped binary wins over PATH.
crates\spt-runtime\src\runtime.rs:613:    fn resolve_program_prefers_install_dir() {
crates\spt-runtime\src\runtime.rs:617:        let resolved = resolve_program_in_dir("claude-spt-digest", dir.path());
crates\spt-runtime\src\runtime.rs:622:    // [unit->REQ-INSTALL-11] a program absent from the install dir falls back to
crates\spt-runtime\src\runtime.rs:623:    // the bare token unchanged (PATH resolution).
crates\spt-runtime\src\runtime.rs:625:    fn resolve_program_falls_back_to_path() {
crates\spt-runtime\src\runtime.rs:627:        let resolved = resolve_program_in_dir("claude-spt-digest", dir.path());
crates\spt-runtime\src\runtime.rs:635:    fn resolve_program_adds_exe_suffix_on_windows() {
crates\spt-runtime\src\runtime.rs:639:        let resolved = resolve_program_in_dir("claude-spt-digest", dir.path());

[Showing results with pagination = limit: 50]


Now I understand install-dir resolution. Let me read the registry `register` function to understand how `source_dir` is set (COPY mode vs. ROOT mode), since the test needs the install dir to be the registered `source_dir`. Let me look at the registry and how `api seed`/`api listen` without `--once` mark online.


fn register\b|source_dir|fn register_copy|copy|fn register_root|--release|root_only


5://! an invalid one), then records it (PRD R-INSTALL-4): a **copy** of the
9://! self-update never leaves a stale copy behind). One command and one dir for
27://! copy mode). Records write atomically (REQ-HAZARD-EBUSY-RENAME stance via
40:/// How the manifest is held: a copy under `adapters/<name>/`, or a pointer
56:    /// the live manifest location in pointer mode, provenance in copy mode.
57:    pub source_dir: String,
126:fn manifest_copy_file(adapters_dir: &Path, name: &str) -> PathBuf {
143:/// its `strings/` aux dir. Copy mode: the held copy under `adapters/<name>/`;
149:        RecordMode::Pointer => PathBuf::from(&record.source_dir),
250:/// Recursively copy `src` into `dst` (used to bring an adapter's shipped
251:/// `strings/` dir into the held copy at register, so file pointers resolve
252:/// post-copy and survive the source going away — Copy mode only).
253:fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
260:            copy_dir_all(&from, &to)?;
262:            std::fs::copy(&from, &to)?;
271:/// the `[update]` avenue — `delegated` ⇒ pointer, `file_pull`/absent ⇒ copy.
276:pub fn register(
287:        // `--release` fetch dir); the manifest is re-read live from there as the
293:    let source_dir = manifest_file
304:    let src_strings_dir = Path::new(&source_dir).join("strings");
316:        source_dir: source_dir.clone(),
324:        atomic_write_string(&manifest_copy_file(adapters_dir, &record.name), &toml_src)?;
325:        // Bring the shipped strings/ dir into the held copy so pointers resolve
326:        // post-copy and survive the source going away (adapter updates overwrite
329:            copy_dir_all(&src_strings_dir, &shipped_strings_dir(adapters_dir, &record))
338:/// copy stay — existing instances keep running off them). Returns the
371:/// The record's manifest: the held copy in copy mode, the live source tree in
379:        RecordMode::Copy => manifest_copy_file(adapters_dir, &record.name),
380:        RecordMode::Pointer => source_manifest_file(Path::new(&record.source_dir)),
419:// (the manifest copy is adapter-owned and overwritten by updates; the user's
673:/// runtime, possibly newer than the registry copy), not a registry load; the
725:    // A copy-mode (avenue-less) shell adapter carrying one shipped profile that
771:    fn register_copy_vs_pointer_by_avenue() {
782:            "copy held"
789:            "no copy"
829:    // active set, record + copy retained (live instances keep running), the
932:    // (the manifest copy is overwritten; the user's profiles/ dir is not).
1059:    /// Seed a copy-mode harness adapter source whose `[strings]` carries `body`
1098:        assert!(copied.exists(), "shipped strings/ copied into the held copy");


419	/// Resolve a command template's **program token** (first whitespace-split token)
420	/// against an adapter's install dir before falling back to `PATH`. A `.spt`
421	/// adapter ships its built binaries either to `adapters/_github/<safe>/` (via
422	/// `--release`/`--github` acquisition) or keeps them in the adapter's
423	/// `source_dir` under copy-mode — both are the adapter's `AdapterRecord`
424	/// `source_dir`. A bare program name (`claude-spt-digest`) would otherwise miss
425	/// the shipped binary and resolve only from `PATH`; resolving against the install
426	/// dir first makes the adapter self-contained.
427	///
428	/// Returns `<install_dir>/<program>` (absolute) when that file exists — on
429	/// Windows also trying the `.exe` suffix — else the original bare token
430	/// unchanged (the `PATH` fallback). Pure and side-effect-free apart from the
431	/// existence probe; never errors (an unresolvable token is simply passed through
432	/// for the OS to resolve / fail at spawn).
433	// [impl->REQ-INSTALL-11]
434	pub fn resolve_program_in_dir(program: &str, install_dir: &Path) -> String {
435	    let direct = install_dir.join(program);
436	    if direct.is_file() {
437	        return direct.to_string_lossy().into_owned();
438	    }
439	    #[cfg(windows)]
440	    {
441	        if Path::new(program).extension().is_none() {
442	            let exe = install_dir.join(format!("{program}.exe"));
443	            if exe.is_file() {
444	                return exe.to_string_lossy().into_owned();
445	            }
446	        }
447	    }
448	    program.to_string()
449	}
450
451	/// As [`run_bounded_command`], but when `install_dir` is `Some`, the template's
452	/// program token is first resolved against that adapter install dir
453	/// ([`resolve_program_in_dir`]) so a bare program name binds to the adapter's
454	/// shipped binary before `PATH` (REQ-INSTALL-11). `None` preserves the legacy
455	/// PATH-only resolution for callers without an install dir.
456	// [impl->REQ-INSTALL-11]
457	// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]
458	pub fn run_bounded_command_in(
459	    template: &str,
460	    keys: &BTreeMap<String, String>,
461	    timeout: Duration,
462	    stdin: Option<&[u8]>,
463	    install_dir: Option<&Path>,
464	) -> Result<BoundedOutput, RuntimeError> {
465	    let filled = fill_template(template, keys)?;
466	    let tokens = tokenize(&filled);
467	    let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;
468	    // [impl->REQ-INSTALL-11] adapter install dir wins over PATH for a bare token.


Now I understand the install-dir resolution. The key insight: `source_dir` of the registered adapter is where the psyche binary must live. In copy mode, `source_dir` = provenance (the original `src` dir), per line 293/316. Let me confirm: in copy mode, `record.source_dir` is the original source manifest's parent dir, and that's what `livehost.rs:201` uses as `install_dir`. So my psyche binary must be in the `src` dir I register from.

Now let me understand how `api seed` + `api listen` (without `--once`) work — I need to know the exact CLI and whether `listen` blocks. Let me check the seed/listen CLI and `--once` semantics.


3://! `--adapter <name>` (the `adapter_name`), and every *mutation* is gated by
13://! (auth refused, no endpoint, bad manifest) · `2` usage (clap).
34:/// The `api` command group. `--adapter` is required on every invocation; an
35:/// optional `--manifest` is parsed for commands that need the adapter contract
38:pub struct ApiArgs {
39:    /// adapter_name — the calling harness adapter. Required on every `api` call.
41:    adapter: String,
42:    /// Path to the adapter's runtime manifest (when the command needs it).
44:    manifest: Option<PathBuf>,
52:pub enum ApiCmd {
54:    Seed {
61:    Listen {
65:        parent_pid: Option<u32>,
68:        once: bool,
190:    /// log-less adapter — appended to the perch's digest store, tailed by the
211:    /// owner→shell ephemeral control channel. REST-only, exactly-once — the
237:    /// Print the adapter's declared capability (`hostable_types`).
241:    /// adapter's context channel. The per-session seen-set fires each hint once
242:    /// per `--session` (a `/clear` = a new session = re-armed). Needs `--manifest`.
244:        /// The harness session id keying the once-per-session seen-set.
257:    /// manifest `can_shutdown` pre-consent grant, fail-closed. The firing
287:    /// The `adapter_name` carried on this call.
288:    pub adapter: String,
289:    /// Parsed manifest, when `--manifest` was supplied (and valid).
290:    pub manifest: Option<Manifest>,
291:    /// The adapter's install dir on the runtime-manifest seam: the directory of
292:    /// the `--manifest` file, where a `.spt` ships its session-role binaries.
314:    // Resolve the manifest up front so a bad path/content fails fast and
316:    let (manifest, install_dir) = match resolve_ctx_manifest(args.manifest.as_deref(), &args.adapter)
322:        adapter: args.adapter,
323:        manifest,
330:        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
332:        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
333:        ApiCmd::Listen {
335:            parent_pid,
336:            once,
340:            parent_pid,
341:            once,
342:            ctx.manifest.as_ref(),
356:                    // M11-W3 (REQ-SHELL-4): open the opaque tunnel if the manifest
383:            // The bind path's adapter source is the api group's own
384:            // --adapter (no seed on this path) — the adapter-at-creation rule.
385:            let adapter = Some(ctx.adapter.as_str()).filter(|a| !a.is_empty());
390:                adapter,
393:                ctx.manifest.as_ref(),
417:                delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
445:            let manifest = ctx.manifest.as_ref();
448:                live::cmd_shutdown(id, manifest, install_dir)
467:/// Resolve the per-call `(manifest, install_dir)` for [`Ctx`]. Two sources, one
471:/// - `--manifest <path>` present → the explicit **override**: load+validate that
474:/// - `--manifest` absent → resolve from the **registry** by `--adapter
476:///   `--manifest`-parent approximation), which also lands the copy-mode
477:///   psyche-binary path (REQ-INSTALL-11). An unregistered adapter is not a
480:fn resolve_ctx_manifest(
481:    manifest: Option<&Path>,
482:    adapter: &str,
484:    match manifest {
487:            // `--manifest` file's directory — a `.spt`'s role-binary home.
488:            let manifest = load_manifest(&path.to_path_buf(), adapter)?;
490:            Ok((Some(manifest), install_dir))
493:            // Resolve by the registered adapter option — the SAME composite
496:            // adapter (no record) degrades to `(None, None)`, unchanged.
497:            let adapters_dir = spt_store::perch::adapters_dir();
498:            match spt_runtime::registry::resolve_option(&adapters_dir, adapter) {
499:                Ok((record, manifest)) => Ok((
500:                    Some(manifest),
509:/// Load + validate the adapter's runtime manifest and resolve the `--adapter`
510:/// option through the merged view. `--adapter` is a `<adapter>[:profile]`
512:/// `adapter.name` (never the whole composite — a profile is not a separate
513:/// adapter), and the `:profile` overlay (shipped from the file's own
514:/// `[profiles]`, local from `adapters_dir`) is applied so every downstream
517:fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
522:    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
526:    let parent = spt_runtime::profile::split_option(adapter).0;
527:    if manifest.adapter.name != parent {
529:            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
530:            manifest.adapter.name
534:    let adapters_dir = spt_store::perch::adapters_dir();
535:    spt_runtime::registry::resolve_option_onto(&manifest, &adapters_dir, adapter).map_err(|e| {
536:        eprintln!("PROFILE_UNRESOLVED: --adapter {adapter}: {e}");

[Showing results with pagination = limit: 80]


Now let me look at `cmd_listen` to understand what it does when NOT `--once` — does it mark online and block? This determines my test's approach (the harness drives `api listen` as a persistent child).


fn cmd_listen|fn cmd_seed|set_status|STATUS_ONLINE|fn run_listen|loop|once|Monitor|fn cmd_bind


36:pub fn cmd_seed(ctx: &Ctx, parent_pid: u32, session_id: String) -> i32 {
89:/// REQ-START-3: the seed is TAKEn (consume-once) from the daemon's in-memory map
115:    // not the live-agent relay loop.)
224:/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
228:/// loop also hosts the LiveAgent lifecycle over the M2b `spt-live` seams: sweep
234:pub fn cmd_listen(
237:    once: bool,
284:        if let Err(e) = info::set_status(&perch_path, spt_store::liveness::STATUS_ONLINE) {
309:    if once {
313:        relay.pump_once(&mut deliver);
316:    // The pure relay loop (M11-W0.2): `Relay::run` drains the backlog first
331:pub fn cmd_bind(
374:                if let Err(e) = info::set_status(&perch_path, spt_store::liveness::STATUS_ONLINE) {
483:        // Seed is consumed from the daemon's in-memory map (take-once).


234	pub fn cmd_listen(
235	    id: &str,
236	    parent_pid_override: Option<u32>,
237	    once: bool,
238	    manifest: Option<&spt_runtime::Manifest>,
239	    subnet: Option<&str>,
240	) -> i32 {
241	    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
242	    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
243	        eprintln!("INVALID_ID:{id}: {e}");
244	        return EXIT_REFUSED;
245	    }
246	    let parent_pid = match parent_pid_override.or_else(proc::parent_pid) {
247	        Some(p) => p,
248	        None => {
249	            eprintln!("NO_PARENT_PID: could not resolve the parent pid anchor");
250	            return EXIT_REFUSED;
251	        }
252	    };
253	    let token = match bind_from_seed(id, parent_pid, subnet) {
254	        Ok(t) => t,
255	        Err(e) => {
256	            report_bind_error(&e);
257	            return EXIT_REFUSED;
258	        }
259	    };
260	    eprintln!("BOUND:{id} token={token}");
261
262	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
263	    let relay = match spt_daemon::relay::Relay::bind(id, &perch_path) {
264	        Ok(r) => r,
265	        Err(e) => {
266	            eprintln!("LISTEN_FAIL:{id}: {e}");
267	            return EXIT_REFUSED;
268	        }
269	    };
270
271	    // Live-agent FIRST-HOST handoff to the daemon (M11-W0.2, REQ-DAEMON-1): the
272	    // Psyche/pulse lifecycle is no longer hosted in THIS process — it lives in the
273	    // brain (`spt_daemon::livehost`). For a live-capable endpoint (manifest
274	    // declares `[session.psyche_init]`) we mark the perch `status=online` at
275	    // establish; the brain's reconcile then hosts the Psyche within one tick (the
276	    // shellwake CLI-flips-status / brain-reconciles pattern). `status` is
277	    // daemon-authoritative (CONTEXT:194). A new `listen` against a not-yet-restarted
278	    // OLD brain leaves the perch online-but-unhosted; that self-heals at the brain's
279	    // next BOOT reconcile (livehost reconciles at boot, not only on tick) — the
280	    // agent + the message relay below are live throughout regardless.
281	    // [impl->REQ-DAEMON-1]
282	    let live_capable = manifest.is_some_and(|m| m.session.psyche_init.is_some());
283	    if live_capable {
284	        if let Err(e) = info::set_status(&perch_path, spt_store::liveness::STATUS_ONLINE) {
285	            eprintln!("LIVE_ONLINE_FAIL:{id}: {e}");
286	        }
287	    }
288
289	    eprintln!("READY:{id}");
290
291	    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
292	    // undismissed notifs surface into the spool before the backlog drain, so
293	    // they ride this very first drain — and render endpoint-native when the
294	    // manifest declares [session.notif]. Best-effort — never fails the listen.
295	    super::reporting::resurface_notifs_with(id, manifest);
296
297	    // Deliver one message to the live agent and record it as an spt context
298	    // injection for the two-origin digest (REQ-TERM-7) — `emit` is the
299	    // agent-facing chokepoint, so every delivered message becomes a glanceable
300	    // `owl_message` context entry. The recorded form is the whole composed
301	    // `<EVENT>` (what the agent sees), not the structural pair. Best-effort;
302	    // never disturbs delivery.
303	    let mut deliver = |from: &str, body: &str| {
304	        emit(from, body);
305	        let event = spt_msg::emit::render_event_whole(from, body);
306	        spt_live::record_context_injection(id, spt_live::KIND_OWL_MESSAGE, &event);
307	    };
308
309	    if once {
310	        // Single-shot (testability): drain the offline backlog (incl the just-
311	        // resurfaced notifs) then forward one live delivery, and exit.
312	        relay.drain_backlog(&mut deliver);
313	        relay.pump_once(&mut deliver);
314	        return 0;
315	    }
316	    // The pure relay loop (M11-W0.2): `Relay::run` drains the backlog first
317	    // (preserving the resurface→drain ordering) then streams live deliveries to
318	    // stdout until the process is killed. No lifecycle state lives in this process
319	    // anymore — the Psyche/pulse runs in the brain. The stop flag is never raised;
320	    // the harness kills the process to end the session.
321	    let stop = std::sync::atomic::AtomicBool::new(false);
322	    relay.run(&stop, &mut deliver);
323	    0
324	}
325
326	/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
327	/// live perch on first contact, or flips an existing skeleton, keyed to the
328	/// session id the binary reports. Auth is intrinsic: an existing *live* perch
329	/// under a different session id is refused (you can only bind your own).
330	// [impl->REQ-SEAM-POSTSPAWN]
331	pub fn cmd_bind(
332	    id: &str,
333	    set_session_id: Option<String>,


Now I have the full picture. The `api listen` (non-`--once`) marks the perch `status=online` then blocks in `relay.run` until killed. That's exactly the persistent harness-hosted online path.

My plan:
1. Isolated SPT_HOME tempdir, net-less/unpaired (no subnet).
2. Register a copy-mode adapter whose `psyche_init.command` is a BARE program token (e.g. `psychebin`) that exists ONLY in the registered `src` dir (= `source_dir`), not on PATH.
3. Launch real `spt daemon run` (broker → spawns `spt daemon brain` child).
4. `spt api seed` then `spt api listen <id>` as a persistent child (marks online, blocks).
5. Capture brain stderr — need to find where it goes. The brain child is spawned by `spawn_brain_child` which doesn't redirect stderr, so it inherits the broker's stderr. The broker is spawned by my test, so I can capture the broker's stderr (which the brain inherits).
6. Assert `LIVEHOST_PSYCHE:<id>` appears (in brain stderr) OR poll the psyche perch on disk, within 25s.

Let me verify how the broker (`spt daemon run`) handles stderr and the psyche binary fixture. Let me check the psyche perch path resolution and how `spawn_psyche` names the perch.


<tool_use_error>Path does not exist: C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\psyche.rs. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.</tool_use_error>

Error: Path does not exist: C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\psyche.rs. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.


No files found


crates\spt-daemon\src\broker.rs:46:use spt_store::perch::{resolve_perch_path, ParentHint};
crates\spt-daemon\src\broker.rs:435:        let perch = resolve_perch_path(&self.endpoint, ParentHint::Infer);
crates\spt-daemon\src\broker.rs:446:        let perch = resolve_perch_path(&self.endpoint, ParentHint::Infer);
crates\spt-daemon\src\consent.rs:35:    pub perch_path: PathBuf,
crates\spt-daemon\src\consent.rs:92:        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
crates\spt-daemon\src\consent.rs:108:            perch_path: PathBuf::from("/x"),
crates\spt-daemon\src\digest.rs:135:    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
crates\spt-daemon\src\digest.rs:136:    let info = spt_store::info::read_info(&perch_path);
crates\spt-daemon\src\digest.rs:159:    let context = read_context_items(&perch_path);
crates\spt-daemon\src\digest.rs:167:            &perch_path,
crates\spt-daemon\src\digest.rs:174:        log_less_activity(id, &perch_path)
crates\spt-daemon\src\digest.rs:190:    perch_path: &std::path::Path,
crates\spt-daemon\src\digest.rs:196:    let ledger = spt_store::sessions::last_k(perch_path, SPAN_SESSIONS);
crates\spt-daemon\src\digest.rs:260:fn log_less_activity(id: &str, perch_path: &std::path::Path) -> Vec<TimelineItem> {
crates\spt-daemon\src\digest.rs:261:    let Ok(s) = std::fs::read_to_string(perch_path.join("digest.log")) else {
crates\spt-daemon\src\digest.rs:296:fn read_context_items(perch_path: &std::path::Path) -> Vec<TimelineItem> {
crates\spt-daemon\src\digest.rs:297:    let Ok(s) = std::fs::read_to_string(perch_path.join("digest.log")) else {
crates\spt-daemon\src\digest.rs:432:            let perch = perch::resolve_perch_path("solo", ParentHint::Infer);
crates\spt-daemon\src\digest.rs:524:            let perch = perch::resolve_perch_path("span", ParentHint::Infer);
crates\spt-daemon\src\digest.rs:572:            let perch = perch::resolve_perch_path("merge", ParentHint::Infer);
crates\spt-daemon\src\dispatch.rs:482:                    let local = perch::resolve_perch_path(&id, spt_store::perch::ParentHint::Infer);
crates\spt-daemon\src\grants.rs:329:            perch_path: PathBuf::from("/x"),
crates\spt-daemon\src\grants.rs:533:                let p = perch::resolve_perch_path_in(&owlery, id, ParentHint::Infer);
crates\spt-daemon\src\linkhost.rs:226:    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
crates\spt-daemon\src\linkhost.rs:250:    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
crates\spt-daemon\src\linkhost.rs:282:    let perch_path = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
crates\spt-daemon\src\linkhost.rs:283:    let Some(token) = shellhost::read_link_token(&perch_path) else {
crates\spt-daemon\src\linkhost.rs:357:    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
crates\spt-daemon\src\linkhost.rs:763:            let perch_path = perch::resolve_shell_perch_path_in(&owlery, "doyle", &id);
crates\spt-daemon\src\linkhost.rs:764:            let token = shellhost::read_link_token(&perch_path).expect("token parked");
crates\spt-daemon\src\linkhost.rs:774:            let rows = spt_store::spool::peek_all_at(&perch_path).unwrap();
crates\spt-daemon\src\linkhost.rs:909:            let perch_path = perch::resolve_shell_perch_path_in(&owlery, "doyle", &id);
crates\spt-daemon\src\linkhost.rs:917:                spt_store::spool::peek_all_at(&perch_path).unwrap().is_empty(),
crates\spt-daemon\src\linkhost.rs:924:            let mut info = shellinfo::read_shell_info(&perch_path).unwrap();
crates\spt-daemon\src\linkhost.rs:926:            shellinfo::write_shell_info(&perch_path, &info).unwrap();
crates\spt-daemon\src\linkhost.rs:940:                spt_store::spool::peek_all_at(&perch_path).unwrap().is_empty(),
crates\spt-daemon\src\linkhost.rs:968:            let perch_path = perch::resolve_shell_perch_path_in(&owlery, "doyle", &id);
crates\spt-daemon\src\linkhost.rs:970:                shellhost::read_link_token(&perch_path).is_none(),
crates\spt-daemon\src\linkhost.rs:983:                shellhost::read_link_token(&perch_path).is_none(),
crates\spt-daemon\src\linkhost.rs:988:                spt_store::spool::peek_all_at(&perch_path).unwrap().is_empty(),

[Showing results with pagination = limit: 40]


The `daemon_lifecycle_real_brain.rs` test already showed me the psyche perch path: `perch::resolve_perch_path("agent9-psyche", ParentHint::Explicit("agent9"))` and the LIVEHOST_PSYCHE marker writes `info.json` to that perch. Let me find the `spawn_psyche` free function to confirm the `<id>-psyche` naming and that it writes info.json.


178:    pub fn spawn_psyche(
191:        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
192:            .map_err(|e| format!("mark psyche online: {e}"))?;
202:    pub fn mark_offline(perch_path: &std::path::Path) {
203:        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
213:        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
214:        let _ = spt_store::info::set_last_active(&perch_path, now_ms());
230:        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
231:        match crate::resting::read_rest(&perch_path).map(|r| r.state) {
366:        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
368:            &perch_path,
506:        let path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
616:        use spt_store::info::{write_info, InfoJson};
617:        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
618:        std::fs::create_dir_all(&perch_path).unwrap();
621:        write_info(&perch_path, &info).unwrap();
627:        perch_path
659:        use spt_store::info::{read_info, write_info};
677:                read_info(&handle.perch_path)
687:            let mut rec = read_info(&handle.perch_path).unwrap();
689:            write_info(&handle.perch_path, &rec).unwrap();
691:                is_perch_alive(&handle.perch_path),
696:            BrainLifecycle::mark_offline(&handle.perch_path);
698:                !is_perch_alive(&handle.perch_path),
830:            let perch_path = seed_supervised_perch("doyle", std::process::id());
834:            let stamped = read_info(&perch_path)
841:            crate::resting::write_rest(&perch_path, crate::resting::RestState::Dormant, 1_000)
844:            let rec = read_info(&perch_path).unwrap();
858:            let rec = read_info(&perch_path).unwrap();


Let me find the free `spawn_psyche` function (not the method) in spt-live or wherever it builds the psyche perch and runs the command.


pub fn spawn_psyche|-psyche|fn psyche_perch|PsycheHandle


crates\spt-proto\src\id.rs:15://! Psyche (`<parent>-psyche`) and Worker (`<parent>-w<N>`) suffix scheme uses
crates\spt-proto\src\id.rs:102:            "doyle-psyche", // Psyche suffix scheme
crates\spt-proto\src\event.rs:382:            r#"<EVENT type="echo_commune" from="doyle-psyche" note="brief">hello world</EVENT>"#;
crates\spt-proto\src\event.rs:386:        assert_eq!(p.from(), Some("doyle-psyche"));
crates\spt\tests\contract_e2e.rs:402:    let psyche_perch = perch::resolve_perch_path("live-e2e-psyche", ParentHint::Explicit(id));
crates\spt\tests\live_bind_firsthost_e2e.rs:135:    let psyche_perch = perch::resolve_perch_path("agent8-psyche", ParentHint::Explicit("agent8"));
crates\spt-daemon\tests\daemon_lifecycle_real_brain.rs:82:    let psyche_perch = perch::resolve_perch_path("agent9-psyche", ParentHint::Explicit("agent9"));
crates\spt-daemon\src\psyrelay.rs:277:            "doyle-psyche",
crates\spt-daemon\src\psyrelay.rs:307:                    from: "doyle-psyche".to_string(),
crates\spt-daemon\src\psyrelay.rs:339:                    from: "doyle-psyche".to_string(),
crates\spt-daemon\src\psyrelay.rs:343:            assert_eq!(rows[0].from, "doyle-psyche");
crates\spt-daemon\src\psyrelay.rs:379:                    from: "doyle-psyche".to_string(),
crates\spt-daemon\src\psyrelay.rs:380:                    body: "<EVENT type=\"notify\" from=\"doyle-psyche\" notif_id=\"cafe:1\" \
crates\spt-daemon\src\psyrelay.rs:449:                "doyle-psyche",
crates\spt-daemon\src\psyrelay.rs:490:                "doyle-psyche",
crates\spt-live\src\psyche.rs:1://! spawn-psyche seam (M2b T2) — bring a Self's Psyche companion to life.
crates\spt-live\src\psyche.rs:4://! `[session.psyche_resume]` (re-entry — T7) as a **nested `<id>-psyche` perch**
crates\spt-live\src\psyche.rs:5://! (M0 layout: `owlery/<parent>/nested/<id>-psyche/`). spt-core owns exactly one
crates\spt-live\src\psyche.rs:8://! §spawn-psyche seam. Everything else — model selection, harness preamble —
crates\spt-live\src\psyche.rs:33:pub struct PsycheHandle {
crates\spt-live\src\psyche.rs:39:/// A spawn-psyche failure. Carries an operator message; never panics.
crates\spt-live\src\psyche.rs:67:/// envelope (CONTEXT §spawn-psyche seam). Consistent across adapters so the
crates\spt-live\src\psyche.rs:91:/// `id` (the nested `<id>-psyche`), `psyche_dir` (the created perch dir, the
crates\spt-live\src\psyche.rs:94:pub fn spawn_psyche(
crates\spt-live\src\psyche.rs:100:) -> Result<PsycheHandle, PsycheError> {
crates\spt-live\src\psyche.rs:107:/// nested `<id>-psyche` perch.
crates\spt-live\src\psyche.rs:115:) -> Result<PsycheHandle, PsycheError> {
crates\spt-live\src\psyche.rs:116:    let psyche_id = format!("{parent_id}-psyche");
crates\spt-live\src\psyche.rs:147:    Ok(PsycheHandle {
crates\spt-live\src\psyche.rs:198:            // Nested layout: owlery/doyle/nested/doyle-psyche/.

[Showing results with pagination = limit: 30]


94	pub fn spawn_psyche(
95	    runtime: &dyn AgentRuntime,
96	    parent_id: &str,
97	    keys: &BTreeMap<String, String>,
98	    event: Option<&str>,
99	    now: &str,
100	) -> Result<PsycheHandle, PsycheError> {
101	    spawn_and_bind(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
102	}
103
104	/// Shared spawn+bind core for the Psyche roles (`psyche_init` — fresh; and
105	/// `psyche_resume` — re-entry, T7). Injects the three spt-core-owned keys
106	/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the
107	/// nested `<id>-psyche` perch.
108	pub(crate) fn spawn_and_bind(
109	    runtime: &dyn AgentRuntime,
110	    role: &str,
111	    parent_id: &str,
112	    keys: &BTreeMap<String, String>,
113	    event: Option<&str>,
114	    now: &str,
115	) -> Result<PsycheHandle, PsycheError> {
116	    let psyche_id = format!("{parent_id}-psyche");
117	    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
118	    // The nested perch dir must exist before spawn — the role's `cwd` is it.
119	    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;
120
121	    let mut k = keys.clone();
122	    k.insert("id".to_string(), psyche_id.clone());
123	    k.insert(
124	        "psyche_dir".to_string(),
125	        perch_path.to_string_lossy().to_string(),
126	    );
127	    k.insert(
128	        "psyche_prompt".to_string(),
129	        compose_psyche_prompt(now, event),
130	    );
131
132	    let spawned = runtime.spawn_session(role, &k)?;
133
134	    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
135	    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
136	    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
137	    let mut rec = InfoJson::new(
138	        &psyche_id,
139	        now,
140	        spawned.pid,
141	        session_id,
142	        &EndpointType::Psyche.as_tag(),
143	    );
144	    rec.parent_pid = None;
145	    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;
146
147	    Ok(PsycheHandle {
148	        id: psyche_id,
149	        pid: spawned.pid,
150	        perch_path,
151	    })
152	}
153
154	#[cfg(test)]
155	mod tests {
156	    use super::*;
157	    use crate::test_support::with_home;
158	    use spt_runtime::{Manifest, ManifestRuntime};
159	    use std::time::{Duration, Instant};
160
161	    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.
162	    fn prompt_includes_time_and_event() {
163	        let p = compose_psyche_prompt("2026-06-01T00:00:00Z", Some("MSG from alice: hi"));
164	        assert!(p.contains("time: 2026-06-01T00:00:00Z"));
165	        assert!(p.contains("MSG from alice: hi"));
166	    }
167
168	    #[test] // [unit->REQ-SEAM-PSYCHE] no event ⇒ an explicit "(none)" envelope.
169	    fn prompt_without_event() {
170	        let p = compose_psyche_prompt("t0", None);
171	        assert!(p.contains("incoming event: (none)"));
172	    }
173
174	    // A psyche_init template that records the recursion-guard env value into a
175	    // marker file in its cwd (the nested perch dir) — proves both that the perch
176	    // is the cwd and that the guard env reached the child.
177	    #[cfg(windows)]
178	    const GUARD_CMD: &str = "cmd /C echo %SPT_GUARD% > guard.txt";
179	    #[cfg(unix)]
180	    const GUARD_CMD: &str = "sh -c \"echo $SPT_GUARD > guard.txt\"";
181
182	    fn psyche_manifest() -> Manifest {
183	        let toml = format!(
184	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
185	             [session.psyche_init]\ncommand='{GUARD_CMD}'\ncwd=\"{{psyche_dir}}\"\n\
186	             recursion_guard_env=\"SPT_GUARD\"\nkeys=[\"psyche_dir\",\"psyche_prompt\"]\n"
187	        );
188	        Manifest::from_toml_str(&toml).unwrap()
189	    }
190
191	    #[test] // [unit->REQ-SEAM-PSYCHE] spawning binds a nested psyche perch + sets the guard env.
192	    fn spawn_binds_nested_perch_and_sets_guard() {
193	        with_home(|_| {
194	            let rt = ManifestRuntime::new(psyche_manifest());
195	            let h = spawn_psyche(&rt, "doyle", &BTreeMap::new(), Some("pulse"), "t0")
196	                .expect("spawn psyche");
197
198	            // Nested layout: owlery/doyle/nested/doyle-psyche/.
199	            assert_eq!(h.id, "doyle-psyche");
200	            assert_eq!(
201	                h.perch_path,
202	                resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"))
203	            );
204
205	            // info.json records the psyche endpoint type + the spawned pid.
206	            let rec = spt_store::info::read_info(&h.perch_path).expect("info.json");
207	            assert_eq!(rec.state, "psyche");
208	            assert_eq!(rec.id, "doyle-psyche");
209
210	            // The detached child writes the guard value into its cwd; poll for it.
211	            let marker = h.perch_path.join("guard.txt");
212	            let start = Instant::now();
213	            let contents = loop {
214	                if let Ok(s) = std::fs::read_to_string(&marker) {
215	                    if !s.trim().is_empty() {
216	                        break s;
217	                    }
218	                }
219	                assert!(
220	                    start.elapsed() < Duration::from_secs(5),
221	                    "psyche child never wrote its guard marker"
222	                );
223	                std::thread::sleep(Duration::from_millis(20));
224	            };
225	            assert!(
226	                contents.contains('1'),
227	                "guard env not set on child: {contents:?}"
228	            );
229	        });
230	    }
231	}
232


I now have everything. The psyche perch's `info.json` is written by `spawn_and_bind` AFTER `runtime.spawn_session` succeeds. If `spawn_session` fails (e.g., bare binary can't be spawned because it's not resolved), `spawn_psyche` returns Err and livehost logs `LIVEHOST_PSYCHE_FAIL`. So the disk-observable proxy (psyche perch info.json) is the right assertion.

Now, the critical question for the bug: does the psyche perch appear net-less? Let me check the common module and the `CommandNoWindowExt`, and confirm the broker spawn doesn't redirect stderr (so I can capture brain stderr via the broker child's stderr).


1	//! Shared test helpers for the `spt` integration tests.
2	//!
3	//! `tests/common/mod.rs` (a subdirectory module) is NOT compiled as its own test
4	//! binary — each test file pulls it in with `mod common;`.
5
6	use std::process::Command;
7
8	/// Chainable: suppress the ephemeral console window a Windows console-subsystem
9	/// child pops when the test harness has a console (every `spt daemon`/`api`/
10	/// `brain` subprocess a test spawns). No-op on non-Windows. CREATE_NO_WINDOW does
11	/// NOT detach — env/stdio inheritance is unchanged — it only hides the console.
12	///
13	/// Usage: `Command::new(bin).no_window().args([...]).spawn()`.
14	pub trait CommandNoWindowExt {
15	    fn no_window(&mut self) -> &mut Command;
16	}
17
18	impl CommandNoWindowExt for Command {
19	    #[cfg(windows)]
20	    fn no_window(&mut self) -> &mut Command {
21	        use std::os::windows::process::CommandExt;
22	        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
23	        self.creation_flags(CREATE_NO_WINDOW)
24	    }
25
26	    #[cfg(not(windows))]
27	    fn no_window(&mut self) -> &mut Command {
28	        self
29	    }
30	}
31


Now let me understand where the brain's stderr goes when launched via `spt daemon run`. The brain child inherits the broker's stderr (`spawn_brain_child` doesn't set stdio). If I capture the broker's stderr with `Stdio::piped()`, I get both broker AND brain stderr (LIVEHOST markers). But piping stderr and reading it while the process runs requires a reader thread. Simpler: redirect the broker child's stderr to a file, and the brain (inheriting) writes to the same file.

Let me verify `spt daemon run` is the broker entry and check whether `daemon run` redirects stderr. Let me look at main.rs/the daemon run command.


29:use spt_daemon::attach::{request_attach, send_attach_input, send_attach_resize};
30:use spt_daemon::brain::{now_ms, Brain, BrokerEvent};
31:use spt_daemon::msg::decode_bytes;
87:/// session table (the spawn's endpoint label, brain.sessions()). `None` ⇒ no
89:fn resolve_session(brain: &mut Brain, endpoint_id: &str) -> std::io::Result<Option<u64>> {
90:    let reply = brain.sessions()?;
182:pub fn run_attach(endpoint_id: &str, intent: AttachIntent) -> Result<(), String> {
196:    spt_daemon::daemon::ensure_running().map_err(|e| format!("daemon start: {e}"))?;
202:    let mut brain = Brain::cold_start_pump(
203:        &spt_daemon::endpoint::broker_socket_name(),
209:    let session_id = resolve_session(&mut brain, endpoint_id)
222:    let dialed = brain
225:    let stream_id = request_attach(&mut brain, dialed.conn_id, session_id, 0, ops.next(), intent)
229:    brain
246:            let _ = send_attach_resize(&mut brain, stream_id, rows, cols);
253:        &mut brain,
296:    brain: &mut Brain,
307:    fn detach(brain: &mut Brain, stream_id: u64) -> PumpEnd {
308:        let _ = brain.net_stream_send(stream_id, &[], None, true);
329:                    let _ = send_attach_resize(brain, stream_id, rows, cols);
339:                        send_attach_input(brain, stream_id, &bytes, ops.next())
342:                    Ok(StdinMsg::Detach) => return Ok(detach(brain, stream_id)),
345:                        return Ok(detach(brain, stream_id))
352:        let ev = match brain.read_event_until(Some(Instant::now() + POLL_SLICE)) {


Let me find the actual `daemon run` and `daemon brain` subcommand dispatch in main.rs.


36:    // to that account. (The Daemon::run / spawn_detached election guards run


crates\spt\src\main.rs:36:    // to that account. (The Daemon::run / spawn_detached election guards run
crates\spt\src\rc.rs:18://! **Single-Brain pump.** A `Brain` is `&mut`-single-threaded, so the main thread
crates\spt\src\rc.rs:30:use spt_daemon::brain::{now_ms, Brain, BrokerEvent};
crates\spt\src\rc.rs:89:fn resolve_session(brain: &mut Brain, endpoint_id: &str) -> std::io::Result<Option<u64>> {
crates\spt\src\rc.rs:202:    let mut brain = Brain::cold_start_pump(
crates\spt\src\rc.rs:296:    brain: &mut Brain,
crates\spt\src\rc.rs:307:    fn detach(brain: &mut Brain, stream_id: u64) -> PumpEnd {
crates\spt\src\wansend.rs:23:use spt_daemon::brain::Brain;
crates\spt\src\wansend.rs:62:    let mut brain = match Brain::cold_start(&broker_socket_name(), now_ms()) {
crates\spt\src\wansend.rs:87:    brain: &mut Brain,
crates\spt\src\wansend.rs:189:    let mut brain = match Brain::cold_start(&broker_socket_name(), now_ms()) {
crates\spt\src\wansend.rs:218:    brain: &mut Brain,
crates\spt\src\wansend.rs:313:    let mut brain = match Brain::cold_start(&broker_socket_name(), now_ms()) {
crates\spt\src\wansend.rs:342:    brain: &mut Brain,
crates\spt\src\wansend.rs:424:    let Ok(mut brain) = Brain::cold_start(&broker_socket_name(), now_ms()) else {
crates\spt\src\wansend.rs:558:    use spt_net::net::registry::{Instance, Status};
crates\spt\src\wansend.rs:585:    fn connect_retry(name: &str) -> Brain {
crates\spt\src\wansend.rs:587:            if let Ok(b) = Brain::cold_start(name, 1) {
crates\spt\src\wansend.rs:601:                status: Status::Active,
crates\spt\src\wansend.rs:913:                    status: Status::Active,
crates\spt\src\cli.rs:100:        /// Run a single drain+receive cycle, then exit (one-shot fallback
crates\spt\src\cli.rs:215:        action: Option<DaemonCmd>,
crates\spt\src\cli.rs:254:    Run {
crates\spt\src\cli.rs:333:    Stop {
crates\spt\src\cli.rs:421:enum DaemonCmd {
crates\spt\src\cli.rs:422:    /// Run the per-machine daemon in the FOREGROUND — this process IS the
crates\spt\src\cli.rs:425:    Run,
crates\spt\src\cli.rs:430:    /// Stop the daemon (service-aware: a managed service is stopped via its
crates\spt\src\cli.rs:432:    Stop,
crates\spt\src\cli.rs:435:    Status,
crates\spt\src\cli.rs:446:    Brain {
crates\spt\src\cli.rs:550:    /// (ADR-0019). Runs the declared extractor over `--sample <log>` (or the
crates\spt\src\cli.rs:773:    Status {
crates\spt\src\cli.rs:854:    /// Stop serving a held subnet (the daemon keeps running).
crates\spt\src\cli.rs:1025:                Some(SubnetCmd::Status { name, nodes }) => cmd_subnet_status(name, nodes, false),
crates\spt\src\cli.rs:1067:            EndpointCmd::Stop { id } => cmd_stop(&id),
crates\spt\src\cli.rs:1075:            EndpointCmd::Run {
crates\spt\src\cli.rs:1129:        Cmd::Daemon { action } => match action.unwrap_or(DaemonCmd::Status) {
crates\spt\src\cli.rs:1130:            DaemonCmd::Run => cmd_daemon_run(),
crates\spt\src\cli.rs:1131:            DaemonCmd::Start => cmd_daemon_start(),

[Showing results with pagination = limit: 40]


`spt daemon run` (foreground broker) and `spt daemon brain` are confirmed. `brain_split.rs` already proves `spt daemon run` spawns a brain child that writes `brain.ready`. Now I have everything to write the test.

One key design decision: capture brain stderr. The brain inherits the broker's stderr. I'll redirect the broker child's `stderr` to a log file in the tempdir, so both broker and brain markers (BRAIN_UP, LIVEHOST_PSYCHE, LIVEHOST_PSYCHE_FAIL) land there. Let me check `cmd_daemon_run` to confirm it stays foreground and that the brain inherits stderr (no redirect in `spawn_brain_child` — confirmed at brainproc.rs:927-948, no `.stderr()` set).

Now let me create the psyche binary fixture. The most robust cross-platform approach: copy the real `spt` binary (CARGO_BIN_EXE_spt) into the install dir under a bare name like `psychebin` (`.exe` on Windows), and invoke it with `daemon status` or similar harmless subcommand so it exits 0 quickly. Actually simplest: use the `spt` binary itself as the psyche binary — but the command needs to exit cleanly. Let me have the psyche command be `psychebin <something harmless>`. The `spt` binary with no args / `--help`... let me check what exits 0 fast. Actually `spt daemon status` exits 0. But it'd connect to seed socket. Simplest harmless: copy a tiny fixture.

Let me check the runtime spawn — does it use `spawn_detached`? If the program token must resolve via install_dir, I need a real file. Let me make a copy of CARGO_BIN_EXE_spt named `psychebin(.exe)` and command `psychebin whoami` or similar. Let me verify what subcommand exits 0 cleanly and fast without side effects. Let me check the runtime spawn_session for how it spawns.


232	        }
233	    }
234
235	    pub fn manifest(&self) -> &Manifest {
236	        &self.manifest
237	    }
238
239	    fn role(&self, role: &str) -> Result<&SessionRole, RuntimeError> {
240	        self.manifest
241	            .session
242	            .roles()
243	            .into_iter()
244	            .find(|(name, _)| *name == role)
245	            .map(|(_, r)| r)
246	            .ok_or_else(|| RuntimeError::UnknownRole(role.to_string()))
247	    }
248
249	    /// Build a `Command` from a role: fill the template, tokenize, apply cwd,
250	    /// env_remove, and the recursion guard. Shared by both spawn paths.
251	    fn command_for(
252	        &self,
253	        role: &SessionRole,
254	        keys: &BTreeMap<String, String>,
255	    ) -> Result<Command, RuntimeError> {
256	        let filled = fill_template(&role.command, keys)?;
257	        let tokens = tokenize(&filled);
258	        let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;
259	        // [impl->REQ-INSTALL-11] resolve a bare program token against the adapter
260	        // install dir before PATH so a `.spt` that ships its binary (e.g. the
261	        // psyche_init runner `claude-spt …`) is self-contained.
262	        let program = match &self.install_dir {
263	            Some(dir) => resolve_program_in_dir(program, dir),
264	            None => program.clone(),
265	        };
266	        let mut cmd = Command::new(&program);
267	        cmd.args(args);
268	        if let Some(cwd) = &role.cwd {
269	            cmd.current_dir(fill_template(cwd, keys)?);
270	        }
271	        for var in &role.env_remove {
272	            cmd.env_remove(var);
273	        }
274	        if let Some(guard) = &role.recursion_guard_env {
275	            cmd.env(guard, "1");
276	        }
277	        // Both command_for callers (spawn_session, run_bounded_stdin) run from
278	        // the console-less daemon (notif pump) and the detached live-agent
279	        // host (psyche/echo/turn): without CREATE_NO_WINDOW each spawn flashes
280	        // a visible blank conhost on the desktop. Setting it here — the one
281	        // shared builder — makes KNOWN-HAZARDS 5.8 hold for every ManifestRuntime
282	        // spawn, not just the free run_bounded_command leg.
283	        // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]
284	        #[cfg(windows)]
285	        {
286	            use std::os::windows::process::CommandExt;
287	            cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
288	        }
289	        Ok(cmd)
290	    }
291	}
292
293	impl AgentRuntime for ManifestRuntime {
294	    fn spawn_session(
295	        &self,
296	        role: &str,
297	        keys: &BTreeMap<String, String>,
298	    ) -> Result<Spawned, RuntimeError> {
299	        let role_def = self.role(role)?;
300	        let mut cmd = self.command_for(role_def, keys)?;
301	        // Detached: discard stdio, spawn, drop the handle. Best-effort detach
302	        // for the interim no-daemon model; M3's broker owns true detachment.
303	        cmd.stdin(Stdio::null())
304	            .stdout(Stdio::null())
305	            .stderr(Stdio::null());
306	        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
307	        Ok(Spawned { pid: child.id() })
308	    }
309
310	    fn run_bounded_stdin(
311	        &self,
312	        role: &str,
313	        keys: &BTreeMap<String, String>,
314	        timeout: Duration,
315	        stdin: Option<&[u8]>,
316	    ) -> Result<BoundedOutput, RuntimeError> {
317	        let role_def = self.role(role)?;
318	        let mut cmd = self.command_for(role_def, keys)?;
319	        cmd.stdin(if stdin.is_some() {
320	            Stdio::piped()
321	        } else {


`spawn_session` does `cmd.spawn()` and if the program can't be spawned, returns `RuntimeError::Spawn(io::Error)`. The psyche binary just needs to exist and exit cleanly. I'll copy the `spt` binary as `psychebin(.exe)` and run it with a harmless arg that exits fast.

Actually, the psyche command with a real spt binary may try to do something. Safest: a tiny dedicated executable. On Windows I can't compile C easily in a test. Best portable: copy CARGO_BIN_EXE_spt as the fixture (it's a real executable that the OS can spawn) and give it args that make it exit 0 quickly without side effects. `spt --version` exits 0 immediately, no daemon contact. Let me verify `--version` exists.


42:#[command(
43:    name = "spt",
44:    version,
45:    about = "spt — a harness-independent core for an agent ecosystem: inter-agent \
48:    long_about = None
55:    #[command(subcommand)]
119:        #[command(subcommand)]
126:    #[command(name = "how-to")]
138:        #[command(subcommand)]
165:        #[command(subcommand)]
174:        #[command(subcommand)]
184:        #[command(subcommand)]
192:        #[command(subcommand)]
201:        #[command(subcommand)]
214:        #[command(subcommand)]
218:    #[command(hide = true)]
370:        #[command(subcommand)]
378:        #[command(subcommand)]
445:    #[command(hide = true)]
494:        /// and versioned (the pattern for a monorepo whose adapter is a subdir).

[Showing results with pagination = limit: 20]


`version` is enabled, so `spt --version` exits 0 immediately with no side effects. The psyche binary fixture = a copy of CARGO_BIN_EXE_spt named `psychebin(.exe)`, invoked as `psychebin --version`. This is a real executable in the install dir resolved ONLY via install-dir resolution (the bare name `psychebin` is not on PATH).

Now I'll write the test. I'll capture brain stderr by redirecting the broker child's stderr to a file. Let me write it.