row 198 r3: real-run M13 pairs, reply join killer M16, real-board M14 (sage-2839)
M13 (stack-release.test.mjs) now runs for real, not just --plan, for an activate to a pre-198 copy and for a rollback. Each checks the plan drop line, then the run: exit 0, the drop line in the output (rollback: steps.log), and no --board in the unit or the pointer. M16 (serve.test.mjs) pins reply()'s event order ["scan","scan","send","bump", "scan"] and requires the next index() to start a fresh scan. Mutant: the counter bump at board.mjs:61. M14 (board-inprocess.test.mjs) is a real createBoard over a tmux shim that sleeps 1 s. The test measures the timer gap and /healthz elapsed time while /api/board is pending. The spawnSync mutant in scan.mjs stalls the loop. BUILD.md: M13 and M14 rows updated, M16 added, the M14 gap closed, N2 left open with the reason. UNVERIFIED: no node or tests run (Sage's gate; the test runs wait for CLEAR).
This commit is contained in:
1 parent
2a758a5bac
commit
fe3a3ec71e
4 files changed
+86
-28
No files matched your search
@@ -82,8 +82,8 @@ plan leaves state unchanged. The README rollback note covers any move to a pre-1
|
||||
answers 401 without the proxy header and 403 with a foreign Host, and the board never runs);
|
||||
M5 (`/api/seen` without `--send` writes no `seen.json`); M10 (positive control: with `--send` on
|
||||
the public name, `/api/seen` writes the mark and `/api/reply` runs exec once); M3 (a board read
|
||||
makes no request to a URL other than the Console's own); M14 (`/healthz` answers 200 within
|
||||
200 ms while `/api/board` is pending).
|
||||
makes no request to a URL other than the Console's own); M14 (a real board over a 1 s tmux shim:
|
||||
the loop never stalls and `/healthz` answers while `/api/board` is pending).
|
||||
- `packages/webui/tests/pinned-copy.test.mjs` (B3, M11): the existing "serve runs from a copy"
|
||||
test also asserts `/api/board` answers 200 with a `sessions` array from the copy.
|
||||
|
||||
@@ -121,19 +121,23 @@ Each mutant runs on a scratch copy with the new tests. Not run yet.
|
||||
| M10 | `/api/reply` and `/api/seen` branches move below the read-only refusal | `board-inprocess.test.mjs` positive control (200, exec once) |
|
||||
| M11 | `COPY_PATHS` drops `packages/control-board/src` or `tools/tmux` | `pinned-copy.test.mjs` "serve runs from a copy": `/api/board` 200 |
|
||||
| M12 | `console_for_target` keeps `--board` for a target that does not parse it | `stack-release.test.mjs` M12 |
|
||||
| M13 | a recorded `--board` is kept for a target that parses it (activate or rollback) | `stack-release.test.mjs` M13 (activate and rollback): drop line and no `--board` in the `Console:` line |
|
||||
| M14 | a board read blocks the loop (spawnSync or a blocking exec) | `board-inprocess.test.mjs` "healthz within 200 ms" catches only a pending read that blocks; see the gap below |
|
||||
| M13 | a recorded `--board` is kept for a target that parses it (activate or rollback) | `stack-release.test.mjs` M13 (activate and rollback): plan drop line, then a real run: drop line in the output (rollback: steps.log), no `--board` in the unit or pointer |
|
||||
| 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` |
|
||||
|
||||
### Gap: M14
|
||||
### M14: closed by the real-board test (Sage sage-2839, Webby N1)
|
||||
|
||||
The M14 test checks that `/healthz` answers while a board read is pending. It does not reliably
|
||||
catch a read that holds the event loop, because the test runs on the same loop. A synchronous
|
||||
`spawnSync` in the read path would pass it. A busy-wait stub in the test did not help, since the
|
||||
read and the health fetch share the thread. The real check is the mutant run on a scratch copy,
|
||||
where a `spawnSync` in `tmuxInspect` makes `/healthz` time out. Whether the mutant run detects it
|
||||
is open until the gate runs. The fix, if it does not, is an out-of-process check that starts the
|
||||
Console and times `/healthz` against a board that stalls in a child process.
|
||||
The earlier gap came from a stub board: a `spawnSync` in `scan.mjs` never ran under the test. The
|
||||
M14 test now builds a real `createBoard` whose tmux check runs through a shim on PATH. The shim is
|
||||
`#!/bin/sh`, `sleep 1`, `exit 1`. The test measures two things, both by elapsed time:
|
||||
- a 10 ms timer's largest gap while `/api/board` is pending (a blocked loop stalls the timer);
|
||||
- `/healthz` elapsed time, fetched after `/api/board` is sent.
|
||||
The bound is 500 ms for each. The async path (`runAsync`) keeps both near 10 ms. The M14 mutant
|
||||
(`tmuxInspectAsync` calls `spawnSync`) stalls the loop for the shim's full second, so both checks
|
||||
fail. The real-board test is not run yet (gate). Known soft spots, to check on the first run: the
|
||||
shim must win the PATH lookup, and the `specs` entry is minimal (no registration), which may not
|
||||
match a real spec.
|
||||
|
||||
## Checks done without running anything
|
||||
|
||||
@@ -168,6 +172,12 @@ Still in the README, on purpose: `packages/webui/README.md` line 826 runs the co
|
||||
- control-board, webui and cli suites; `scripts/test-*.sh`; the mutants M1-M15 on a scratch copy.
|
||||
All wait for Sage's CLEAR.
|
||||
- The per-target drop (sage-2834 ruling a, sage-2837) is implemented and documented; M13 has its
|
||||
expectation for activate and rollback.
|
||||
expectation for activate and rollback. Pushed at 2a758a5ba (sage-2852 gap, 17:20Z).
|
||||
- Webby webby-0152/0153 and Sage sage-2839, after 2a758a5ba: B1 and N3 met at 2a758a5ba. B2 (real
|
||||
activate and rollback runs, unit and pointer checked), B3 and M16 (reply() join), N1 and M14 (real
|
||||
board over a tmux shim) are written and uncommitted; the test runs wait for the CLEAR.
|
||||
- N2 is open, not changed: `boardSend`'s `/api/seen` catch answers 400 with `err.message` for a scan
|
||||
that fails after the mark is written. Webby marked it non-blocking; Sage has not ruled on it. A
|
||||
500 for that case would need `ConfigError` to tell refusals from scan failures.
|
||||
- M14: Sage accepted the gap as recorded (sage-2834). The mutant run is the check.
|
||||
- Push: not done. Sage's gaps govern it.
|
||||
@@ -871,22 +871,28 @@ test('row 198 item 8 (M12): a re-pin plan drops a --board the target Console doe
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
});
|
||||
|
||||
test('row 198 item 8 (M13): an activate to a pre-198 copy that parses --board drops the pointer\'s --board with the retired-board reason, and its install line has none',async(t)=>{
|
||||
test('row 198 item 8 (M13, activate): an activate to a pre-198 copy that parses --board drops the unit\'s --board, and the install and unit have none',async(t)=>{
|
||||
// Sage sage-2837: a target that parses --board is pre-198, and webui-service.sh install still refuses the flag.
|
||||
const e=await mk(t);await seed(e);setCli(t,C1,PARSES_BOARD);withBoard(e);
|
||||
// The unit carries the flag before, as a pre-198 Console does. The plan is checked first, then a real run.
|
||||
const e=await mk(t);await seed(e);setCli(t,C1,PARSES_BOARD);
|
||||
edit(join(e.units,CON),'--business acme',`--business acme ${BOARD}`);withBoard(e);
|
||||
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
|
||||
const r=await run(e,'activate',C1,'acme','--plan');
|
||||
let r=await run(e,'activate',C1,'acme','--plan');
|
||||
assert.equal(r.code,0,r.out+r.err);assert.equal(r.err,'');
|
||||
const lines=r.out.split('\n');
|
||||
assert.ok(lines.includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
|
||||
assert.doesNotMatch(lines.find((l)=>l.startsWith('Console: ')),/--board/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.ok(r.out.split('\n').includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
|
||||
assert.equal(pointer(e).copy,C1);assert.equal(pointer(e).console.includes('--board'),false);
|
||||
assert.doesNotMatch(consoleText(e),/--board/);
|
||||
});
|
||||
|
||||
test('row 198 item 8 (M13, rollback): a rollback drops the unit\'s --board even for a copy that parses it, and its install line has none',async(t)=>{
|
||||
// Sage sage-2834 ruling (a): webui-service.sh install refuses --board, so a rollback drops it and
|
||||
test('row 198 item 8 (M13, rollback): a rollback drops the unit\'s --board even for a copy that parses it, and the install and unit have none',async(t)=>{
|
||||
// Sage sage-2834 ruling (a), sage-2837: webui-service.sh install refuses --board, so a rollback drops it and
|
||||
// names the drop with the retired-board reason. The activation runs without --board; the unit then carries it,
|
||||
// and the rollback target B parses it, which must not keep the flag.
|
||||
// and the rollback target B parses it, which must not keep the flag. The plan is checked first, then a real run.
|
||||
const e=await mk(t);setCli(t,B,PARSES_BOARD);
|
||||
await seed(e);
|
||||
let r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
@@ -897,6 +903,10 @@ test('row 198 item 8 (M13, rollback): a rollback drops the unit\'s --board even
|
||||
assert.ok(lines.includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
|
||||
assert.doesNotMatch(lines.find((l)=>l.startsWith('Console: ')),/--board/);
|
||||
assert.deepEqual(fp(e),before);
|
||||
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.equal(pointer(e).console.includes('--board'),false);
|
||||
assert.doesNotMatch(consoleText(e),/--board/);
|
||||
assert.match(stepText(e),/drop --board http:\/\/127\.0\.0\.1:9; the 7331 board is retired/);
|
||||
});
|
||||
|
||||
// Row 181 (#1679, P19 390): COUNTED and STORE_JS as the script under test has them. The fixture
|
||||
|
||||
@@ -230,6 +230,23 @@ test("reply: runs agent-send.sh with -s from the registration, -S <host>:control
|
||||
}
|
||||
});
|
||||
|
||||
// M16 (Webby B3): reply() must bump the write counter after a send. Without the bump, an index()
|
||||
// started before the reply joins that pre-send scan and serves a view that predates the reply.
|
||||
// The event log pins the whole order, then the next index() must start a fresh scan.
|
||||
test("reply: a send bumps the counter, so the next index starts a fresh scan (M16)", async () => {
|
||||
const f = replyFixture();
|
||||
const events = [];
|
||||
const board = createBoard({ specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, agentSend: f.agentSend, now: () => new Date("2026-09-12T18:00:00Z"), onEvent: (name) => events.push(name) });
|
||||
await board.index();
|
||||
const { status, body } = await board.reply({ agent: "proj/agent1", text: "yes, go ahead" });
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.delivered, true);
|
||||
assert.deepEqual(events, ["scan", "scan", "send", "bump", "scan"]);
|
||||
const before = events.length;
|
||||
await board.index();
|
||||
assert.deepEqual(events.slice(before), ["scan"], "index() joined a scan that started before the reply");
|
||||
});
|
||||
|
||||
test("reply: a registration with a tmux socket adds -L <socket>", async () => {
|
||||
const f = replyFixture({ tmux: { socket: "mosaic-fleet", session: "agent1" } });
|
||||
const { status, body } = await replyBoard(f).reply({ agent: "proj/agent1", text: "hi" });
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
// reach the board under --send) and M14 (a pending board read never blocks /healthz).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { request } from 'node:http';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createBoard } from '../../control-board/src/board.mjs';
|
||||
import { seenPath } from '../../control-board/src/scan.mjs';
|
||||
import { close, fixture } from './fixture.mjs';
|
||||
|
||||
@@ -109,15 +111,34 @@ test('a board read makes no request to a board URL (M3)', async t => {
|
||||
assert.ok(urls.every(u => u.startsWith(base)), `a request left the Console: ${urls.join(', ')}`);
|
||||
});
|
||||
|
||||
// M14: /healthz answers within 200 ms while a board read is pending. This checks the async
|
||||
// path only. A read that blocks the loop (spawnSync) is not reliably caught in-process, since
|
||||
// the test shares the loop; BUILD.md records that gap, and the mutant run is the real check.
|
||||
test('healthz answers within 200 ms while a board read is pending (M14)', async t => {
|
||||
const web = await startServer({ port: 0, board: countingBoard([], () => new Promise(() => {})) });
|
||||
// M14: a board read never blocks the Console's loop. The board is a real createBoard, and its tmux
|
||||
// check runs through a shim on PATH that sleeps 1 s. A 10 ms timer records the longest stall of the
|
||||
// loop while /api/board is pending; a spawnSync in the read path stalls the loop for the shim's full
|
||||
// second. /healthz is timed by elapsed time from the same moment.
|
||||
test('a board read never blocks the loop: the timer gap and /healthz stay short while /api/board is pending (M14)', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-m14-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const shim = join(root, 'shim'), sessionsDir = join(root, 'sessions'), seatsDir = join(root, 'seats');
|
||||
for (const d of [shim, sessionsDir, seatsDir]) mkdirSync(d);
|
||||
writeFileSync(join(shim, 'tmux'), '#!/bin/sh\nsleep 1\nexit 1\n');
|
||||
chmodSync(join(shim, 'tmux'), 0o755);
|
||||
const PATH = process.env.PATH;
|
||||
process.env.PATH = `${shim}:${PATH}`;
|
||||
t.after(() => { process.env.PATH = PATH; });
|
||||
const board = createBoard({ specs: [{ agent: 'agent1', project: 'proj', sessionsDir, tmux: { socket: null, session: 'agent1' } }], boardDir: join(root, 'board'), seatsDir, discordDataRoot: root, host: 'm14' });
|
||||
const web = await startServer({ port: 0, board });
|
||||
t.after(() => stop(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
// stop() drops the pending socket, so this fetch rejects at teardown; its outcome is unused.
|
||||
fetch(base + '/api/board').catch(() => {});
|
||||
const health = await fetch(base + '/healthz', { signal: AbortSignal.timeout(200) });
|
||||
let last = Date.now(), worst = 0;
|
||||
const tick = setInterval(() => { const now = Date.now(); worst = Math.max(worst, now - last); last = now; }, 10);
|
||||
t.after(() => clearInterval(tick));
|
||||
const pending = fetch(base + '/api/board');
|
||||
const t0 = Date.now();
|
||||
const health = await fetch(base + '/healthz');
|
||||
const healthMs = Date.now() - t0;
|
||||
assert.equal(health.status, 200);
|
||||
assert.ok(healthMs < 500, `/healthz took ${healthMs} ms while the board read was pending`);
|
||||
assert.equal((await pending).status, 200);
|
||||
clearInterval(tick);
|
||||
assert.ok(worst < 500, `the loop stalled for ${worst} ms during the board read`);
|
||||
});
|
||||
Reference in new issue
Block a user