researcher: row 97 round 2 build packet (#1591)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
2026-10-13 07:40:06 -05:00
co-authored by Claude Sonnet 5.5
parent 8eda2ac50a
commit 5aa38c3a25
7 changed files with 336 additions and 0 deletions
@@ -0,0 +1,57 @@
# Row 97 (#1591) round 2: the board timer gets a positive control
Owner: Researcher. Reviewers: Darkwing and Filbert. Round 1 change request: Darkwing, comment 28083 (rev 865).
Base 6b0215aa (rebased from 3bd3f85c). Nothing under `packages/webui` changed between the two, so
the three candidate paths carry over; `browser-edge.test.mjs` is byte-identical to round 1.
`build.patch` is `git diff 6b0215aa` of the three paths (sha256
efe65504a1b890ae575b0d49ae3c00f09c9f229897ba61d2cac44e1e1b56bf19). `candidate-manifest.sha256` has
repo-relative paths and verifies against the candidate worktree
`/mnt/storage/scratch/researcher/q97` (checked after the runs). No source under `src/` changes.
## The finding
The stub filed a 10 s timer as 'board' only when `f.name === 'refresh'`. Test 1's three
`boardPolls() === 0` checks had no positive control, so a board timer armed through an arrow
callback (the shape bus.js uses) was filed as 'bus' and the checks passed.
## Both fixes, as Darkwing allowed
1. **Classification.** The stub files a timer by the script that armed it: `new Error().stack`
matched against `/app.js`, else 'bus'. The callback's shape no longer matters.
2. **Positive control.** Test 1 waits for `window.__polls.includes('board')` once the toggle is on,
so the zero checks sit between a proven armed timer and a proven absent one.
The README sentence says the timers are filed by the script that armed them and that one is armed
once the toggle is on.
## Mutants (`mutants.py`, outputs `mut-base.txt`, `mut-candidate.txt`)
Baseline = 6b0215aa unmodified (round 1's tests are not the baseline here; Darkwing's 13a was alive
on them).
| mutant | baseline | candidate |
|---|---|---|
| 13 schedule polls with the toggle off | ALIVE | killed |
| 13a Darkwing: 13 with `setTimeout(() => refresh(), 10000)` | ALIVE | killed |
| 13b the arrow callback alone | ALIVE | ALIVE (equivalent: same behaviour) |
| 27 bus schedule polls signed out | ALIVE | killed |
| 12 refresh reads with the toggle off | killed | killed |
| 26 bus render without its signed-out guard | killed | killed |
| 19 toggle on reads nothing | STALLED (30 s) | killed (5.8 s) |
| 14 refresh without its signed-out guard | ALIVE | ALIVE (equivalent: `api()` already returns when signed out) |
Candidate 6 of 8, the two survivors equivalent; baseline 2 of 8. Sources restored byte for byte
(sha256 checked at the end of both runs).
## Suites
- webui `node --test tests/`: 101 pass, 0 fail, on the real `app.js` and `bus.js` (`suites.txt`).
- Not rerun: control-board and the `scripts/test-*.sh` suites. Only webui test files and its README
changed, and nothing outside `packages/webui` moved between 3bd3f85c and 6b0215aa on these paths.
Round 1's results for those stand; Sage's gate covers a rerun.
- After the runs no process had a cwd under my scratch (/proc check, 0 found).
## Secrets scan
`build.patch` and `mutants.py`: no token, secret, password, api key or private key marker.
@@ -0,0 +1,177 @@
diff --git a/packages/webui/README.md b/packages/webui/README.md
index 6656f1ff..ce56d59f 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -566,11 +566,19 @@ claim that has an address and one that has none. They check the held roles
with the machine sessions off by default and no `/api/board` request, the
toggle on and off and its persistence across reloads, Message addressing
the send form to each role, no action without send, and that every fetch
-asked for `redirect: 'manual'`. Then a redirect answer on a board read, an
-`agents` read and a send: one line, no NetworkError, nothing stale left, no
-further request on a route change, Refresh or the toggle, and the draft
-kept. `send-browser.test.mjs` also checks the sign-out after its redirect
-receipt and that a reload works again. Since the held roles lead the Board,
+asked for `redirect: 'manual'`. The tests record every 10 s poll timer the
+page arms, filed by the script that armed it, and assert that none is armed
+for the board while the toggle is off, even across Pause and Resume, and that
+one is armed once it is on. Then a redirect answer on each of six
+requests, the board read (`/api/board`), the `agents` read, `/api/conversations`,
+`/api/seen`, `/api/queue` and the send: one line, no NetworkError, nothing
+stale left, and no further request or new poll timer on a route change,
+Refresh, the toggle, a forced Pause click or a poll tick, and the draft kept. In
+that test the poll timers fire after 150 ms, so the check spans several
+ticks. `browser-edge.test.mjs` fails in 5 s, and closes the browser and
+servers, if the toggle makes no `/api/board` request. `send-browser.test.mjs`
+also checks the sign-out after its redirect receipt and that a reload works
+again. Since the held roles lead the Board,
the shell test's no-scroll check at 1440px uses a 1000px-tall window.
Bus tests (`bus.test.mjs`, `bus-browser.test.mjs`) run the routes and the
diff --git a/packages/webui/tests/board-held.test.mjs b/packages/webui/tests/board-held.test.mjs
index 19b875a8..6815a4bf 100644
--- a/packages/webui/tests/board-held.test.mjs
+++ b/packages/webui/tests/board-held.test.mjs
@@ -5,7 +5,11 @@
// through its reader session. A script added before the page loads records every fetch the page
// makes, answers /api/config with send on (unless the URL has ?nosend), answers POST
// /api/bus/message from a queue, and answers any path in window.__redirect the way fetch does
-// under redirect: 'manual' (type opaqueredirect, status 0).
+// under redirect: 'manual' (type opaqueredirect, status 0). It also records every 10 s poll timer
+// the page arms (window.__polls, 'board' for app.js and 'bus' for bus.js). The second test has the
+// timers fire after 150 ms, so its "no further request" check spans real poll ticks, and both
+// tests assert on the timers themselves: a guard that only the fetch behind it backs up would
+// otherwise change nothing the page sends.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
@@ -29,9 +33,17 @@ const businesses = {
},
};
-const STUB = `(() => {
+const stub = fast => `(() => {
const real = window.fetch.bind(window);
- window.__reqs = []; window.__answers = []; window.__redirect = [];
+ window.__reqs = []; window.__answers = []; window.__redirect = []; window.__polls = [];
+ // app.js and bus.js both poll with a 10000 delay. A timer is filed by the script that armed it, read
+ // from the call stack, not from the shape of the callback, which either script may change.
+ const timeout = window.setTimeout.bind(window);
+ window.setTimeout = (f, ms, ...rest) => {
+ if (ms !== 10000) return timeout(f, ms, ...rest);
+ window.__polls.push(/\\/app\\.js[:?]/.test(new Error().stack) ? 'board' : 'bus');
+ return timeout(f, ${fast} ? 150 : ms, ...rest);
+ };
const redirect = () => ({ type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } });
window.fetch = async (url, init = {}) => {
const u = String(url), path = u.split('?')[0];
@@ -49,7 +61,7 @@ const STUB = `(() => {
};
})();`;
-async function setup(machine) {
+async function setup(machine, fast = false) {
const root = mkdtempSync(join(tmpdir(), 'webui-board-held-'));
const store = new Store(root), b0 = new Broker({ store, businesses });
// pm's run has an address; coder's claim has none, which the Board still lists.
@@ -59,22 +71,24 @@ async function setup(machine) {
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser({ machine });
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
- await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
+ await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub(fast) });
await b.viewport(1280);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`);
const reqs = () => b.evaluate('window.__reqs');
+ const polls = () => b.evaluate('window.__polls');
const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
- return { b, base, wait, text, shown, reqs, settle, done };
+ return { b, base, wait, text, shown, reqs, polls, settle, done };
}
const HELD = 'document.querySelectorAll("#held table tbody tr").length === 2';
const SESSIONS = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")';
test('the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role', { timeout: 120000 }, async () => {
- const { b, base, wait, text, shown, reqs, settle, done } = await setup(false);
+ const { b, base, wait, text, shown, reqs, polls, settle, done } = await setup(false);
+ const boardPolls = async () => (await polls()).filter(p => p === 'board').length;
try {
// Off by default: the held roles show, the machine scan is never read.
await b.navigate(base);
@@ -95,11 +109,19 @@ test('the Board: held roles with no machine sessions, a claim with no address, t
await settle();
assert.equal((await reqs()).some(r => r.path === '/api/board'), false);
assert.equal(await b.evaluate(`!!${SESSIONS}`), false);
+ // The board is not polled either: no timer for it, even after Pause and Resume, which re-arm one.
+ assert.equal(await boardPolls(), 0);
+ assert.ok((await polls()).includes('bus'), 'the bus views still poll');
+ await b.evaluate('document.querySelector("#pause").click(), document.querySelector("#pause").click(), true');
+ await settle();
+ assert.equal(await boardPolls(), 0);
// On: the machine sessions are read and shown, and the choice is kept for the next load.
await b.evaluate('document.querySelector("#show-machine").click(), true');
await wait(SESSIONS);
assert.equal(await shown('#board-view'), true);
+ // Positive control for the zero checks above and below: with the toggle on, the board does arm its timer.
+ await wait("window.__polls.includes('board')");
assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-board")'), '{"machine":true}');
await b.navigate(`${base}?on`);
await wait(SESSIONS);
@@ -117,6 +139,7 @@ test('the Board: held roles with no machine sessions, a claim with no address, t
assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), false);
await settle();
assert.equal((await reqs()).some(r => r.path === '/api/board'), false);
+ assert.equal(await boardPolls(), 0);
// Message opens the send form addressed to that role, with the caret in the message.
for (const role of ['coder', 'pm']) {
@@ -138,7 +161,8 @@ test('the Board: held roles with no machine sessions, a claim with no address, t
});
test('a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads', { timeout: 120000 }, async () => {
- const { b, base, wait, text, shown, reqs, settle, done } = await setup(true);
+ // The poll timers fire after 150 ms here, so the 600 ms settle below spans several ticks.
+ const { b, base, wait, text, shown, reqs, polls, settle, done } = await setup(true, true);
const LINE = 'Signed out. Reload the page to sign in.';
const out = async () => {
await wait('!document.querySelector("#signed-out").hidden');
@@ -153,12 +177,17 @@ test('a redirect answer on a Board read, a bus read, a conversation read, a boar
assert.equal(await b.evaluate('document.querySelector("#held").children.length'), 0);
for (const sel of ['#held-section', '#board-view', '#board-head', '#error']) assert.equal(await shown(sel), false, sel);
assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true);
- // No further Console fetch: not on a route change, a Refresh, the toggle or the poll.
- const n = (await reqs()).length;
+ // No further Console fetch and no new poll timer: not on a route change, a Refresh, the toggle
+ // or a poll tick (a timer armed before the sign-out may still fire, and finds nothing to read).
+ const n = (await reqs()).length, armed = (await polls()).length;
await b.evaluate(`location.hash = location.hash === '#/agents' ? '#/inbox' : '#/agents', true`);
await b.evaluate('document.querySelector("#refresh").click(), document.querySelector("#show-machine").click(), document.querySelector("#show-machine").click(), true');
+ // Pause is disabled when signed out. Enabled by force for a pause and a resume, its handlers must still
+ // arm nothing: the guard in each schedule() is the only thing between the resume and a timer.
+ await b.evaluate('(() => { const p = document.querySelector("#pause"); p.disabled = false; p.click(); p.click(); p.disabled = true; return true; })()');
await settle();
assert.equal((await reqs()).length, n);
+ assert.equal((await polls()).length, armed);
assert.equal(await b.evaluate('!!document.querySelector("#bus-view h1")'), false);
assert.deepEqual((await reqs()).filter(r => r.redirect !== 'manual'), []);
};
diff --git a/packages/webui/tests/browser-edge.test.mjs b/packages/webui/tests/browser-edge.test.mjs
index 446c9cc2..efb261ff 100644
--- a/packages/webui/tests/browser-edge.test.mjs
+++ b/packages/webui/tests/browser-edge.test.mjs
@@ -26,7 +26,10 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Machine sessions hidden/);
await b.evaluate('document.querySelector("#show-machine").click()');
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Loading/);
- await boardRequested;
+ // Fails in 5 s if the toggle never reads the board; the finally below then closes the browser and servers.
+ let timer;
+ try { await Promise.race([boardRequested, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Show machine sessions made no /api/board request in 5 s')), 5000); })]); }
+ finally { clearTimeout(timer); }
holdBoard = false; boardResponse.end(JSON.stringify(index));
await wait('document.querySelector("#sessions").textContent.includes("No sessions")');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
@@ -0,0 +1,3 @@
01213bffd915afa42dc0c531c8605364e19466686701ddadad514346bbf5851a packages/webui/README.md
32d01fbac94fff0fed337aa47182f78a623eaf3ad5845a880a3dccfbe1c70586 packages/webui/tests/board-held.test.mjs
fc3cc8fa7388a6bae669bbaf60c21947f803620ff140af7e33d798a326716fc4 packages/webui/tests/browser-edge.test.mjs
@@ -0,0 +1,11 @@
worktree /mnt/storage/scratch/researcher/q97-base
start 2026-10-13T12:36:46Z
13 schedule polls with the toggle off: ALIVE (exit 0, 8.7s, board-held.test.mjs browser-edge.test.mjs)
13a Darkwing: schedule polls with the toggle off, arrow callback: ALIVE (exit 0, 8.0s, board-held.test.mjs browser-edge.test.mjs)
13b arrow callback alone (equivalent): ALIVE (exit 0, 8.1s, board-held.test.mjs browser-edge.test.mjs)
27 bus schedule polls signed out: ALIVE (exit 0, 8.1s, board-held.test.mjs browser-edge.test.mjs)
12 refresh reads with the toggle off: killed (exit 1, 6.9s, board-held.test.mjs browser-edge.test.mjs)
14 refresh without its signed-out guard: ALIVE (exit 0, 8.0s, board-held.test.mjs browser-edge.test.mjs)
26 bus render without its signed-out guard: killed (exit 1, 4.2s, board-held.test.mjs browser-edge.test.mjs)
19 toggle on reads nothing: STALLED (exit timeout, 30.0s, browser-edge.test.mjs)
killed 2 of 8; sources restored byte for byte: True
@@ -0,0 +1,11 @@
worktree /mnt/storage/scratch/researcher/q97
start 2026-10-13T12:35:43Z
13 schedule polls with the toggle off: killed (exit 1, 7.6s, board-held.test.mjs browser-edge.test.mjs)
13a Darkwing: schedule polls with the toggle off, arrow callback: killed (exit 1, 7.4s, board-held.test.mjs browser-edge.test.mjs)
13b arrow callback alone (equivalent): ALIVE (exit 0, 8.7s, board-held.test.mjs browser-edge.test.mjs)
27 bus schedule polls signed out: killed (exit 1, 4.8s, board-held.test.mjs browser-edge.test.mjs)
12 refresh reads with the toggle off: killed (exit 1, 6.9s, board-held.test.mjs browser-edge.test.mjs)
14 refresh without its signed-out guard: ALIVE (exit 0, 8.6s, board-held.test.mjs browser-edge.test.mjs)
26 bus render without its signed-out guard: killed (exit 1, 4.9s, board-held.test.mjs browser-edge.test.mjs)
19 toggle on reads nothing: killed (exit 1, 5.8s, browser-edge.test.mjs)
killed 6 of 8; sources restored byte for byte: True
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
# Row 97 (#1591) round 2 mutants: round 1's list plus Darkwing's (13 with the arrow callback, comment 28083)
# and the arrow shape alone, an equivalent mutant. Each edit must match exactly once. Every run starts in its own process
# group and the group is killed when the run ends or times out, so no node or chromium is left
# behind. The mutated file is restored from the bytes read before the edit, and every touched
# file's sha256 is checked against its start at the end.
# Usage: mutants.py <worktree> <out.txt> [timeout-seconds]
import hashlib, os, signal, subprocess, sys, time
WT, OUT = sys.argv[1], sys.argv[2]
TIMEOUT = int(sys.argv[3]) if len(sys.argv) > 3 else 120
A = 'packages/webui/src/public/app.js'
B = 'packages/webui/src/public/bus.js'
BOTH = ['tests/board-held.test.mjs', 'tests/browser-edge.test.mjs']
EDGE = ['tests/browser-edge.test.mjs']
# (name, file, old, new, tests, per-run timeout)
M = [
('13 schedule polls with the toggle off', A, 'if (!paused && machine && !out()) timer', 'if (!paused && !out()) timer', BOTH, TIMEOUT),
('13a Darkwing: schedule polls with the toggle off, arrow callback', A, 'if (!paused && machine && !out()) timer = setTimeout(refresh, 10000)', 'if (!paused && !out()) timer = setTimeout(() => refresh(), 10000)', BOTH, TIMEOUT),
('13b arrow callback alone (equivalent)', A, 'timer = setTimeout(refresh, 10000)', 'timer = setTimeout(() => refresh(), 10000)', BOTH, TIMEOUT),
('27 bus schedule polls signed out', B, 'if (!paused() && !out()) timer', 'if (!paused()) timer', BOTH, TIMEOUT),
('12 refresh reads with the toggle off', A, 'if (busy || !machine || out()) return;', 'if (busy || out()) return;', BOTH, TIMEOUT),
('14 refresh without its signed-out guard', A, 'if (busy || !machine || out()) return;', 'if (busy || !machine) return;', BOTH, TIMEOUT),
('26 bus render without its signed-out guard', B, " clearTimeout(timer);\n if (out()) return;\n", " clearTimeout(timer);\n", BOTH, TIMEOUT),
('19 toggle on reads nothing', A, "fresh(); refresh(); } else hideMachine();", "fresh(); } else hideMachine();", EDGE, 30),
]
env = dict(os.environ, TMPDIR=os.environ.get('TMPDIR', '/mnt/storage/scratch/tmp'))
sha = lambda p: hashlib.sha256(open(os.path.join(WT, p), 'rb').read()).hexdigest()
start = {p: sha(p) for p in (A, B)}
def run(tests, limit):
t0 = time.time()
p = subprocess.Popen(['node', '--test', *tests], cwd=os.path.join(WT, 'packages/webui'), env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True, text=True)
try:
out, _ = p.communicate(timeout=limit)
code = p.returncode
except subprocess.TimeoutExpired:
code, out = 'timeout', ''
finally:
try: os.killpg(p.pid, signal.SIGKILL)
except ProcessLookupError: pass
try: p.communicate(timeout=5)
except Exception: pass
return code, round(time.time() - t0, 1)
with open(OUT, 'w') as f:
def say(s): print(s); f.write(s + '\n'); f.flush()
say(f'worktree {WT}')
say(f'start {time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}')
killed = 0
for name, path, old, new, tests, limit in M:
full = os.path.join(WT, path)
orig = open(full, 'rb').read()
src = orig.decode()
if src.count(old) != 1:
say(f'{name}: NO MATCH ({src.count(old)}), skipped'); continue
try:
open(full, 'w').write(src.replace(old, new))
code, secs = run(tests, limit)
finally:
open(full, 'wb').write(orig)
# A timeout is a stall, not a kill: the file never exited by itself.
dead = code not in (0, 'timeout')
killed += dead
verdict = 'killed' if dead else 'STALLED' if code == 'timeout' else 'ALIVE'
say(f'{name}: {verdict} (exit {code}, {secs}s, {" ".join(t.split("/")[-1] for t in tests)})')
ok = all(sha(p) == start[p] for p in (A, B))
say(f'killed {killed} of {len(M)}; sources restored byte for byte: {ok}')
sys.exit(0 if ok else 1)
@@ -0,0 +1,5 @@
== webui 12:34:42Z
ℹ tests 101
ℹ pass 101
ℹ fail 0
exit 0