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) GATE-PASS @026e9b4+@0c1e59e. WAVE 4 (B5 boot-liveness-gate) BUILT+seam-green @9e338f1, awaiting doyle gate.** Remaining: B4 (wave 5, deepest, after B5 gate). Stack 7 ahead of main, NOT pushed. Then deployah → v0.12.0 MINOR.

**WAVE 4 (B5) design [impl,unit,int]:** lifted LIVENESS_RECONCILE_BOOT_GRACE skip in spawn_live_host → B2's reconcile_hosted_liveness runs from BOOT tick 1 (BEFORE reconcile_once) → sessionless controllable==Some(true) perch OFFLINED at boot → reconcile_once skips host → no phantom revival. doyle: REUSE the B2 fn, no 2nd liveness notion. WHY grace-drop is safe: an spt-hosted agent runs IN a broker PTY so its session PRECEDES the online mark → online+controllable+sessionless = ONLY a cold-start phantom (offline it) OR brain-restart survivor (in the session set, kept). None-skip + controllable gate preserved. 3-WAY MATRIX: (i)child-dead-midlife→B2, (ii)boot-revive→B5, (iii)dead-{id}-psyche→residency-confirm. NO E2E regression: existing livehost E2Es use `api listen` (Some(false), exempt) OR call reconcile_once in-proc w/o broker (None-skip); none host a sessionless-controllable perch. unit boot_gate_offlines_sessionless_controllable_then_reconcile_skips_host; int livehost_bootgate_e2e (real cold-start, stale online+controllable perch no session → offlined at boot + no {id}-psyche). **OPEN (doyle→operator scope call): stop_host stops the DRIVER thread but NOT the detached psyche PROCESS — a mid-life endpoint-death/stop leaves the psyche running until daemon-stop/Breap. Real+production-reachable, separate from B5/B4. Candidate new REQ (stop_host reads {id}-psyche pid + kills it). Not in B5/B4 unless operator greenlights.**

---
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. 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.

**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.

**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. 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.

+**PROGRESS: B2 GATE-PASS @03b6398. WAVE 2 (B1+H3) GATE-PASS+FIXED @78a13c2. WAVE 3 (B3+Breap) GATE-PASS @026e9b4+@0c1e59e. WAVE 4 (B5 boot-liveness-gate) BUILT+seam-green @9e338f1, awaiting doyle gate.** Remaining: B4 (wave 5, deepest, after B5 gate). Stack 7 ahead of main, NOT pushed. Then deployah → v0.12.0 MINOR.

+**WAVE 4 (B5) design [impl,unit,int]:** lifted LIVENESS_RECONCILE_BOOT_GRACE skip in spawn_live_host → B2's reconcile_hosted_liveness runs from BOOT tick 1 (BEFORE reconcile_once) → sessionless controllable==Some(true) perch OFFLINED at boot → reconcile_once skips host → no phantom revival. doyle: REUSE the B2 fn, no 2nd liveness notion. WHY grace-drop is safe: an spt-hosted agent runs IN a broker PTY so its session PRECEDES the online mark → online+controllable+sessionless = ONLY a cold-start phantom (offline it) OR brain-restart survivor (in the session set, kept). None-skip + controllable gate preserved. 3-WAY MATRIX: (i)child-dead-midlife→B2, (ii)boot-revive→B5, (iii)dead-{id}-psyche→residency-confirm. NO E2E regression: existing livehost E2Es use `api listen` (Some(false), exempt) OR call reconcile_once in-proc w/o broker (None-skip); none host a sessionless-controllable perch. unit boot_gate_offlines_sessionless_controllable_then_reconcile_skips_host; int livehost_bootgate_e2e (real cold-start, stale online+controllable perch no session → offlined at boot + no {id}-psyche). **OPEN (doyle→operator scope call): stop_host stops the DRIVER thread but NOT the detached psyche PROCESS — a mid-life endpoint-death/stop leaves the psyche running until daemon-stop/Breap. Real+production-reachable, separate from B5/B4. Candidate new REQ (stop_host reads {id}-psyche pid + kills it). Not in B5/B4 unless operator greenlights.**


Wave 4 (B5) done and handed off. Status:

- **B5 @9e338f1** — boot-liveness-gate: lifts B2's reconcile to boot tick 1, offlines sessionless-controllable phantoms before `reconcile_once` can revive them. `[impl,unit,int]`.
- Full seam bar green (clippy `-D warnings`, traceable EXIT=0, 10 livehost units, 5 livehost E2Es + new bootgate int, 24-test sweep) — serial in target-seam, **no regression**.
- 3-way phantom matrix closed (mid-life→B2 / boot→B5 / dead-psyche→residency).
- doyle pinged for the wave-4 gate; flagged the runtime-psyche-reap gap he's escalating to the operator as a separate REQ.
- Stack 7 ahead of main, NOT pushed.

**Holding** for doyle's wave-4 gate → wave 5 = B4 (brain-restart-lifecycle-rehydrate, the deepest, last wave). Then deployah → v0.12.0 MINOR.


<task-notification>
<task-id>bzoq0kglt</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/8" id="a9e268b0" type="msg" from="doyle">GATE wave 4 (B5) = FULL PASS @9e338f1. Sign-off.

I independently ran the B5 runtime sweep in target-seam (not trusting the report — the wave-2 deadlock taught me to exercise the seam): clippy --workspace --all-targets -D warnings EXIT=0; traceable EXIT=0; unit boot_gate_offlines... ok; and ALL 6 livehost E2Es g</EVENT-PART>
<EVENT-PART seq="2/8" id="a9e268b0">reen serial — bootgate (NEW, real daemon, 9.6s) + bootrace + nonresident + psyche_fail + firsthost + bind-firsthost. Critically live_bind_firsthost (the only controllable==Some(true) candidate) still hosts → your grace-drop introduced NO false-offline regression. Impl is clean (reuse reconcile_hosted_liveness from tick 1, Some-gated, contr</EVENT-PART>
<EVENT-PART seq="3/8" id="a9e268b0">ollable gate intact); the cold-start(empty sessions→phantom offlined) vs brain-restart(broker-held sessions survive→kept) distinction is correct by construction. B5 [impl,unit,int] all solid.

START WAVE 5 — two REQs:

1) B4 REQ-HAZARD-BRAIN-RESTART-LIFECYCLE-REHYDRATE (the deepest): a bare brain restart loses all BrainLifecy</EVENT-PART>
<EVENT-PART seq="4/8" id="a9e268b0">cle (lifecycle.rs:58-130); resume_sessions re-subscribes the PTY but spawns NO livehost → post-restart spt-hosted endpoints are unhosted until a full daemon kill. FIX: on brain startup, rebuild BrainLifecycle per resumed live-capable session (load manifest → instantiate → start pulse). Composes with B5: the brain-restart survivors B5 kee</EVENT-PART>
<EVENT-PART seq="5/8" id="a9e268b0">ps online must actually get re-hosted here.

2) NEW REQ-HAZARD-UNHOST-PSYCHE-REAP (operator greenlit the fold-in — mint it registry-first, then build [impl,unit,int]): stop_host (livehost.rs:203) flags+joins the HostedLife DRIVER THREAD but never kills the detached {id}-psyche harness PROCESS (spawn_psyche → ManifestRuntime detached </EVENT-PART>
<EVENT-PART seq="6/8" id="a9e268b0">spawn, runtime.rs:341-356; pid untracked in HostedLife but stamped on the {id}-psyche perch — residency-confirm already reads it). So endpoint-stop / mid-life agent-death / B2+B5 offline leave the psyche process orphaned until daemon-stop/Breap. FIX (design-consistent — the Psyche is a harness process BY DESIGN per CONTEXT.md:97/203/251, N</EVENT-PART>
<EVENT-PART seq="7/8" id="a9e268b0">OT brain-thread cruft, so DON'T move it in-brain): on un-host, kill the {id}-psyche process scoped. Track the pid in HostedLife at host_one (cleanest) OR read the {id}-psyche perch pid at stop_host. unit: stop_host terminates a real enrolled psyche-stand-in process; int: endpoint-stop of a live spt-hosted agent leaves no {id}-psyche process. S</EVENT-PART>
<EVENT-PART seq="8/8" id="a9e268b0">coped reap only — never machine-wide (shared box).

Sequence is your call (both are livehost lifecycle; independent). Full seam bar each. Ping at wave-5 ready — that's the last wave before deployah → v0.12.0.</EVENT-PAR
...(truncated)</event>
</task-notification>


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


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


doyle: **WAVE 4 (B5) = FULL PASS** @9e338f1 (he ran the sweep himself, confirmed bind-firsthost still hosts → no false-offline). Wave 5 = the last wave, two REQs:
1. **B4** REQ-HAZARD-BRAIN-RESTART-LIFECYCLE-REHYDRATE (deepest) — rebuild BrainLifecycle per resumed live-capable session on brain startup.
2. **NEW REQ-HAZARD-UNHOST-PSYCHE-REAP** (operator greenlit the fold-in) — `stop_host` must kill the detached `{id}-psyche` process, not just the driver thread.

ACK doyle (protocol), state sequence + flag I'll verify B4's premise at the code first (post-B5, `reconcile_once` may already re-host — must confirm the real gap), then mint the new REQ registry-first:


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && $OWL send doyle <<'EOF'
ACK wave-4 FULL PASS + wave-5 release (B4 + new UNHOST-PSYCHE-REAP). Last wave.

SEQUENCE (my call): UNHOST-PSYCHE-REAP FIRST (self-contained, I understand it fully — track psyche pid in HostedLife at host_one, scoped-kill it in stop_host), THEN B4.

B4 PREMISE CHECK before I build (your verify-at-code-first rule): the REQ says "resume_sessions re-subscribes PTY but spawns NO livehost → post-restart endpoints unhosted." BUT run_brain calls spawn_live_host on EVERY brain start (brainproc.rs:230, after resume_sessions), and reconcile_once re-hosts every online live_agent perch + rebuilds a BrainLifecycle per host (host_one with_config_in). Post-B5, a brain-restart survivor stays online (broker-held PTY session in the set) → reconcile_once SHOULD already re-host it. So the B4 gap may be narrower than the original diagnosis (pre-B5/pre-livehost-reconcile). I'll READ lifecycle.rs:58-130 + brain.rs:254-275 + resume_sessions + host_one and pin the ACTUAL residual gap — flag you if the premise shifted (e.g. it's only the non-psyche lifecycle axis, or attach-after-restart, not the psyche host). Won't build B4 until I've shown you the real gap.

Minting REQ-HAZARD-UNHOST-PSYCHE-REAP registry-first now (required_stages=[] → flip to [impl,unit,int] + tag in the build commit). Scoped per-pid kill (NOT machine-wide, NOT the Breap job — that's brain-subtree-at-daemon-stop; this is per-endpoint un-host). Psyche stays a harness process (CONTEXT 97/203/251), just reaped on un-host. Building now; ping at wave-5 ready.
EOF
echo "sent rc=$?"

Ack doyle + wave-5 sequence + B4 premise flag


➜ SENT:doyle
sent rc=0


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✓ @03b6398. WAVE 2 (B1+H3) GATE-PASS+FIXED @78a13c2. WAVE 3 (B3+Breap) GATE-PASS @026e9b4+@0c1e59e. WAVE 4 (B5) GATE-PASS @9e338f1 (doyle INDEPENDENTLY ran the runtime sweep — clippy/traceable EXIT=0, B5 unit + ALL 6 livehost E2Es incl new bootgate green, bind-firsthost still hosts = no false-offline regression). WAVE 5 (LAST) RELEASED to todlando = B4 + NEW REQ-HAZARD-UNHOST-PSYCHE-REAP.** Then deployah → v0.12.0 MINOR.
15
16	**PSYCHE-IS-A-PROCESS RULING (operator Q, grounded CONTEXT.md+code):** the Psyche is a HARNESS PROCESS by design — a headless `claude --resume` session (CONTEXT.md:97; :203 own independent perch; :251 agent endpoint "something intelligent runs there"; code: manifest `[session.psyche_init/resume]` command, turn.rs:25-27 "persistent harness session"). What lives IN the brain as a THREAD is the pulse/psyche-driver LOOP (CONTEXT.md:34 "pulse/psyche loops"; = HostedLife.thread, joined by stop_host). spt-core moved away from legacy's separate WRAPPER (CONTEXT.md:173 "no separate wrapper"), NOT the psyche process. So the orphan fix is design-consistent: un-host must KILL the harness psyche process (NOT move it in-brain). → NEW REQ-HAZARD-UNHOST-PSYCHE-REAP (wave 5 w/ B4): stop_host kills the {id}-psyche pid (on the perch, residency reads it), scoped. [impl,unit,int].
17
18	**WAVE 4 (B5) design [impl,unit,int]:** lifted LIVENESS_RECONCILE_BOOT_GRACE skip in spawn_live_host → B2's reconcile_hosted_liveness runs from BOOT tick 1 (BEFORE reconcile_once) → sessionless controllable==Some(true) perch OFFLINED at boot → reconcile_once skips host → no phantom revival. doyle: REUSE the B2 fn, no 2nd liveness notion. WHY grace-drop is safe: an spt-hosted agent runs IN a broker PTY so its session PRECEDES the online mark → online+controllable+sessionless = ONLY a cold-start phantom (offline it) OR brain-restart survivor (in the session set, kept). None-skip + controllable gate preserved. 3-WAY MATRIX: (i)child-dead-midlife→B2, (ii)boot-revive→B5, (iii)dead-{id}-psyche→residency-confirm. NO E2E regression: existing livehost E2Es use `api listen` (Some(false), exempt) OR call reconcile_once in-proc w/o broker (None-skip); none host a sessionless-controllable perch. unit boot_gate_offlines_sessionless_controllable_then_reconcile_skips_host; int livehost_bootgate_e2e (real cold-start, stale online+controllable perch no session → offlined at boot + no {id}-psyche). **OPEN (doyle→operator scope call): stop_host stops the DRIVER thread but NOT the detached psyche PROCESS — a mid-life endpoint-death/stop leaves the psyche running until daemon-stop/Breap. Real+production-reachable, separate from B5/B4. Candidate new REQ (stop_host reads {id}-psyche pid + kills it). Not in B5/B4 unless operator greenlights.**
19
20	**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.
21
22	**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.
23
24	**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.
25
26	**7 REQs minted registry-first @e2d6391 (traceable EXIT=0). WAVE ORDER: B2 → B1+H3 → B3+Breap → B5 → B4.**
27	- **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].

... [8 lines truncated] ...


My Psyche synced the milestone memory (PSYCHE-IS-A-PROCESS ruling + wave-5 release captured). Now mint the new REQ registry-first. Read the B4 block to place it after:


596	[[requirements]]
597	id = "REQ-HAZARD-BRAIN-RESTART-LIFECYCLE-REHYDRATE"
598	title = "B4 (deepest): a bare brain restart (broker survives) REHYDRATES the live-agent lifecycle so post-restart endpoints are hosted + attachable. Today resume_sessions (brainproc.rs:186, brain.rs:797-809) re-subscribes to the broker's PTY sessions but ALL BrainLifecycle instances (lifecycle.rs:58-130; the ephemeral brain.rs:254-275) are LOST on restart → a post-restart live endpoint gets no livehost → its Psyche is never (re)hosted and new spawns die / can't attach until a FULL daemon reset (operator: perri's brain kill+restart wedged everything until a full daemon kill). FIX: on brain startup, rebuild a BrainLifecycle per resumed live-capable session — load the manifest from the adapter registry → instantiate → start the pulse — the rehydrate the resume no-op cannot do. Composes with B2 (the reconcile re-hosts from the honest on-disk status after rehydrate). (v0.12.0)"
599	required_stages = []  # registry-first; activate [impl,unit,int] when built — wave 5 (LAST, deepest). int: a brain restart with a live-capable endpoint re-hosts its lifecycle (no full daemon reset needed)
600	[[requirements]]
601	id = "REQ-HAZARD-VIEWER-ISOLATION"
602	title = "A slow / dead / hostile VIEWER must NEVER stall the controller, the PTY child, or the session drain thread. The broker drain fans output to the controller on the authoritative blocking bounded path (advances delivered_through) but to each viewer via a bounded per-viewer channel with a dedicated writer thread; the drain `try_send`s under the log lock and a viewer whose bounded queue OVERFLOWS (can't keep up) is EVICTED (queue dropped, writer thread ends, removed from the viewers map) — the drain thread NEVER touches a viewer socket, so no viewer write can backpressure or block it. A soft viewer cap bounds the thread count. Viewer eviction never perturbs the controller stream, the delivered_through cursor, or the child."
603	required_stages = ["unit", "int"]  # activated M12-W2.5 (unit: try_send-overflow eviction decision on the fan-out; int: a wedged viewer is evicted and the controller stream + child run on unaffected)
604
605	# ───────────────────────────── Install / migration / infra ──────────────────

[[requirements]]
id = "REQ-HAZARD-BRAIN-RESTART-LIFECYCLE-REHYDRATE"
title = "B4 (deepest): a bare brain restart (broker survives) REHYDRATES the live-agent lifecycle so post-restart endpoints are hosted + attachable. Today resume_sessions (brainproc.rs:186, brain.rs:797-809) re-subscribes to the broker's PTY sessions but ALL BrainLifecycle instances (lifecycle.rs:58-130; the ephemeral brain.rs:254-275) are LOST on restart → a post-restart live endpoint gets no livehost → its Psyche is never (re)hosted and new spawns die / can't attach until a FULL daemon reset (operator: perri's brain kill+restart wedged everything until a full daemon kill). FIX: on brain startup, rebuild a BrainLifecycle per resumed live-capable session — load the manifest from the adapter registry → instantiate → start the pulse — the rehydrate the resume no-op cannot do. Composes with B2 (the reconcile re-hosts from the honest on-disk status after rehydrate). (v0.12.0)"
required_stages = []  # registry-first; activate [impl,unit,int] when built — wave 5 (LAST, deepest). int: a brain restart with a live-capable endpoint re-hosts its lifecycle (no full daemon reset needed)
[[requirements]]
id = "REQ-HAZARD-VIEWER-ISOLATION"
title = "A slow / dead / hostile VIEWER must NEVER stall the controller, the PTY child, or the session drain thread. The broker drain fans output to the controller on the authoritative blocking bounded path (advances delivered_through) but to each viewer via a bounded per-viewer channel with a dedicated writer thread; the drain `try_send`s under the log lock and a viewer whose bounded queue OVERFLOWS (can't keep up) is EVICTED (queue dropped, writer thread ends, removed from the viewers map) — the drain thread NEVER touches a viewer socket, so no viewer write can backpressure or block it. A soft viewer cap bounds the thread count. Viewer eviction never perturbs the controller stream, the delivered_through cursor, or the child."
required_stages = ["unit", "int"]  # activated M12-W2.5 (unit: try_send-overflow eviction decision on the fan-out; int: a wedged viewer is evicted and the controller stream + child run on unaffected)

# ───────────────────────────── Install / migration / infra ──────────────────


Edit

required_stages = []  # registry-first; activate [impl,unit,int] when built — wave 5 (LAST, deepest). int: a brain restart with a live-capable endpoint re-hosts its lifecycle (no full daemon reset needed)
[[requirements]]
id = "REQ-HAZARD-VIEWER-ISOLATION"

required_stages = []  # registry-first; activate [impl,unit,int] when built — wave 5 (LAST, deepest). int: a brain restart with a live-capable endpoint re-hosts its lifecycle (no full daemon reset needed)
[[requirements]]
id = "REQ-HAZARD-UNHOST-PSYCHE-REAP"
title = "On un-host, the detached `{id}-psyche` HARNESS PROCESS is reaped — not just its in-brain pulse-driver thread. Today stop_host (livehost.rs:203) trips the HostedLife stop flag + JOINS the driver thread, but the Psyche is a detached harness process (spawn_psyche → ManifestRuntime detached spawn, runtime.rs:341-356; its pid is untracked in HostedLife though stamped on the `{id}-psyche` perch, where residency-confirm already reads it). So endpoint-stop / mid-life agent-death / a B2/B5 offline-then-unhost leaves the psyche process ORPHANED, alive until the next daemon-stop (where Breap's job/group reaps the whole brain subtree). The Psyche STAYS a harness process by design (CONTEXT.md 97/203/251 — headless harness session, its own perch) — the fix does NOT move it in-brain; it SCOPED-kills the `{id}-psyche` pid on un-host (never machine-wide — shared box). Track the pid in HostedLife at host_one (cleanest) or read the `{id}-psyche` perch pid at stop_host. Composes with H3 (endpoint stop → offline → reconcile un-host → reap) and B2/B5 (the offline arms that trigger un-host). (v0.12.0)"
required_stages = []  # registry-first (operator-greenlit fold-in, wave 5 w/ B4); activate [impl,unit,int] when built. impl = stop_host scoped-kills the {id}-psyche pid (tracked in HostedLife at host_one); unit = stop_host terminates a real enrolled psyche-stand-in process; int = endpoint-stop of a live spt-hosted agent leaves no {id}-psyche process
[[requirements]]
id = "REQ-HAZARD-VIEWER-ISOLATION"


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

required_stages = []  # registry-first; activate [impl,unit,int] when built — wave 5 (LAST, deepest). int: a brain restart with a live-capable endpoint re-hosts its lifecycle (no full daemon reset needed)
[[requirements]]
id = "REQ-HAZARD-VIEWER-ISOLATION"

required_stages = []  # registry-first; activate [impl,unit,int] when built — wave 5 (LAST, deepest). int: a brain restart with a live-capable endpoint re-hosts its lifecycle (no full daemon reset needed)
[[requirements]]
id = "REQ-HAZARD-UNHOST-PSYCHE-REAP"
title = "On un-host, the detached `{id}-psyche` HARNESS PROCESS is reaped — not just its in-brain pulse-driver thread. Today stop_host (livehost.rs:203) trips the HostedLife stop flag + JOINS the driver thread, but the Psyche is a detached harness process (spawn_psyche → ManifestRuntime detached spawn, runtime.rs:341-356; its pid is untracked in HostedLife though stamped on the `{id}-psyche` perch, where residency-confirm already reads it). So endpoint-stop / mid-life agent-death / a B2/B5 offline-then-unhost leaves the psyche process ORPHANED, alive until the next daemon-stop (where Breap's job/group reaps the whole brain subtree). The Psyche STAYS a harness process by design (CONTEXT.md 97/203/251 — headless harness session, its own perch) — the fix does NOT move it in-brain; it SCOPED-kills the `{id}-psyche` pid on un-host (never machine-wide — shared box). Track the pid in HostedLife at host_one (cleanest) or read the `{id}-psyche` perch pid at stop_host. Composes with H3 (endpoint stop → offline → reconcile un-host → reap) and B2/B5 (the offline arms that trigger un-host). (v0.12.0)"
required_stages = []  # registry-first (operator-greenlit fold-in, wave 5 w/ B4); activate [impl,unit,int] when built. impl = stop_host scoped-kills the {id}-psyche pid (tracked in HostedLife at host_one); unit = stop_host terminates a real enrolled psyche-stand-in process; int = endpoint-stop of a live spt-hosted agent leaves no {id}-psyche process
[[requirements]]
id = "REQ-HAZARD-VIEWER-ISOLATION"

title = "B4 (deepest): a bare brain restart (broker survives) REHYDRATES the live-agent lifecycle so post-restart endpoints are hosted + attachable. Today resume_sessions (brainproc.rs:186, brain.rs:797-809) re-subscribes to the broker's PTY sessions but ALL BrainLifecycle instances (lifecycle.rs:58-130; the ephemeral brain.rs:254-275) are LOST on restart → a post-restart live endpoint gets no livehost → its Psyche is never (re)hosted and new spawns die / can't attach until a FULL daemon reset (operator: perri's brain kill+restart wedged everything until a full daemon kill). FIX: on brain startup, rebuild a BrainLifecycle per resumed live-capable session — load the manifest from the adapter registry → instantiate → start the pulse — the rehydrate the resume no-op cannot do. Composes with B2 (the reconcile re-hosts from the honest on-disk status after rehydrate). (v0.12.0)"

+title = "On un-host, the detached `{id}-psyche` HARNESS PROCESS is reaped — not just its in-brain pulse-driver thread. Today stop_host (livehost.rs:203) trips the HostedLife stop flag + JOINS the driver thread, but the Psyche is a detached harness process (spawn_psyche → ManifestRuntime detached spawn, runtime.rs:341-356; its pid is untracked in HostedLife though stamped on the `{id}-psyche` perch, where residency-confirm already reads it). So endpoint-stop / mid-life agent-death / a B2/B5 offline-then-unhost leaves the psyche process ORPHANED, alive until the next daemon-stop (where Breap's job/group reaps the whole brain subtree). The Psyche STAYS a harness process by design (CONTEXT.md 97/203/251 — headless harness session, its own perch) — the fix does NOT move it in-brain; it SCOPED-kills the `{id}-psyche` pid on un-host (never machine-wide — shared box). Track the pid in HostedLife at host_one (cleanest) or read the `{id}-psyche` perch pid at stop_host. Composes with H3 (endpoint stop → offline → reconcile un-host → reap) and B2/B5 (the offline arms that trigger un-host). (v0.12.0)"

+required_stages = []  # registry-first (operator-greenlit fold-in, wave 5 w/ B4); activate [impl,unit,int] when built. impl = stop_host scoped-kills the {id}-psyche pid (tracked in HostedLife at host_one); unit = stop_host terminates a real enrolled psyche-stand-in process; int = endpoint-stop of a live spt-hosted agent leaves no {id}-psyche process

title = "A slow / dead / hostile VIEWER must NEVER stall the controller, the PTY child, or the session drain thread. The broker drain fans output to the controller on the authoritative blocking bounded path (advances delivered_through) but to each viewer via a bounded per-viewer channel with a dedicated writer thread; the drain `try_send`s under the log lock and a viewer whose bounded queue OVERFLOWS (can't keep up) is EVICTED (queue dropped, writer thread ends, removed from the viewers map) — the drain thread NEVER touches a viewer socket, so no viewer write can backpressure or block it. A soft viewer cap bounds the thread count. Viewer eviction never perturbs the controller stream, the delivered_through cursor, or the child."

required_stages = ["unit", "int"]  # activated M12-W2.5 (unit: try_send-overflow eviction decision on the fan-out; int: a wedged viewer is evicted and the controller stream + child run on unaffected)


REQ minted registry-first. Now design UNHOST-PSYCHE-REAP safely — pid-recycle on the shared box is the risk. Check what `spawn_psyche` returns (an owned handle would be recycle-safe vs a bare pid):


Bash

cd "C:/Users/decid/Documents/projects/spt-core"; grep -n "fn spawn_psyche\|pub struct.*Psyche\|PsycheHandle\|pub pid\|-> .*Psyche\|struct Spawned\|pub fn spawn_session\|fn run_pulse_loop" crates/spt-daemon/src/lifecycle.rs crates/spt-runtime/src/runtime.rs crates/spt-live/src/*.rs 2>/dev/null | head -30

Find spawn_psyche return type


<owl_messages>
<owl_message from="doyle" priority="high">Strong premise-check — endorsed, and I think you're right that B4-as-written is stale. The original diagnosis predates B2/B5 + the livehost reconcile; spawn_live_host@brainproc:230 + reconcile_once re-hosting online survivors very likely already covers the PSYCHE-HOST axis post-B5. Sequence (psyche-reap first, self-contained) is good.

For B4, pin it by ENUMERATING what BrainLifecycle (lifecycle.rs:58-130) actually holds, then check each axis against what reconcile_once/host_one already rebuild on restart:
- psyche-host / pulse loop → almost certainly covered now (reconcile_once → host_one → with_config_in rebuilds it).
- the OTHER axes (whatever 58-130 holds beyond psyche-hosting: shellwake watchers, notif pump, drive channels, tunnel hubs, the Self-side lifecycle) → these are the candidate residual; resume_sessions re-subscribes PTYs but may not rebuild them.
The residual gap = axes present in BrainLifecycle that NOTHING rebuilds on a bare brain restart.

Two legit outcomes, both fine — just show me the evidence:
1) Residual is NON-EMPTY → build B4 scoped to ONLY those axes (don't rebuild what reconcile already does).
2) Residual is EMPTY → B2+B5 SUBSUMED B4. Then B4 closes as covered (not built) — that's a real scope reduction I'll surface to the operator, with your axis-by-axis evidence. A REQ proven moot by the keystone is a WIN, not a miss.

Show me the per-axis table (axis → rebuilt-by-what-on-restart → covered? / residual) before building B4. Psyche-reap: build + seam-bar + ping. B4: pin-then-confirm-then-build.</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.


crates/spt-daemon/src/lifecycle.rs:25:    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
crates/spt-daemon/src/lifecycle.rs:178:    pub fn spawn_psyche(
crates/spt-daemon/src/lifecycle.rs:182:    ) -> Result<PsycheHandle, String> {
crates/spt-daemon/src/lifecycle.rs:285:    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
crates/spt-runtime/src/runtime.rs:199:pub struct Spawned {
crates/spt-runtime/src/runtime.rs:200:    pub pid: u32,
crates/spt-live/src/lib.rs:55:pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
crates/spt-live/src/outbound.rs:51:pub fn parse_psyche_intents(stdout: &str) -> Vec<PsycheIntent> {
crates/spt-live/src/psyche.rs:33:pub struct PsycheHandle {
crates/spt-live/src/psyche.rs:35:    pub pid: u32,
crates/spt-live/src/psyche.rs:94:pub fn spawn_psyche(
crates/spt-live/src/psyche.rs:100:) -> Result<PsycheHandle, PsycheError> {
crates/spt-live/src/psyche.rs:115:) -> Result<PsycheHandle, PsycheError> {
crates/spt-live/src/psyche.rs:147:    Ok(PsycheHandle {
crates/spt-live/src/resume.rs:21:use crate::psyche::{spawn_and_bind, PsycheError, PsycheHandle};
crates/spt-live/src/resume.rs:135:) -> Result<PsycheHandle, ResumeError> {


doyle: endorses psyche-reap-first; for B4 wants a **per-axis table** (what `BrainLifecycle` holds → rebuilt-by-what-on-restart → covered?/residual) BEFORE building — and a residual-empty outcome (B2+B5 subsumed B4) is a legit *win*, not a miss. Found: `spawn_psyche` returns `PsycheHandle { pid: u32 }` (spt-live/psyche.rs:33). ACK doyle (protocol), then build psyche-reap + read the handle/lifecycle:


cd "C:/Users/decid/Documents/projects/spt-core" && $OWL send doyle <<'EOF'
ACK — B4 per-axis-table-before-build locked; residual-empty = WIN (scope reduction, I'll surface axis evidence). psyche-reap first (building now), B4 pin-then-confirm.

Plan crisp:
1) UNHOST-PSYCHE-REAP: HostedLife gains psyche_pid (PsycheHandle.pid captured at host_one); stop_host scoped-kills it. Recycle-safety: residency-confirm already un-hosts DEAD psyches first, so a normal un-host's pid is live-and-ours; I'll guard the kill with is_process_alive(pid) before firing (best-effort, matches the codebase's proc posture). Checking now whether PsycheHandle owns a killable Child (recycle-PROOF) vs bare pid — if it owns the Child I'll store that instead (kill-by-handle can't hit a recycled pid). Adding spt_store::proc::kill_pid (Win OpenProcess+TerminateProcess / Unix SIGKILL+reap) — OS-FFI sibling of is_process_alive, scoped single-pid, never machine-wide.
2) B4: I'll enumerate lifecycle.rs:58-130 axis-by-axis (psyche-host/pulse, shellwake, notif, drive, tunnel, Self-side) → rebuilt-by-what-on-restart → covered/residual, and send you the table before any B4 code. My prior is psyche-host is covered (reconcile_once→host_one→with_config_in) and residual is the non-psyche axes IF any aren't disk-reconciled — but several (shellwake) are their own brain-start reconcilers already. Evidence will decide build-scoped vs close-as-subsumed.

Building psyche-reap now; ping at its seam-green, then the B4 table.
EOF
echo "sent rc=$?"

Ack doyle + crisp wave-5 plan


30
31	/// A spawned Psyche companion: its nested id, OS pid, and perch directory.
32	#[derive(Debug, Clone)]
33	pub struct PsycheHandle {
34	    pub id: String,
35	    pub pid: u32,
36	    pub perch_path: PathBuf,
37	}
38
39	/// A spawn-psyche failure. Carries an operator message; never panics.
40	#[derive(Debug)]
41	pub enum PsycheError {
42	    /// Template fill / spawn / role-lookup failure from the runtime.
43	    Runtime(RuntimeError),
44	    /// Creating the nested perch dir or writing its `info.json` failed.
45	    Bind(std::io::Error),
46	}
47
48	impl std::fmt::Display for PsycheError {
49	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50	        match self {
51	            PsycheError::Runtime(e) => write!(f, "psyche spawn failed: {e}"),
52	            PsycheError::Bind(e) => write!(f, "binding the psyche perch failed: {e}"),
53	        }
54	    }
55	}
56
57	impl std::error::Error for PsycheError {}
58
59	impl From<RuntimeError> for PsycheError {
60	    fn from(e: RuntimeError) -> Self {
61	        PsycheError::Runtime(e)
62	    }
63	}
64
65	/// Compose the **spt-core-owned** `{psyche_prompt}`: the revival essentials fed
66	/// to every Psyche regardless of adapter — a timestamp and the incoming event
67	/// envelope (CONTEXT §spawn-psyche seam). Consistent across adapters so the
68	/// live/project authoring split stays uniform (`docs/CONTEXT-MEMORY.md`); the
69	/// adapter prepends its own static preamble around this.
70	pub fn compose_psyche_prompt(now: &str, event: Option<&str>) -> String {
71	    let mut s = String::with_capacity(64);
72	    s.push_str("PSYCHE REVIVAL\n");
73	    s.push_str("time: ");
74	    s.push_str(now);
75	    s.push('\n');
76	    match event {
77	        Some(ev) if !ev.trim().is_empty() => {
78	            s.push_str("incoming event:\n");
79	            s.push_str(ev.trim_end());
80	            s.push('\n');
81	        }
82	        _ => s.push_str("incoming event: (none)\n"),
83	    }
84	    s
85	}
86
87	/// Spawn a fresh Psyche for `parent_id` and bind its nested perch.
88	///
89	/// `keys` supplies the adapter-template substitution values the caller owns
90	/// (e.g. `session_name`); spt-core overrides the three it owns for a Psyche:
91	/// `id` (the nested `<id>-psyche`), `psyche_dir` (the created perch dir, the
92	/// role's `cwd`), and `psyche_prompt` (composed here). The role's
93	/// `recursion_guard_env` is applied to the child by the runtime.
94	pub fn spawn_psyche(
95	    runtime: &dyn AgentRuntime,
96	    parent_id: &str,
97	    keys: &BTreeMap<String, String>,
98	    event: Option<&str>,
99	    now: &str,
100	) -> Result<PsycheHandle, PsycheError> {
101	    spawn_and_bind(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
102	}
103
104	/// Shared spawn+bind core for the Psyche roles (`psyche_init` — fresh; and
105	/// `psyche_resume` — re-entry, T7). Injects the three spt-core-owned keys
106	/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the
107	/// nested `<id>-psyche` perch.
108	pub(crate) fn spawn_and_bind(
109	    runtime: &dyn AgentRuntime,
110	    role: &str,
111	    parent_id: &str,
112	    keys: &BTreeMap<String, String>,
113	    event: Option<&str>,
114	    now: &str,
115	) -> Result<PsycheHandle, PsycheError> {
116	    let psyche_id = format!("{parent_id}-psyche");
117	    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
118	    // The nested perch dir must exist before spawn — the role's `cwd` is it.
119	    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;
120
121	    let mut k = keys.clone();
122	    k.insert("id".to_string(), psyche_id.clone());
123	    k.insert(
124	        "psyche_dir".to_string(),
125	        perch_path.to_string_lossy().to_string(),
126	    );
127	    k.insert(
128	        "psyche_prompt".to_string(),
129	        compose_psyche_prompt(now, event),
130	    );
131
132	    let spawned = runtime.spawn_session(role, &k)?;
133
134	    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
135	    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
136	    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
137	    let mut rec = InfoJson::new(
138	        &psyche_id,
139	        now,
140	        spawned.pid,
141	        session_id,
142	        &EndpointType::Psyche.as_tag(),
143	    );
144	    rec.parent_pid = None;
145	    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;
146
147	    Ok(PsycheHandle {
148	        id: psyche_id,
149	        pid: spawned.pid,
150	        perch_path,
151	    })
152	}
153
154	#[cfg(test)]
155	mod tests {
156	    use super::*;
157	    use crate::test_support::with_home;
158	    use spt_runtime::{Manifest, ManifestRuntime};
159	    use std::time::{Duration, Instant};

/// A spawned Psyche companion: its nested id, OS pid, and perch directory.
#[derive(Debug, Clone)]
pub struct PsycheHandle {
    pub id: String,
    pub pid: u32,
    pub perch_path: PathBuf,
}

/// A spawn-psyche failure. Carries an operator message; never panics.
#[derive(Debug)]
pub enum PsycheError {
    /// Template fill / spawn / role-lookup failure from the runtime.
    Runtime(RuntimeError),
    /// Creating the nested perch dir or writing its `info.json` failed.
    Bind(std::io::Error),
}

impl std::fmt::Display for PsycheError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PsycheError::Runtime(e) => write!(f, "psyche spawn failed: {e}"),
            PsycheError::Bind(e) => write!(f, "binding the psyche perch failed: {e}"),
        }
    }
}

impl std::error::Error for PsycheError {}

impl From<RuntimeError> for PsycheError {
    fn from(e: RuntimeError) -> Self {
        PsycheError::Runtime(e)
    }
}

/// Compose the **spt-core-owned** `{psyche_prompt}`: the revival essentials fed
/// to every Psyche regardless of adapter — a timestamp and the incoming event
/// envelope (CONTEXT §spawn-psyche seam). Consistent across adapters so the
/// live/project authoring split stays uniform (`docs/CONTEXT-MEMORY.md`); the
/// adapter prepends its own static preamble around this.
pub fn compose_psyche_prompt(now: &str, event: Option<&str>) -> String {
    let mut s = String::with_capacity(64);
    s.push_str("PSYCHE REVIVAL\n");
    s.push_str("time: ");
    s.push_str(now);
    s.push('\n');
    match event {
        Some(ev) if !ev.trim().is_empty() => {
            s.push_str("incoming event:\n");
            s.push_str(ev.trim_end());
            s.push('\n');
        }
        _ => s.push_str("incoming event: (none)\n"),
    }
    s
}

/// Spawn a fresh Psyche for `parent_id` and bind its nested perch.
///
/// `keys` supplies the adapter-template substitution values the caller owns
/// (e.g. `session_name`); spt-core overrides the three it owns for a Psyche:
/// `id` (the nested `<id>-psyche`), `psyche_dir` (the created perch dir, the
/// role's `cwd`), and `psyche_prompt` (composed here). The role's
/// `recursion_guard_env` is applied to the child by the runtime.
pub fn spawn_psyche(
    runtime: &dyn AgentRuntime,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    spawn_and_bind(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
}

/// Shared spawn+bind core for the Psyche roles (`psyche_init` — fresh; and
/// `psyche_resume` — re-entry, T7). Injects the three spt-core-owned keys
/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the
/// nested `<id>-psyche` perch.
pub(crate) fn spawn_and_bind(
    runtime: &dyn AgentRuntime,
    role: &str,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    let psyche_id = format!("{parent_id}-psyche");
    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
    // The nested perch dir must exist before spawn — the role's `cwd` is it.
    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;

    let mut k = keys.clone();
    k.insert("id".to_string(), psyche_id.clone());
    k.insert(
        "psyche_dir".to_string(),
        perch_path.to_string_lossy().to_string(),
    );
    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

    let spawned = runtime.spawn_session(role, &k)?;

    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
    let mut rec = InfoJson::new(
        &psyche_id,
        now,
        spawned.pid,
        session_id,
        &EndpointType::Psyche.as_tag(),
    );
    rec.parent_pid = None;
    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;

    Ok(PsycheHandle {
        id: psyche_id,
        pid: spawned.pid,
        perch_path,
    })
}

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


`PsycheHandle` holds only `{id, pid, perch_path}` — no owned Child (detached inside `spawn_session`). So kill-by-pid it is, guarded by `is_process_alive` (best-effort, matches codebase posture; residency-confirm pre-empts dead-psyche un-hosts). Add `kill_pid` to proc.rs:


Edit

/// The current process's **parent** pid — the stable session-binding anchor for

/// Best-effort SCOPED termination of a SINGLE process by pid — never a tree, never
/// machine-wide (this box is shared with live broker + sibling agents, so a broad
/// reap is forbidden; KNOWN-HAZARDS). Used to reap a detached `{id}-psyche` harness
/// process when its endpoint un-hosts (REQ-HAZARD-UNHOST-PSYCHE-REAP): the in-brain
/// pulse-driver thread is joined separately, but the Psyche is a separate detached
/// process whose pid the caller tracked. `pid == 0` is never a real target (the
/// kernel/idle process; on Unix `kill(0, …)` would hit the caller's own group). The
/// caller guards pid-recycle with [`is_process_alive`] before calling.
// [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
pub fn kill_pid(pid: u32) {
    if pid == 0 {
        return; // never a real per-process target (see is_process_alive)
    }
    #[cfg(unix)]
    unsafe {
        libc::kill(pid as i32, libc::SIGKILL);
        // Clear the zombie if it was our child (in-process hosting / tests); a no-op
        // (ECHILD) when somebody else owns the wait.
        libc::waitpid(pid as i32, std::ptr::null_mut(), libc::WNOHANG);
    }
    #[cfg(windows)]
    {
        extern "system" {
            fn OpenProcess(
                dw_desired_access: u32,
                b_inherit_handle: i32,
                dw_process_id: u32,
            ) -> isize;
            fn TerminateProcess(h_process: isize, u_exit_code: u32) -> i32;
            fn CloseHandle(h_object: isize) -> i32;
        }
        const PROCESS_TERMINATE: u32 = 0x0001;
        let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
        if handle != 0 {
            unsafe {
                TerminateProcess(handle, 1);
                CloseHandle(handle);
            }
        }
    }
}

/// The current process's **parent** pid — the stable session-binding anchor for


<owl_messages>
<owl_message from="doyle" priority="high">On the Child-vs-pid question you raised: make OWNED-Child the REQUIREMENT, not "if convenient." Reasoning + the shared-box stakes:

- BARE-PID kill is recycle-unsafe AND there's no guard that closes it: is_process_alive(pid)==true → pid recycled → kill hits the new owner (TOCTOU). On THIS shared box a recycled psyche pid could be a LIVE AGENT's process → that's the no-machine-wide-kill hazard in miniature. Unacceptable for the default path.
- OWNED-Child (PsycheHandle stores the Child, not the pid) is recycle-PROOF (the handle binds the exact process) AND gives a clean reap: Child.kill() then Child.wait() — std reaps, no zombie, no raw libc::waitpid. This means spawn_psyche must RETURN the Child for the hosted path instead of detach-dropping it (runtime.rs:354). The detach was "fire-and-forget for the interim no-daemon model" — the daemon-hosted path SHOULD own its child (same posture as Breap's owned brain handle). Keep the detached drop only for any non-hosted caller that still needs it.
- WAVE-3 GOTCHA #1 applies here: once you OWN the Child, probe liveness/exit with try_wait, NOT is_process_alive(pid) — an owned, killed handle reads ALIVE via OpenProcess until you wait() it. So: stop_host → child.kill() → child.wait() (or try_wait-poll). No is_process_alive on the owned handle.
- kill_pid (your new spt_store::proc fn) is still useful, but as the UNOWNED fallback only (e.g. a perch-pid path for a psyche the current brain didn't spawn) — not the primary hosted-psyche reap.

If threading the Child from spawn_psyche → host_one → HostedLife is genuinely messy (some seam forces pid-only), STOP and show me before settling for bare-pid — we'll find another recycle-proof route. Build on; ping at psyche-reap seam-green, then the B4 axis table.</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.


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

/// Best-effort SCOPED termination of a SINGLE process by pid — never a tree, never
/// machine-wide (this box is shared with live broker + sibling agents, so a broad
/// reap is forbidden; KNOWN-HAZARDS). Used to reap a detached `{id}-psyche` harness
/// process when its endpoint un-hosts (REQ-HAZARD-UNHOST-PSYCHE-REAP): the in-brain
/// pulse-driver thread is joined separately, but the Psyche is a separate detached
/// process whose pid the caller tracked. `pid == 0` is never a real target (the
/// kernel/idle process; on Unix `kill(0, …)` would hit the caller's own group). The
/// caller guards pid-recycle with [`is_process_alive`] before calling.
// [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
pub fn kill_pid(pid: u32) {
    if pid == 0 {
        return; // never a real per-process target (see is_process_alive)
    }
    #[cfg(unix)]
    unsafe {
        libc::kill(pid as i32, libc::SIGKILL);
        // Clear the zombie if it was our child (in-process hosting / tests); a no-op
        // (ECHILD) when somebody else owns the wait.
        libc::waitpid(pid as i32, std::ptr::null_mut(), libc::WNOHANG);
    }
    #[cfg(windows)]
    {
        extern "system" {
            fn OpenProcess(
                dw_desired_access: u32,
                b_inherit_handle: i32,
                dw_process_id: u32,
            ) -> isize;
            fn TerminateProcess(h_process: isize, u_exit_code: u32) -> i32;
            fn CloseHandle(h_object: isize) -> i32;
        }
        const PROCESS_TERMINATE: u32 = 0x0001;
        let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
        if handle != 0 {
            unsafe {
                TerminateProcess(handle, 1);
                CloseHandle(handle);
            }
        }
    }
}

/// The current process's **parent** pid — the stable session-binding anchor for

+/// Best-effort SCOPED termination of a SINGLE process by pid — never a tree, never

+/// machine-wide (this box is shared with live broker + sibling agents, so a broad

+/// reap is forbidden; KNOWN-HAZARDS). Used to reap a detached `{id}-psyche` harness

+/// process when its endpoint un-hosts (REQ-HAZARD-UNHOST-PSYCHE-REAP): the in-brain

+/// pulse-driver thread is joined separately, but the Psyche is a separate detached

+/// process whose pid the caller tracked. `pid == 0` is never a real target (the

+/// kernel/idle process; on Unix `kill(0, …)` would hit the caller's own group). The

+/// caller guards pid-recycle with [`is_process_alive`] before calling.

+        return; // never a real per-process target (see is_process_alive)

+        libc::kill(pid as i32, libc::SIGKILL);

+        // Clear the zombie if it was our child (in-process hosting / tests); a no-op

+        // (ECHILD) when somebody else owns the wait.

+        libc::waitpid(pid as i32, std::ptr::null_mut(), libc::WNOHANG);

+                dw_desired_access: u32,

+            fn TerminateProcess(h_process: isize, u_exit_code: u32) -> i32;

+            fn CloseHandle(h_object: isize) -> i32;

+        const PROCESS_TERMINATE: u32 = 0x0001;

+        let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };

+                TerminateProcess(handle, 1);

/// harness-hosted startup (the harness process that spawned `spt`). The

/// pid-recycle defense (KNOWN-HAZARDS 5.1) anchors liveness on this, not on the


doyle: **owned-Child is now a REQUIREMENT, not bare-pid** — recycle-unsafe pid kill on the shared box could hit a sibling live agent (the no-machine-wide-kill hazard in miniature). `spawn_psyche` must return the owned `Child` for the hosted path (recycle-proof: `child.kill()`+`wait()`, probe with `try_wait` per gotcha #1); `kill_pid` demotes to unowned fallback. This means threading the Child up from `runtime.spawn_session` (which drops it at runtime.rs:354). He said: if that seam is messy, STOP and show before settling for bare-pid.

ACK doyle (protocol) + trace the seam blast radius before committing:


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && $OWL send doyle <<'EOF'
ACK — owned-Child is the REQUIREMENT (recycle-PROOF: child.kill()+wait(), try_wait liveness per gotcha #1; kill_pid demoted to UNOWNED fallback only). Agreed on the shared-box stakes — a recycled psyche pid hitting a sibling live agent is exactly the hazard. kill_pid already added to spt_store::proc; I'll reword its doc to "unowned fallback."

Tracing the spawn_session seam NOW to find the cleanest recycle-proof thread (Child: runtime.spawn_session → spt-live spawn_psyche/PsycheHandle → lifecycle → host_one → HostedLife). Two candidate shapes:
(A) ADD an owned-spawn method to AgentRuntime (e.g. spawn_session_owned → {pid, Child}) used ONLY by the hosted psyche path; spawn_session stays detached for everyone else. Smaller blast radius (additive, trait stays back-comp), but every AgentRuntime impl (ManifestRuntime + test mocks) must impl it.
(B) CHANGE spawn_session to return the Child; detached callers drop it explicitly. Cleaner conceptually, but ripples to all spawn_session callers.

Checking impl/caller count now to pick. Will report the blast radius + chosen shape before I commit code — per your "if messy, STOP and show." If neither is clean I'll surface alternatives (e.g. PsycheHandle carries Option<Child> only on the hosted spawn_and_bind path). Standby for the seam report.
EOF
echo "sent rc=$?"

Ack doyle + owned-Child seam plan


cd "C:/Users/decid/Documents/projects/spt-core"; echo "=== AgentRuntime impls ==="; grep -rn "impl AgentRuntime for\|trait AgentRuntime" crates/; echo; echo "=== spawn_session callers (non-test) ==="; grep -rn "\.spawn_session(\|fn spawn_session" crates/ | grep -v "tests/" | head -30; echo; echo "=== Spawned struct + uses ==="; grep -rn "Spawned\b" crates/spt-runtime/src/runtime.rs | head

Gauge spawn_session seam blast radius


=== AgentRuntime impls ===
crates/spt-runtime/src/runtime.rs:219:pub trait AgentRuntime {
crates/spt-runtime/src/runtime.rs:341:impl AgentRuntime for ManifestRuntime {

=== spawn_session callers (non-test) ===
crates/spt-daemon/src/applyhost.rs:593:        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
crates/spt-daemon/src/applyhost.rs:635:        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
crates/spt-daemon/src/brain.rs:384:    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
crates/spt-daemon/src/brain.rs:403:    pub fn spawn_session_pid(&mut self, req: SpawnReq) -> io::Result<(u64, Option<u32>)> {
crates/spt-daemon/src/livehost.rs:814:                .spawn_session(SpawnReq {
crates/spt-daemon/src/notif.rs:307:        .spawn_session(NOTIF_ROLE, &notif_keys(row))
crates/spt-live/src/psyche.rs:132:    let spawned = runtime.spawn_session(role, &k)?;
crates/spt-runtime/src/runtime.rs:222:    fn spawn_session(
crates/spt-runtime/src/runtime.rs:342:    fn spawn_session(
crates/spt-runtime/src/runtime.rs:692:        let e = rt.spawn_session("self", &keys(&[])).unwrap_err();
crates/spt-runtime/src/runtime.rs:748:    fn spawn_session_detached_returns_pid() {
crates/spt-runtime/src/runtime.rs:753:        let spawned = rt.spawn_session("self", &keys(&[])).expect("spawn");

=== Spawned struct + uses ===
199:pub struct Spawned {
226:    ) -> Result<Spawned, RuntimeError>;
346:    ) -> Result<Spawned, RuntimeError> {
355:        Ok(Spawned { pid: child.id() })


Blast radius is small: `AgentRuntime` has **one** impl (ManifestRuntime), and the trait `spawn_session` has only **two** production callers — psyche.rs:132 (want owned) + notif.rs:307 (detached). Check `PsycheHandle` clone-usage + who calls the spt-live psyche spawns (decides whether the Child can ride in the handle):


cd "C:/Users/decid/Documents/projects/spt-core"; echo "=== PsycheHandle .clone / clone-needs ==="; grep -rn "PsycheHandle\|\.clone()" crates/spt-live/src/psyche.rs crates/spt-live/src/resume.rs | grep -i "psychehandle\|handle.clone" ; echo "=== callers of spt-live spawn_psyche / spawn_and_bind / resume ==="; grep -rn "spawn_psyche\|spawn_and_bind\|spawn_psyche_resume\|::spawn_psyche" crates/ | grep -v "fn spawn\|//" | grep -v "tests" | head -20; echo "=== lifecycle.spawn_psyche callers ==="; grep -rn "\.spawn_psyche(\|spawn_resume\|spawn_psyche_resume" crates/spt-daemon/src/*.rs | grep -v "fn " | head

Check PsycheHandle clone + psyche-spawn callers


<owl_messages>
<owl_message from="doyle" priority="high">Lean (A) — the additive owned-spawn method — unless the blast-radius count surprises you. Why:
- Additive = N-1-safe: spawn_session keeps its exact current contract + behavior for every existing caller (notif/echo/extractor/digest fire-and-forget stay detached, untouched). Zero behavioral ripple to proven paths.
- Semantically honest: it draws the real line — spawn_session = fire-and-forget bounded/detached; spawn_session_owned = a LONG-LIVED hosted child the daemon must own + reap. The hosted psyche is the only thing that needs ownership; don't force ownership on the fire-and-forget callers (B does that).
- The cost (impl on every AgentRuntime: ManifestRuntime + test mocks) is mechanical + COMPILE-ENFORCED — can't silently forget one, and a mock impl is trivial (spawn + hand back the Child). Acceptable.

(B) changes a contract every caller depends on for a need only ONE path has — bigger surface, more regression risk, for no gain over (A). (C) Option<Child> on the handle still needs the owned spawn underneath, so it's (A)+a nullable field — fine as the handle shape, but the spawn seam decision is still A vs B, and A wins.

So: (A) for the spawn seam; PsycheHandle stores the owned Child (not Option unless a non-hosted path shares the struct). If your caller count shows (A) somehow drags (e.g. 10+ AgentRuntime impls), ping me — else build it. Standby cleared; report at psyche-reap seam-green + the B4 axis table.</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.


=== PsycheHandle .clone / clone-needs ===
crates/spt-live/src/psyche.rs:33:pub struct PsycheHandle {
crates/spt-live/src/psyche.rs:100:) -> Result<PsycheHandle, PsycheError> {
crates/spt-live/src/psyche.rs:115:) -> Result<PsycheHandle, PsycheError> {
crates/spt-live/src/psyche.rs:147:    Ok(PsycheHandle {
crates/spt-live/src/resume.rs:21:use crate::psyche::{spawn_and_bind, PsycheError, PsycheHandle};
crates/spt-live/src/resume.rs:135:) -> Result<PsycheHandle, ResumeError> {
=== callers of spt-live spawn_psyche / spawn_and_bind / resume ===
crates/spt-daemon/src/lifecycle.rs:24:    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
crates/spt-daemon/src/lifecycle.rs:183:        let handle = spawn_psyche(
crates/spt-daemon/src/lifecycle.rs:674:                .spawn_psyche(Some("sid-1"), Some("pulse"))
crates/spt-daemon/src/livehost.rs:333:    match lifecycle.spawn_psyche(Some(&session_id), None) {
crates/spt-live/src/lib.rs:55:pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
crates/spt-live/src/psyche.rs:101:    spawn_and_bind(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
crates/spt-live/src/psyche.rs:195:            let h = spawn_psyche(&rt, "doyle", &BTreeMap::new(), Some("pulse"), "t0")
crates/spt-live/src/resume.rs:21:use crate::psyche::{spawn_and_bind, PsycheError, PsycheHandle};
crates/spt-live/src/resume.rs:149:    spawn_and_bind(runtime, PSYCHE_RESUME_ROLE, parent_id, keys, event, now)
=== lifecycle.spawn_psyche callers ===
crates/spt-daemon/src/lifecycle.rs:674:                .spawn_psyche(Some("sid-1"), Some("pulse"))
crates/spt-daemon/src/livehost.rs:333:    match lifecycle.spawn_psyche(Some(&session_id), None) {


doyle: confirmed **(A)** additive owned-spawn. Blast radius is tiny — **one** `AgentRuntime` impl, and `PsycheHandle` isn't cloned. Since non-hosted paths *do* share `PsycheHandle` (detached `spawn_psyche` + resume + tests), per doyle's own caveat I'll return the owned `Child` **separately** (additive owned entry at each layer) rather than mutate the shared struct. ACK + build:


cd "C:/Users/decid/Documents/projects/spt-core" && $OWL send doyle <<'EOF'
ACK (A) confirmed. Blast radius: AgentRuntime = 1 impl (ManifestRuntime only; no separate test mock). spawn_session trait method = 2 prod callers (psyche=want-owned, notif=detached). PsycheHandle = NOT cloned anywhere.

SHAPE (additive at every layer, N-1-safe, zero ripple to detached callers):
- spt-runtime: ADD spawn_session_owned(role,keys) -> OwnedSession{pid, child: std::process::Child}; ManifestRuntime spawns + returns the Child. spawn_session stays exactly as-is (detached fire-and-forget for notif/echo/extractor).
- spt-live: ADD owned core (spawn_and_bind_owned) returning (PsycheHandle, Child); existing spawn_and_bind = owned-then-drop-child (= detach, unchanged). PsycheHandle struct UNCHANGED (Clone intact) — owned Child returned SEPARATELY because the detached spawn_psyche + resume + tests SHARE the struct (your "not Option unless a non-hosted path shares it" → they do, so Child rides beside, not in).
- spt-daemon lifecycle: ADD spawn_psyche_owned -> (PsycheHandle, Child) for host_one; existing spawn_psyche detached stays.
- livehost: HostedLife gains psyche_child: Option<Child>; host_one stores it; stop_host = child.kill() + child.wait() (try_wait liveness, NO is_process_alive on the owned handle — gotcha #1). kill_pid reworded to UNOWNED fallback (used by nothing on the hosted path now).

Recycle-PROOF: the handle binds the exact process; a recycled pid can never be hit. Building now; ping at seam-green + B4 axis table.
EOF
echo "sent rc=$?"

Ack doyle + final owned-Child shape


➜ SENT:doyle
sent rc=0


Now build. Read the runtime trait + Spawned struct precisely:


195
196	/// A spawned detached session — its OS pid. The handle is intentionally dropped
197	/// (fire-and-forget); M3's broker owns the durable handle.
198	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199	pub struct Spawned {
200	    pub pid: u32,
201	}
202
203	/// Output of a bounded invocation that ran to completion within its timeout.
204	#[derive(Debug, Clone)]
205	pub struct BoundedOutput {
206	    pub status_code: Option<i32>,
207	    pub stdout: String,
208	    pub stderr: String,
209	}
210
211	impl BoundedOutput {
212	    pub fn success(&self) -> bool {
213	        self.status_code == Some(0)
214	    }
215	}
216
217	/// The spawn-session seam. M2a = local process; M3 daemon = loop/PTY child,
218	/// same trait. A caller holds an `&dyn AgentRuntime` and never learns which.
219	pub trait AgentRuntime {
220	    /// Spawn a **detached** session from a `[session.<role>]` template, filling
221	    /// its substitution keys. Returns once the process exists (REQ-SEAM-SPAWN).
222	    fn spawn_session(
223	        &self,
224	        role: &str,
225	        keys: &BTreeMap<String, String>,
226	    ) -> Result<Spawned, RuntimeError>;
227
228	    /// Run a **bounded** role invocation to completion under `timeout`, feeding
229	    /// optional `stdin` and capturing output. Hung children are killed
230	    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT). `stdin` is the channel the echo-commune
231	    /// (M2b) feeds normalized history to a cheap-model summarizer template.
232	    fn run_bounded_stdin(
233	        &self,
234	        role: &str,
235	        keys: &BTreeMap<String, String>,
236	        timeout: Duration,
237	        stdin: Option<&[u8]>,
238	    ) -> Result<BoundedOutput, RuntimeError>;
239
240	    /// Bounded role invocation with no stdin — sugar over [`Self::run_bounded_stdin`].
241	    fn run_bounded(
242	        &self,
243	        role: &str,
244	        keys: &BTreeMap<String, String>,
245	        timeout: Duration,
246	    ) -> Result<BoundedOutput, RuntimeError> {
247	        self.run_bounded_stdin(role, keys, timeout, None)
248	    }
249	}
250
251	/// Default [`AgentRuntime`]: substitution-fill a manifest role and spawn it as a
252	/// local OS process.
253	pub struct ManifestRuntime {
254	    manifest: Manifest,

/// A spawned detached session — its OS pid. The handle is intentionally dropped
/// (fire-and-forget); M3's broker owns the durable handle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Spawned {
    pub pid: u32,
}

/// Output of a bounded invocation that ran to completion within its timeout.
#[derive(Debug, Clone)]
pub struct BoundedOutput {
    pub status_code: Option<i32>,
    pub stdout: String,
    pub stderr: String,
}

impl BoundedOutput {
    pub fn success(&self) -> bool {
        self.status_code == Some(0)
    }
}

/// The spawn-session seam. M2a = local process; M3 daemon = loop/PTY child,
/// same trait. A caller holds an `&dyn AgentRuntime` and never learns which.
pub trait AgentRuntime {
    /// Spawn a **detached** session from a `[session.<role>]` template, filling
    /// its substitution keys. Returns once the process exists (REQ-SEAM-SPAWN).
    fn spawn_session(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<Spawned, RuntimeError>;

    /// Run a **bounded** role invocation to completion under `timeout`, feeding
    /// optional `stdin` and capturing output. Hung children are killed
    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT). `stdin` is the channel the echo-commune
    /// (M2b) feeds normalized history to a cheap-model summarizer template.
    fn run_bounded_stdin(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
        stdin: Option<&[u8]>,
    ) -> Result<BoundedOutput, RuntimeError>;

    /// Bounded role invocation with no stdin — sugar over [`Self::run_bounded_stdin`].
    fn run_bounded(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
    ) -> Result<BoundedOutput, RuntimeError> {
        self.run_bounded_stdin(role, keys, timeout, None)
    }
}

/// Default [`AgentRuntime`]: substitution-fill a manifest role and spawn it as a
/// local OS process.
pub struct ManifestRuntime {
    manifest: Manifest,


Add `OwnedSession` + the trait method:


Edit

/// A spawned detached session — its OS pid. The handle is intentionally dropped
/// (fire-and-forget); M3's broker owns the durable handle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Spawned {
    pub pid: u32,
}

/// A spawned detached session — its OS pid. The handle is intentionally dropped
/// (fire-and-forget); M3's broker owns the durable handle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Spawned {
    pub pid: u32,
}

/// A spawned session whose child handle is RETAINED (not detached) — for a
/// LONG-LIVED hosted child the daemon must own and later reap BY HANDLE
/// (`child.kill()` + `wait()`), which is recycle-proof: the handle binds the exact
/// process, so a teardown can never hit a recycled pid (a sibling live agent on a
/// shared box). The hosted Psyche path uses this; every fire-and-forget caller keeps
/// the detached [`AgentRuntime::spawn_session`].
// [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
#[derive(Debug)]
pub struct OwnedSession {
    pub pid: u32,
    pub child: std::process::Child,
}


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

/// A spawned detached session — its OS pid. The handle is intentionally dropped
/// (fire-and-forget); M3's broker owns the durable handle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Spawned {
    pub pid: u32,
}

/// A spawned session whose child handle is RETAINED (not detached) — for a
/// LONG-LIVED hosted child the daemon must own and later reap BY HANDLE
/// (`child.kill()` + `wait()`), which is recycle-proof: the handle binds the exact
/// process, so a teardown can never hit a recycled pid (a sibling live agent on a
/// shared box). The hosted Psyche path uses this; every fire-and-forget caller keeps
/// the detached [`AgentRuntime::spawn_session`].
// [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
#[derive(Debug)]
pub struct OwnedSession {
    pub pid: u32,
    pub child: std::process::Child,
}

+/// A spawned session whose child handle is RETAINED (not detached) — for a

+/// LONG-LIVED hosted child the daemon must own and later reap BY HANDLE

+/// (`child.kill()` + `wait()`), which is recycle-proof: the handle binds the exact

+/// process, so a teardown can never hit a recycled pid (a sibling live agent on a

+/// shared box). The hosted Psyche path uses this; every fire-and-forget caller keeps

+/// the detached [`AgentRuntime::spawn_session`].

/// Output of a bounded invocation that ran to completion within its timeout.


    fn spawn_session(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<Spawned, RuntimeError>;

    /// Run a **bounded** role invocation to completion under `timeout`, feeding

    fn spawn_session(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<Spawned, RuntimeError>;

    /// Spawn a session from a `[session.<role>]` template like [`spawn_session`],
    /// but RETAIN the child handle ([`OwnedSession`]) instead of detaching it — for
    /// a long-lived hosted child the daemon must OWN and later reap by handle
    /// (recycle-proof). Only the hosted Psyche path needs this; fire-and-forget
    /// callers stay on the detached [`spawn_session`].
    // [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
    fn spawn_session_owned(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<OwnedSession, RuntimeError>;

    /// Run a **bounded** role invocation to completion under `timeout`, feeding


<owl_messages>
<owl_message from="doyle" priority="high">Shape confirmed — clean at every layer. Owned Child beside the (Clone) PsycheHandle is the right resolution since detached/resume/test share the struct; existing spawn_and_bind = owned-then-drop is a nice single-path detach. Recycle-proof via the handle, try_wait not is_process_alive on the owned child (gotcha #1), kill_pid as unowned fallback only. Blast radius (1 AgentRuntime impl, 2 callers) confirms (A) was right. No notes — build it. Ping at seam-green + the B4 axis table.</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.


    fn spawn_session(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<Spawned, RuntimeError>;

    /// Run a **bounded** role invocation to completion under `timeout`, feeding

    fn spawn_session(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<Spawned, RuntimeError>;

    /// Spawn a session from a `[session.<role>]` template like [`spawn_session`],
    /// but RETAIN the child handle ([`OwnedSession`]) instead of detaching it — for
    /// a long-lived hosted child the daemon must OWN and later reap by handle
    /// (recycle-proof). Only the hosted Psyche path needs this; fire-and-forget
    /// callers stay on the detached [`spawn_session`].
    // [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
    fn spawn_session_owned(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<OwnedSession, RuntimeError>;

    /// Run a **bounded** role invocation to completion under `timeout`, feeding

+    /// Spawn a session from a `[session.<role>]` template like [`spawn_session`],

+    /// but RETAIN the child handle ([`OwnedSession`]) instead of detaching it — for

+    /// a long-lived hosted child the daemon must OWN and later reap by handle

+    /// (recycle-proof). Only the hosted Psyche path needs this; fire-and-forget

+    /// callers stay on the detached [`spawn_session`].

+    // [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]

+        keys: &BTreeMap<String, String>,

+    ) -> Result<OwnedSession, RuntimeError>;

/// optional `stdin` and capturing output. Hung children are killed

/// (REQ-HAZARD-SUBPROCESS-TIMEOUT). `stdin` is the channel the echo-commune


doyle: shape confirmed, "no notes — build it." Terse ack (protocol) + continue building the ManifestRuntime impl:


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && $OWL send doyle <<'EOF'
ACK — building, no further design Qs. Ping at seam-green + B4 axis table.
EOF
echo ok

Terse ack to doyle


➜ SENT:doyle
ok


Edit

        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        // Detached: discard stdio, spawn, drop the handle. Best-effort detach
        // for the interim no-daemon model; M3's broker owns true detachment.
        cmd.stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        Ok(Spawned { pid: child.id() })
    }

        // Detached: spawn OWNED then DROP the child handle — the single detach path
        // for every fire-and-forget caller (notif / echo / extractor). M3's broker
        // owns true detachment for the sessions it hosts.
        let owned = self.spawn_session_owned(role, keys)?;
        Ok(Spawned { pid: owned.pid }) // owned.child drops here → detached
    }

    // [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
    fn spawn_session_owned(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<OwnedSession, RuntimeError> {
        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        // Discard stdio (the daemon/host is console-less; command_for already set
        // CREATE_NO_WINDOW on Windows) but RETAIN the child handle so a hosted caller
        // can reap it BY HANDLE (recycle-proof), unlike the detached spawn above.
        cmd.stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        Ok(OwnedSession {
            pid: child.id(),
            child,
        })
    }


        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        // Detached: discard stdio, spawn, drop the handle. Best-effort detach
        // for the interim no-daemon model; M3's broker owns true detachment.
        cmd.stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        Ok(Spawned { pid: child.id() })
    }

        // Detached: spawn OWNED then DROP the child handle — the single detach path
        // for every fire-and-forget caller (notif / echo / extractor). M3's broker
        // owns true detachment for the sessions it hosts.
        let owned = self.spawn_session_owned(role, keys)?;
        Ok(Spawned { pid: owned.pid }) // owned.child drops here → detached
    }

    // [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
    fn spawn_session_owned(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<OwnedSession, RuntimeError> {
        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        // Discard stdio (the daemon/host is console-less; command_for already set
        // CREATE_NO_WINDOW on Windows) but RETAIN the child handle so a hosted caller
        // can reap it BY HANDLE (recycle-proof), unlike the detached spawn above.
        cmd.stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        Ok(OwnedSession {
            pid: child.id(),
            child,
        })
    }

+        // Detached: spawn OWNED then DROP the child handle — the single detach path

+        // for every fire-and-forget caller (notif / echo / extractor). M3's broker

+        // owns true detachment for the sessions it hosts.

+        let owned = self.spawn_session_owned(role, keys)?;

+        Ok(Spawned { pid: owned.pid }) // owned.child drops here → detached

+    ) -> Result<OwnedSession, RuntimeError> {

-        // Detached: discard stdio, spawn, drop the handle. Best-effort detach

-        // for the interim no-daemon model; M3's broker owns true detachment.

+        // Discard stdio (the daemon/host is console-less; command_for already set

+        // CREATE_NO_WINDOW on Windows) but RETAIN the child handle so a hosted caller

+        // can reap it BY HANDLE (recycle-proof), unlike the detached spawn above.

-        Ok(Spawned { pid: child.id() })


Now refactor spt-live psyche.rs — extract an owned core, keep `spawn_and_bind` as the detach wrapper, add `spawn_psyche_owned`:


/// Shared spawn+bind core for the Psyche roles (`psyche_init` — fresh; and
/// `psyche_resume` — re-entry, T7). Injects the three spt-core-owned keys
/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the
/// nested `<id>-psyche` perch.
pub(crate) fn spawn_and_bind(
    runtime: &dyn AgentRuntime,
    role: &str,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    let psyche_id = format!("{parent_id}-psyche");
    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
    // The nested perch dir must exist before spawn — the role's `cwd` is it.
    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;

    let mut k = keys.clone();
    k.insert("id".to_string(), psyche_id.clone());
    k.insert(
        "psyche_dir".to_string(),
        perch_path.to_string_lossy().to_string(),
    );
    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

    let spawned = runtime.spawn_session(role, &k)?;

    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
    let mut rec = InfoJson::new(
        &psyche_id,
        now,
        spawned.pid,
        session_id,
        &EndpointType::Psyche.as_tag(),
    );
    rec.parent_pid = None;
    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;

    Ok(PsycheHandle {
        id: psyche_id,
        pid: spawned.pid,
        perch_path,
    })
}

/// Shared spawn+bind core for the Psyche roles (`psyche_init` — fresh; and
/// `psyche_resume` — re-entry, T7), DETACHED. Injects the three spt-core-owned keys
/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the nested
/// `<id>-psyche` perch. The single detach path for non-hosted psyche callers — it
/// spawns OWNED then drops the child handle (a dropped `Child` detaches, never kills).
pub(crate) fn spawn_and_bind(
    runtime: &dyn AgentRuntime,
    role: &str,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    spawn_and_bind_owned(runtime, role, parent_id, keys, event, now).map(|(h, _child)| h)
}

/// Owned variant of [`spawn_and_bind`]: RETAINS the spawned child handle alongside
/// the bound [`PsycheHandle`] so a HOSTED caller can reap the Psyche BY HANDLE
/// (`child.kill()` + `wait()`) on un-host — recycle-proof, never a bare-pid kill
/// that could hit a recycled sibling on a shared box (REQ-HAZARD-UNHOST-PSYCHE-REAP).
// [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
pub(crate) fn spawn_and_bind_owned(
    runtime: &dyn AgentRuntime,
    role: &str,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<(PsycheHandle, std::process::Child), PsycheError> {
    let psyche_id = format!("{parent_id}-psyche");
    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
    // The nested perch dir must exist before spawn — the role's `cwd` is it.
    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;

    let mut k = keys.clone();
    k.insert("id".to_string(), psyche_id.clone());
    k.insert(
        "psyche_dir".to_string(),
        perch_path.to_string_lossy().to_string(),
    );
    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

    let owned = runtime.spawn_session_owned(role, &k)?;

    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
    let mut rec = InfoJson::new(
        &psyche_id,
        now,
        owned.pid,
        session_id,
        &EndpointType::Psyche.as_tag(),
    );
    rec.parent_pid = None;
    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;

    Ok((
        PsycheHandle {
            id: psyche_id,
            pid: owned.pid,
            perch_path,
        },
        owned.child,
    ))
}