Edit

**Status:** D9-1 COMPLETE 2026-06-04 (five CI-green sub-slices — the slice-1 body grew
attach/xfer arms and split for atomicity):

**Status:** D9-2 COMPLETE 2026-06-04 (three CI-green sub-slices; REQ-INST-3 and
REQ-INST-4 activated at `[impl, unit]`):
- D9-2-1 `0315783` — the explicit resting-state machine (`spt-daemon::resting`: pure
  transition table, dormancy-onset auto-suspend anchor, global→node→endpoint knob
  chain default OFF) + `info.json` `rest_state`/`dormant_since_ms` durable pair +
  registry `Status::Suspended` (additive, addressable) + `advertise_local` follows
  the machine epoch-bumped.
- D9-2-2 `8a7afd6` — edge effects: `apply_event` transition host (echo BEFORE the
  flip persists, KH 3.3; loud echo failure never wedges the seat active);
  `BrainLifecycle::rest_event` binds the real `run_echo_commune`; wake fires
  `resurface_at_boundary` (the D8 deferral closed) + the freshness-pull marker the
  peer pump consumes (forces one sync round NOW).
- D9-2-3 `56a98db` — production feeds: attach arm (Request=Wake, viewport
  end=Detach), registry arm attention flips (`apply_feed_flips` — only OBSERVED
  transitions-to-Active on peer nodes; never restart re-learns or periodic
  re-advertisements), pulse recency gated on the resting state (the D8 gap: a
  resting seat never wins most-recently-active), `spt suspend`/`spt wake` local
  arms. Daemon-side echoes are GATE-ARMED (the endpoint's own pulse loop runs the
  bounded call — KH 7.4 holds by construction; the hazard requirement stays
  unbound until the daemon hosts per-agent runtimes). REQ-INST-6 seam documented,
  stays inactive (M5). Remote `spt suspend id@node` arm = M5 seam.

D9-1 COMPLETE 2026-06-04 (five CI-green sub-slices — the slice-1 body grew
attach/xfer arms and split for atomicity):


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

**Status:** D9-1 COMPLETE 2026-06-04 (five CI-green sub-slices — the slice-1 body grew
attach/xfer arms and split for atomicity):

**Status:** D9-2 COMPLETE 2026-06-04 (three CI-green sub-slices; REQ-INST-3 and
REQ-INST-4 activated at `[impl, unit]`):
- D9-2-1 `0315783` — the explicit resting-state machine (`spt-daemon::resting`: pure
  transition table, dormancy-onset auto-suspend anchor, global→node→endpoint knob
  chain default OFF) + `info.json` `rest_state`/`dormant_since_ms` durable pair +
  registry `Status::Suspended` (additive, addressable) + `advertise_local` follows
  the machine epoch-bumped.
- D9-2-2 `8a7afd6` — edge effects: `apply_event` transition host (echo BEFORE the
  flip persists, KH 3.3; loud echo failure never wedges the seat active);
  `BrainLifecycle::rest_event` binds the real `run_echo_commune`; wake fires
  `resurface_at_boundary` (the D8 deferral closed) + the freshness-pull marker the
  peer pump consumes (forces one sync round NOW).
- D9-2-3 `56a98db` — production feeds: attach arm (Request=Wake, viewport
  end=Detach), registry arm attention flips (`apply_feed_flips` — only OBSERVED
  transitions-to-Active on peer nodes; never restart re-learns or periodic
  re-advertisements), pulse recency gated on the resting state (the D8 gap: a
  resting seat never wins most-recently-active), `spt suspend`/`spt wake` local
  arms. Daemon-side echoes are GATE-ARMED (the endpoint's own pulse loop runs the
  bounded call — KH 7.4 holds by construction; the hazard requirement stays
  unbound until the daemon hosts per-agent runtimes). REQ-INST-6 seam documented,
  stays inactive (M5). Remote `spt suspend id@node` arm = M5 seam.

D9-1 COMPLETE 2026-06-04 (five CI-green sub-slices — the slice-1 body grew
attach/xfer arms and split for atomicity):

# 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

+**Status:** D9-2 COMPLETE 2026-06-04 (three CI-green sub-slices; REQ-INST-3 and

+REQ-INST-4 activated at `[impl, unit]`):

+- D9-2-1 `0315783` — the explicit resting-state machine (`spt-daemon::resting`: pure

+  transition table, dormancy-onset auto-suspend anchor, global→node→endpoint knob

+  chain default OFF) + `info.json` `rest_state`/`dormant_since_ms` durable pair +

+- D9-2-2 `8a7afd6` — edge effects: `apply_event` transition host (echo BEFORE the

+  flip persists, KH 3.3; loud echo failure never wedges the seat active);

+  `BrainLifecycle::rest_event` binds the real `run_echo_commune`; wake fires

+  `resurface_at_boundary` (the D8 deferral closed) + the freshness-pull marker the

+  peer pump consumes (forces one sync round NOW).

+- D9-2-3 `56a98db` — production feeds: attach arm (Request=Wake, viewport

+  end=Detach), registry arm attention flips (`apply_feed_flips` — only OBSERVED

+  transitions-to-Active on peer nodes; never restart re-learns or periodic

+  re-advertisements), pulse recency gated on the resting state (the D8 gap: a

+  resting seat never wins most-recently-active), `spt suspend`/`spt wake` local

+  arms. Daemon-side echoes are GATE-ARMED (the endpoint's own pulse loop runs the

+  bounded call — KH 7.4 holds by construction; the hazard requirement stays

+  unbound until the daemon hosts per-agent runtimes). REQ-INST-6 seam documented,

+  stays inactive (M5). Remote `spt suspend id@node` arm = M5 seam.

+D9-1 COMPLETE 2026-06-04 (five CI-green sub-slices — the slice-1 body grew

- D9-1-1 `ea73c9a` — inbound dispatch (sync/update/notif/wan arms; `initiated_locally`

on the stream table; first-record shape demux).


<tool_use_error>Path does not exist: C:\Users\decid\Documents\projects\spt-core\docs\adr\0003-adapter-agnostic-endpoint-identity.md. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.</tool_use_error>

Error: Path does not exist: C:\Users\decid\Documents\projects\spt-core\docs\adr\0003-adapter-agnostic-endpoint-identity.md. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.


D9-2-1: resting state machine + Status::Suspended + advertise-follows-state

Pure RestState/RestEvent transition core in spt-daemon/resting.rs; info.json rest_state + dormant_since_ms + auto_suspend_after_ms; DaemonConfig auto_suspend_after_ms knob; registry Status::Suspended (additive serde, routable); advertise_local maps rest_state → Status. Activate REQ-INST-3 [impl, unit]. CI green.

D9-2-2: transition effects — echo edge + wake resurface + freshness pull

Transition host applies events, persists state, fires effects: run_echo_commune on active→(dormant|suspended) edge (KH 3.3, REQ-INST-4 [impl, unit]); wake edge fires resurface_at_boundary + writes freshness-pull marker the peer pump consumes (forces due_sync). KH 7.4: if >1 agent's echo scheduled by daemon, activate REQ-HAZARD-DAEMON-SCHED-NONBLOCKING + off-scheduler echo. CI green.

D9-2-3: production feed points + spt suspend/wake CLI + INST-6 seam doc

Attach arm feeds Wake on Request / Detach on viewport end; registry inbound arm feeds AttentionShift on peer row transition-to-Active for locally hosted id; spt suspend/spt wake local CLI arms; REQ-INST-6 deferred-message gate seam documented (stays inactive). CI green; plan status updated.


43:## Stage A red-team amendments (2026-05-31)
49:- **#9 — dormant-warm default has real resource/security cost.** Warm sessions retain credentials, file locks, model/billing footprint, PTYs, watchers on *every* node. **Required:** measure N dormant sessions on real adapters; define an adapter-specific warm/cold policy + resource budget before locking warm as the default (R-INST-3).


1	expected an array but got: object ({"conclusion":"","headSha ...})
2	expected an array but got: object ({"conclusion":"","headSha ...})
3	expected an array but got: object ({"conclusion":"","headSha ...})
4

expected an array but got: object ({"conclusion":"","headSha ...})
expected an array but got: object ({"conclusion":"","headSha ...})
expected an array but got: object ({"conclusion":"","headSha ...})


jq bug in the waiter. Fix:


ToolSearch

Question: select:TaskStop


Question: select:TaskStop


Successfully stopped task: b6jd121hg (until [ "$(gh run list --branch dev-freeform --limit 1 --json status,conclusion,headSha --jq '.[0] | select(.headSha=="56a98db8") // .[0] | .status')" = "completed" ]; do sleep 20; done; gh run list --branch dev-freeform --limit 1 --json status,conclusion,headSha --jq '.[0] | "\(.headSha[0:7]) \(.status) \(.conclusion)"')


Bash

until gh run list --branch dev-freeform --limit 1 --json status,conclusion,headSha --jq '.[0]' | grep -q '"status":"completed"'; do sleep 20; done; gh run list --branch dev-freeform --limit 1 --json status,conclusion,headSha --jq '.[0] | "\(.headSha[0:7]) \(.status) \(.conclusion)"'

Wait for D9-2-3 CI run completion


Command running in background with ID: bev1vzb02. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bev1vzb02.output. You will be notified when it completes. To check interim output, use Read on that file path.


While CI waits — D9-3 recon. How tests spawn broker PTY sessions:


201-    /// Spawn a session under a broker PTY; the broker auto-subscribes this
202-    /// connection. Reads until the `spawned` reply (consuming any output that
203-    /// races ahead).
204:    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
205-        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
206-        loop {
207-            match self.read_event()? {
208-                BrokerEvent::Spawned { session_id, .. } => {
209-                    self.session_id = Some(session_id);
210-                    return Ok(session_id);
211-                }
212-                BrokerEvent::Error { message } => {
213-                    return Err(io::Error::other(message));
214-                }
215-                _ => continue,
216-            }


crates\spt-daemon\src\msg.rs:102:pub struct SpawnReq {
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-103-    /// The program to exec (resolved on `PATH` by the OS spawn).
crates\spt-daemon\src\msg.rs-104-    pub program: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-105-    /// Its arguments, verbatim.
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-106-    #[serde(default)]
crates\spt-daemon\src\msg.rs-107-    pub args: Vec<String>,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-108-    /// Initial PTY rows.
crates\spt-daemon\src\msg.rs-109-    pub rows: u16,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-110-    /// Initial PTY cols.
crates\spt-daemon\src\msg.rs-111-    pub cols: u16,
crates\spt-daemon\src\msg.rs-112-    \ The endpoint this session belongs to (D9-1: the remote-drive
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-113-    /// dispatcher's gate subject — `serve_attach` gates on the *owning*
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-114-    /// endpoint's whitelist, resolved from the broker's table, never from
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-115-    /// wire bytes). Empty = unlabeled (a legacy/test spawn); the dispatcher
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-116-    /// refuses to serve an unlabeled session, fail-closed.
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-117-    #[serde(default)]
crates\spt-daemon\src\msg.rs-118-    pub endpoint: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-119-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-120-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-121-/// `spawned` payload — the broker-minted session id + the child's OS pid.
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-122-#[derive(Debug, Clone, Serialize, Deserialize)]
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-123-pub struct Spawned {
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-124-    /// The id the brain uses to address this session in later commands.
crates\spt-daemon\src\msg.rs-125-    pub session_id: u64,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-126-    /// The child's OS pid, if the backend exposes one.
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\msg.rs-127-    #[serde(default)]


<system-reminder>Warning: the file exists but is shorter than the provided offset (1). The file has 1 lines.</system-reminder>


CI still queued. Start D9-3 harness while waiting — copy broker-bind pattern from an existing E2E:


1	//! Inbound dispatch E2E (D9-1-1): the serve half of every wire protocol runs
2	//! **undriven** — no probe brain hand-wires `serve_*`; the daemon-side
3	//! dispatcher claims each peer-initiated stream, classifies it by first
4	//! record, and serves it. This is the production behavior the D5–D8 E2E
5	//! suites deferred ("the daemon dispatch").
6	//!
7	//! Topology per test: two hermetic loopback brokers (requester A, responder
8	//! B). B runs `run_dispatch_loop` like the real daemon; B's gates load from
9	//! the **canonical** SPT_HOME stores (the production path), so each test
10	//! seeds subnet/trust/visibility files first. Tests serialize on one lock —
11	//! they share the process's SPT_HOME.
12
13	use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
14	use std::sync::{Arc, Mutex, OnceLock};
15	use std::thread;
16	use std::time::Duration;
17
18	use spt_daemon::brain::Brain;
19	use spt_daemon::broker::Broker;
20	use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
21	use spt_daemon::nethost::{NetConfig, NetHost};
22	use spt_daemon::propagate::{request_update, UpdatePullOutcome};
23	use spt_daemon::relcache::ReleaseCache;
24	use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease, VerifyPolicy};
25	use spt_daemon::sync::request_sync;
26	use spt_daemon::update::{BrokerAbi, BROKER_RESOURCE_ABI};
27	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
28	use spt_net::net::notif::NotifRecord;
29	use spt_net::net::wanmsg::WanMessage;
30	use spt_proto::identity::Identity;
31	use spt_store::contextstore::ContextStore;
32	use spt_store::info::{self, InfoJson};
33	use spt_store::notif::{NotifRow, NotifStore};
34	use spt_store::perch::{self, ParentHint};
35	use spt_store::spool;
36	use spt_store::subnet::SubnetStore;
37	use spt_store::trust::TrustStore;
38	use spt_store::visibility::VisibilityStore;
39	use tempfile::TempDir;
40
41	/// One SPT_HOME for the whole binary; tests serialize on [`LOCK`] because the
42	/// dispatcher's gates read the canonical stores under it.
43	static HOME: OnceLock<TempDir> = OnceLock::new();
44	static LOCK: Mutex<()> = Mutex::new(());
45	fn init_home() {
46	    HOME.get_or_init(|| {
47	        let dir = TempDir::new().expect("temp dir");
48	        std::env::set_var("SPT_HOME", dir.path());
49	        dir
50	    });
51	}
52
53	static SEQ: AtomicU32 = AtomicU32::new(0);
54	fn unique_name() -> String {
55	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
56	    format!("spt-daemon-d91-{}-{}.sock", std::process::id(), n)
57	}
58
59	static OP: AtomicU64 = AtomicU64::new(1);
60	fn op() -> u64 {
61	    OP.fetch_add(1, Ordering::Relaxed)
62	}
63
64	fn hermetic() -> NetConfig {
65	    NetConfig {
66	        identity: Identity::generate(),
67	        relay: RelayPolicy::Disabled,
68	        discovery: LocalDiscovery::Off,
69	        scope: BindScope::Loopback,
70	    }
71	}
72
73	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
74	    let host = NetHost::start(hermetic()).expect("net host start");
75	    let broker =
76	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
77	    let serve = Arc::clone(&broker);
78	    thread::spawn(move || {
79	        let _ = serve.serve();
80	    });
81	    broker
82	}
83
84	fn connect_retry(name: &str) -> Brain {
85	    for _ in 0..300 {
86	        if let Ok(b) = Brain::cold_start(name, 1) {
87	            return b;
88	        }
89	        thread::sleep(Duration::from_millis(10));
90	    }
91	    panic!("brain could not connect");
92	}
93
94	/// Stand B's dispatcher up exactly as the daemon does, returning its stop
95	/// flag (set on test exit so the loop thread winds down).
96	fn spawn_dispatcher_for(broker_name: &str, scratch: &std::path::Path) -> Arc<AtomicBool> {
97	    let stop = Arc::new(AtomicBool::new(false));
98	    let flag = Arc::clone(&stop);
99	    let name = broker_name.to_string();
100	    let home = perch::spt_home();
101	    let paths = DispatchPaths {
102	        owlery: perch::owlery_dir(),
103	        releases: home.join("releases"),
104	        scratch: scratch.to_path_buf(),
105	    };
106	    let registry = Arc::new(spt_daemon::registryhost::RegistryHost::new("b-dispatch"));
107	    thread::spawn(move || {
108	        let _ = run_dispatch_loop(&name, &paths, registry, &flag, Duration::from_millis(25));
109	    });
110	    stop
111	}
112
113	/// Seed B's canonical gate stores: a subnet, A's pubkey trusted in it, and
114	/// (optionally) endpoint ids synced there. The dispatcher's serve arms load
115	/// these from SPT_HOME — the production read path.
116	fn seed_gates(subnet: &str, peer_hex: &str, synced_ids: &[&str]) {
117	    let mut subnets = SubnetStore::load();
118	    if !subnets.subnets.iter().any(|s| s.name == subnet) {
119	        subnets.create_subnet(subnet).expect("create subnet");
120	        subnets.save().expect("save subnets");

//! Inbound dispatch E2E (D9-1-1): the serve half of every wire protocol runs
//! **undriven** — no probe brain hand-wires `serve_*`; the daemon-side
//! dispatcher claims each peer-initiated stream, classifies it by first
//! record, and serves it. This is the production behavior the D5–D8 E2E
//! suites deferred ("the daemon dispatch").
//!
//! Topology per test: two hermetic loopback brokers (requester A, responder
//! B). B runs `run_dispatch_loop` like the real daemon; B's gates load from
//! the **canonical** SPT_HOME stores (the production path), so each test
//! seeds subnet/trust/visibility files first. Tests serialize on one lock —
//! they share the process's SPT_HOME.

use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::propagate::{request_update, UpdatePullOutcome};
use spt_daemon::relcache::ReleaseCache;
use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease, VerifyPolicy};
use spt_daemon::sync::request_sync;
use spt_daemon::update::{BrokerAbi, BROKER_RESOURCE_ABI};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::notif::NotifRecord;
use spt_net::net::wanmsg::WanMessage;
use spt_proto::identity::Identity;
use spt_store::contextstore::ContextStore;
use spt_store::info::{self, InfoJson};
use spt_store::notif::{NotifRow, NotifStore};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;
use tempfile::TempDir;

/// One SPT_HOME for the whole binary; tests serialize on [`LOCK`] because the
/// dispatcher's gates read the canonical stores under it.
static HOME: OnceLock<TempDir> = OnceLock::new();
static LOCK: Mutex<()> = Mutex::new(());
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-d91-{}-{}.sock", std::process::id(), n)
}

static OP: AtomicU64 = AtomicU64::new(1);
fn op() -> u64 {
    OP.fetch_add(1, Ordering::Relaxed)
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

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

/// Stand B's dispatcher up exactly as the daemon does, returning its stop
/// flag (set on test exit so the loop thread winds down).
fn spawn_dispatcher_for(broker_name: &str, scratch: &std::path::Path) -> Arc<AtomicBool> {
    let stop = Arc::new(AtomicBool::new(false));
    let flag = Arc::clone(&stop);
    let name = broker_name.to_string();
    let home = perch::spt_home();
    let paths = DispatchPaths {
        owlery: perch::owlery_dir(),
        releases: home.join("releases"),
        scratch: scratch.to_path_buf(),
    };
    let registry = Arc::new(spt_daemon::registryhost::RegistryHost::new("b-dispatch"));
    thread::spawn(move || {
        let _ = run_dispatch_loop(&name, &paths, registry, &flag, Duration::from_millis(25));
    });
    stop
}

/// Seed B's canonical gate stores: a subnet, A's pubkey trusted in it, and
/// (optionally) endpoint ids synced there. The dispatcher's serve arms load
/// these from SPT_HOME — the production read path.
fn seed_gates(subnet: &str, peer_hex: &str, synced_ids: &[&str]) {
    let mut subnets = SubnetStore::load();
    if !subnets.subnets.iter().any(|s| s.name == subnet) {
        subnets.create_subnet(subnet).expect("create subnet");
        subnets.save().expect("save subnets");


Grep

KIND_[A-Z_]+|pub fn kill|pub fn stop_session


22:KIND_SPAWN
24:KIND_SPAWNED
26:KIND_SUBSCRIBE
28:KIND_INPUT
30:KIND_RESIZE
32:KIND_KILL
34:KIND_OUTPUT
36:KIND_EXIT
41:KIND_APPLIED
43:KIND_ERROR
47:KIND_NET_STATUS
49:KIND_NET_STATUS_REPLY
52:KIND_NET_DIAL
54:KIND_APPLIED
55:KIND_NET_DIALED
58:KIND_NET_STREAM_OPEN
60:KIND_NET_STREAM_OPENED
62:KIND_NET_SENT
63:KIND_NET_STREAM_SEND
65:KIND_NET_SENT
69:KIND_NET_STREAMS
71:KIND_NET_STREAMS_REPLY
74:KIND_NET_STREAM_SUBSCRIBE
76:KIND_NET_STREAM_DATA
79:KIND_NET_STREAM_EOF
84:KIND_NET_PRESENCE_SUBSCRIBE
86:KIND_NET_PRESENCE_EVENT
95:KIND_SESSIONS
96:KIND_SESSIONS
97:KIND_SESSIONS_REPLY
153:KIND_APPLIED
312:KIND_NET_STREAM_EOF
441:KIND_OUTPUT
451:KIND_APPLIED
461:KIND_NET_STREAM_DATA
470:KIND_NET_STREAM_EOF
481:KIND_NET_PRESENCE_EVENT
495:KIND_NET_DIALED
545:KIND_NET_DIALED
562:KIND_NET_PRESENCE_EVENT

[Showing results with pagination = limit: 40]


264:    pub fn kill_session(&mut self) -> io::Result<()> {
265-        let sid = self.require_session()?;
266-        self.send(
267-            KIND_KILL,
268-            serde_json::to_value(KillReq { session_id: sid }).expect("KillReq serializes"),
269-        )
270-    }


Write the D9-3 budget harness:


Write

//! Dormancy resource-budget measurement (D9-3, ADR-0003 red-team #9).
//!
//! **Env-gated: runs only with `SPT_BUDGET=1`** — CI never sets it, so this
//! is a silent no-op there; the real runs happen manually on the rig hosts
//! (HFENDULEAM Windows + gravity Linux) and their output is recorded in
//! `docs/DORMANCY-BUDGET.md`. Not a pass/fail test: it MEASURES the warm
//! (dormant) seat cost so the warm/cold default is a number-backed policy
//! instead of a guess (the #9 gate on REQ-INST-3's `doc` stage).
//!
//! ## What it measures
//!
//! A **dormant** seat is a live, state-preserved harness session held warm
//! by the broker (PTY master + child process + output ring). A **suspended**
//! seat is that session closed — only the on-disk perch record remains. So:
//!
//! 1. spawn N idle sessions through the real broker (the production PTY
//!    hosting path), labeled per seat;
//! 2. settle, then sample each child's RSS / handle (fd) count / CPU time,
//!    twice over an idle window (the delta = idle CPU burn);
//! 3. also sample THIS process's RSS before/after spawning (the broker-side
//!    per-seat overhead: PTY plumbing + output rings);
//! 4. kill the sessions (the suspended state) and sample the residual.
//!
//! The per-seat child cost + broker delta IS the warm premium over
//! suspended; the suspended residual is ~the daemon baseline.
//!
//! ## Knobs (env)
//!
//! - `SPT_BUDGET_N` — seats to spawn (default 5).
//! - `SPT_BUDGET_PROGRAM` / `SPT_BUDGET_ARGS` — the harness command per seat
//!   (space-split args). Default: the platform shell idling at a prompt. On
//!   a rig run, point it at the real adapter's harness binary (e.g.
//!   `claude`) — the "real adapter" #9 asks for.
//! - `SPT_BUDGET_SETTLE_MS` — post-spawn settle before the first sample
//!   (default 3000; raise for a heavy harness that loads slowly).
//! - `SPT_BUDGET_IDLE_MS` — the idle CPU window between samples (default 5000).
// [impl->REQ-INST-3]

use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::msg::SpawnReq;
use tempfile::TempDir;

/// One sampled process snapshot.
#[derive(Debug, Clone, Copy, Default)]
struct Sample {
    rss_bytes: u64,
    handles: u64,
    cpu_seconds: f64,
}

#[test]
fn measure_dormant_seat_budget() {
    if std::env::var("SPT_BUDGET").as_deref() != Ok("1") {
        return; // silent skip — the rig runs this, never CI
    }

    let n: usize = env_or("SPT_BUDGET_N", 5);
    let settle = Duration::from_millis(env_or("SPT_BUDGET_SETTLE_MS", 3_000));
    let idle = Duration::from_millis(env_or("SPT_BUDGET_IDLE_MS", 5_000));
    let (program, args) = budget_command();

    // Hermetic home + a real (net-less) broker — the production PTY host.
    let home = TempDir::new().expect("temp home");
    std::env::set_var("SPT_HOME", home.path());
    let name = format!("spt-budget-{}.sock", std::process::id());
    let broker = Broker::bind_in_with_net(&name, home.path().join("effects.log"), None)
        .expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });

    let self_before = sample_pid(std::process::id()).unwrap_or_default();

    // One probe brain per seat (kill_session targets the brain's own session).
    let mut seats: Vec<(Brain, u64, u32)> = Vec::new(); // (brain, session, child pid)
    for i in 0..n {
        let mut brain = connect_retry(&name);
        let req = SpawnReq {
            program: program.clone(),
            args: args.clone(),
            rows: 24,
            cols: 80,
            endpoint: format!("budget-{i}"),
        };
        let session = brain.spawn_session(req).expect("spawn seat");
        let pid = brain.session_pid().expect("seat child pid");
        seats.push((brain, session, pid));
    }

    thread::sleep(settle);
    let first: Vec<Sample> =
        seats.iter().map(|(_, _, pid)| sample_pid(*pid).unwrap_or_default()).collect();
    let self_warm = sample_pid(std::process::id()).unwrap_or_default();

    thread::sleep(idle);
    let second: Vec<Sample> =
        seats.iter().map(|(_, _, pid)| sample_pid(*pid).unwrap_or_default()).collect();

    // The measurement report — the runbook captures this stdout block into
    // docs/DORMANCY-BUDGET.md verbatim.
    println!("## dormancy budget — {} x `{program} {}`", n, args.join(" "));
    println!("host: {} ({})", hostname(), std::env::consts::OS);
    println!();
    println!("| seat | pid | rss | handles/fds | idle cpu (s over {:?}) |", idle);
    println!("|------|-----|-----|-------------|------------------------|");
    let (mut rss_total, mut handle_total, mut cpu_total) = (0u64, 0u64, 0f64);
    for (i, (a, b)) in first.iter().zip(&second).enumerate() {
        let burn = (b.cpu_seconds - a.cpu_seconds).max(0.0);
        rss_total += b.rss_bytes;
        handle_total += b.handles;
        cpu_total += burn;
        println!(
            "| budget-{i} | {} | {} | {} | {burn:.3} |",
            seats[i].2,
            fmt_bytes(b.rss_bytes),
            b.handles
        );
    }
    println!();
    println!(
        "per-seat avg: rss {} · {} handles/fds · idle cpu {:.3} s/{:?}",
        fmt_bytes(rss_total / n as u64),
        handle_total / n as u64,
        cpu_total / n as f64,
        idle
    );
    println!(
        "broker-side overhead for {n} seats (this process RSS delta): {}",
        fmt_bytes(self_warm.rss_bytes.saturating_sub(self_before.rss_bytes))
    );

    // Suspend: close every seat (the session ends; only the perch record
    // would remain in production). The residual is the daemon baseline.
    for (brain, _, _) in seats.iter_mut() {
        let _ = brain.kill_session();
    }
    thread::sleep(Duration::from_millis(1_500));
    let self_after = sample_pid(std::process::id()).unwrap_or_default();
    println!(
        "suspended residual (this process RSS after killing all seats): {} (was {} warm)",
        fmt_bytes(self_after.rss_bytes),
        fmt_bytes(self_warm.rss_bytes)
    );
}

fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
    std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
}

/// The per-seat harness command: `SPT_BUDGET_PROGRAM`/`SPT_BUDGET_ARGS`, or
/// the platform shell idling at a prompt.
fn budget_command() -> (String, Vec<String>) {
    let program = std::env::var("SPT_BUDGET_PROGRAM").unwrap_or_else(|_| {
        if cfg!(windows) { "cmd".to_string() } else { "sh".to_string() }
    });
    let args = std::env::var("SPT_BUDGET_ARGS")
        .map(|s| s.split_whitespace().map(str::to_string).collect())
        .unwrap_or_default();
    (program, args)
}

fn hostname() -> String {
    std::env::var("COMPUTERNAME")
        .or_else(|_| std::env::var("HOSTNAME"))
        .unwrap_or_else(|_| "unknown".to_string())
}

fn fmt_bytes(b: u64) -> String {
    if b >= 1 << 20 {
        format!("{:.1} MiB", b as f64 / (1 << 20) as f64)
    } else {
        format!("{:.1} KiB", b as f64 / 1024.0)
    }
}

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

/// Sample one process: RSS bytes, handle/fd count, cumulative CPU seconds.
/// Shelling out keeps the harness dependency-free — measurement cadence is
/// seconds, not microseconds.
#[cfg(windows)]
fn sample_pid(pid: u32) -> Option<Sample> {
    let out = std::process::Command::new("powershell")
        .args([
            "-NoProfile",
            "-Command",
            &format!(
                "$p = Get-Process -Id {pid} -ErrorAction Stop; \
                 Write-Output \"$($p.WorkingSet64)|$($p.HandleCount)|$($p.TotalProcessorTime.TotalSeconds)\""
            ),
        ])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    let mut parts = text.trim().split('|');
    Some(Sample {
        rss_bytes: parts.next()?.parse().ok()?,
        handles: parts.next()?.parse().ok()?,
        cpu_seconds: parts.next()?.parse().ok()?,
    })
}

#[cfg(unix)]
fn sample_pid(pid: u32) -> Option<Sample> {
    // VmRSS from /proc/<pid>/status (kB).
    let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
    let rss_kb: u64 = status
        .lines()
        .find(|l| l.starts_with("VmRSS:"))?
        .split_whitespace()
        .nth(1)?
        .parse()
        .ok()?;
    // Open fd count.
    let fds = std::fs::read_dir(format!("/proc/{pid}/fd")).map(|d| d.count() as u64).unwrap_or(0);
    // utime+stime (clock ticks) from /proc/<pid>/stat — fields 14+15, counted
    // after the parenthesized comm (which may contain spaces).
    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
    let after_comm = stat.rsplit_once(')')?.1;
    let fields: Vec<&str> = after_comm.split_whitespace().collect();
    let utime: u64 = fields.get(11)?.parse().ok()?; // field 14 overall
    let stime: u64 = fields.get(12)?.parse().ok()?; // field 15 overall
    let hz = 100.0; // USER_HZ on every Linux the rig runs
    Some(Sample {
        rss_bytes: rss_kb * 1024,
        handles: fds,
        cpu_seconds: (utime + stime) as f64 / hz,
    })
}


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

//! Dormancy resource-budget measurement (D9-3, ADR-0003 red-team #9).
//!
//! **Env-gated: runs only with `SPT_BUDGET=1`** — CI never sets it, so this
//! is a silent no-op there; the real runs happen manually on the rig hosts
//! (HFENDULEAM Windows + gravity Linux) and their output is recorded in
//! `docs/DORMANCY-BUDGET.md`. Not a pass/fail test: it MEASURES the warm
//! (dormant) seat cost so the warm/cold default is a number-backed policy
//! instead of a guess (the #9 gate on REQ-INST-3's `doc` stage).
//!
//! ## What it measures
//!
//! A **dormant** seat is a live, state-preserved harness session held warm
//! by the broker (PTY master + child process + output ring). A **suspended**
//! seat is that session closed — only the on-disk perch record remains. So:
//!
//! 1. spawn N idle sessions through the real broker (the production PTY
//!    hosting path), labeled per seat;
//! 2. settle, then sample each child's RSS / handle (fd) count / CPU time,
//!    twice over an idle window (the delta = idle CPU burn);
//! 3. also sample THIS process's RSS before/after spawning (the broker-side
//!    per-seat overhead: PTY plumbing + output rings);
//! 4. kill the sessions (the suspended state) and sample the residual.
//!
//! The per-seat child cost + broker delta IS the warm premium over
//! suspended; the suspended residual is ~the daemon baseline.
//!
//! ## Knobs (env)
//!
//! - `SPT_BUDGET_N` — seats to spawn (default 5).
//! - `SPT_BUDGET_PROGRAM` / `SPT_BUDGET_ARGS` — the harness command per seat
//!   (space-split args). Default: the platform shell idling at a prompt. On
//!   a rig run, point it at the real adapter's harness binary (e.g.
//!   `claude`) — the "real adapter" #9 asks for.
//! - `SPT_BUDGET_SETTLE_MS` — post-spawn settle before the first sample
//!   (default 3000; raise for a heavy harness that loads slowly).
//! - `SPT_BUDGET_IDLE_MS` — the idle CPU window between samples (default 5000).
// [impl->REQ-INST-3]

use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::msg::SpawnReq;
use tempfile::TempDir;

/// One sampled process snapshot.
#[derive(Debug, Clone, Copy, Default)]
struct Sample {
    rss_bytes: u64,
    handles: u64,
    cpu_seconds: f64,
}

#[test]
fn measure_dormant_seat_budget() {
    if std::env::var("SPT_BUDGET").as_deref() != Ok("1") {
        return; // silent skip — the rig runs this, never CI
    }

    let n: usize = env_or("SPT_BUDGET_N", 5);
    let settle = Duration::from_millis(env_or("SPT_BUDGET_SETTLE_MS", 3_000));
    let idle = Duration::from_millis(env_or("SPT_BUDGET_IDLE_MS", 5_000));
    let (program, args) = budget_command();

    // Hermetic home + a real (net-less) broker — the production PTY host.
    let home = TempDir::new().expect("temp home");
    std::env::set_var("SPT_HOME", home.path());
    let name = format!("spt-budget-{}.sock", std::process::id());
    let broker = Broker::bind_in_with_net(&name, home.path().join("effects.log"), None)
        .expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });

    let self_before = sample_pid(std::process::id()).unwrap_or_default();

    // One probe brain per seat (kill_session targets the brain's own session).
    let mut seats: Vec<(Brain, u64, u32)> = Vec::new(); // (brain, session, child pid)
    for i in 0..n {
        let mut brain = connect_retry(&name);
        let req = SpawnReq {
            program: program.clone(),
            args: args.clone(),
            rows: 24,
            cols: 80,
            endpoint: format!("budget-{i}"),
        };
        let session = brain.spawn_session(req).expect("spawn seat");
        let pid = brain.session_pid().expect("seat child pid");
        seats.push((brain, session, pid));
    }

    thread::sleep(settle);
    let first: Vec<Sample> =
        seats.iter().map(|(_, _, pid)| sample_pid(*pid).unwrap_or_default()).collect();
    let self_warm = sample_pid(std::process::id()).unwrap_or_default();

    thread::sleep(idle);
    let second: Vec<Sample> =
        seats.iter().map(|(_, _, pid)| sample_pid(*pid).unwrap_or_default()).collect();

    // The measurement report — the runbook captures this stdout block into
    // docs/DORMANCY-BUDGET.md verbatim.
    println!("## dormancy budget — {} x `{program} {}`", n, args.join(" "));
    println!("host: {} ({})", hostname(), std::env::consts::OS);
    println!();
    println!("| seat | pid | rss | handles/fds | idle cpu (s over {:?}) |", idle);
    println!("|------|-----|-----|-------------|------------------------|");
    let (mut rss_total, mut handle_total, mut cpu_total) = (0u64, 0u64, 0f64);
    for (i, (a, b)) in first.iter().zip(&second).enumerate() {
        let burn = (b.cpu_seconds - a.cpu_seconds).max(0.0);
        rss_total += b.rss_bytes;
        handle_total += b.handles;
        cpu_total += burn;
        println!(
            "| budget-{i} | {} | {} | {} | {burn:.3} |",
            seats[i].2,
            fmt_bytes(b.rss_bytes),
            b.handles
        );
    }
    println!();
    println!(
        "per-seat avg: rss {} · {} handles/fds · idle cpu {:.3} s/{:?}",
        fmt_bytes(rss_total / n as u64),
        handle_total / n as u64,
        cpu_total / n as f64,
        idle
    );
    println!(
        "broker-side overhead for {n} seats (this process RSS delta): {}",
        fmt_bytes(self_warm.rss_bytes.saturating_sub(self_before.rss_bytes))
    );

    // Suspend: close every seat (the session ends; only the perch record
    // would remain in production). The residual is the daemon baseline.
    for (brain, _, _) in seats.iter_mut() {
        let _ = brain.kill_session();
    }
    thread::sleep(Duration::from_millis(1_500));
    let self_after = sample_pid(std::process::id()).unwrap_or_default();
    println!(
        "suspended residual (this process RSS after killing all seats): {} (was {} warm)",
        fmt_bytes(self_after.rss_bytes),
        fmt_bytes(self_warm.rss_bytes)
    );
}

fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
    std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
}

/// The per-seat harness command: `SPT_BUDGET_PROGRAM`/`SPT_BUDGET_ARGS`, or
/// the platform shell idling at a prompt.
fn budget_command() -> (String, Vec<String>) {
    let program = std::env::var("SPT_BUDGET_PROGRAM").unwrap_or_else(|_| {
        if cfg!(windows) { "cmd".to_string() } else { "sh".to_string() }
    });
    let args = std::env::var("SPT_BUDGET_ARGS")
        .map(|s| s.split_whitespace().map(str::to_string).collect())
        .unwrap_or_default();
    (program, args)
}

fn hostname() -> String {
    std::env::var("COMPUTERNAME")
        .or_else(|_| std::env::var("HOSTNAME"))
        .unwrap_or_else(|_| "unknown".to_string())
}

fn fmt_bytes(b: u64) -> String {
    if b >= 1 << 20 {
        format!("{:.1} MiB", b as f64 / (1 << 20) as f64)
    } else {
        format!("{:.1} KiB", b as f64 / 1024.0)
    }
}

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

/// Sample one process: RSS bytes, handle/fd count, cumulative CPU seconds.
/// Shelling out keeps the harness dependency-free — measurement cadence is
/// seconds, not microseconds.
#[cfg(windows)]
fn sample_pid(pid: u32) -> Option<Sample> {
    let out = std::process::Command::new("powershell")
        .args([
            "-NoProfile",
            "-Command",
            &format!(
                "$p = Get-Process -Id {pid} -ErrorAction Stop; \
                 Write-Output \"$($p.WorkingSet64)|$($p.HandleCount)|$($p.TotalProcessorTime.TotalSeconds)\""
            ),
        ])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    let mut parts = text.trim().split('|');
    Some(Sample {
        rss_bytes: parts.next()?.parse().ok()?,
        handles: parts.next()?.parse().ok()?,
        cpu_seconds: parts.next()?.parse().ok()?,
    })
}

#[cfg(unix)]
fn sample_pid(pid: u32) -> Option<Sample> {
    // VmRSS from /proc/<pid>/status (kB).
    let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
    let rss_kb: u64 = status
        .lines()
        .find(|l| l.starts_with("VmRSS:"))?
        .split_whitespace()
        .nth(1)?
        .parse()
        .ok()?;
    // Open fd count.
    let fds = std::fs::read_dir(format!("/proc/{pid}/fd")).map(|d| d.count() as u64).unwrap_or(0);
    // utime+stime (clock ticks) from /proc/<pid>/stat — fields 14+15, counted
    // after the parenthesized comm (which may contain spaces).
    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
    let after_comm = stat.rsplit_once(')')?.1;
    let fields: Vec<&str> = after_comm.split_whitespace().collect();
    let utime: u64 = fields.get(11)?.parse().ok()?; // field 14 overall
    let stime: u64 = fields.get(12)?.parse().ok()?; // field 15 overall
    let hz = 100.0; // USER_HZ on every Linux the rig runs
    Some(Sample {
        rss_bytes: rss_kb * 1024,
        handles: fds,
        cpu_seconds: (utime + stime) as f64 / hz,
    })
}


Check `BrokerEvent::Spawned` fields for child pid access:


79-    Output { session_id: u64, seq: u64, bytes: Vec<u8> },
80-    /// A spawn succeeded.
81:    Spawned { session_id: u64, pid: Option<u32> },
82-    /// A session's child exited.
83-    Exit { session_id: u64, code: Option<i32> },
84-    /// An idempotent effect is durably applied (or was deduped — `applied_now`
85-    \ distinguishes). The exactly-once ack for a [`Brain::send_effect`] (B5).
86-    Applied { session_id: u64, op_id: u64, applied_now: bool },
87-    /// The broker-owned net endpoint's status (a `net-status` reply, D4a).
88-    NetStatus(NetStatusReply),
89-    /// A net dial durably landed — fresh or deduped (D4a).
--
206-        loop {
207-            match self.read_event()? {
208:                BrokerEvent::Spawned { session_id, .. } => {
209-                    self.session_id = Some(session_id);
210-                    return Ok(session_id);
211-                }
212-                BrokerEvent::Error { message } => {
213-                    return Err(io::Error::other(message));
214-                }
215-                _ => continue,
216-            }
--
321-                    let ev: Spawned = serde_json::from_value(env.payload)
322-                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
323:                    return Ok(BrokerEvent::Spawned { session_id: ev.session_id, pid: ev.pid });
324-                }
325-                KIND_EXIT => {
326-                    let ev: ExitEvent = serde_json::from_value(env.payload)
327-                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
328-                    return Ok(BrokerEvent::Exit { session_id: ev.session_id, code: ev.code });
329-                }
330-                KIND_APPLIED => {
331-                    let ev: AppliedEvent = serde_json::from_value(env.payload)


Grep

SessionsReply|struct SessionInfo|pub fn sessions


crates\spt-daemon\src\brain.rs:39:    NetStreamsReply, OutputEvent, ResizeReq, SessionsReply, SpawnReq, Spawned, SubscribeReq,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-40-    KIND_APPLIED, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_NET_DIAL, KIND_NET_DIALED,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-41-    KIND_NET_PRESENCE_EVENT, KIND_NET_PRESENCE_SUBSCRIBE, KIND_NET_SENT, KIND_NET_STATUS,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-42-    KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY, KIND_NET_STREAM_DATA,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-43-    KIND_NET_STREAM_EOF, KIND_NET_STREAM_OPEN, KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-44-    KIND_NET_STREAM_SUBSCRIBE, KIND_OUTPUT, KIND_RESIZE, KIND_SESSIONS, KIND_SESSIONS_REPLY,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-45-    KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-46-};
crates\spt-daemon\src\brain.rs-47-use crate::transport::{send_hello, LocalSocketTransport};
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-48-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-49-/// Wall-clock now in epoch milliseconds — the source for `gen_start` (matches
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-50-/// the epoch-ms stamping used elsewhere in the tree; no date dependency).
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-51-pub fn now_ms() -> u64 {
crates\spt-daemon\src\brain.rs-52-    SystemTime::now()
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-53-        .duration_since(UNIX_EPOCH)
--
crates\spt-daemon\src\brain.rs:98:    Sessions(SessionsReply),
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-99-    /// A contiguous chunk of a stream's reads (the cursor has advanced past it).
crates\spt-daemon\src\brain.rs-100-    NetStreamData { stream_id: u64, seq: u64, bytes: Vec<u8> },
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-101-    /// A stream's read side finished cleanly; `next_seq` is the completeness
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-102-    /// check (the subscriber's cursor must equal it to have missed nothing).
crates\spt-daemon\src\brain.rs-103-    NetStreamEof { stream_id: u64, next_seq: u64 },
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-104-    /// A contiguous conn-liveness event (the presence cursor has advanced past
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-105-    /// it) — connected/disconnected on the broker's conn table (D4c).
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-106-    NetPresence(NetPresenceEvent),
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-107-    /// The broker reported a command error.
crates\spt-daemon\src\brain.rs-108-    Error { message: String },
crates\spt-daemon\src\brain.rs-109-    \ A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-110-    Other(Envelope),
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-111-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-112-
--
crates\spt-daemon\src\brain.rs:365:                    let ev: SessionsReply = serde_json::from_value(env.payload)
crates\spt-daemon\src\brain.rs-366-                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
crates\spt-daemon\src\brain.rs-367-                    return Ok(BrokerEvent::Sessions(ev));
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-368-                }
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-369-                KIND_NET_STREAM_DATA => {
crates\spt-daemon\src\brain.rs-370-                    let ev: NetStreamData = serde_json::from_value(env.payload)
crates\spt-daemon\src\brain.rs-371-                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
crates\spt-daemon\src\brain.rs-372-                    \ Same exactly-once discipline as PTY output, per stream:
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-373-                    // accept the contiguous next seq, drop a replay duplicate,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-374-                    // reject a forward gap (a lost chunk must not pass silently).
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-375-                    let cursor = self.net_cursors.entry(ev.stream_id).or_insert(ev.seq);
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-376-                    if ev.seq < *cursor {
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-377-                        continue; // replay duplicate — dedup
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-378-                    }
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-379-                    if ev.seq > *cursor {
--
crates\spt-daemon\src\brain.rs:591:    pub fn sessions(&mut self) -> io::Result<SessionsReply> {
crates\spt-daemon\src\brain.rs-592-        self.send(KIND_SESSIONS, serde_json::Value::Null)?;
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-593-        loop {
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-594-            match self.read_event()? {
crates\spt-daemon\src\brain.rs-595-                BrokerEvent::Sessions(ev) => return Ok(ev),
crates\spt-daemon\src\brain.rs-596-                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-597-                _ => continue,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-598-            }
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-599-        }
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-600-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-601-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\brain.rs-602-    /// (Re)subscribe to a stream's buffered reads from `from_seq`, setting the

[Showing results with pagination = limit: 60]


370:pub struct SessionsReply {
371-    #[serde(default)]
372:    pub sessions: Vec<SessionInfo>,
373-}
374-
375-/// `net-stream-subscribe` payload — attach this connection as a stream's read
376-/// subscriber, replaying buffered chunks with `seq >= from_seq` first (D4b).
377-#[derive(Debug, Clone, Serialize, Deserialize)]
378-pub struct NetStreamSubscribeReq {
379-    /// Target stream.
380-    pub stream_id: u64,
381-    /// Lowest read sequence the subscriber still needs (replay floor).
382-    pub from_seq: u64,
383-}
384-
385-/// `net-stream-data` event — one sequenced chunk of a stream's reads.
386-#[derive(Debug, Clone, Serialize, Deserialize)]
387-pub struct NetStreamData {
388-    /// Originating stream.
389-    pub stream_id: u64,
390-    /// The broker-assigned, per-stream monotonic sequence (the resume cursor).


360:pub struct SessionInfo {
361-    /// The broker-minted session id.
362-    pub session_id: u64,
363-    /// The owning endpoint (the spawn's label; empty = unlabeled).
364-    #[serde(default)]
365-    pub endpoint: String,
366-}
367-
368-/// `sessions-reply` payload — every session the broker currently hosts.
369-#[derive(Debug, Clone, Serialize, Deserialize)]
370-pub struct SessionsReply {
371-    #[serde(default)]
372-    pub sessions: Vec<SessionInfo>,
373-}
374-
375-/// `net-stream-subscribe` payload — attach this connection as a stream's read


No pid in session table. Add a pid-returning spawn variant to Brain:


196	            next_seq: self.next_seq,
197	            gen_start_ms: self.gen_start_ms,
198	        }
199	    }
200
201	    /// Spawn a session under a broker PTY; the broker auto-subscribes this
202	    /// connection. Reads until the `spawned` reply (consuming any output that
203	    /// races ahead).
204	    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
205	        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
206	        loop {
207	            match self.read_event()? {
208	                BrokerEvent::Spawned { session_id, .. } => {
209	                    self.session_id = Some(session_id);
210	                    return Ok(session_id);
211	                }
212	                BrokerEvent::Error { message } => {
213	                    return Err(io::Error::other(message));
214	                }
215	                _ => continue,
216	            }
217	        }
218	    }
219
220	    /// Inject input bytes into the current session (send-keys), the **legacy
221	    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
222	    /// of an effect that originates from a durable source (a spool row, a
223	    /// sequenced injection), use [`Brain::send_effect`].

            next_seq: self.next_seq,
            gen_start_ms: self.gen_start_ms,
        }
    }

    /// Spawn a session under a broker PTY; the broker auto-subscribes this
    /// connection. Reads until the `spawned` reply (consuming any output that
    /// races ahead).
    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
        loop {
            match self.read_event()? {
                BrokerEvent::Spawned { session_id, .. } => {
                    self.session_id = Some(session_id);
                    return Ok(session_id);
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => continue,
            }
        }
    }

    /// Inject input bytes into the current session (send-keys), the **legacy
    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
    /// of an effect that originates from a durable source (a spool row, a
    /// sequenced injection), use [`Brain::send_effect`].


<task-notification>
<task-id>bev1vzb02</task-id>
<tool-use-id>toolu_01AAysWjaZFfmbVGmNzJmwHx</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bev1vzb02.output</output-file>
<status>completed</status>
<summary>Background command "Wait for D9-2-3 CI run completion" completed (exit code 0)</summary>
</task-notification>


Edit

    /// Spawn a session under a broker PTY; the broker auto-subscribes this
    /// connection. Reads until the `spawned` reply (consuming any output that
    /// races ahead).
    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
        loop {
            match self.read_event()? {
                BrokerEvent::Spawned { session_id, .. } => {
                    self.session_id = Some(session_id);
                    return Ok(session_id);
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => continue,
            }
        }
    }

    /// Spawn a session under a broker PTY; the broker auto-subscribes this
    /// connection. Reads until the `spawned` reply (consuming any output that
    /// races ahead).
    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
        self.spawn_session_pid(req).map(|(session_id, _)| session_id)
    }

    /// [`spawn_session`](Self::spawn_session) that also surfaces the child's
    /// OS pid (when the backend exposes one) — measurement/observability
    /// callers (the D9-3 dormancy-budget harness) sample the seat by pid.
    pub fn spawn_session_pid(&mut self, req: SpawnReq) -> io::Result<(u64, Option<u32>)> {
        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
        loop {
            match self.read_event()? {
                BrokerEvent::Spawned { session_id, pid } => {
                    self.session_id = Some(session_id);
                    return Ok((session_id, pid));
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => continue,
            }
        }
    }


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

    /// Spawn a session under a broker PTY; the broker auto-subscribes this
    /// connection. Reads until the `spawned` reply (consuming any output that
    /// races ahead).
    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
        loop {
            match self.read_event()? {
                BrokerEvent::Spawned { session_id, .. } => {
                    self.session_id = Some(session_id);
                    return Ok(session_id);
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => continue,
            }
        }
    }

    /// Spawn a session under a broker PTY; the broker auto-subscribes this
    /// connection. Reads until the `spawned` reply (consuming any output that
    /// races ahead).
    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
        self.spawn_session_pid(req).map(|(session_id, _)| session_id)
    }

    /// [`spawn_session`](Self::spawn_session) that also surfaces the child's
    /// OS pid (when the backend exposes one) — measurement/observability
    /// callers (the D9-3 dormancy-budget harness) sample the seat by pid.
    pub fn spawn_session_pid(&mut self, req: SpawnReq) -> io::Result<(u64, Option<u32>)> {
        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
        loop {
            match self.read_event()? {
                BrokerEvent::Spawned { session_id, pid } => {
                    self.session_id = Some(session_id);
                    return Ok((session_id, pid));
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => continue,
            }
        }
    }

+        self.spawn_session_pid(req).map(|(session_id, _)| session_id)

+    /// [`spawn_session`](Self::spawn_session) that also surfaces the child's

+    /// OS pid (when the backend exposes one) — measurement/observability

+    /// callers (the D9-3 dormancy-budget harness) sample the seat by pid.

+    pub fn spawn_session_pid(&mut self, req: SpawnReq) -> io::Result<(u64, Option<u32>)> {

-                BrokerEvent::Spawned { session_id, .. } => {

+                BrokerEvent::Spawned { session_id, pid } => {

-                    return Ok(session_id);

+                    return Ok((session_id, pid));


        let session = brain.spawn_session(req).expect("spawn seat");
        let pid = brain.session_pid().expect("seat child pid");
        seats.push((brain, session, pid));

        let (session, pid) = brain.spawn_session_pid(req).expect("spawn seat");
        let pid = pid.expect("seat child pid");
        seats.push((brain, session, pid));


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

        let (session, pid) = brain.spawn_session_pid(req).expect("spawn seat");
        let pid = pid.expect("seat child pid");
        seats.push((brain, session, pid));

//! Dormancy resource-budget measurement (D9-3, ADR-0003 red-team #9).
//!
//! **Env-gated: runs only with `SPT_BUDGET=1`** — CI never sets it, so this
//! is a silent no-op there; the real runs happen manually on the rig hosts
//! (HFENDULEAM Windows + gravity Linux) and their output is recorded in
//! `docs/DORMANCY-BUDGET.md`. Not a pass/fail test: it MEASURES the warm
//! (dormant) seat cost so the warm/cold default is a number-backed policy
//! instead of a guess (the #9 gate on REQ-INST-3's `doc` stage).
//!
//! ## What it measures
//!
//! A **dormant** seat is a live, state-preserved harness session held warm
//! by the broker (PTY master + child process + output ring). A **suspended**
//! seat is that session closed — only the on-disk perch record remains. So:
//!
//! 1. spawn N idle sessions through the real broker (the production PTY
//!    hosting path), labeled per seat;
//! 2. settle, then sample each child's RSS / handle (fd) count / CPU time,
//!    twice over an idle window (the delta = idle CPU burn);
//! 3. also sample THIS process's RSS before/after spawning (the broker-side
//!    per-seat overhead: PTY plumbing + output rings);
//! 4. kill the sessions (the suspended state) and sample the residual.
//!
//! The per-seat child cost + broker delta IS the warm premium over
//! suspended; the suspended residual is ~the daemon baseline.
//!
//! ## Knobs (env)
//!
//! - `SPT_BUDGET_N` — seats to spawn (default 5).
//! - `SPT_BUDGET_PROGRAM` / `SPT_BUDGET_ARGS` — the harness command per seat
//!   (space-split args). Default: the platform shell idling at a prompt. On
//!   a rig run, point it at the real adapter's harness binary (e.g.
//!   `claude`) — the "real adapter" #9 asks for.
//! - `SPT_BUDGET_SETTLE_MS` — post-spawn settle before the first sample
//!   (default 3000; raise for a heavy harness that loads slowly).
//! - `SPT_BUDGET_IDLE_MS` — the idle CPU window between samples (default 5000).
// [impl->REQ-INST-3]

use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::msg::SpawnReq;
use tempfile::TempDir;

/// One sampled process snapshot.
#[derive(Debug, Clone, Copy, Default)]
struct Sample {
    rss_bytes: u64,
    handles: u64,
    cpu_seconds: f64,
}

#[test]
fn measure_dormant_seat_budget() {
    if std::env::var("SPT_BUDGET").as_deref() != Ok("1") {
        return; // silent skip — the rig runs this, never CI
    }

    let n: usize = env_or("SPT_BUDGET_N", 5);
    let settle = Duration::from_millis(env_or("SPT_BUDGET_SETTLE_MS", 3_000));
    let idle = Duration::from_millis(env_or("SPT_BUDGET_IDLE_MS", 5_000));
    let (program, args) = budget_command();

    // Hermetic home + a real (net-less) broker — the production PTY host.
    let home = TempDir::new().expect("temp home");
    std::env::set_var("SPT_HOME", home.path());
    let name = format!("spt-budget-{}.sock", std::process::id());
    let broker = Broker::bind_in_with_net(&name, home.path().join("effects.log"), None)
        .expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });

    let self_before = sample_pid(std::process::id()).unwrap_or_default();

    // One probe brain per seat (kill_session targets the brain's own session).
    let mut seats: Vec<(Brain, u64, u32)> = Vec::new(); // (brain, session, child pid)
    for i in 0..n {
        let mut brain = connect_retry(&name);
        let req = SpawnReq {
            program: program.clone(),
            args: args.clone(),
            rows: 24,
            cols: 80,
            endpoint: format!("budget-{i}"),
        };
        let session = brain.spawn_session(req).expect("spawn seat");
        let pid = brain.session_pid().expect("seat child pid");
        seats.push((brain, session, pid));
    }

    thread::sleep(settle);
    let first: Vec<Sample> =
        seats.iter().map(|(_, _, pid)| sample_pid(*pid).unwrap_or_default()).collect();
    let self_warm = sample_pid(std::process::id()).unwrap_or_default();

    thread::sleep(idle);
    let second: Vec<Sample> =
        seats.iter().map(|(_, _, pid)| sample_pid(*pid).unwrap_or_default()).collect();

    // The measurement report — the runbook captures this stdout block into
    // docs/DORMANCY-BUDGET.md verbatim.
    println!("## dormancy budget — {} x `{program} {}`", n, args.join(" "));
    println!("host: {} ({})", hostname(), std::env::consts::OS);
    println!();
    println!("| seat | pid | rss | handles/fds | idle cpu (s over {:?}) |", idle);
    println!("|------|-----|-----|-------------|------------------------|");
    let (mut rss_total, mut handle_total, mut cpu_total) = (0u64, 0u64, 0f64);
    for (i, (a, b)) in first.iter().zip(&second).enumerate() {
        let burn = (b.cpu_seconds - a.cpu_seconds).max(0.0);
        rss_total += b.rss_bytes;
        handle_total += b.handles;
        cpu_total += burn;
        println!(
            "| budget-{i} | {} | {} | {} | {burn:.3} |",
            seats[i].2,
            fmt_bytes(b.rss_bytes),
            b.handles
        );
    }
    println!();
    println!(
        "per-seat avg: rss {} · {} handles/fds · idle cpu {:.3} s/{:?}",
        fmt_bytes(rss_total / n as u64),
        handle_total / n as u64,
        cpu_total / n as f64,
        idle
    );
    println!(
        "broker-side overhead for {n} seats (this process RSS delta): {}",
        fmt_bytes(self_warm.rss_bytes.saturating_sub(self_before.rss_bytes))
    );

    // Suspend: close every seat (the session ends; only the perch record
    // would remain in production). The residual is the daemon baseline.
    for (brain, _, _) in seats.iter_mut() {
        let _ = brain.kill_session();
    }
    thread::sleep(Duration::from_millis(1_500));
    let self_after = sample_pid(std::process::id()).unwrap_or_default();
    println!(
        "suspended residual (this process RSS after killing all seats): {} (was {} warm)",
        fmt_bytes(self_after.rss_bytes),
        fmt_bytes(self_warm.rss_bytes)
    );
}

fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
    std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
}

/// The per-seat harness command: `SPT_BUDGET_PROGRAM`/`SPT_BUDGET_ARGS`, or
/// the platform shell idling at a prompt.
fn budget_command() -> (String, Vec<String>) {
    let program = std::env::var("SPT_BUDGET_PROGRAM").unwrap_or_else(|_| {
        if cfg!(windows) { "cmd".to_string() } else { "sh".to_string() }
    });
    let args = std::env::var("SPT_BUDGET_ARGS")
        .map(|s| s.split_whitespace().map(str::to_string).collect())
        .unwrap_or_default();
    (program, args)
}

fn hostname() -> String {
    std::env::var("COMPUTERNAME")
        .or_else(|_| std::env::var("HOSTNAME"))
        .unwrap_or_else(|_| "unknown".to_string())
}

fn fmt_bytes(b: u64) -> String {
    if b >= 1 << 20 {
        format!("{:.1} MiB", b as f64 / (1 << 20) as f64)
    } else {
        format!("{:.1} KiB", b as f64 / 1024.0)
    }
}

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

/// Sample one process: RSS bytes, handle/fd count, cumulative CPU seconds.
/// Shelling out keeps the harness dependency-free — measurement cadence is
/// seconds, not microseconds.
#[cfg(windows)]
fn sample_pid(pid: u32) -> Option<Sample> {
    let out = std::process::Command::new("powershell")
        .args([
            "-NoProfile",
            "-Command",
            &format!(
                "$p = Get-Process -Id {pid} -ErrorAction Stop; \
                 Write-Output \"$($p.WorkingSet64)|$($p.HandleCount)|$($p.TotalProcessorTime.TotalSeconds)\""
            ),
        ])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    let mut parts = text.trim().split('|');
    Some(Sample {
        rss_bytes: parts.next()?.parse().ok()?,
        handles: parts.next()?.parse().ok()?,
        cpu_seconds: parts.next()?.parse().ok()?,
    })
}

#[cfg(unix)]
fn sample_pid(pid: u32) -> Option<Sample> {
    // VmRSS from /proc/<pid>/status (kB).
    let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
    let rss_kb: u64 = status
        .lines()
        .find(|l| l.starts_with("VmRSS:"))?
        .split_whitespace()
        .nth(1)?
        .parse()
        .ok()?;
    // Open fd count.
    let fds = std::fs::read_dir(format!("/proc/{pid}/fd")).map(|d| d.count() as u64).unwrap_or(0);
    // utime+stime (clock ticks) from /proc/<pid>/stat — fields 14+15, counted
    // after the parenthesized comm (which may contain spaces).
    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
    let after_comm = stat.rsplit_once(')')?.1;
    let fields: Vec<&str> = after_comm.split_whitespace().collect();
    let utime: u64 = fields.get(11)?.parse().ok()?; // field 14 overall
    let stime: u64 = fields.get(12)?.parse().ok()?; // field 15 overall
    let hz = 100.0; // USER_HZ on every Linux the rig runs
    Some(Sample {
        rss_bytes: rss_kb * 1024,
        handles: fds,
        cpu_seconds: (utime + stime) as f64 / hz,
    })
}

-        let session = brain.spawn_session(req).expect("spawn seat");

-        let pid = brain.session_pid().expect("seat child pid");

+        let (session, pid) = brain.spawn_session_pid(req).expect("spawn seat");

+        let pid = pid.expect("seat child pid");


Prompt:
<task-notification>
<task-id>bev1vzb02</task-id>
<tool-use-id>toolu_01AAysWjaZFfmbVGmNzJmwHx</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bev1vzb02.output</output-file>
<status>completed</status>
<summary>Background command "Wait for D9-2-3 CI run completion" completed (exit code 0)</summary>
</task-notification>


1	56a98db completed success
2

56a98db completed success


D9-2-3 CI FINAL green — **D9-2 COMPLETE**. Commit plan-status (separate from in-progress D9-3 files):


Updated task #3 status


PowerShell

git add M4-D9-PLAN.md && git commit -m @'
docs(plan): mark M4-D9-2 complete (three CI-green sub-slices)

REQ-INST-3 and REQ-INST-4 activated at [impl, unit]; the D8 deferrals
(wake-edge resurface, recency edge detection) closed; REQ-INST-6 and the
remote suspend arm documented as M5 seams.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@ && git push origin dev-freeform

Commit and push D9-2 plan status


warning: in the working copy of 'M4-D9-PLAN.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform a52dacb] docs(plan): mark M4-D9-2 complete (three CI-green sub-slices)
 1 file changed, 23 insertions(+), 1 deletion(-)
To https://github.com/SaberMage/spt-core.git
   56a98db..a52dacb  dev-freeform -> dev-freeform


D9-2 done. Commune to Psyche, then continue D9-3:


Write

<live-context>
Generation 15. D9-2 COMPLETE — three CI-green-FINAL sub-slices, plan-status @ a52dacb. D9-3 in flight (budget harness written, not yet committed).

## D9-2 COMPLETE (resting-state model — REQ-INST-3/4 activated [impl, unit])
- 0315783 D9-2-1: spt-daemon/resting.rs — pure transition table (active→dormant on Detach/AttentionShift; suspend from EITHER live state; opt-in auto-suspend counted from dormancy-onset anchor dormant_since_ms, NEVER last activity; wake = re-activate in place; event replay = no-edge by table idempotence). info.json grew rest_state + dormant_since_ms (atomic pair — anchor moves with state) + auto_suspend_after_ms endpoint override. Knob chain: global default OFF → daemon.json node leg → endpoint leg, 0 = explicit endpoint OFF (effective_auto_suspend). Registry Status grew Suspended (additive serde, ADDRESSABLE — only Offline unroutable; wake must route). advertise_local follows the machine (live+record → machine state; recordless/dead → legacy mapping), epoch-bumped per advert.
- 8a7afd6 D9-2-2: apply_event transition host — echo BEFORE flip persists (KH 3.3); echo failure LOUD but state persists (signoff_with posture — the driver being gone is a fact, echo is best-effort-loud). BrainLifecycle::rest_event binds real run_echo_commune. Wake fires resurface_at_boundary (D8 deferral closed) + freshness-pull marker identity/sync-pull-now; pump consumes via take_freshness_pull (remove IS the take) → forces one sync round NOW. PumpPaths grew pull_marker.
- 56a98db D9-2-3: production feeds. Attach arm: Request=Wake, viewport end (EOF/Exit/Error)=Detach, best-effort like mark_driven_by. Registry arm: apply_feed_flips — flip = Updated ∧ incoming Active ∧ prev != Active ∧ node != self (NEVER Inserted: in-memory registry restart re-learn must not knock seats dormant; never periodic Active re-adverts; lease drops stale first); dispatch feeds AttentionShift only for locally hosted ids. Pulse recency GATED on rest state (D8 gap closed): only active/recordless ticks stamp last_active_ms — resting seat never wins most-recently-active (REQ-UPD-4/notif first-fire); dormant tick checks auto-suspend threshold; suspended tick just ingests. spt suspend/spt wake local CLI arms (qualified id@node → CROSS_NODE_M5 refusal).

## Key architecture call (KH 7.4)
Daemon-side rest-edge echoes are GATE-ARMED (resting::arm_transition_echo writes the echo-gate sentinel) — endpoint's OWN pulse loop runs the bounded summarizer call. N flips in one feed = N gate files, never N LLM calls serialized in a dispatch worker. REQ-HAZARD-DAEMON-SCHED-NONBLOCKING stays required_stages=[] — holds by construction; binds at the per-agent-runtime fan-out (same seam as in-pump Psyche reconcile). Documented in toml comment.

## Seams documented
- REQ-INST-6 deferred-message gate: precondition (state machine + durable record) shipped; gate = M5. toml comment + resting.rs module doc.
- Remote suspend-from-anywhere arm (id@node) = M5.
- REQ-INST-3 doc stage = D9-3 DORMANCY-BUDGET.md (the warm/cold policy lock).

## D9-3 IN FLIGHT (dormancy budget, ADR-0003 red-team #9)
Written, uncommitted: crates/spt-daemon/tests/budget.rs — SPT_BUDGET=1 env-gated (CI silent-skips), spawns N seats via real broker PTY hosting (SPT_BUDGET_N/PROGRAM/ARGS/SETTLE_MS/IDLE_MS knobs; default platform shell, rig run points at real adapter e.g. claude), samples RSS/handles/CPU per child twice over idle window (delta = idle burn) + this-process RSS delta (broker-side per-seat overhead) + post-kill residual (suspended baseline). Prints markdown table for DORMANCY-BUDGET.md capture. Brain grew spawn_session_pid (spawn_session delegates). Next: build+clippy, run on HFENDULEAM (shell + claude), gravity leg (runner or runbook), write docs/DORMANCY-BUDGET.md + ADR-0003 #9 closed + CONTEXT policy confirm + <!-- [doc->REQ-INST-3] -->.
Then D9-4 (INST-14 resources blurb), D9-5 (INST-15 home+fork), D9-6 (two-host+runbook+gh-interim retire), D9-7 (sweep).

## Workflow constraints, gotchas 1–10, flake ledger, CI topology: unchanged, carry all.

## No Blockers
</live-context>
<project-context>
name=spt-core

M4-D9 in flight: D9-1 COMPLETE cb81e83 (production trigger loops). D9-2 COMPLETE a52dacb (resting-state model: pure transition table + durable rest_state record + Status::Suspended + advertise-follows-state; transition echo gate-armed cross-process KH 7.4-safe; wake resurface + freshness pull pump leg; production feeds attach/registry-flip/pulse; spt suspend/wake local). REQ-INST-3/4 active [impl, unit]. Plan = M4-D9-PLAN.md (7 slices). Remaining: D9-3 dormancy budget (harness drafted), D9-4 resource advertisement, D9-5 home subnet + fork, D9-6 two-host rig proof + gh-interim retirement, D9-7 sweep. Conventions hold: no cargo fmt; evidence-only tags; traceable-reqs check repo root; clippy -D warnings; push slice → watch FINAL via gh run list (gotcha #7); commit trailer Claude Opus 4.8 (1M context).
</project-context>


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.claude\doyle-commune.md (file state is current in your context — no need to Read it back)

<live-context>
Generation 15. D9-2 COMPLETE — three CI-green-FINAL sub-slices, plan-status @ a52dacb. D9-3 in flight (budget harness written, not yet committed).

## D9-2 COMPLETE (resting-state model — REQ-INST-3/4 activated [impl, unit])
- 0315783 D9-2-1: spt-daemon/resting.rs — pure transition table (active→dormant on Detach/AttentionShift; suspend from EITHER live state; opt-in auto-suspend counted from dormancy-onset anchor dormant_since_ms, NEVER last activity; wake = re-activate in place; event replay = no-edge by table idempotence). info.json grew rest_state + dormant_since_ms (atomic pair — anchor moves with state) + auto_suspend_after_ms endpoint override. Knob chain: global default OFF → daemon.json node leg → endpoint leg, 0 = explicit endpoint OFF (effective_auto_suspend). Registry Status grew Suspended (additive serde, ADDRESSABLE — only Offline unroutable; wake must route). advertise_local follows the machine (live+record → machine state; recordless/dead → legacy mapping), epoch-bumped per advert.
- 8a7afd6 D9-2-2: apply_event transition host — echo BEFORE flip persists (KH 3.3); echo failure LOUD but state persists (signoff_with posture — the driver being gone is a fact, echo is best-effort-loud). BrainLifecycle::rest_event binds real run_echo_commune. Wake fires resurface_at_boundary (D8 deferral closed) + freshness-pull marker identity/sync-pull-now; pump consumes via take_freshness_pull (remove IS the take) → forces one sync round NOW. PumpPaths grew pull_marker.
- 56a98db D9-2-3: production feeds. Attach arm: Request=Wake, viewport end (EOF/Exit/Error)=Detach, best-effort like mark_driven_by. Registry arm: apply_feed_flips — flip = Updated ∧ incoming Active ∧ prev != Active ∧ node != self (NEVER Inserted: in-memory registry restart re-learn must not knock seats dormant; never periodic Active re-adverts; lease drops stale first); dispatch feeds AttentionShift only for locally hosted ids. Pulse recency GATED on rest state (D8 gap closed): only active/recordless ticks stamp last_active_ms — resting seat never wins most-recently-active (REQ-UPD-4/notif first-fire); dormant tick checks auto-suspend threshold; suspended tick just ingests. spt suspend/spt wake local CLI arms (qualified id@node → CROSS_NODE_M5 refusal).

## Key architecture call (KH 7.4)
Daemon-side rest-edge echoes are GATE-ARMED (resting::arm_transition_echo writes the echo-gate sentinel) — endpoint's OWN pulse loop runs the bounded summarizer call. N flips in one feed = N gate files, never N LLM calls serialized in a dispatch worker. REQ-HAZARD-DAEMON-SCHED-NONBLOCKING stays required_stages=[] — holds by construction; binds at the per-agent-runtime fan-out (same seam as in-pump Psyche reconcile). Documented in toml comment.

## Seams documented
- REQ-INST-6 deferred-message gate: precondition (state machine + durable record) shipped; gate = M5. toml comment + resting.rs module doc.
- Remote suspend-from-anywhere arm (id@node) = M5.
- REQ-INST-3 doc stage = D9-3 DORMANCY-BUDGET.md (the warm/cold policy lock).

## D9-3 IN FLIGHT (dormancy budget, ADR-0003 red-team #9)
Written, uncommitted: crates/spt-daemon/tests/budget.rs — SPT_BUDGET=1 env-gated (CI silent-skips), spawns N seats via real broker PTY hosting (SPT_BUDGET_N/PROGRAM/ARGS/SETTLE_MS/IDLE_MS knobs; default platform shell, rig run points at real adapter e.g. claude), samples RSS/handles/CPU per child twice over idle window (delta = idle burn) + this-process RSS delta (broker-side per-seat overhead) + post-kill residual (suspended baseline). Prints markdown table for DORMANCY-BUDGET.md capture. Brain grew spawn_session_pid (spawn_session delegates). Next: build+clippy, run on HFENDULEAM (shell + claude), gravity leg (runner or runbook), write docs/DORMANCY-BUDGET.md + ADR-0003 #9 closed + CONTEXT policy confirm + <!-- [doc->REQ-INST-3] -->.
Then D9-4 (INST-14 resources blurb), D9-5 (INST-15 home+fork), D9-6 (two-host+runbook+gh-interim retire), D9-7 (sweep).

## Workflow constraints, gotchas 1–10, flake ledger, CI topology: unchanged, carry all.

## No Blockers
</live-context>
<project-context>
name=spt-core

M4-D9 in flight: D9-1 COMPLETE cb81e83 (production trigger loops). D9-2 COMPLETE a52dacb (resting-state model: pure transition table + durable rest_state record + Status::Suspended + advertise-follows-state; transition echo gate-armed cross-process KH 7.4-safe; wake resurface + freshness pull pump leg; production feeds attach/registry-flip/pulse; spt suspend/wake local). REQ-INST-3/4 active [impl, unit]. Plan = M4-D9-PLAN.md (7 slices). Remaining: D9-3 dormancy budget (harness drafted), D9-4 resource advertisement, D9-5 home subnet + fork, D9-6 two-host rig proof + gh-interim retirement, D9-7 sweep. Conventions hold: no cargo fmt; evidence-only tags; traceable-reqs check repo root; clippy -D warnings; push slice → watch FINAL via gh run list (gotcha #7); commit trailer Claude Opus 4.8 (1M context).
</project-context>


Commune dropped. Continue D9-3 — build the harness:


PowerShell

cargo test -p spt-daemon --test budget 2>&1 | Select-String -Pattern "test result:|error" | Select-Object -First 10

Compile + run (skip-gated) budget harness


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


Compiles + skips clean. Real measurement run — shell seats first:


$env:SPT_BUDGET="1"; cargo test -p spt-daemon --test budget --release -- --nocapture 2>&1 | Select-Object -Last 25

Run dormancy budget measurement with shell seats


   Compiling tempfile v3.27.0
   Compiling spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
   Compiling spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `release` profile [optimized] target(s) in 1m 26s
     Running tests\budget.rs (target\release\deps\budget-e1b368d78c50cf72.exe)

running 1 test
## dormancy budget — 5 x `cmd `
host: HFENDULEAM (windows)

| seat | pid | rss | handles/fds | idle cpu (s over 5s) |
|------|-----|-----|-------------|------------------------|
| budget-0 | 64488 | 7.7 MiB | 85 | 0.000 |
| budget-1 | 480 | 7.7 MiB | 85 | 0.000 |
| budget-2 | 33888 | 7.7 MiB | 85 | 0.000 |
| budget-3 | 56716 | 7.7 MiB | 85 | 0.000 |
| budget-4 | 17896 | 7.7 MiB | 85 | 0.000 |

per-seat avg: rss 7.7 MiB · 85 handles/fds · idle cpu 0.000 s/5s
broker-side overhead for 5 seats (this process RSS delta): 1.0 MiB
suspended residual (this process RSS after killing all seats): 6.9 MiB (was 6.9 MiB warm)
test measure_dormant_seat_budget ... ok

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


Harness works — shell baseline captured. Now the real adapter (claude, N=3, longer settle):


$env:SPT_BUDGET="1"; $env:SPT_BUDGET_N="3"; $env:SPT_BUDGET_PROGRAM="claude"; $env:SPT_BUDGET_SETTLE_MS="15000"; cargo test -p spt-daemon --test budget --release -- --nocapture 2>&1 | Select-Object -Last 18

Run dormancy budget with real claude adapter seats


running 1 test
## dormancy budget — 3 x `claude `
host: HFENDULEAM (windows)

| seat | pid | rss | handles/fds | idle cpu (s over 5s) |
|------|-----|-----|-------------|------------------------|
| budget-0 | 36180 | 307.9 MiB | 244 | 0.000 |
| budget-1 | 63704 | 308.9 MiB | 244 | 0.000 |
| budget-2 | 49284 | 308.6 MiB | 244 | 0.000 |

per-seat avg: rss 308.5 MiB · 244 handles/fds · idle cpu 0.000 s/5s
broker-side overhead for 3 seats (this process RSS delta): 840.0 KiB
suspended residual (this process RSS after killing all seats): 6.2 MiB (was 6.2 MiB warm)
test measure_dormant_seat_budget ... ok

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


TaskCreate

D9-3: dormancy budget measurement + DORMANCY-BUDGET.md + ADR-0003 #9 close

SPT_BUDGET=1 harness (done, uncommitted). HFENDULEAM runs done (shell 7.7MiB, claude 308MiB, zero idle CPU). Remaining: gravity-linux leg (workflow_dispatch budget job on gravity runner), docs/DORMANCY-BUDGET.md (methodology + numbers + policy lock, doc->REQ-INST-3), ADR-0003 #9 amendment, CONTEXT.md policy confirm. CI green.


Task #4 created successfully: D9-3: dormancy budget measurement + DORMANCY-BUDGET.md + ADR-0003 #9 close


1	name: ci
2
3	on:
4	  push:
5	    branches: [main, dev-freeform]
6	  pull_request:
7
8	# Cancel superseded runs on the same ref.
9	concurrency:
10	  group: ci-${{ github.ref }}
11	  cancel-in-progress: true
12
13	jobs:
14	  # ---- Rust build + test + lint (cross-platform: exercises both the
15	  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
16	  #
17	  # CI-SELFHOST-PLAN.md: fully self-hosted (2026-06-03) — hfenduleam carries
18	  # the Windows leg, gravity-linux the Linux leg + traceability. Free minutes;
19	  # persistent cargo/target = warm builds (the multi-exe iroh link cost is
20	  # paid once per machine); hung tests debuggable on-box. Both boxes own
21	  # their rust toolchains (rustup-managed); no toolchain action, no
22	  # rust-cache (the persistent target dir IS the cache).
23	  test:
24	    strategy:
25	      fail-fast: false
26	      matrix:
27	        runner: [[self-hosted, Linux, gravity], [self-hosted, Windows, hfenduleam]]
28	    runs-on: ${{ matrix.runner }}
29	    # A wedged test must not block the single self-hosted runner slot (the
30	    # 2026-06-03 handoff.rs ConPTY stall burned 22 hosted minutes unbounded).
31	    timeout-minutes: 25
32	    steps:
33	      - uses: actions/checkout@v5
34	      - name: Build
35	        run: cargo build --workspace --all-targets
36	      - name: Test
37	        run: cargo test --workspace
38	      - name: Clippy (deny warnings)
39	        run: cargo clippy --workspace --all-targets -- -D warnings
40
41	  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
42	  traceability:
43	    runs-on: [self-hosted, Linux, gravity]
44	    timeout-minutes: 10
45	    steps:
46	      - uses: actions/checkout@v5
47	      - name: Install traceable-reqs
48	        # The self-hosted gravity runner carries a source-built binary in
49	        # /usr/local/bin (the release asset needs GLIBC 2.39; gravity's
50	        # Ubuntu 22.04 has 2.35). The download is the portability fallback
51	        # for any other runner.
52	        run: |
53	          if command -v traceable-reqs >/dev/null; then
54	            ln -sf "$(command -v traceable-reqs)" ./traceable-reqs
55	          else
56	            gh release download --repo BigscreenVR/traceable-reqs \
57	              --pattern '*linux-x86_64' --output traceable-reqs
58	            chmod +x traceable-reqs
59	          fi
60	        env:
61	          # Cross-org private repo: the default repo-scoped GITHUB_TOKEN cannot
62	          # read BigscreenVR/traceable-reqs. Supply a PAT with read access to it
63	          # as the TRACEABLE_REQS_TOKEN repo secret. See README/CI notes.
64	          GH_TOKEN: ${{ secrets.TRACEABLE_REQS_TOKEN }}
65	      - name: Coverage gate (hard — fails PR on uncovered activated req)
66	        run: ./traceable-reqs check --json
67	      - name: Quality audit (advisory)
68	        run: ./traceable-reqs lint || true
69

name: ci

on:
  push:
    branches: [main, dev-freeform]
  pull_request:

# Cancel superseded runs on the same ref.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---- Rust build + test + lint (cross-platform: exercises both the
  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
  #
  # CI-SELFHOST-PLAN.md: fully self-hosted (2026-06-03) — hfenduleam carries
  # the Windows leg, gravity-linux the Linux leg + traceability. Free minutes;
  # persistent cargo/target = warm builds (the multi-exe iroh link cost is
  # paid once per machine); hung tests debuggable on-box. Both boxes own
  # their rust toolchains (rustup-managed); no toolchain action, no
  # rust-cache (the persistent target dir IS the cache).
  test:
    strategy:
      fail-fast: false
      matrix:
        runner: [[self-hosted, Linux, gravity], [self-hosted, Windows, hfenduleam]]
    runs-on: ${{ matrix.runner }}
    # A wedged test must not block the single self-hosted runner slot (the
    # 2026-06-03 handoff.rs ConPTY stall burned 22 hosted minutes unbounded).
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v5
      - name: Build
        run: cargo build --workspace --all-targets
      - name: Test
        run: cargo test --workspace
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
  traceability:
    runs-on: [self-hosted, Linux, gravity]
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v5
      - name: Install traceable-reqs
        # The self-hosted gravity runner carries a source-built binary in
        # /usr/local/bin (the release asset needs GLIBC 2.39; gravity's
        # Ubuntu 22.04 has 2.35). The download is the portability fallback
        # for any other runner.
        run: |
          if command -v traceable-reqs >/dev/null; then
            ln -sf "$(command -v traceable-reqs)" ./traceable-reqs
          else
            gh release download --repo BigscreenVR/traceable-reqs \
              --pattern '*linux-x86_64' --output traceable-reqs
            chmod +x traceable-reqs
          fi
        env:
          # Cross-org private repo: the default repo-scoped GITHUB_TOKEN cannot
          # read BigscreenVR/traceable-reqs. Supply a PAT with read access to it
          # as the TRACEABLE_REQS_TOKEN repo secret. See README/CI notes.
          GH_TOKEN: ${{ secrets.TRACEABLE_REQS_TOKEN }}
      - name: Coverage gate (hard — fails PR on uncovered activated req)
        run: ./traceable-reqs check --json
      - name: Quality audit (advisory)
        run: ./traceable-reqs lint || true


Gravity leg routing — check SSH config first (cheapest path):


PowerShell

if (Test-Path ~/.ssh/config) { Get-Content ~/.ssh/config } else { "no ssh config" }

Check SSH config for gravity host


no ssh config


ssh -o ConnectTimeout=5 -o BatchMode=yes gravity "hostname" 2>&1; "EXIT=$LASTEXITCODE"

Probe SSH to gravity


Error: Exit code 255
ssh: Could not resolve hostname gravity: No such host is known.
EXIT=255


Edit

      - uses: actions/checkout@v5
      - name: Build
        run: cargo build --workspace --all-targets
      - name: Test
        run: cargo test --workspace
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

      - uses: actions/checkout@v5
      - name: Build
        run: cargo build --workspace --all-targets
      - name: Test
        run: cargo test --workspace
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

      # ---- D9-3 dormancy-budget measurement (ADR-0003 red-team #9) ----
      # Opt-in per push: runs ONLY when the head commit message carries
      # "[budget]" — both rig hosts measure in one tagged push, output lands
      # in the run log and is captured into docs/DORMANCY-BUDGET.md. The
      # harness itself is also env-gated (SPT_BUDGET=1), so an untagged CI
      # run silent-skips it inside the normal test sweep.
      - name: Dormancy budget (shell seats)
        if: contains(github.event.head_commit.message, '[budget]')
        run: cargo test -p spt-daemon --test budget --release -- --nocapture
        env:
          SPT_BUDGET: "1"
      - name: Dormancy budget (real adapter seats, when installed)
        if: contains(github.event.head_commit.message, '[budget]')
        shell: bash
        run: |
          if command -v claude >/dev/null; then
            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
              cargo test -p spt-daemon --test budget --release -- --nocapture
          else
            echo "claude not installed on this runner — shell numbers only"
          fi


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

      - uses: actions/checkout@v5
      - name: Build
        run: cargo build --workspace --all-targets
      - name: Test
        run: cargo test --workspace
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

      # ---- D9-3 dormancy-budget measurement (ADR-0003 red-team #9) ----
      # Opt-in per push: runs ONLY when the head commit message carries
      # "[budget]" — both rig hosts measure in one tagged push, output lands
      # in the run log and is captured into docs/DORMANCY-BUDGET.md. The
      # harness itself is also env-gated (SPT_BUDGET=1), so an untagged CI
      # run silent-skips it inside the normal test sweep.
      - name: Dormancy budget (shell seats)
        if: contains(github.event.head_commit.message, '[budget]')
        run: cargo test -p spt-daemon --test budget --release -- --nocapture
        env:
          SPT_BUDGET: "1"
      - name: Dormancy budget (real adapter seats, when installed)
        if: contains(github.event.head_commit.message, '[budget]')
        shell: bash
        run: |
          if command -v claude >/dev/null; then
            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
              cargo test -p spt-daemon --test budget --release -- --nocapture
          else
            echo "claude not installed on this runner — shell numbers only"
          fi

name: ci

on:
  push:
    branches: [main, dev-freeform]
  pull_request:

# Cancel superseded runs on the same ref.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---- Rust build + test + lint (cross-platform: exercises both the
  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
  #
  # CI-SELFHOST-PLAN.md: fully self-hosted (2026-06-03) — hfenduleam carries
  # the Windows leg, gravity-linux the Linux leg + traceability. Free minutes;
  # persistent cargo/target = warm builds (the multi-exe iroh link cost is
  # paid once per machine); hung tests debuggable on-box. Both boxes own
  # their rust toolchains (rustup-managed); no toolchain action, no
  # rust-cache (the persistent target dir IS the cache).
  test:
    strategy:
      fail-fast: false
      matrix:
        runner: [[self-hosted, Linux, gravity], [self-hosted, Windows, hfenduleam]]
    runs-on: ${{ matrix.runner }}
    # A wedged test must not block the single self-hosted runner slot (the
    # 2026-06-03 handoff.rs ConPTY stall burned 22 hosted minutes unbounded).
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v5
      - name: Build
        run: cargo build --workspace --all-targets
      - name: Test
        run: cargo test --workspace
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
  traceability:
    runs-on: [self-hosted, Linux, gravity]
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v5
      - name: Install traceable-reqs
        # The self-hosted gravity runner carries a source-built binary in
        # /usr/local/bin (the release asset needs GLIBC 2.39; gravity's
        # Ubuntu 22.04 has 2.35). The download is the portability fallback
        # for any other runner.
        run: |
          if command -v traceable-reqs >/dev/null; then
            ln -sf "$(command -v traceable-reqs)" ./traceable-reqs
          else
            gh release download --repo BigscreenVR/traceable-reqs \
              --pattern '*linux-x86_64' --output traceable-reqs
            chmod +x traceable-reqs
          fi
        env:
          # Cross-org private repo: the default repo-scoped GITHUB_TOKEN cannot
          # read BigscreenVR/traceable-reqs. Supply a PAT with read access to it
          # as the TRACEABLE_REQS_TOKEN repo secret. See README/CI notes.
          GH_TOKEN: ${{ secrets.TRACEABLE_REQS_TOKEN }}
      - name: Coverage gate (hard — fails PR on uncovered activated req)
        run: ./traceable-reqs check --json
      - name: Quality audit (advisory)
        run: ./traceable-reqs lint || true

+      # ---- D9-3 dormancy-budget measurement (ADR-0003 red-team #9) ----

+      # Opt-in per push: runs ONLY when the head commit message carries

+      # "[budget]" — both rig hosts measure in one tagged push, output lands

+      # in the run log and is captured into docs/DORMANCY-BUDGET.md. The

+      # harness itself is also env-gated (SPT_BUDGET=1), so an untagged CI

+      # run silent-skips it inside the normal test sweep.

+      - name: Dormancy budget (shell seats)

+        if: contains(github.event.head_commit.message, '[budget]')

+        run: cargo test -p spt-daemon --test budget --release -- --nocapture

+      - name: Dormancy budget (real adapter seats, when installed)

+          if command -v claude >/dev/null; then

+            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \

+              cargo test -p spt-daemon --test budget --release -- --nocapture

+            echo "claude not installed on this runner — shell numbers only"


Write docs/DORMANCY-BUDGET.md (HFENDULEAM numbers in, gravity pending):