The file crates\spt\tests\live_firsthost_e2e.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    let set = Arc::new(LiveSet::new());
    // FAST pulse: the driver hammers set_last_active every tick. The W0.4
    // mutate_info sentinel lock makes the offline flip below survive that
    // contention, so no long-pulse+settle workaround is needed (the lock, not
    // timing, makes the un-host deterministic).
    let cfg = DaemonConfig {
        pulse_period: Duration::from_millis(15),
        ..DaemonConfig::default()
    };
    let registered = spt_runtime::registry::registered(&perch::adapters_dir());

//! M11-W0.2 first-host int (REQ-DAEMON-1) — the REAL establish-marks-online path,
//! end-to-end through the actual `spt` binary (no synthetic-online seed like the
//! W0.1 units):
//!
//! a live-capable `spt api listen --once` BINDS the Self perch and marks it
//! `status=online` (the W0.2 first-host handoff — the Psyche no longer spawns in
//! this process) → the brain's `livehost::reconcile_once` then HOSTS the Psyche
//! lifecycle off that real online status → a graceful offline transition un-hosts
//! it. This proves the cutover wiring: the spt process marks online, the brain
//! reconcile reacts, exactly the shellwake CLI-flips / brain-reconciles pattern.
//
// [int->REQ-DAEMON-1]

use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::Arc;
use std::time::Duration;

mod common;
use common::CommandNoWindowExt;

use spt_daemon::brainproc::StartReason;
use spt_daemon::config::DaemonConfig;
use spt_daemon::livehost::{self, LiveSet};
use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
use spt_store::perch::{self, ParentHint};

// A no-op Psyche summarizer: spawns + exits 0 (the daemon online-stamp hosts it,
// not the pid).
#[cfg(windows)]
const NOOP: &str = "cmd /C rem";
#[cfg(unix)]
const NOOP: &str = "true";

/// In-process seed daemon so `api seed`/`api listen` succeed without a REAL
/// detached `spt daemon` (shell_e2e's pattern).
fn start_inproc_daemon() {
    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
    let name = spt_daemon::seed_socket_name();
    std::thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
    });
    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }
        std::thread::sleep(Duration::from_millis(5));
    }
    panic!("in-process seed daemon did not come up");
}

/// `Command::output()` with a deadline, run off-thread (bounds a hang into a
/// clean failure).
fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect("captured spt call must complete")
        .expect("run spt")
}

#[test]
fn live_listen_marks_online_and_brain_reconcile_hosts() {
    // One test in this file: a plain set_var is safe; every child inherits the home.
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    start_inproc_daemon();

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));

    // A live-capable adapter (declares [session.psyche_init]) + register it so the
    // brain reconcile can resolve the manifest from disk by the perch's adapter.
    let drops = home.path().join("drops");
    std::fs::create_dir_all(&drops).unwrap();
    let dir = drops.to_string_lossy().replace('\\', "/");
    let manifest_toml = format!(
        "[adapter]\nname=\"mocklive\"\nkind=\"harness\"\nversion=\"1\"\n\
         min_spt_core_version=\"0\"\n\n\
         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
         [session.psyche_init]\ncommand='{NOOP}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
    );
    let manifest_path = home.path().join("mocklive.toml");
    std::fs::write(&manifest_path, &manifest_toml).unwrap();
    let src = perch::spt_home().join("srcs").join("mocklive");
    std::fs::create_dir_all(&src).unwrap();
    std::fs::write(src.join("manifest.toml"), &manifest_toml).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();

    let pid = std::process::id().to_string();
    let mp = manifest_path.to_string_lossy().to_string();
    let spt = |args: &[&str]| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(60))
    };

    // ── seed (keyed by THIS process's pid — alive, passes the recycle guard) ──
    let out = spt(&[
        "api", "--adapter", "mocklive", "seed", "--pid", &pid, "--session-id", "sid-1",
    ]);
    assert!(out.status.success(), "seed: {}", String::from_utf8_lossy(&out.stderr));

    // ── the REAL live listen --once: binds the Self perch + marks it online
    // (the W0.2 establish-marks-online), drains, exits. The Psyche does NOT spawn
    // in this process anymore.
    let out = spt(&[
        "api", "--adapter", "mocklive", "--manifest", &mp, "listen", "agent7",
        "--parent-pid", &pid, "--once",
    ]);
    assert!(out.status.success(), "listen: {}", String::from_utf8_lossy(&out.stderr));

    // The real establish-marks-online: the perch carries status=online, written by
    // cmd_listen (NOT hand-seeded).
    let self_perch = perch::resolve_perch_path("agent7", ParentHint::Infer);
    let status = spt_store::info::read_info(&self_perch).and_then(|i| i.status);
    assert_eq!(
        status.as_deref(),
        Some(STATUS_ONLINE),
        "a live `api listen` marks the perch online (the first-host handoff)"
    );

    // ── the brain reconcile hosts off that REAL online status ──
    let set = Arc::new(LiveSet::new());
    // A LONG pulse: the driver fires once immediately (cold anchor) then parks.
    // This keeps the un-host step below deterministic — the driver's per-tick
    // touch_active (a set_last_active read-modify-write on info.json) is NOT racing
    // the offline flip; at the production 5s pulse the same window is negligible,
    // but a fast test pulse would let touch_active clobber the offline status back.
    let cfg = DaemonConfig {
        pulse_period: Duration::from_secs(10),
        ..DaemonConfig::default()
    };
    let registered = spt_runtime::registry::registered(&perch::adapters_dir());
    livehost::reconcile_once(
        &perch::owlery_dir(),
        &registered,
        &perch::adapters_dir(),
        &set,
        &cfg,
        StartReason::Cold,
    );
    assert_eq!(
        set.len(),
        1,
        "the brain reconcile hosts the live-listen-onlined endpoint"
    );

    // ── a graceful offline transition un-hosts it (the exit guard, end-to-end) ──
    // Let the driver's immediate first pulse settle (then it parks for 10s) so the
    // offline flip is not clobbered by a concurrent set_last_active.
    std::thread::sleep(Duration::from_millis(300));
    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();
    livehost::reconcile_once(
        &perch::owlery_dir(),
        &registered,
        &perch::adapters_dir(),
        &set,
        &cfg,
        StartReason::Cold,
    );
    assert!(set.is_empty(), "an offline transition un-hosts the lifecycle");

    // Reap any REAL daemon that auto-started against this throwaway home (hygiene).
    if let Ok(p) = std::fs::read_to_string(home.path().join("daemon.pid")) {
        if let Ok(p) = p.trim().parse::<u32>() {
            #[cfg(windows)]
            let _ = Command::new("taskkill")
                .no_window()
                .args(["/PID", &p.to_string(), "/F"])
                .output();
            #[cfg(unix)]
            let _ = Command::new("kill").args(["-9", &p.to_string()]).output();
        }
    }
}

-    // A LONG pulse: the driver fires once immediately (cold anchor) then parks.

-    // This keeps the un-host step below deterministic — the driver's per-tick

-    // touch_active (a set_last_active read-modify-write on info.json) is NOT racing

-    // the offline flip; at the production 5s pulse the same window is negligible,

-    // but a fast test pulse would let touch_active clobber the offline status back.

+    // FAST pulse: the driver hammers set_last_active every tick. The W0.4

+    // mutate_info sentinel lock makes the offline flip below survive that

+    // contention, so no long-pulse+settle workaround is needed (the lock, not

+    // timing, makes the un-host deterministic).

-        pulse_period: Duration::from_secs(10),

+        pulse_period: Duration::from_millis(15),


Edit

    // ── a graceful offline transition un-hosts it (the exit guard, end-to-end) ──
    // Let the driver's immediate first pulse settle (then it parks for 10s) so the
    // offline flip is not clobbered by a concurrent set_last_active.
    std::thread::sleep(Duration::from_millis(300));
    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();

    // ── a graceful offline transition un-hosts it (the exit guard, end-to-end) ──
    // No settle: the W0.4 sentinel lock makes the offline flip survive the driver's
    // concurrent per-tick set_last_active (un-host fires under contention).
    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();


    // ── a graceful offline transition un-hosts it (the exit guard, end-to-end) ──
    // No settle: the W0.4 sentinel lock makes the offline flip survive the driver's
    // concurrent per-tick set_last_active (un-host fires under contention).
    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();

//! M11-W0.2 first-host int (REQ-DAEMON-1) — the REAL establish-marks-online path,
//! end-to-end through the actual `spt` binary (no synthetic-online seed like the
//! W0.1 units):
//!
//! a live-capable `spt api listen --once` BINDS the Self perch and marks it
//! `status=online` (the W0.2 first-host handoff — the Psyche no longer spawns in
//! this process) → the brain's `livehost::reconcile_once` then HOSTS the Psyche
//! lifecycle off that real online status → a graceful offline transition un-hosts
//! it. This proves the cutover wiring: the spt process marks online, the brain
//! reconcile reacts, exactly the shellwake CLI-flips / brain-reconciles pattern.
//
// [int->REQ-DAEMON-1]

use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::Arc;
use std::time::Duration;

mod common;
use common::CommandNoWindowExt;

use spt_daemon::brainproc::StartReason;
use spt_daemon::config::DaemonConfig;
use spt_daemon::livehost::{self, LiveSet};
use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
use spt_store::perch::{self, ParentHint};

// A no-op Psyche summarizer: spawns + exits 0 (the daemon online-stamp hosts it,
// not the pid).
#[cfg(windows)]
const NOOP: &str = "cmd /C rem";
#[cfg(unix)]
const NOOP: &str = "true";

/// In-process seed daemon so `api seed`/`api listen` succeed without a REAL
/// detached `spt daemon` (shell_e2e's pattern).
fn start_inproc_daemon() {
    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
    let name = spt_daemon::seed_socket_name();
    std::thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
    });
    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }
        std::thread::sleep(Duration::from_millis(5));
    }
    panic!("in-process seed daemon did not come up");
}

/// `Command::output()` with a deadline, run off-thread (bounds a hang into a
/// clean failure).
fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect("captured spt call must complete")
        .expect("run spt")
}

#[test]
fn live_listen_marks_online_and_brain_reconcile_hosts() {
    // One test in this file: a plain set_var is safe; every child inherits the home.
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    start_inproc_daemon();

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));

    // A live-capable adapter (declares [session.psyche_init]) + register it so the
    // brain reconcile can resolve the manifest from disk by the perch's adapter.
    let drops = home.path().join("drops");
    std::fs::create_dir_all(&drops).unwrap();
    let dir = drops.to_string_lossy().replace('\\', "/");
    let manifest_toml = format!(
        "[adapter]\nname=\"mocklive\"\nkind=\"harness\"\nversion=\"1\"\n\
         min_spt_core_version=\"0\"\n\n\
         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
         [session.psyche_init]\ncommand='{NOOP}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
    );
    let manifest_path = home.path().join("mocklive.toml");
    std::fs::write(&manifest_path, &manifest_toml).unwrap();
    let src = perch::spt_home().join("srcs").join("mocklive");
    std::fs::create_dir_all(&src).unwrap();
    std::fs::write(src.join("manifest.toml"), &manifest_toml).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();

    let pid = std::process::id().to_string();
    let mp = manifest_path.to_string_lossy().to_string();
    let spt = |args: &[&str]| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(60))
    };

    // ── seed (keyed by THIS process's pid — alive, passes the recycle guard) ──
    let out = spt(&[
        "api", "--adapter", "mocklive", "seed", "--pid", &pid, "--session-id", "sid-1",
    ]);
    assert!(out.status.success(), "seed: {}", String::from_utf8_lossy(&out.stderr));

    // ── the REAL live listen --once: binds the Self perch + marks it online
    // (the W0.2 establish-marks-online), drains, exits. The Psyche does NOT spawn
    // in this process anymore.
    let out = spt(&[
        "api", "--adapter", "mocklive", "--manifest", &mp, "listen", "agent7",
        "--parent-pid", &pid, "--once",
    ]);
    assert!(out.status.success(), "listen: {}", String::from_utf8_lossy(&out.stderr));

    // The real establish-marks-online: the perch carries status=online, written by
    // cmd_listen (NOT hand-seeded).
    let self_perch = perch::resolve_perch_path("agent7", ParentHint::Infer);
    let status = spt_store::info::read_info(&self_perch).and_then(|i| i.status);
    assert_eq!(
        status.as_deref(),
        Some(STATUS_ONLINE),
        "a live `api listen` marks the perch online (the first-host handoff)"
    );

    // ── the brain reconcile hosts off that REAL online status ──
    let set = Arc::new(LiveSet::new());
    // FAST pulse: the driver hammers set_last_active every tick. The W0.4
    // mutate_info sentinel lock makes the offline flip below survive that
    // contention, so no long-pulse+settle workaround is needed (the lock, not
    // timing, makes the un-host deterministic).
    let cfg = DaemonConfig {
        pulse_period: Duration::from_millis(15),
        ..DaemonConfig::default()
    };
    let registered = spt_runtime::registry::registered(&perch::adapters_dir());
    livehost::reconcile_once(
        &perch::owlery_dir(),
        &registered,
        &perch::adapters_dir(),
        &set,
        &cfg,
        StartReason::Cold,
    );
    assert_eq!(
        set.len(),
        1,
        "the brain reconcile hosts the live-listen-onlined endpoint"
    );

    // ── a graceful offline transition un-hosts it (the exit guard, end-to-end) ──
    // Let the driver's immediate first pulse settle (then it parks for 10s) so the
    // offline flip is not clobbered by a concurrent set_last_active.
    std::thread::sleep(Duration::from_millis(300));
    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();
    livehost::reconcile_once(
        &perch::owlery_dir(),
        &registered,
        &perch::adapters_dir(),
        &set,
        &cfg,
        StartReason::Cold,
    );
    assert!(set.is_empty(), "an offline transition un-hosts the lifecycle");

    // Reap any REAL daemon that auto-started against this throwaway home (hygiene).
    if let Ok(p) = std::fs::read_to_string(home.path().join("daemon.pid")) {
        if let Ok(p) = p.trim().parse::<u32>() {
            #[cfg(windows)]
            let _ = Command::new("taskkill")
                .no_window()
                .args(["/PID", &p.to_string(), "/F"])
                .output();
            #[cfg(unix)]
            let _ = Command::new("kill").args(["-9", &p.to_string()]).output();
        }
    }
}

-    // Let the driver's immediate first pulse settle (then it parks for 10s) so the

-    // offline flip is not clobbered by a concurrent set_last_active.

-    std::thread::sleep(Duration::from_millis(300));

+    // No settle: the W0.4 sentinel lock makes the offline flip survive the driver's

+    // concurrent per-tick set_last_active (un-host fires under contention).


Strip the workaround from `live_bind_firsthost_e2e` (same pattern).


    let set = Arc::new(LiveSet::new());
    // A LONG pulse so the driver parks (deterministic un-host below, pending W0.4 (iii)
    // lost-update fix).
    let cfg = DaemonConfig {
        pulse_period: Duration::from_secs(10),
        ..DaemonConfig::default()
    };

    let set = Arc::new(LiveSet::new());
    // FAST pulse: the driver hammers set_last_active every tick; the W0.4
    // mutate_info sentinel lock makes the offline flip below survive that
    // contention (no long-pulse+settle workaround needed).
    let cfg = DaemonConfig {
        pulse_period: Duration::from_millis(15),
        ..DaemonConfig::default()
    };


<tool_use_error>String to replace not found in file.
String:     let set = Arc::new(LiveSet::new());
    // A LONG pulse so the driver parks (deterministic un-host below, pending W0.4 (iii)
    // lost-update fix).
    let cfg = DaemonConfig {
        pulse_period: Duration::from_secs(10),
        ..DaemonConfig::default()
    };</tool_use_error>

Error: String to replace not found in file.
String:     let set = Arc::new(LiveSet::new());
    // A LONG pulse so the driver parks (deterministic un-host below, pending W0.4 (iii)
    // lost-update fix).
    let cfg = DaemonConfig {
        pulse_period: Duration::from_secs(10),
        ..DaemonConfig::default()
    };


    // ── teardown via the public un-host path (offline transition). The long pulse
    // + this settle keep it deterministic until W0.4 (iii) fixes the
    // set_last_active/mark_offline lost-update.
    std::thread::sleep(Duration::from_millis(300));
    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();

    // ── teardown via the public un-host path (offline transition). No settle: the
    // W0.4 sentinel lock makes the offline flip survive the driver's concurrent
    // per-tick set_last_active.
    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();


The file crates\spt\tests\live_bind_firsthost_e2e.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    // ── teardown via the public un-host path (offline transition). No settle: the
    // W0.4 sentinel lock makes the offline flip survive the driver's concurrent
    // per-tick set_last_active.
    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();

//! M11-W0.3 first-host int (REQ-DAEMON-1) — the spt-hosted (bind, no listen)
//! mirror of `live_firsthost_e2e`, closing the bind-no-Psyche gap:
//!
//! a live-capable `spt api bind <id> --type live_agent` BINDS the Self perch
//! (direct-PTY topology, no relay — CONTEXT:39) and marks it `status=online` (the
//! W0.3 establish-marks-online, mirror of W0.2's listen mark) → the brain's
//! `livehost::reconcile_once` then HOSTS the Psyche off that real online status.
//! Before W0.3 a bind-only spt-hosted live agent never got a Psyche (the Psyche
//! spawn was cmd_listen-only); this proves it now does — through the REAL bind,
//! not a hand-seeded status.
//
// [int->REQ-DAEMON-1]

use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::Arc;
use std::time::Duration;

mod common;
use common::CommandNoWindowExt;

use spt_daemon::brainproc::StartReason;
use spt_daemon::config::DaemonConfig;
use spt_daemon::livehost::{self, LiveSet};
use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
use spt_store::perch::{self, ParentHint};

#[cfg(windows)]
const NOOP: &str = "cmd /C rem";
#[cfg(unix)]
const NOOP: &str = "true";

fn start_inproc_daemon() {
    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
    let name = spt_daemon::seed_socket_name();
    std::thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
    });
    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }
        std::thread::sleep(Duration::from_millis(5));
    }
    panic!("in-process seed daemon did not come up");
}

fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect("captured spt call must complete")
        .expect("run spt")
}

#[test]
fn live_bind_marks_online_and_brain_reconcile_hosts() {
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    start_inproc_daemon();

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));

    // A live-capable adapter (declares [session.psyche_init]) + register it so the
    // brain reconcile resolves the manifest from disk by the perch's adapter.
    let drops = home.path().join("drops");
    std::fs::create_dir_all(&drops).unwrap();
    let dir = drops.to_string_lossy().replace('\\', "/");
    let manifest_toml = format!(
        "[adapter]\nname=\"mocklive\"\nkind=\"harness\"\nversion=\"1\"\n\
         min_spt_core_version=\"0\"\n\n\
         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
         [session.psyche_init]\ncommand='{NOOP}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
    );
    let manifest_path = home.path().join("mocklive.toml");
    std::fs::write(&manifest_path, &manifest_toml).unwrap();
    let src = perch::spt_home().join("srcs").join("mocklive");
    std::fs::create_dir_all(&src).unwrap();
    std::fs::write(src.join("manifest.toml"), &manifest_toml).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();

    let mp = manifest_path.to_string_lossy().to_string();
    let spt = |args: &[&str]| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(60))
    };

    // ── the REAL spt-hosted bind (no seed, no listen): establishes the Self perch
    // and marks it online (the W0.3 first-host handoff). The Psyche does NOT spawn
    // in this process — bind only writes the online signal.
    let out = spt(&[
        "api", "--adapter", "mocklive", "--manifest", &mp, "bind", "agent8",
        "--type", "live_agent", "--set-session-id", "sid-1",
    ]);
    assert!(out.status.success(), "bind: {}", String::from_utf8_lossy(&out.stderr));

    // The real establish-marks-online: written by cmd_bind, NOT hand-seeded.
    let self_perch = perch::resolve_perch_path("agent8", ParentHint::Infer);
    assert_eq!(
        spt_store::info::read_info(&self_perch)
            .and_then(|i| i.status)
            .as_deref(),
        Some(STATUS_ONLINE),
        "a live-capable `api bind` marks the perch online (the spt-hosted first-host handoff)"
    );

    // ── THE GAP CLOSES: the brain reconcile hosts the Psyche for the bind-only
    // spt-hosted live agent — it never did before W0.3 (spawn_psyche was
    // cmd_listen-only). A LONG pulse so the driver parks (deterministic un-host
    // below, pending W0.4 (iii) lost-update fix).
    let set = Arc::new(LiveSet::new());
    let cfg = DaemonConfig {
        pulse_period: Duration::from_secs(10),
        ..DaemonConfig::default()
    };
    let registered = spt_runtime::registry::registered(&perch::adapters_dir());
    livehost::reconcile_once(
        &perch::owlery_dir(),
        &registered,
        &perch::adapters_dir(),
        &set,
        &cfg,
        StartReason::Cold,
    );
    assert_eq!(
        set.len(),
        1,
        "the brain hosts the Psyche for the bind-established live agent (bind-no-Psyche gap closed)"
    );
    // The Psyche nested perch was created by the brain host (daemon online-stamp).
    let psyche_perch = perch::resolve_perch_path("agent8-psyche", ParentHint::Explicit("agent8"));
    assert!(
        spt_store::info::read_info(&psyche_perch).is_some(),
        "the brain spawned the Psyche nested perch"
    );

    // ── teardown via the public un-host path (offline transition). The long pulse
    // + this settle keep it deterministic until W0.4 (iii) fixes the
    // set_last_active/mark_offline lost-update.
    std::thread::sleep(Duration::from_millis(300));
    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();
    livehost::reconcile_once(
        &perch::owlery_dir(),
        &registered,
        &perch::adapters_dir(),
        &set,
        &cfg,
        StartReason::Cold,
    );
    assert!(set.is_empty(), "an offline transition un-hosts the lifecycle");

    // Reap any REAL daemon that auto-started against this throwaway home (hygiene).
    if let Ok(p) = std::fs::read_to_string(home.path().join("daemon.pid")) {
        if let Ok(p) = p.trim().parse::<u32>() {
            #[cfg(windows)]
            let _ = Command::new("taskkill")
                .no_window()
                .args(["/PID", &p.to_string(), "/F"])
                .output();
            #[cfg(unix)]
            let _ = Command::new("kill").args(["-9", &p.to_string()]).output();
        }
    }
}

-    // ── teardown via the public un-host path (offline transition). The long pulse

-    // + this settle keep it deterministic until W0.4 (iii) fixes the

-    // set_last_active/mark_offline lost-update.

+    // ── teardown via the public un-host path (offline transition). No settle: the

+    // W0.4 sentinel lock makes the offline flip survive the driver's concurrent


The cfg comment differs — read the actual block in live_bind_firsthost_e2e.


C:\Users\decid\Documents\projects\spt-core\crates\spt\tests\live_bind_firsthost_e2e.rs-109-
crates\spt\tests\live_bind_firsthost_e2e.rs-110-    \ ── THE GAP CLOSES: the brain reconcile hosts the Psyche for the bind-only
C:\Users\decid\Documents\projects\spt-core\crates\spt\tests\live_bind_firsthost_e2e.rs-111-    // spt-hosted live agent — it never did before W0.3 (spawn_psyche was
crates\spt\tests\live_bind_firsthost_e2e.rs:112:    // cmd_listen-only). A LONG pulse so the driver parks (deterministic un-host
C:\Users\decid\Documents\projects\spt-core\crates\spt\tests\live_bind_firsthost_e2e.rs-113-    // below, pending W0.4 (iii) lost-update fix).
crates\spt\tests\live_bind_firsthost_e2e.rs-114-    let set = Arc::new(LiveSet::new());
C:\Users\decid\Documents\projects\spt-core\crates\spt\tests\live_bind_firsthost_e2e.rs-115-    let cfg = DaemonConfig {
crates\spt\tests\live_bind_firsthost_e2e.rs:116:        pulse_period: Duration::from_secs(10),


    // ── THE GAP CLOSES: the brain reconcile hosts the Psyche for the bind-only
    // spt-hosted live agent — it never did before W0.3 (spawn_psyche was
    // cmd_listen-only). A LONG pulse so the driver parks (deterministic un-host
    // below, pending W0.4 (iii) lost-update fix).
    let set = Arc::new(LiveSet::new());
    let cfg = DaemonConfig {
        pulse_period: Duration::from_secs(10),
        ..DaemonConfig::default()
    };

    // ── THE GAP CLOSES: the brain reconcile hosts the Psyche for the bind-only
    // spt-hosted live agent — it never did before W0.3 (spawn_psyche was
    // cmd_listen-only). FAST pulse: the driver hammers set_last_active every tick;
    // the W0.4 mutate_info sentinel lock makes the offline flip below survive that
    // contention (no long-pulse+settle workaround needed).
    let set = Arc::new(LiveSet::new());
    let cfg = DaemonConfig {
        pulse_period: Duration::from_millis(15),
        ..DaemonConfig::default()
    };


    // ── THE GAP CLOSES: the brain reconcile hosts the Psyche for the bind-only
    // spt-hosted live agent — it never did before W0.3 (spawn_psyche was
    // cmd_listen-only). FAST pulse: the driver hammers set_last_active every tick;
    // the W0.4 mutate_info sentinel lock makes the offline flip below survive that
    // contention (no long-pulse+settle workaround needed).
    let set = Arc::new(LiveSet::new());
    let cfg = DaemonConfig {
        pulse_period: Duration::from_millis(15),
        ..DaemonConfig::default()
    };

//! M11-W0.3 first-host int (REQ-DAEMON-1) — the spt-hosted (bind, no listen)
//! mirror of `live_firsthost_e2e`, closing the bind-no-Psyche gap:
//!
//! a live-capable `spt api bind <id> --type live_agent` BINDS the Self perch
//! (direct-PTY topology, no relay — CONTEXT:39) and marks it `status=online` (the
//! W0.3 establish-marks-online, mirror of W0.2's listen mark) → the brain's
//! `livehost::reconcile_once` then HOSTS the Psyche off that real online status.
//! Before W0.3 a bind-only spt-hosted live agent never got a Psyche (the Psyche
//! spawn was cmd_listen-only); this proves it now does — through the REAL bind,
//! not a hand-seeded status.
//
// [int->REQ-DAEMON-1]

use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::Arc;
use std::time::Duration;

mod common;
use common::CommandNoWindowExt;

use spt_daemon::brainproc::StartReason;
use spt_daemon::config::DaemonConfig;
use spt_daemon::livehost::{self, LiveSet};
use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
use spt_store::perch::{self, ParentHint};

#[cfg(windows)]
const NOOP: &str = "cmd /C rem";
#[cfg(unix)]
const NOOP: &str = "true";

fn start_inproc_daemon() {
    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
    let name = spt_daemon::seed_socket_name();
    std::thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
    });
    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }
        std::thread::sleep(Duration::from_millis(5));
    }
    panic!("in-process seed daemon did not come up");
}

fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect("captured spt call must complete")
        .expect("run spt")
}

#[test]
fn live_bind_marks_online_and_brain_reconcile_hosts() {
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    start_inproc_daemon();

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));

    // A live-capable adapter (declares [session.psyche_init]) + register it so the
    // brain reconcile resolves the manifest from disk by the perch's adapter.
    let drops = home.path().join("drops");
    std::fs::create_dir_all(&drops).unwrap();
    let dir = drops.to_string_lossy().replace('\\', "/");
    let manifest_toml = format!(
        "[adapter]\nname=\"mocklive\"\nkind=\"harness\"\nversion=\"1\"\n\
         min_spt_core_version=\"0\"\n\n\
         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
         [session.psyche_init]\ncommand='{NOOP}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
    );
    let manifest_path = home.path().join("mocklive.toml");
    std::fs::write(&manifest_path, &manifest_toml).unwrap();
    let src = perch::spt_home().join("srcs").join("mocklive");
    std::fs::create_dir_all(&src).unwrap();
    std::fs::write(src.join("manifest.toml"), &manifest_toml).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();

    let mp = manifest_path.to_string_lossy().to_string();
    let spt = |args: &[&str]| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(60))
    };

    // ── the REAL spt-hosted bind (no seed, no listen): establishes the Self perch
    // and marks it online (the W0.3 first-host handoff). The Psyche does NOT spawn
    // in this process — bind only writes the online signal.
    let out = spt(&[
        "api", "--adapter", "mocklive", "--manifest", &mp, "bind", "agent8",
        "--type", "live_agent", "--set-session-id", "sid-1",
    ]);
    assert!(out.status.success(), "bind: {}", String::from_utf8_lossy(&out.stderr));

    // The real establish-marks-online: written by cmd_bind, NOT hand-seeded.
    let self_perch = perch::resolve_perch_path("agent8", ParentHint::Infer);
    assert_eq!(
        spt_store::info::read_info(&self_perch)
            .and_then(|i| i.status)
            .as_deref(),
        Some(STATUS_ONLINE),
        "a live-capable `api bind` marks the perch online (the spt-hosted first-host handoff)"
    );

    // ── THE GAP CLOSES: the brain reconcile hosts the Psyche for the bind-only
    // spt-hosted live agent — it never did before W0.3 (spawn_psyche was
    // cmd_listen-only). A LONG pulse so the driver parks (deterministic un-host
    // below, pending W0.4 (iii) lost-update fix).
    let set = Arc::new(LiveSet::new());
    let cfg = DaemonConfig {
        pulse_period: Duration::from_secs(10),
        ..DaemonConfig::default()
    };
    let registered = spt_runtime::registry::registered(&perch::adapters_dir());
    livehost::reconcile_once(
        &perch::owlery_dir(),
        &registered,
        &perch::adapters_dir(),
        &set,
        &cfg,
        StartReason::Cold,
    );
    assert_eq!(
        set.len(),
        1,
        "the brain hosts the Psyche for the bind-established live agent (bind-no-Psyche gap closed)"
    );
    // The Psyche nested perch was created by the brain host (daemon online-stamp).
    let psyche_perch = perch::resolve_perch_path("agent8-psyche", ParentHint::Explicit("agent8"));
    assert!(
        spt_store::info::read_info(&psyche_perch).is_some(),
        "the brain spawned the Psyche nested perch"
    );

    // ── teardown via the public un-host path (offline transition). No settle: the
    // W0.4 sentinel lock makes the offline flip survive the driver's concurrent
    // per-tick set_last_active.
    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();
    livehost::reconcile_once(
        &perch::owlery_dir(),
        &registered,
        &perch::adapters_dir(),
        &set,
        &cfg,
        StartReason::Cold,
    );
    assert!(set.is_empty(), "an offline transition un-hosts the lifecycle");

    // Reap any REAL daemon that auto-started against this throwaway home (hygiene).
    if let Ok(p) = std::fs::read_to_string(home.path().join("daemon.pid")) {
        if let Ok(p) = p.trim().parse::<u32>() {
            #[cfg(windows)]
            let _ = Command::new("taskkill")
                .no_window()
                .args(["/PID", &p.to_string(), "/F"])
                .output();
            #[cfg(unix)]
            let _ = Command::new("kill").args(["-9", &p.to_string()]).output();
        }
    }
}

-    // cmd_listen-only). A LONG pulse so the driver parks (deterministic un-host

-    // below, pending W0.4 (iii) lost-update fix).

+    // cmd_listen-only). FAST pulse: the driver hammers set_last_active every tick;

+    // the W0.4 mutate_info sentinel lock makes the offline flip below survive that

+    // contention (no long-pulse+settle workaround needed).

-        pulse_period: Duration::from_secs(10),

+        pulse_period: Duration::from_millis(15),


(iii) done — `mutate_info` + 6 wrappers + hammer + workarounds stripped. Now **(ii) read-error un-host** — read the reconcile stop-side to fix the discriminator.


150	            .map(|i| i.status.as_deref() == Some(STATUS_ONLINE))
151	            .unwrap_or(false);
152	        if !still_online {
153	            set.stop_host(&id);
154	        }
155	    }
156
157	    // Start side: every online live endpoint not yet hosted.
158	    for id in perch::list_self_perch_ids(owlery) {
159	        if set.contains(&id) {
160	            continue; // already hosted (leave-alone)
161	        }
162	        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
163	        let Some(info) = spt_store::info::read_info(&perch) else {
164	            continue;
165	        };
166	        if info.state != LIVE_AGENT_STATE {
167	            continue; // not a live agent (ready_agent etc.)
168	        }
169	        if info.status.as_deref() != Some(STATUS_ONLINE) {
170	            continue; // interim (status-absent) or signed-off (offline)
171	        }
172	        let Some(adapter) = info.adapter.as_deref() else {
173	            continue; // adapterless endpoint — never live-capable
174	        };
175	        let Ok(manifest) = spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter)
176	        else {
177	            continue; // deregistered adapter: nothing to host
178	        };
179	        let Some(lifecycle) = BrainLifecycle::with_config(&manifest, &id, cfg.clone()) else {

            .map(|i| i.status.as_deref() == Some(STATUS_ONLINE))
            .unwrap_or(false);
        if !still_online {
            set.stop_host(&id);
        }
    }

    // Start side: every online live endpoint not yet hosted.
    for id in perch::list_self_perch_ids(owlery) {
        if set.contains(&id) {
            continue; // already hosted (leave-alone)
        }
        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
        let Some(info) = spt_store::info::read_info(&perch) else {
            continue;
        };
        if info.state != LIVE_AGENT_STATE {
            continue; // not a live agent (ready_agent etc.)
        }
        if info.status.as_deref() != Some(STATUS_ONLINE) {
            continue; // interim (status-absent) or signed-off (offline)
        }
        let Some(adapter) = info.adapter.as_deref() else {
            continue; // adapterless endpoint — never live-capable
        };
        let Ok(manifest) = spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter)
        else {
            continue; // deregistered adapter: nothing to host
        };
        let Some(lifecycle) = BrainLifecycle::with_config(&manifest, &id, cfg.clone()) else {


143	    reason: StartReason,
144	) {
145	    // Stop side first: hosted endpoints that left the online set (signed off /
146	    // perch gone) — trip the flag, join, de-table.
147	    for id in set.ids() {
148	        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
149	        let still_online = spt_store::info::read_info(&perch)
150	            .map(|i| i.status.as_deref() == Some(STATUS_ONLINE))
151	            .unwrap_or(false);
152	        if !still_online {
153	            set.stop_host(&id);
154	        }
155	    }

    reason: StartReason,
) {
    // Stop side first: hosted endpoints that left the online set (signed off /
    // perch gone) — trip the flag, join, de-table.
    for id in set.ids() {
        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
        let still_online = spt_store::info::read_info(&perch)
            .map(|i| i.status.as_deref() == Some(STATUS_ONLINE))
            .unwrap_or(false);
        if !still_online {
            set.stop_host(&id);
        }
    }


    // Stop side first: hosted endpoints that left the online set (signed off /
    // perch gone) — trip the flag, join, de-table.
    for id in set.ids() {
        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
        let still_online = spt_store::info::read_info(&perch)
            .map(|i| i.status.as_deref() == Some(STATUS_ONLINE))
            .unwrap_or(false);
        if !still_online {
            set.stop_host(&id);
        }
    }

    // Stop side first: hosted endpoints that DEFINITIVELY left the online set —
    // trip the flag, join, de-table. Un-host on a definitive departure ONLY, never
    // on a TRANSIENT read failure (M11-W0.4 (ii)): a momentarily-unreadable
    // info.json must not stop+respawn the driver (Psyche churn on a disk blip). The
    // discriminator is the perch DIR:
    //   - dir gone        → torn down (teardown removed the perch) → un-host;
    //   - dir present, read_info Some(status≠online) → signed-off/offline → un-host;
    //   - dir present, read_info None (transient/torn read) → LEAVE the driver, retry.
    // [impl->REQ-DAEMON-1]
    for id in set.ids() {
        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
        let unhost = if !perch.exists() {
            true // the perch dir is gone — torn down
        } else {
            match spt_store::info::read_info(&perch) {
                Some(info) => info.status.as_deref() != Some(STATUS_ONLINE),
                None => false, // dir present but unreadable: transient → keep hosting
            }
        };
        if unhost {
            set.stop_host(&id);
        }
    }


The file crates\spt-daemon\src\livehost.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    // Stop side first: hosted endpoints that left the online set (signed off /
    // perch gone) — trip the flag, join, de-table.
    for id in set.ids() {
        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
        let still_online = spt_store::info::read_info(&perch)
            .map(|i| i.status.as_deref() == Some(STATUS_ONLINE))
            .unwrap_or(false);
        if !still_online {
            set.stop_host(&id);
        }
    }

    // Stop side first: hosted endpoints that DEFINITIVELY left the online set —
    // trip the flag, join, de-table. Un-host on a definitive departure ONLY, never
    // on a TRANSIENT read failure (M11-W0.4 (ii)): a momentarily-unreadable
    // info.json must not stop+respawn the driver (Psyche churn on a disk blip). The
    // discriminator is the perch DIR:
    //   - dir gone        → torn down (teardown removed the perch) → un-host;
    //   - dir present, read_info Some(status≠online) → signed-off/offline → un-host;
    //   - dir present, read_info None (transient/torn read) → LEAVE the driver, retry.
    // [impl->REQ-DAEMON-1]
    for id in set.ids() {
        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
        let unhost = if !perch.exists() {
            true // the perch dir is gone — torn down
        } else {
            match spt_store::info::read_info(&perch) {
                Some(info) => info.status.as_deref() != Some(STATUS_ONLINE),
                None => false, // dir present but unreadable: transient → keep hosting
            }
        };
        if unhost {
            set.stop_host(&id);
        }
    }

-    // Stop side first: hosted endpoints that left the online set (signed off /

-    // perch gone) — trip the flag, join, de-table.

+    // Stop side first: hosted endpoints that DEFINITIVELY left the online set —

+    // trip the flag, join, de-table. Un-host on a definitive departure ONLY, never

+    // on a TRANSIENT read failure (M11-W0.4 (ii)): a momentarily-unreadable

+    // info.json must not stop+respawn the driver (Psyche churn on a disk blip). The

+    //   - dir gone        → torn down (teardown removed the perch) → un-host;

+    //   - dir present, read_info Some(status≠online) → signed-off/offline → un-host;

+    //   - dir present, read_info None (transient/torn read) → LEAVE the driver, retry.

-        let still_online = spt_store::info::read_info(&perch)

-            .map(|i| i.status.as_deref() == Some(STATUS_ONLINE))

+        let unhost = if !perch.exists() {

+            true // the perch dir is gone — torn down

+            match spt_store::info::read_info(&perch) {

+                Some(info) => info.status.as_deref() != Some(STATUS_ONLINE),

+                None => false, // dir present but unreadable: transient → keep hosting


(ii) fixed. Add livehost unit (f) — read-error leaves the driver, dir-gone un-hosts. Find the (e) test end.


Bash

grep -n "reconcile_unhosts_on_offline_transition\|^    }\|^}" crates/spt-daemon/src/livehost.rs | tail -8

Find livehost test module end


354:    }
377:    }
406:    }
450:    }
454:    }
460:    fn reconcile_unhosts_on_offline_transition() {
497:    }
498:}


485	            assert_eq!(set.len(), 1, "hosted while online");
486
487	            // Graceful signoff flips the Self perch offline WHILE the driver pulses.
488	            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
489	            spt_store::info::set_status(&self_perch, spt_store::liveness::STATUS_OFFLINE).unwrap();
490
491	            run(StartReason::Cold);
492	            assert!(
493	                set.is_empty(),
494	                "an offline-transitioned endpoint is un-hosted (driver stopped + de-tabled)"
495	            );
496	        });
497	    }
498	}

            assert_eq!(set.len(), 1, "hosted while online");

            // Graceful signoff flips the Self perch offline WHILE the driver pulses.
            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
            spt_store::info::set_status(&self_perch, spt_store::liveness::STATUS_OFFLINE).unwrap();

            run(StartReason::Cold);
            assert!(
                set.is_empty(),
                "an offline-transitioned endpoint is un-hosted (driver stopped + de-tabled)"
            );
        });
    }
}


Edit

            run(StartReason::Cold);
            assert!(
                set.is_empty(),
                "an offline-transitioned endpoint is un-hosted (driver stopped + de-tabled)"
            );
        });
    }
}

            run(StartReason::Cold);
            assert!(
                set.is_empty(),
                "an offline-transitioned endpoint is un-hosted (driver stopped + de-tabled)"
            );
        });
    }

    // [unit->REQ-DAEMON-1] (f) READ-ERROR discrimination (M11-W0.4 (ii)): a hosted
    // endpoint whose info.json becomes momentarily UNREADABLE while its perch DIR
    // remains is NOT un-hosted (transient → keep the driver, no Psyche stop/respawn
    // churn on a disk blip); only a GONE perch dir (torn down) un-hosts.
    #[test]
    fn reconcile_keeps_driver_on_transient_read_fail_unhosts_on_dir_gone() {
        with_home(|_| {
            let drops = perch::spt_home().join("drops");
            std::fs::create_dir_all(&drops).unwrap();
            register_live_adapter("mock", &drops);
            seed_live_perch("doyle", "mock", STATUS_ONLINE);

            let set = Arc::new(LiveSet::new());
            let cfg = fast_cfg();
            let run = || {
                reconcile_once(
                    &perch::owlery_dir(),
                    &registered_now(),
                    &perch::adapters_dir(),
                    &set,
                    &cfg,
                    StartReason::Cold,
                )
            };
            run();
            assert_eq!(set.len(), 1, "hosted while online");

            // TRANSIENT read failure: info.json gone, but the perch DIR remains.
            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
            std::fs::remove_file(self_perch.join("info.json")).unwrap();
            run();
            assert_eq!(
                set.len(),
                1,
                "a transient read failure (dir present) must NOT un-host — driver kept"
            );

            // DEFINITIVE: the perch dir is gone (torn down) → un-host.
            std::fs::remove_dir_all(&self_perch).unwrap();
            run();
            assert!(set.is_empty(), "a gone perch dir un-hosts the driver");
        });
    }
}


            run(StartReason::Cold);
            assert!(
                set.is_empty(),
                "an offline-transitioned endpoint is un-hosted (driver stopped + de-tabled)"
            );
        });
    }
}

            run(StartReason::Cold);
            assert!(
                set.is_empty(),
                "an offline-transitioned endpoint is un-hosted (driver stopped + de-tabled)"
            );
        });
    }

    // [unit->REQ-DAEMON-1] (f) READ-ERROR discrimination (M11-W0.4 (ii)): a hosted
    // endpoint whose info.json becomes momentarily UNREADABLE while its perch DIR
    // remains is NOT un-hosted (transient → keep the driver, no Psyche stop/respawn
    // churn on a disk blip); only a GONE perch dir (torn down) un-hosts.
    #[test]
    fn reconcile_keeps_driver_on_transient_read_fail_unhosts_on_dir_gone() {
        with_home(|_| {
            let drops = perch::spt_home().join("drops");
            std::fs::create_dir_all(&drops).unwrap();
            register_live_adapter("mock", &drops);
            seed_live_perch("doyle", "mock", STATUS_ONLINE);

            let set = Arc::new(LiveSet::new());
            let cfg = fast_cfg();
            let run = || {
                reconcile_once(
                    &perch::owlery_dir(),
                    &registered_now(),
                    &perch::adapters_dir(),
                    &set,
                    &cfg,
                    StartReason::Cold,
                )
            };
            run();
            assert_eq!(set.len(), 1, "hosted while online");

            // TRANSIENT read failure: info.json gone, but the perch DIR remains.
            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
            std::fs::remove_file(self_perch.join("info.json")).unwrap();
            run();
            assert_eq!(
                set.len(),
                1,
                "a transient read failure (dir present) must NOT un-host — driver kept"
            );

            // DEFINITIVE: the perch dir is gone (torn down) → un-host.
            std::fs::remove_dir_all(&self_perch).unwrap();
            run();
            assert!(set.is_empty(), "a gone perch dir un-hosts the driver");
        });
    }
}

+    // [unit->REQ-DAEMON-1] (f) READ-ERROR discrimination (M11-W0.4 (ii)): a hosted

+    // endpoint whose info.json becomes momentarily UNREADABLE while its perch DIR

+    // remains is NOT un-hosted (transient → keep the driver, no Psyche stop/respawn

+    // churn on a disk blip); only a GONE perch dir (torn down) un-hosts.

+    fn reconcile_keeps_driver_on_transient_read_fail_unhosts_on_dir_gone() {

+            let drops = perch::spt_home().join("drops");

+            std::fs::create_dir_all(&drops).unwrap();

+            register_live_adapter("mock", &drops);

+            seed_live_perch("doyle", "mock", STATUS_ONLINE);

+            let set = Arc::new(LiveSet::new());

+                    &perch::owlery_dir(),

+                    &perch::adapters_dir(),

+            assert_eq!(set.len(), 1, "hosted while online");

+            // TRANSIENT read failure: info.json gone, but the perch DIR remains.

+            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);

+            std::fs::remove_file(self_perch.join("info.json")).unwrap();

+                "a transient read failure (dir present) must NOT un-host — driver kept"

+            // DEFINITIVE: the perch dir is gone (torn down) → un-host.

+            std::fs::remove_dir_all(&self_perch).unwrap();

+            assert!(set.is_empty(), "a gone perch dir un-hosts the driver");


(ii) done with unit (f). Build + run livehost units (a–f, incl the de-workaround'd (e)) + confirm the lock makes them deterministic.


Bash

cargo test -p spt-daemon --lib livehost 2>&1 | tail -14

Run livehost units a-f


---- livehost::tests::reconcile_keeps_driver_on_transient_read_fail_unhosts_on_dir_gone stdout ----
LIVEHOST_PSYCHE:doyle pid=209276

thread 'livehost::tests::reconcile_keeps_driver_on_transient_read_fail_unhosts_on_dir_gone' (153608) panicked at crates\spt-daemon\src\livehost.rs:537:50:
called `Result::unwrap()` on an `Err` value: Os { code: 32, kind: Uncategorized, message: "The process cannot access the file because it is being used by another process." }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    livehost::tests::reconcile_keeps_driver_on_transient_read_fail_unhosts_on_dir_gone

test result: FAILED. 5 passed; 1 failed; 0 ignored; 0 measured; 288 filtered out; finished in 0.46s

error: test failed, to rerun pass `-p spt-daemon --lib`


(f) failed on Windows: `remove_dir_all` races the running driver's open file handles (os err 32) — a Windows test artifact (the driver holds perch files between fast pulses). Not a production concern (teardown stops the host first). Retry past the µs handle window.


Edit

            // DEFINITIVE: the perch dir is gone (torn down) → un-host.
            std::fs::remove_dir_all(&self_perch).unwrap();
            run();
            assert!(set.is_empty(), "a gone perch dir un-hosts the driver");

            // DEFINITIVE: the perch dir is gone (torn down) → un-host. The fast-pulse
            // driver holds perch files open between pulses; on Windows that
            // transiently blocks dir removal (os err 32) — retry past the µs window
            // (production teardown stops the host first, so this race is test-only).
            let mut removed = false;
            for _ in 0..100 {
                if std::fs::remove_dir_all(&self_perch).is_ok() {
                    removed = true;
                    break;
                }
                std::thread::sleep(std::time::Duration::from_millis(20));
            }
            assert!(removed, "could not remove the perch dir for the torn-down case");
            run();
            assert!(set.is_empty(), "a gone perch dir un-hosts the driver");


            // DEFINITIVE: the perch dir is gone (torn down) → un-host.
            std::fs::remove_dir_all(&self_perch).unwrap();
            run();
            assert!(set.is_empty(), "a gone perch dir un-hosts the driver");

            // DEFINITIVE: the perch dir is gone (torn down) → un-host. The fast-pulse
            // driver holds perch files open between pulses; on Windows that
            // transiently blocks dir removal (os err 32) — retry past the µs window
            // (production teardown stops the host first, so this race is test-only).
            let mut removed = false;
            for _ in 0..100 {
                if std::fs::remove_dir_all(&self_perch).is_ok() {
                    removed = true;
                    break;
                }
                std::thread::sleep(std::time::Duration::from_millis(20));
            }
            assert!(removed, "could not remove the perch dir for the torn-down case");
            run();
            assert!(set.is_empty(), "a gone perch dir un-hosts the driver");

"a transient read failure (dir present) must NOT un-host — driver kept"

-            // DEFINITIVE: the perch dir is gone (torn down) → un-host.

-            std::fs::remove_dir_all(&self_perch).unwrap();

+            // DEFINITIVE: the perch dir is gone (torn down) → un-host. The fast-pulse

+            // driver holds perch files open between pulses; on Windows that

+            // transiently blocks dir removal (os err 32) — retry past the µs window

+            // (production teardown stops the host first, so this race is test-only).

+                if std::fs::remove_dir_all(&self_perch).is_ok() {

+                std::thread::sleep(std::time::Duration::from_millis(20));

+            assert!(removed, "could not remove the perch dir for the torn-down case");


Bash

cargo test -p spt-daemon --lib livehost 2>&1 | tail -12

Re-run livehost units a-f


     Running unittests src\lib.rs (target\debug\deps\spt_daemon-93396354a8687ac0.exe)

running 6 tests
test livehost::tests::fresh_brain_rehydrates_an_online_live_endpoint ... ok
test livehost::tests::reconcile_does_not_double_host ... ok
test livehost::tests::reconcile_unhosts_on_offline_transition ... ok
test livehost::tests::reconcile_keeps_driver_on_transient_read_fail_unhosts_on_dir_gone ... ok
test livehost::tests::reconcile_skips_an_offline_live_endpoint ... ok
test livehost::tests::reconcile_hosts_an_online_live_endpoint_and_drives_its_pulse ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 288 filtered out; finished in 0.48s


All 6 livehost units green — (iii) + (ii) done. Now **(i)** the real-brain int re-point (heaviest). Scope how to spawn the real `run_brain` in a test — check existing brain-spawn test patterns + run_brain callers.


crates\spt\src\cli.rs:1747:    match spt_daemon::Daemon::run_brain(generation, start_reason) {
crates\spt\tests\live_bind_firsthost_e2e.rs:22:use spt_daemon::brainproc::StartReason;
crates\spt\tests\contract_e2e.rs:397:        spt_daemon::brainproc::StartReason::Cold,
crates\spt\tests\live_firsthost_e2e.rs:22:use spt_daemon::brainproc::StartReason;
crates\spt-daemon\src\applyhost.rs:551:        let signal = Arc::new(crate::brainproc::BrainRestart::new());
crates\spt-daemon\src\brainproc.rs:159:pub fn run_brain(generation: u64, reason: StartReason) -> io::Result<()> {
crates\spt-daemon\src\brainproc.rs:184:    // run_pulse_loop production caller) is hosted by `livehost::spawn_live_host`
crates\spt-daemon\src\brainproc.rs:230:    let _ = crate::livehost::spawn_live_host(Arc::new(AtomicBool::new(false)), reason);
crates\spt-daemon\src\broker.rs:172:/// `brainproc::consumer_gate`/`next_backoff`): advance to `seq + 1` **only** on a
crates\spt-daemon\src\broker.rs:529:    brain_restart: std::sync::OnceLock<Arc<crate::brainproc::BrainRestart>>,
crates\spt-daemon\src\broker.rs:591:    pub fn set_brain_restart(&self, signal: Arc<crate::brainproc::BrainRestart>) -> bool {
crates\spt\tests\brain_survive.rs:69:use spt_daemon::brainproc::{supervise_brain, BrainRestart, NoTrialEnv, StartReason};
crates\spt-daemon\src\daemon.rs:222:        // [`crate::brainproc::run_brain`].
crates\spt-daemon\src\daemon.rs:237:            let (_handle, restart) = crate::brainproc::spawn_brain_supervisor(stop);
crates\spt-daemon\src\daemon.rs:258:    /// is parsed leniently ([`crate::brainproc::StartReason::from_arg`]) so an
crates\spt-daemon\src\daemon.rs:261:    pub fn run_brain(generation: u64, start_reason: &str) -> io::Result<()> {
crates\spt-daemon\src\daemon.rs:262:        crate::brainproc::run_brain(
crates\spt-daemon\src\daemon.rs:264:            crate::brainproc::StartReason::from_arg(start_reason),
crates\spt-daemon\src\deadline.rs:39:use crate::brainproc::StartReason;
crates\spt-store\src\epoch.rs:17://! D6 auto-rollback notif (`brainproc::fire_rollback_notif`, fired only after the
crates\spt-daemon\src\lifecycle.rs:31:use crate::brainproc::StartReason;
crates\spt-daemon\src\livehost.rs:38:use crate::brainproc::StartReason;
crates\spt-daemon\src\livehost.rs:235:pub fn spawn_live_host(stop: Arc<AtomicBool>, reason: StartReason) -> JoinHandle<()> {
crates\spt-daemon\src\rollback_compat.rs:154:            crate::brainproc::parse_ready_generation(&with_extra.to_string()),
crates\spt-daemon\src\rollback_compat.rs:159:            crate::brainproc::parse_ready_exe_hash(&with_extra.to_string()),

[Showing results with pagination = limit: 25]


`brain_survive.rs` (spt/tests) already spawns the **real brain** via `supervise_brain` + tests restart survival — the ideal base for the (i) re-point. Read it to model the real-brain hosting int.


1	//! Restoration D7-1 (ADR-0018 V5) — **process-level survival across a binary
2	//! swap**: a hosted PTY child + a live QUIC connection survive a brain-**PROCESS**
3	//! restart that lands on a **swapped on-disk binary**, with the broker holding
4	//! both endpoints untouched throughout. This is the `int` E2E that re-points the
5	//! regression-masked REQ-DAEMON-2 / REQ-UPD-3 evidence off the in-process
6	//! `brain_swap.rs` shape onto a real process boundary.
7	//!
8	//! ## The proof is a TRIANGLE (doyle D7 vet, call 1) — this is leg (b)
9	//!
10	//! No single test proves the whole claim; three legs do, and this file is honest
11	//! only when read alongside the other two:
12	//!
13	//! - **Leg (a) — the real-broker-process leg: `crates/spt/tests/brain_split.rs`.**
14	//!   The REAL `spt daemon run` broker *process* survives a brain kill and respawns
15	//!   it. That is where the broker's own process-packaging is proven.
16	//! - **Leg (b) — the survival leg: THIS test.** A real brain *child process* +
17	//!   a real on-disk binary swap + real broker-**held** endpoints (a PTY child and
18	//!   a live QUIC conn). The broker is modeled **in-process** — faithful, not a
19	//!   weakening: the broker is the never-restarting anchor *by design*, so an
20	//!   in-process [`Broker`] runs the **same Broker code holding the same
21	//!   resources** the real broker would across a brain swap. The claim under test
22	//!   is the brain process + the swapped binary + broker-held endpoint survival —
23	//!   all real here. (Bonus: the in-process broker gives the test direct access to
24	//!   its session pid / count and QUIC conn count for the assertions.)
25	//! - **Leg (c) — the field leg: D7-4.** The real daemon, a real `apply`, real
26	//!   hardware — the `exe_hash` flips and matches the released artifact.
27	//!
28	//! DEFERRED upgrade: once the live-agent adapter makes daemon-hosted sessions a
29	//! production reality, a full-stack single-process E2E (the real `spt daemon run`
30	//! hosting *and* surviving) becomes reachable — tracked in `docs/DEFERRED.md`,
31	//! NOT D7 work.
32	//!
33	//! ## What makes this STRONGER than `brain_swap.rs`
34	//!
35	//! `brain_swap.rs` (REQ-UPD-3's prior `int`) relaunches the brain via an
36	//! **in-process closure** — same process image, no disk involved. This test runs
37	//! the **real `spt daemon brain` child process** the production
38	//! [`supervise_brain`] spawns, swaps the on-disk binary it respawns from, and
39	//! proves the post-respawn brain is running the **new bytes** via the D7-1
40	//! `exe_hash` breadcrumb (`exe_hash == fixture-B != fixture-A`). It is also the
41	//! **first process-level exercise of the D4 cursor-of-record resume with N≥1
42	//! hosted sessions** (the supervised child re-attaches the test-established PTY
43	//! session on cold start).
44	//!
45	//! The swap is modeled as a **path flip** between two distinct on-disk fixtures
46	//! (B = A + appended padding — PE and ELF both tolerate trailing data, so behavior
47	//! is identical but the hash flips). This is faithful to the production swap (the
48	//! supervisor respawns from whatever the binary *selection* resolves to) and
49	//! sidesteps the Windows running-exe lock (you cannot overwrite a live `.exe`).
50	//!
51	//! **Held AND functional (doyle F1).** Proving the endpoints are *held* (pid /
52	//! session_count / conn_count unchanged) is not proving they are *alive* — a
53	//! zombie conn or wedged PTY would pass. So after the swap the test also drives a
54	//! marker through the held PTY child and watches the broker's output seq advance:
55	//! the full round-trip (input → child → echo → broker ring) works through the
56	//! swapped-in brain.
57	// [int->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
58	// [int->REQ-UPD-3]
59	// [int->REQ-DAEMON-2]
60
61	use std::path::PathBuf;
62	use std::process::Command;
63	use std::sync::atomic::{AtomicBool, Ordering};
64	use std::sync::{Arc, Mutex};
65	use std::thread;
66	use std::time::{Duration, Instant};
67
68	use spt_daemon::brain::Brain;
69	use spt_daemon::brainproc::{supervise_brain, BrainRestart, NoTrialEnv, StartReason};
70	use spt_daemon::codec::write_frame;
71	use spt_daemon::endpoint::broker_socket_name;
72	use spt_daemon::frame::{Envelope, Role};
73	use spt_daemon::msg::{encode_bytes, InputReq, SpawnReq, KIND_INPUT};
74	use spt_daemon::nethost::{NetConfig, NetHost};
75	use spt_daemon::transport::{send_hello, LocalSocketTransport};
76	use spt_daemon::Broker;
77	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
78	use spt_proto::identity::Identity;
79
80	mod common;
81	use common::CommandNoWindowExt;
82
83	/// A long-lived echo child to host under a broker PTY (the endpoint REQ-UPD-3
84	/// forbids us from disturbing across the swap). `cat`/`findstr` idle on stdin.
85	fn echo_spawn_req() -> SpawnReq {
86	    #[cfg(unix)]
87	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
88	    #[cfg(windows)]
89	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
90	    SpawnReq {
91	        program,
92	        args,
93	        rows: 24,
94	        cols: 80,
95	        endpoint: String::new(),
96	        cwd: None,
97	    }
98	}
99
100	/// A hermetic net config (mirrors `netbroker.rs`): relays disabled, no discovery,
101	/// loopback-only sockets — no real interface touched, no firewall prompt.
102	fn hermetic(identity: Identity) -> NetConfig {
103	    NetConfig {
104	        identity,
105	        relay: RelayPolicy::Disabled,
106	        discovery: LocalDiscovery::Off,
107	        scope: BindScope::Loopback,
108	        membership: None,
109	    }
110	}
111
112	/// Bind a broker (serving on a background thread) that owns a hermetic QUIC
113	/// endpoint for `identity`, with its effect journal under `dir`.
114	fn net_broker(name: &str, identity: Identity, dir: &std::path::Path) -> Arc<Broker> {
115	    let host = NetHost::start(hermetic(identity)).expect("net host start");
116	    let broker = Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host))
117	        .expect("bind broker with net");
118	    let serve = Arc::clone(&broker);
119	    thread::spawn(move || {
120	        let _ = serve.serve();
121	    });
122	    broker
123	}
124
125	fn connect_retry(name: &str) -> Brain {
126	    for _ in 0..400 {
127	        if let Ok(b) = Brain::cold_start(name, 1) {
128	            return b;
129	        }
130	        thread::sleep(Duration::from_millis(10));

//! Restoration D7-1 (ADR-0018 V5) — **process-level survival across a binary
//! swap**: a hosted PTY child + a live QUIC connection survive a brain-**PROCESS**
//! restart that lands on a **swapped on-disk binary**, with the broker holding
//! both endpoints untouched throughout. This is the `int` E2E that re-points the
//! regression-masked REQ-DAEMON-2 / REQ-UPD-3 evidence off the in-process
//! `brain_swap.rs` shape onto a real process boundary.
//!
//! ## The proof is a TRIANGLE (doyle D7 vet, call 1) — this is leg (b)
//!
//! No single test proves the whole claim; three legs do, and this file is honest
//! only when read alongside the other two:
//!
//! - **Leg (a) — the real-broker-process leg: `crates/spt/tests/brain_split.rs`.**
//!   The REAL `spt daemon run` broker *process* survives a brain kill and respawns
//!   it. That is where the broker's own process-packaging is proven.
//! - **Leg (b) — the survival leg: THIS test.** A real brain *child process* +
//!   a real on-disk binary swap + real broker-**held** endpoints (a PTY child and
//!   a live QUIC conn). The broker is modeled **in-process** — faithful, not a
//!   weakening: the broker is the never-restarting anchor *by design*, so an
//!   in-process [`Broker`] runs the **same Broker code holding the same
//!   resources** the real broker would across a brain swap. The claim under test
//!   is the brain process + the swapped binary + broker-held endpoint survival —
//!   all real here. (Bonus: the in-process broker gives the test direct access to
//!   its session pid / count and QUIC conn count for the assertions.)
//! - **Leg (c) — the field leg: D7-4.** The real daemon, a real `apply`, real
//!   hardware — the `exe_hash` flips and matches the released artifact.
//!
//! DEFERRED upgrade: once the live-agent adapter makes daemon-hosted sessions a
//! production reality, a full-stack single-process E2E (the real `spt daemon run`
//! hosting *and* surviving) becomes reachable — tracked in `docs/DEFERRED.md`,
//! NOT D7 work.
//!
//! ## What makes this STRONGER than `brain_swap.rs`
//!
//! `brain_swap.rs` (REQ-UPD-3's prior `int`) relaunches the brain via an
//! **in-process closure** — same process image, no disk involved. This test runs
//! the **real `spt daemon brain` child process** the production
//! [`supervise_brain`] spawns, swaps the on-disk binary it respawns from, and
//! proves the post-respawn brain is running the **new bytes** via the D7-1
//! `exe_hash` breadcrumb (`exe_hash == fixture-B != fixture-A`). It is also the
//! **first process-level exercise of the D4 cursor-of-record resume with N≥1
//! hosted sessions** (the supervised child re-attaches the test-established PTY
//! session on cold start).
//!
//! The swap is modeled as a **path flip** between two distinct on-disk fixtures
//! (B = A + appended padding — PE and ELF both tolerate trailing data, so behavior
//! is identical but the hash flips). This is faithful to the production swap (the
//! supervisor respawns from whatever the binary *selection* resolves to) and
//! sidesteps the Windows running-exe lock (you cannot overwrite a live `.exe`).
//!
//! **Held AND functional (doyle F1).** Proving the endpoints are *held* (pid /
//! session_count / conn_count unchanged) is not proving they are *alive* — a
//! zombie conn or wedged PTY would pass. So after the swap the test also drives a
//! marker through the held PTY child and watches the broker's output seq advance:
//! the full round-trip (input → child → echo → broker ring) works through the
//! swapped-in brain.
// [int->REQ-HAZARD-BROKER-PROCESS-ISOLATION]
// [int->REQ-UPD-3]
// [int->REQ-DAEMON-2]

use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};

use spt_daemon::brain::Brain;
use spt_daemon::brainproc::{supervise_brain, BrainRestart, NoTrialEnv, StartReason};
use spt_daemon::codec::write_frame;
use spt_daemon::endpoint::broker_socket_name;
use spt_daemon::frame::{Envelope, Role};
use spt_daemon::msg::{encode_bytes, InputReq, SpawnReq, KIND_INPUT};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::transport::{send_hello, LocalSocketTransport};
use spt_daemon::Broker;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_proto::identity::Identity;

mod common;
use common::CommandNoWindowExt;

/// A long-lived echo child to host under a broker PTY (the endpoint REQ-UPD-3
/// forbids us from disturbing across the swap). `cat`/`findstr` idle on stdin.
fn echo_spawn_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq {
        program,
        args,
        rows: 24,
        cols: 80,
        endpoint: String::new(),
        cwd: None,
    }
}

/// A hermetic net config (mirrors `netbroker.rs`): relays disabled, no discovery,
/// loopback-only sockets — no real interface touched, no firewall prompt.
fn hermetic(identity: Identity) -> NetConfig {
    NetConfig {
        identity,
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
        membership: None,
    }
}

/// Bind a broker (serving on a background thread) that owns a hermetic QUIC
/// endpoint for `identity`, with its effect journal under `dir`.
fn net_broker(name: &str, identity: Identity, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic(identity)).expect("net host start");
    let broker = Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host))
        .expect("bind broker with net");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..400 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));


`run_brain`-on-a-thread is doyle's described (i) path. But the `mutate_info` change has **wide blast radius** (6 setters used everywhere) — must regression-check before more. Run the full spt-store + spt-daemon suites first.


cargo test -p spt-store -p spt-daemon 2>&1 | grep -iE "test result: FAILED|panicked|error\[|FAILED|running [0-9]+ tests" | grep -ivE "PASS|_fail|fail_|fails" | tail -30; echo "=== exit check ==="; cargo test -p spt-store -p spt-daemon 2>&1 | grep -cE "test result: ok"

Regression-check spt-store + spt-daemon after mutate_info


running 294 tests
test effect::tests::failed_effect_is_not_recorded_applied ... ok
running 12 tests
running 11 tests
running 2 tests
running 5 tests
running 2 tests
running 2 tests
running 6 tests
running 2 tests
running 3 tests
running 2 tests
running 4 tests
running 2 tests
running 3 tests
running 2 tests
running 3 tests
running 2 tests
running 161 tests
running 0 tests
running 0 tests
=== exit check ===
34


spt-store + spt-daemon fully green (34 test bins ok, zero FAILED) — the wide `mutate_info` change didn't regress. Now run the 3 changed spt ints (fast-pulse, lock-dependent). Reap stray daemons first (Windows exe-lock).


PowerShell

$ws = "C:\Users\decid\Documents\projects\spt-core"; Get-Process spt,mock-shell,mock-session,mock-gateway -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "$ws\*" } | ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue }; Write-Output "reaped"

Reap workspace-scoped stray daemons


reaped


Bash

cargo test -p spt --test live_firsthost_e2e --test live_bind_firsthost_e2e --test contract_e2e 2>&1 | tail -20

Run the 3 changed spt int tests


test live_agent_lifecycle_e2e ... ok
SEEDED:156800
test seed_then_listen_binds_and_relays ... ok

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

     Running tests\live_bind_firsthost_e2e.rs (target\debug\deps\live_bind_firsthost_e2e-ec701b2810416739.exe)

running 1 test
test live_bind_marks_online_and_brain_reconcile_hosts ... ok

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

     Running tests\live_firsthost_e2e.rs (target\debug\deps\live_firsthost_e2e-f4679ac135019854.exe)

running 1 test
test live_listen_marks_online_and_brain_reconcile_hosts ... ok

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


All 3 changed ints green (fast-pulse, lock-deterministic). (iii)+(ii) complete + regression-clean. Checkpoint them with a commit (insurance before the heavy (i)). Pre-flight clippy `--workspace` + traceable.


cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -5; echo "=== traceable ==="; traceable-reqs check >/dev/null 2>&1; echo "EXIT=$?"

Workspace clippy + traceable after iii+ii


    Checking mock-adapter v0.7.1 (C:\Users\decid\Documents\projects\spt-core\adapters\mock)
    Checking spt-daemon v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Checking spt v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Checking xtask v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.61s
=== traceable ===
EXIT=0


clippy `--workspace` clean + traceable EXIT 0. Commit (iii)+(ii) checkpoint.


git branch --show-current && git add -A && git status --short

Branch check + stage iii+ii


m11-shell-substrate
warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/livehost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/tests/live_bind_firsthost_e2e.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/tests/live_firsthost_e2e.rs', LF will be replaced by CRLF the next time Git touches it
M  Cargo.lock
M  crates/spt-daemon/src/livehost.rs
M  crates/spt-store/Cargo.toml
M  crates/spt-store/src/info.rs
M  crates/spt/tests/live_bind_firsthost_e2e.rs
M  crates/spt/tests/live_firsthost_e2e.rs


git commit -q -F - <<'EOF'
M11-W0.4 (iii)+(ii): info.json lost-update lock + read-error un-host (REQ-DAEMON-1)

The first two of W0.4's three close-out blockers (doyle-gated A; (i) real-brain
int re-point lands next).

(iii) LOST-UPDATE — the per-perch info.json write lock (mutate_info funnel):
- All SIX info.json read-modify-write setters (set_status, set_last_active,
  set_rest_state, set_resources, set_driven_by, set_viewer_count) now funnel
  through one private mutate_info(perch, |rec|{...}) = lock → read_info → mutate →
  write(tmp+rename) → RAII release. Single chokepoint — a future setter is forced
  through it too.
- The lock is taken on a STABLE per-perch sentinel <perch>/.info.lock (created
  once, NEVER renamed), NOT info.json. Load-bearing: info.json is replaced by
  tmp+rename (a new inode each write), and advisory locks lock the INODE, so a lock
  on info.json's fd would not serialize across the rename (a fake lock — a second
  writer locks the old unlinked inode, reads stale, clobbers). The constant
  sentinel inode is what actually serializes the RMW. fs2 lock_exclusive; OS
  auto-release on fd drop / process exit (no stale-lock reaping).
- Readers (read_info) stay LOCK-FREE — the atomic tmp+rename already gives a
  complete old-or-new record (KH-1.2); the lock serializes WRITERS only.
- Closes the un-host correctness bug: a pulse set_last_active could clobber a
  concurrent mark_offline back to online → un-host never fired → a signed-off
  agent kept its Psyche.
- HAMMER TEST (info.rs): 6 threads spam the transient setters while one
  mark_offlines mid-storm → status stays offline under contention. The
  long-pulse+settle workarounds are removed from livehost unit (e) +
  live_firsthost_e2e + live_bind_firsthost_e2e (fast pulse now safe — the driver
  hammers set_last_active while the test mark_offlines and un-host reliably fires).

(ii) READ-ERROR UN-HOST (livehost reconcile stop-side):
- Un-host on a DEFINITIVE departure only, never a transient read failure. New
  discriminator: perch dir gone → torn down → un-host; dir present + read_info
  Some(status≠online) → signed-off → un-host; dir present + read_info None
  (transient/torn) → LEAVE the driver, retry next tick. No Psyche stop/respawn
  churn on a disk blip.
- Unit (f): a hosted endpoint whose info.json vanishes (dir present) is kept;
  only a gone perch dir un-hosts.

fs2 0.4.3 added to spt-store. spt-store + spt-daemon full suites green; the 3
changed spt ints green; clippy --workspace clean; traceable EXIT 0. (i) int
re-point + the moved [int->REQ-DAEMON-1] tag is the next commit.

Co-authored by: todlando
EOF
git log --oneline -1

Commit W0.4 iii+ii


bb9100c M11-W0.4 (iii)+(ii): info.json lost-update lock + read-error un-host (REQ-DAEMON-1)


(iii)+(ii) committed @bb9100c. Checkpoint commune (W0.4 progress) + study the real-brain harness for (i).


Write

# todlando commune — W0.4 in progress: (iii)+(ii) DONE @bb9100c, (i) remaining

## W0.4 = OPTION A close-out (REQ-DAEMON-1). doyle gates hardest.
- **(iii) LOST-UPDATE — DONE @bb9100c**: mutate_info(perch,|rec|{}) funnel = fs2 lock on the STABLE `<perch>/.info.lock` sentinel (NOT info.json — inode/rename catch) → read_info → mutate → write(tmp+rename) → RAII release. All 6 setters funneled. Readers lock-free. HAMMER test (info.rs): 6 threads spam transient setters + 1 mark_offline → status stays offline. Long-pulse+settle workarounds STRIPPED from livehost (e) + live_firsthost_e2e + live_bind_firsthost_e2e (fast pulse now safe). fs2 0.4.3 added.
- **(ii) READ-ERROR UN-HOST — DONE @bb9100c**: livehost reconcile stop-side discriminator — perch-dir-gone→un-host; dir-present+status≠online→un-host; dir-present+read None (transient)→LEAVE driver. Unit (f).
- **(i) REAL-BRAIN INT RE-POINT — REMAINING (next commit)**: doyle's path = Broker + brainproc::run_brain ON A THREAD (runs livehost::spawn_live_host since W0.1 → reconcile is the REAL brain's, not in-test). Real establish→online→REAL brain reconcile hosts (poll psyche perch)→commune ingest→brain-restart (kill broker→run_brain exits→new broker+run_brain)→boot reconcile RE-HOSTS. **MOVE the [int->REQ-DAEMON-1] tag OFF the type-level daemon_e2e ONTO this** (daemon_e2e keeps DAEMON-2/4 + hazard tags). HARNESS MODEL: brain_survive.rs (spt/tests) — net_broker() binds Broker w/ hermetic net + serve thread; uses supervise_brain (real child process). For (i) use run_brain-on-thread per doyle. run_brain connects to broker_socket_name(), heartbeat loop calls net_status (needs broker to answer → may need net_broker not bare Broker — VERIFY). 5s reconcile tick → poll ~6s.

## STATUS
- W1·W0.1·W0.2·W0.3 FULL PASS. W0.4 (iii)+(ii) @bb9100c (NOT yet pushed/CI). (i) next → then push → CI → submit W0.4 to doyle (gates: sentinel-not-info.json, all-6-funneled, hammer-asserts-un-host, MOVED-int-real-brain-arc).
- (iii) was A→B+→A→B+→A whipsaw (doyle's, resolved A). DONE. No more (iii) architecture talk; next doyle ping = W0.4 evidence.
- After W0: W0.5 perri cutover → M11 W2 drive/W3 tunnel/W4 Gateway/W5 rig+docs.
- GOTCHAS: pre-flight `clippy --workspace`; `git branch --show-current`; Windows exe-lock=reap WORKSPACE-SCOPED (`Get-Process spt|Where Path -like "$ws\*"|Stop-Process`); handoff test=timeout-flake→rerun.


File created successfully at: .claude\todlando-commune.md (file state is current in your context — no need to Read it back)

# todlando commune — W0.4 in progress: (iii)+(ii) DONE @bb9100c, (i) remaining

## W0.4 = OPTION A close-out (REQ-DAEMON-1). doyle gates hardest.
- **(iii) LOST-UPDATE — DONE @bb9100c**: mutate_info(perch,|rec|{}) funnel = fs2 lock on the STABLE `<perch>/.info.lock` sentinel (NOT info.json — inode/rename catch) → read_info → mutate → write(tmp+rename) → RAII release. All 6 setters funneled. Readers lock-free. HAMMER test (info.rs): 6 threads spam transient setters + 1 mark_offline → status stays offline. Long-pulse+settle workarounds STRIPPED from livehost (e) + live_firsthost_e2e + live_bind_firsthost_e2e (fast pulse now safe). fs2 0.4.3 added.
- **(ii) READ-ERROR UN-HOST — DONE @bb9100c**: livehost reconcile stop-side discriminator — perch-dir-gone→un-host; dir-present+status≠online→un-host; dir-present+read None (transient)→LEAVE driver. Unit (f).
- **(i) REAL-BRAIN INT RE-POINT — REMAINING (next commit)**: doyle's path = Broker + brainproc::run_brain ON A THREAD (runs livehost::spawn_live_host since W0.1 → reconcile is the REAL brain's, not in-test). Real establish→online→REAL brain reconcile hosts (poll psyche perch)→commune ingest→brain-restart (kill broker→run_brain exits→new broker+run_brain)→boot reconcile RE-HOSTS. **MOVE the [int->REQ-DAEMON-1] tag OFF the type-level daemon_e2e ONTO this** (daemon_e2e keeps DAEMON-2/4 + hazard tags). HARNESS MODEL: brain_survive.rs (spt/tests) — net_broker() binds Broker w/ hermetic net + serve thread; uses supervise_brain (real child process). For (i) use run_brain-on-thread per doyle. run_brain connects to broker_socket_name(), heartbeat loop calls net_status (needs broker to answer → may need net_broker not bare Broker — VERIFY). 5s reconcile tick → poll ~6s.

## STATUS
- W1·W0.1·W0.2·W0.3 FULL PASS. W0.4 (iii)+(ii) @bb9100c (NOT yet pushed/CI). (i) next → then push → CI → submit W0.4 to doyle (gates: sentinel-not-info.json, all-6-funneled, hammer-asserts-un-host, MOVED-int-real-brain-arc).
- (iii) was A→B+→A→B+→A whipsaw (doyle's, resolved A). DONE. No more (iii) architecture talk; next doyle ping = W0.4 evidence.
- After W0: W0.5 perri cutover → M11 W2 drive/W3 tunnel/W4 Gateway/W5 rig+docs.
- GOTCHAS: pre-flight `clippy --workspace`; `git branch --show-current`; Windows exe-lock=reap WORKSPACE-SCOPED (`Get-Process spt|Where Path -like "$ws\*"|Stop-Process`); handoff test=timeout-flake→rerun.


130	        thread::sleep(Duration::from_millis(10));
131	    }
132	    panic!("brain could not connect to {name}");
133	}
134
135	/// The three breadcrumb fields out of a `brain.ready` body (parsed inline so this
136	/// test depends on no private brainproc helper). `None` until the file exists and
137	/// carries a parseable pid.
138	fn read_ready(path: &std::path::Path) -> Option<(u32, u64, Option<String>)> {
139	    let body = std::fs::read_to_string(path).ok()?;
140	    let v: serde_json::Value = serde_json::from_str(&body).ok()?;
141	    let pid = v.get("pid")?.as_u64()? as u32;
142	    let generation = v.get("generation").and_then(|g| g.as_u64()).unwrap_or(0);
143	    let exe_hash = v
144	        .get("exe_hash")
145	        .and_then(|h| h.as_str())
146	        .map(str::to_string);
147	    Some((pid, generation, exe_hash))
148	}
149
150	/// Poll `brain.ready` until it holds a pid (optionally one DIFFERENT from `was`),
151	/// returning `(pid, generation, exe_hash)`. `None` if it never appears in time.
152	fn wait_ready(
153	    path: &std::path::Path,
154	    was: Option<u32>,
155	    budget: Duration,
156	) -> Option<(u32, u64, Option<String>)> {
157	    let deadline = Instant::now() + budget;
158	    while Instant::now() < deadline {
159	        if let Some((pid, gen, hash)) = read_ready(path) {
160	            if was != Some(pid) {
161	                return Some((pid, gen, hash));
162	            }
163	        }
164	        thread::sleep(Duration::from_millis(50));
165	    }
166	    None
167	}
168
169	/// Lowercase-hex SHA-256 of a file (the same digest `brain.ready.exe_hash`
170	/// carries; recomputed independently here to assert the breadcrumb matches the
171	/// fixture the supervisor actually spawned).
172	fn file_hash(path: &std::path::Path) -> String {
173	    use sha2::{Digest, Sha256};
174	    let bytes = std::fs::read(path).expect("read fixture for hashing");
175	    let mut h = Sha256::new();
176	    h.update(&bytes);
177	    let digest = h.finalize();
178	    let mut s = String::with_capacity(digest.len() * 2);
179	    for b in digest {
180	        s.push_str(&format!("{b:02x}"));
181	    }
182	    s
183	}
184
185	#[test]
186	fn pty_and_quic_survive_brain_process_restart_onto_swapped_binary() {
187	    // The in-process broker + brain children both resolve `broker_socket_name()`
188	    // from SPT_HOME, so the supervised child connects to OUR broker. (The
189	    // established daemon-test pattern — daemon_e2e.rs et al. set it the same way.)
190	    let home = tempfile::tempdir().expect("tempdir");
191	    // Single-test binary by design — this is the SOLE `#[test]` here, so it owns
192	    // the process-global SPT_HOME on its own thread (cross-binary tests are
193	    // separate processes). A SECOND `#[test]` added to this file MUST bring the
194	    // env-lock discipline (a shared mutex / serialized home), or it will race this
195	    // one — the D6 env-race lesson (see the spt-daemon test_home pattern).
196	    std::env::set_var("SPT_HOME", home.path());
197
198	    // ── Two fixtures: A, and B = A + appended padding (the on-disk "swap"). ──
199	    // Both are the real `spt` binary, so `daemon brain` runs; B differs only by
200	    // trailing bytes (behavior identical, exe_hash flips).
201	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
202	    let suffix = spt_bin.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
203	    let fixture_a = home.path().join(format!("brain-A{suffix}"));
204	    let fixture_b = home.path().join(format!("brain-B{suffix}"));
205	    std::fs::copy(&spt_bin, &fixture_a).expect("stage fixture A");
206	    std::fs::copy(&spt_bin, &fixture_b).expect("stage fixture B");
207	    {
208	        use std::io::Write;
209	        let mut f = std::fs::OpenOptions::new()
210	            .append(true)
211	            .open(&fixture_b)
212	            .expect("open fixture B to append padding");
213	        f.write_all(&[0u8; 4096]).expect("append padding to fixture B");
214	    }
215	    let hash_a = file_hash(&fixture_a);
216	    let hash_b = file_hash(&fixture_b);
217	    assert_ne!(hash_a, hash_b, "the swap must flip the binary hash");
218
219	    // ── The broker (leg-b stable kernel) + a loopback QUIC peer. ──
220	    let broker = net_broker(&broker_socket_name(), Identity::generate(), &home.path().join("a"));
221	    let peer_name = format!("spt-d7-peer-{}.sock", std::process::id());
222	    let _peer = net_broker(&peer_name, Identity::generate(), &home.path().join("b"));
223
224	    // ── Establish the two endpoints ONCE via a test-driver brain, then drop it
225	    //    HARD (the D4 hard-kill posture — the outgoing side is gone before the
226	    //    supervised child resumes). ──
227	    let (sid, child_pid) = {
228	        let mut driver = connect_retry(&broker_socket_name());
229

        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect to {name}");
}

/// The three breadcrumb fields out of a `brain.ready` body (parsed inline so this
/// test depends on no private brainproc helper). `None` until the file exists and
/// carries a parseable pid.
fn read_ready(path: &std::path::Path) -> Option<(u32, u64, Option<String>)> {
    let body = std::fs::read_to_string(path).ok()?;
    let v: serde_json::Value = serde_json::from_str(&body).ok()?;
    let pid = v.get("pid")?.as_u64()? as u32;
    let generation = v.get("generation").and_then(|g| g.as_u64()).unwrap_or(0);
    let exe_hash = v
        .get("exe_hash")
        .and_then(|h| h.as_str())
        .map(str::to_string);
    Some((pid, generation, exe_hash))
}

/// Poll `brain.ready` until it holds a pid (optionally one DIFFERENT from `was`),
/// returning `(pid, generation, exe_hash)`. `None` if it never appears in time.
fn wait_ready(
    path: &std::path::Path,
    was: Option<u32>,
    budget: Duration,
) -> Option<(u32, u64, Option<String>)> {
    let deadline = Instant::now() + budget;
    while Instant::now() < deadline {
        if let Some((pid, gen, hash)) = read_ready(path) {
            if was != Some(pid) {
                return Some((pid, gen, hash));
            }
        }
        thread::sleep(Duration::from_millis(50));
    }
    None
}

/// Lowercase-hex SHA-256 of a file (the same digest `brain.ready.exe_hash`
/// carries; recomputed independently here to assert the breadcrumb matches the
/// fixture the supervisor actually spawned).
fn file_hash(path: &std::path::Path) -> String {
    use sha2::{Digest, Sha256};
    let bytes = std::fs::read(path).expect("read fixture for hashing");
    let mut h = Sha256::new();
    h.update(&bytes);
    let digest = h.finalize();
    let mut s = String::with_capacity(digest.len() * 2);
    for b in digest {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

#[test]
fn pty_and_quic_survive_brain_process_restart_onto_swapped_binary() {
    // The in-process broker + brain children both resolve `broker_socket_name()`
    // from SPT_HOME, so the supervised child connects to OUR broker. (The
    // established daemon-test pattern — daemon_e2e.rs et al. set it the same way.)
    let home = tempfile::tempdir().expect("tempdir");
    // Single-test binary by design — this is the SOLE `#[test]` here, so it owns
    // the process-global SPT_HOME on its own thread (cross-binary tests are
    // separate processes). A SECOND `#[test]` added to this file MUST bring the
    // env-lock discipline (a shared mutex / serialized home), or it will race this
    // one — the D6 env-race lesson (see the spt-daemon test_home pattern).
    std::env::set_var("SPT_HOME", home.path());

    // ── Two fixtures: A, and B = A + appended padding (the on-disk "swap"). ──
    // Both are the real `spt` binary, so `daemon brain` runs; B differs only by
    // trailing bytes (behavior identical, exe_hash flips).
    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let suffix = spt_bin.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
    let fixture_a = home.path().join(format!("brain-A{suffix}"));
    let fixture_b = home.path().join(format!("brain-B{suffix}"));
    std::fs::copy(&spt_bin, &fixture_a).expect("stage fixture A");
    std::fs::copy(&spt_bin, &fixture_b).expect("stage fixture B");
    {
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(&fixture_b)
            .expect("open fixture B to append padding");
        f.write_all(&[0u8; 4096]).expect("append padding to fixture B");
    }
    let hash_a = file_hash(&fixture_a);
    let hash_b = file_hash(&fixture_b);
    assert_ne!(hash_a, hash_b, "the swap must flip the binary hash");

    // ── The broker (leg-b stable kernel) + a loopback QUIC peer. ──
    let broker = net_broker(&broker_socket_name(), Identity::generate(), &home.path().join("a"));
    let peer_name = format!("spt-d7-peer-{}.sock", std::process::id());
    let _peer = net_broker(&peer_name, Identity::generate(), &home.path().join("b"));

    // ── Establish the two endpoints ONCE via a test-driver brain, then drop it
    //    HARD (the D4 hard-kill posture — the outgoing side is gone before the
    //    supervised child resumes). ──
    let (sid, child_pid) = {
        let mut driver = connect_retry(&broker_socket_name());


brain_survive uses `supervise_brain` (child process + fixtures) — heavy. doyle's (i) path is lighter: `run_brain` on a thread. The restart-rehost needs stopping the brain (it exits on broker loss). Check Broker for a clean stop.


Grep

pub fn stop|pub fn shutdown|fn bind\b|pub fn bind|impl Drop for Broker|fn serve\b


crates\spt-daemon\src\broker.rs:535:    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
crates\spt-daemon\src\broker.rs:541:    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
crates\spt-daemon\src\broker.rs:550:    pub fn bind_in_with_net(
crates\spt-daemon\src\broker.rs:564:    pub fn bind_in_with_net_cap(
crates\spt-daemon\src\broker.rs:622:    pub fn serve(self: &Arc<Self>) -> io::Result<()> {


Broker has `bind` + `serve` (no clean stop) — so the restart-rehost is best modeled as a **cold `run_brain` over an already-online perch** (= the post-restart rehost: perch survives, fresh brain re-hosts at boot). `spawn_live_host` reconciles at boot (immediate), so hosting is fast. Write the (i) real-brain int + move the tag off daemon_e2e.