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).
This commit is contained in:
1 parent
fe3a3ec71e
commit
844f25659d
3 files changed
+43
-3
No files matched your search
@@ -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 |
|
| 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) |
|
| 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` |
|
| 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)
|
### M14: closed by the real-board test (Sage sage-2839, Webby N1)
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,18 @@ export function createBoard({ specs, boardDir, seatsDir = null, discordDataRoot
|
|||||||
};
|
};
|
||||||
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
|
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() {
|
function index() {
|
||||||
if (inflight && inflight.generation === writes) return inflight.promise;
|
if (inflight && inflight.generation === writes) return inflight.promise;
|
||||||
const generation = writes;
|
const generation = writes;
|
||||||
const promise = scanFresh();
|
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;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, after } from "node:test";
|
import { test, after } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
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 { tmpdir } from "node:os";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
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(events.slice(before), ["write", "bump", "scan", "scan"]);
|
||||||
assert.deepEqual(after.seen, ["proj/agent1"]);
|
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");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user