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

**PROGRESS: B2 GATE-PASS @03b6398. WAVE 2 (B1+H3) GATE-PASS+FIXED @78a13c2. WAVE 3 (B3+Breap) BUILT @026e9b4 + grandchild gate-cond @0c1e59e — doyle CONDITIONAL-PASS (design/safety/wiring sound), condition MET, awaiting final sign-off.** Remaining waves: B5 (wave 4) → B4 (wave 5). Stack 6 ahead of main, NOT pushed.

**WAVE 3 design (B3+Breap), both [impl,unit]:** B3 = client-side barrier in seedmap::request_stop (after STOPPING ack, drop conn, poll ping() to failure / 10ms slices, 5s wedge-ceiling — doyle's option 2, NOT socket-unbind-before-ack). Breap = `reap::BrainReaper` rooted at the BRAIN SUBTREE (never the daemon → reap never self-terminates, never touches a sibling live agent on the shared box): Win = kill-on-job-close Job, brain assigned per (re)spawn in spawn_brain_supervisor, Psyches inherit; Unix = brain pre_exec setpgid(0,0) leads own group, supervisor records pgid, kill(-pgid,SIGKILL). Daemon::run graceful path raises supervisor stop flag FIRST (hoisted the "held for symmetry never raised" Arc) THEN reaps; straggler-brain backstop = self-exit on broker-loss.

**WAVE 3 LESSONS (2 Windows-process gotchas, both reusable):** (1) **terminated process w/ open handle reads ALIVE** — is_process_alive(pid) (OpenProcess) returns true for a TerminateJobObject'd process while a Child handle is still held (kernel object lingers; same class as the Unix-zombie OpenProcess gap). For an OWNED child use try_wait, not is_process_alive(pid). (2) **UseShellExecute=true → job breakaway** — Windows PowerShell's [Diagnostics.Process]::Start defaults UseShellExecute=true → ShellExecute spawns the grandchild OUTSIDE the job → it survives the reap. Force UseShellExecute=$false (CreateProcess) so it inherits the job — mirrors the brain→Psyche spawn (null stdio + CreateProcess, no breakaway). doyle's "exercise the grandchild link, don't reason it" caught this — the child-only unit was green while the real orphan path escaped.

---
name: v012-lifecycle-milestone
description: "v0.12.0 spt-hosted lifecycle & liveness reconciliation milestone (operator-mandated blocking, doyle dispatch) — 7 REQs, wave-by-wave, B2 keystone built"
metadata:
  node_type: memory
  type: project
  originSessionId: bf7260cd-fc4e-463b-9c46-b2ed704b4bab
---

**v0.12.0 spt-hosted LIFECYCLE & LIVENESS RECONCILIATION** — operator-mandated BLOCKING (endpoint run unusable: attach hangs, daemon stop doesn't stop). doyle dispatched 2026-06-17 (2-agent diagnosis). Branch **v0.12.0-lifecycle** off main@27e0619 (v0.11.0). todlando builds keystone-first in waves, doyle gates PER-WAVE, then deployah → v0.12.0 MINOR.

**UNIFYING ROOT:** info.json `status=online` is a ONE-WAY LATCH — set at establish (startup.rs:361/468), NEVER cleared vs real liveness (liveness.rs:80-93 is_perch_alive returns ONLINE for daemon-hosted). Every symptom falls out of this + stop/restart lifecycle not tearing-down/rehydrating.

**PROGRESS: B2 GATE-PASS @03b6398. WAVE 2 (B1+H3) GATE-PASS+FIXED @78a13c2** (was @f85bf69; path-(c) was a deadlock, removed). B1 NOW 2-paths: (a) run_attach status-gate (status=offline→clean short-circuit; NOT is_perch_alive — true for absent info.json), (b) PumpEnd::Stalled 30s backstop (never bites mid-init alive). H3=cmd_stop set_status OFFLINE. Full attach 12/12 (1.08s), broker 5/5, units 3/3, clippy --workspace --all-targets clean, traceable EXIT=0. Remaining waves: B3+Breap → B5 → B4.

**CRITICAL LESSON — B1 path-(c) deadlock (cost ~3h, gate code-read MISSED it):** f85bf69's "primary dead-detector" `if let Ok(Some)=session.try_wait()` in Broker::dispatch_subscribe DEADLOCKS every LIVE attach (incl production rc). `PtySession::try_wait` locks the child mutex; the per-session exit-waiter thread holds that SAME mutex in a blocking `wait()` for the WHOLE life of a live child (broker.rs exit-waiter, pty.rs:203). Live child → try_wait blocks forever → no Subscribed reply → hang. Broker units passed (their children exit fast → lock freed); only the FULL attach suite (matrix/loopback/remote, 85-200s hangs) caught it. **Gating data-flow logic ("try_wait Some→Exit; None→live-safe") is BLIND to lock-contention/ordering deadlocks — you MUST run the real runtime sweep, not just touched units + a code-read.** Sibling of [[shared-seam-change-run-all-seam-tests]]. Path-(c) was also redundant: exit-waiter already broadcasts Exit to all sinks on reap; post-removal subscribe → "no such session". RED HERRINGS chased first: "concurrent cargo wedge" (real but not this), "input-race needs ready-banner" (phantom — silent-child tests pass in CI), "environmental QUIC-under-load" (wrong — cwd passed instantly). Operator's "fix it, don't tape it" forced the real root.

**7 REQs minted registry-first @e2d6391 (traceable EXIT=0). WAVE ORDER: B2 → B1+H3 → B3+Breap → B5 → B4.**
- **B2 REQ-HAZARD-HOSTED-LIVENESS-RECONCILE** (keystone) — **BUILT @03b6398, WAVE-READY, awaiting doyle gate.** doyle ruled PULL-PRIMARY (live-status analog of v0.11.0 ROSTER-GHOST). new `reconcile_hosted_liveness(owlery, live_sessions)` in livehost.rs: queries broker KIND_SESSIONS each tick (`query_live_session_endpoints`), marks_offline any status=online live_agent perch with NO live broker session. Wired into spawn_live_host BEFORE reconcile_once, gated past-10s-boot-grace + broker-reachable (None→skip). **CONTROLLABLE GATE (load-bearing): only controllable==Some(true) spt-hosted; relay Some(false) + legacy None EXEMPT** (else kills perri /sptc:live agents). PUSH (ExitEvent→mark_offline) NOT added — confirmed daemon brain has NO event-consumer loop (main=net_status heartbeat), push has nowhere to land for unattended; correctness rides pull. unit pull_liveness_marks_sessionless_spt_hosted_offline_only + int pull_reconcile_offlines_perch_when_broker_session_dies (real broker session killed+reaped→latch cleared). [impl,unit,int].
- **B1 REQ-HAZARD-RC-ATTACH-FAILFAST** — rc to dead/silent session → infinite blank. SUB-MECHANISM PINNED: wall-f hang = broker STILL resolves a session (resolve_session rc.rs:209 returns Some — child alive-silent OR dead-not-reaped on Win tab-close) → pump blocks on first read that never comes. FIX (doyle): (a) gate attach on is_online/status [after B2], (b) fail-fast bound first-output/ack→clear msg, (c) broker EOF stream on dead-child→PumpEnd::BrokerGone (REQ-HAZARD-RC-EOF). PIN with repro test FIRST (deterministic = attach to alive-but-silent session). wave 2.
- **H3 REQ-ENDPOINT-STOP-OFFLINE** — cmd_stop (cli.rs:2994-3010) removes ready+unregister but NO set_status offline → stopped still alive=true. FIX: add set_status(perch, STATUS_OFFLINE), folds B2 setter. CONFIRMED at code. wave 2.
- **B3 REQ-HAZARD-DAEMON-STOP-BARRIER** — request_stop (seedmap.rs:240-255) returns on KIND_STOPPING ack (174-176) BEFORE seed socket unbinds → is_running ping (daemon.rs:375) wins → start sees ALREADY_RUNNING. FIX: unbind before ack OR wait-ping-fail. wave 3.
- **Breap REQ-HAZARD-DAEMON-STOP-REAP** — stop leaves ~8 orphaned psyche/spt.exe (psyches DETACHED runtime.rs:342-356; livehost stop flag brainproc.rs:227-230 NEVER raised). FIX: raise stop flag + kill children via Win job object / Unix process-group. folds B3. wave 3.
- **B5 REQ-HAZARD-LIVEHOST-BOOT-LIVENESS-GATE** — daemon start spawns psyche per online-latched perch (reconcile_once livehost.rs:285) w/o child-liveness check → revives phantoms. FIX: boot-gate on real session (shares B2 reconcile — no-session perch marked offline at boot not revived). Verify None-spt-hosted phantom caught by residency-confirm (matrix: i child-dead→B2, ii boot-revive→B5, iii dead-psyche/None→residency). wave 4.
- **B4 REQ-HAZARD-BRAIN-RESTART-LIFECYCLE-REHYDRATE** (deepest) — bare brain restart loses ALL BrainLifecycle (lifecycle.rs:58-130); resume_sessions re-subscribes PTY but no livehost → post-restart endpoints unhosted until full daemon kill. FIX: on brain startup rebuild BrainLifecycle per resumed live-capable session (load manifest→instantiate→start pulse). wave 5.

**SEAM BAR (HARD, EVERY wave):** clippy --workspace -D warnings + traceable + FULL sweep daemon_lifecycle_real_brain + livehost(bootrace/nonresident/firsthost/bind-firsthost/psyche_fail) + broker + handoff + attach + brain_swap + resume — NOT just new tests. livehost E2Es need a SCRATCH target dir (live daemon holds target/debug/spt.exe — shared runner, no kill). doyle: "verify each agent-traced root at the code before building — flag wrong" (I confirmed B2 latch + push-has-no-consumer + H3; flagged the B2 push-vs-pull gap → doyle ruled pull-primary).

-**PROGRESS: B2 GATE-PASS @03b6398. WAVE 2 (B1+H3) GATE-PASS+FIXED @78a13c2** (was @f85bf69; path-(c) was a deadlock, removed). B1 NOW 2-paths: (a) run_attach status-gate (status=offline→clean short-circuit; NOT is_perch_alive — true for absent info.json), (b) PumpEnd::Stalled 30s backstop (never bites mid-init alive). H3=cmd_stop set_status OFFLINE. Full attach 12/12 (1.08s), broker 5/5, units 3/3, clippy --workspace --all-targets clean, traceable EXIT=0. Remaining waves: B3+Breap → B5 → B4.

+**PROGRESS: B2 GATE-PASS @03b6398. WAVE 2 (B1+H3) GATE-PASS+FIXED @78a13c2. WAVE 3 (B3+Breap) BUILT @026e9b4 + grandchild gate-cond @0c1e59e — doyle CONDITIONAL-PASS (design/safety/wiring sound), condition MET, awaiting final sign-off.** Remaining waves: B5 (wave 4) → B4 (wave 5). Stack 6 ahead of main, NOT pushed.

+**WAVE 3 design (B3+Breap), both [impl,unit]:** B3 = client-side barrier in seedmap::request_stop (after STOPPING ack, drop conn, poll ping() to failure / 10ms slices, 5s wedge-ceiling — doyle's option 2, NOT socket-unbind-before-ack). Breap = `reap::BrainReaper` rooted at the BRAIN SUBTREE (never the daemon → reap never self-terminates, never touches a sibling live agent on the shared box): Win = kill-on-job-close Job, brain assigned per (re)spawn in spawn_brain_supervisor, Psyches inherit; Unix = brain pre_exec setpgid(0,0) leads own group, supervisor records pgid, kill(-pgid,SIGKILL). Daemon::run graceful path raises supervisor stop flag FIRST (hoisted the "held for symmetry never raised" Arc) THEN reaps; straggler-brain backstop = self-exit on broker-loss.

+**WAVE 3 LESSONS (2 Windows-process gotchas, both reusable):** (1) **terminated process w/ open handle reads ALIVE** — is_process_alive(pid) (OpenProcess) returns true for a TerminateJobObject'd process while a Child handle is still held (kernel object lingers; same class as the Unix-zombie OpenProcess gap). For an OWNED child use try_wait, not is_process_alive(pid). (2) **UseShellExecute=true → job breakaway** — Windows PowerShell's [Diagnostics.Process]::Start defaults UseShellExecute=true → ShellExecute spawns the grandchild OUTSIDE the job → it survives the reap. Force UseShellExecute=$false (CreateProcess) so it inherits the job — mirrors the brain→Psyche spawn (null stdio + CreateProcess, no breakaway). doyle's "exercise the grandchild link, don't reason it" caught this — the child-only unit was green while the real orphan path escaped.


Memory updated. doyle's message pre-authorized wave 4 on grandchild-green ("→ full PASS → wave 4"), and the operator's standing order is don't-wait — so I'll proceed to **wave 4 (B5)** now rather than idle. Reading `livehost.rs` to design the boot-liveness-gate (B5 is the boot-tick arm of B2's reconcile):


240	    set: &Arc<LiveSet>,
241	    cfg: &DaemonConfig,
242	    reason: StartReason,
243	) {
244	    // Stop side first: hosted endpoints that DEFINITIVELY left the online set —
245	    // trip the flag, join, de-table. Un-host on a definitive departure ONLY, never
246	    // on a TRANSIENT read failure (M11-W0.4 (ii)): a momentarily-unreadable
247	    // info.json must not stop+respawn the driver (Psyche churn on a disk blip). The
248	    // discriminator is the perch DIR:
249	    //   - dir gone        → torn down (teardown removed the perch) → un-host;
250	    //   - dir present, read_info Some(status≠online) → signed-off/offline → un-host;
251	    //   - dir present, read_info None (transient/torn read) → LEAVE the driver, retry.
252	    // [impl->REQ-DAEMON-1]
253	    for id in set.ids() {
254	        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
255	        let unhost = if !perch.exists() {
256	            true // the perch dir is gone — torn down
257	        } else {
258	            match spt_store::info::read_info(&perch) {
259	                Some(info) => info.status.as_deref() != Some(STATUS_ONLINE),
260	                None => false, // dir present but unreadable: transient → keep hosting
261	            }
262	        };
263	        if unhost {
264	            set.stop_host(&id);
265	            continue;
266	        }
267	        // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT] RESIDENCY confirm: the parent is
268	        // still online, but if its `{id}-psyche` child spawned then DIED within the
269	        // grace window (the F-009 bad-argv exit-2 case — spawn() returned Ok so the
270	        // v0.8.1 spawn-Err stamp never fired), stamp the parent psyche_host_error,
271	        // un-host, and clear the phantom online nested perch. Runs on a LATER tick,
272	        // never a synchronous wait — the sweep is not blocked.
273	        confirm_residency_or_unhost(set, &id);
274	    }
275
276	    // Start side: every online live endpoint not yet hosted.
277	    for id in perch::list_self_perch_ids(owlery) {
278	        if set.contains(&id) {
279	            continue; // already hosted (leave-alone)
280	        }
281	        // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT] a just-failed residency endpoint is
282	        // held off until its cooldown expires — do NOT re-spawn the doomed psyche this
283	        // same sweep (that would clear+recreate the phantom + churn). One quiescent
284	        // window, then a single retry the tick after the cooldown.
285	        if set.in_nonresident_cooldown(&id) {
286	            continue;
287	        }
288	        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
289	        let Some(info) = spt_store::info::read_info(&perch) else {
290	            continue;
291	        };
292	        if info.state != LIVE_AGENT_STATE {
293	            continue; // not a live agent (ready_agent etc.)
294	        }
295	        if info.status.as_deref() != Some(STATUS_ONLINE) {
296	            continue; // interim (status-absent) or signed-off (offline)
297	        }
298	        let Some(adapter) = info.adapter.as_deref() else {
299	            continue; // adapterless endpoint — never live-capable
300	        };
301	        let Ok(manifest) = spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter)
302	        else {
303	            continue; // deregistered adapter: nothing to host
304	        };
305	        // [impl->REQ-INSTALL-11] resolve the Psyche role program against the
306	        // adapter install dir — the registry record's precise `source_dir`, the
307	        // same dir Feature E's api seam uses (mod.rs::resolve_ctx_manifest). The
308	        // daemon-host site is the heir to the retired in-process spawn's
309	        // install-dir resolution: without it the daemon-hosted Psyche resolves a
310	        // copy-mode psyche binary only via PATH, not its install dir.
311	        let parent = adapter.split(':').next().unwrap_or(adapter);
312	        let install_dir = registered
313	            .iter()
314	            .find(|(rec, _)| rec.name == parent)
315	            .map(|(rec, _)| std::path::PathBuf::from(&rec.source_dir));
316	        let Some(lifecycle) =
317	            BrainLifecycle::with_config_in(&manifest, &id, cfg.clone(), install_dir.as_deref())
318	        else {
319	            continue; // manifest declares no [session.psyche_init]
320	        };
321	        host_one(set, &id, lifecycle, info.session_id, reason);
322	    }
323	}
324
325	/// Host one live endpoint: sweep a stale signoff (3.2), spawn the Psyche (the
326	/// daemon online-stamp makes liveness daemon-authoritative), then start the
327	/// config-paced pulse driver on its own supervised thread with a held stop flag.
328	/// A Psyche-spawn failure tables nothing — the next sweep retries.
329	// [impl->REQ-DAEMON-1]
330	fn host_one(
331	    set: &Arc<LiveSet>,
332	    id: &str,
333	    lifecycle: BrainLifecycle,
334	    session_id: String,
335	    reason: StartReason,
336	) {
337	    lifecycle.sweep_on_start();
338	    // Resolve the live perch once — the same path the start-scan resolved it by
339	    // (resolve_perch_path/Infer), so the harness-reachable stamp lands on the
340	    // exact info.json `spt endpoint list` / `whoami` reads.
341	    let perch = perch::resolve_perch_path(id, ParentHint::Infer);
342	    match lifecycle.spawn_psyche(Some(&session_id), None) {
343	        Ok(h) => {
344	            eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid);
345	            // NOTE: the prior psyche-host-failure stamp is NOT cleared here. Post
346	            // F-010 a successful detached spawn() is NOT a successful host — the
347	            // child may die within moments (the bad-argv exit-2 case). The clear
348	            // moved to confirm_residency_or_unhost's Resident arm, where a LIVE
349	            // resident psyche past the grace window proves the host truly took.
350	            // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
351	        }
352	        Err(e) => {
353	            // Keep the (invisible) brain-stderr line AND stamp the failure onto the
354	            // perch's info.json so it is HARNESS-REACHABLE — a harness/human reads
355	            // perch state, never the brain's stderr. Current-state stamp: overwrite
356	            // reason+ts, increment attempts (the helper reads the prior count inside
357	            // its locked RMW). `status` is NOT touched — liveness stays authoritative.
358	            // host_one returns without tabling, so the 5s reconcile retries and each
359	            // retry re-stamps; a later CONFIRMED-RESIDENT host clears it (in
360	            // confirm_residency_or_unhost, not on bare spawn-Ok — see F-010 note above).
361	            // [impl->REQ-HAZARD-LIVEHOST-BOOT-RACE]
362	            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
363	            let _ = spt_store::info::set_psyche_host_error(&perch, Some(&e.to_string()));
364	            return; // no Psyche → no driver; retried next reconcile
365	        }
366	    }
367	    let stop = Arc::new(AtomicBool::new(false));
368	    let handle = {
369	        let stop = Arc::clone(&stop);
370	        std::thread::spawn(move || {
371	            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
372	        })
373	    };
374	    // Stamp the host instant — the residency-grace anchor a later tick measures
375	    // the `{id}-psyche` perch's liveness against (REQ-HAZARD-LIVEHOST-NONRESIDENT).
376	    set.insert(
377	        id,
378	        HostedLife {
379	            stop,
380	            thread: handle,
381	            hosted_at: Instant::now(),
382	        },
383	    );
384	}
385
386	/// Confirm RESIDENCY of a hosted endpoint's `{id}-psyche` child past its grace
387	/// window — the F-010 fix (REQ-HAZARD-LIVEHOST-NONRESIDENT). `spawn_psyche`
388	/// detaches (the summarizer handle is dropped), so a child that spawned Ok but
389	/// DIED within moments (a bad-argv exit-2 child — the F-009 case) leaves the
390	/// nested `{id}-psyche` info.json written `status=online` with a real-but-DEAD
391	/// pid while the PARENT carries no failure. The reconcile-tick guard:
392	///
393	/// - Read the `{id}-psyche` perch's RECORDED pid and pid-probe it DIRECTLY
394	///   ([`spt_store::proc::is_process_alive`]) — NOT [`is_perch_alive`], which
395	///   short-circuits on the `status=online` stamp and would mask the dead pid.
396	/// - [`classify_residency`] then decides: alive ⇒ resident (leave alone);
397	///   inside grace ⇒ pending (retry next tick); past grace + dead/missing ⇒ a
398	///   host failure → stamp the PARENT perch `psyche_host_error{reason: host
399	///   not resident within <n>s}`, un-host the driver, and clear the phantom online
400	///   nested perch so it is not falsely reported live.
401	///
402	/// Returns `true` when it un-hosted (so the caller knows the endpoint left the
403	/// table). Best-effort: every store call ignores transient errors.
404	// [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
405	fn confirm_residency_or_unhost(set: &Arc<LiveSet>, id: &str) -> bool {
406	    let Some(elapsed) = set.hosted_elapsed(id) else {
407	        return false; // not tabled (or just un-hosted) — nothing to confirm
408	    };
409	    let psyche_perch = perch::resolve_perch_path(&format!("{id}-psyche"), ParentHint::Explicit(id));
410	    // DIRECT pid probe — the psyche perch is stamped status=online by spawn_psyche,
411	    // so is_perch_alive() would read it alive even with a dead pid (that is the
412	    // exact masking this closes). read_pid → dead/missing ⇒ not alive.
413	    let psyche_alive = spt_store::info::read_pid(&psyche_perch)
414	        .map(spt_store::proc::is_process_alive)
415	        .unwrap_or(false);
416	    let parent_perch = perch::resolve_perch_path(id, ParentHint::Infer);
417	    match classify_residency(elapsed, PSYCHE_RESIDENCY_GRACE, psyche_alive) {
418	        Residency::Resident => {
419	            // RESIDENT past observation: the host truly succeeded. CLEAR any prior

    set: &Arc<LiveSet>,
    cfg: &DaemonConfig,
    reason: StartReason,
) {
    // Stop side first: hosted endpoints that DEFINITIVELY left the online set —
    // trip the flag, join, de-table. Un-host on a definitive departure ONLY, never
    // on a TRANSIENT read failure (M11-W0.4 (ii)): a momentarily-unreadable
    // info.json must not stop+respawn the driver (Psyche churn on a disk blip). The
    // discriminator is the perch DIR:
    //   - dir gone        → torn down (teardown removed the perch) → un-host;
    //   - dir present, read_info Some(status≠online) → signed-off/offline → un-host;
    //   - dir present, read_info None (transient/torn read) → LEAVE the driver, retry.
    // [impl->REQ-DAEMON-1]
    for id in set.ids() {
        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
        let unhost = if !perch.exists() {
            true // the perch dir is gone — torn down
        } else {
            match spt_store::info::read_info(&perch) {
                Some(info) => info.status.as_deref() != Some(STATUS_ONLINE),
                None => false, // dir present but unreadable: transient → keep hosting
            }
        };
        if unhost {
            set.stop_host(&id);
            continue;
        }
        // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT] RESIDENCY confirm: the parent is
        // still online, but if its `{id}-psyche` child spawned then DIED within the
        // grace window (the F-009 bad-argv exit-2 case — spawn() returned Ok so the
        // v0.8.1 spawn-Err stamp never fired), stamp the parent psyche_host_error,
        // un-host, and clear the phantom online nested perch. Runs on a LATER tick,
        // never a synchronous wait — the sweep is not blocked.
        confirm_residency_or_unhost(set, &id);
    }

    // Start side: every online live endpoint not yet hosted.
    for id in perch::list_self_perch_ids(owlery) {
        if set.contains(&id) {
            continue; // already hosted (leave-alone)
        }
        // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT] a just-failed residency endpoint is
        // held off until its cooldown expires — do NOT re-spawn the doomed psyche this
        // same sweep (that would clear+recreate the phantom + churn). One quiescent
        // window, then a single retry the tick after the cooldown.
        if set.in_nonresident_cooldown(&id) {
            continue;
        }
        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
        let Some(info) = spt_store::info::read_info(&perch) else {
            continue;
        };
        if info.state != LIVE_AGENT_STATE {
            continue; // not a live agent (ready_agent etc.)
        }
        if info.status.as_deref() != Some(STATUS_ONLINE) {
            continue; // interim (status-absent) or signed-off (offline)
        }
        let Some(adapter) = info.adapter.as_deref() else {
            continue; // adapterless endpoint — never live-capable
        };
        let Ok(manifest) = spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter)
        else {
            continue; // deregistered adapter: nothing to host
        };
        // [impl->REQ-INSTALL-11] resolve the Psyche role program against the
        // adapter install dir — the registry record's precise `source_dir`, the
        // same dir Feature E's api seam uses (mod.rs::resolve_ctx_manifest). The
        // daemon-host site is the heir to the retired in-process spawn's
        // install-dir resolution: without it the daemon-hosted Psyche resolves a
        // copy-mode psyche binary only via PATH, not its install dir.
        let parent = adapter.split(':').next().unwrap_or(adapter);
        let install_dir = registered
            .iter()
            .find(|(rec, _)| rec.name == parent)
            .map(|(rec, _)| std::path::PathBuf::from(&rec.source_dir));
        let Some(lifecycle) =
            BrainLifecycle::with_config_in(&manifest, &id, cfg.clone(), install_dir.as_deref())
        else {
            continue; // manifest declares no [session.psyche_init]
        };
        host_one(set, &id, lifecycle, info.session_id, reason);
    }
}

/// Host one live endpoint: sweep a stale signoff (3.2), spawn the Psyche (the
/// daemon online-stamp makes liveness daemon-authoritative), then start the
/// config-paced pulse driver on its own supervised thread with a held stop flag.
/// A Psyche-spawn failure tables nothing — the next sweep retries.
// [impl->REQ-DAEMON-1]
fn host_one(
    set: &Arc<LiveSet>,
    id: &str,
    lifecycle: BrainLifecycle,
    session_id: String,
    reason: StartReason,
) {
    lifecycle.sweep_on_start();
    // Resolve the live perch once — the same path the start-scan resolved it by
    // (resolve_perch_path/Infer), so the harness-reachable stamp lands on the
    // exact info.json `spt endpoint list` / `whoami` reads.
    let perch = perch::resolve_perch_path(id, ParentHint::Infer);
    match lifecycle.spawn_psyche(Some(&session_id), None) {
        Ok(h) => {
            eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid);
            // NOTE: the prior psyche-host-failure stamp is NOT cleared here. Post
            // F-010 a successful detached spawn() is NOT a successful host — the
            // child may die within moments (the bad-argv exit-2 case). The clear
            // moved to confirm_residency_or_unhost's Resident arm, where a LIVE
            // resident psyche past the grace window proves the host truly took.
            // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
        }
        Err(e) => {
            // Keep the (invisible) brain-stderr line AND stamp the failure onto the
            // perch's info.json so it is HARNESS-REACHABLE — a harness/human reads
            // perch state, never the brain's stderr. Current-state stamp: overwrite
            // reason+ts, increment attempts (the helper reads the prior count inside
            // its locked RMW). `status` is NOT touched — liveness stays authoritative.
            // host_one returns without tabling, so the 5s reconcile retries and each
            // retry re-stamps; a later CONFIRMED-RESIDENT host clears it (in
            // confirm_residency_or_unhost, not on bare spawn-Ok — see F-010 note above).
            // [impl->REQ-HAZARD-LIVEHOST-BOOT-RACE]
            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
            let _ = spt_store::info::set_psyche_host_error(&perch, Some(&e.to_string()));
            return; // no Psyche → no driver; retried next reconcile
        }
    }
    let stop = Arc::new(AtomicBool::new(false));
    let handle = {
        let stop = Arc::clone(&stop);
        std::thread::spawn(move || {
            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
        })
    };
    // Stamp the host instant — the residency-grace anchor a later tick measures
    // the `{id}-psyche` perch's liveness against (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    set.insert(
        id,
        HostedLife {
            stop,
            thread: handle,
            hosted_at: Instant::now(),
        },
    );
}

/// Confirm RESIDENCY of a hosted endpoint's `{id}-psyche` child past its grace
/// window — the F-010 fix (REQ-HAZARD-LIVEHOST-NONRESIDENT). `spawn_psyche`
/// detaches (the summarizer handle is dropped), so a child that spawned Ok but
/// DIED within moments (a bad-argv exit-2 child — the F-009 case) leaves the
/// nested `{id}-psyche` info.json written `status=online` with a real-but-DEAD
/// pid while the PARENT carries no failure. The reconcile-tick guard:
///
/// - Read the `{id}-psyche` perch's RECORDED pid and pid-probe it DIRECTLY
///   ([`spt_store::proc::is_process_alive`]) — NOT [`is_perch_alive`], which
///   short-circuits on the `status=online` stamp and would mask the dead pid.
/// - [`classify_residency`] then decides: alive ⇒ resident (leave alone);
///   inside grace ⇒ pending (retry next tick); past grace + dead/missing ⇒ a
///   host failure → stamp the PARENT perch `psyche_host_error{reason: host
///   not resident within <n>s}`, un-host the driver, and clear the phantom online
///   nested perch so it is not falsely reported live.
///
/// Returns `true` when it un-hosted (so the caller knows the endpoint left the
/// table). Best-effort: every store call ignores transient errors.
// [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
fn confirm_residency_or_unhost(set: &Arc<LiveSet>, id: &str) -> bool {
    let Some(elapsed) = set.hosted_elapsed(id) else {
        return false; // not tabled (or just un-hosted) — nothing to confirm
    };
    let psyche_perch = perch::resolve_perch_path(&format!("{id}-psyche"), ParentHint::Explicit(id));
    // DIRECT pid probe — the psyche perch is stamped status=online by spawn_psyche,
    // so is_perch_alive() would read it alive even with a dead pid (that is the
    // exact masking this closes). read_pid → dead/missing ⇒ not alive.
    let psyche_alive = spt_store::info::read_pid(&psyche_perch)
        .map(spt_store::proc::is_process_alive)
        .unwrap_or(false);
    let parent_perch = perch::resolve_perch_path(id, ParentHint::Infer);
    match classify_residency(elapsed, PSYCHE_RESIDENCY_GRACE, psyche_alive) {
        Residency::Resident => {
            // RESIDENT past observation: the host truly succeeded. CLEAR any prior


Read the reconcile loop entry, boot grace, and the B2 `reconcile_hosted_liveness` I built (B5 is its boot-tick arm):


1	//! The brain's live-agent Psyche host (M11-W0.1, REQ-DAEMON-1) — re-host the
2	//! daemon-hosted LiveAgent lifecycle from disk and reconcile the host-set.
3	//!
4	//! This is the production caller `run_pulse_loop` lacked: the brain instantiates
5	//! a [`BrainLifecycle`] per hosted **live** endpoint and drives its config-paced
6	//! pulse loop on a supervised thread. It is the **harness-endpoint mirror** of
7	//! [`crate::shellwake::reconcile_once`] (which re-hosts offline shells from
8	//! disk): same shape — enumerate Self perches, resolve each manifest from its
9	//! `info.json` `adapter` option, hold a stop flag per supervised thread, and run
10	//! one bounded reconcile sweep at boot and every tick. Where the shell waker
11	//! hosts **offline** shells, this hosts **online** live endpoints — `status` is
12	//! daemon-authoritative liveness (CONTEXT:194 / KH 2.5), so re-hosting the
13	//! online ones re-hosts exactly what was live before a brain (re)start, and
14	//! never resurrects a gracefully-departed (offline) agent.
15	//!
16	//! **Online-only is staged behind the interim.** The interim `spt api listen`
17	//! process still first-hosts its live agents today; those perches are
18	//! `status`-ABSENT (not "online"), so this scan ignores them — no double-host in
19	//! the W0.1→W0.2 window. W0.2 retires the interim and makes the brain the
20	//! first-host (it sets `status=online` on a live `listen`/`bind`); from then this
21	//! reconcile reacts within one tick, the same way the shell waker reacts to a
22	//! CLI-process status flip. The full bind→online→brain-restart→re-host arc is
23	//! proven through the real brain process by the W0.4 production int.
24	// [impl->REQ-DAEMON-1]
25
26	use std::collections::{BTreeSet, HashMap};
27	use std::path::Path;
28	use std::sync::atomic::{AtomicBool, Ordering};
29	use std::sync::{Arc, Mutex};
30	use std::thread::JoinHandle;
31	use std::time::{Duration, Instant};
32
33	use spt_runtime::registry::AdapterRecord;
34	use spt_runtime::Manifest;
35	use spt_store::liveness::STATUS_ONLINE;
36	use spt_store::perch::{self, ParentHint};
37
38	use crate::brain::{now_ms, Brain};
39	use crate::brainproc::StartReason;
40	use crate::config::DaemonConfig;
41	use crate::lifecycle::BrainLifecycle;
42
43	/// The `InfoJson::state` tag of a live agent — only these get a daemon-hosted
44	/// Psyche; a `ready_agent` (messaging-only) never does.
45	const LIVE_AGENT_STATE: &str = "live_agent";
46
47	/// The reconcile cadence (ms): a lifecycle flip lands in a CLI process, so the
48	/// daemon loop is the invariant holder — a flip is at most one tick stale
49	/// (mirrors [`crate::shellwake::RECONCILE_INTERVAL_MS`]).
50	pub const LIVE_RECONCILE_INTERVAL_MS: u64 = 5_000;
51
52	/// Residency grace after a host attempt: a hosted child's `{id}-psyche` perch
53	/// must show a LIVE pid within this window, else the host is declared failed
54	/// (REQ-HAZARD-LIVEHOST-NONRESIDENT). Chosen as ~one reconcile interval (5s): a
55	/// child that exited fast (the F-009 bad-argv case, ~1s) is well past it on the
56	/// next tick, while a slow-but-healthy psyche binary that is still coming up
57	/// inside the first interval is NOT prematurely torn down. The check runs on a
58	/// LATER tick (never a synchronous wait inside the sweep), so the effective
59	/// detection latency is `grace + up to one reconcile interval`.
60	pub const PSYCHE_RESIDENCY_GRACE: Duration = Duration::from_secs(5);
61
62	/// Boot grace before the B2 pull-liveness reconcile runs
63	/// (REQ-HAZARD-HOSTED-LIVENESS-RECONCILE): a freshly (re)started daemon brain may
64	/// still be re-spawning harnesses / the broker session set may lag the on-disk
65	/// `status=online` for a window. Holding the no-session→offline pass off until the
66	/// loop has run a few seconds prevents a cold-start / brain-restart window from
67	/// mass-offlining endpoints that are merely mid-bringup. Chosen as ~two reconcile
68	/// intervals.
69	pub const LIVENESS_RECONCILE_BOOT_GRACE: Duration = Duration::from_secs(10);
70
71	/// The residency verdict for a hosted endpoint's `{id}-psyche` child, decided
72	/// purely from observed facts (no IO) so it is unit-testable in isolation.
73	// [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
74	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75	pub enum Residency {
76	    /// The psyche child is alive (a live recorded pid) — host confirmed.
77	    Resident,
78	    /// Still inside the grace window — too early to judge; check again next tick.
79	    Pending,
80	    /// Past grace with a missing/dead `{id}-psyche` pid — a host failure.
81	    Failed,
82	}
83
84	/// Decide residency from observed facts: how long since the host attempt, the
85	/// grace window, and whether the `{id}-psyche` perch currently shows a LIVE pid
86	/// (`None` ⇒ the perch is missing or carries no/dead numeric pid). Pure.
87	// [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
88	pub fn classify_residency(elapsed: Duration, grace: Duration, psyche_alive: bool) -> Residency {
89	    if psyche_alive {
90	        Residency::Resident
91	    } else if elapsed < grace {
92	        Residency::Pending
93	    } else {
94	        Residency::Failed
95	    }
96	}
97
98	/// One brain-hosted live lifecycle: its config-paced pulse-driver thread, the
99	/// held stop flag (tripped to un-host on an offline-transition), and the host
100	/// start instant (the anchor the residency grace counts from).
101	struct HostedLife {
102	    stop: Arc<AtomicBool>,
103	    thread: JoinHandle<()>,
104	    /// When [`host_one`] spawned this endpoint's Psyche — the residency-grace
105	    /// anchor (REQ-HAZARD-LIVEHOST-NONRESIDENT).
106	    hosted_at: Instant,
107	}
108
109	/// After a residency failure, hold off re-hosting the doomed endpoint for this
110	/// long so the system goes QUIESCENT in the failed state — the parent's
111	/// `psyche_host_error` stamp stays visible and the phantom nested perch stays
112	/// cleared, instead of a spawn-storm of a fast-dying psyche every reconcile tick
113	/// (and a flickering stamp). One reconcile interval: the next host attempt is the
114	/// tick AFTER the cooldown, so a transiently-broken psyche still gets retried, a
115	/// permanently-broken one is stamped + quiet (REQ-HAZARD-LIVEHOST-NONRESIDENT).
116	pub const RESIDENCY_RETRY_COOLDOWN: Duration = Duration::from_millis(LIVE_RECONCILE_INTERVAL_MS);
117
118	/// The brain's hosted live-endpoint table, keyed by endpoint id. Shared between
119	/// the reconcile loop and anything that must stop a host. Mirror of
120	/// [`crate::shellwake::WakeSet`].
121	#[derive(Default)]
122	pub struct LiveSet {
123	    inner: Mutex<HashMap<String, HostedLife>>,
124	    /// Per-endpoint cooldown deadline after a residency failure: the start-side
125	    /// skips re-hosting until now passes this instant, so a doomed psyche is not
126	    /// re-spawned every tick (REQ-HAZARD-LIVEHOST-NONRESIDENT).
127	    nonresident_until: Mutex<HashMap<String, Instant>>,
128	}
129
130	impl LiveSet {
131	    pub fn new() -> Self {
132	        Self::default()
133	    }
134
135	    /// How many lifecycles are hosted (finished driver threads pruned first).
136	    pub fn len(&self) -> usize {
137	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
138	        map.retain(|_, h| !h.thread.is_finished());
139	        map.len()
140	    }
141
142	    pub fn is_empty(&self) -> bool {
143	        self.len() == 0
144	    }
145
146	    /// Is `id` currently hosted (driver thread alive)? Prunes a finished entry so
147	    /// a crashed driver is re-hosted next sweep.
148	    fn contains(&self, id: &str) -> bool {
149	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
150	        match map.get(id) {
151	            Some(h) if !h.thread.is_finished() => true,
152	            Some(_) => {
153	                map.remove(id);
154	                false
155	            }
156	            None => false,
157	        }
158	    }
159
160	    fn insert(&self, id: &str, handle: HostedLife) {
161	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
162	        map.insert(id.to_string(), handle);
163	    }
164
165	    /// The currently-tabled ids (finished pruned) — the stop-side reconcile input.
166	    fn ids(&self) -> Vec<String> {
167	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
168	        map.retain(|_, h| !h.thread.is_finished());
169	        map.keys().cloned().collect()
170	    }
171
172	    /// How long since `id` was hosted (the residency-grace anchor), or `None` if
173	    /// it is not tabled. [`host_one`] stamps `hosted_at` at spawn time.
174	    // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
175	    fn hosted_elapsed(&self, id: &str) -> Option<Duration> {
176	        let map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
177	        map.get(id).map(|h| h.hosted_at.elapsed())
178	    }
179
180	    /// Arm the residency-failure cooldown for `id` (the start-side then skips
181	    /// re-hosting until it expires). // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
182	    fn arm_nonresident_cooldown(&self, id: &str) {
183	        let mut map = self
184	            .nonresident_until
185	            .lock()
186	            .unwrap_or_else(|p| p.into_inner());
187	        map.insert(id.to_string(), Instant::now() + RESIDENCY_RETRY_COOLDOWN);
188	    }
189
190	    /// Is `id` still inside its residency-failure cooldown (skip re-host)? Expired
191	    /// entries are pruned. // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
192	    fn in_nonresident_cooldown(&self, id: &str) -> bool {
193	        let mut map = self
194	            .nonresident_until
195	            .lock()
196	            .unwrap_or_else(|p| p.into_inner());
197	        match map.get(id) {
198	            Some(until) if Instant::now() < *until => true,
199	            Some(_) => {
200	                map.remove(id);
201	                false
202	            }
203	            None => false,
204	        }
205	    }
206
207	    /// Un-host one lifecycle: trip its stop flag, join the driver, de-table. The
208	    /// pulse loop checks the flag each sleep-slice, so the join is prompt. We do
209	    /// NOT re-stamp `offline` — the perch already left the online set (graceful
210	    /// signoff cleared it, or the perch is gone); a brain *crash* deliberately
211	    /// leaves `status=online` so the next brain rehydrates it (CONTEXT:194).
212	    fn stop_host(&self, id: &str) {
213	        let handle = {
214	            let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
215	            map.remove(id)
216	        };
217	        if let Some(h) = handle {
218	            h.stop.store(true, Ordering::SeqCst);
219	            let _ = h.thread.join();
220	        }
221	    }
222	}
223
224	/// One reconcile sweep — the brain's live host-set invariant holder, run at boot
225	/// and every [`LIVE_RECONCILE_INTERVAL_MS`]. The complete set-diff against the
226	/// on-disk **online** live endpoints (mirror of [`crate::shellwake::reconcile_once`]):
227	///
228	/// - **un-host** (tabled ∧ ¬online) — stop the driver + de-table (exit guard: a
229	///   signed-off / removed endpoint must not keep pulsing — thread + dead-agent leak).
230	/// - **host** (online ∧ ¬tabled) — spawn the Psyche + start the driver.
231	/// - **leave-alone** (online ∧ tabled).
232	///
233	/// `status` is daemon-authoritative (CONTEXT:194); interim `listen` perches are
234	/// `status`-ABSENT so they are never hosted here (no double-host) until W0.2.
235	// [impl->REQ-DAEMON-1]
236	pub fn reconcile_once(
237	    owlery: &Path,
238	    registered: &[(AdapterRecord, Manifest)],
239	    adapters_dir: &Path,
240	    set: &Arc<LiveSet>,

//! The brain's live-agent Psyche host (M11-W0.1, REQ-DAEMON-1) — re-host the
//! daemon-hosted LiveAgent lifecycle from disk and reconcile the host-set.
//!
//! This is the production caller `run_pulse_loop` lacked: the brain instantiates
//! a [`BrainLifecycle`] per hosted **live** endpoint and drives its config-paced
//! pulse loop on a supervised thread. It is the **harness-endpoint mirror** of
//! [`crate::shellwake::reconcile_once`] (which re-hosts offline shells from
//! disk): same shape — enumerate Self perches, resolve each manifest from its
//! `info.json` `adapter` option, hold a stop flag per supervised thread, and run
//! one bounded reconcile sweep at boot and every tick. Where the shell waker
//! hosts **offline** shells, this hosts **online** live endpoints — `status` is
//! daemon-authoritative liveness (CONTEXT:194 / KH 2.5), so re-hosting the
//! online ones re-hosts exactly what was live before a brain (re)start, and
//! never resurrects a gracefully-departed (offline) agent.
//!
//! **Online-only is staged behind the interim.** The interim `spt api listen`
//! process still first-hosts its live agents today; those perches are
//! `status`-ABSENT (not "online"), so this scan ignores them — no double-host in
//! the W0.1→W0.2 window. W0.2 retires the interim and makes the brain the
//! first-host (it sets `status=online` on a live `listen`/`bind`); from then this
//! reconcile reacts within one tick, the same way the shell waker reacts to a
//! CLI-process status flip. The full bind→online→brain-restart→re-host arc is
//! proven through the real brain process by the W0.4 production int.
// [impl->REQ-DAEMON-1]

use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};

use spt_runtime::registry::AdapterRecord;
use spt_runtime::Manifest;
use spt_store::liveness::STATUS_ONLINE;
use spt_store::perch::{self, ParentHint};

use crate::brain::{now_ms, Brain};
use crate::brainproc::StartReason;
use crate::config::DaemonConfig;
use crate::lifecycle::BrainLifecycle;

/// The `InfoJson::state` tag of a live agent — only these get a daemon-hosted
/// Psyche; a `ready_agent` (messaging-only) never does.
const LIVE_AGENT_STATE: &str = "live_agent";

/// The reconcile cadence (ms): a lifecycle flip lands in a CLI process, so the
/// daemon loop is the invariant holder — a flip is at most one tick stale
/// (mirrors [`crate::shellwake::RECONCILE_INTERVAL_MS`]).
pub const LIVE_RECONCILE_INTERVAL_MS: u64 = 5_000;

/// Residency grace after a host attempt: a hosted child's `{id}-psyche` perch
/// must show a LIVE pid within this window, else the host is declared failed
/// (REQ-HAZARD-LIVEHOST-NONRESIDENT). Chosen as ~one reconcile interval (5s): a
/// child that exited fast (the F-009 bad-argv case, ~1s) is well past it on the
/// next tick, while a slow-but-healthy psyche binary that is still coming up
/// inside the first interval is NOT prematurely torn down. The check runs on a
/// LATER tick (never a synchronous wait inside the sweep), so the effective
/// detection latency is `grace + up to one reconcile interval`.
pub const PSYCHE_RESIDENCY_GRACE: Duration = Duration::from_secs(5);

/// Boot grace before the B2 pull-liveness reconcile runs
/// (REQ-HAZARD-HOSTED-LIVENESS-RECONCILE): a freshly (re)started daemon brain may
/// still be re-spawning harnesses / the broker session set may lag the on-disk
/// `status=online` for a window. Holding the no-session→offline pass off until the
/// loop has run a few seconds prevents a cold-start / brain-restart window from
/// mass-offlining endpoints that are merely mid-bringup. Chosen as ~two reconcile
/// intervals.
pub const LIVENESS_RECONCILE_BOOT_GRACE: Duration = Duration::from_secs(10);

/// The residency verdict for a hosted endpoint's `{id}-psyche` child, decided
/// purely from observed facts (no IO) so it is unit-testable in isolation.
// [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Residency {
    /// The psyche child is alive (a live recorded pid) — host confirmed.
    Resident,
    /// Still inside the grace window — too early to judge; check again next tick.
    Pending,
    /// Past grace with a missing/dead `{id}-psyche` pid — a host failure.
    Failed,
}

/// Decide residency from observed facts: how long since the host attempt, the
/// grace window, and whether the `{id}-psyche` perch currently shows a LIVE pid
/// (`None` ⇒ the perch is missing or carries no/dead numeric pid). Pure.
// [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
pub fn classify_residency(elapsed: Duration, grace: Duration, psyche_alive: bool) -> Residency {
    if psyche_alive {
        Residency::Resident
    } else if elapsed < grace {
        Residency::Pending
    } else {
        Residency::Failed
    }
}

/// One brain-hosted live lifecycle: its config-paced pulse-driver thread, the
/// held stop flag (tripped to un-host on an offline-transition), and the host
/// start instant (the anchor the residency grace counts from).
struct HostedLife {
    stop: Arc<AtomicBool>,
    thread: JoinHandle<()>,
    /// When [`host_one`] spawned this endpoint's Psyche — the residency-grace
    /// anchor (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    hosted_at: Instant,
}

/// After a residency failure, hold off re-hosting the doomed endpoint for this
/// long so the system goes QUIESCENT in the failed state — the parent's
/// `psyche_host_error` stamp stays visible and the phantom nested perch stays
/// cleared, instead of a spawn-storm of a fast-dying psyche every reconcile tick
/// (and a flickering stamp). One reconcile interval: the next host attempt is the
/// tick AFTER the cooldown, so a transiently-broken psyche still gets retried, a
/// permanently-broken one is stamped + quiet (REQ-HAZARD-LIVEHOST-NONRESIDENT).
pub const RESIDENCY_RETRY_COOLDOWN: Duration = Duration::from_millis(LIVE_RECONCILE_INTERVAL_MS);

/// The brain's hosted live-endpoint table, keyed by endpoint id. Shared between
/// the reconcile loop and anything that must stop a host. Mirror of
/// [`crate::shellwake::WakeSet`].
#[derive(Default)]
pub struct LiveSet {
    inner: Mutex<HashMap<String, HostedLife>>,
    /// Per-endpoint cooldown deadline after a residency failure: the start-side
    /// skips re-hosting until now passes this instant, so a doomed psyche is not
    /// re-spawned every tick (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    nonresident_until: Mutex<HashMap<String, Instant>>,
}

impl LiveSet {
    pub fn new() -> Self {
        Self::default()
    }

    /// How many lifecycles are hosted (finished driver threads pruned first).
    pub fn len(&self) -> usize {
        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        map.retain(|_, h| !h.thread.is_finished());
        map.len()
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Is `id` currently hosted (driver thread alive)? Prunes a finished entry so
    /// a crashed driver is re-hosted next sweep.
    fn contains(&self, id: &str) -> bool {
        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        match map.get(id) {
            Some(h) if !h.thread.is_finished() => true,
            Some(_) => {
                map.remove(id);
                false
            }
            None => false,
        }
    }

    fn insert(&self, id: &str, handle: HostedLife) {
        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        map.insert(id.to_string(), handle);
    }

    /// The currently-tabled ids (finished pruned) — the stop-side reconcile input.
    fn ids(&self) -> Vec<String> {
        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        map.retain(|_, h| !h.thread.is_finished());
        map.keys().cloned().collect()
    }

    /// How long since `id` was hosted (the residency-grace anchor), or `None` if
    /// it is not tabled. [`host_one`] stamps `hosted_at` at spawn time.
    // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
    fn hosted_elapsed(&self, id: &str) -> Option<Duration> {
        let map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        map.get(id).map(|h| h.hosted_at.elapsed())
    }

    /// Arm the residency-failure cooldown for `id` (the start-side then skips
    /// re-hosting until it expires). // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
    fn arm_nonresident_cooldown(&self, id: &str) {
        let mut map = self
            .nonresident_until
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        map.insert(id.to_string(), Instant::now() + RESIDENCY_RETRY_COOLDOWN);
    }

    /// Is `id` still inside its residency-failure cooldown (skip re-host)? Expired
    /// entries are pruned. // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
    fn in_nonresident_cooldown(&self, id: &str) -> bool {
        let mut map = self
            .nonresident_until
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        match map.get(id) {
            Some(until) if Instant::now() < *until => true,
            Some(_) => {
                map.remove(id);
                false
            }
            None => false,
        }
    }

    /// Un-host one lifecycle: trip its stop flag, join the driver, de-table. The
    /// pulse loop checks the flag each sleep-slice, so the join is prompt. We do
    /// NOT re-stamp `offline` — the perch already left the online set (graceful
    /// signoff cleared it, or the perch is gone); a brain *crash* deliberately
    /// leaves `status=online` so the next brain rehydrates it (CONTEXT:194).
    fn stop_host(&self, id: &str) {
        let handle = {
            let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
            map.remove(id)
        };
        if let Some(h) = handle {
            h.stop.store(true, Ordering::SeqCst);
            let _ = h.thread.join();
        }
    }
}

/// One reconcile sweep — the brain's live host-set invariant holder, run at boot
/// and every [`LIVE_RECONCILE_INTERVAL_MS`]. The complete set-diff against the
/// on-disk **online** live endpoints (mirror of [`crate::shellwake::reconcile_once`]):
///
/// - **un-host** (tabled ∧ ¬online) — stop the driver + de-table (exit guard: a
///   signed-off / removed endpoint must not keep pulsing — thread + dead-agent leak).
/// - **host** (online ∧ ¬tabled) — spawn the Psyche + start the driver.
/// - **leave-alone** (online ∧ tabled).
///
/// `status` is daemon-authoritative (CONTEXT:194); interim `listen` perches are
/// `status`-ABSENT so they are never hosted here (no double-host) until W0.2.
// [impl->REQ-DAEMON-1]
pub fn reconcile_once(
    owlery: &Path,
    registered: &[(AdapterRecord, Manifest)],
    adapters_dir: &Path,
    set: &Arc<LiveSet>,


Now the B2 reconcile + the spawn_live_host loop (where B5's boot-tick gating must hook):


419	            // RESIDENT past observation: the host truly succeeded. CLEAR any prior
420	            // psyche-host-failure stamp so a now-confirmed live agent shows clean.
421	            // (The clear was deliberately MOVED here from host_one's spawn-Ok leg:
422	            // post-F-010 a successful spawn() is NOT a successful host — only a
423	            // live, resident psyche is. Best-effort; status untouched.)
424	            // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
425	            let _ = spt_store::info::set_psyche_host_error(&parent_perch, None);
426	            false
427	        }
428	        Residency::Pending => false, // inside grace — judge next tick
429	        Residency::Failed => {
430	            let reason = format!(
431	                "host not resident within {}s (psyche perch missing/dead pid)",
432	                PSYCHE_RESIDENCY_GRACE.as_secs()
433	            );
434	            // Stamp the PARENT (harness-reachable signal) — status is NOT touched
435	            // (liveness stays daemon-authoritative; the SIGNAL is psyche_host_error,
436	            // sibling of the v0.8.1 spawn-Err stamp).
437	            let _ = spt_store::info::set_psyche_host_error(&parent_perch, Some(&reason));
438	            // Clear the phantom online nested perch so it is not falsely reported
439	            // live: mark it offline (best-effort; the perch dir may already be gone).
440	            let _ = spt_store::info::set_status(&psyche_perch, spt_store::liveness::STATUS_OFFLINE);
441	            // Un-host the driver + de-table, then ARM a cooldown so the start-side
442	            // does NOT re-spawn the doomed psyche this same sweep (which would clear
443	            // the phantom then re-create it + churn). The system goes quiescent in
444	            // the failed state until the cooldown expires (then one retry).
445	            eprintln!("LIVEHOST_PSYCHE_NONRESIDENT:{id}: {reason}");
446	            set.stop_host(id);
447	            set.arm_nonresident_cooldown(id);
448	            true
449	        }
450	    }
451	}
452
453	/// B2 KEYSTONE — PULL liveness reconcile (REQ-HAZARD-HOSTED-LIVENESS-RECONCILE):
454	/// mark OFFLINE any spt-hosted (controllable) `status=online` `live_agent` perch
455	/// whose broker session is GONE — the correction for the one-way `status=online`
456	/// latch (the broker exit-waiter reaps its session but never clears `info.json`).
457	/// This is the LIVE-STATUS analog of the v0.11.0 roster ghost-heal
458	/// (`advertise_local` → Offline): same crash-robust, self-healing-next-tick shape.
459	///
460	/// `live_sessions` is the set of endpoint ids the broker currently hosts a PTY
461	/// session for ([`crate::msg::SessionsReply`] via `KIND_SESSIONS`). A perch that
462	/// is online + controllable but ABSENT from it has a dead/exited harness → mark it
463	/// offline; the existing [`reconcile_once`] stop-side then un-hosts its driver and
464	/// the start-side stops re-hosting it.
465	///
466	/// **Controllable gate (load-bearing):** only `controllable == Some(true)`
467	/// (spt-hosted, broker-PTY) perches are reconciled here. A HARNESS-HOSTED relay
468	/// live agent (`api listen`, `controllable == Some(false)`) is legitimately online
469	/// with NO broker session (its liveness is the relay + ready marker), so it is
470	/// NEVER marked offline by this pass. A `controllable == None` perch (legacy /
471	/// dead-psyche like wall-a) is also exempt here — its phantom class is the
472	/// residency-confirm arm's job ([`confirm_residency_or_unhost`]), not the
473	/// broker-session arm.
474	///
475	/// The caller passes `live_sessions` ONLY when the broker is reachable AND past
476	/// the [`LIVENESS_RECONCILE_BOOT_GRACE`], so a transient broker hiccup or the
477	/// cold-start spawn window never mass-offlines. Returns the ids it marked offline.
478	// [impl->REQ-HAZARD-HOSTED-LIVENESS-RECONCILE]
479	pub fn reconcile_hosted_liveness(owlery: &Path, live_sessions: &BTreeSet<String>) -> Vec<String> {
480	    let mut offlined = Vec::new();
481	    for id in perch::list_self_perch_ids(owlery) {
482	        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
483	        let Some(info) = spt_store::info::read_info(&perch) else {
484	            continue;
485	        };
486	        if info.state != LIVE_AGENT_STATE {
487	            continue; // not a live agent
488	        }
489	        if info.status.as_deref() != Some(STATUS_ONLINE) {
490	            continue; // already offline / interim (status-absent)
491	        }
492	        if info.controllable != Some(true) {
493	            continue; // harness-hosted relay (Some(false)) / legacy (None) — exempt
494	        }
495	        if !live_sessions.contains(&id) {
496	            // spt-hosted, online-latched, but the broker hosts no session → the
497	            // harness child is gone. Clear the latch (the keystone).
498	            BrainLifecycle::mark_offline(&perch);
499	            eprintln!("LIVENESS_RECONCILE_OFFLINE:{id}: no live broker session (dead harness)");
500	            offlined.push(id);
501	        }
502	    }
503	    offlined
504	}
505
506	/// Query the broker for the set of endpoint ids it currently hosts a session for
507	/// (`KIND_SESSIONS`) — the B2 pull signal. `None` when the broker is unreachable
508	/// (the caller then SKIPS the offline pass this tick rather than mass-offlining).
509	fn query_live_session_endpoints() -> Option<BTreeSet<String>> {
510	    let mut brain = Brain::cold_start(&crate::endpoint::broker_socket_name(), now_ms()).ok()?;
511	    let reply = brain.sessions().ok()?;
512	    Some(
513	        reply
514	            .sessions
515	            .into_iter()
516	            .map(|s| s.endpoint)
517	            .filter(|e| !e.is_empty())
518	            .collect(),
519	    )
520	}
521
522	/// Spawn the brain's live host: one thread sweeping [`reconcile_once`] at boot
523	/// and every [`LIVE_RECONCILE_INTERVAL_MS`] until `stop`. The registered set +
524	/// config are re-read each sweep (adapter / pulse-period changes land between
525	/// ticks). Mirror of [`crate::shellwake::spawn_wake_host`]; the stop flag is held
526	/// for symmetry — the threads die with the brain process and the next brain
527	/// rehydrates from disk.
528	// [impl->REQ-DAEMON-1]
529	pub fn spawn_live_host(stop: Arc<AtomicBool>, reason: StartReason) -> JoinHandle<()> {
530	    std::thread::spawn(move || {
531	        let set = Arc::new(LiveSet::new());
532	        let booted = Instant::now();
533	        while !stop.load(Ordering::SeqCst) {
534	            let owlery = perch::owlery_dir();
535	            let adapters_dir = perch::adapters_dir();
536	            let registered = spt_runtime::registry::registered(&adapters_dir);
537	            let cfg = DaemonConfig::load();
538	            // B2 keystone (REQ-HAZARD-HOSTED-LIVENESS-RECONCILE): past the boot
539	            // grace, clear the status=online latch for any spt-hosted endpoint
540	            // whose broker session is gone. Runs BEFORE reconcile_once so the
541	            // stop-side un-hosts the now-offline perch the same tick. Skipped when
542	            // the broker is unreachable (None) — never mass-offline on a hiccup.
543	            if booted.elapsed() >= LIVENESS_RECONCILE_BOOT_GRACE {
544	                if let Some(live) = query_live_session_endpoints() {
545	                    reconcile_hosted_liveness(&owlery, &live);
546	                }
547	            }
548	            reconcile_once(&owlery, &registered, &adapters_dir, &set, &cfg, reason);
549	            // Sleep in slices so a stop lands promptly.
550	            let mut left = LIVE_RECONCILE_INTERVAL_MS;
551	            while left > 0 && !stop.load(Ordering::SeqCst) {
552	                let step = left.min(100);
553	                std::thread::sleep(Duration::from_millis(step));
554	                left -= step;
555	            }
556	        }
557	    })
558	}
559
560	#[cfg(test)]
561	mod tests {
562	    use super::*;
563	    use crate::test_home::with_home;
564	    use std::time::{Duration, Instant};
565
566	    // [unit->REQ-HAZARD-LIVEHOST-NONRESIDENT] the pure residency decision: a live
567	    // psyche pid is RESIDENT regardless of elapsed; a dead/missing pid is PENDING
568	    // inside the grace (too early to judge — a slow-but-healthy psyche may still be
569	    // coming up) and FAILED only once past the grace (the F-009 fast-exit case).
570	    #[test]
571	    fn residency_classification() {
572	        let grace = Duration::from_secs(5);
573	        // alive ⇒ resident (even at t=0, even past grace).
574	        assert_eq!(
575	            classify_residency(Duration::from_millis(0), grace, true),
576	            Residency::Resident
577	        );
578	        assert_eq!(
579	            classify_residency(Duration::from_secs(60), grace, true),
580	            Residency::Resident
581	        );
582	        // dead/missing inside grace ⇒ pending (retry next tick, no premature stamp).
583	        assert_eq!(
584	            classify_residency(Duration::from_secs(1), grace, false),
585	            Residency::Pending
586	        );
587	        assert_eq!(
588	            classify_residency(Duration::from_millis(4999), grace, false),
589	            Residency::Pending
590	        );
591	        // dead/missing AT/past grace ⇒ failed (a host that exited within the window).
592	        assert_eq!(
593	            classify_residency(grace, grace, false),
594	            Residency::Failed
595	        );
596	        assert_eq!(
597	            classify_residency(Duration::from_secs(10), grace, false),
598	            Residency::Failed
599	        );
600	    }
601
602	    // A no-op Psyche summarizer command: spawns + exits 0 (we never depend on its
603	    // output — the daemon online-stamp is what hosts it, not the pid).
604	    #[cfg(windows)]
605	    const NOOP_CMD: &str = "cmd /C rem";
606	    #[cfg(unix)]
607	    const NOOP_CMD: &str = "true";
608
609	    /// Register a live-capable adapter `name` (declares `[session.psyche_init]` +
610	    /// a watched commune/signoff dir) so `resolve_option_in` finds it from disk.
611	    fn register_live_adapter(name: &str, drops: &Path) {
612	        let dir = drops.to_string_lossy().replace('\\', "/");
613	        let toml = format!(
614	            "[adapter]\nname=\"{name}\"\nkind=\"harness\"\nversion=\"1\"\n\
615	             min_spt_core_version=\"0\"\n\n\
616	             [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
617	             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
618	        );

            // RESIDENT past observation: the host truly succeeded. CLEAR any prior
            // psyche-host-failure stamp so a now-confirmed live agent shows clean.
            // (The clear was deliberately MOVED here from host_one's spawn-Ok leg:
            // post-F-010 a successful spawn() is NOT a successful host — only a
            // live, resident psyche is. Best-effort; status untouched.)
            // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
            let _ = spt_store::info::set_psyche_host_error(&parent_perch, None);
            false
        }
        Residency::Pending => false, // inside grace — judge next tick
        Residency::Failed => {
            let reason = format!(
                "host not resident within {}s (psyche perch missing/dead pid)",
                PSYCHE_RESIDENCY_GRACE.as_secs()
            );
            // Stamp the PARENT (harness-reachable signal) — status is NOT touched
            // (liveness stays daemon-authoritative; the SIGNAL is psyche_host_error,
            // sibling of the v0.8.1 spawn-Err stamp).
            let _ = spt_store::info::set_psyche_host_error(&parent_perch, Some(&reason));
            // Clear the phantom online nested perch so it is not falsely reported
            // live: mark it offline (best-effort; the perch dir may already be gone).
            let _ = spt_store::info::set_status(&psyche_perch, spt_store::liveness::STATUS_OFFLINE);
            // Un-host the driver + de-table, then ARM a cooldown so the start-side
            // does NOT re-spawn the doomed psyche this same sweep (which would clear
            // the phantom then re-create it + churn). The system goes quiescent in
            // the failed state until the cooldown expires (then one retry).
            eprintln!("LIVEHOST_PSYCHE_NONRESIDENT:{id}: {reason}");
            set.stop_host(id);
            set.arm_nonresident_cooldown(id);
            true
        }
    }
}

/// B2 KEYSTONE — PULL liveness reconcile (REQ-HAZARD-HOSTED-LIVENESS-RECONCILE):
/// mark OFFLINE any spt-hosted (controllable) `status=online` `live_agent` perch
/// whose broker session is GONE — the correction for the one-way `status=online`
/// latch (the broker exit-waiter reaps its session but never clears `info.json`).
/// This is the LIVE-STATUS analog of the v0.11.0 roster ghost-heal
/// (`advertise_local` → Offline): same crash-robust, self-healing-next-tick shape.
///
/// `live_sessions` is the set of endpoint ids the broker currently hosts a PTY
/// session for ([`crate::msg::SessionsReply`] via `KIND_SESSIONS`). A perch that
/// is online + controllable but ABSENT from it has a dead/exited harness → mark it
/// offline; the existing [`reconcile_once`] stop-side then un-hosts its driver and
/// the start-side stops re-hosting it.
///
/// **Controllable gate (load-bearing):** only `controllable == Some(true)`
/// (spt-hosted, broker-PTY) perches are reconciled here. A HARNESS-HOSTED relay
/// live agent (`api listen`, `controllable == Some(false)`) is legitimately online
/// with NO broker session (its liveness is the relay + ready marker), so it is
/// NEVER marked offline by this pass. A `controllable == None` perch (legacy /
/// dead-psyche like wall-a) is also exempt here — its phantom class is the
/// residency-confirm arm's job ([`confirm_residency_or_unhost`]), not the
/// broker-session arm.
///
/// The caller passes `live_sessions` ONLY when the broker is reachable AND past
/// the [`LIVENESS_RECONCILE_BOOT_GRACE`], so a transient broker hiccup or the
/// cold-start spawn window never mass-offlines. Returns the ids it marked offline.
// [impl->REQ-HAZARD-HOSTED-LIVENESS-RECONCILE]
pub fn reconcile_hosted_liveness(owlery: &Path, live_sessions: &BTreeSet<String>) -> Vec<String> {
    let mut offlined = Vec::new();
    for id in perch::list_self_perch_ids(owlery) {
        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
        let Some(info) = spt_store::info::read_info(&perch) else {
            continue;
        };
        if info.state != LIVE_AGENT_STATE {
            continue; // not a live agent
        }
        if info.status.as_deref() != Some(STATUS_ONLINE) {
            continue; // already offline / interim (status-absent)
        }
        if info.controllable != Some(true) {
            continue; // harness-hosted relay (Some(false)) / legacy (None) — exempt
        }
        if !live_sessions.contains(&id) {
            // spt-hosted, online-latched, but the broker hosts no session → the
            // harness child is gone. Clear the latch (the keystone).
            BrainLifecycle::mark_offline(&perch);
            eprintln!("LIVENESS_RECONCILE_OFFLINE:{id}: no live broker session (dead harness)");
            offlined.push(id);
        }
    }
    offlined
}

/// Query the broker for the set of endpoint ids it currently hosts a session for
/// (`KIND_SESSIONS`) — the B2 pull signal. `None` when the broker is unreachable
/// (the caller then SKIPS the offline pass this tick rather than mass-offlining).
fn query_live_session_endpoints() -> Option<BTreeSet<String>> {
    let mut brain = Brain::cold_start(&crate::endpoint::broker_socket_name(), now_ms()).ok()?;
    let reply = brain.sessions().ok()?;
    Some(
        reply
            .sessions
            .into_iter()
            .map(|s| s.endpoint)
            .filter(|e| !e.is_empty())
            .collect(),
    )
}

/// Spawn the brain's live host: one thread sweeping [`reconcile_once`] at boot
/// and every [`LIVE_RECONCILE_INTERVAL_MS`] until `stop`. The registered set +
/// config are re-read each sweep (adapter / pulse-period changes land between
/// ticks). Mirror of [`crate::shellwake::spawn_wake_host`]; the stop flag is held
/// for symmetry — the threads die with the brain process and the next brain
/// rehydrates from disk.
// [impl->REQ-DAEMON-1]
pub fn spawn_live_host(stop: Arc<AtomicBool>, reason: StartReason) -> JoinHandle<()> {
    std::thread::spawn(move || {
        let set = Arc::new(LiveSet::new());
        let booted = Instant::now();
        while !stop.load(Ordering::SeqCst) {
            let owlery = perch::owlery_dir();
            let adapters_dir = perch::adapters_dir();
            let registered = spt_runtime::registry::registered(&adapters_dir);
            let cfg = DaemonConfig::load();
            // B2 keystone (REQ-HAZARD-HOSTED-LIVENESS-RECONCILE): past the boot
            // grace, clear the status=online latch for any spt-hosted endpoint
            // whose broker session is gone. Runs BEFORE reconcile_once so the
            // stop-side un-hosts the now-offline perch the same tick. Skipped when
            // the broker is unreachable (None) — never mass-offline on a hiccup.
            if booted.elapsed() >= LIVENESS_RECONCILE_BOOT_GRACE {
                if let Some(live) = query_live_session_endpoints() {
                    reconcile_hosted_liveness(&owlery, &live);
                }
            }
            reconcile_once(&owlery, &registered, &adapters_dir, &set, &cfg, reason);
            // Sleep in slices so a stop lands promptly.
            let mut left = LIVE_RECONCILE_INTERVAL_MS;
            while left > 0 && !stop.load(Ordering::SeqCst) {
                let step = left.min(100);
                std::thread::sleep(Duration::from_millis(step));
                left -= step;
            }
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use std::time::{Duration, Instant};

    // [unit->REQ-HAZARD-LIVEHOST-NONRESIDENT] the pure residency decision: a live
    // psyche pid is RESIDENT regardless of elapsed; a dead/missing pid is PENDING
    // inside the grace (too early to judge — a slow-but-healthy psyche may still be
    // coming up) and FAILED only once past the grace (the F-009 fast-exit case).
    #[test]
    fn residency_classification() {
        let grace = Duration::from_secs(5);
        // alive ⇒ resident (even at t=0, even past grace).
        assert_eq!(
            classify_residency(Duration::from_millis(0), grace, true),
            Residency::Resident
        );
        assert_eq!(
            classify_residency(Duration::from_secs(60), grace, true),
            Residency::Resident
        );
        // dead/missing inside grace ⇒ pending (retry next tick, no premature stamp).
        assert_eq!(
            classify_residency(Duration::from_secs(1), grace, false),
            Residency::Pending
        );
        assert_eq!(
            classify_residency(Duration::from_millis(4999), grace, false),
            Residency::Pending
        );
        // dead/missing AT/past grace ⇒ failed (a host that exited within the window).
        assert_eq!(
            classify_residency(grace, grace, false),
            Residency::Failed
        );
        assert_eq!(
            classify_residency(Duration::from_secs(10), grace, false),
            Residency::Failed
        );
    }

    // A no-op Psyche summarizer command: spawns + exits 0 (we never depend on its
    // output — the daemon online-stamp is what hosts it, not the pid).
    #[cfg(windows)]
    const NOOP_CMD: &str = "cmd /C rem";
    #[cfg(unix)]
    const NOOP_CMD: &str = "true";

    /// Register a live-capable adapter `name` (declares `[session.psyche_init]` +
    /// a watched commune/signoff dir) so `resolve_option_in` finds it from disk.
    fn register_live_adapter(name: &str, drops: &Path) {
        let dir = drops.to_string_lossy().replace('\\', "/");
        let toml = format!(
            "[adapter]\nname=\"{name}\"\nkind=\"harness\"\nversion=\"1\"\n\
             min_spt_core_version=\"0\"\n\n\
             [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
        );


This is the boot-tick arm of B2's reconcile. The B5 fix gates the controllable (spt-hosted) psyche-spawn on a live broker session even at boot — but that touches the exact hosting path the 5 livehost E2Es exercise, and B2's deliberate boot-grace. Before building speculatively (regression risk + it's doyle's B2 design), gather evidence: do the livehost E2Es host `controllable=Some(true)` perches, and do they have broker sessions?


Bash

cd "C:/Users/decid/Documents/projects/spt-core"; echo "=== controllable usage in livehost E2Es ==="; grep -rn "controllable\|set_status\|STATUS_ONLINE\|status.*online\|KIND_SESSIONS\|sessions\|broker" crates/spt/tests/livehost_bootrace_e2e.rs crates/spt/tests/live_firsthost_e2e.rs crates/spt/tests/livehost_nonresident_e2e.rs 2>/dev/null | head -40

Inspect livehost E2E perch setup


<owl_messages>
<owl_message from="doyle" priority="high">GATE wave 3 = FULL PASS (sign-off). @026e9b4 + @0c1e59e.

Verified the grandchild tests by reading them: Win spawns the grandchild AFTER enroll (1s window) via UseShellExecute=$false → proves LATER-spawned descendants inherit the job (the real Psyche timing); Unix backgrounds into the brain's group; reap takes both (owned child via try_wait, unowned grandchild via is_process_alive). The ShellExecute breakaway you caught+fixed is a genuine escape — good catch, that's exactly why we exercise it. 0c1e59e is test-only, so the 026e9b4 full sweep still holds + reap units 2/2 green = complete. Both Windows-process gotchas (open-handle-reads-alive, ShellExecute-breakaway) are logged for reuse.

START WAVE 4 = B5 REQ-HAZARD-LIVEHOST-BOOT-LIVENESS-GATE:
- daemon start currently spawns a Psyche per status=online live_agent perch at boot (reconcile_once, livehost.rs:285) WITHOUT verifying the harness child / {id}-psyche is actually alive → a cold start after an unclean stop revives N phantom psyches for N dead-but-latched perches.
- FIX: gate the boot psyche-spawn on real child-liveness — a perch with NO live broker session (the B2 reconcile signal) is marked OFFLINE at boot instead of hosted/revived. This is the BOOT-GATE arm of the B2 reconcile loop — reuse reconcile_hosted_liveness, don't fork a second liveness notion. Keep the same controllable==Some(true) gate (don't offline relay/legacy-None perches).
- Also closes wall-a's psyche_host_error gap: residency-confirm doesn't run at boot tick-1 (livehost.rs ~395-441 / 257-263).
- Verify the 3-way matrix from the milestone: (i) child-dead → B2 pull-reconcile, (ii) boot-revive → B5 boot-gate, (iii) dead-psyche/None → residency-confirm. Activate REQ [impl,unit,int] when you start.

SEAM BAR unchanged (full sweep, serial in target-seam, no concurrent cargo, no machine-wide kill, scratch target dir). Ping me at wave-4 ready. After B5 → wave 5 (B4 brain-restart-lifecycle-rehydrate, the deepest).</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


=== controllable usage in livehost E2Es ===
crates/spt/tests/livehost_bootrace_e2e.rs:3://! `status=online` and the REAL daemon brain hosts its Psyche on a net-less /
crates/spt/tests/livehost_bootrace_e2e.rs:13://!   - This test launches the REAL `spt daemon run` broker PROCESS (which spawns a
crates/spt/tests/livehost_bootrace_e2e.rs:42://! never a hang. All spawned processes (broker + supervised brain + listen + the
crates/spt/tests/livehost_bootrace_e2e.rs:57:/// Scoped to a SINGLE pid we spawned — never a machine-wide reap (a live broker
crates/spt/tests/livehost_bootrace_e2e.rs:82:/// D6-1b). The come-up proof: the broker spawned a brain child.
crates/spt/tests/livehost_bootrace_e2e.rs:117:    // "NODE_KEY_FAIL: identity unavailable — broker runs net-less, no retry"). The
crates/spt/tests/livehost_bootrace_e2e.rs:191:    // ── (3) Launch the REAL daemon broker PROCESS. `spt daemon run` is the broker;
crates/spt/tests/livehost_bootrace_e2e.rs:192:    //    it spawns a supervised `spt daemon brain` CHILD. Redirect the broker's
crates/spt/tests/livehost_bootrace_e2e.rs:193:    //    stderr to a log file in the tempdir — the brain child inherits the broker's
crates/spt/tests/livehost_bootrace_e2e.rs:198:    let mut broker: Child = Command::new(&spt_bin)
crates/spt/tests/livehost_bootrace_e2e.rs:205:        .expect("spawn spt daemon run (broker process)");
crates/spt/tests/livehost_bootrace_e2e.rs:207:    // Come-up: the broker spawned a brain child that signalled ready. This is the
crates/spt/tests/livehost_bootrace_e2e.rs:213:            let _ = broker.kill();
crates/spt/tests/livehost_bootrace_e2e.rs:214:            let _ = broker.wait();
crates/spt/tests/livehost_bootrace_e2e.rs:216:                "PRECONDITION FAILED: the real broker never spawned a ready brain child \
crates/spt/tests/livehost_bootrace_e2e.rs:250:        let _ = broker.kill();
crates/spt/tests/livehost_bootrace_e2e.rs:251:        let _ = broker.wait();
crates/spt/tests/livehost_bootrace_e2e.rs:258:    // The persistent live `api listen` — marks the perch status=online (live-capable
crates/spt/tests/livehost_bootrace_e2e.rs:283:    // status=online (written by cmd_listen). Poll briefly — `listen` writes it
crates/spt/tests/livehost_bootrace_e2e.rs:290:            if st.as_deref() == Some(spt_store::liveness::STATUS_ONLINE) {
crates/spt/tests/livehost_bootrace_e2e.rs:302:        let _ = broker.kill();
crates/spt/tests/livehost_bootrace_e2e.rs:303:        let _ = broker.wait();
crates/spt/tests/livehost_bootrace_e2e.rs:371:    //    machine-wide reap (a live broker shares this box). The resident psyche pid
crates/spt/tests/livehost_bootrace_e2e.rs:379:    let _ = broker.kill();
crates/spt/tests/livehost_bootrace_e2e.rs:380:    let _ = broker.wait();
crates/spt/tests/live_firsthost_e2e.rs:6://! `status=online` (the W0.2 first-host handoff — the Psyche no longer spawns in
crates/spt/tests/live_firsthost_e2e.rs:25:use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
crates/spt/tests/live_firsthost_e2e.rs:114:    // The real establish-marks-online: the perch carries status=online, written by
crates/spt/tests/live_firsthost_e2e.rs:120:        Some(STATUS_ONLINE),
crates/spt/tests/live_firsthost_e2e.rs:152:    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();
crates/spt/tests/livehost_nonresident_e2e.rs:2://! live agent goes `status=online` and its Psyche binary EXISTS and SPAWNS Ok, but
crates/spt/tests/livehost_nonresident_e2e.rs:5://! gap is: the nested `{id}-psyche` info.json is written `status=online` with a
crates/spt/tests/livehost_nonresident_e2e.rs:15://! production path — `spt daemon run` broker → supervised `spt daemon brain` CHILD →
crates/spt/tests/livehost_nonresident_e2e.rs:24://! spawned processes (broker + supervised brain + listen) are reaped — scoped to OUR
crates/spt/tests/livehost_nonresident_e2e.rs:25://! pids only, never a machine-wide reap (a live broker shares this box).
crates/spt/tests/livehost_nonresident_e2e.rs:44:/// machine-wide reap (a live broker shares this box).
crates/spt/tests/livehost_nonresident_e2e.rs:150:    // ── (3) Launch the REAL daemon broker PROCESS (spawns a supervised brain CHILD). ──
crates/spt/tests/livehost_nonresident_e2e.rs:153:    let mut broker: Child = Command::new(&spt_bin)
crates/spt/tests/livehost_nonresident_e2e.rs:160:        .expect("spawn spt daemon run (broker process)");
crates/spt/tests/livehost_nonresident_e2e.rs:166:            let _ = broker.kill();


1	---
2	name: v012-lifecycle-milestone
3	description: "v0.12.0 spt-hosted lifecycle & liveness reconciliation milestone (operator-mandated blocking, doyle dispatch) — 7 REQs, wave-by-wave, B2 keystone built"
4	metadata:
5	  node_type: memory
6	  type: project
7	  originSessionId: bf7260cd-fc4e-463b-9c46-b2ed704b4bab
8	---
9
10	**v0.12.0 spt-hosted LIFECYCLE & LIVENESS RECONCILIATION** — operator-mandated BLOCKING (endpoint run unusable: attach hangs, daemon stop doesn't stop). doyle dispatched 2026-06-17 (2-agent diagnosis). Branch **v0.12.0-lifecycle** off main@27e0619 (v0.11.0). todlando builds keystone-first in waves, doyle gates PER-WAVE, then deployah → v0.12.0 MINOR.
11
12	**UNIFYING ROOT:** info.json `status=online` is a ONE-WAY LATCH — set at establish (startup.rs:361/468), NEVER cleared vs real liveness (liveness.rs:80-93 is_perch_alive returns ONLINE for daemon-hosted). Every symptom falls out of this + stop/restart lifecycle not tearing-down/rehydrating.
13
14	**PROGRESS: B2 GATE-PASS @03b6398. WAVE 2 (B1+H3) GATE-PASS+FIXED @78a13c2. WAVE 3 (B3+Breap) GATE-PASS @026e9b4+@0c1e59e (grandchild reap proven, ShellExecute-breakaway escape caught+fixed). WAVE 4 (B5 boot-liveness-gate) RELEASED to todlando.** Remaining: B5 (wave 4, in progress) → B4 (wave 5, deepest). Stack 6 ahead of main, NOT pushed. Then deployah → v0.12.0 MINOR.
15
16	**WAVE 3 design (B3+Breap), both [impl,unit]:** B3 = client-side barrier in seedmap::request_stop (after STOPPING ack, drop conn, poll ping() to failure / 10ms slices, 5s wedge-ceiling — doyle's option 2, NOT socket-unbind-before-ack). Breap = `reap::BrainReaper` rooted at the BRAIN SUBTREE (never the daemon → reap never self-terminates, never touches a sibling live agent on the shared box): Win = kill-on-job-close Job, brain assigned per (re)spawn in spawn_brain_supervisor, Psyches inherit; Unix = brain pre_exec setpgid(0,0) leads own group, supervisor records pgid, kill(-pgid,SIGKILL). Daemon::run graceful path raises supervisor stop flag FIRST (hoisted the "held for symmetry never raised" Arc) THEN reaps; straggler-brain backstop = self-exit on broker-loss.
17
18	**WAVE 3 LESSONS (2 Windows-process gotchas, both reusable):** (1) **terminated process w/ open handle reads ALIVE** — is_process_alive(pid) (OpenProcess) returns true for a TerminateJobObject'd process while a Child handle is still held (kernel object lingers; same class as the Unix-zombie OpenProcess gap). For an OWNED child use try_wait, not is_process_alive(pid). (2) **UseShellExecute=true → job breakaway** — Windows PowerShell's [Diagnostics.Process]::Start defaults UseShellExecute=true → ShellExecute spawns the grandchild OUTSIDE the job → it survives the reap. Force UseShellExecute=$false (CreateProcess) so it inherits the job — mirrors the brain→Psyche spawn (null stdio + CreateProcess, no breakaway). doyle's "exercise the grandchild link, don't reason it" caught this — the child-only unit was green while the real orphan path escaped.
19
20	**CRITICAL LESSON — B1 path-(c) deadlock (cost ~3h, gate code-read MISSED it):** f85bf69's "primary dead-detector" `if let Ok(Some)=session.try_wait()` in Broker::dispatch_subscribe DEADLOCKS every LIVE attach (incl production rc). `PtySession::try_wait` locks the child mutex; the per-session exit-waiter thread holds that SAME mutex in a blocking `wait()` for the WHOLE life of a live child (broker.rs exit-waiter, pty.rs:203). Live child → try_wait blocks forever → no Subscribed reply → hang. Broker units passed (their children exit fast → lock freed); only the FULL attach suite (matrix/loopback/remote, 85-200s hangs) caught it. **Gating data-flow logic ("try_wait Some→Exit; None→live-safe") is BLIND to lock-contention/ordering deadlocks — you MUST run the real runtime sweep, not just touched units + a code-read.** Sibling of [[shared-seam-change-run-all-seam-tests]]. Path-(c) was also redundant: exit-waiter already broadcasts Exit to all sinks on reap; post-removal subscribe → "no such session". RED HERRINGS chased first: "concurrent cargo wedge" (real but not this), "input-race needs ready-banner" (phantom — silent-child tests pass in CI), "environmental QUIC-under-load" (wrong — cwd passed instantly). Operator's "fix it, don't tape it" forced the real root.
21
22	**7 REQs minted registry-first @e2d6391 (traceable EXIT=0). WAVE ORDER: B2 → B1+H3 → B3+Breap → B5 → B4.**
23	- **B2 REQ-HAZARD-HOSTED-LIVENESS-RECONCILE** (keystone) — **BUILT @03b6398, WAVE-READY, awaiting doyle gate.** doyle ruled PULL-PRIMARY (live-status analog of v0.11.0 ROSTER-GHOST). new `reconcile_hosted_liveness(owlery, live_sessions)` in livehost.rs: queries broker KIND_SESSIONS each tick (`query_live_session_endpoints`), marks_offline any status=online live_agent perch with NO live broker session. Wired into spawn_live_host BEFORE reconcile_once, gated past-10s-boot-grace + broker-reachable (None→skip). **CONTROLLABLE GATE (load-bearing): only controllable==Some(true) spt-hosted; relay Some(false) + legacy None EXEMPT** (else kills perri /sptc:live agents). PUSH (ExitEvent→mark_offline) NOT added — confirmed daemon brain has NO event-consumer loop (main=net_status heartbeat), push has nowhere to land for unattended; correctness rides pull. unit pull_liveness_marks_sessionless_spt_hosted_offline_only + int pull_reconcile_offlines_perch_when_broker_session_dies (real broker session killed+reaped→latch cleared). [impl,unit,int].
24	- **B1 REQ-HAZARD-RC-ATTACH-FAILFAST** — rc to dead/silent session → infinite blank. SUB-MECHANISM PINNED: wall-f hang = broker STILL resolves a session (resolve_session rc.rs:209 returns Some — child alive-silent OR dead-not-reaped on Win tab-close) → pump blocks on first read that never comes. FIX (doyle): (a) gate attach on is_online/status [after B2], (b) fail-fast bound first-output/ack→clear msg, (c) broker EOF stream on dead-child→PumpEnd::BrokerGone (REQ-HAZARD-RC-EOF). PIN with repro test FIRST (deterministic = attach to alive-but-silent session). wave 2.
25	- **H3 REQ-ENDPOINT-STOP-OFFLINE** — cmd_stop (cli.rs:2994-3010) removes ready+unregister but NO set_status offline → stopped still alive=true. FIX: add set_status(perch, STATUS_OFFLINE), folds B2 setter. CONFIRMED at code. wave 2.
26	- **B3 REQ-HAZARD-DAEMON-STOP-BARRIER** — request_stop (seedmap.rs:240-255) returns on KIND_STOPPING ack (174-176) BEFORE seed socket unbinds → is_running ping (daemon.rs:375) wins → start sees ALREADY_RUNNING. FIX: unbind before ack OR wait-ping-fail. wave 3.
27	- **Breap REQ-HAZARD-DAEMON-STOP-REAP** — stop leaves ~8 orphaned psyche/spt.exe (psyches DETACHED runtime.rs:342-356; livehost stop flag brainproc.rs:227-230 NEVER raised). FIX: raise stop flag + kill children via Win job object / Unix process-group. folds B3. wave 3.
28	- **B5 REQ-HAZARD-LIVEHOST-BOOT-LIVENESS-GATE** — daemon start spawns psyche per online-latched perch (reconcile_once livehost.rs:285) w/o child-liveness check → revives phantoms. FIX: boot-gate on real session (shares B2 reconcile — no-session perch marked offline at boot not revived). Verify None-spt-hosted phantom caught by residency-confirm (matrix: i child-dead→B2, ii boot-revive→B5, iii dead-psyche/None→residency). wave 4.
29	- **B4 REQ-HAZARD-BRAIN-RESTART-LIFECYCLE-REHYDRATE** (deepest) — bare brain restart loses ALL BrainLifecycle (lifecycle.rs:58-130); resume_sessions re-subscribes PTY but no livehost → post-restart endpoints unhosted until full daemon kill. FIX: on brain startup rebuild BrainLifecycle per resumed live-capable session (load manifest→instantiate→start pulse). wave 5.
30
31	**SEAM BAR (HARD, EVERY wave):** clippy --workspace -D warnings + traceable + FULL sweep daemon_lifecycle_real_brain + livehost(bootrace/nonresident/firsthost/bind-firsthost/psyche_fail) + broker + handoff + attach + brain_swap + resume — NOT just new tests. livehost E2Es need a SCRATCH target dir (live daemon holds target/debug/spt.exe — shared runner, no kill). doyle: "verify each agent-traced root at the code before building — flag wrong" (I confirmed B2 latch + push-has-no-consumer + H3; flagged the B2 push-vs-pull gap → doyle ruled pull-primary).


doyle: **WAVE 3 = FULL PASS** (signed off, verified the grandchild tests, logged both gotchas). Wave 4 = B5 released. Key directive: **reuse `reconcile_hosted_liveness` as the boot-gate arm — don't fork a second liveness notion**; keep the `controllable==Some(true)` gate; activate `[impl,unit,int]`.

Ack doyle first (priority protocol), then continue the regression-risk check:


cd "C:/Users/decid/Documents/projects/spt-core" && $OWL send doyle <<'EOF'
ACK wave-3 FULL PASS + wave-4 B5 release. Thanks for the grandchild-test read.

B5 plan (faithful to your directive — reuse reconcile_hosted_liveness, no second liveness notion): run the B2 reconcile_hosted_liveness from BOOT tick 1 (lift the LIVENESS_RECONCILE_BOOT_GRACE skip) so a sessionless controllable==Some(true) perch is marked OFFLINE before reconcile_once's start-side can revive its Psyche. Same controllable gate (relay Some(false)/legacy None exempt). None (broker-unreachable) still skips — run_brain connects to the broker BEFORE spawning livehost, so query returns Some at boot; None only on a real hiccup → no mass-offline.

VERIFYING ONE REGRESSION before I build: lifting the grace makes reconcile_hosted_liveness run in the first 10s. Checking the livehost E2Es' controllable values — bootrace uses `api listen` (relay = Some(false), exempt → safe). firsthost/bind-firsthost ran >10s in the seam sweep and passed WITH B2 already running, so their perches are exempt-or-session-backed (else B2 would already offline them). psyche_fail/nonresident finish <10s so B5 newly touches them — confirming their controllable value now so the boot-gate doesn't offline a perch they expect hosted. Will report + build.

Activating REQ-HAZARD-LIVEHOST-BOOT-LIVENESS-GATE [impl,unit,int]. Ping you at wave-4 ready.
EOF
echo "sent rc=$?"

Ack doyle + state B5 plan


➜ SENT:doyle
sent rc=0


cd "C:/Users/decid/Documents/projects/spt-core"; echo "=== controllable set in tests ==="; grep -rn "controllable" crates/spt/tests/*.rs | head -40; echo; echo "=== how controllable gets set in src (establish/listen/bind) ==="; grep -rn "controllable" crates/spt/src crates/spt-store/src crates/spt-daemon/src | grep -iv test | head -30

Find controllable values in tests + how it is set


=== controllable set in tests ===

=== how controllable gets set in src (establish/listen/bind) ===
crates/spt/src/api/startup.rs:143:    // so there is no broker PTY → not controllable (REQ-PICKER-1).
crates/spt/src/api/startup.rs:182:    controllable: Option<bool>,
crates/spt/src/api/startup.rs:240:    rec.controllable = controllable.or_else(|| prior.as_ref().and_then(|p| p.controllable));
crates/spt/src/api/startup.rs:433:    // broker-PTY-controllable (REQ-PICKER-1). A non-live bind (a gateway/shell — not
crates/spt/src/api/startup.rs:436:    let controllable = if endpoint_type == "live_agent" {
crates/spt/src/api/startup.rs:448:        controllable,
crates/spt/src/api/startup.rs:727:    fn establish_stamps_controllable_by_hosting_mode() {
crates/spt/src/api/startup.rs:738:                .controllable,
crates/spt/src/api/startup.rs:740:            "listen = harness-hosted = not controllable"
crates/spt/src/api/startup.rs:748:                .controllable,
crates/spt/src/api/startup.rs:750:            "bind live_agent = spt-hosted = controllable"
crates/spt/src/api/startup.rs:758:                .controllable,
crates/spt/src/api/startup.rs:760:            "non-live bind leaves controllable unset"
crates/spt/src/cli.rs:2009:    // any — so `start`/`stop` behavior is legible. A registered controllable
crates/spt/src/cli.rs:2676:/// `controllable==Some(true)`, and it has no resolvable TCP relay address. These
crates/spt/src/cli.rs:2680:/// `Some`) takes the normal TCP path; an offline or non-controllable endpoint is
crates/spt/src/cli.rs:2690:        .and_then(|r| r.controllable)
crates/spt/src/cli.rs:7070:    // controllable endpoint with NO relay address is an inject target; a relay
crates/spt/src/cli.rs:7071:    // address (harness-hosted), an offline perch, or a non-controllable one is not.
crates/spt/src/cli.rs:7081:        rec.controllable = Some(true);
crates/spt/src/cli.rs:7087:        // spt-hosted: online + controllable + no relay → inject path.
crates/spt/src/cli.rs:7096:        // Not controllable (harness-hosted live agent) → not an inject target.
crates/spt/src/cli.rs:7097:        rec.controllable = None;
crates/spt/src/cli.rs:7100:        assert!(!is_spt_hosted_no_relay(id, &owlery), "non-controllable is not an inject target");
crates/spt/src/cli.rs:7103:        rec.controllable = Some(true);
crates/spt/src/picker/data.rs:85:/// status/controllable/endpoint_type with the local fact. Both listings are kept
crates/spt/src/picker/data.rs:93:        .map(|r| (r.id.clone(), (r.status, r.controllable, r.endpoint_type.clone())))
crates/spt/src/picker/data.rs:99:        if let Some((status, controllable, endpoint_type)) = local.get(&row.id) {
crates/spt/src/picker/data.rs:101:            row.controllable = *controllable;
crates/spt/src/picker/data.rs:150:                controllable: rec.as_ref().and_then(|r| r.controllable),


`controllable` is set by hosting mode (startup.rs:436): `bind` live_agent = spt-hosted = `Some(true)`; `listen` = harness-hosted = not controllable. Read the exact semantics + the at-risk E2E setups (firsthost/bind-firsthost/psyche_fail/nonresident — do they stamp `Some(true)` and have a broker session?):


430	    // The session's own anchor: the process that fired this hook (the session).
431	    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
432	    // spt-hosted: the daemon spawned a live_agent into a broker-owned PTY → it IS
433	    // broker-PTY-controllable (REQ-PICKER-1). A non-live bind (a gateway/shell — not
434	    // a PTY-attachable agent) stamps None; the picker's type-gated derivation never
435	    // amber-paints it regardless.
436	    let controllable = if endpoint_type == "live_agent" {
437	        Some(true)
438	    } else {
439	        None
440	    };
441	    match establish_perch(
442	        id,
443	        &session_id,
444	        parent_pid,
445	        adapter,
446	        subnet,
447	        endpoint_type,
448	        controllable,
449	    ) {

    // The session's own anchor: the process that fired this hook (the session).
    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
    // spt-hosted: the daemon spawned a live_agent into a broker-owned PTY → it IS
    // broker-PTY-controllable (REQ-PICKER-1). A non-live bind (a gateway/shell — not
    // a PTY-attachable agent) stamps None; the picker's type-gated derivation never
    // amber-paints it regardless.
    let controllable = if endpoint_type == "live_agent" {
        Some(true)
    } else {
        None
    };
    match establish_perch(
        id,
        &session_id,
        parent_pid,
        adapter,
        subnet,
        endpoint_type,
        controllable,
    ) {


727	    fn establish_stamps_controllable_by_hosting_mode() {
728	        let _h = isolated_home();
729	        let reg = start_seed_daemon();
730	        let anchor = std::process::id();
731
732	        // listen (harness-hosted) → Some(false).
733	        put_seed(&reg, anchor, "sid-l");
734	        bind_from_seed("h", anchor, None, None).unwrap();
735	        assert_eq!(
736	            info::read_info(&perch::resolve_perch_path("h", ParentHint::Infer))
737	                .unwrap()
738	                .controllable,
739	            Some(false),
740	            "listen = harness-hosted = not controllable"
741	        );
742
743	        // bind live_agent (spt-hosted) → Some(true).
744	        cmd_bind("s", Some("sid-s".into()), None, None, None, "live_agent", None);
745	        assert_eq!(
746	            info::read_info(&perch::resolve_perch_path("s", ParentHint::Infer))
747	                .unwrap()
748	                .controllable,
749	            Some(true),
750	            "bind live_agent = spt-hosted = controllable"
751	        );
752
753	        // bind gateway (non-live) → None.
754	        cmd_bind("gw", Some("sid-gw".into()), None, None, None, "gateway", None);
755	        assert_eq!(
756	            info::read_info(&perch::resolve_perch_path("gw", ParentHint::Infer))
757	                .unwrap()
758	                .controllable,
759	            None,
760	            "non-live bind leaves controllable unset"
761	        );
762	    }

    fn establish_stamps_controllable_by_hosting_mode() {
        let _h = isolated_home();
        let reg = start_seed_daemon();
        let anchor = std::process::id();

        // listen (harness-hosted) → Some(false).
        put_seed(&reg, anchor, "sid-l");
        bind_from_seed("h", anchor, None, None).unwrap();
        assert_eq!(
            info::read_info(&perch::resolve_perch_path("h", ParentHint::Infer))
                .unwrap()
                .controllable,
            Some(false),
            "listen = harness-hosted = not controllable"
        );

        // bind live_agent (spt-hosted) → Some(true).
        cmd_bind("s", Some("sid-s".into()), None, None, None, "live_agent", None);
        assert_eq!(
            info::read_info(&perch::resolve_perch_path("s", ParentHint::Infer))
                .unwrap()
                .controllable,
            Some(true),
            "bind live_agent = spt-hosted = controllable"
        );

        // bind gateway (non-live) → None.
        cmd_bind("gw", Some("sid-gw".into()), None, None, None, "gateway", None);
        assert_eq!(
            info::read_info(&perch::resolve_perch_path("gw", ParentHint::Infer))
                .unwrap()
                .controllable,
            None,
            "non-live bind leaves controllable unset"
        );
    }