Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
0a34ea0
fix(cognition): wire the LIVED-TURN producer — her own conversations …
joelteply Aug 17, 2026
08ccd61
fix(cognition): the lived-turn producer belongs in the settle DRIVER,…
joelteply Aug 17, 2026
a87f7c8
fix(persona): a YIELD is not a REST — kill the starvation ratchet tha…
joelteply Aug 17, 2026
6229b37
fix(cognition): the ambient-turn pool was a hardcoded 1 — derive it f…
joelteply Aug 17, 2026
5d62bf3
fix(runtime): a deploy in flight must block implicit autostart — the …
joelteply Aug 17, 2026
be5cd11
fix(deploy): ⚠ STALE CLI fired on every SUCCESSFUL reboot — and its t…
joelteply Aug 17, 2026
67fbfbf
docs(observations): dated post-run card — the two starvation defects,…
joelteply Aug 17, 2026
3375ad6
docs(observations): CORRECTION — the 27B is already the persona model…
joelteply Aug 17, 2026
b4089a8
feat(cognition): make the ambient YIELD visible — a starved roster re…
joelteply Aug 17, 2026
00f6d57
fix(cognition): my own starvation probe reported CUMULATIVE under a f…
joelteply Aug 17, 2026
4323ac1
fix(persona): the work board grew into the prompt — cap the per-card …
joelteply Aug 17, 2026
cabecb9
fix(swe): one era-pinned install path, so the sdist build-deps heal l…
joelteply Aug 17, 2026
2566a09
fix(rag): roster + doctrine read the room she is STANDING in, not the…
joelteply Aug 17, 2026
fe4db28
docs(observations): 2026-08-17 bench rounds + the room gate that made…
joelteply Aug 17, 2026
4178d50
fix(swe): build tools fall back to UNPINNED when the era pin is unsat…
joelteply Aug 17, 2026
a37ac70
feat(bench): dispatch parks on the roster like it parks on serving (#…
joelteply Aug 17, 2026
185816b
fix(work): the grade tail cannot depend on wire delivery — work/state…
joelteply Aug 17, 2026
99c59fe
fix(swe): grade clones through a staging path — a .DS_Store must not …
joelteply Aug 18, 2026
b0141e8
fix(swe): install the repo's own TEST extra — a suite that cannot COL…
joelteply Aug 18, 2026
dc1f4e0
fix(swe): the test-extra parser was half-blind — read setup.cfg too, …
joelteply Aug 18, 2026
6b1b0c7
fix(swe): the UNGRADEABLE verdict must carry the pristine run's outpu…
joelteply Aug 18, 2026
1fc25c0
fix(benchmark): a kickoff addressed to a citizen must never be AUTHOR…
joelteply Aug 18, 2026
dc4621f
fix(bench): dispatch resolves against RESIDENCY, not registration (#455)
joelteply Aug 18, 2026
f772aee
fix(boot): boot OWNS the process tree — adopt healthy, reap unhealthy…
joelteply Aug 18, 2026
07c67e4
feat(persona): BaseModelPolicy — one answer to "what base does she th…
joelteply Aug 18, 2026
8366770
feat(persona): PegReason::Training — the peg's biggest job is targeti…
joelteply Aug 18, 2026
18ecef7
feat(persona): the work turn roots her hands at the card AND feeds th…
joelteply Aug 18, 2026
884da43
docs(planning): BENCHMARKS-THAT-LEARN — the plan to a number we can d…
joelteply Aug 18, 2026
f1417e1
feat(benchmark): the ROUND owns who drives its cards — WorkDriver{Det…
joelteply Aug 18, 2026
b24ea58
refactor(persona): staged_workspace becomes the ONE staging resolver …
joelteply Aug 18, 2026
191e31d
fix(benchmark): citizen-driven pre-claim honors the same broken-env r…
joelteply Aug 18, 2026
58e927a
fix(persona): the held-work gate was unreachable on the ONE path benc…
joelteply Aug 18, 2026
89bd2a1
docs(planning): THE ROOM IS THE RUNNER — build plan with a probe gate…
joelteply Aug 18, 2026
94d008e
feat(persona): the act-question becomes its own module, asked on BOTH…
joelteply Aug 18, 2026
8a4abec
docs(architecture): content travels by HANDLE, never by copy — the de…
joelteply Aug 18, 2026
e9c310d
feat(content): ContentSource — oversized content stays put and travel…
joelteply Aug 18, 2026
6958293
feat(content): content/fetch + ContentModule — the handle is now call…
joelteply Aug 18, 2026
2e6594d
fix(content): content/fetch leaves the native surface — the header na…
joelteply Aug 18, 2026
e287dc2
docs(architecture): collapse-and-expand is positron's actual purpose …
joelteply Aug 18, 2026
4bcb509
feat(cognition): root the act chain in its trigger — the missing link…
joelteply Aug 18, 2026
345cada
feat(cognition): the wake message becomes the turn's cause — the caus…
joelteply Aug 18, 2026
4ece044
feat(cognition): a turn must SAY what caused it — Cause replaces the …
joelteply Aug 18, 2026
0ca190e
fix(bench): patch custody is not a caller courtesy — every attempt ke…
joelteply Aug 18, 2026
389c707
fix(bench): patches land in the GOVERNED benchmarks root, not a dir I…
joelteply Aug 18, 2026
db02ef6
docs: the index omitted architecture/ — the one directory CLAUDE.md s…
joelteply Aug 18, 2026
a807a24
docs: fold the duplicated Chapters prose into the directory table (on…
joelteply Aug 18, 2026
4ddf7ce
chore(protocol): commit the ts-rs bindings that landed Rust-only (Wor…
joelteply Aug 18, 2026
1561bfb
refactor(content): delete the duplicate handle mechanism I built yest…
joelteply Aug 18, 2026
4b785b9
refactor(cognition): retire four superseded symbols whose docs still …
joelteply Aug 18, 2026
e712027
refactor(benchmark): kill a duplicate SWE task prompt and a fake "def…
joelteply Aug 18, 2026
bd6d5ab
refactor(tools): delete the superseded brace-scanner in the tool-call…
joelteply Aug 18, 2026
e14e0fe
fix(benchmark): the grader could not read colorized pytest output — e…
joelteply Aug 18, 2026
59e9438
fix(benchmark): a private keypair could reach a graded patch and the …
joelteply Aug 18, 2026
72948dc
fix(benchmark): the board could not see a finished patch — staged wor…
joelteply Aug 18, 2026
5759b1c
docs(planning): the grade tail — plan of record, and the forbidden mo…
joelteply Aug 18, 2026
f3cb3a6
fix(benchmark): the boot reaper and the reboot guard read a ledger na…
joelteply Aug 18, 2026
7a7a6b1
docs(planning): correct the grade-tail plan — Seam 2 was already buil…
joelteply Aug 18, 2026
c12fe90
fix(benchmark): reap writes run_id, not runId — I shipped two names f…
joelteply Aug 18, 2026
a35322a
docs(architecture): run ledgers are typed artifacts with one owner — …
joelteply Aug 19, 2026
d335a43
docs(architecture): L3b — the record carries PROGRESS, because livene…
joelteply Aug 19, 2026
9cffd65
docs(architecture): outlier B result — the draft did NOT fit, and eva…
joelteply Aug 19, 2026
3ffe570
docs(architecture): eval is the BYPASS, not a family to harden — outl…
joelteply Aug 19, 2026
c31600e
fix(benchmark): verdicts PERSIST — a measurement the system cannot re…
joelteply Aug 19, 2026
c453ee8
fix(benchmark): an EMPTY candidate is an absence, not a zero — the po…
joelteply Aug 19, 2026
4f900be
fix(benchmark): swe_cache_dir became the second root its own doc warn…
joelteply Aug 19, 2026
ef0eb5b
fix(benchmark): grade RESOLVES which citizen's copy, instead of trust…
joelteply Aug 19, 2026
a7ddaf8
fix(benchmark): a verdict EMITS a board row — until now it only subtr…
joelteply Aug 19, 2026
3375fbb
chore(protocol): regenerate BenchRunCard binding for the `ungraded` p…
joelteply Aug 19, 2026
e0b70d1
feat(benchmark): the recipe grades its own artifacts — no operator in…
joelteply Aug 19, 2026
a85acee
feat(benchmark): a round can be ASKED its stage — #371's acceptance t…
joelteply Aug 19, 2026
dd441a6
feat(benchmark): STAGING → READY is a gate, not a suggestion (#442) —…
joelteply Aug 19, 2026
29b244f
feat(serving): Qwen3.8-27B SERVES on the M5 — 17.2 tok/s measured, ca…
joelteply Aug 19, 2026
4c6157d
fix(serving): the planner reasons about the budget the incumbent is o…
joelteply Aug 19, 2026
9e27ba6
feat(benchmark): every catalogued suite becomes fetchable — one shape…
joelteply Aug 19, 2026
77d9360
feat(benchmark): fetch coordinates are catalog DATA, and each refusal…
joelteply Aug 19, 2026
cea89e2
feat(benchmark): ONE task shape, per-suite adapters that carry only t…
joelteply Aug 19, 2026
971de3e
feat(benchmark): the parse stops at the foreign edge — typed classes …
joelteply Aug 19, 2026
2ccada5
fix(benchmark): staging held two copies of a dataset to learn one num…
joelteply Aug 19, 2026
4dae25f
fix(agent): a roomless solve now DECLARES that it is invisible (#425 …
joelteply Aug 19, 2026
0e342d1
feat(resources): benchmark staging becomes the FIFTH peer consumer — …
joelteply Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 0 additions & 21 deletions core/continuum-core/src/ai/json_in_prompt_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2087,27 +2087,6 @@ where
out
}

/// Yield substrings of `text` that are balanced `{...}` objects, outermost-first
/// at each start position — so a `{"tool_call": {...}}` envelope is tried before
/// its inner `{...}`. Brace-depth scan that ignores braces inside JSON strings
/// (so `{"k":"}"}` doesn't fool it). Cheap; the candidate set is tiny in practice.
fn json_object_candidates(text: &str) -> Vec<&str> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{' {
if let Some(end) = matching_brace_end(bytes, i) {
out.push(&text[i..=end]);
// Continue scanning AFTER this object's open brace so nested/later
// objects are still considered, but we tried the outermost first.
}
}
i += 1;
}
out
}

/// Index of the `}` matching the `{` at `start`, respecting JSON string literals
/// and escapes. `None` if unbalanced (truncated output).
fn matching_brace_end(bytes: &[u8], start: usize) -> Option<usize> {
Expand Down
160 changes: 155 additions & 5 deletions core/continuum-core/src/bin/continuum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ async fn run() -> Result<(), String> {
}
// Standalone #194 check: prove the RUNNING core is built from current HEAD,
// without a full reboot. Prints "✅ deploy verified" or fails loud on mismatch.
"deploy-verify" | "verify" => verify_deployed_build().await,
"deploy-verify" | "verify" => verify_deployed_build(false).await,
// Anything else is a command name. `--help`/`-h` renders the manual in the
// CLI's paradigm (bash flags), adapted from the SAME schema the AI gets as
// a tool spec. Otherwise dispatch, params adapted procedurally.
Expand Down Expand Up @@ -508,6 +508,10 @@ async fn ensure_core_running(command: &str) -> Result<(), String> {
}
BindDecision::Free => {}
}
// A deploy in flight is the one state the bind guard above cannot see: mid-build no
// core answers and no core pid exists, which reads as "safe to start" and is exactly
// when starting is wrong.
deploy_gate(command)?;
if std::env::var("CONTINUUM_NO_AUTOSTART").is_ok_and(|v| v != "0") {
return Err(format!(
"no core is answering on {} and CONTINUUM_NO_AUTOSTART is set, so `{command}` \
Expand Down Expand Up @@ -551,7 +555,15 @@ async fn start(force: bool) -> Result<(), String> {
println!("core already running (socket={socket})");
return Ok(());
}
BindDecision::Free => {}
// Only the path that actually LAUNCHES consults the deploy claim. An already-serving
// core is a no-op and must stay one — gating a no-op would turn a mid-deploy
// `continuum start` into a spurious error about a core that is already fine.
// `--force` is the documented override, consistent with the Occupied arm below.
BindDecision::Free => {
if !force {
deploy_gate("start")?;
}
}
BindDecision::Occupied { pids } => {
let list = pids.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(",");
if !force {
Expand Down Expand Up @@ -683,6 +695,12 @@ async fn reboot(force: bool) -> Result<(), String> {
// script, and one decision belongs in exactly one place. `launch_core`'s
// `wait_for_death` on `old` is then trivially satisfied on Windows and
// still does the real work on Unix, where the overlapping build stands.
// Publish the claim for the WHOLE build+swap. Held until this function returns, so a
// concurrent `continuum <verb>` refuses instead of autostarting the pre-swap installed
// image and stealing the socket (the DEPLOY MISMATCH measured 2026-08-17).
let _deploy_claim = DeployClaimGuard::take(
git_head_short_sha().as_deref().unwrap_or("unknown"),
);
let secs = launch_core(&old, LaunchSource::FromSource).await?;
// Deploy-verification (#194): a new core is up — but is it the FRESHLY-BUILT one? If
// start-server.sh's build was a stale cache no-op or silently failed, an OLD binary would
Expand All @@ -693,7 +711,15 @@ async fn reboot(force: bool) -> Result<(), String> {
println!(
"core answering (socket={socket}) after ~{secs}s — verifying deploy provenance (#194)"
);
verify_deployed_build().await
// Did THIS reboot replace the installed CLI? Only when it went through the build script
// (a source tree exists — the same condition `plan_launch` uses to pick `Script` for a
// FromSource launch) AND the platform allows a self-build. Both terms matter: on an
// installed node with no checkout nothing was rebuilt, and on Windows `cli_self_build`
// deliberately skips. Getting this wrong in either direction re-creates the noise this
// flag exists to remove, or hides a genuinely stale CLI behind a reassuring handoff line.
let rebuilt_cli = locate_start_script().is_ok()
&& matches!(cli_self_build(std::env::consts::OS), CliSelfBuild::Rebuild);
verify_deployed_build(rebuilt_cli).await
}

/// Prove the running core is built from the source this deploy shipped — the honest half of
Expand All @@ -719,7 +745,12 @@ async fn reboot(force: bool) -> Result<(), String> {
/// describing the binary you are RUNNING rather than one found on disk.
const CLI_BUILD_SHA: &str = env!("CONTINUUM_BUILD_GIT_SHA");

async fn verify_deployed_build() -> Result<(), String> {
/// `rebuilt_cli` says whether THIS invocation replaced the installed CLI — true from
/// `reboot` (start-server.sh rebuilds + reinstalls it unless `cli_self_build` skips the
/// platform), false from a bare `deploy-verify`. It is what lets the CLI-provenance note
/// tell a HANDOFF ("the next run gets the new CLI") apart from real STALENESS, instead of
/// warning on every successful deploy.
async fn verify_deployed_build(rebuilt_cli: bool) -> Result<(), String> {
let socket = socket_path();
// The RUNNING core's provenance, from the process itself.
let reply = connection()
Expand All @@ -745,7 +776,7 @@ async fn verify_deployed_build() -> Result<(), String> {
let running_desc = describe_running_core(&socket);
// The CLI's own provenance rides alongside the core's, on BOTH outcomes: a stale CLI
// is relevant whether or not the core swap took.
let cli_note = cli_staleness_note(CLI_BUILD_SHA, &expected, &expected_source);
let cli_note = cli_staleness_note(CLI_BUILD_SHA, &expected, &expected_source, rebuilt_cli);
match deploy_verdict(
actual.as_deref(),
&expected,
Expand Down Expand Up @@ -844,6 +875,95 @@ fn core_artifact_candidates(home: &str, cargo_target_dir: Option<&str>) -> Vec<P
out
}

/// The continuum root (`~/.continuum`) — where the deploy claim lives.
fn continuum_root() -> Result<PathBuf, String> {
Ok(PathBuf::from(home_dir()?).join(".continuum"))
}

fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}

/// Consult the deploy claim before minting a core, and REPORT what it found.
///
/// Returns `Err` only while a deploy is genuinely in flight. See
/// [`continuum_core::runtime::deploy_claim`] for the incident: mid-build there is no core
/// answering AND the installed image is still the PREVIOUS build, so any autostart in that
/// window launches stale code, wins the socket, and defeats the deploy.
///
/// A claim whose owner is gone or that has aged out is swept and announced rather than
/// silently obeyed — a claim must never be able to wedge the machine (#355's failure mode).
fn deploy_gate(verb: &str) -> Result<(), String> {
use continuum_core::runtime::deploy_claim::{self, DeployGate};
let Ok(root) = continuum_root() else {
return Ok(()); // no HOME → no claim file → nothing to honour
};
let claim = deploy_claim::read(&root);
let alive = claim.as_ref().is_some_and(|c| pid_alive(c.pid));
match deploy_claim::decide(claim.as_ref(), alive, now_ms()) {
DeployGate::Clear => Ok(()),
DeployGate::Abandoned { pid, age_ms, why } => {
eprintln!(
"⚠ sweeping an abandoned deploy claim (pid {pid}, {}s old, {why:?}) — \
proceeding with `{verb}`",
age_ms / 1000
);
let _ = deploy_claim::clear(&root);
Ok(())
}
DeployGate::InProgress { pid, age_ms, target_sha } => Err(format!(
"a deploy is in flight (pid {pid} shipping build {target_sha}, {}s in) and no core \
is answering yet. Starting one now would launch the PRE-SWAP installed binary, \
which would then hold the socket and make the deploy report the OLD build — \
measured 2026-08-17. Wait for the deploy to finish; `{verb}` works the moment it \
does. (If that deploy is dead, its claim is swept automatically once its process \
exits.)",
age_ms / 1000
)),
}
}

/// RAII deploy claim: published for the length of a swap, released on EVERY exit path
/// (Ok, Err, `?`, panic-unwind). A claim that leaked past its deploy would block autostarts
/// until its owner died, so the release cannot be a line at the end of the happy path.
struct DeployClaimGuard {
root: PathBuf,
}

impl DeployClaimGuard {
/// Best-effort by design: if the claim cannot be written the deploy still proceeds —
/// losing the guard degrades to the old behaviour (which `deploy-verify` still catches),
/// whereas refusing to deploy over an unwritable advisory file turns a hint into an outage.
fn take(target_sha: &str) -> Option<Self> {
use continuum_core::runtime::deploy_claim::{self, DeployClaim};
let root = continuum_root().ok()?;
let claim = DeployClaim {
pid: std::process::id() as i32,
started_ms: now_ms(),
target_sha: target_sha.to_string(),
};
match deploy_claim::write(&root, &claim) {
Ok(()) => Some(Self { root }),
Err(e) => {
eprintln!(
"⚠ could not publish a deploy claim ({e}) — a concurrent command could \
autostart a stale core during this build; deploy-verify still catches it"
);
None
}
}
}
}

impl Drop for DeployClaimGuard {
fn drop(&mut self) {
let _ = continuum_core::runtime::deploy_claim::clear(&self.root);
}
}

/// The user's home dir — `HOME` (unix) or `USERPROFILE` (Windows). Loud when absent: the
/// resolution order depends on it, and guessing would defeat the shared contract.
fn home_dir() -> Result<String, String> {
Expand Down Expand Up @@ -1539,6 +1659,36 @@ async fn stop() -> Result<(), String> {
// holding a port or VRAM once `stop` returns.
reap_owned_orphans(&[]);

// Serving lanes are NOT descended from any core we just reaped (the daemon
// spawns them detached) and are NOT under `~/.continuum/bin`, so neither the
// tree kill nor the ownership sweep above can see them. Until this call
// existed, `stop` left every `llama-server` running and the registry was
// swept only on the NEXT boot — measured 2026-08-17 on the M5 as two lanes
// resident at once (a 19 GB ephemeral 27B beside the live 14B), which
// starved the planner into serving a 2,816-token window that cannot hold the
// tool surface. `reboot` could not clear it either: reboot is stop + start,
// and neither half owned lanes.
for outcome in continuum_core::inference::lane_registry::sweep_all() {
use continuum_core::inference::lane_registry::SweepOutcome as S;
match outcome {
S::ReapedLive { pid, port } => {
println!(" reaping serving lane (pid {pid}, port {port}) — live lane, this core is stopping")
}
S::ReapedEphemeral { pid, port } => {
println!(" reaping serving lane (pid {pid}, port {port}) — ephemeral lane, owner gone")
}
// A record whose pid is dead / recycled / unparseable is bookkeeping,
// not an event: garbage-collected silently so the loud lines above
// stay meaningful.
S::RemovedDead { .. } | S::RemovedReused { .. } | S::RemovedUnparseable { .. } => {}
// Unreachable under Shutdown (every role is reaped) — but matched
// explicitly so adding a mode can never silently fall through here.
S::LeftLive { pid } => {
println!(" WARNING: serving lane (pid {pid}) left running by a shutdown sweep — report this")
}
}
}

let _ = std::fs::remove_file(&socket);
Ok(())
}
Expand Down
23 changes: 21 additions & 2 deletions core/continuum-core/src/cognition/act_observe/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,31 @@ fn short_circuit_acts(calls: &[ToolCall], nudge: &str, status: ActStatus) -> Vec
pub struct ActChain(std::sync::Mutex<Option<Uuid>>);

impl ActChain {
/// A chain with no recorded antecedent — its first act links to nothing.
/// Prefer [`rooted_in`](Self::rooted_in): a chain that knows what caused it is
/// what makes "which acts were done FOR this card" answerable.
pub fn new() -> Self {
Self::default()
}

/// The chain's latest admitted act engram — the CAUSE of whatever act
/// comes next in this chain. `None` until the first admission.
/// A chain rooted in whatever CAUSED the turn (CAUSAL-MEMORY-GRAPH.md §3a) — the
/// stimulus engram for a real arrival, nothing for an ambient or synthetic burst.
///
/// Seeding rather than special-casing is the whole trick: the write site already
/// links each act to `prior()`, so rooting the chain makes the FIRST act link to
/// its trigger through the same line of code. No new branch, no second rule, and
/// the thread has a head instead of starting mid-air.
///
/// Takes the whole [`Cause`] rather than a pre-extracted id so the decision about
/// what counts as a root lives in ONE place (`Cause::root`) instead of at every
/// driver that builds a chain.
pub fn rooted_in(cause: &crate::cognition::workspace::Cause) -> Self {
Self(std::sync::Mutex::new(cause.root()))
}

/// The CAUSE of whatever act comes next in this chain: the latest admitted act
/// engram, or — before any act has run — the trigger the chain was rooted in.
/// `None` only when the chain has no antecedent at all.
pub fn prior(&self) -> Option<Uuid> {
*self.0.lock().unwrap_or_else(|p| p.into_inner())
}
Expand Down
63 changes: 63 additions & 0 deletions core/continuum-core/src/cognition/act_observe/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,69 @@ mod tests {
);
}

// what this catches: THE gap that made "which acts were done for this card"
// unanswerable. The chain used to start at `None`, so the FIRST act of every turn
// carried no CausedBy edge — and since a turn is triggered by a message or a
// work-card kickoff, that meant NO PATH IN THE GRAPH from a card to the work done
// for it. The card and its acts were causally disconnected, so every query showed
// "claimed, did nothing" no matter how much she actually did.
//
// Rooting the chain fixes it through the SAME write site — no new branch, no second
// rule — which is why this test asserts on the first act specifically.
#[tokio::test]
async fn a_chain_rooted_in_its_trigger_links_the_first_act_to_what_caused_the_turn() {
let exec = Arc::new(RecordingExecutor {
seen_context: Mutex::new(None),
result_content: "ok\n".into(),
});
let adm = admission();
let cycle = WorkspaceCycle::new(Vec::new(), Arc::new(SalienceArbiter), 8)
.with_acting(body(exec.clone(), adm.clone()));
let room = Uuid::new_v4();

// The kickoff / inbound message that caused this turn to happen at all.
let trigger = Uuid::new_v4();
let chain =
ActChain::rooted_in(&crate::cognition::workspace::Cause::Stimulus(trigger));

acts_of(apply_act(&cycle, &[tool_call()], "start", room, &chain).await);
let first = chain.prior().expect("first act admitted onto the chain");
assert_ne!(first, trigger, "the act is its own engram, not the trigger");

let edges = adm.engram_neighbors(&first);
assert!(
edges.iter().any(|e| e.target == trigger
&& e.kind == crate::persona::engram_graph::EdgeKind::CausedBy),
"the FIRST act must chain to the trigger that caused the turn — without \
this edge there is no path from a work card to the acts done for it; \
got {edges:?}"
);
}

// what this catches: an unrooted chain silently gaining a phantom antecedent. A
// burst with no admitted trigger — an idle tick (`Ambient`), an eval fixture
// (`Synthetic`) — must produce a first act with NO edge rather than one pointing
// at something invented. Honest absence over a fabricated link, which is also what
// makes the `engram.chain.rooted` probe's ambient rows mean something.
#[tokio::test]
async fn an_unrooted_chain_leaves_its_first_act_honestly_unlinked() {
let exec = Arc::new(RecordingExecutor {
seen_context: Mutex::new(None),
result_content: "ok\n".into(),
});
let adm = admission();
let cycle = WorkspaceCycle::new(Vec::new(), Arc::new(SalienceArbiter), 8)
.with_acting(body(exec.clone(), adm.clone()));
let chain = ActChain::rooted_in(&crate::cognition::workspace::Cause::Ambient);

acts_of(apply_act(&cycle, &[tool_call()], "start", Uuid::new_v4(), &chain).await);
let first = chain.prior().expect("first act admitted");
assert!(
adm.engram_neighbors(&first).is_empty(),
"no trigger means no edge — never a fabricated one"
);
}

/// Unwrap the typed acts of an `Acted` outcome (panics on NoHands/ExecutorError) —
/// the typed sibling of the old `.expect("acted")` on the `Option<String>`.
fn acts_of(outcome: ActOutcome) -> Vec<Observation> {
Expand Down
Loading
Loading