From 9691c4f7c5ada491f35cfa572ca87c83762eb09a Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Thu, 22 Oct 2026 17:51:20 +0200 Subject: [PATCH] test(ui-server): isolate maintenance fixtures and gate Pi scope progress --- docs/decisions/runtime-requirements.md | 2 +- docs/http-api-coverage.md | 8 +- packages/ui-server/tests/brain-client.test.ts | 8 ++ .../tests/fixtures/scope-pi-worker.ts | 21 +++- .../tests/helpers/private-maintenance-test.ts | 118 ++++++++++++++++++ .../tests/helpers/scope-pi-progress.ts | 39 ++++++ packages/ui-server/tests/sync-mirror.test.ts | 2 + .../ui-server/tests/ws-scope-caps.test.ts | 41 +++++- 8 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 packages/ui-server/tests/helpers/private-maintenance-test.ts create mode 100644 packages/ui-server/tests/helpers/scope-pi-progress.ts diff --git a/docs/decisions/runtime-requirements.md b/docs/decisions/runtime-requirements.md index 906a74002..d21be59df 100644 --- a/docs/decisions/runtime-requirements.md +++ b/docs/decisions/runtime-requirements.md @@ -194,7 +194,7 @@ Paths may appear in local diagnostics; public fixtures use temporary paths. | Requirement | Initial value | Evidence and limit of the claim | | --- | --- | --- | | Claude backend's SDK range | Retain `^0.3.241` | It is the published declaration (`"@anthropic-ai/claude-agent-sdk"`, `packages/ui-backend-claude/package.json:49`). Registry metadata on 2026-09-30 confirms 0.3.241 exists. This is a declared compatibility bound, not proof every allowed release passed current measurements. | -| ui-server's content CLI floor | Retain `0.33.0` | `--` support is the reason documented immediately above `MIN_BRAIN_CLI_VERSION` (`MIN_BRAIN_CLI_VERSION`, `packages/ui-server/src/brain/client.ts:100`); the initial boot refusal test used 0.32.9. The current test exercises the corrected floor (`a 0.40.0 brain repo pin`, `packages/ui-server/tests/brain-client.test.ts:243-255`). | +| ui-server's content CLI floor | Retain `0.33.0` | `--` support is the reason documented immediately above `MIN_BRAIN_CLI_VERSION` (`MIN_BRAIN_CLI_VERSION`, `packages/ui-server/src/brain/client.ts:100`); the initial boot refusal test used 0.32.9. The current test exercises the corrected floor (`a 0.40.0 brain repo pin`, `packages/ui-server/tests/brain-client.test.ts:244-256`). | | Bun engine floor | Retain existing `>=1.3.5` declarations | Existing engine metadata and the doctor's CVE-based warning (`const MIN_BUN`, `packages/core/src/cli/commands/doctor.ts:45`; `function checkRuntime`, `packages/core/src/cli/commands/doctor.ts:82-89`). This record introduces no new doctor verdict or blanket boot gate. | | Pi SDK requirements | Retain existing exact `0.87.1` declarations | `packages/ui-backend-pi/package.json`; a host floor composes with each actual imported Pi dependency's existing exact requirement. The Claude range ruling does not authorize changing Pi's pins. | | Additional default Claude runtime floor | None | There is no evidenced numerical incompatibility threshold in the existing record. The successful pairs are observations, not proof that the preceding release fails. A new library floor needs a feature requirement or reproducible incompatibility and its versioning review. | diff --git a/docs/http-api-coverage.md b/docs/http-api-coverage.md index 2b7d0e724..366622146 100644 --- a/docs/http-api-coverage.md +++ b/docs/http-api-coverage.md @@ -202,10 +202,10 @@ cover start refusal, mid-turn partial work, overlapping observations and leases. **B — Corpus transport and CLI errors.** -- `places flags before -- and a --prefixed search query after it` ([source](../packages/ui-server/tests/brain-client.test.ts)), (`places flags before -- and a --prefixed search query after it`, `packages/ui-server/tests/brain-client.test.ts:121-145`) -- `HTTP search deadlines return 504 and reap the stalled process` ([source](../packages/ui-server/tests/brain-client.test.ts)), (`HTTP search deadlines return 504 and reap the stalled process`, `packages/ui-server/tests/brain-client.test.ts:207-221`) -- `a stats embeddings count the CLI could not take reaches the HTTP body as null, not 0` ([source](../packages/ui-server/tests/brain-client.test.ts)), (`a stats embeddings count the CLI could not take reaches the HTTP body as null, not 0`, `packages/ui-server/tests/brain-client.test.ts:64-90`) -- “stats history runs `brain stats --history` and the route passes its nulls through” ([source](../packages/ui-server/tests/brain-client.test.ts)), (`stats history runs`, `packages/ui-server/tests/brain-client.test.ts:92-119`) +- `places flags before -- and a --prefixed search query after it` ([source](../packages/ui-server/tests/brain-client.test.ts)), (`places flags before -- and a --prefixed search query after it`, `packages/ui-server/tests/brain-client.test.ts:122-146`) +- `HTTP search deadlines return 504 and reap the stalled process` ([source](../packages/ui-server/tests/brain-client.test.ts)), (`HTTP search deadlines return 504 and reap the stalled process`, `packages/ui-server/tests/brain-client.test.ts:208-222`) +- `a stats embeddings count the CLI could not take reaches the HTTP body as null, not 0` ([source](../packages/ui-server/tests/brain-client.test.ts)), (`a stats embeddings count the CLI could not take reaches the HTTP body as null, not 0`, `packages/ui-server/tests/brain-client.test.ts:65-91`) +- “stats history runs `brain stats --history` and the route passes its nulls through” ([source](../packages/ui-server/tests/brain-client.test.ts)), (`stats history runs`, `packages/ui-server/tests/brain-client.test.ts:93-120`) - `mounted corpus handlers preserve CLI failures and sync terminates with an unsuccessful SSE frame` ([source](../packages/ui-server/tests/http-cli-errors.test.ts)), (`mounted corpus handlers preserve CLI failures and sync terminates with an unsuccessful SSE frame`, `packages/ui-server/tests/http-cli-errors.test.ts:7-37`) **C — Capture and index recovery.** diff --git a/packages/ui-server/tests/brain-client.test.ts b/packages/ui-server/tests/brain-client.test.ts index 3554b3ba7..dd546ba1a 100644 --- a/packages/ui-server/tests/brain-client.test.ts +++ b/packages/ui-server/tests/brain-client.test.ts @@ -24,6 +24,7 @@ import { createBrainRoutes } from "../src/routes/brain"; import { BRAIN_BIN, runCli } from "../../core/tests/cli-harness"; import { registeredVersionDeadline } from "../../ui-sdk/tests/helpers/registered-version-deadline"; +import { privateMaintenanceTest } from "./helpers/private-maintenance-test.js"; const temporaryRoots: string[] = []; @@ -241,6 +242,7 @@ describe("search cancellation and deadlines", () => { describe("brain CLI version probe", () => { test("a 0.40.0 brain repo pin refuses app creation and names 0.42.0", async () => { + if (await privateMaintenanceTest(import.meta.path, "a 0.40.0 brain repo pin refuses app creation and names 0.42.0")) return; const root = temporaryBrain(); installBrainCli(root, `console.log("0.40.0");\n`); const observability = createRecordingObservability(); @@ -253,6 +255,7 @@ describe("brain CLI version probe", () => { }); test("the first compatible CLI release boots", async () => { + if (await privateMaintenanceTest(import.meta.path, "the first compatible CLI release boots")) return; const root = temporaryBrain(); installBrainCli(root, `console.log("0.42.0");\n`); const app = await createApp({ config: appConfig(root), observability: createRecordingObservability(), registry: registry() }); @@ -260,6 +263,7 @@ describe("brain CLI version probe", () => { }); test("a malformed version string warns and still boots", async () => { + if (await privateMaintenanceTest(import.meta.path, "a malformed version string warns and still boots")) return; // "0.32.9-.." is not SemVer: the prerelease identifiers are empty. A loose // prerelease pattern would parse it as a real 0.32.9 prerelease and refuse // to boot; the contract is that anything unparseable warns and continues. @@ -280,6 +284,7 @@ describe("brain CLI version probe", () => { }); test("an unresolvable CLI warns and still boots", async () => { + if (await privateMaintenanceTest(import.meta.path, "an unresolvable CLI warns and still boots")) return; const root = temporaryBrain(); const observability = createRecordingObservability(); const app = await createApp({ @@ -464,6 +469,7 @@ describe("brain CLI client over the keyless Odysseus corpus", () => { */ describe("the exec wrapper covers the shared CLI client", () => { let previous: string | undefined; + beforeEach(() => { previous = process.env.BRAIN_UI_EXEC_WRAPPER; }); afterEach(() => { if (previous === undefined) delete process.env.BRAIN_UI_EXEC_WRAPPER; @@ -552,6 +558,7 @@ describe("the exec wrapper covers the shared CLI client", () => { }); test("an app spawns through its configured wrapper, not the ambient one (#1363)", async () => { + if (await privateMaintenanceTest(import.meta.path, "an app spawns through its configured wrapper, not the ambient one (#1363)")) return; // Two apps in one process must not share a privilege boundary: the // wrapper comes from the config handed to createApp, even while the // process environment names a different one. @@ -563,6 +570,7 @@ describe("the exec wrapper covers the shared CLI client", () => { }, 30_000); test("a config without exec keeps the ambient wrapper rather than spawning unwrapped", async () => { + if (await privateMaintenanceTest(import.meta.path, "a config without exec keeps the ambient wrapper rather than spawning unwrapped")) return; // A configuration built before `ServerConfig.exec` existed must not lose // its wrapper: createApp resolves the ambient one at the edge. const seen = await wrappersSeenByApp((root, wrapper) => { diff --git a/packages/ui-server/tests/fixtures/scope-pi-worker.ts b/packages/ui-server/tests/fixtures/scope-pi-worker.ts index 2e0321e51..0111475b0 100644 --- a/packages/ui-server/tests/fixtures/scope-pi-worker.ts +++ b/packages/ui-server/tests/fixtures/scope-pi-worker.ts @@ -2,6 +2,11 @@ import { createInterface } from "node:readline"; import { join } from "node:path"; let brain = "", sessionId = "", running = false; +// The test pipe acknowledges this checkpoint before delivering its backlog. +// Only the parent, after observation and accepted Continue, releases the rest. +const PAUSE_AFTER_CALL = Number(process.env.SCOPE_FIXTURE_CHECKPOINT ?? "2"); +let continueFixture!: () => void; +const continuation = new Promise(done => { continueFixture = done; }); const emit = (value: unknown) => process.stdout.write(JSON.stringify(value) + "\n"); createInterface({ input: process.stdin }).on("line", async line => { const message = JSON.parse(line); @@ -12,19 +17,23 @@ createInterface({ input: process.stdin }).on("line", async line => { } else if (message.type === "prompt" && !running) { running = true; const heartbeat = join(process.env.BRAIN_WORKER_SCRATCH!, "adapter-heartbeat"); - const shell = Bun.spawn(["/bin/sh", "-c", 'while :; do printf x >> "$1"; sleep 0.02; done', "heartbeat", heartbeat], { cwd: brain, env: { PATH: "/usr/bin:/bin" }, stdout: "ignore", stderr: "pipe" }); + const shell = Bun.spawn(["/bin/sh", "-c", 'while :; do printf x >> "$1"; sleep 0.02; done', "heartbeat", heartbeat], { cwd: brain, env: { PATH: "/usr/bin:/bin" }, stdout: "ignore", stderr: "inherit" }); void shell.exited; const deadline = performance.now() + 2_000; while (Bun.file(heartbeat).size < 2) { if (performance.now() > deadline) throw Error("SCOPE_ADAPTER_HEARTBEAT_DID_NOT_GROW"); await Bun.sleep(1); } - let call = 0; - const interval = setInterval(() => { - const toolCallId = `loom-${++call}`; + for (let call = 1; call <= 4; call++) { + const toolCallId = `loom-${call}`; emit({ type: "event", event: { type: "tool_execution_start", toolCallId, toolName: "bash", args: { command: "printf Ithaca" } } }); emit({ type: "event", event: { type: "tool_execution_end", toolCallId, toolName: "bash", result: { content: [{ type: "text", text: "Ithaca" }] }, isError: false } }); - if (call === 4) clearInterval(interval); - }, 100); + if (call === PAUSE_AFTER_CALL) { + emit({ type: "fixture_checkpoint", calls: call }); + await continuation; + } + } + } else if (message.type === "fixture_continue") { + emit({ type: "fixture_continued" }); continueFixture(); } else if (message.type === "abort") process.exit(0); }); diff --git a/packages/ui-server/tests/helpers/private-maintenance-test.ts b/packages/ui-server/tests/helpers/private-maintenance-test.ts new file mode 100644 index 000000000..a4d838ca2 --- /dev/null +++ b/packages/ui-server/tests/helpers/private-maintenance-test.ts @@ -0,0 +1,118 @@ +/** Re-execute a real test with an owned default maintenance ledger, preserving /tmp and the caller's UID. */ +import assert from "node:assert/strict"; +import { Database } from "bun:sqlite"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { invocationChild } from "../../../../scripts/test-invocation-child.js"; +import { reserveMaintenanceTemp } from "../../../ui-sdk/src/server/maintenance-temp-budget.js"; +import { ledgerLine } from "../../../ui-sdk/tests/maintenance-temp-ledger-protocol.js"; + +const ROOT = resolve(import.meta.dir, "../../../.."); +const ENV = "BRAIN_TEST_PRIVATE_MAINTENANCE"; +const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const LIMITS = { allowanceBytes: 268435456, budgetBytes: 268435456 }; +const defaultLedger = () => `/tmp/brain-maintenance-budget-${process.getuid!()}`; +const inode = (path: string) => { const info = statSync(path); return [info.dev, info.ino]; }; +const namespace = (ledger: string, ...command: string[]) => ["unshare", "--user", "--map-current-user", "--keep-caps", "--mount", "bash", + join(ROOT, "scripts/unit-worker-entry.sh"), ledger, ...command]; + +async function run(command: string[], timeoutMs: number, env = process.env) { + const child = invocationChild(command, { cwd: ROOT, env }); + const stdout = new Response(child.stdout).text(), stderr = new Response(child.stderr).text(); + const deadline = setTimeout(() => { void child.stop("SIGTERM"); }, timeoutMs); + try { + await child.ready; + const [code, out, err] = await Promise.all([child.exited, stdout, stderr]); + return { code, out, err }; + } finally { + clearTimeout(deadline); + await child.stop("SIGTERM"); await child.finish(); await Promise.allSettled([stdout, stderr]); + } +} + +/** Return true in the outer test; false only in its acknowledged private child. */ +export async function privateMaintenanceTest(file: string, name: string, timeoutMs = 30_000): Promise { + const selected = process.env[ENV] ? JSON.parse(process.env[ENV]!) as { file: string; name: string; ledger: string } : undefined; + if (selected?.file === file && selected.name === name) { + assert.deepEqual(inode(defaultLedger()), inode(selected.ledger), "MAINTENANCE_TEST_USES_ITS_OWN_LEDGER"); + return false; + } + const root = mkdtempSync("/var/tmp/odysseus-maintenance-test-"); + const ledger = join(root, "foreign"), privateLedger = join(root, "private"); + for (const path of [ledger, privateLedger]) mkdirSync(path, { mode: 0o700 }); + try { + const { code, out, err } = await run(namespace(ledger, process.execPath, "--preload", join(ROOT, "scripts/test-network-child-preload.ts"), import.meta.path, "--hold", file, name, privateLedger, String(timeoutMs)), timeoutMs - 500); + assert.equal(code, 0, `PRIVATE_MAINTENANCE_TEST_COMPLETES: ${name}\n${out}\n${err}`); + assert.match(err, /\b1 pass\b/, "PRIVATE_MAINTENANCE_CHILD_EXECUTED_THE_NAMED_TEST"); + return true; + } finally { + // A failed/aborted child must release its own reservations too. Retain + // evidence and fail cleanup rather than deleting any unconfirmed state. + for (const directory of [ledger, privateLedger]) { + const file = join(directory, "reservations.db"); + if (!existsSync(file)) continue; + const db = new Database(file, { readonly: true }); + try { assert.deepEqual(db.query("SELECT id FROM reservations").all(), [], `Unreleased owned maintenance state retained at ${root}`); } + finally { db.close(); } + } + rmSync(root, { recursive: true, force: true }); + } +} + +if (import.meta.main && process.argv[2] === "--reserve") { + const reservation = reserveMaintenanceTemp(LIMITS); + const input = Bun.stdin.stream().getReader(); + const stop = () => { void input.cancel().catch(() => {}); }; + const signals = ["SIGTERM", "SIGINT", "SIGHUP"] as const; + for (const signal of signals) process.on(signal, stop); + try { + console.log(JSON.stringify({ admitted: true, id: reservation.id, pid: process.pid })); + while (!(await input.read()).done) { /* Parent lifetime pipe; EOF or cancellation releases only this reservation. */ } + } finally { + try { await input.cancel(); input.releaseLock(); reservation.release(); } + finally { for (const signal of signals) process.off(signal, stop); } + } +} + +if (import.meta.main && process.argv[2] === "--hold") { + const [file, name, ledger, timeout] = process.argv.slice(3) as [string, string, string, string]; + const holder = Bun.spawn([process.execPath, import.meta.path, "--reserve"], { stdin: "pipe", stdout: "pipe", stderr: "pipe" }); + const stderr = new Response(holder.stderr).text(); + let db: Database | undefined; + try { + const receipt = JSON.parse(await ledgerLine(holder.stdout)) as { admitted: boolean; id: string; pid: number }; + assert.equal(receipt.admitted, true, "FOREIGN_MAINTENANCE_HOLDER_ACKNOWLEDGED"); + db = new Database(join(defaultLedger(), "reservations.db")); + const held = () => { + assert.equal(holder.exitCode, null, "FOREIGN_MAINTENANCE_HOLDER_STILL_LIVE"); + process.kill(receipt.pid, 0); + assert.deepEqual(db!.query("SELECT bytes,released FROM reservations WHERE id=?").get(receipt.id), { bytes: LIMITS.allowanceBytes, released: 0 }, "FOREIGN_MAINTENANCE_RESERVATION_STILL_HELD"); + }; + held(); + const parent = statSync(defaultLedger()); + console.log("OWNED_MAINTENANCE_HOLDER_ACK", JSON.stringify({ directory: defaultLedger(), uid: process.getuid!(), gid: process.getgid!(), groups: process.getgroups!(), + parent: { uid: parent.uid, gid: parent.gid, mode: parent.mode & 0o777 }, row: db.query("SELECT * FROM reservations WHERE id=?").get(receipt.id) })); + assert.throws(() => reserveMaintenanceTemp(LIMITS), { errorClass: "worker_state_busy" }, "SAME_DOMAIN_MAINTENANCE_STILL_REFUSES"); + assert.notDeepEqual(inode(defaultLedger()), inode(ledger), "FOREIGN_AND_TEST_LEDGERS_ARE_DISTINCT"); + // Invoke the normal test wrapper directly: Bun's run builtin closes the + // lease FD, and a nested user namespace cannot read its outer owner's FD. + const result = await run(namespace(ledger, process.execPath, join(ROOT, "scripts/test.ts"), "--timeout", timeout, file, "-t", `${escape(name)}$`), Number(timeout) - 1_000, + { ...process.env, [ENV]: JSON.stringify({ file, name, ledger }) }); + process.stdout.write(result.out); process.stderr.write(result.err); + held(); + console.log("OWNED_MAINTENANCE_HOLDER_STILL_RESERVED"); + assert.equal(result.code, 0, "ISOLATED_TEST_PASSES_WHILE_FOREIGN_RESERVATION_IS_LIVE"); + } finally { + holder.stdin.end(); + const deadline = setTimeout(() => holder.kill("SIGKILL"), 5_000); + try { + assert.equal(await holder.exited, 0, `FOREIGN_MAINTENANCE_HOLDER_RELEASED_AND_JOINED: ${await stderr}`); + if (db) assert.deepEqual(db.query("SELECT id FROM reservations").all(), [], "OWNED_MAINTENANCE_RESERVATIONS_DRAINED"); + console.log("OWNED_MAINTENANCE_HOLDER_RELEASED_AND_JOINED"); + } finally { + clearTimeout(deadline); + try { for await (const _chunk of holder.stdout) { /* Drain the receipt stream after its first acknowledged line. */ } await stderr; } + finally { db?.close(); } + } + } +} diff --git a/packages/ui-server/tests/helpers/scope-pi-progress.ts b/packages/ui-server/tests/helpers/scope-pi-progress.ts new file mode 100644 index 000000000..4662c9ac1 --- /dev/null +++ b/packages/ui-server/tests/helpers/scope-pi-progress.ts @@ -0,0 +1,39 @@ +/** Deliberately backlog fixture events until its explicit progress checkpoint. */ +import { Transform } from "node:stream"; +import type { launchAgentWorker } from "@schlessera/brain-ui-sdk/internal"; + +export function scopePiProgress(worker: ReturnType) { + let pending = "", checkpoint: number | undefined, released = false, continued = false; + const calls: string[] = [], backlog: string[] = []; + const stdout = new Transform({ transform(chunk, _encoding, done) { + try { + pending += chunk.toString(); + for (let end; (end = pending.indexOf("\n")) >= 0;) { + const line = pending.slice(0, end); pending = pending.slice(end + 1); + const message = JSON.parse(line); + if (message.type === "fixture_checkpoint") { + checkpoint = message.calls; + this.push(backlog.splice(0).join("")); + } else if (message.type === "fixture_continued") continued = true; + else { + if (message.type === "event" && message.event.type === "tool_execution_start") calls.push(message.event.toolCallId); + if (message.type === "event" && checkpoint === undefined) backlog.push(line + "\n"); + else this.push(line + "\n"); + } + } + done(); + } catch (error) { done(error as Error); } + } }); + worker.stdout.pipe(stdout); + return { + worker: { ...worker, stdout }, calls, + get checkpoint() { return checkpoint; }, + get continued() { return continued; }, + release() { + if (released || worker.stdin.destroyed) return; + released = true; + worker.stdin.write(JSON.stringify({ type: "fixture_continue" }) + "\n"); + }, + close() { worker.stdout.unpipe(stdout); stdout.destroy(); worker.stdout.destroy(); }, + }; +} diff --git a/packages/ui-server/tests/sync-mirror.test.ts b/packages/ui-server/tests/sync-mirror.test.ts index ea159a93f..8a2fc11ae 100644 --- a/packages/ui-server/tests/sync-mirror.test.ts +++ b/packages/ui-server/tests/sync-mirror.test.ts @@ -10,6 +10,7 @@ import { assertMirrorStorage, syncMirrorPath, syncTransportCommand } from "../sr import { createHostExecution } from "../src/brain/host-execution"; import { createUiDb } from "../src/db/client"; import { createNativeAppFixture } from "./helpers/native-app-fixture.js"; +import { privateMaintenanceTest } from "./helpers/private-maintenance-test.js"; const ROOT = resolve(import.meta.dir, "../../.."); const TOKEN = "fixture-sync-token-never-for-the-brain"; @@ -403,6 +404,7 @@ for (const output of ["stdout", "stderr"] as const) { } test("an in-memory host uses its private temporary mirror for a real sync", async () => { + if (await privateMaintenanceTest(import.meta.path, "an in-memory host uses its private temporary mirror for a real sync", 120_000)) return; const f = fixture(); // This host has no operational state directory to mask. The configured // host credential-file proof belongs to the persistent-database test. diff --git a/packages/ui-server/tests/ws-scope-caps.test.ts b/packages/ui-server/tests/ws-scope-caps.test.ts index b7afd7786..dd2e77cfb 100644 --- a/packages/ui-server/tests/ws-scope-caps.test.ts +++ b/packages/ui-server/tests/ws-scope-caps.test.ts @@ -15,25 +15,30 @@ import { handleChatMessage } from "../src/ws/run-session.js"; import { createWsHandlers } from "../src/ws/connection.js"; import { createPrincipal } from "../src/db/principals.js"; import { createFixtureBrain } from "../../../scripts/captures/core-fixture.js"; +import { scopePiProgress } from "./helpers/scope-pi-progress.js"; import type { ServerMessage, ScopeCapReceipt } from "@schlessera/brain-ui-sdk/protocol"; async function wait(check: () => boolean, detail: () => unknown) { const end = Date.now() + 8000; while (!check()) { if (Date.now() > end) throw Error(JSON.stringify(detail())); await Bun.sleep(10); } } -for (const adapter of ["claude", "pi"] as const) for (const voice of [false, true]) test(`${adapter}: ${voice ? (adapter === "pi" ? "voice admission refuses before a worker" : "true voice stops without a grant") : "dictation pauses, grants once, refuses a second grant and Stops normally"}`, async () => { +const cases = (["claude", "pi"] as const).flatMap(adapter => [false, true].map(voice => ({ adapter, voice, overshoot: false }))); +cases.push({ adapter: "pi", voice: false, overshoot: true }); +for (const { adapter, voice, overshoot } of cases) test(`${adapter}: ${overshoot ? "backlogged four-call overshoot is Stop-only" : voice ? (adapter === "pi" ? "voice admission refuses before a worker" : "true voice stops without a grant") : "dictation pauses, grants once, refuses a second grant and Stops normally"}`, async () => { const root = mkdtempSync(join(tmpdir(), "odysseus-scope-adapter-")), brain = join(root, "brain"), config = join(root, "agent"), home = join(root, "home"); await createFixtureBrain(resolve(import.meta.dir, "../../.."), brain); mkdirSync(config); mkdirSync(home); writeFileSync(join(config, "settings.json"), "{}"); mkdirSync(join(brain, "context/policies"), { recursive: true }); writeFileSync(join(brain, "context/policies/governance.md"), "---\nschema_version: 1\nscope: { max_tool_calls: 2 }\n---\nAthena bounds the voyage.\n"); let spawn: ReturnType | undefined; let heartbeatPath: string | undefined; + let piProgress: ReturnType | undefined; const heartbeatSize = () => heartbeatPath ? Bun.file(heartbeatPath).size : 0; const originalLaunch = workers.launchAgentWorker; const priorPi = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = config; spawn = spyOn(workers, "launchAgentWorker").mockImplementation(input => { const worker = originalLaunch(adapter === "pi" ? { ...input, - command: [process.execPath, "--no-env-file", join(import.meta.dir, "fixtures/scope-pi-worker.ts")], env: { PATH: "/usr/bin:/bin" } } : input); + command: [process.execPath, "--no-env-file", join(import.meta.dir, "fixtures/scope-pi-worker.ts")], env: { PATH: "/usr/bin:/bin", ...(overshoot ? { SCOPE_FIXTURE_CHECKPOINT: "4" } : {}) } } : input); // Read the actual worker's private writable tmpfs through its retained host handle. expect(worker.turnTemp, "SCOPE_ADAPTER_NATIVE_TURN_TEMP_PRESENT").toBeDefined(); heartbeatPath = join(worker.turnTemp!.root, relative("/tmp", workers.WORKER_SCRATCH), "adapter-heartbeat"); + if (adapter === "pi") { piProgress = scopePiProgress(worker); return piProgress.worker; } return worker; }); const backend = adapter === "pi" ? createPiBackend({ brainPath: brain, sessionDir: join(root, "sessions"), loadExtensions: false }) @@ -111,7 +116,15 @@ for (const adapter of ["claude", "pi"] as const) for (const voice of [false, tru await wait(paused, () => frames); const first = latest()!, receipt = first.journal.scope!.receipts.at(-1)!; expect(first.journal.budget, "COMBINED_SCOPE_PAUSE_RETAINS_BUDGET_FACTS").toBeDefined(); - expect(receipt, "SCOPE_ADAPTER_PAUSES_WITH_EXACT_FINITE_CEILING").toMatchObject({ dimension: "tool_calls", cap: 2, count: 2, newCap: 4, frozen: true, state: "paused" }); + if (overshoot) { + expect(receipt, "PI_BACKLOGGED_OVERSHOOT_REMAINS_STOP_ONLY").toMatchObject({ dimension: "tool_calls", cap: 2, count: 4, overshoot: 2, frozen: true, state: "paused", reason: "The finite continuation ceiling of 4 tool calls is already exhausted. Stop only." }); + expect(receipt.newCap, "PI_EXHAUSTED_CEILING_HAS_NO_FINITE_GRANT").toBeUndefined(); + expect(piProgress!.checkpoint, "PI_OVERSHOOT_CONTROL_DELIVERS_FOUR_BUFFERED_CALLS").toBe(4); + } else expect(receipt, "SCOPE_ADAPTER_PAUSES_WITH_EXACT_FINITE_CEILING").toMatchObject({ dimension: "tool_calls", cap: 2, count: 2, newCap: 4, frozen: true, state: "paused" }); + if (adapter === "pi" && !overshoot) { + expect(piProgress!.checkpoint, "PI_BACKLOG_CHECKPOINT_AFTER_EXACTLY_TWO_CALLS").toBe(2); + expect(piProgress!.calls, "PI_REMAINING_CALLS_HELD_BEFORE_PAUSE").toEqual(["loom-1", "loom-2"]); + } if (adapter === "pi") await wait(() => observationHeld, () => frames); expect(runningHeartbeat, "SCOPE_ADAPTER_HEARTBEAT_GROWS_WHILE_RUNNING").toBeGreaterThan(1); const heartbeat = heartbeatSize(); @@ -123,6 +136,17 @@ for (const adapter of ["claude", "pi"] as const) for (const voice of [false, tru await wait(() => frames.some(frame => frame.type === "scope_decision_receipt" && frame.submissionId === "wrong-session"), () => frames); expect(frames.find(frame => frame.type === "scope_decision_receipt" && frame.submissionId === "wrong-session")).toMatchObject({ state: "refused" }); const decisions = (submissionId: string) => frames.filter((frame): frame is Extract => frame.type === "scope_decision_receipt" && frame.submissionId === submissionId); + if (overshoot) { + releaseObservation(); await app.wsHost.journal!.idle(); + send(decision(receipt, "continue", "exhausted-grant")); + await wait(() => decisions("exhausted-grant").length === 1, () => frames); + expect(decisions("exhausted-grant")[0], "PI_OVERSHOOT_CONTINUE_REFUSED").toMatchObject({ state: "refused" }); + send(decision(receipt, "stop", "exhausted-stop")); + await wait(() => !app!.wsHost.coordinator.isTurnActive(), () => frames); + expect(latest()!.journal.scope!.receipts.at(-1), "PI_OVERSHOOT_NATIVE_STOP").toMatchObject({ state: "stopped", count: 4 }); + expect(frames.some(frame => frame.type === "error"), "PI_OVERSHOOT_STOP_NORMAL_NOT_FAILURE").toBe(false); + return; + } if (adapter === "pi") { expect(app.wsHost.journal!.isHeld(), "SCOPE_ADAPTER_REAL_OBSERVATION_COMMIT_HELD").toBe(true); send(decision(receipt, "continue", "athena-held")); @@ -138,6 +162,11 @@ for (const adapter of ["claude", "pi"] as const) for (const voice of [false, tru send(decision(receipt, "continue", "athena-grant")); send(decision(receipt, "continue", "athena-grant")); await wait(() => decisions("athena-grant").length === 2, () => frames); expect(decisions("athena-grant").map(frame => frame.state), "SCOPE_ADAPTER_FIRST_GRANT_AND_DUPLICATE_ACCEPTED").toEqual(["accepted", "accepted"]); + if (adapter === "pi") { + expect(piProgress!.calls, "PI_REMAINING_CALLS_HELD_UNTIL_OBSERVATION_AND_ACCEPTED_CONTINUE").toEqual(["loom-1", "loom-2"]); + piProgress!.release(); + await wait(() => piProgress!.continued, () => frames); + } await wait(() => latest()?.journal.scope!.receipts.length === 2, () => frames); const second = latest()!.journal.scope!.receipts.at(-1)!; expect(second).toMatchObject({ cap: 4, count: 4, frozen: true, state: "paused" }); expect(second.newCap).toBeUndefined(); @@ -157,7 +186,11 @@ for (const adapter of ["claude", "pi"] as const) for (const voice of [false, tru expect(block.eventIds).toHaveLength(2); } finally { releaseObservation(); + piProgress?.release(); try { await app?.close(); } - finally { captureSpy?.mockRestore(); spawn?.mockRestore(); if (priorPi === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = priorPi; rmSync(root, { recursive: true, force: true }); } + finally { + try { if (piProgress) { await piProgress.worker.exited; piProgress.close(); } } + finally { captureSpy?.mockRestore(); spawn?.mockRestore(); if (priorPi === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = priorPi; rmSync(root, { recursive: true, force: true }); } + } } }, 30_000);