File created successfully at: C:\Users\decid\Documents\projects\spt-core\docs\reviews\STAGE-A-codex-redteam.md (file state is current in your context — no need to Read it back)

# Stage A — Codex adversarial red-team review

> Date: 2026-05-31. Reviewer: OpenAI Codex (gpt-5.5, reasoning=high, read-only sandbox).
> Target: PRD.md, CONTEXT.md, ADR-0001..0005, docs/KNOWN-HAZARDS.md.
> Scope locked = full PRD; goal = surface fatal flaws, not cut scope.
> This is the first half of ROADMAP Stage A (codex pass). `plan-eng-review` is the second half.

## Verdict

**Single most-likely redesign trigger: ADR-0004 live-stream survival across brain restart.**
If the broker must own live Iroh/client/QUIC/PTY stream state to survive a brain restart, the broker *is* the real daemon and the "minimal stable broker / updatable brain" split changes shape. If the broker does *not* own that state, the no-terminate self-update invariant (R-UPD-3) is false. The split's load-bearing boundary is currently **undefined**.

5 FATAL, 9 SERIOUS. FATAL cluster is entirely ADR-0004 (4) + ADR-0005 revocation (1).

## Findings

### FATAL

1. **ADR-0004 / R-UPD-3 — invariant contradicts its own ADR.**
   "No endpoint terminates/suspends during self-update" is asserted absolute, but ADR-0004 itself admits broker updates may need FD-passing or a "planned endpoint-cycle." Real invariant is only *brain-only updates don't terminate endpoints*.
   → Resolve: define update classes (brain-only / broker-compatible / broker-breaking); prove invariant per class, or explicitly weaken R-UPD-3.

2. **ADR-0004 / CONTEXT self-update — listening socket ≠ live connection.**
   Holding a *listening* socket does not preserve accepted connections, QUIC streams, Iroh endpoints, file-transfer streams, or remote-terminal streams — those carry process-local protocol state. Brain owns I/O → restart drops them. Broker owns them → broker isn't dumb.
   → Spike: active remote terminal over Iroh + active file transfer + active `api listen`; restart brain; assert no reconnect, no byte loss, stream continuity.

3. **ADR-0004 / CONTEXT — brain "reattach" to broker PTYs on Windows ConPTY.**
   Plausible only if broker owns the *full* PTY I/O loop, buffers, resize state, backpressure, child lifetime. Any duplicated pipe/master handle in the brain → brain death causes EOF / stuck stdin / lost output / orphan. Linux `forkpty` easier; **Windows ConPTY handle/pipe/job-object is the high-risk path.**
   → Spike #1 (broker/brain handoff): interactive children both OSes, stream under load, resize, input, restart brain 100×; assert child lives, no EOF/SIGHUP, no lost bytes, no wedged input.

4. **ADR-0002 + ADR-0004 — networking collapse vs minimal broker.**
   To preserve live WAN streams across brain restart, broker must own Iroh endpoint, stream mux, accepted sessions, relay behavior, maybe registry subscriptions → moves volatile networking code into the "stable kernel." Contradicts "rarely updates."
   → Resolve: ownership table for PTY masters, accepted sockets, Iroh endpoint, QUIC streams, mDNS sockets, registry gossip, transfer queues. *Any live stream owned by brain invalidates the update invariant.*

10. **ADR-0005 — trust-store delete is not revocation.**
   TOTP seed is replicated to every trusted node and fetchable. Compromised node/seed: deleting its pubkey does not stop it re-pairing as a *new* node.
   → Resolve: seed epoch + rotation protocol; demonstrate "remove compromised node" blocks old node/old seed rejoin.

### SERIOUS

5. **ADR-0004 self-update delivery** — signature alone insufficient. Missing rollback protection, metadata expiry, channel pinning, version monotonicity, key rotation/revocation, adapter-content signing. Compromised node can replay old signed-vulnerable binary. → TUF-like metadata: signed target hashes, monotonic versions, expiry, rollback tests, key rotation, adapter signatures.

6. **ADR-0002** — one collapsed daemon = one failure domain. Networking bug kills terminal hosting; PTY broker bug kills WAN reachability. → Fault-injection matrix: crash/hang Iroh, mDNS, registry, PTY broker, manifest invocation; measure blast radius + recovery.

7. **ADR-0003 / Instances** — two-tier sync + wall-clock "newest" loses valid concurrent updates; LLM-authored live/project tiering not reliable as conflict boundary. → Offline two nodes, mutate same live topic + different project topics, reconnect with clock skew; require vector clocks / op-logs / CRDT-ish merge / explicit conflict review.

8. **ADR-0003** — `local → most-recently-active → id@node` ambiguous under partition / clock skew / concurrent drive; can route to wrong instance around active/dormant transitions. → Registry lease/epoch using monotonic per-node epochs, not wall-clock; chaos-test stale entries + ambiguous active instances.

9. **ADR-0003 / R-INST-3** — dormant-warm default retains creds, file locks, model/billing footprint, PTYs, watchers on *every* node → multiplies resource + security exposure. → Measure N dormant sessions on real adapters; define adapter-specific warm/cold policy + resource budget before locking warm default.

11. **ADR-0005** — SPAKE2 one-guess bound breaks if rate limits aren't *subnet-global*: public pre-trust relay + multiple seed-holders → distributed guessing; ±1 TOTP window triples accepted passwords. → One active pairing ceremony per subnet, shared attempt counters, exponential backoff, relay-abuse tests.

12. **ADR-0005** — PAKE binds pubkeys only if transcript binds roles, node pubkeys, subnet ID, time-step, confirmation MACs. Otherwise unknown-key-share / reflection / wrong-subnet pairing possible. → Full transcript spec + negative tests (MITM, replay, reflection, wrong subnet, stale timestep).

13. **KNOWN-HAZARDS — missing: local `api` mutation auth.** Hazards don't require `api bind/state/session-end/history-log/poll` mutations to be authenticated to endpoint/session; local untrusted processes are in scope via shells/adapters. → Per-endpoint link token or OS-credential binding; test unrelated local process cannot bind/inject/end/spoof.

14. **KNOWN-HAZARDS — missing: exactly-once/idempotent delivery across brain restart.** Broker queues, spool rows, PTY injection, remote streams, transfers cross the broker/brain boundary; restart can dup/drop without durable IDs + replay rules. → Crash brain at every boundary (before/after spool write, before/after PTY write, mid-transfer, mid-registry); assert idempotency.

## Disposition (to fold into ADRs/CONTEXT — ROADMAP step 3)

- **ADR-0004:** add update-class taxonomy (#1); add the ownership table (#4, #2) as a normative section; mark Spike #1 to also cover live Iroh/file-transfer stream survival, not just PTY (#2, #3).
- **ADR-0005:** add seed-epoch/rotation for real revocation (#10); subnet-global pairing rate-limit + full transcript binding spec (#11, #12).
- **ADR-0003:** replace wall-clock "newest" with monotonic per-node epochs for both resolution (#8) and context merge (#7); resource/security budget for dormant-warm default (#9).
- **ADR-0002:** document the single-failure-domain tradeoff + fault-injection acceptance (#6).
- **KNOWN-HAZARDS:** add two invariants — local api-mutation auth (#13), idempotent delivery across brain restart (#14). Both become `REQ-HAZARD-*` once their milestone activates.
- **Self-update (R-UPD):** TUF-like metadata (#5).


Codex pass done. Findings saved → `docs/reviews/STAGE-A-codex-redteam.md`.

## Codex red-team verdict

**5 FATAL, 9 SERIOUS.** Single redesign trigger = **ADR-0004 live-stream survival across brain restart**. Boundary undefined → either broker owns live streams (then broker = real daemon, "minimal broker" split collapses) OR brain owns them (then no-terminate self-update invariant R-UPD-3 is false). Can't have both as written.

**FATAL cluster (all real, all load-bearing):**
1. **R-UPD-3 self-contradicts** — ADR-0004 admits broker updates need "endpoint-cycle," but PRD asserts absolute no-terminate. Real invariant = *brain-only* updates don't terminate.
2. **Listening socket ≠ live connection** — holding listen-socket saves nothing for accepted QUIC/Iroh/file-transfer/remote-terminal streams. Those carry process-local state.
3. **ConPTY reattach** = highest-risk path. Brain reattach works only if broker owns *full* PTY I/O loop. Any duplicated handle in brain → EOF/stuck-stdin/orphan on restart.
4. **Networking collapse vs minimal broker** — preserving WAN streams forces Iroh/QUIC/relay state INTO the "stable kernel." Contradicts "rarely updates."
10. **Trust-store delete ≠ revocation** — TOTP seed replicated to all nodes; deleting pubkey doesn't stop re-pairing. Need seed epoch+rotation.

**SERIOUS:** TUF-style update metadata (#5), single-failure-domain blast radius (#6), wall-clock "newest" loses concurrent writes → need vector clocks/epochs (#7,#8), dormant-warm resource/security cost (#9), subnet-global pairing rate-limit + transcript binding (#11,#12), + 2 missing hazards: local api-mutation auth (#13), idempotent delivery across brain restart (#14).

**Strong signal:** every FATAL maps to roadmap **Spike #1** (broker/brain handoff). Codex says the spike must also exercise live Iroh + file-transfer streams, not just PTY.

---

Stage A half done. Three paths:

1. **Finish Stage A** — run `plan-eng-review` (interactive architecture pass), then fold all findings into ADRs.
2. **Fold codex findings into ADRs now** — amend ADR-0004/0003/0005/0002 + add 2 hazards (the ownership table is the key artifact), defer eng-review.
3. **Jump to Spike #1** — codex made it clear this is THE risk; build the throwaway ConPTY+Iroh handoff proof, let results drive the ADR rewrite.

Recommend **3 then 2**: the FATALs are empirical questions (does ConPTY reattach survive? do streams?), not debate. Spike answers them, then ADR amendments write themselves. `plan-eng-review` adds less until the broker/brain boundary is proven. Your call.


proceed as recommended


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


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


Proceeding: **Spike #1 — broker/brain no-terminate handoff**, Windows ConPTY first (codex's #3, highest-risk path). Throwaway. Check toolchain first.


Bash

rustc --version 2>&1; cargo --version 2>&1; echo "---"; rustc --print target-list 2>&1 | grep -i windows | head -3

Check Rust toolchain and Windows targets


rustc 1.93.0 (254b59607 2026-01-19)
cargo 1.93.0 (083ac5135 2025-12-15)
---
aarch64-pc-windows-gnullvm
aarch64-pc-windows-msvc
aarch64-uwp-windows-msvc


Rust 1.93 ready. Building Spike #1 as throwaway in sibling dir `spt-spikes/spike-01-broker-handoff` (keeps spt-core clean).

Design — single binary, subcommands, proves 4 invariants codex flagged:
- **A** child PID unchanged across brain restart
- **B** PTY output counter contiguous, no EOF (codex #3)
- **C** input still routed after brain restart (broker-owned writer survives)
- **D** live external client socket survives brain restart, no byte loss (codex #2)

Ownership model under test: **broker owns sole PTY reader+writer+child+accepted client sockets**; brain holds none — only a forwarding control socket. Plus a `--brain-holds-handles` trap mode to reproduce the failure codex warns about.


mkdir -p "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff/src" && echo created

Create spike directory


created


Write

[package]
name = "spike-01-broker-handoff"
version = "0.0.0"
edition = "2021"
publish = false

# THROWAWAY de-risk spike for spt-core ROADMAP Spike #1 (broker/brain no-terminate handoff).
# Proves: a PTY child + an accepted client socket survive a "brain" (logic) restart while the
# "broker" holds the load-bearing handles. Answers codex red-team FATAL findings #2 and #3.

[[bin]]
name = "spike"
path = "src/main.rs"

[dependencies]
portable-pty = "0.9"
anyhow = "1"


File created successfully at: C:\Users\decid\Documents\projects\spt-spikes\spike-01-broker-handoff\Cargo.toml (file state is current in your context — no need to Read it back)

[package]
name = "spike-01-broker-handoff"
version = "0.0.0"
edition = "2021"
publish = false

# THROWAWAY de-risk spike for spt-core ROADMAP Spike #1 (broker/brain no-terminate handoff).
# Proves: a PTY child + an accepted client socket survive a "brain" (logic) restart while the
# "broker" holds the load-bearing handles. Answers codex red-team FATAL findings #2 and #3.

[[bin]]
name = "spike"
path = "src/main.rs"

[dependencies]
portable-pty = "0.9"
anyhow = "1"


//! Spike #1 — broker/brain no-terminate handoff (Windows ConPTY first).
//!
//! THROWAWAY proof for spt-core ROADMAP Spike #1 + codex red-team FATAL #2/#3.
//!
//! Thesis under test: if the *broker* owns the load-bearing handles (the PTY master
//! reader+writer, the child process, and every accepted client socket), then the
//! *brain* (logic) can die and restart with **no** termination of the child and **no**
//! byte loss on the live client stream. The brain holds only a forwarding control
//! socket — never a PTY handle, never the accepted client fd.
//!
//! Invariants asserted by `run`:
//!   A  child PID alive for the whole run (no EOF on the PTY across brain restart)
//!   B  PTY output counter (`tick N`) contiguous — no gap, no premature EOF
//!   C  input still reaches the child app AFTER brain restart (broker-owned writer survives)
//!   D  a live external client socket keeps receiving ticks across brain restart, no gap
//!
//! Subcommands: child | broker | brain <ver> | client | run

use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};

const BRAIN_PORT: u16 = 7401;
const CLIENT_PORT: u16 = 7402;
const TICK_MS: u64 = 150;
const PTY_LOG: &str = "broker-pty.log";
const META_LOG: &str = "broker-meta.log";
const CLIENT_LOG: &str = "client.log";

fn main() -> anyhow::Result<()> {
    let args: Vec<String> = std::env::args().collect();
    let mode = args.get(1).map(|s| s.as_str()).unwrap_or("run");
    match mode {
        "child" => child(),
        "broker" => broker(),
        "brain" => brain(args.get(2).map(|s| s.as_str()).unwrap_or("1")),
        "client" => client(),
        "run" => run(),
        other => {
            eprintln!("unknown mode: {other}");
            std::process::exit(2);
        }
    }
}

/// Long-lived workload inside the PTY: emit a monotonic counter, echo stdin lines.
fn child() -> anyhow::Result<()> {
    // stdin reader thread: echo every line so we can prove input reaches the app.
    thread::spawn(|| {
        let stdin = std::io::stdin();
        for line in stdin.lock().lines() {
            match line {
                Ok(l) => {
                    let l = l.trim_end_matches(['\r', '\n']);
                    if !l.is_empty() {
                        println!("echo: {l}");
                        let _ = std::io::stdout().flush();
                    }
                }
                Err(_) => break,
            }
        }
    });
    let mut n: u64 = 0;
    loop {
        println!("tick {n}");
        std::io::stdout().flush().ok();
        n += 1;
        thread::sleep(Duration::from_millis(TICK_MS));
    }
}

/// Broker: owns the PTY (reader+writer), the child, and all accepted sockets.
/// Never restarts. Forwards PTY output to every connected sink; routes brain
/// `INPUT:` control lines to the PTY writer.
fn broker() -> anyhow::Result<()> {
    use portable_pty::{native_pty_system, CommandBuilder, PtySize};

    let mut meta = File::create(META_LOG)?;
    let pty = native_pty_system();
    let pair = pty.openpty(PtySize {
        rows: 30,
        cols: 120,
        pixel_width: 0,
        pixel_height: 0,
    })?;

    let exe = std::env::current_exe()?;
    let mut cmd = CommandBuilder::new(exe);
    cmd.arg("child");
    let child = pair.slave.spawn_command(cmd)?;
    let child_pid = child.process_id().unwrap_or(0);
    writeln!(meta, "CHILD_PID={child_pid}")?;
    meta.flush()?;
    // Drop the slave so only the child holds it; broker keeps the master.
    drop(pair.slave);

    let reader = pair.master.try_clone_reader()?;
    let writer = Arc::new(Mutex::new(pair.master.take_writer()?));
    // Keep the master alive for the whole process (writer holds it internally too).
    let _master = pair.master;

    // Sinks: every stream that should receive the live PTY output (brains + clients).
    let sinks: Arc<Mutex<Vec<TcpStream>>> = Arc::new(Mutex::new(Vec::new()));

    // PTY reader thread: sole owner of the read side. Logs + broadcasts.
    {
        let sinks = sinks.clone();
        thread::spawn(move || {
            let mut log = OpenOptions::new()
                .create(true)
                .append(true)
                .open(PTY_LOG)
                .expect("open pty log");
            let mut r = reader;
            let mut buf = [0u8; 4096];
            loop {
                match r.read(&mut buf) {
                    Ok(0) => {
                        let _ = writeln!(log, "[PTY-EOF]");
                        let _ = log.flush();
                        break; // EOF => child/pty gone. Invariant A failure if before end.
                    }
                    Ok(k) => {
                        let chunk = &buf[..k];
                        let _ = log.write_all(chunk);
                        let _ = log.flush();
                        let mut guard = sinks.lock().unwrap();
                        guard.retain_mut(|s| s.write_all(chunk).and_then(|_| s.flush()).is_ok());
                    }
                    Err(_) => break,
                }
            }
        });
    }

    // Brain control acceptor: a brain connects, becomes a sink, and may send
    // `INPUT: <text>` lines which the broker writes to the PTY writer it owns.
    {
        let sinks = sinks.clone();
        let writer = writer.clone();
        let listener = TcpListener::bind(("127.0.0.1", BRAIN_PORT))?;
        thread::spawn(move || {
            for conn in listener.incoming() {
                let Ok(stream) = conn else { continue };
                sinks.lock().unwrap().push(stream.try_clone().unwrap());
                let writer = writer.clone();
                thread::spawn(move || {
                    let rdr = BufReader::new(stream);
                    for line in rdr.lines() {
                        let Ok(line) = line else { break };
                        if let Some(payload) = line.strip_prefix("INPUT: ") {
                            let mut w = writer.lock().unwrap();
                            let _ = w.write_all(payload.as_bytes());
                            let _ = w.write_all(b"\r");
                            let _ = w.flush();
                        }
                    }
                });
            }
        });
    }

    // Client acceptor: external client becomes a pure output sink (broker owns the fd).
    {
        let sinks = sinks.clone();
        let listener = TcpListener::bind(("127.0.0.1", CLIENT_PORT))?;
        thread::spawn(move || {
            for conn in listener.incoming() {
                if let Ok(stream) = conn {
                    sinks.lock().unwrap().push(stream);
                }
            }
        });
    }

    // Park forever; orchestrator kills the broker at the end.
    loop {
        thread::sleep(Duration::from_secs(3600));
    }
}

/// Brain: logic process. Connects to the broker, consumes the forwarded stream.
/// Holds NO pty handle and NO client fd. Restarting it must not disturb anything.
/// Version 2 sends one input line to prove the broker-owned writer still works.
fn brain(ver: &str) -> anyhow::Result<()> {
    let mut stream = connect_retry(BRAIN_PORT, Duration::from_secs(5))?;
    eprintln!("brain v{ver} alive (pid {})", std::process::id());
    if ver == "2" {
        // Prove input path post-restart.
        stream.write_all(b"INPUT: hello-after-restart\n")?;
        stream.flush()?;
    }
    // "Do logic": just drain the forwarded output until killed.
    let mut buf = [0u8; 4096];
    let mut s = stream;
    loop {
        match s.read(&mut buf) {
            Ok(0) => break,
            Ok(_) => {}
            Err(_) => break,
        }
    }
    Ok(())
}

/// External client: simulates a live connection that MUST survive brain restarts.
fn client() -> anyhow::Result<()> {
    let mut stream = connect_retry(CLIENT_PORT, Duration::from_secs(5))?;
    let mut log = File::create(CLIENT_LOG)?;
    let mut buf = [0u8; 4096];
    loop {
        match stream.read(&mut buf) {
            Ok(0) => break,
            Ok(k) => {
                log.write_all(&buf[..k])?;
                log.flush()?;
            }
            Err(_) => break,
        }
    }
    Ok(())
}

fn connect_retry(port: u16, timeout: Duration) -> anyhow::Result<TcpStream> {
    let start = Instant::now();
    loop {
        match TcpStream::connect(("127.0.0.1", port)) {
            Ok(s) => return Ok(s),
            Err(e) => {
                if start.elapsed() > timeout {
                    return Err(e.into());
                }
                thread::sleep(Duration::from_millis(50));
            }
        }
    }
}

/// Orchestrator: drives the whole scenario and asserts the four invariants.
fn run() -> anyhow::Result<()> {
    // Clean prior logs.
    for f in [PTY_LOG, META_LOG, CLIENT_LOG] {
        let _ = std::fs::remove_file(f);
    }
    let exe = std::env::current_exe()?;
    let spawn = |arg: &str, arg2: Option<&str>| -> std::process::Child {
        let mut c = std::process::Command::new(&exe);
        c.arg(arg);
        if let Some(a) = arg2 {
            c.arg(a);
        }
        c.spawn().expect("spawn")
    };

    println!("[run] starting broker (owns PTY + child + sockets)");
    let mut broker = spawn("broker", None);
    thread::sleep(Duration::from_millis(800)); // let child tick + ports bind

    println!("[run] connecting live external client");
    let mut client = spawn("client", None);
    thread::sleep(Duration::from_millis(1200));

    println!("[run] starting brain v1");
    let mut brain1 = spawn("brain", Some("1"));
    thread::sleep(Duration::from_millis(1500));

    let ticks_at_kill = count_ticks(PTY_LOG);
    println!("[run] killing brain v1 (ticks so far: {ticks_at_kill})");
    brain1.kill().ok();
    brain1.wait().ok();
    thread::sleep(Duration::from_millis(700)); // brain GONE window — child must keep ticking

    println!("[run] starting brain v2 (sends input post-restart)");
    let mut brain2 = spawn("brain", Some("2"));
    thread::sleep(Duration::from_millis(1800)); // let input echo + more ticks land

    println!("[run] tearing down");
    brain2.kill().ok();
    client.kill().ok();
    broker.kill().ok();
    brain2.wait().ok();
    client.wait().ok();
    broker.wait().ok();
    thread::sleep(Duration::from_millis(200));

    // ---- assertions ----
    let pty = read_to_string(PTY_LOG);
    let meta = read_to_string(META_LOG);
    let client_log = read_to_string(CLIENT_LOG);

    let child_pid = meta
        .lines()
        .find_map(|l| l.strip_prefix("CHILD_PID="))
        .unwrap_or("?")
        .to_string();

    let pty_ticks = extract_tick_seq(&pty);
    let client_ticks = extract_tick_seq(&client_log);
    let saw_eof = pty.contains("[PTY-EOF]");
    let echoed = pty.contains("echo: hello-after-restart");

    let a_ok = !saw_eof && pty_ticks.last().copied().unwrap_or(0) > ticks_at_kill_seq(ticks_at_kill);
    let b_ok = contiguous(&pty_ticks) && pty_ticks.len() >= 10;
    let c_ok = echoed;
    let d_ok = contiguous(&client_ticks) && client_ticks.len() >= 5;

    println!("\n===== SPIKE #1 RESULT =====");
    println!("child pid             : {child_pid}");
    println!("pty ticks captured    : {} (max {:?})", pty_ticks.len(), pty_ticks.last());
    println!("client ticks captured : {} (max {:?})", client_ticks.len(), client_ticks.last());
    println!("pty EOF seen          : {saw_eof}");
    println!("post-restart echo     : {echoed}");
    println!("---------------------------");
    report("A  child survives brain restart (no PTY EOF, ticks advanced)", a_ok);
    report("B  PTY counter contiguous (no gap / no premature EOF)", b_ok);
    report("C  input reaches child AFTER brain restart (broker writer survived)", c_ok);
    report("D  live client socket survives brain restart, no gap", d_ok);
    println!("===========================");

    let all = a_ok && b_ok && c_ok && d_ok;
    println!("\nOVERALL: {}", if all { "PASS ✅" } else { "FAIL ❌" });
    if !all {
        std::process::exit(1);
    }
    Ok(())
}

fn report(label: &str, ok: bool) {
    println!("[{}] {label}", if ok { "PASS" } else { "FAIL" });
}

fn read_to_string(p: &str) -> String {
    std::fs::read(p).map(|b| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default()
}

fn count_ticks(p: &str) -> usize {
    extract_tick_seq(&read_to_string(p)).len()
}

// Marker so invariant A compares against a real captured value, not the count.
fn ticks_at_kill_seq(count_at_kill: usize) -> u64 {
    // We only need "advanced beyond the kill point"; using count as a floor is sufficient
    // because ticks are monotonic and contiguous (checked by B).
    count_at_kill.saturating_sub(1) as u64
}

/// Pull the integer N out of every `tick N` occurrence, in order, tolerating
/// ConPTY escape sequences and \r\n framing around the text.
fn extract_tick_seq(s: &str) -> Vec<u64> {
    let mut out = Vec::new();
    let bytes = s.as_bytes();
    let needle = b"tick ";
    let mut i = 0;
    while i + needle.len() < bytes.len() {
        if &bytes[i..i + needle.len()] == needle {
            let mut j = i + needle.len();
            let mut val: u64 = 0;
            let mut got = false;
            while j < bytes.len() && bytes[j].is_ascii_digit() {
                val = val * 10 + (bytes[j] - b'0') as u64;
                got = true;
                j += 1;
            }
            if got {
                out.push(val);
            }
            i = j;
        } else {
            i += 1;
        }
    }
    out
}

/// True if the sequence (after dedup of repeats) increases by exactly 1 with no gaps.
fn contiguous(seq: &[u64]) -> bool {
    if seq.is_empty() {
        return false;
    }
    let mut prev: Option<u64> = None;
    for &v in seq {
        if let Some(p) = prev {
            if v == p {
                continue; // duplicate render of same line — tolerate
            }
            if v != p + 1 {
                return false; // gap or rewind => failure
            }
        }
        prev = Some(v);
    }
    true
}


File created successfully at: C:\Users\decid\Documents\projects\spt-spikes\spike-01-broker-handoff\src\main.rs (file state is current in your context — no need to Read it back)

//! Spike #1 — broker/brain no-terminate handoff (Windows ConPTY first).
//!
//! THROWAWAY proof for spt-core ROADMAP Spike #1 + codex red-team FATAL #2/#3.
//!
//! Thesis under test: if the *broker* owns the load-bearing handles (the PTY master
//! reader+writer, the child process, and every accepted client socket), then the
//! *brain* (logic) can die and restart with **no** termination of the child and **no**
//! byte loss on the live client stream. The brain holds only a forwarding control
//! socket — never a PTY handle, never the accepted client fd.
//!
//! Invariants asserted by `run`:
//!   A  child PID alive for the whole run (no EOF on the PTY across brain restart)
//!   B  PTY output counter (`tick N`) contiguous — no gap, no premature EOF
//!   C  input still reaches the child app AFTER brain restart (broker-owned writer survives)
//!   D  a live external client socket keeps receiving ticks across brain restart, no gap
//!
//! Subcommands: child | broker | brain <ver> | client | run

use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};

const BRAIN_PORT: u16 = 7401;
const CLIENT_PORT: u16 = 7402;
const TICK_MS: u64 = 150;
const PTY_LOG: &str = "broker-pty.log";
const META_LOG: &str = "broker-meta.log";
const CLIENT_LOG: &str = "client.log";

fn main() -> anyhow::Result<()> {
    let args: Vec<String> = std::env::args().collect();
    let mode = args.get(1).map(|s| s.as_str()).unwrap_or("run");
    match mode {
        "child" => child(),
        "broker" => broker(),
        "brain" => brain(args.get(2).map(|s| s.as_str()).unwrap_or("1")),
        "client" => client(),
        "run" => run(),
        other => {
            eprintln!("unknown mode: {other}");
            std::process::exit(2);
        }
    }
}

/// Long-lived workload inside the PTY: emit a monotonic counter, echo stdin lines.
fn child() -> anyhow::Result<()> {
    // stdin reader thread: echo every line so we can prove input reaches the app.
    thread::spawn(|| {
        let stdin = std::io::stdin();
        for line in stdin.lock().lines() {
            match line {
                Ok(l) => {
                    let l = l.trim_end_matches(['\r', '\n']);
                    if !l.is_empty() {
                        println!("echo: {l}");
                        let _ = std::io::stdout().flush();
                    }
                }
                Err(_) => break,
            }
        }
    });
    let mut n: u64 = 0;
    loop {
        println!("tick {n}");
        std::io::stdout().flush().ok();
        n += 1;
        thread::sleep(Duration::from_millis(TICK_MS));
    }
}

/// Broker: owns the PTY (reader+writer), the child, and all accepted sockets.
/// Never restarts. Forwards PTY output to every connected sink; routes brain
/// `INPUT:` control lines to the PTY writer.
fn broker() -> anyhow::Result<()> {
    use portable_pty::{native_pty_system, CommandBuilder, PtySize};

    let mut meta = File::create(META_LOG)?;
    let pty = native_pty_system();
    let pair = pty.openpty(PtySize {
        rows: 30,
        cols: 120,
        pixel_width: 0,
        pixel_height: 0,
    })?;

    let exe = std::env::current_exe()?;
    let mut cmd = CommandBuilder::new(exe);
    cmd.arg("child");
    let child = pair.slave.spawn_command(cmd)?;
    let child_pid = child.process_id().unwrap_or(0);
    writeln!(meta, "CHILD_PID={child_pid}")?;
    meta.flush()?;
    // Drop the slave so only the child holds it; broker keeps the master.
    drop(pair.slave);

    let reader = pair.master.try_clone_reader()?;
    let writer = Arc::new(Mutex::new(pair.master.take_writer()?));
    // Keep the master alive for the whole process (writer holds it internally too).
    let _master = pair.master;

    // Sinks: every stream that should receive the live PTY output (brains + clients).
    let sinks: Arc<Mutex<Vec<TcpStream>>> = Arc::new(Mutex::new(Vec::new()));

    // PTY reader thread: sole owner of the read side. Logs + broadcasts.
    {
        let sinks = sinks.clone();
        thread::spawn(move || {
            let mut log = OpenOptions::new()
                .create(true)
                .append(true)
                .open(PTY_LOG)
                .expect("open pty log");
            let mut r = reader;
            let mut buf = [0u8; 4096];
            loop {
                match r.read(&mut buf) {
                    Ok(0) => {
                        let _ = writeln!(log, "[PTY-EOF]");
                        let _ = log.flush();
                        break; // EOF => child/pty gone. Invariant A failure if before end.
                    }
                    Ok(k) => {
                        let chunk = &buf[..k];
                        let _ = log.write_all(chunk);
                        let _ = log.flush();
                        let mut guard = sinks.lock().unwrap();
                        guard.retain_mut(|s| s.write_all(chunk).and_then(|_| s.flush()).is_ok());
                    }
                    Err(_) => break,
                }
            }
        });
    }

    // Brain control acceptor: a brain connects, becomes a sink, and may send
    // `INPUT: <text>` lines which the broker writes to the PTY writer it owns.
    {
        let sinks = sinks.clone();
        let writer = writer.clone();
        let listener = TcpListener::bind(("127.0.0.1", BRAIN_PORT))?;
        thread::spawn(move || {
            for conn in listener.incoming() {
                let Ok(stream) = conn else { continue };
                sinks.lock().unwrap().push(stream.try_clone().unwrap());
                let writer = writer.clone();
                thread::spawn(move || {
                    let rdr = BufReader::new(stream);
                    for line in rdr.lines() {
                        let Ok(line) = line else { break };
                        if let Some(payload) = line.strip_prefix("INPUT: ") {
                            let mut w = writer.lock().unwrap();
                            let _ = w.write_all(payload.as_bytes());
                            let _ = w.write_all(b"\r");
                            let _ = w.flush();
                        }
                    }
                });
            }
        });
    }

    // Client acceptor: external client becomes a pure output sink (broker owns the fd).
    {
        let sinks = sinks.clone();
        let listener = TcpListener::bind(("127.0.0.1", CLIENT_PORT))?;
        thread::spawn(move || {
            for conn in listener.incoming() {
                if let Ok(stream) = conn {
                    sinks.lock().unwrap().push(stream);
                }
            }
        });
    }

    // Park forever; orchestrator kills the broker at the end.
    loop {
        thread::sleep(Duration::from_secs(3600));
    }
}

/// Brain: logic process. Connects to the broker, consumes the forwarded stream.
/// Holds NO pty handle and NO client fd. Restarting it must not disturb anything.
/// Version 2 sends one input line to prove the broker-owned writer still works.
fn brain(ver: &str) -> anyhow::Result<()> {
    let mut stream = connect_retry(BRAIN_PORT, Duration::from_secs(5))?;
    eprintln!("brain v{ver} alive (pid {})", std::process::id());
    if ver == "2" {
        // Prove input path post-restart.
        stream.write_all(b"INPUT: hello-after-restart\n")?;
        stream.flush()?;
    }
    // "Do logic": just drain the forwarded output until killed.
    let mut buf = [0u8; 4096];
    let mut s = stream;
    loop {
        match s.read(&mut buf) {
            Ok(0) => break,
            Ok(_) => {}
            Err(_) => break,
        }
    }
    Ok(())
}

/// External client: simulates a live connection that MUST survive brain restarts.
fn client() -> anyhow::Result<()> {
    let mut stream = connect_retry(CLIENT_PORT, Duration::from_secs(5))?;
    let mut log = File::create(CLIENT_LOG)?;
    let mut buf = [0u8; 4096];
    loop {
        match stream.read(&mut buf) {
            Ok(0) => break,
            Ok(k) => {
                log.write_all(&buf[..k])?;
                log.flush()?;
            }
            Err(_) => break,
        }
    }
    Ok(())
}

fn connect_retry(port: u16, timeout: Duration) -> anyhow::Result<TcpStream> {
    let start = Instant::now();
    loop {
        match TcpStream::connect(("127.0.0.1", port)) {
            Ok(s) => return Ok(s),
            Err(e) => {
                if start.elapsed() > timeout {
                    return Err(e.into());
                }
                thread::sleep(Duration::from_millis(50));
            }
        }
    }
}

/// Orchestrator: drives the whole scenario and asserts the four invariants.
fn run() -> anyhow::Result<()> {
    // Clean prior logs.
    for f in [PTY_LOG, META_LOG, CLIENT_LOG] {
        let _ = std::fs::remove_file(f);
    }
    let exe = std::env::current_exe()?;
    let spawn = |arg: &str, arg2: Option<&str>| -> std::process::Child {
        let mut c = std::process::Command::new(&exe);
        c.arg(arg);
        if let Some(a) = arg2 {
            c.arg(a);
        }
        c.spawn().expect("spawn")
    };

    println!("[run] starting broker (owns PTY + child + sockets)");
    let mut broker = spawn("broker", None);
    thread::sleep(Duration::from_millis(800)); // let child tick + ports bind

    println!("[run] connecting live external client");
    let mut client = spawn("client", None);
    thread::sleep(Duration::from_millis(1200));

    println!("[run] starting brain v1");
    let mut brain1 = spawn("brain", Some("1"));
    thread::sleep(Duration::from_millis(1500));

    let ticks_at_kill = count_ticks(PTY_LOG);
    println!("[run] killing brain v1 (ticks so far: {ticks_at_kill})");
    brain1.kill().ok();
    brain1.wait().ok();
    thread::sleep(Duration::from_millis(700)); // brain GONE window — child must keep ticking

    println!("[run] starting brain v2 (sends input post-restart)");
    let mut brain2 = spawn("brain", Some("2"));
    thread::sleep(Duration::from_millis(1800)); // let input echo + more ticks land

    println!("[run] tearing down");
    brain2.kill().ok();
    client.kill().ok();
    broker.kill().ok();
    brain2.wait().ok();
    client.wait().ok();
    broker.wait().ok();
    thread::sleep(Duration::from_millis(200));

    // ---- assertions ----
    let pty = read_to_string(PTY_LOG);
    let meta = read_to_string(META_LOG);
    let client_log = read_to_string(CLIENT_LOG);

    let child_pid = meta
        .lines()
        .find_map(|l| l.strip_prefix("CHILD_PID="))
        .unwrap_or("?")
        .to_string();

    let pty_ticks = extract_tick_seq(&pty);
    let client_ticks = extract_tick_seq(&client_log);
    let saw_eof = pty.contains("[PTY-EOF]");
    let echoed = pty.contains("echo: hello-after-restart");

    let a_ok = !saw_eof && pty_ticks.last().copied().unwrap_or(0) > ticks_at_kill_seq(ticks_at_kill);
    let b_ok = contiguous(&pty_ticks) && pty_ticks.len() >= 10;
    let c_ok = echoed;
    let d_ok = contiguous(&client_ticks) && client_ticks.len() >= 5;

    println!("\n===== SPIKE #1 RESULT =====");
    println!("child pid             : {child_pid}");
    println!("pty ticks captured    : {} (max {:?})", pty_ticks.len(), pty_ticks.last());
    println!("client ticks captured : {} (max {:?})", client_ticks.len(), client_ticks.last());
    println!("pty EOF seen          : {saw_eof}");
    println!("post-restart echo     : {echoed}");
    println!("---------------------------");
    report("A  child survives brain restart (no PTY EOF, ticks advanced)", a_ok);
    report("B  PTY counter contiguous (no gap / no premature EOF)", b_ok);
    report("C  input reaches child AFTER brain restart (broker writer survived)", c_ok);
    report("D  live client socket survives brain restart, no gap", d_ok);
    println!("===========================");

    let all = a_ok && b_ok && c_ok && d_ok;
    println!("\nOVERALL: {}", if all { "PASS ✅" } else { "FAIL ❌" });
    if !all {
        std::process::exit(1);
    }
    Ok(())
}

fn report(label: &str, ok: bool) {
    println!("[{}] {label}", if ok { "PASS" } else { "FAIL" });
}

fn read_to_string(p: &str) -> String {
    std::fs::read(p).map(|b| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default()
}

fn count_ticks(p: &str) -> usize {
    extract_tick_seq(&read_to_string(p)).len()
}

// Marker so invariant A compares against a real captured value, not the count.
fn ticks_at_kill_seq(count_at_kill: usize) -> u64 {
    // We only need "advanced beyond the kill point"; using count as a floor is sufficient
    // because ticks are monotonic and contiguous (checked by B).
    count_at_kill.saturating_sub(1) as u64
}

/// Pull the integer N out of every `tick N` occurrence, in order, tolerating
/// ConPTY escape sequences and \r\n framing around the text.
fn extract_tick_seq(s: &str) -> Vec<u64> {
    let mut out = Vec::new();
    let bytes = s.as_bytes();
    let needle = b"tick ";
    let mut i = 0;
    while i + needle.len() < bytes.len() {
        if &bytes[i..i + needle.len()] == needle {
            let mut j = i + needle.len();
            let mut val: u64 = 0;
            let mut got = false;
            while j < bytes.len() && bytes[j].is_ascii_digit() {
                val = val * 10 + (bytes[j] - b'0') as u64;
                got = true;
                j += 1;
            }
            if got {
                out.push(val);
            }
            i = j;
        } else {
            i += 1;
        }
    }
    out
}

/// True if the sequence (after dedup of repeats) increases by exactly 1 with no gaps.
fn contiguous(seq: &[u64]) -> bool {
    if seq.is_empty() {
        return false;
    }
    let mut prev: Option<u64> = None;
    for &v in seq {
        if let Some(p) = prev {
            if v == p {
                continue; // duplicate render of same line — tolerate
            }
            if v != p + 1 {
                return false; // gap or rewind => failure
            }
        }
        prev = Some(v);
    }
    true
}


Build (fetches portable-pty; backgrounded to stay reachable).


Bash

cargo build --manifest-path "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff/Cargo.toml" 2>&1

Build spike (fetch deps + compile)


Command running in background with ID: bbmkq07g1. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bbmkq07g1.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>bbmkq07g1</task-id>
<tool-use-id>toolu_01YcFgHC6LXWGd4hrvETY3iQ</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bbmkq07g1.output</output-file>
<status>completed</status>
<summary>Background command "Build spike (fetch deps + compile)" completed (exit code 0)</summary>
</task-notification>


Build running. Waiting for compile.


sleep 25; tail -30 "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/b494f5e6-5b4d-4145-aaaa-5585e1168377/tasks/bbmkq07g1.output" 2>/dev/null || echo "no output yet"

Peek build progress


<tool_use_error>Blocked: sleep 25 followed by: tail -30 "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/b494f5e6-5b4d-4145-aaaa-5585e1168377/tasks/bbmkq07g1.output" echo "no output yet". To wait for a condition, use Monitor with an until-loop (e.g. `until <check>; do sleep 2; done`). To wait for a command you started, use run_in_background: true. Do not chain shorter sleeps to work around this block.</tool_use_error>

Error: Blocked: sleep 25 followed by: tail -30 "C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/b494f5e6-5b4d-4145-aaaa-5585e1168377/tasks/bbmkq07g1.output" echo "no output yet". To wait for a condition, use Monitor with an until-loop (e.g. `until <check>; do sleep 2; done`). To wait for a command you started, use run_in_background: true. Do not chain shorter sleeps to work around this block.


Foreground sleep blocked. Background notifies on completion — waiting. Quick interim peek:


1	    Updating crates.io index
2	     Locking 27 packages to latest compatible versions
3	 Downloading crates ...
4	  Downloaded shared_library v0.1.9
5	  Downloaded serial2 v0.2.37
6	  Downloaded shell-words v1.1.1
7	  Downloaded portable-pty v0.9.0
8	  Downloaded winreg v0.10.1
9	  Downloaded log v0.4.30
10	  Downloaded bitflags v2.11.1
11	  Downloaded filedescriptor v0.8.3
12	  Downloaded downcast-rs v1.2.1
13	  Downloaded cfg_aliases v0.1.1
14	  Downloaded nix v0.28.0
15	  Downloaded libc v0.2.186
16	   Compiling proc-macro2 v1.0.106
17	   Compiling quote v1.0.45
18	   Compiling unicode-ident v1.0.24
19	   Compiling libc v0.2.186
20	   Compiling winapi v0.3.9
21	   Compiling thiserror v1.0.69
22	   Compiling cfg_aliases v0.1.1
23	   Compiling winreg v0.10.1
24	   Compiling windows-link v0.2.1
25	   Compiling anyhow v1.0.102
26	   Compiling bitflags v2.11.1
27	   Compiling lazy_static v1.5.0
28	   Compiling cfg-if v1.0.4
29	   Compiling shell-words v1.1.1
30	   Compiling downcast-rs v1.2.1
31	   Compiling log v0.4.30
32	   Compiling windows-sys v0.61.2
33	   Compiling bitflags v1.3.2
34	   Compiling nix v0.28.0
35	   Compiling shared_library v0.1.9
36	   Compiling syn v2.0.117
37	   Compiling serial2 v0.2.37
38	   Compiling thiserror-impl v1.0.69
39	   Compiling filedescriptor v0.8.3
40	   Compiling portable-pty v0.9.0
41	   Compiling spike-01-broker-handoff v0.0.0 (C:\Users\decid\Documents\projects\spt-spikes\spike-01-broker-handoff)
42	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.54s
43

    Updating crates.io index
     Locking 27 packages to latest compatible versions
 Downloading crates ...
  Downloaded shared_library v0.1.9
  Downloaded serial2 v0.2.37
  Downloaded shell-words v1.1.1
  Downloaded portable-pty v0.9.0
  Downloaded winreg v0.10.1
  Downloaded log v0.4.30
  Downloaded bitflags v2.11.1
  Downloaded filedescriptor v0.8.3
  Downloaded downcast-rs v1.2.1
  Downloaded cfg_aliases v0.1.1
  Downloaded nix v0.28.0
  Downloaded libc v0.2.186
   Compiling proc-macro2 v1.0.106
   Compiling quote v1.0.45
   Compiling unicode-ident v1.0.24
   Compiling libc v0.2.186
   Compiling winapi v0.3.9
   Compiling thiserror v1.0.69
   Compiling cfg_aliases v0.1.1
   Compiling winreg v0.10.1
   Compiling windows-link v0.2.1
   Compiling anyhow v1.0.102
   Compiling bitflags v2.11.1
   Compiling lazy_static v1.5.0
   Compiling cfg-if v1.0.4
   Compiling shell-words v1.1.1
   Compiling downcast-rs v1.2.1
   Compiling log v0.4.30
   Compiling windows-sys v0.61.2
   Compiling bitflags v1.3.2
   Compiling nix v0.28.0
   Compiling shared_library v0.1.9
   Compiling syn v2.0.117
   Compiling serial2 v0.2.37
   Compiling thiserror-impl v1.0.69
   Compiling filedescriptor v0.8.3
   Compiling portable-pty v0.9.0
   Compiling spike-01-broker-handoff v0.0.0 (C:\Users\decid\Documents\projects\spt-spikes\spike-01-broker-handoff)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.54s


Prompt:
<task-notification>
<task-id>bbmkq07g1</task-id>
<tool-use-id>toolu_01YcFgHC6LXWGd4hrvETY3iQ</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bbmkq07g1.output</output-file>
<status>completed</status>
<summary>Background command "Build spike (fetch deps + compile)" completed (exit code 0)</summary>
</task-notification>


Build clean (exit 0, 6.5s). Run the spike scenario (~7s, self-terminating). Run from spike dir so logs land there.


cd "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff" && ./target/debug/spike.exe run 2>&1

Run broker/brain handoff spike scenario


Error: Exit code 1
[run] starting broker (owns PTY + child + sockets)
[run] connecting live external client
[run] starting brain v1
brain v1 alive (pid 5748)
[run] killing brain v1 (ticks so far: 0)
[run] starting brain v2 (sends input post-restart)
brain v2 alive (pid 56472)
[run] tearing down

===== SPIKE #1 RESULT =====
child pid             : 55708
pty ticks captured    : 0 (max None)
client ticks captured : 0 (max None)
pty EOF seen          : false
post-restart echo     : false
---------------------------
[FAIL] A  child survives brain restart (no PTY EOF, ticks advanced)
[FAIL] B  PTY counter contiguous (no gap / no premature EOF)
[FAIL] C  input reaches child AFTER brain restart (broker writer survived)
[FAIL] D  live client socket survives brain restart, no gap
===========================

OVERALL: FAIL ❌


0 ticks — child spawned (pid 55708) but no PTY output captured. Inspect raw logs.


cd "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff" && echo "=== META ===" && cat broker-meta.log && echo "=== PTY LOG (size) ===" && wc -c broker-pty.log && echo "=== PTY LOG (od first 400 bytes) ===" && od -c broker-pty.log | head -30 && echo "=== CLIENT LOG (size) ===" && wc -c client.log

Inspect spike log files raw


=== META ===
CHILD_PID=55708
=== PTY LOG (size) ===
4 broker-pty.log
=== PTY LOG (od first 400 bytes) ===
0000000 033   [   6   n
0000004
=== CLIENT LOG (size) ===
0 client.log
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


PTY log = 4 bytes: `\033[6n` (ConPTY's startup cursor-position query). So ConPTY alive + reader working, but child produced no stdout. Child likely died or stdout not on pty. Test child standalone.


cd "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff" && timeout 2 ./target/debug/spike.exe child 2>&1 | head -15; echo "(exit: $?)"

Run child mode standalone for 2s


tick 0
tick 1
tick 2
tick 3
tick 4
tick 5
tick 6
tick 7
tick 8
tick 9
tick 10
tick 11
tick 12
tick 13
(exit: 0)
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


Child works standalone but is silent under ConPTY — only ConPTY's own DSR (`\e[6n`) came through, no `[PTY-EOF]` (reader blocked waiting). So child stdout isn't reaching the pty master. Probe the portable-pty path with a known-good child (`cmd.exe`) to isolate: plumbing bug vs my child.


Edit

        "client" => client(),
        "run" => run(),

        "client" => client(),
        "ptyprobe" => ptyprobe(),
        "run" => run(),


The file C:\Users\decid\Documents\projects\spt-spikes\spike-01-broker-handoff\src\main.rs has been updated successfully. (file state is current in your context — no need to Read it back)

        "client" => client(),
        "run" => run(),

        "client" => client(),
        "ptyprobe" => ptyprobe(),
        "run" => run(),

"brain" => brain(args.get(2).map(|s| s.as_str()).unwrap_or("1")),


/// Long-lived workload inside the PTY: emit a monotonic counter, echo stdin lines.

/// Diagnostic: open a pty, spawn a KNOWN-GOOD child (cmd.exe / sh) and dump what the
/// master reader sees for 2s. Isolates "portable-pty plumbing" from "my child binary".
fn ptyprobe() -> anyhow::Result<()> {
    use portable_pty::{native_pty_system, CommandBuilder, PtySize};
    let pty = native_pty_system();
    let pair = pty.openpty(PtySize { rows: 30, cols: 120, pixel_width: 0, pixel_height: 0 })?;
    let mut cmd = if cfg!(windows) {
        let mut c = CommandBuilder::new("cmd.exe");
        c.arg("/c");
        c.arg("echo HELLO_FROM_PTY & ping -n 3 127.0.0.1 >NUL & echo DONE_FROM_PTY");
        c
    } else {
        let mut c = CommandBuilder::new("sh");
        c.arg("-c");
        c.arg("echo HELLO_FROM_PTY; sleep 2; echo DONE_FROM_PTY");
        c
    };
    let _child = pair.slave.spawn_command(cmd)?;
    drop(pair.slave);
    let mut reader = pair.master.try_clone_reader()?;
    let _w = pair.master.take_writer()?;
    let _m = pair.master;
    let start = Instant::now();
    let mut total = Vec::new();
    let mut buf = [0u8; 4096];
    while start.elapsed() < Duration::from_secs(3) {
        match reader.read(&mut buf) {
            Ok(0) => { eprintln!("[probe] EOF"); break; }
            Ok(k) => total.extend_from_slice(&buf[..k]),
            Err(e) => { eprintln!("[probe] err {e}"); break; }
        }
    }
    let s = String::from_utf8_lossy(&total);
    eprintln!("[probe] bytes={} sawHELLO={} sawDONE={}",
        total.len(), s.contains("HELLO_FROM_PTY"), s.contains("DONE_FROM_PTY"));
    let _ = cmd_noop();
    Ok(())
}
fn cmd_noop() {}

/// Long-lived workload inside the PTY: emit a monotonic counter, echo stdin lines.


/// Diagnostic: open a pty, spawn a KNOWN-GOOD child (cmd.exe / sh) and dump what the
/// master reader sees for 2s. Isolates "portable-pty plumbing" from "my child binary".
fn ptyprobe() -> anyhow::Result<()> {
    use portable_pty::{native_pty_system, CommandBuilder, PtySize};
    let pty = native_pty_system();
    let pair = pty.openpty(PtySize { rows: 30, cols: 120, pixel_width: 0, pixel_height: 0 })?;
    let mut cmd = if cfg!(windows) {
        let mut c = CommandBuilder::new("cmd.exe");
        c.arg("/c");
        c.arg("echo HELLO_FROM_PTY & ping -n 3 127.0.0.1 >NUL & echo DONE_FROM_PTY");
        c
    } else {
        let mut c = CommandBuilder::new("sh");
        c.arg("-c");
        c.arg("echo HELLO_FROM_PTY; sleep 2; echo DONE_FROM_PTY");
        c
    };
    let _child = pair.slave.spawn_command(cmd)?;
    drop(pair.slave);
    let mut reader = pair.master.try_clone_reader()?;
    let _w = pair.master.take_writer()?;
    let _m = pair.master;
    let start = Instant::now();
    let mut total = Vec::new();
    let mut buf = [0u8; 4096];
    while start.elapsed() < Duration::from_secs(3) {
        match reader.read(&mut buf) {
            Ok(0) => { eprintln!("[probe] EOF"); break; }
            Ok(k) => total.extend_from_slice(&buf[..k]),
            Err(e) => { eprintln!("[probe] err {e}"); break; }
        }
    }
    let s = String::from_utf8_lossy(&total);
    eprintln!("[probe] bytes={} sawHELLO={} sawDONE={}",
        total.len(), s.contains("HELLO_FROM_PTY"), s.contains("DONE_FROM_PTY"));
    let _ = cmd_noop();
    Ok(())
}
fn cmd_noop() {}

/// Long-lived workload inside the PTY: emit a monotonic counter, echo stdin lines.

+/// Diagnostic: open a pty, spawn a KNOWN-GOOD child (cmd.exe / sh) and dump what the

+/// master reader sees for 2s. Isolates "portable-pty plumbing" from "my child binary".

+    use portable_pty::{native_pty_system, CommandBuilder, PtySize};

+    let pair = pty.openpty(PtySize { rows: 30, cols: 120, pixel_width: 0, pixel_height: 0 })?;

+        let mut c = CommandBuilder::new("cmd.exe");

+        c.arg("echo HELLO_FROM_PTY & ping -n 3 127.0.0.1 >NUL & echo DONE_FROM_PTY");

+        let mut c = CommandBuilder::new("sh");

+        c.arg("echo HELLO_FROM_PTY; sleep 2; echo DONE_FROM_PTY");

+    let _child = pair.slave.spawn_command(cmd)?;

+    let mut reader = pair.master.try_clone_reader()?;

+    let _w = pair.master.take_writer()?;

+    while start.elapsed() < Duration::from_secs(3) {

+            Ok(0) => { eprintln!("[probe] EOF"); break; }

+            Ok(k) => total.extend_from_slice(&buf[..k]),

+            Err(e) => { eprintln!("[probe] err {e}"); break; }

+    let s = String::from_utf8_lossy(&total);

+    eprintln!("[probe] bytes={} sawHELLO={} sawDONE={}",

+        total.len(), s.contains("HELLO_FROM_PTY"), s.contains("DONE_FROM_PTY"));

// stdin reader thread: echo every line so we can prove input reaches the app.


Bash

cargo build --manifest-path "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff/Cargo.toml" 2>&1 | tail -5 && cd "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff" && ./target/debug/spike.exe ptyprobe 2>&1

Rebuild and run ptyprobe with cmd.exe child


Command running in background with ID: bm3u2p26g. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bm3u2p26g.output. You will be notified when it completes. To check interim output, use Read on that file path.


1	   |
2	   = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default
3
4	warning: `spike-01-broker-handoff` (bin "spike") generated 1 warning (run `cargo fix --bin "spike" -p spike-01-broker-handoff` to apply 1 suggestion)
5	    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
6

   |
   = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default

warning: `spike-01-broker-handoff` (bin "spike") generated 1 warning (run `cargo fix --bin "spike" -p spike-01-broker-handoff` to apply 1 suggestion)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s


Build done (0.54s). Probe running (cmd.exe ~2s ping). Waiting.


Probe still running (or finishing). Awaiting completion notification.


cd "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff" && ./target/debug/spike.exe ptyprobe

Run ptyprobe directly capturing stderr


Command running in background with ID: b4uf3a7hv. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b4uf3a7hv.output. You will be notified when it completes. To check interim output, use Read on that file path.


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


Found it — my read loop blocks in `reader.read()` (ConPTY never EOFs the master while we hold the writer), so the 3s guard never fires and the summary never prints. That also means the probe hangs (auto-backgrounds). Fixing: reader on its own thread, watchdog exits after timeout, write summary to a file.


Edit

    let _child = pair.slave.spawn_command(cmd)?;
    drop(pair.slave);
    let mut reader = pair.master.try_clone_reader()?;
    let _w = pair.master.take_writer()?;
    let _m = pair.master;
    let start = Instant::now();
    let mut total = Vec::new();
    let mut buf = [0u8; 4096];
    while start.elapsed() < Duration::from_secs(3) {
        match reader.read(&mut buf) {
            Ok(0) => { eprintln!("[probe] EOF"); break; }
            Ok(k) => total.extend_from_slice(&buf[..k]),
            Err(e) => { eprintln!("[probe] err {e}"); break; }
        }
    }
    let s = String::from_utf8_lossy(&total);
    eprintln!("[probe] bytes={} sawHELLO={} sawDONE={}",
        total.len(), s.contains("HELLO_FROM_PTY"), s.contains("DONE_FROM_PTY"));
    let _ = cmd_noop();
    Ok(())
}
fn cmd_noop() {}

    let _child = pair.slave.spawn_command(cmd)?;
    drop(pair.slave);
    let mut reader = pair.master.try_clone_reader()?;
    let _w = pair.master.take_writer()?;
    let _m = pair.master;
    let total: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
    {
        let total = total.clone();
        thread::spawn(move || {
            let mut buf = [0u8; 4096];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) => break,
                    Ok(k) => total.lock().unwrap().extend_from_slice(&buf[..k]),
                    Err(_) => break,
                }
            }
        });
    }
    thread::sleep(Duration::from_secs(3)); // collect, don't block on read
    let g = total.lock().unwrap();
    let s = String::from_utf8_lossy(&g);
    let mut out = File::create("probe.log")?;
    writeln!(out, "bytes={} sawHELLO={} sawDONE={}",
        g.len(), s.contains("HELLO_FROM_PTY"), s.contains("DONE_FROM_PTY"))?;
    writeln!(out, "--- raw (lossy) ---\n{s}")?;
    out.flush()?;
    std::process::exit(0);
}


    let _child = pair.slave.spawn_command(cmd)?;
    drop(pair.slave);
    let mut reader = pair.master.try_clone_reader()?;
    let _w = pair.master.take_writer()?;
    let _m = pair.master;
    let start = Instant::now();
    let mut total = Vec::new();
    let mut buf = [0u8; 4096];
    while start.elapsed() < Duration::from_secs(3) {
        match reader.read(&mut buf) {
            Ok(0) => { eprintln!("[probe] EOF"); break; }
            Ok(k) => total.extend_from_slice(&buf[..k]),
            Err(e) => { eprintln!("[probe] err {e}"); break; }
        }
    }
    let s = String::from_utf8_lossy(&total);
    eprintln!("[probe] bytes={} sawHELLO={} sawDONE={}",
        total.len(), s.contains("HELLO_FROM_PTY"), s.contains("DONE_FROM_PTY"));
    let _ = cmd_noop();
    Ok(())
}
fn cmd_noop() {}

    let _child = pair.slave.spawn_command(cmd)?;
    drop(pair.slave);
    let mut reader = pair.master.try_clone_reader()?;
    let _w = pair.master.take_writer()?;
    let _m = pair.master;
    let total: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
    {
        let total = total.clone();
        thread::spawn(move || {
            let mut buf = [0u8; 4096];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) => break,
                    Ok(k) => total.lock().unwrap().extend_from_slice(&buf[..k]),
                    Err(_) => break,
                }
            }
        });
    }
    thread::sleep(Duration::from_secs(3)); // collect, don't block on read
    let g = total.lock().unwrap();
    let s = String::from_utf8_lossy(&g);
    let mut out = File::create("probe.log")?;
    writeln!(out, "bytes={} sawHELLO={} sawDONE={}",
        g.len(), s.contains("HELLO_FROM_PTY"), s.contains("DONE_FROM_PTY"))?;
    writeln!(out, "--- raw (lossy) ---\n{s}")?;
    out.flush()?;
    std::process::exit(0);
}

-    while start.elapsed() < Duration::from_secs(3) {

-            Ok(0) => { eprintln!("[probe] EOF"); break; }

-            Ok(k) => total.extend_from_slice(&buf[..k]),

-            Err(e) => { eprintln!("[probe] err {e}"); break; }

+    let total: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));

+                match reader.read(&mut buf) {

+                    Ok(k) => total.lock().unwrap().extend_from_slice(&buf[..k]),

-    let s = String::from_utf8_lossy(&total);

-    eprintln!("[probe] bytes={} sawHELLO={} sawDONE={}",

-        total.len(), s.contains("HELLO_FROM_PTY"), s.contains("DONE_FROM_PTY"));

+    thread::sleep(Duration::from_secs(3)); // collect, don't block on read

+    let s = String::from_utf8_lossy(&g);

+    let mut out = File::create("probe.log")?;

+    writeln!(out, "bytes={} sawHELLO={} sawDONE={}",

+        g.len(), s.contains("HELLO_FROM_PTY"), s.contains("DONE_FROM_PTY"))?;

+    writeln!(out, "--- raw (lossy) ---\n{s}")?;

/// Long-lived workload inside the PTY: emit a monotonic counter, echo stdin lines.


Bash

cargo build --manifest-path "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff/Cargo.toml" 2>&1 | tail -3 && cd "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff" && ./target/debug/spike.exe ptyprobe; echo "exit $?"; echo "=== probe.log ==="; cat probe.log

Rebuild and run fixed ptyprobe, show probe.log


Command running in background with ID: bla2qqeyv. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bla2qqeyv.output. You will be notified when it completes. To check interim output, use Read on that file path.


Backgrounded. Awaiting completion notification.


1
2	Caused by:
3	  Access is denied. (os error 5)
4

Caused by:
  Access is denied. (os error 5)


<task-notification>
<task-id>bm3u2p26g</task-id>
<tool-use-id>toolu_01Nz5gJX3PRaiwzKJtNkbQZ9</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bm3u2p26g.output</output-file>
<status>failed</status>
<summary>Background command "Rebuild and run ptyprobe with cmd.exe child" failed with exit code 127</summary>
</task-notification>


<task-notification>
<task-id>b4uf3a7hv</task-id>
<tool-use-id>toolu_01N5ZTUVqWeBrZwG4Y9p4Ljb</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b4uf3a7hv.output</output-file>
<status>failed</status>
<summary>Background command "Run ptyprobe directly capturing stderr" failed with exit code 127</summary>
</task-notification>


<task-notification>
<task-id>bla2qqeyv</task-id>
<tool-use-id>toolu_01RJgLaGF3i22bTUzayNF6Dd</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bla2qqeyv.output</output-file>
<status>failed</status>
<summary>Background command "Rebuild and run fixed ptyprobe, show probe.log" failed with exit code 1</summary>
</task-notification>


Build relink failed — `Access is denied (os error 5)`: lingering hung `spike.exe` processes (earlier probes) lock the binary. Kill them.


PowerShell

Get-Process spike -ErrorAction SilentlyContinue | Select-Object Id,StartTime; Stop-Process -Name spike -Force -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 300; "remaining: " + (@(Get-Process spike -ErrorAction SilentlyContinue).Count)

Kill lingering spike.exe processes


   Id StartTime
   -- ---------
41348 5/31/2026 7:45:02 PM
42896 5/31/2026 7:46:18 PM
53460 5/31/2026 7:47:19 PM
remaining: 0


Prompt:
<task-notification>
<task-id>bm3u2p26g</task-id>
<tool-use-id>toolu_01Nz5gJX3PRaiwzKJtNkbQZ9</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bm3u2p26g.output</output-file>
<status>failed</status>
<summary>Background command "Rebuild and run ptyprobe with cmd.exe child" failed with exit code 127</summary>
</task-notification>


Prompt:
<task-notification>
<task-id>b4uf3a7hv</task-id>
<tool-use-id>toolu_01N5ZTUVqWeBrZwG4Y9p4Ljb</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b4uf3a7hv.output</output-file>
<status>failed</status>
<summary>Background command "Run ptyprobe directly capturing stderr" failed with exit code 127</summary>
</task-notification>


Prompt:
<task-notification>
<task-id>bla2qqeyv</task-id>
<tool-use-id>toolu_01RJgLaGF3i22bTUzayNF6Dd</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bla2qqeyv.output</output-file>
<status>failed</status>
<summary>Background command "Rebuild and run fixed ptyprobe, show probe.log" failed with exit code 1</summary>
</task-notification>


3 zombies killed. Rebuild + run fixed probe (now exits in 3s).


Bash

cargo build --manifest-path "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff/Cargo.toml" 2>&1 | tail -3 && cd "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff" && ./target/debug/spike.exe ptyprobe; echo "exit $?"; echo "=== probe.log ==="; cat probe.log

Rebuild and run fixed ptyprobe


warning: `spike-01-broker-handoff` (bin "spike") generated 1 warning (run `cargo fix --bin "spike" -p spike-01-broker-handoff` to apply 1 suggestion)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
exit 0
=== probe.log ===
bytes=4 sawHELLO=false sawDONE=false
--- raw (lossy) ---

Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


Confirmed: it's a **portable-pty/ConPTY plumbing issue, not my child** — even `cmd.exe` yields only the 4-byte DSR `\e[6n`, no `HELLO`.

Root cause hypothesis: ConPTY emits `\e[6n` (cursor-position query) and **stalls rendering until the terminal answers it**. We never reply, so program output never flushes. Fix: detect `\e[6n`, write back `\e[1;1R`. Testing in probe.


Edit

    let _child = pair.slave.spawn_command(cmd)?;
    drop(pair.slave);
    let mut reader = pair.master.try_clone_reader()?;
    let _w = pair.master.take_writer()?;
    let _m = pair.master;
    let total: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
    {
        let total = total.clone();
        thread::spawn(move || {
            let mut buf = [0u8; 4096];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) => break,
                    Ok(k) => total.lock().unwrap().extend_from_slice(&buf[..k]),
                    Err(_) => break,
                }
            }
        });
    }

    let _child = pair.slave.spawn_command(cmd)?;
    drop(pair.slave);
    let mut reader = pair.master.try_clone_reader()?;
    let writer = Arc::new(Mutex::new(pair.master.take_writer()?));
    let _m = pair.master;
    let total: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
    {
        let total = total.clone();
        let writer = writer.clone();
        thread::spawn(move || {
            let mut buf = [0u8; 4096];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) => break,
                    Ok(k) => {
                        let chunk = &buf[..k];
                        // Answer ConPTY's DSR (cursor-position query) so it unblocks rendering.
                        if chunk.windows(4).any(|w| w == b"\x1b[6n") {
                            let mut w = writer.lock().unwrap();
                            let _ = w.write_all(b"\x1b[1;1R");
                            let _ = w.flush();
                        }
                        total.lock().unwrap().extend_from_slice(chunk);
                    }
                    Err(_) => break,
                }
            }
        });
    }


    let _child = pair.slave.spawn_command(cmd)?;
    drop(pair.slave);
    let mut reader = pair.master.try_clone_reader()?;
    let _w = pair.master.take_writer()?;
    let _m = pair.master;
    let total: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
    {
        let total = total.clone();
        thread::spawn(move || {
            let mut buf = [0u8; 4096];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) => break,
                    Ok(k) => total.lock().unwrap().extend_from_slice(&buf[..k]),
                    Err(_) => break,
                }
            }
        });
    }

    let _child = pair.slave.spawn_command(cmd)?;
    drop(pair.slave);
    let mut reader = pair.master.try_clone_reader()?;
    let writer = Arc::new(Mutex::new(pair.master.take_writer()?));
    let _m = pair.master;
    let total: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
    {
        let total = total.clone();
        let writer = writer.clone();
        thread::spawn(move || {
            let mut buf = [0u8; 4096];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) => break,
                    Ok(k) => {
                        let chunk = &buf[..k];
                        // Answer ConPTY's DSR (cursor-position query) so it unblocks rendering.
                        if chunk.windows(4).any(|w| w == b"\x1b[6n") {
                            let mut w = writer.lock().unwrap();
                            let _ = w.write_all(b"\x1b[1;1R");
                            let _ = w.flush();
                        }
                        total.lock().unwrap().extend_from_slice(chunk);
                    }
                    Err(_) => break,
                }
            }
        });
    }

-    let _w = pair.master.take_writer()?;

+    let writer = Arc::new(Mutex::new(pair.master.take_writer()?));

-                    Ok(k) => total.lock().unwrap().extend_from_slice(&buf[..k]),

+                        let chunk = &buf[..k];

+                        // Answer ConPTY's DSR (cursor-position query) so it unblocks rendering.

+                        if chunk.windows(4).any(|w| w == b"\x1b[6n") {

+                            let mut w = writer.lock().unwrap();

+                            let _ = w.write_all(b"\x1b[1;1R");

+                            let _ = w.flush();

+                        total.lock().unwrap().extend_from_slice(chunk);


Bash

cargo build --manifest-path "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff/Cargo.toml" 2>&1 | tail -2 && cd "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff" && ./target/debug/spike.exe ptyprobe; echo "exit $?"; echo "=== probe.log (head) ==="; head -8 probe.log

Rebuild and run probe with DSR answer


warning: `spike-01-broker-handoff` (bin "spike") generated 1 warning (run `cargo fix --bin "spike" -p spike-01-broker-handoff` to apply 1 suggestion)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.57s
exit 0
=== probe.log (head) ===
bytes=93 sawHELLO=true sawDONE=true
--- raw (lossy) ---
]0;C:\WINDOWS\system32\cmd.exeHELLO_FROM_PTY
DONE_FROM_PTY
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


**Confirmed + fixed.** Answering DSR `\e[6n` → `\e[1;1R` unblocks ConPTY: now `bytes=93 sawHELLO=true sawDONE=true`.

This is a real spike finding: **ConPTY withholds ALL program output until the terminal answers its startup cursor-position query.** Any spt-core broker reading a ConPTY master must reply to DSR or get zero output. → new hazard.

Apply same fix to broker reader, rerun full spike.


Edit

    // PTY reader thread: sole owner of the read side. Logs + broadcasts.
    {
        let sinks = sinks.clone();
        thread::spawn(move || {
            let mut log = OpenOptions::new()
                .create(true)
                .append(true)
                .open(PTY_LOG)
                .expect("open pty log");
            let mut r = reader;
            let mut buf = [0u8; 4096];
            loop {
                match r.read(&mut buf) {
                    Ok(0) => {
                        let _ = writeln!(log, "[PTY-EOF]");
                        let _ = log.flush();
                        break; // EOF => child/pty gone. Invariant A failure if before end.
                    }
                    Ok(k) => {
                        let chunk = &buf[..k];
                        let _ = log.write_all(chunk);
                        let _ = log.flush();
                        let mut guard = sinks.lock().unwrap();
                        guard.retain_mut(|s| s.write_all(chunk).and_then(|_| s.flush()).is_ok());
                    }
                    Err(_) => break,
                }
            }
        });
    }

    // PTY reader thread: sole owner of the read side. Logs + broadcasts.
    // Must answer ConPTY's DSR query or ConPTY withholds all program output (spike finding).
    {
        let sinks = sinks.clone();
        let writer = writer.clone();
        thread::spawn(move || {
            let mut log = OpenOptions::new()
                .create(true)
                .append(true)
                .open(PTY_LOG)
                .expect("open pty log");
            let mut r = reader;
            let mut buf = [0u8; 4096];
            loop {
                match r.read(&mut buf) {
                    Ok(0) => {
                        let _ = writeln!(log, "[PTY-EOF]");
                        let _ = log.flush();
                        break; // EOF => child/pty gone. Invariant A failure if before end.
                    }
                    Ok(k) => {
                        let chunk = &buf[..k];
                        if chunk.windows(4).any(|w| w == b"\x1b[6n") {
                            let mut w = writer.lock().unwrap();
                            let _ = w.write_all(b"\x1b[1;1R");
                            let _ = w.flush();
                        }
                        let _ = log.write_all(chunk);
                        let _ = log.flush();
                        let mut guard = sinks.lock().unwrap();
                        guard.retain_mut(|s| s.write_all(chunk).and_then(|_| s.flush()).is_ok());
                    }
                    Err(_) => break,
                }
            }
        });
    }


    // PTY reader thread: sole owner of the read side. Logs + broadcasts.
    {
        let sinks = sinks.clone();
        thread::spawn(move || {
            let mut log = OpenOptions::new()
                .create(true)
                .append(true)
                .open(PTY_LOG)
                .expect("open pty log");
            let mut r = reader;
            let mut buf = [0u8; 4096];
            loop {
                match r.read(&mut buf) {
                    Ok(0) => {
                        let _ = writeln!(log, "[PTY-EOF]");
                        let _ = log.flush();
                        break; // EOF => child/pty gone. Invariant A failure if before end.
                    }
                    Ok(k) => {
                        let chunk = &buf[..k];
                        let _ = log.write_all(chunk);
                        let _ = log.flush();
                        let mut guard = sinks.lock().unwrap();
                        guard.retain_mut(|s| s.write_all(chunk).and_then(|_| s.flush()).is_ok());
                    }
                    Err(_) => break,
                }
            }
        });
    }

    // PTY reader thread: sole owner of the read side. Logs + broadcasts.
    // Must answer ConPTY's DSR query or ConPTY withholds all program output (spike finding).
    {
        let sinks = sinks.clone();
        let writer = writer.clone();
        thread::spawn(move || {
            let mut log = OpenOptions::new()
                .create(true)
                .append(true)
                .open(PTY_LOG)
                .expect("open pty log");
            let mut r = reader;
            let mut buf = [0u8; 4096];
            loop {
                match r.read(&mut buf) {
                    Ok(0) => {
                        let _ = writeln!(log, "[PTY-EOF]");
                        let _ = log.flush();
                        break; // EOF => child/pty gone. Invariant A failure if before end.
                    }
                    Ok(k) => {
                        let chunk = &buf[..k];
                        if chunk.windows(4).any(|w| w == b"\x1b[6n") {
                            let mut w = writer.lock().unwrap();
                            let _ = w.write_all(b"\x1b[1;1R");
                            let _ = w.flush();
                        }
                        let _ = log.write_all(chunk);
                        let _ = log.flush();
                        let mut guard = sinks.lock().unwrap();
                        guard.retain_mut(|s| s.write_all(chunk).and_then(|_| s.flush()).is_ok());
                    }
                    Err(_) => break,
                }
            }
        });
    }

let sinks: Arc<Mutex<Vec<TcpStream>>> = Arc::new(Mutex::new(Vec::new()));

+    // Must answer ConPTY's DSR query or ConPTY withholds all program output (spike finding).


Bash

cargo build --manifest-path "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff/Cargo.toml" 2>&1 | tail -2 && cd "C:/Users/decid/Documents/projects/spt-spikes/spike-01-broker-handoff" && ./target/debug/spike.exe run 2>&1; echo "exit $?"

Rebuild and run full spike scenario


warning: `spike-01-broker-handoff` (bin "spike") generated 1 warning (run `cargo fix --bin "spike" -p spike-01-broker-handoff` to apply 1 suggestion)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.58s
[run] starting broker (owns PTY + child + sockets)
[run] connecting live external client
[run] starting brain v1
brain v1 alive (pid 49664)
[run] killing brain v1 (ticks so far: 24)
[run] starting brain v2 (sends input post-restart)
brain v2 alive (pid 47656)
[run] tearing down

===== SPIKE #1 RESULT =====
child pid             : 33880
pty ticks captured    : 40 (max Some(39))
client ticks captured : 34 (max Some(39))
pty EOF seen          : false
post-restart echo     : true
---------------------------
[PASS] A  child survives brain restart (no PTY EOF, ticks advanced)
[PASS] B  PTY counter contiguous (no gap / no premature EOF)
[PASS] C  input reaches child AFTER brain restart (broker writer survived)
[PASS] D  live client socket survives brain restart, no gap
===========================

OVERALL: PASS ✅
exit 0
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


**SPIKE #1 PASS — all 4 invariants green on Windows ConPTY** (the highest-risk path):

```
child pid 33880 survived | pty ticks 0..39 contiguous, no EOF
post-restart input echoed | client socket: 34 ticks, no gap across restart
A PASS  B PASS  C PASS  D PASS  →  OVERALL PASS ✅
```

Thesis validated: **broker owns sole PTY reader+writer+child+client-sockets; brain holds only a forwarding socket and can die/restart with zero endpoint termination.** That's exactly the "brain-only update" class — the one ADR-0004 needs to preserve.

Now fold findings (roadmap step 3). Reading ADR-0004 to amend with: update-class taxonomy, ownership table, the DSR requirement, and the still-open gaps.


1	# Single consolidated daemon with broker/brain split; peer-propagated gated self-update
2
3	## Status
4
5	accepted (2026-05-29)
6
7	## Context
8
9	ADR-0002 collapsed PTY-hosting and network-hosting into one per-machine `spt-daemon`. Two further forces refine its shape:
10
11	1. **Consolidation.** The sister project runs poll listeners as ephemeral per-session background tasks and Psyche wrappers as detached per-live-agent supervisor processes. But poll listeners already interact directly with the agent session (capsule/idle), and Psyche wrappers already invoke harness binaries directly. Once the daemon owns every PTY, keeping these as separate processes is unjustified.
12
13	2. **Seamless self-update with a hard no-terminate constraint.** Self-update is a day-one pillar. The constraint: *no endpoint process may terminate or suspend during an spt-core update* — we cannot assume every endpoint can safely suspend. The naive "drain + restart the daemon" approach violates this for spt-hosted sessions (the daemon owns their PTY; killing the daemon SIGHUPs the child).
14
15	## Decision
16
17	**Consolidate all per-machine logic into the one daemon.** Poll-listener logic and Psyche/pulse loops move into the `spt-daemon` — no separate listener or wrapper processes. The only residue is a thin, stateless **in-session relay** for harness-hosted sessions (topology 1), where spt cannot reach into a process tree it doesn't own; the relay just pipes the daemon's events into the session's stdout and is freely killable.
18
19	**Split the logical daemon into two implementation layers** to satisfy the no-terminate invariant:
20	- **broker** (stable kernel) — holds *only* un-transferable, must-not-die resources: PTY master fds, spawned harness child processes, listening network sockets. Minimal, versioned local IPC. Almost never updates.
21	- **daemon brain** (userspace) — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely; rehydrates from disk state and re-attaches to broker-held handles.
22
23	Routine self-updates replace only the brain → endpoints never notice. The broker updates rarely (IPC-contract change, held-resource-type change, OS PTY/socket API change, or broker bugfix); the IPC is versioned so a newer brain talks to an older broker. Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is internal, not separately addressable.
24
25	Rejected alternatives for the invariant: **drain + restart** (violates no-terminate for spt-hosted sessions); **whole-daemon live FD-passing** (ConPTY handle transfer mid-swap is hard and platform-divergent — pushing that complexity into the rare broker update instead of every routine update is strictly better); **topology-1-only invariant with spt-hosted as fast-follow** (silently breaks the stated hard constraint for the exact new topology spt-core introduces).
26
27	**Self-update delivery:** peer-propagated over P2P, layered on self-fetch, out-of-band still supported. All binaries signature-verified before handoff (spt-core's own release key) regardless of source — peer-propagation otherwise lets one compromised node poison the subnet. spt-core conducts updates for the whole stack: self first, then ripple-update each registered adapter via the adapter manifest's update declaration (file-pull or delegated command). The plugin's role shrinks to initial bootstrap only.
28
29	**Cadence/consent:** not fully automatic by default; gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto choice.
30
31	## Consequences
32
33	- The daemon is the single brain for a machine; crash-recovery and update logic must cover PTYs, networking, registry, spools, listeners, and psyche loops together.
34	- A small internal broker process exists beneath the daemon — a deliberate, bounded walk-back of "literally one process," preserving B1's *intent* (one network identity, one supervisor, one firewall prompt) while guaranteeing endpoint survival across updates.
35	- Peer-propagated updates make release signing mandatory, not optional.
36	- spt-core becomes the update conductor for adapters too; adapter manifests must declare an update avenue.
37	- The "deliver to most-recently-active session" mechanism is a v1 building block that the deferred PresenceChannel will later generalize.
38	- Whole-daemon live FD-passing (zero-interruption even for broker updates) remains a possible future polish but is explicitly not required for v1.
39

# Single consolidated daemon with broker/brain split; peer-propagated gated self-update

## Status

accepted (2026-05-29)

## Context

ADR-0002 collapsed PTY-hosting and network-hosting into one per-machine `spt-daemon`. Two further forces refine its shape:

1. **Consolidation.** The sister project runs poll listeners as ephemeral per-session background tasks and Psyche wrappers as detached per-live-agent supervisor processes. But poll listeners already interact directly with the agent session (capsule/idle), and Psyche wrappers already invoke harness binaries directly. Once the daemon owns every PTY, keeping these as separate processes is unjustified.

2. **Seamless self-update with a hard no-terminate constraint.** Self-update is a day-one pillar. The constraint: *no endpoint process may terminate or suspend during an spt-core update* — we cannot assume every endpoint can safely suspend. The naive "drain + restart the daemon" approach violates this for spt-hosted sessions (the daemon owns their PTY; killing the daemon SIGHUPs the child).

## Decision

**Consolidate all per-machine logic into the one daemon.** Poll-listener logic and Psyche/pulse loops move into the `spt-daemon` — no separate listener or wrapper processes. The only residue is a thin, stateless **in-session relay** for harness-hosted sessions (topology 1), where spt cannot reach into a process tree it doesn't own; the relay just pipes the daemon's events into the session's stdout and is freely killable.

**Split the logical daemon into two implementation layers** to satisfy the no-terminate invariant:
- **broker** (stable kernel) — holds *only* un-transferable, must-not-die resources: PTY master fds, spawned harness child processes, listening network sockets. Minimal, versioned local IPC. Almost never updates.
- **daemon brain** (userspace) — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely; rehydrates from disk state and re-attaches to broker-held handles.

Routine self-updates replace only the brain → endpoints never notice. The broker updates rarely (IPC-contract change, held-resource-type change, OS PTY/socket API change, or broker bugfix); the IPC is versioned so a newer brain talks to an older broker. Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is internal, not separately addressable.

Rejected alternatives for the invariant: **drain + restart** (violates no-terminate for spt-hosted sessions); **whole-daemon live FD-passing** (ConPTY handle transfer mid-swap is hard and platform-divergent — pushing that complexity into the rare broker update instead of every routine update is strictly better); **topology-1-only invariant with spt-hosted as fast-follow** (silently breaks the stated hard constraint for the exact new topology spt-core introduces).

**Self-update delivery:** peer-propagated over P2P, layered on self-fetch, out-of-band still supported. All binaries signature-verified before handoff (spt-core's own release key) regardless of source — peer-propagation otherwise lets one compromised node poison the subnet. spt-core conducts updates for the whole stack: self first, then ripple-update each registered adapter via the adapter manifest's update declaration (file-pull or delegated command). The plugin's role shrinks to initial bootstrap only.

**Cadence/consent:** not fully automatic by default; gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto choice.

## Consequences

- The daemon is the single brain for a machine; crash-recovery and update logic must cover PTYs, networking, registry, spools, listeners, and psyche loops together.
- A small internal broker process exists beneath the daemon — a deliberate, bounded walk-back of "literally one process," preserving B1's *intent* (one network identity, one supervisor, one firewall prompt) while guaranteeing endpoint survival across updates.
- Peer-propagated updates make release signing mandatory, not optional.
- spt-core becomes the update conductor for adapters too; adapter manifests must declare an update avenue.
- The "deliver to most-recently-active session" mechanism is a v1 building block that the deferred PresenceChannel will later generalize.
- Whole-daemon live FD-passing (zero-interruption even for broker updates) remains a possible future polish but is explicitly not required for v1.


Amending ADR-0004 with the spike outcome + codex dispositions (ownership table, update-class taxonomy, DSR requirement, open gaps).


Edit

- Whole-daemon live FD-passing (zero-interruption even for broker updates) remains a possible future polish but is explicitly not required for v1.

- Whole-daemon live FD-passing (zero-interruption even for broker updates) remains a possible future polish but is explicitly not required for v1.

## Validation & amendments (2026-05-31 — Stage A red-team + Spike #1)

Codex adversarial review (`docs/reviews/STAGE-A-codex-redteam.md`) put 4 FATAL findings on this ADR; Spike #1 (`docs/spikes/SPIKE-01-broker-handoff.md`, `spt-spikes/spike-01-broker-handoff`) tested the hardest path. Resulting binding amendments:

### A. Update-class taxonomy (resolves FATAL #1 — R-UPD-3 self-contradiction)
R-UPD-3's "no endpoint terminates during update" is **only** absolute for the *brain-only* class. Three classes, each with its own invariant:
- **brain-only** (the routine case) — endpoints MUST survive untouched. **Spike #1: PROVEN on Windows ConPTY.**
- **broker-compatible** — broker binary swap behind a versioned IPC the running broker can hot-accept; endpoints survive; no spike yet.
- **broker-breaking** — held-resource-type or OS-API change; requires a *planned endpoint-cycle*. R-UPD-3 is explicitly weakened here: this class MAY suspend endpoints, with consent + scheduling. PRD R-UPD-3 must be reworded to scope the absolute guarantee to brain-only.

### B. Ownership table (resolves FATAL #4 — "any live stream owned by brain breaks the invariant")
The boundary is normative, not "broker holds fds, brain holds logic." A resource is broker-owned **iff** a live consumer would lose continuity on brain restart.

| Resource | Owner | Spiked? |
|---|---|---|
| PTY master read+write handle | **broker** | ✅ #1 |
| Spawned harness child process | **broker** | ✅ #1 |
| Accepted local client socket (`api listen`, relay) | **broker** | ✅ #1 (plain TCP) |
| Iroh endpoint + accepted QUIC streams | **broker** | ❌ open (FATAL #2) |
| mDNS socket / relay session | **broker** | ❌ open |
| Listening sockets | **broker** | ✅ #1 |
| Routing, registry state, pulse/psyche loops, manifest parse, update orchestration | brain (rehydrate from disk) | n/a |

Consequence codex forced into the open: pushing live Iroh/QUIC ownership into the broker means networking stream-state lives in the "stable kernel," enlarging it. Accepted as the cost of the invariant; the broker is "stable" in *update cadence*, not in *narrowness*.

### C. ConPTY DSR gotcha (new hazard, discovered by Spike #1)
ConPTY withholds **all** child stdout until the terminal answers its startup cursor-position query (`ESC [ 6 n`). A broker reading a ConPTY master that does not reply `ESC [ <r>;<c> R` sees only the 4-byte query and then nothing — looks like a hung/silent child. Spike got 0 bytes of program output until the reader answered DSR, then full output. → `REQ-HAZARD-CONPTY-DSR`; every ConPTY reader must auto-answer DSR. Added to KNOWN-HAZARDS §5.

### D. Self-update delivery hardening (resolves SERIOUS #5)
Signature-verify alone is insufficient. v1 adds: monotonic version field (rollback rejection), release-metadata expiry, channel pinning, key rotation/revocation list, and **adapter content signing** (adapter file-pull / delegated-command updates were uncovered by "binary signature"). Track as `REQ-HAZARD-UPDATE-ROLLBACK`.

### E. Open spike gaps (must close before M3 builds the daemon)
1. **Live Iroh + file-transfer stream survival across brain restart** (FATAL #2) — Spike #1 only proved PTY + plain TCP. Extend with an active QUIC stream.
2. **Linux `forkpty` parity** — Spike #1 was Windows-only (the high-risk path); rerun on Linux.
3. **100× restart + resize-under-load stress** (codex #3) — Spike #1 did one restart; harden before trusting.
4. **Idempotent/exactly-once delivery across brain restart** (FATAL-adjacent #14) — durable IDs + replay rules at every broker↔brain side-effect boundary. `REQ-HAZARD-RESTART-IDEMPOTENT`.