From 844f25659dfd99ee6678dab708e8200fd692b549 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 22 Oct 2026 12:30:03 -0500 Subject: [PATCH] row 198 r3: index() clears a settled scan; B4 tests and mutant M17 (webby-0157) index() kept the last scan in inflight and never cleared it, so after the first scan settled every index() returned that promise until a write bumped the counter. The Console's /api/board served the first view it built. A rejected scan stuck the same way. The clear now runs on both branches of the settled promise (then(clear, clear)), so a rejection is not left unhandled. board.test.mjs B4: two index() calls with no write log ["scan","scan"], and a session line appended between them shows in the second answer. A second case rejects the first scan and checks that the next index() runs fresh. BUILD.md: mutant M17 (drop the clear). M15 and M16 event pins were re-read against the change and still hold. UNVERIFIED: no node or tests run (Sage's gate; test runs wait for CLEAR). --- agents/bushroot/work/queue-198/BUILD.md | 1 + packages/control-board/src/board.mjs | 10 ++++-- packages/control-board/tests/board.test.mjs | 35 ++++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/agents/bushroot/work/queue-198/BUILD.md b/agents/bushroot/work/queue-198/BUILD.md index f8c66196f..5d10cd5ba 100644 --- a/agents/bushroot/work/queue-198/BUILD.md +++ b/agents/bushroot/work/queue-198/BUILD.md @@ -125,6 +125,7 @@ Each mutant runs on a scratch copy with the new tests. Not run yet. | M14 | a board read blocks the loop: `tmuxInspectAsync` in `scan.mjs` swapped for `tmuxInspect` (spawnSync) | `board-inprocess.test.mjs` M14: real `createBoard`, tmux shim on PATH sleeps 1 s; the timer gap while `/api/board` is pending and `/healthz` elapsed time | | M15 | `index()` joins a scan that started before the last write | `board.test.mjs` M15 (event log) | | M16 | `reply()` drops the counter bump: `writes++` at `board.mjs:61` (the event at line 62 is a second mutant, caught by the event log) | `serve.test.mjs` M16: event log `["scan","scan","send","bump","scan"]`, then the next `index()` must log `scan` | +| M17 | `index()` never clears a settled scan: the `promise.then(clear, clear)` line in `board.mjs` `index()` is dropped (Webby B4, r3) | `board.test.mjs` B4: two `index()` calls with no write log `["scan","scan"]` and the second shows a session line appended between them; `board.test.mjs` B4 rejected scan: the next `index()` runs fresh. M15 and M16 still pass after the clear, so they do not kill M17 | ### M14: closed by the real-board test (Sage sage-2839, Webby N1) diff --git a/packages/control-board/src/board.mjs b/packages/control-board/src/board.mjs index 4789060b4..38520361a 100644 --- a/packages/control-board/src/board.mjs +++ b/packages/control-board/src/board.mjs @@ -24,12 +24,18 @@ export function createBoard({ specs, boardDir, seatsDir = null, discordDataRoot }; const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) }); - // Joins a scan only when it started after the last write (see header). + // Joins a scan only when it started after the last write and is still running + // (see header). A settled scan, fulfilled or rejected, is cleared so the next + // index() starts a fresh one; the clear is on both branches, so a rejection is + // not left unhandled. function index() { if (inflight && inflight.generation === writes) return inflight.promise; const generation = writes; const promise = scanFresh(); - inflight = { generation, promise }; + const current = { generation, promise }; + inflight = current; + const clear = () => { if (inflight === current) inflight = null; }; + promise.then(clear, clear); return promise; } diff --git a/packages/control-board/tests/board.test.mjs b/packages/control-board/tests/board.test.mjs index 6dbc17e1e..4c812420e 100644 --- a/packages/control-board/tests/board.test.mjs +++ b/packages/control-board/tests/board.test.mjs @@ -1,6 +1,6 @@ import { test, after } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, appendFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; @@ -94,3 +94,36 @@ test("index after a write starts a fresh scan and sees the mark (M15)", async () assert.deepEqual(events.slice(before), ["write", "bump", "scan", "scan"]); assert.deepEqual(after.seen, ["proj/agent1"]); }); + +// B4 (Webby r3): a settled scan is not in flight. With no write between two index() +// calls, the second must start a fresh scan and show activity appended in between. +// The mutant: drop the clear on settle in index(); the second call then joins the +// settled promise, logs one scan, and shows the first view. +test("index after a settled scan starts a fresh scan and shows new activity (B4)", async () => { + const { boardDir, specs, isAlive } = fixture(); + const events = []; + const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) }); + await board.index(); + appendFileSync(join(specs[0].sessionsDir, "session.jsonl"), JSON.stringify({ type: "message", timestamp: "2026-10-20T19:05:00Z", message: { role: "user", content: [{ type: "text", text: "new activity" }] } }) + "\n"); + const second = await board.index(); + assert.deepEqual(events, ["scan", "scan"]); + assert.equal(second.sessions[0].lastActivity, "2026-10-20T19:05:00Z"); +}); + +// B4 (Webby r3): a scan that rejects is cleared too. The next index() runs fresh +// and answers, rather than replaying the rejection on every poll. +test("index after a rejected scan starts a fresh scan (B4)", async () => { + const { boardDir, specs, isAlive } = fixture(); + const events = []; + let fail = true; + const flaky = (tmux) => { + if (fail) throw new Error("boom"); + return isAlive(tmux); + }; + const board = createBoard({ specs, boardDir, isAlive: flaky, onEvent: (name) => events.push(name) }); + await assert.rejects(board.index(), /boom/); + fail = false; + const answer = await board.index(); + assert.deepEqual(events, ["scan", "scan"]); + assert.equal(answer.sessions[0].agent, "agent1"); +});