1	# M4-D9 — activation sweep + cross-node E2E + M4 closeout (JIT plan)
2
3	**Status:** D9-1 COMPLETE 2026-06-04 (five CI-green sub-slices — the slice-1 body grew
4	attach/xfer arms and split for atomicity):
5	- D9-1-1 `ea73c9a` — inbound dispatch (sync/update/notif/wan arms; `initiated_locally`
6	  on the stream table; first-record shape demux).
7	- D9-1-2 `822d3ce` — RegistryHost (gated inbound merge + owlery advertisement + atomic
8	  snapshots) + peer pump (registry/notif push, anti-entropy sync pull with
9	  registry-derived bootstrap refs) + `daemon.json` knobs + `addr_for_node_hex`.
10	- D9-1-3 `b15cf2c` — production VerifyPolicy (`release-keys.json`, fail-closed) +
11	  update pump leg (stage + consent notif, REQ-UPD-4 gated default).
12	- D9-1-4 `af7f394` — `spt send` WAN leg (snapshot-resolved, refuse-and-qualify;
13	  deferred stays local-only, documented seam).
14	- D9-1-5 `0dd85d5` — attach/xfer dispatch arms: `SpawnReq.endpoint` label + broker
15	  `sessions` IPC = the attach gate subject (table-resolved, never wire); xfer wire
16	  names the gating endpoint (rule set wire-named, origin handshake-proven).
17	Notes for later slices: the in-pump Psyche reconcile turn has no daemon-hosted
18	per-agent runtime to ride (the 7.4 fan-out seam) — conflicts surface loud via
19	`PumpHooks::on_pull`, the turn stays at activation paths; binary APPLY orchestration
20	after a staged+consented update remains the M3c machinery's seam (not pump scope).
21
22	**Drafted** 2026-06-04 at D9 start. Design forks resolved with user 2026-06-04:
23	(1) **two-host posture** = env-gated int tests + manual runbook on the real rig — no
24	cross-runner CI choreography (a cross-runner job is a documented M5 seam);
25	(2) **REQ-INST-14 and REQ-INST-15 both activate and build in D9** (the full V1-mid line);
26	(3) **REQ-PAIR-7** (subnet icon, GUI-only consumer) defers to the GUI milestone —
27	`required_stages` stays `[]`, documented seam.
28	Authored against M4-PLAN §D9, ADR-0003 (red-team #9), ADR-0010 (home subnet / fork),
29	ADR-0013 (sync + reconcile), and the deferred-to-D9 ledger accumulated across D5–D8.
30
31	## Goal
32
33	M4 exits real: every production trigger loop deferred from D5/D6/D7/D8 wired into the
34	daemon lifecycle (the subnet *self-drives* — sync, update, notif, reconcile fire on
35	their own, not only under test harnesses); the dormant/suspended resting model built and
36	**budgeted on real adapters** (red-team #9 closed with numbers); resource advertisement
37	+ immutable-home/fork shipped (REQ-INST-3, -4, -14, -15); the two-host proof demoed on
38	the rig (HFENDULEAM ↔ gravity-linux) with `[int->REQ-*]` evidence; the gh-repo interim
39	retired; every M4 req's `required_stages` satisfied; CI matrix green.
40
41	## The deferred-to-D9 ledger (all land here)
42
43	| Item | Deferred at | Lands in |
44	|------|-------------|----------|
45	| Production trigger loops (sync notify/anti-entropy, update check, notif feed pump, post-sync reconcile election) | D5/D6/D7/D8 | D9-1 |
46	| Production `VerifyPolicy` assembly (trusted-key store, channel pin, running version) | D7 | D9-1 |
47	| `spt send` WAN leg CLI wiring | D5 | D9-1 |
48	| Wake-edge (state→active) resurface hook | D8 | D9-2 |
49	| Dormancy resource budget + warm/cold policy (red-team #9) | ADR-0003 | D9-2/D9-3 |
50	| REQ-NOTIF / sync / update / msg two-host int legs | D5–D8 | D9-6 |
51	| gh-repo interim sync retirement | D6 | D9-6 |
52	| Relay rendezvous routing (D2f Q1) | D2f | **M5** (document, not build — no consumer until shells) |
53	| REQ-PAIR-7 subnet icon | D2 | **GUI milestone** (user 2026-06-04) |
54	| Adapter-payload propagation; `p-` tier per-file visibility filtering | D6/D7 | **post-v1** (unchanged) |
55
56	## Decisions (locked)
57
58	- **Two-host = env-gated tests + runbook.** Int tests gate on `SPT_TWO_HOST=1`
59	  (+ peer address/role env), skip silently otherwise — CI never runs them; the rig run
60	  is manual, scripted by `docs/TWO-HOST-RUNBOOK.md`, evidence tags live **on the gated
61	  tests** (real runs on real hosts). CI keeps the loopback int coverage it already has.
62	- **Trigger loops are thin calls over existing drivers** (`select_refs`/`request_sync`,
63	  `request_update`/`serve_update`, D8-2 notif emit/apply, `reconcile_after_sync`) —
64	  D9 wires *when*, the drivers already own *how*. Same posture as every D5–D8 deferral
65	  note. Per-loop period/enable knobs in `DaemonConfig`; loops named, observable, and
66	  stoppable like `run_pulse_loop`.
67	- **Pump loops journal with `None`** (gotcha #1) and snapshot stream tables before
68	  opening requester streams (gotcha #9) — inherited test discipline, now production
69	  discipline.
70	- **KH 7.4 activates when it binds:** transition echo communes (D9-2) are per-agent
71	  LLM-bound calls scheduled by the daemon — the moment the daemon hosts more than one
72	  agent's bounded call, `REQ-HAZARD-DAEMON-SCHED-NONBLOCKING` activates and the calls
73	  run off the shared scheduler.
74	- **Registry `Status` gains `Suspended`.** CONTEXT.md's registry status is
75	  active/dormant/suspended/offline; the wire enum is 3-state today. Additive variant,
76	  pre-release fleet — no compat machinery; the NDJSON decode posture (unknown-kind
77	  skip) is already the registry record's forward-compat story.
78	- **Resource blurb = `Option<String>` on the registry `Instance`** — rides the existing
79	  epoch lease + `RegistryUpdate` replication. No new record kind, no new store, no new
80	  merge surface (the lease orders blurb updates like any instance update). The
81	  "subnet resource registry" is a **projection**, exactly as CONTEXT.md defines it.
82	- **Fork = copy-then-diverge, never re-home** (ADR-0010): new identity, one-time
83	  BranchStore tier copy, join-time collision check, no ongoing sync, source untouched
84	  unless `--delete-source`.
85	- **Suspended is node-local mechanics, registry-advertised state**: dormant = harness
86	  session alive (warm); suspended = session closed, resume-on-wake (cold). Warm stays
87	  the default; auto-suspend ships **opt-in, default OFF**, global → node → endpoint
88	  override chain (CONTEXT.md policy, confirmed or amended by D9-3 numbers).
89
90	## Slices (each CI-green before the next)
91
92	1. **D9-1 — production trigger loops + VerifyPolicy assembly + `spt send` WAN CLI.**
93	   - `spt-daemon/src/peerloop.rs` (new): the per-subnet peer pump host. Holds/refreshes
94	     trusted-peer connections via `NetHost` (registry + trust-store sourced), then runs
95	     the pumps as scheduled thin calls:
96	     a. **sync** — context-write notify → peers pull; periodic anti-entropy
97	        (`select_refs` + `request_sync`); dormant→active freshness pull *seam* (the
98	        trigger arrives with D9-2's wake edge).
99	     b. **update** — periodic trusted-peer query via `request_update` under the
100	        **production `VerifyPolicy`**: trusted-key store, channel pin, running version
101	        (assembled here, the D7 deferral). Serve side already gated.
102	     c. **notif** — feed pump: local insert/dismiss → emit to subnet member conns;
103	        inbound apply (D8-2 drivers).
104	     d. **post-sync reconcile election** — `reconcile_after_sync` at pump completion
105	        (ADR-0013 wiring already exists; the pump calls it).
106	   - `DaemonConfig` knobs: `sync_period`, `update_check_period`, `notif_pump_period`
107	     (+ per-loop enable flags). Defaults conservative; document in MANIFEST/STORAGE
108	     as applicable.
109	   - `spt send` WAN leg: the CLI grows the off-node arm transparently (D5's deferral —
110	     resolution policy picks the instance; `id@node` pins).
111	   - Loopback E2E (`tests/peerloop.rs`): **the loops fire on their own** across two
112	     brokers — a context write on A appears on B with no manual driver call; an
113	     undelivered notif pumps over; a staged newer release self-heals B. Gotchas #1/#9.
114	   - Evidence: `[impl->REQ-INST-5]`/`[impl->REQ-UPD-1]`/`[impl->REQ-NOTIF-1]` on the
115	     respective pump arms (production wiring of already-activated reqs — tags on the
116	     real loop code, stages unchanged).
117
118	2. **D9-2 — resting-state model + transition echo + wake-edge resurface.**
119	   Activate **REQ-INST-3 → `[impl, unit]`**, **REQ-INST-4 → `[impl, unit]`**.
120	   - Explicit instance state machine (daemon-owned): **active → dormant** (driver
121	     detach, or attention shift — another instance of the id becomes
122	     most-recently-interacted via the existing `touch_active` recency);
123	     **dormant → suspended** (`spt suspend` / shell `api owner-shutdown`, or opt-in
124	     `auto-suspend-after` counted from dormancy onset; global → node → endpoint
125	     override chain, default OFF); **wake** = re-activation in place (state's there).
126	   - Registry advertisement follows transitions: `Status::Suspended` variant added;
127	     epoch-bumped updates on every transition (the lease orders them).
128	   - **Transition echo commune** on active → (dormant|suspended): `run_echo_commune`
129	     at the transition edge (KH 3.3 ordering — echo before any teardown), syncs to
130	     whichever instance activates next. `[impl->REQ-INST-4]`.
131	   - **Wake edge:** state→active transition fires `resurface_at_boundary(Wake)` (the
132	     D8 deferral — edge detection is real now that the state machine exists) and the
133	     dormant→active **freshness pull** (D9-1's seam consumes it).
134	   - KH 7.4: if the daemon now schedules >1 agent's bounded echo call, activate
135	     `REQ-HAZARD-DAEMON-SCHED-NONBLOCKING` and run the calls off the shared scheduler.
136	   - REQ-INST-6 (deferred-message instance-state gate) stays inactive — document the
137	     seam (the state machine is its precondition; the gate is M5).
138	   - Unit: transition table (detach, attention-shift, suspend, auto-suspend threshold,
139	     wake), echo-fires-once per edge, registry status follows, wake resurfaces.
140
141	3. **D9-3 — dormancy resource budget + warm/cold policy lock (red-team #9 gate).**
142	   Closes REQ-INST-3's open policy question with **measured numbers**.
143	   - Env-gated measurement harness (`SPT_BUDGET=1`, ignored in CI): spawn N dormant
144	     sessions on a real adapter on the rig, sample RSS / handle count / idle CPU per
145	     dormant seat and per suspended seat (the delta IS the warm cost).
146	   - Record in `docs/DORMANCY-BUDGET.md`: methodology, numbers per host
147	     (HFENDULEAM Windows + gravity Linux), the resulting policy.
148	   - Lock policy: warm default confirmed (or amended — the numbers decide),
149	     auto-suspend default OFF globally, node-overridable (constrained nodes may
150	     default ON), thresholds as config knobs. Amend ADR-0003 (#9 closed: "measured
151	     budget + warm/cold policy") + CONTEXT.md. `<!-- [doc->REQ-INST-3] -->`.
152
153	4. **D9-4 — REQ-INST-14 resource advertisement → `[doc, impl, unit]`.**
154	   - `resources: Option<String>` on registry `Instance` (additive, serde-default —
155	     absent = no blurb). Epoch lease orders updates; replication = existing
156	     `RegistryUpdate` path untouched.
157	   - Both-authored: config seeds the default blurb; `spt resources set <text>`
158	     (the agent refines its own at runtime) + `spt resources show [id]`.
159	   - Projection view: `spt resources list [--subnet S]` = `(id, node, blurb)` over
160	     **visible** rows only — reuses `resolve_visible`-path filtering, so exclusion
161	     leaks nothing by construction (ADR-0006 §6).
162	   - Doc tag in CONTEXT.md's existing *resource advertisement* entry; unit: blurb
163	     advertises/updates under the lease, hidden endpoint never appears in the
164	     projection, absent-blurb rows render clean.
165
166	5. **D9-5 — REQ-INST-15 immutable home subnet + `spt fork` → `[doc, impl, unit]`.**
167	   - **Home assignment at creation** (persisted on the endpoint record, identity dir):
168	     sole-subnet node → auto; multi-subnet node → must be specified (refuse-and-qualify,
169	     no silent guess); unpaired node → local-only until first join sets home.
170	     **No setter exists** — immutability by construction (ADR-0010).
171	   - Creation seeds `sync_subnets = [home]` (the `visibility.rs` documented seam, now
172	     real) + the adapter-at-creation rule (chosen from registered hostable adapters;
173	     changed only via launch/resume-under-new).
174	   - `spt fork <src> <new-id> --subnet <target> [--delete-source]`: new identity
175	     (fresh perch), join-time collision check in the target (default = source's bare
176	     name if free, else refuse with rename guidance), **one-time copy** of the live +
177	     project context tiers (BranchStore seed commit — copied-then-independent, no
178	     ongoing sync), fork's home = target. Source untouched unless `--delete-source`.
179	     Cross-node fork composes with instantiate-anywhere's consent gate — **same-node
180	     only in v1** (the remote arm is M5, with the gate).
181	   - ADR-0010 doc tags; unit: home auto/ask/local-only matrix, fork collision refusal,
182	     fork copies-then-diverges (a post-fork write on the source never reaches the fork),
183	     delete-source removes exactly the source.
184
185	6. **D9-6 — two-host E2E + runbook + gh-repo interim retirement.**
186	   - `SPT_TWO_HOST=1`-gated int tests (skip silently without the env; role/peer-addr
187	     via env): the M4-PLAN §D9b ladder — **pair → register → cross-node message →
188	     remote-drive → Psyche sync → update self-heal**, plus the notif cross-node leg
189	     (insert on A → fires on B; dismiss on B → replicates to A). Int evidence tags on
190	     these tests: `[int->REQ-NET-1]`, `[int->REQ-INST-5]`, `[int->REQ-UPD-1]`,
191	     `[int->REQ-NOTIF-1]` (+ pairing/registry reqs as the ladder touches them).
192	   - `docs/TWO-HOST-RUNBOOK.md`: the exact rig procedure (HFENDULEAM ↔ gravity-linux),
193	     env setup, run order, what green looks like; evidence (run output) captured in a
194	     runbook appendix when the rig run happens.
195	   - **Run the runbook on the rig.** Activate the touched reqs' `int` stages only
196	     after the real run is green.
197	   - **Retire the gh-repo interim**: the sister gh-repo-sync skill stands down after
198	     the proof (the D6 exit condition); `psyche-sync-setup` remains for the hub-mode
199	     seam but is no longer the sync path — note in CONTEXT.md.
200	   - Flake-ledger discipline: any rig flake gets a ledger entry, not a shrug.
201
202	7. **D9-7 — closeout sweep.**
203	   - Full `traceable-reqs.toml` audit: every M4 req either satisfied at its
204	     `required_stages` or explicitly deferred with a rationale comment (rule 5 —
205	     nothing silently pre-failed). `traceable-reqs check` green from repo root.
206	   - ROADMAP.md: M4 delivered (scope line met; deferred items annotated to M5/post-v1).
207	   - CONTEXT.md glossary amendments accumulated across D9 (resting states' final
208	     policy numbers, fork, resource advertisement, retired interim).
209	   - M4-PLAN + this plan get plan-status COMPLETE; CI matrix green
210	     (ubuntu + windows + traceability) verified FINAL via `gh run list` (gotcha #7).
211
212	## Out of scope (unchanged by D9)
213
214	- REQ-INST-6 deferred-message instance-state gate — M5 (seam documented in D9-2).
215	- REQ-PAIR-7 subnet icon — GUI milestone (user 2026-06-04).
216	- Cross-runner CI two-host job — M5 seam (runbook is the v1 proof).
217	- Remote fork arm (cross-node `spt fork`) — M5, lands with instantiate-anywhere's
218	  consent UX.
219	- REQ-REACH-2, REQ-EP-5, PresenceChannel full impl, cross-user generalizations —
220	  M5 / post-v1 per M4-PLAN §Out.
221	- Relay rendezvous routing (D2f Q1) — M5, no consumer until shells.
222

# M4-D9 — activation sweep + cross-node E2E + M4 closeout (JIT plan)

**Status:** D9-1 COMPLETE 2026-06-04 (five CI-green sub-slices — the slice-1 body grew
attach/xfer arms and split for atomicity):
- D9-1-1 `ea73c9a` — inbound dispatch (sync/update/notif/wan arms; `initiated_locally`
  on the stream table; first-record shape demux).
- D9-1-2 `822d3ce` — RegistryHost (gated inbound merge + owlery advertisement + atomic
  snapshots) + peer pump (registry/notif push, anti-entropy sync pull with
  registry-derived bootstrap refs) + `daemon.json` knobs + `addr_for_node_hex`.
- D9-1-3 `b15cf2c` — production VerifyPolicy (`release-keys.json`, fail-closed) +
  update pump leg (stage + consent notif, REQ-UPD-4 gated default).
- D9-1-4 `af7f394` — `spt send` WAN leg (snapshot-resolved, refuse-and-qualify;
  deferred stays local-only, documented seam).
- D9-1-5 `0dd85d5` — attach/xfer dispatch arms: `SpawnReq.endpoint` label + broker
  `sessions` IPC = the attach gate subject (table-resolved, never wire); xfer wire
  names the gating endpoint (rule set wire-named, origin handshake-proven).
Notes for later slices: the in-pump Psyche reconcile turn has no daemon-hosted
per-agent runtime to ride (the 7.4 fan-out seam) — conflicts surface loud via
`PumpHooks::on_pull`, the turn stays at activation paths; binary APPLY orchestration
after a staged+consented update remains the M3c machinery's seam (not pump scope).

**Drafted** 2026-06-04 at D9 start. Design forks resolved with user 2026-06-04:
(1) **two-host posture** = env-gated int tests + manual runbook on the real rig — no
cross-runner CI choreography (a cross-runner job is a documented M5 seam);
(2) **REQ-INST-14 and REQ-INST-15 both activate and build in D9** (the full V1-mid line);
(3) **REQ-PAIR-7** (subnet icon, GUI-only consumer) defers to the GUI milestone —
`required_stages` stays `[]`, documented seam.
Authored against M4-PLAN §D9, ADR-0003 (red-team #9), ADR-0010 (home subnet / fork),
ADR-0013 (sync + reconcile), and the deferred-to-D9 ledger accumulated across D5–D8.

## Goal

M4 exits real: every production trigger loop deferred from D5/D6/D7/D8 wired into the
daemon lifecycle (the subnet *self-drives* — sync, update, notif, reconcile fire on
their own, not only under test harnesses); the dormant/suspended resting model built and
**budgeted on real adapters** (red-team #9 closed with numbers); resource advertisement
+ immutable-home/fork shipped (REQ-INST-3, -4, -14, -15); the two-host proof demoed on
the rig (HFENDULEAM ↔ gravity-linux) with `[int->REQ-*]` evidence; the gh-repo interim
retired; every M4 req's `required_stages` satisfied; CI matrix green.

## The deferred-to-D9 ledger (all land here)

| Item | Deferred at | Lands in |
|------|-------------|----------|
| Production trigger loops (sync notify/anti-entropy, update check, notif feed pump, post-sync reconcile election) | D5/D6/D7/D8 | D9-1 |
| Production `VerifyPolicy` assembly (trusted-key store, channel pin, running version) | D7 | D9-1 |
| `spt send` WAN leg CLI wiring | D5 | D9-1 |
| Wake-edge (state→active) resurface hook | D8 | D9-2 |
| Dormancy resource budget + warm/cold policy (red-team #9) | ADR-0003 | D9-2/D9-3 |
| REQ-NOTIF / sync / update / msg two-host int legs | D5–D8 | D9-6 |
| gh-repo interim sync retirement | D6 | D9-6 |
| Relay rendezvous routing (D2f Q1) | D2f | **M5** (document, not build — no consumer until shells) |
| REQ-PAIR-7 subnet icon | D2 | **GUI milestone** (user 2026-06-04) |
| Adapter-payload propagation; `p-` tier per-file visibility filtering | D6/D7 | **post-v1** (unchanged) |

## Decisions (locked)

- **Two-host = env-gated tests + runbook.** Int tests gate on `SPT_TWO_HOST=1`
  (+ peer address/role env), skip silently otherwise — CI never runs them; the rig run
  is manual, scripted by `docs/TWO-HOST-RUNBOOK.md`, evidence tags live **on the gated
  tests** (real runs on real hosts). CI keeps the loopback int coverage it already has.
- **Trigger loops are thin calls over existing drivers** (`select_refs`/`request_sync`,
  `request_update`/`serve_update`, D8-2 notif emit/apply, `reconcile_after_sync`) —
  D9 wires *when*, the drivers already own *how*. Same posture as every D5–D8 deferral
  note. Per-loop period/enable knobs in `DaemonConfig`; loops named, observable, and
  stoppable like `run_pulse_loop`.
- **Pump loops journal with `None`** (gotcha #1) and snapshot stream tables before
  opening requester streams (gotcha #9) — inherited test discipline, now production
  discipline.
- **KH 7.4 activates when it binds:** transition echo communes (D9-2) are per-agent
  LLM-bound calls scheduled by the daemon — the moment the daemon hosts more than one
  agent's bounded call, `REQ-HAZARD-DAEMON-SCHED-NONBLOCKING` activates and the calls
  run off the shared scheduler.
- **Registry `Status` gains `Suspended`.** CONTEXT.md's registry status is
  active/dormant/suspended/offline; the wire enum is 3-state today. Additive variant,
  pre-release fleet — no compat machinery; the NDJSON decode posture (unknown-kind
  skip) is already the registry record's forward-compat story.
- **Resource blurb = `Option<String>` on the registry `Instance`** — rides the existing
  epoch lease + `RegistryUpdate` replication. No new record kind, no new store, no new
  merge surface (the lease orders blurb updates like any instance update). The
  "subnet resource registry" is a **projection**, exactly as CONTEXT.md defines it.
- **Fork = copy-then-diverge, never re-home** (ADR-0010): new identity, one-time
  BranchStore tier copy, join-time collision check, no ongoing sync, source untouched
  unless `--delete-source`.
- **Suspended is node-local mechanics, registry-advertised state**: dormant = harness
  session alive (warm); suspended = session closed, resume-on-wake (cold). Warm stays
  the default; auto-suspend ships **opt-in, default OFF**, global → node → endpoint
  override chain (CONTEXT.md policy, confirmed or amended by D9-3 numbers).

## Slices (each CI-green before the next)

1. **D9-1 — production trigger loops + VerifyPolicy assembly + `spt send` WAN CLI.**
   - `spt-daemon/src/peerloop.rs` (new): the per-subnet peer pump host. Holds/refreshes
     trusted-peer connections via `NetHost` (registry + trust-store sourced), then runs
     the pumps as scheduled thin calls:
     a. **sync** — context-write notify → peers pull; periodic anti-entropy
        (`select_refs` + `request_sync`); dormant→active freshness pull *seam* (the
        trigger arrives with D9-2's wake edge).
     b. **update** — periodic trusted-peer query via `request_update` under the
        **production `VerifyPolicy`**: trusted-key store, channel pin, running version
        (assembled here, the D7 deferral). Serve side already gated.
     c. **notif** — feed pump: local insert/dismiss → emit to subnet member conns;
        inbound apply (D8-2 drivers).
     d. **post-sync reconcile election** — `reconcile_after_sync` at pump completion
        (ADR-0013 wiring already exists; the pump calls it).
   - `DaemonConfig` knobs: `sync_period`, `update_check_period`, `notif_pump_period`
     (+ per-loop enable flags). Defaults conservative; document in MANIFEST/STORAGE
     as applicable.
   - `spt send` WAN leg: the CLI grows the off-node arm transparently (D5's deferral —
     resolution policy picks the instance; `id@node` pins).
   - Loopback E2E (`tests/peerloop.rs`): **the loops fire on their own** across two
     brokers — a context write on A appears on B with no manual driver call; an
     undelivered notif pumps over; a staged newer release self-heals B. Gotchas #1/#9.
   - Evidence: `[impl->REQ-INST-5]`/`[impl->REQ-UPD-1]`/`[impl->REQ-NOTIF-1]` on the
     respective pump arms (production wiring of already-activated reqs — tags on the
     real loop code, stages unchanged).

2. **D9-2 — resting-state model + transition echo + wake-edge resurface.**
   Activate **REQ-INST-3 → `[impl, unit]`**, **REQ-INST-4 → `[impl, unit]`**.
   - Explicit instance state machine (daemon-owned): **active → dormant** (driver
     detach, or attention shift — another instance of the id becomes
     most-recently-interacted via the existing `touch_active` recency);
     **dormant → suspended** (`spt suspend` / shell `api owner-shutdown`, or opt-in
     `auto-suspend-after` counted from dormancy onset; global → node → endpoint
     override chain, default OFF); **wake** = re-activation in place (state's there).
   - Registry advertisement follows transitions: `Status::Suspended` variant added;
     epoch-bumped updates on every transition (the lease orders them).
   - **Transition echo commune** on active → (dormant|suspended): `run_echo_commune`
     at the transition edge (KH 3.3 ordering — echo before any teardown), syncs to
     whichever instance activates next. `[impl->REQ-INST-4]`.
   - **Wake edge:** state→active transition fires `resurface_at_boundary(Wake)` (the
     D8 deferral — edge detection is real now that the state machine exists) and the
     dormant→active **freshness pull** (D9-1's seam consumes it).
   - KH 7.4: if the daemon now schedules >1 agent's bounded echo call, activate
     `REQ-HAZARD-DAEMON-SCHED-NONBLOCKING` and run the calls off the shared scheduler.
   - REQ-INST-6 (deferred-message instance-state gate) stays inactive — document the
     seam (the state machine is its precondition; the gate is M5).
   - Unit: transition table (detach, attention-shift, suspend, auto-suspend threshold,
     wake), echo-fires-once per edge, registry status follows, wake resurfaces.

3. **D9-3 — dormancy resource budget + warm/cold policy lock (red-team #9 gate).**
   Closes REQ-INST-3's open policy question with **measured numbers**.
   - Env-gated measurement harness (`SPT_BUDGET=1`, ignored in CI): spawn N dormant
     sessions on a real adapter on the rig, sample RSS / handle count / idle CPU per
     dormant seat and per suspended seat (the delta IS the warm cost).
   - Record in `docs/DORMANCY-BUDGET.md`: methodology, numbers per host
     (HFENDULEAM Windows + gravity Linux), the resulting policy.
   - Lock policy: warm default confirmed (or amended — the numbers decide),
     auto-suspend default OFF globally, node-overridable (constrained nodes may
     default ON), thresholds as config knobs. Amend ADR-0003 (#9 closed: "measured
     budget + warm/cold policy") + CONTEXT.md. `<!-- [doc->REQ-INST-3] -->`.

4. **D9-4 — REQ-INST-14 resource advertisement → `[doc, impl, unit]`.**
   - `resources: Option<String>` on registry `Instance` (additive, serde-default —
     absent = no blurb). Epoch lease orders updates; replication = existing
     `RegistryUpdate` path untouched.
   - Both-authored: config seeds the default blurb; `spt resources set <text>`
     (the agent refines its own at runtime) + `spt resources show [id]`.
   - Projection view: `spt resources list [--subnet S]` = `(id, node, blurb)` over
     **visible** rows only — reuses `resolve_visible`-path filtering, so exclusion
     leaks nothing by construction (ADR-0006 §6).
   - Doc tag in CONTEXT.md's existing *resource advertisement* entry; unit: blurb
     advertises/updates under the lease, hidden endpoint never appears in the
     projection, absent-blurb rows render clean.

5. **D9-5 — REQ-INST-15 immutable home subnet + `spt fork` → `[doc, impl, unit]`.**
   - **Home assignment at creation** (persisted on the endpoint record, identity dir):
     sole-subnet node → auto; multi-subnet node → must be specified (refuse-and-qualify,
     no silent guess); unpaired node → local-only until first join sets home.
     **No setter exists** — immutability by construction (ADR-0010).
   - Creation seeds `sync_subnets = [home]` (the `visibility.rs` documented seam, now
     real) + the adapter-at-creation rule (chosen from registered hostable adapters;
     changed only via launch/resume-under-new).
   - `spt fork <src> <new-id> --subnet <target> [--delete-source]`: new identity
     (fresh perch), join-time collision check in the target (default = source's bare
     name if free, else refuse with rename guidance), **one-time copy** of the live +
     project context tiers (BranchStore seed commit — copied-then-independent, no
     ongoing sync), fork's home = target. Source untouched unless `--delete-source`.
     Cross-node fork composes with instantiate-anywhere's consent gate — **same-node
     only in v1** (the remote arm is M5, with the gate).
   - ADR-0010 doc tags; unit: home auto/ask/local-only matrix, fork collision refusal,
     fork copies-then-diverges (a post-fork write on the source never reaches the fork),
     delete-source removes exactly the source.

6. **D9-6 — two-host E2E + runbook + gh-repo interim retirement.**
   - `SPT_TWO_HOST=1`-gated int tests (skip silently without the env; role/peer-addr
     via env): the M4-PLAN §D9b ladder — **pair → register → cross-node message →
     remote-drive → Psyche sync → update self-heal**, plus the notif cross-node leg
     (insert on A → fires on B; dismiss on B → replicates to A). Int evidence tags on
     these tests: `[int->REQ-NET-1]`, `[int->REQ-INST-5]`, `[int->REQ-UPD-1]`,
     `[int->REQ-NOTIF-1]` (+ pairing/registry reqs as the ladder touches them).
   - `docs/TWO-HOST-RUNBOOK.md`: the exact rig procedure (HFENDULEAM ↔ gravity-linux),
     env setup, run order, what green looks like; evidence (run output) captured in a
     runbook appendix when the rig run happens.
   - **Run the runbook on the rig.** Activate the touched reqs' `int` stages only
     after the real run is green.
   - **Retire the gh-repo interim**: the sister gh-repo-sync skill stands down after
     the proof (the D6 exit condition); `psyche-sync-setup` remains for the hub-mode
     seam but is no longer the sync path — note in CONTEXT.md.
   - Flake-ledger discipline: any rig flake gets a ledger entry, not a shrug.

7. **D9-7 — closeout sweep.**
   - Full `traceable-reqs.toml` audit: every M4 req either satisfied at its
     `required_stages` or explicitly deferred with a rationale comment (rule 5 —
     nothing silently pre-failed). `traceable-reqs check` green from repo root.
   - ROADMAP.md: M4 delivered (scope line met; deferred items annotated to M5/post-v1).
   - CONTEXT.md glossary amendments accumulated across D9 (resting states' final
     policy numbers, fork, resource advertisement, retired interim).
   - M4-PLAN + this plan get plan-status COMPLETE; CI matrix green
     (ubuntu + windows + traceability) verified FINAL via `gh run list` (gotcha #7).

## Out of scope (unchanged by D9)

- REQ-INST-6 deferred-message instance-state gate — M5 (seam documented in D9-2).
- REQ-PAIR-7 subnet icon — GUI milestone (user 2026-06-04).
- Cross-runner CI two-host job — M5 seam (runbook is the v1 proof).
- Remote fork arm (cross-node `spt fork`) — M5, lands with instantiate-anywhere's
  consent UX.
- REQ-REACH-2, REQ-EP-5, PresenceChannel full impl, cross-user generalizations —
  M5 / post-v1 per M4-PLAN §Out.
- Relay rendezvous routing (D2f Q1) — M5, no consumer until shells.


Found 9 files
M4-D9-PLAN.md
M4-D8-PLAN.md
traceable-reqs.toml
crates\spt-daemon\src\notif.rs
crates\spt-daemon\src\consent.rs
crates\spt-daemon\src\lifecycle.rs
crates\spt-store\src\info.rs
M3c-PLAN.md
ROADMAP.md


crates\spt-net\src\net\registry.rs:52:pub enum Status {


Found 10 files
M4-D9-PLAN.md
crates\spt-daemon\src\lib.rs
M4-D8-PLAN.md
crates\spt\src\api\reporting.rs
crates\spt-daemon\src\notif.rs
crates\spt-live\src\lib.rs
crates\spt-daemon\src\lifecycle.rs
crates\spt-live\src\pulse.rs
M3b-PLAN.md
crates\spt-live\src\echo.rs


1	//! The brain's LiveAgent lifecycle host (B3) — the consolidated re-host of the
2	//! interim `api listen` runner's live half.
3	//!
4	//! In the no-daemon interim model the long-lived `spt api listen` process hosted
5	//! the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest, echo fires,
6	//! graceful signoff) directly in the agent's session. Consolidation
7	//! (REQ-DAEMON-1) moves that orchestration **down into `spt-daemon`** so the
8	//! brain runs it as scheduled in-process loops, with a durable, configurable
9	//! pulse period ([`crate::config::DaemonConfig`]) instead of the interim 5s
10	//! constant. The `spt` binary's `LiveHost` becomes a thin shim over this type —
11	//! a re-host, not a rewrite: the heavy lifting stays in the already-pure
12	//! [`spt_live`] seams ([`spawn_psyche`], [`ingest_drops`], [`run_echo_commune`],
13	//! [`graceful_signoff`], [`sweep_stale_signoff`]); the ordering invariants
14	//! (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-sweep 3.2) live
15	//! where they always have.
16	// [impl->REQ-DAEMON-1]
17
18	use std::collections::BTreeMap;
19	use std::path::{Path, PathBuf};
20	use std::sync::atomic::{AtomicBool, Ordering};
21	use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23	use spt_live::{
24	    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
25	    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
26	};
27	use spt_runtime::{Manifest, ManifestRuntime};
28	use spt_store::perch::{self, ParentHint};
29	use spt_store::proc::is_process_alive;
30
31	use crate::config::DaemonConfig;
32
33	/// Bound for a bounded history/echo invocation in the host (carried from M2b).
34	const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);
35
36	/// Wall-clock now in epoch millis — precedence-marker stamp for ingested writes.
37	fn now_ms() -> u64 {
38	    SystemTime::now()
39	        .duration_since(UNIX_EPOCH)
40	        .map(|d| d.as_millis() as u64)
41	        .unwrap_or(0)
42	}
43
44	/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
45	fn now_stamp() -> String {
46	    format!("epoch-ms:{}", now_ms())
47	}
48
49	/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
50	#[derive(Debug, Clone, Default)]
51	pub struct TickReport {
52	    pub ingested: Vec<Ingested>,
53	    pub echo_fired: bool,
54	}
55
56	/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
57	/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
58	pub struct BrainLifecycle {
59	    runtime: ManifestRuntime,
60	    id: String,
61	    /// The project this lifecycle's context routes under (two-tier split,
62	    /// REQ-INST-2): derived once from the watched commune dir's parent — the
63	    /// drop dir lives inside the project (`<project>/.claude/`), so its parent
64	    /// is the project root — falling back to the daemon's cwd for a manifest
65	    /// without watched dirs.
66	    project_id: String,
67	    manifest: Manifest,
68	    commune_dir: Option<PathBuf>,
69	    signoff_dir: Option<PathBuf>,
70	    cfg: DaemonConfig,
71	}
72
73	impl BrainLifecycle {
74	    /// Build a host for `id` from a live-capable manifest (declares
75	    /// `[session.psyche_init]`); `None` for an absent manifest or a plain relay
76	    /// endpoint. Loads the durable [`DaemonConfig`] from disk.
77	    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
78	        Self::with_config(manifest?, id, DaemonConfig::load())
79	    }
80
81	    /// Build a host from a live-capable manifest with an explicit config (the
82	    /// testable seam). `None` if the manifest declares no Psyche.
83	    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
84	        manifest.session.psyche_init.as_ref()?;
85	        let commune_dir = manifest.session.commune_dir.as_ref().map(PathBuf::from);
86	        // [impl->REQ-INST-2] project identity for the two-tier context split:
87	        // the commune drop dir's parent is the project root; cwd is the
88	        // fallback for a manifest without watched dirs.
89	        let project_anchor = commune_dir
90	            .as_ref()
91	            .and_then(|d| d.parent().map(Path::to_path_buf))
92	            .or_else(|| std::env::current_dir().ok())
93	            .unwrap_or_else(|| PathBuf::from("."));
94	        let project_id = spt_store::project::project_id_for_dir(&project_anchor);
95	        Some(BrainLifecycle {
96	            runtime: ManifestRuntime::new(manifest.clone()),
97	            id: id.to_string(),
98	            project_id,
99	            commune_dir,
100	            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
101	            manifest: manifest.clone(),
102	            cfg,
103	        })
104	    }
105
106	    /// The configured pulse period (config-driven, not a constant).
107	    pub fn pulse_period(&self) -> Duration {
108	        self.cfg.pulse_period
109	    }
110
111	    /// Base substitution keys spt-core fills for the lifecycle templates.
112	    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
113	        let mut k = BTreeMap::new();
114	        k.insert("id".to_string(), self.id.clone());
115	        if let Some(sid) = session_id {
116	            k.insert("session_id".to_string(), sid.to_string());
117	        }
118	        k
119	    }
120
121	    /// The unique watched drop dirs (commune + signoff; usually the same dir).
122	    fn drop_dirs(&self) -> Vec<PathBuf> {
123	        let mut dirs: Vec<PathBuf> = Vec::new();
124	        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
125	            .into_iter()
126	            .flatten()
127	        {
128	            if !dirs.contains(d) {
129	                dirs.push(d.clone());
130	            }
131	        }
132	        dirs
133	    }
134
135	    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
136	    pub fn sweep_on_start(&self) {
137	        if let Some(dir) = &self.signoff_dir {
138	            let _ = sweep_stale_signoff(dir, &self.id);
139	        }
140	    }
141
142	    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
143	    /// spawn is event-driven; `None` at cold start.
144	    ///
145	    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
146	    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
147	    /// after binding, mark the perch `online` — from here liveness reads the
148	    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
149	    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
150	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
151	    pub fn spawn_psyche(
152	        &self,
153	        session_id: Option<&str>,
154	        event: Option<&str>,
155	    ) -> Result<PsycheHandle, String> {
156	        let handle =
157	            spawn_psyche(&self.runtime, &self.id, &self.base_keys(session_id), event, &now_stamp())
158	                .map_err(|e| e.to_string())?;
159	        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
160	            .map_err(|e| format!("mark psyche online: {e}"))?;
161	        Ok(handle)
162	    }
163
164	    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
165	    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
166	    /// the single liveness resolver reports the perch dead regardless of any pid
167	    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
168	    /// (perch already gone) is not an error.
169	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
170	    pub fn mark_offline(perch_path: &std::path::Path) {
171	        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
172	    }
173
174	    /// Stamp this endpoint's `last_active_ms` recency marker (REQ-UPD-4) — the
175	    /// signal the consent gate resolves "most-recently-active live session"
176	    /// against ([`crate::consent::most_recently_active`]). Best-effort: a hosted
177	    /// perch that has not been bound yet (no `info.json`) is silently skipped, so
178	    /// a pre-bind tick never errors. Stamps the Self perch for this brain's id.
179	    // [impl->REQ-UPD-4]
180	    pub fn touch_active(&self) {
181	        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
182	        let _ = spt_store::info::set_last_active(&perch_path, now_ms());
183	    }
184
185	    /// One pulse tick (T5): refresh the activity stamp (REQ-UPD-4), ingest drops
186	    /// from the watched dirs (T4), then if the echo-gate is armed fire exactly one
187	    /// echo-commune (T3).
188	    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
189	        self.touch_active();
190	        let now = now_ms();
191	        let mut ingested = Vec::new();
192	        for dir in self.drop_dirs() {
193	            let mut got =
194	                ingest_drops(&dir, &self.id, &self.project_id, now, self.cfg.protection_window_ms)
195	                    .map_err(|e| e.to_string())?;
196	            ingested.append(&mut got);
197	        }
198	        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
199	            self.fire_echo(session_id)?;
200	            true
201	        } else {
202	            false
203	        };
204	        Ok(TickReport { ingested, echo_fired })
205	    }
206
207	    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
208	    /// each tick (so a caller — or a test — observes cadence and can decide to
209	    /// stop). The period comes from the durable config, never a constant. The
210	    /// loop holds no state beyond the cursor on disk, so it is freely
211	    /// killable + respawnable: a fresh loop re-reads the drop dirs and gate.
212	    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
213	        &self,
214	        session_id: Option<&str>,
215	        stop: &AtomicBool,
216	        mut on_tick: F,
217	    ) {
218	        while !stop.load(Ordering::Relaxed) {
219	            match self.pulse_tick(session_id) {
220	                Ok(report) => on_tick(&report),
221	                Err(_) => { /* a transient tick error must not kill the loop */ }
222	            }
223	            // Sleep the configured period in small slices so a stop is honored
224	            // promptly rather than after a full (possibly multi-second) period.
225	            sleep_interruptible(self.cfg.pulse_period, stop);
226	        }
227	    }
228
229	    /// Fetch history per the manifest `[history]` strategy (empty if none).
230	    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
231	        let Some(h) = &self.manifest.history else {
232	            return Vec::new();
233	        };
234	        fetch_history(h, &self.base_keys(session_id), &self.id, ParentHint::Infer, LIVE_BOUNDED_TIMEOUT)
235	            .unwrap_or_default()
236	    }
237
238	    /// Run the echo-commune into the commune dir (T3). No-op (Ok) without a dir.
239	    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
240	        let Some(dir) = &self.commune_dir else {
241	            return Ok(());
242	        };
243	        let history = self.history(session_id);
244	        run_echo_commune(
245	            &self.runtime,
246	            &self.id,
247	            &self.base_keys(session_id),
248	            &history,
249	            dir,
250	            LIVE_BOUNDED_TIMEOUT,
251	        )
252	        .map(|_| ())
253	        .map_err(|e| e.to_string())
254	    }
255
256	    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
257	    /// grace recheck before any signoff write (1.1). The graceful path is an
258	    /// **intentional** shutdown, so it uses zero grace + an always-gone recheck —
259	    /// the Self is leaving on purpose, there is nothing to recover. The
260	    /// supervised-crash path ([`supervised_crash_signoff`](Self::supervised_crash_signoff))
261	    /// supplies the real grace + liveness recheck for an *un*intentional death.
262	    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
263	        self.signoff_with(session_id, Duration::ZERO, || true)
264	    }
265
266	    /// Execute the **supervised-crash** signoff (B7, orphan-watch) — the
267	    /// counterpart of [`graceful_signoff`](Self::graceful_signoff) for an
268	    /// *unexpected* harness death. It runs the identical load-bearing ordering —
269	    /// echo-commune (3.3) → grace wait (1.1) → liveness recheck → teardown — but
270	    /// with the **real** grace window from config and the **real** recheck
271	    /// `still_gone`. A Self that recovered across the grace window (a binary
272	    /// handoff relaunch, a brief stale poll) makes `still_gone` false and is
273	    /// **spared** — the phantom-INIT_SIGNOFF bug (1.1).
274	    ///
275	    /// `still_gone` is the post-grace liveness verdict (true ⇒ the harness owner
276	    /// is still gone ⇒ tear down). [`run_orphan_watch`](Self::run_orphan_watch)
277	    /// supplies the production recheck ([`harness_owner_gone`](Self::harness_owner_gone));
278	    /// it is injected here so the ordering is testable without a real pid.
279	    // [impl->REQ-DAEMON-4]
280	    // [impl->REQ-HAZARD-GRACE-BEFORE-SIGNOFF]
281	    // [impl->REQ-HAZARD-ECHO-BEFORE-SIGNOFF]
282	    pub fn supervised_crash_signoff(
283	        &self,
284	        session_id: Option<&str>,
285	        still_gone: impl FnOnce() -> bool,
286	    ) -> Result<SignoffOutcome, String> {
287	        self.signoff_with(session_id, self.cfg.crash_grace, still_gone)
288	    }
289
290	    /// The shared signoff wiring behind both the graceful and supervised-crash
291	    /// paths. The only two things that differ between them are exactly the two
292	    /// the 1.1 hazard turns on — the `grace` window and the `recheck` verdict — so
293	    /// they are the only parameters; the echo (3.3) and teardown effects are
294	    /// identical. Keeping one body guarantees the crash path can never drift out
295	    /// of the echo-before-teardown ordering the graceful path proves.
296	    fn signoff_with(
297	        &self,
298	        session_id: Option<&str>,
299	        grace: Duration,
300	        recheck: impl FnOnce() -> bool,
301	    ) -> Result<SignoffOutcome, String> {
302	        let mut echo_err: Option<String> = None;
303	        let mut teardown_err: Option<String> = None;
304	        let outcome = graceful_signoff::<std::convert::Infallible>(
305	            session_id,
306	            grace,
307	            || {
308	                if let Err(e) = self.fire_echo(session_id) {
309	                    echo_err = Some(e);
310	                }
311	                Ok(())
312	            },
313	            recheck,
314	            || {
315	                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
316	                if let Err(e) = std::fs::remove_file(&ready) {
317	                    if e.kind() != std::io::ErrorKind::NotFound {
318	                        teardown_err = Some(e.to_string());
319	                    }
320	                }
321	                Ok(())
322	            },
323	        )
324	        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
325	        if let Some(e) = echo_err {
326	            return Err(format!("echo failed during signoff: {e}"));
327	        }
328	        if let Some(e) = teardown_err {
329	            return Err(format!("teardown failed during signoff: {e}"));
330	        }
331	        Ok(outcome)
332	    }
333
334	    /// Whether the supervised Self's **harness owner** is gone. Anchors liveness on
335	    /// the stable harness-session pid (`info.json` `parent_pid`), never an
336	    /// ephemeral poll pid (KNOWN-HAZARDS 2.1): a recycled ephemeral pid could read
337	    /// a foreign process as the Self and mask a real death, or flip a live Self
338	    /// dead. Re-reads the perch fresh so a Self that **rebound** under a new
339	    /// harness (a recovery) — recording a fresh, live `parent_pid` — reads alive.
340	    /// A perch with no recorded owner is treated as gone (nothing supervises it).
341	    // [impl->REQ-DAEMON-4]
342	    pub fn harness_owner_gone(&self) -> bool {
343	        let path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
344	        match spt_store::info::read_info(&path).and_then(|i| i.parent_pid) {
345	            Some(pid) => !is_process_alive(pid),
346	            None => true,
347	        }
348	    }
349
350	    /// Supervise a harness-hosted Self and carry the graceful ordering into the
351	    /// **supervised-crash** path the always-on daemon now enables (B7). On start it
352	    /// sweeps any stale signoff sentinel (3.2) so a prior generation's leftover
353	    /// never reads as a live signoff. It then polls the harness owner pid each
354	    /// cycle (anchored on `parent_pid`, 2.1); on the first observed death it runs
355	    /// [`supervised_crash_signoff`](Self::supervised_crash_signoff) with the real
356	    /// grace + a fresh liveness recheck ([`harness_owner_gone`](Self::harness_owner_gone)),
357	    /// so a transient recovery across the grace window is spared (1.1).
358	    ///
359	    /// Returns the signoff outcome once the watch fires, or `None` if `stop` was
360	    /// set before any death was observed. `owner_pid` is the supervised harness
361	    /// pid; the poll cadence reuses the configured `pulse_period` (the same
362	    /// scheduled-supervision clock as the pulse loop).
363	    // [impl->REQ-DAEMON-4]
364	    // [impl->REQ-HAZARD-STALE-SIGNOFF-SENTINEL]
365	    pub fn run_orphan_watch(
366	        &self,
367	        session_id: Option<&str>,
368	        owner_pid: u32,
369	        stop: &AtomicBool,
370	    ) -> Option<Result<SignoffOutcome, String>> {
371	        // 3.2 — sweep a stale signoff sentinel before supervising, exactly as a
372	        // listener start does, so a prior crash's sentinel can't self-trigger.
373	        self.sweep_on_start();
374	        while !stop.load(Ordering::Relaxed) {
375	            if !is_process_alive(owner_pid) {
376	                // Observed gone — run the crash teardown. The recheck re-reads the
377	                // perch's CURRENT owner, so a Self that rebound across the grace
378	                // window (binary handoff) is spared (1.1).
379	                return Some(self.supervised_crash_signoff(session_id, || self.harness_owner_gone()));
380	            }
381	            sleep_interruptible(self.cfg.pulse_period, stop);
382	        }
383	        None
384	    }
385	}
386
387	/// Sleep `period`, waking early if `stop` is set (10ms slices). Keeps the pulse
388	/// loop responsive to a kill without busy-spinning.
389	fn sleep_interruptible(period: Duration, stop: &AtomicBool) {
390	    const SLICE: Duration = Duration::from_millis(10);
391	    let mut left = period;
392	    while left > Duration::ZERO {
393	        if stop.load(Ordering::Relaxed) {
394	            return;
395	        }
396	        let nap = left.min(SLICE);
397	        std::thread::sleep(nap);
398	        left = left.saturating_sub(nap);
399	    }
400	}
401
402	#[cfg(test)]
403	mod tests {
404	    use super::*;
405	    use crate::test_home::with_home;
406	    use std::sync::atomic::AtomicUsize;
407
408	    // A live-capable manifest: a Psyche init template + a commune dir to watch.
409	    // The Psyche command is a trivial no-op (we never spawn it in these tests;
410	    // we exercise the pulse/ingest loop, which does not launch the Psyche).
411	    #[cfg(windows)]
412	    const NOOP_CMD: &str = "cmd /C rem";
413	    #[cfg(unix)]
414	    const NOOP_CMD: &str = "true";
415
416	    fn live_manifest(commune_dir: &std::path::Path) -> Manifest {
417	        let dir = commune_dir.to_string_lossy().replace('\\', "/");
418	        let toml = format!(
419	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
420	             [session]\ncommune_dir=\"{dir}\"\n\n\
421	             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
422	        );
423	        Manifest::from_toml_str(&toml).unwrap()
424	    }
425
426	    // A stdin→stdout passthrough so the echo-commune summarizer succeeds and
427	    // writes a real drop file — lets the signoff tests observe that the echo
428	    // (3.3) actually ran before any teardown.
429	    #[cfg(windows)]
430	    const PASSTHROUGH: &str = "sort";
431	    #[cfg(unix)]
432	    const PASSTHROUGH: &str = "cat";
433
434	    // A live manifest that ALSO declares an echo-commune summarizer, so
435	    // `fire_echo` (and thus the signoff echo step) succeeds and writes a commune.
436	    fn live_manifest_with_echo(commune_dir: &std::path::Path) -> Manifest {
437	        let dir = commune_dir.to_string_lossy().replace('\\', "/");
438	        let toml = format!(
439	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
440	             [session]\ncommune_dir=\"{dir}\"\n\n\
441	             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n\n\
442	             [session.echo_commune]\ncommand='{PASSTHROUGH}'\n"
443	        );
444	        Manifest::from_toml_str(&toml).unwrap()
445	    }
446
447	    /// Create the perch dir + a `ready` marker for `id`, recording `parent_pid`
448	    /// as the supervised harness owner. Returns the perch path.
449	    fn seed_supervised_perch(id: &str, parent_pid: u32) -> std::path::PathBuf {
450	        use spt_store::info::{write_info, InfoJson};
451	        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
452	        std::fs::create_dir_all(&perch_path).unwrap();
453	        let mut info = InfoJson::new(id, "epoch-ms:0", parent_pid, "sid-1", "ready");
454	        info.parent_pid = Some(parent_pid);
455	        write_info(&perch_path, &info).unwrap();
456	        let ready = perch::resolve_ready_file(id, ParentHint::Infer);
457	        if let Some(p) = ready.parent() {
458	            std::fs::create_dir_all(p).unwrap();
459	        }
460	        std::fs::write(&ready, "").unwrap();
461	        perch_path
462	    }
463
464	    // A pid that is never a real allocation (above Linux pid_max / Windows
465	    // ranges; positive as i32) — a deterministic stand-in for a dead owner.
466	    const DEAD_PID: u32 = 2_000_000_000;
467
468	    #[test] // [unit->REQ-DAEMON-1] a daemon-hosted pulse tick ingests a commune drop.
469	    fn pulse_tick_ingests_drop() {
470	        with_home(|_| {
471	            let drops = tempfile::tempdir().unwrap();
472	            let manifest = live_manifest(drops.path());
473	            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
474	                .expect("live host");
475
476	            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();
477	            let report = host.pulse_tick(Some("sid-1")).expect("tick");
478	            assert_eq!(report.ingested.len(), 1, "the drop was folded in");
479	            assert!(!report.echo_fired, "no echo gate armed");
480	            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
481	        });
482	    }
483
484	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted Psyche derives
485	    // liveness from the daemon `status`, not its summarizer pid: spawning marks
486	    // the perch `online`, so a dead summarizer subprocess does NOT flip it
487	    // offline; `mark_offline` is the only thing that does.
488	    #[test]
489	    fn daemon_hosted_psyche_liveness_is_status_not_pid() {
490	        use spt_store::info::{read_info, write_info};
491	        use spt_store::liveness::{is_perch_alive, STATUS_ONLINE};
492
493	        // A pid that is never a real allocation — stands in for the exited
494	        // summarizer subprocess (deterministic; no spawn-reap timing flake).
495	        const DEAD_PID: u32 = 2_000_000_000;
496
497	        with_home(|_| {
498	            let drops = tempfile::tempdir().unwrap();
499	            let manifest = live_manifest(drops.path());
500	            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
501	                .expect("live host");
502
503	            // Spawn the Psyche — the host binds the nested perch and marks it online.
504	            let handle = host.spawn_psyche(Some("sid-1"), Some("pulse")).expect("spawn psyche");
505	            assert_eq!(
506	                read_info(&handle.perch_path).expect("info.json").status.as_deref(),
507	                Some(STATUS_ONLINE),
508	                "the daemon marked the hosted Psyche online"
509	            );
510
511	            // Simulate the summarizer subprocess exiting: its recorded pid is now
512	            // dead, but the daemon `status` stays online.
513	            let mut rec = read_info(&handle.perch_path).unwrap();
514	            rec.pid = spt_store::info::PidValue::Numeric(DEAD_PID);
515	            write_info(&handle.perch_path, &rec).unwrap();
516	            assert!(
517	                is_perch_alive(&handle.perch_path),
518	                "dead summarizer pid must NOT flip a daemon-hosted perch offline"
519	            );
520
521	            // Only an explicit teardown marks it offline.
522	            BrainLifecycle::mark_offline(&handle.perch_path);
523	            assert!(!is_perch_alive(&handle.perch_path), "mark_offline flips it offline");
524	        });
525	    }
526
527	    #[test] // [unit->REQ-DAEMON-1] the scheduled loop is config-paced + killable.
528	    fn pulse_loop_is_config_paced_and_killable() {
529	        with_home(|_| {
530	            let drops = tempfile::tempdir().unwrap();
531	            let manifest = live_manifest(drops.path());
532	            // Fast period so the test is quick; proves the period is config-driven.
533	            let cfg = DaemonConfig { pulse_period: Duration::from_millis(15), ..DaemonConfig::default() };
534	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
535	            assert_eq!(host.pulse_period(), Duration::from_millis(15));
536
537	            let stop = AtomicBool::new(false);
538	            let ticks = AtomicUsize::new(0);
539	            // Stop the loop from inside on_tick after a few ticks — proves the
540	            // loop actually schedules repeated ticks and honors the stop flag.
541	            host.run_pulse_loop(None, &stop, |_| {
542	                if ticks.fetch_add(1, Ordering::Relaxed) + 1 >= 3 {
543	                    stop.store(true, Ordering::Relaxed);
544	                }
545	            });
546	            assert!(ticks.load(Ordering::Relaxed) >= 3, "loop ran repeated ticks");
547	        });
548	    }
549
550	    // [unit->REQ-DAEMON-4] the supervised-crash path runs the SAME ordering as a
551	    // graceful teardown: the echo-commune (3.3) fires and the perch is torn down
552	    // when the post-grace recheck confirms the harness owner is still gone.
553	    #[test]
554	    fn crash_signoff_tears_down_when_still_gone() {
555	        with_home(|_| {
556	            let drops = tempfile::tempdir().unwrap();
557	            let manifest = live_manifest_with_echo(drops.path());
558	            // A short crash_grace keeps the test quick (the grace wait is real).
559	            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
560	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
561
562	            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
563	            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
564	            std::fs::write(&ready, "").unwrap();
565
566	            // still_gone = true: the Self did not recover across the grace window.
567	            let outcome = host.supervised_crash_signoff(Some("sid-1"), || true).expect("signoff");
568	            assert!(outcome.echo_ran, "echo-before-signoff (3.3) must have run");
569	            assert!(outcome.signed_off, "still-gone Self is torn down");
570	            assert!(
571	                drops.path().join("doyle-commune.md").exists(),
572	                "the echo wrote a final-delta commune before teardown"
573	            );
574	            assert!(!ready.exists(), "teardown removed the ready marker");
575	        });
576	    }
577
578	    // [unit->REQ-HAZARD-GRACE-BEFORE-SIGNOFF] a Self that recovers across the
579	    // grace window is NOT signed off — the phantom-INIT_SIGNOFF bug (1.1). The
580	    // echo still fires (it precedes the recheck), but no teardown happens.
581	    #[test]
582	    fn crash_signoff_spares_recovered_self() {
583	        with_home(|_| {
584	            let drops = tempfile::tempdir().unwrap();
585	            let manifest = live_manifest_with_echo(drops.path());
586	            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
587	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
588
589	            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
590	            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
591	            std::fs::write(&ready, "").unwrap();
592
593	            // still_gone = false: the Self came back alive during the grace wait.
594	            let outcome = host.supervised_crash_signoff(Some("sid-1"), || false).expect("signoff");
595	            assert!(outcome.echo_ran, "echo precedes the recheck, so it still ran");
596	            assert!(!outcome.signed_off, "a recovered Self must never be signed off");
597	            assert!(ready.exists(), "the live Self's ready marker is left intact");
598	        });
599	    }
600
601	    // [unit->REQ-DAEMON-4] liveness for the supervised Self anchors on the stable
602	    // harness pid (`parent_pid`), never an ephemeral poll pid (2.1): a dead owner
603	    // reads gone, a live owner reads present, and no recorded owner reads gone.
604	    #[test]
605	    fn harness_owner_gone_anchors_on_parent_pid() {
606	        with_home(|_| {
607	            let drops = tempfile::tempdir().unwrap();
608	            let manifest = live_manifest(drops.path());
609	            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
610	                .expect("live host");
611
612	            // No perch yet → no recorded owner → treated as gone.
613	            assert!(host.harness_owner_gone(), "an unsupervised perch reads gone");
614
615	            // A live owner (this very process) reads present.
616	            seed_supervised_perch("doyle", std::process::id());
617	            assert!(!host.harness_owner_gone(), "a live parent_pid reads present");
618
619	            // A dead owner reads gone.
620	            seed_supervised_perch("doyle", DEAD_PID);
621	            assert!(host.harness_owner_gone(), "a dead parent_pid reads gone");
622	        });
623	    }
624
625	    // [unit->REQ-DAEMON-4] the orphan-watch loop fires the crash signoff on an
626	    // observed owner death, sweeping a stale sentinel first (3.2) and tearing the
627	    // perch down once the recheck confirms the dead owner.
628	    #[test]
629	    fn orphan_watch_fires_on_dead_owner() {
630	        with_home(|_| {
631	            let drops = tempfile::tempdir().unwrap();
632	            let manifest = live_manifest_with_echo(drops.path());
633	            let cfg = DaemonConfig {
634	                pulse_period: Duration::from_millis(10),
635	                crash_grace: Duration::from_millis(10),
636	                ..DaemonConfig::default()
637	            };
638	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
639
640	            // Supervise a perch whose harness owner is already dead.
641	            seed_supervised_perch("doyle", DEAD_PID);
642	            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
643	            assert!(ready.exists(), "precondition: ready marker present");
644
645	            let stop = AtomicBool::new(false);
646	            let outcome = host
647	                .run_orphan_watch(Some("sid-1"), DEAD_PID, &stop)
648	                .expect("watch fired")
649	                .expect("signoff ok");
650	            assert!(outcome.echo_ran && outcome.signed_off, "dead owner → echo + teardown");
651	            assert!(!ready.exists(), "the orphan-watch tore the perch down");
652	        });
653	    }
654
655	    // [unit->REQ-DAEMON-4] the orphan-watch honors the stop flag and never fires
656	    // while the supervised owner is alive (no false-positive teardown).
657	    #[test]
658	    fn orphan_watch_honors_stop_for_live_owner() {
659	        with_home(|_| {
660	            let drops = tempfile::tempdir().unwrap();
661	            let manifest = live_manifest_with_echo(drops.path());
662	            let cfg = DaemonConfig { pulse_period: Duration::from_millis(10), ..DaemonConfig::default() };
663	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
664
665	            seed_supervised_perch("doyle", std::process::id());
666	            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
667
668	            // Owner is this (alive) process; stop is already set → loop exits with
669	            // no signoff and leaves the perch untouched.
670	            let stop = AtomicBool::new(true);
671	            let outcome = host.run_orphan_watch(Some("sid-1"), std::process::id(), &stop);
672	            assert!(outcome.is_none(), "a live owner under stop never signs off");
673	            assert!(ready.exists(), "a live Self's perch is left intact");
674	        });
675	    }
676	}
677

//! The brain's LiveAgent lifecycle host (B3) — the consolidated re-host of the
//! interim `api listen` runner's live half.
//!
//! In the no-daemon interim model the long-lived `spt api listen` process hosted
//! the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest, echo fires,
//! graceful signoff) directly in the agent's session. Consolidation
//! (REQ-DAEMON-1) moves that orchestration **down into `spt-daemon`** so the
//! brain runs it as scheduled in-process loops, with a durable, configurable
//! pulse period ([`crate::config::DaemonConfig`]) instead of the interim 5s
//! constant. The `spt` binary's `LiveHost` becomes a thin shim over this type —
//! a re-host, not a rewrite: the heavy lifting stays in the already-pure
//! [`spt_live`] seams ([`spawn_psyche`], [`ingest_drops`], [`run_echo_commune`],
//! [`graceful_signoff`], [`sweep_stale_signoff`]); the ordering invariants
//! (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-sweep 3.2) live
//! where they always have.
// [impl->REQ-DAEMON-1]

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use spt_live::{
    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
};
use spt_runtime::{Manifest, ManifestRuntime};
use spt_store::perch::{self, ParentHint};
use spt_store::proc::is_process_alive;

use crate::config::DaemonConfig;

/// Bound for a bounded history/echo invocation in the host (carried from M2b).
const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);

/// Wall-clock now in epoch millis — precedence-marker stamp for ingested writes.
fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
fn now_stamp() -> String {
    format!("epoch-ms:{}", now_ms())
}

/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
#[derive(Debug, Clone, Default)]
pub struct TickReport {
    pub ingested: Vec<Ingested>,
    pub echo_fired: bool,
}

/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
pub struct BrainLifecycle {
    runtime: ManifestRuntime,
    id: String,
    /// The project this lifecycle's context routes under (two-tier split,
    /// REQ-INST-2): derived once from the watched commune dir's parent — the
    /// drop dir lives inside the project (`<project>/.claude/`), so its parent
    /// is the project root — falling back to the daemon's cwd for a manifest
    /// without watched dirs.
    project_id: String,
    manifest: Manifest,
    commune_dir: Option<PathBuf>,
    signoff_dir: Option<PathBuf>,
    cfg: DaemonConfig,
}

impl BrainLifecycle {
    /// Build a host for `id` from a live-capable manifest (declares
    /// `[session.psyche_init]`); `None` for an absent manifest or a plain relay
    /// endpoint. Loads the durable [`DaemonConfig`] from disk.
    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
        Self::with_config(manifest?, id, DaemonConfig::load())
    }

    /// Build a host from a live-capable manifest with an explicit config (the
    /// testable seam). `None` if the manifest declares no Psyche.
    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
        manifest.session.psyche_init.as_ref()?;
        let commune_dir = manifest.session.commune_dir.as_ref().map(PathBuf::from);
        // [impl->REQ-INST-2] project identity for the two-tier context split:
        // the commune drop dir's parent is the project root; cwd is the
        // fallback for a manifest without watched dirs.
        let project_anchor = commune_dir
            .as_ref()
            .and_then(|d| d.parent().map(Path::to_path_buf))
            .or_else(|| std::env::current_dir().ok())
            .unwrap_or_else(|| PathBuf::from("."));
        let project_id = spt_store::project::project_id_for_dir(&project_anchor);
        Some(BrainLifecycle {
            runtime: ManifestRuntime::new(manifest.clone()),
            id: id.to_string(),
            project_id,
            commune_dir,
            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
            manifest: manifest.clone(),
            cfg,
        })
    }

    /// The configured pulse period (config-driven, not a constant).
    pub fn pulse_period(&self) -> Duration {
        self.cfg.pulse_period
    }

    /// Base substitution keys spt-core fills for the lifecycle templates.
    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
        let mut k = BTreeMap::new();
        k.insert("id".to_string(), self.id.clone());
        if let Some(sid) = session_id {
            k.insert("session_id".to_string(), sid.to_string());
        }
        k
    }

    /// The unique watched drop dirs (commune + signoff; usually the same dir).
    fn drop_dirs(&self) -> Vec<PathBuf> {
        let mut dirs: Vec<PathBuf> = Vec::new();
        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
            .into_iter()
            .flatten()
        {
            if !dirs.contains(d) {
                dirs.push(d.clone());
            }
        }
        dirs
    }

    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
    pub fn sweep_on_start(&self) {
        if let Some(dir) = &self.signoff_dir {
            let _ = sweep_stale_signoff(dir, &self.id);
        }
    }

    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
    /// spawn is event-driven; `None` at cold start.
    ///
    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
    /// after binding, mark the perch `online` — from here liveness reads the
    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        let handle =
            spawn_psyche(&self.runtime, &self.id, &self.base_keys(session_id), event, &now_stamp())
                .map_err(|e| e.to_string())?;
        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
            .map_err(|e| format!("mark psyche online: {e}"))?;
        Ok(handle)
    }

    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
    /// the single liveness resolver reports the perch dead regardless of any pid
    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
    /// (perch already gone) is not an error.
    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
    pub fn mark_offline(perch_path: &std::path::Path) {
        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
    }

    /// Stamp this endpoint's `last_active_ms` recency marker (REQ-UPD-4) — the
    /// signal the consent gate resolves "most-recently-active live session"
    /// against ([`crate::consent::most_recently_active`]). Best-effort: a hosted
    /// perch that has not been bound yet (no `info.json`) is silently skipped, so
    /// a pre-bind tick never errors. Stamps the Self perch for this brain's id.
    // [impl->REQ-UPD-4]
    pub fn touch_active(&self) {
        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
        let _ = spt_store::info::set_last_active(&perch_path, now_ms());
    }

    /// One pulse tick (T5): refresh the activity stamp (REQ-UPD-4), ingest drops
    /// from the watched dirs (T4), then if the echo-gate is armed fire exactly one
    /// echo-commune (T3).
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
        self.touch_active();
        let now = now_ms();
        let mut ingested = Vec::new();
        for dir in self.drop_dirs() {
            let mut got =
                ingest_drops(&dir, &self.id, &self.project_id, now, self.cfg.protection_window_ms)
                    .map_err(|e| e.to_string())?;
            ingested.append(&mut got);
        }
        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
            self.fire_echo(session_id)?;
            true
        } else {
            false
        };
        Ok(TickReport { ingested, echo_fired })
    }

    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
    /// each tick (so a caller — or a test — observes cadence and can decide to
    /// stop). The period comes from the durable config, never a constant. The
    /// loop holds no state beyond the cursor on disk, so it is freely
    /// killable + respawnable: a fresh loop re-reads the drop dirs and gate.
    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
        &self,
        session_id: Option<&str>,
        stop: &AtomicBool,
        mut on_tick: F,
    ) {
        while !stop.load(Ordering::Relaxed) {
            match self.pulse_tick(session_id) {
                Ok(report) => on_tick(&report),
                Err(_) => { /* a transient tick error must not kill the loop */ }
            }
            // Sleep the configured period in small slices so a stop is honored
            // promptly rather than after a full (possibly multi-second) period.
            sleep_interruptible(self.cfg.pulse_period, stop);
        }
    }

    /// Fetch history per the manifest `[history]` strategy (empty if none).
    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
        let Some(h) = &self.manifest.history else {
            return Vec::new();
        };
        fetch_history(h, &self.base_keys(session_id), &self.id, ParentHint::Infer, LIVE_BOUNDED_TIMEOUT)
            .unwrap_or_default()
    }

    /// Run the echo-commune into the commune dir (T3). No-op (Ok) without a dir.
    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
        let Some(dir) = &self.commune_dir else {
            return Ok(());
        };
        let history = self.history(session_id);
        run_echo_commune(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            &history,
            dir,
            LIVE_BOUNDED_TIMEOUT,
        )
        .map(|_| ())
        .map_err(|e| e.to_string())
    }

    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
    /// grace recheck before any signoff write (1.1). The graceful path is an
    /// **intentional** shutdown, so it uses zero grace + an always-gone recheck —
    /// the Self is leaving on purpose, there is nothing to recover. The
    /// supervised-crash path ([`supervised_crash_signoff`](Self::supervised_crash_signoff))
    /// supplies the real grace + liveness recheck for an *un*intentional death.
    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
        self.signoff_with(session_id, Duration::ZERO, || true)
    }

    /// Execute the **supervised-crash** signoff (B7, orphan-watch) — the
    /// counterpart of [`graceful_signoff`](Self::graceful_signoff) for an
    /// *unexpected* harness death. It runs the identical load-bearing ordering —
    /// echo-commune (3.3) → grace wait (1.1) → liveness recheck → teardown — but
    /// with the **real** grace window from config and the **real** recheck
    /// `still_gone`. A Self that recovered across the grace window (a binary
    /// handoff relaunch, a brief stale poll) makes `still_gone` false and is
    /// **spared** — the phantom-INIT_SIGNOFF bug (1.1).
    ///
    /// `still_gone` is the post-grace liveness verdict (true ⇒ the harness owner
    /// is still gone ⇒ tear down). [`run_orphan_watch`](Self::run_orphan_watch)
    /// supplies the production recheck ([`harness_owner_gone`](Self::harness_owner_gone));
    /// it is injected here so the ordering is testable without a real pid.
    // [impl->REQ-DAEMON-4]
    // [impl->REQ-HAZARD-GRACE-BEFORE-SIGNOFF]
    // [impl->REQ-HAZARD-ECHO-BEFORE-SIGNOFF]
    pub fn supervised_crash_signoff(
        &self,
        session_id: Option<&str>,
        still_gone: impl FnOnce() -> bool,
    ) -> Result<SignoffOutcome, String> {
        self.signoff_with(session_id, self.cfg.crash_grace, still_gone)
    }

    /// The shared signoff wiring behind both the graceful and supervised-crash
    /// paths. The only two things that differ between them are exactly the two
    /// the 1.1 hazard turns on — the `grace` window and the `recheck` verdict — so
    /// they are the only parameters; the echo (3.3) and teardown effects are
    /// identical. Keeping one body guarantees the crash path can never drift out
    /// of the echo-before-teardown ordering the graceful path proves.
    fn signoff_with(
        &self,
        session_id: Option<&str>,
        grace: Duration,
        recheck: impl FnOnce() -> bool,
    ) -> Result<SignoffOutcome, String> {
        let mut echo_err: Option<String> = None;
        let mut teardown_err: Option<String> = None;
        let outcome = graceful_signoff::<std::convert::Infallible>(
            session_id,
            grace,
            || {
                if let Err(e) = self.fire_echo(session_id) {
                    echo_err = Some(e);
                }
                Ok(())
            },
            recheck,
            || {
                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
                if let Err(e) = std::fs::remove_file(&ready) {
                    if e.kind() != std::io::ErrorKind::NotFound {
                        teardown_err = Some(e.to_string());
                    }
                }
                Ok(())
            },
        )
        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
        if let Some(e) = echo_err {
            return Err(format!("echo failed during signoff: {e}"));
        }
        if let Some(e) = teardown_err {
            return Err(format!("teardown failed during signoff: {e}"));
        }
        Ok(outcome)
    }

    /// Whether the supervised Self's **harness owner** is gone. Anchors liveness on
    /// the stable harness-session pid (`info.json` `parent_pid`), never an
    /// ephemeral poll pid (KNOWN-HAZARDS 2.1): a recycled ephemeral pid could read
    /// a foreign process as the Self and mask a real death, or flip a live Self
    /// dead. Re-reads the perch fresh so a Self that **rebound** under a new
    /// harness (a recovery) — recording a fresh, live `parent_pid` — reads alive.
    /// A perch with no recorded owner is treated as gone (nothing supervises it).
    // [impl->REQ-DAEMON-4]
    pub fn harness_owner_gone(&self) -> bool {
        let path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
        match spt_store::info::read_info(&path).and_then(|i| i.parent_pid) {
            Some(pid) => !is_process_alive(pid),
            None => true,
        }
    }

    /// Supervise a harness-hosted Self and carry the graceful ordering into the
    /// **supervised-crash** path the always-on daemon now enables (B7). On start it
    /// sweeps any stale signoff sentinel (3.2) so a prior generation's leftover
    /// never reads as a live signoff. It then polls the harness owner pid each
    /// cycle (anchored on `parent_pid`, 2.1); on the first observed death it runs
    /// [`supervised_crash_signoff`](Self::supervised_crash_signoff) with the real
    /// grace + a fresh liveness recheck ([`harness_owner_gone`](Self::harness_owner_gone)),
    /// so a transient recovery across the grace window is spared (1.1).
    ///
    /// Returns the signoff outcome once the watch fires, or `None` if `stop` was
    /// set before any death was observed. `owner_pid` is the supervised harness
    /// pid; the poll cadence reuses the configured `pulse_period` (the same
    /// scheduled-supervision clock as the pulse loop).
    // [impl->REQ-DAEMON-4]
    // [impl->REQ-HAZARD-STALE-SIGNOFF-SENTINEL]
    pub fn run_orphan_watch(
        &self,
        session_id: Option<&str>,
        owner_pid: u32,
        stop: &AtomicBool,
    ) -> Option<Result<SignoffOutcome, String>> {
        // 3.2 — sweep a stale signoff sentinel before supervising, exactly as a
        // listener start does, so a prior crash's sentinel can't self-trigger.
        self.sweep_on_start();
        while !stop.load(Ordering::Relaxed) {
            if !is_process_alive(owner_pid) {
                // Observed gone — run the crash teardown. The recheck re-reads the
                // perch's CURRENT owner, so a Self that rebound across the grace
                // window (binary handoff) is spared (1.1).
                return Some(self.supervised_crash_signoff(session_id, || self.harness_owner_gone()));
            }
            sleep_interruptible(self.cfg.pulse_period, stop);
        }
        None
    }
}

/// Sleep `period`, waking early if `stop` is set (10ms slices). Keeps the pulse
/// loop responsive to a kill without busy-spinning.
fn sleep_interruptible(period: Duration, stop: &AtomicBool) {
    const SLICE: Duration = Duration::from_millis(10);
    let mut left = period;
    while left > Duration::ZERO {
        if stop.load(Ordering::Relaxed) {
            return;
        }
        let nap = left.min(SLICE);
        std::thread::sleep(nap);
        left = left.saturating_sub(nap);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use std::sync::atomic::AtomicUsize;

    // A live-capable manifest: a Psyche init template + a commune dir to watch.
    // The Psyche command is a trivial no-op (we never spawn it in these tests;
    // we exercise the pulse/ingest loop, which does not launch the Psyche).
    #[cfg(windows)]
    const NOOP_CMD: &str = "cmd /C rem";
    #[cfg(unix)]
    const NOOP_CMD: &str = "true";

    fn live_manifest(commune_dir: &std::path::Path) -> Manifest {
        let dir = commune_dir.to_string_lossy().replace('\\', "/");
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session]\ncommune_dir=\"{dir}\"\n\n\
             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    // A stdin→stdout passthrough so the echo-commune summarizer succeeds and
    // writes a real drop file — lets the signoff tests observe that the echo
    // (3.3) actually ran before any teardown.
    #[cfg(windows)]
    const PASSTHROUGH: &str = "sort";
    #[cfg(unix)]
    const PASSTHROUGH: &str = "cat";

    // A live manifest that ALSO declares an echo-commune summarizer, so
    // `fire_echo` (and thus the signoff echo step) succeeds and writes a commune.
    fn live_manifest_with_echo(commune_dir: &std::path::Path) -> Manifest {
        let dir = commune_dir.to_string_lossy().replace('\\', "/");
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session]\ncommune_dir=\"{dir}\"\n\n\
             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n\n\
             [session.echo_commune]\ncommand='{PASSTHROUGH}'\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    /// Create the perch dir + a `ready` marker for `id`, recording `parent_pid`
    /// as the supervised harness owner. Returns the perch path.
    fn seed_supervised_perch(id: &str, parent_pid: u32) -> std::path::PathBuf {
        use spt_store::info::{write_info, InfoJson};
        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&perch_path).unwrap();
        let mut info = InfoJson::new(id, "epoch-ms:0", parent_pid, "sid-1", "ready");
        info.parent_pid = Some(parent_pid);
        write_info(&perch_path, &info).unwrap();
        let ready = perch::resolve_ready_file(id, ParentHint::Infer);
        if let Some(p) = ready.parent() {
            std::fs::create_dir_all(p).unwrap();
        }
        std::fs::write(&ready, "").unwrap();
        perch_path
    }

    // A pid that is never a real allocation (above Linux pid_max / Windows
    // ranges; positive as i32) — a deterministic stand-in for a dead owner.
    const DEAD_PID: u32 = 2_000_000_000;

    #[test] // [unit->REQ-DAEMON-1] a daemon-hosted pulse tick ingests a commune drop.
    fn pulse_tick_ingests_drop() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
                .expect("live host");

            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();
            let report = host.pulse_tick(Some("sid-1")).expect("tick");
            assert_eq!(report.ingested.len(), 1, "the drop was folded in");
            assert!(!report.echo_fired, "no echo gate armed");
            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
        });
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted Psyche derives
    // liveness from the daemon `status`, not its summarizer pid: spawning marks
    // the perch `online`, so a dead summarizer subprocess does NOT flip it
    // offline; `mark_offline` is the only thing that does.
    #[test]
    fn daemon_hosted_psyche_liveness_is_status_not_pid() {
        use spt_store::info::{read_info, write_info};
        use spt_store::liveness::{is_perch_alive, STATUS_ONLINE};

        // A pid that is never a real allocation — stands in for the exited
        // summarizer subprocess (deterministic; no spawn-reap timing flake).
        const DEAD_PID: u32 = 2_000_000_000;

        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
                .expect("live host");

            // Spawn the Psyche — the host binds the nested perch and marks it online.
            let handle = host.spawn_psyche(Some("sid-1"), Some("pulse")).expect("spawn psyche");
            assert_eq!(
                read_info(&handle.perch_path).expect("info.json").status.as_deref(),
                Some(STATUS_ONLINE),
                "the daemon marked the hosted Psyche online"
            );

            // Simulate the summarizer subprocess exiting: its recorded pid is now
            // dead, but the daemon `status` stays online.
            let mut rec = read_info(&handle.perch_path).unwrap();
            rec.pid = spt_store::info::PidValue::Numeric(DEAD_PID);
            write_info(&handle.perch_path, &rec).unwrap();
            assert!(
                is_perch_alive(&handle.perch_path),
                "dead summarizer pid must NOT flip a daemon-hosted perch offline"
            );

            // Only an explicit teardown marks it offline.
            BrainLifecycle::mark_offline(&handle.perch_path);
            assert!(!is_perch_alive(&handle.perch_path), "mark_offline flips it offline");
        });
    }

    #[test] // [unit->REQ-DAEMON-1] the scheduled loop is config-paced + killable.
    fn pulse_loop_is_config_paced_and_killable() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            // Fast period so the test is quick; proves the period is config-driven.
            let cfg = DaemonConfig { pulse_period: Duration::from_millis(15), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
            assert_eq!(host.pulse_period(), Duration::from_millis(15));

            let stop = AtomicBool::new(false);
            let ticks = AtomicUsize::new(0);
            // Stop the loop from inside on_tick after a few ticks — proves the
            // loop actually schedules repeated ticks and honors the stop flag.
            host.run_pulse_loop(None, &stop, |_| {
                if ticks.fetch_add(1, Ordering::Relaxed) + 1 >= 3 {
                    stop.store(true, Ordering::Relaxed);
                }
            });
            assert!(ticks.load(Ordering::Relaxed) >= 3, "loop ran repeated ticks");
        });
    }

    // [unit->REQ-DAEMON-4] the supervised-crash path runs the SAME ordering as a
    // graceful teardown: the echo-commune (3.3) fires and the perch is torn down
    // when the post-grace recheck confirms the harness owner is still gone.
    #[test]
    fn crash_signoff_tears_down_when_still_gone() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            // A short crash_grace keeps the test quick (the grace wait is real).
            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
            std::fs::write(&ready, "").unwrap();

            // still_gone = true: the Self did not recover across the grace window.
            let outcome = host.supervised_crash_signoff(Some("sid-1"), || true).expect("signoff");
            assert!(outcome.echo_ran, "echo-before-signoff (3.3) must have run");
            assert!(outcome.signed_off, "still-gone Self is torn down");
            assert!(
                drops.path().join("doyle-commune.md").exists(),
                "the echo wrote a final-delta commune before teardown"
            );
            assert!(!ready.exists(), "teardown removed the ready marker");
        });
    }

    // [unit->REQ-HAZARD-GRACE-BEFORE-SIGNOFF] a Self that recovers across the
    // grace window is NOT signed off — the phantom-INIT_SIGNOFF bug (1.1). The
    // echo still fires (it precedes the recheck), but no teardown happens.
    #[test]
    fn crash_signoff_spares_recovered_self() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
            std::fs::write(&ready, "").unwrap();

            // still_gone = false: the Self came back alive during the grace wait.
            let outcome = host.supervised_crash_signoff(Some("sid-1"), || false).expect("signoff");
            assert!(outcome.echo_ran, "echo precedes the recheck, so it still ran");
            assert!(!outcome.signed_off, "a recovered Self must never be signed off");
            assert!(ready.exists(), "the live Self's ready marker is left intact");
        });
    }

    // [unit->REQ-DAEMON-4] liveness for the supervised Self anchors on the stable
    // harness pid (`parent_pid`), never an ephemeral poll pid (2.1): a dead owner
    // reads gone, a live owner reads present, and no recorded owner reads gone.
    #[test]
    fn harness_owner_gone_anchors_on_parent_pid() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
                .expect("live host");

            // No perch yet → no recorded owner → treated as gone.
            assert!(host.harness_owner_gone(), "an unsupervised perch reads gone");

            // A live owner (this very process) reads present.
            seed_supervised_perch("doyle", std::process::id());
            assert!(!host.harness_owner_gone(), "a live parent_pid reads present");

            // A dead owner reads gone.
            seed_supervised_perch("doyle", DEAD_PID);
            assert!(host.harness_owner_gone(), "a dead parent_pid reads gone");
        });
    }

    // [unit->REQ-DAEMON-4] the orphan-watch loop fires the crash signoff on an
    // observed owner death, sweeping a stale sentinel first (3.2) and tearing the
    // perch down once the recheck confirms the dead owner.
    #[test]
    fn orphan_watch_fires_on_dead_owner() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            let cfg = DaemonConfig {
                pulse_period: Duration::from_millis(10),
                crash_grace: Duration::from_millis(10),
                ..DaemonConfig::default()
            };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            // Supervise a perch whose harness owner is already dead.
            seed_supervised_perch("doyle", DEAD_PID);
            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
            assert!(ready.exists(), "precondition: ready marker present");

            let stop = AtomicBool::new(false);
            let outcome = host
                .run_orphan_watch(Some("sid-1"), DEAD_PID, &stop)
                .expect("watch fired")
                .expect("signoff ok");
            assert!(outcome.echo_ran && outcome.signed_off, "dead owner → echo + teardown");
            assert!(!ready.exists(), "the orphan-watch tore the perch down");
        });
    }

    // [unit->REQ-DAEMON-4] the orphan-watch honors the stop flag and never fires
    // while the supervised owner is alive (no false-positive teardown).
    #[test]
    fn orphan_watch_honors_stop_for_live_owner() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            let cfg = DaemonConfig { pulse_period: Duration::from_millis(10), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            seed_supervised_perch("doyle", std::process::id());
            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);

            // Owner is this (alive) process; stop is already set → loop exits with
            // no signoff and leaves the perch untouched.
            let stop = AtomicBool::new(true);
            let outcome = host.run_orphan_watch(Some("sid-1"), std::process::id(), &stop);
            assert!(outcome.is_none(), "a live owner under stop never signs off");
            assert!(ready.exists(), "a live Self's perch is left intact");
        });
    }
}


Recon continues. Registry Status, info.rs, notif resurface, config knobs: