researcher: row 97 round 3 packet, confirm-only rebase onto cd976b09 (#1591)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
2026-10-13 08:15:43 -05:00
co-authored by Claude Sonnet 5.5
parent e94bb289cf
commit 8812bf831d
4 changed files with 238 additions and 0 deletions
@@ -0,0 +1 @@
cd976b09aca47f6506a1a8c27361fa35933a172b
@@ -0,0 +1,57 @@
# Row 97 (#1591) round 3: confirm-only rebase of the approved round 2
Owner: Researcher. Round 2 was approved by Darkwing and Filbert on manifest f5a11097 (base 6b0215aa).
Row 94 (2b2a9e14) then landed on `packages/webui/README.md`, so that file's hash no longer matches
any landing commit. This round changes no test, README sentence or mutant; it re-bases the same
change onto cd976b09 (origin/refactor, `base.txt`).
## What moved
6b0215aa..cd976b09 under `packages/webui`:
- `README.md` +28/-2 (row 94): hunks at lines 276, 466 and 633 onward. My README hunk (lines 566 and
following in `build.patch` of round 2) is untouched, but its start line moved from 566 to 567.
- Also changed, none of them mine: `src/public/bus.js` (+38, a Messages view and a `messages` read),
`src/public/bus.css`, `src/serve.mjs`, and tests `bus-browser`, `messages`, `shell`, `views`.
- `board-held.test.mjs` and `browser-edge.test.mjs` are byte-identical on both bases.
## Identical-hunk evidence
In a fresh worktree at cd976b09, `git apply --3way agents/researcher/work/queue-97/r2/build.patch`
applied all three files cleanly. The `+` and `-` lines of the two patches (file headers excluded)
are 72 lines each and `cmp` reports them identical. The only difference between `r2/build.patch` and
`r3/build.patch` is one hunk header: `@@ -566,11 +566,19 @@` became `@@ -567,11 +567,19 @@`, plus
the context that follows. The manifest differs from round 2 in one line:
| path | round 2 | round 3 |
|---|---|---|
| `packages/webui/README.md` | 01213bff... | ed0a1fe1... |
| `packages/webui/tests/board-held.test.mjs` | 32d01fba... | 32d01fba... (same) |
| `packages/webui/tests/browser-edge.test.mjs` | fc3cc8fa... | fc3cc8fa... (same) |
No test or mutant changed, and none was run (per Sage).
## Could an outcome change on the new base? Yes, for the browser tests
`bus.js` is the file two of the mutants edit and the page the board-held test drives. Row 94's
change makes `render()` read `/api/bus/messages` before every view except Messages (swallowing a
non-sign-out error) and adds `messages` to the Board's `Promise.allSettled` reads. Two things this
could touch in my tests, neither checked by a run:
1. `board-held.test.mjs` test 2 counts requests after sign-out and asserts none follow. Reading
`bus.js`, `render()` returns on `out()` at its first statement (line 696), before any of the
new `messages` reads, so I expect no change. I have not run it.
2. Request totals in both tests are compared with the count taken just before, not with a constant,
so extra reads in a render do not matter as long as none follow sign-out.
What I did check, without running anything: each of the eight mutant anchors in `r2/mutants.py`
(including `bus.js` mutants 26 and 27) still matches exactly once in `app.js` and `bus.js` at
cd976b09. So the mutant runner works unchanged on this base.
Suggested gate if Sage schedules one: the webui suite, then `r2/mutants.py` against the r3 worktree
for 27 and 26 (the two on `bus.js`). Sage's landing gate reruns webui in any case.
## Packet
`base.txt`, `build.patch` (`git diff cd976b09` of the three paths), `candidate-manifest.sha256`
(repo-relative paths, verifies against `/mnt/storage/scratch/researcher/q97-r3`). No secret markers.
@@ -0,0 +1,177 @@
diff --git a/packages/webui/README.md b/packages/webui/README.md
index 69ed45d2a..07caedf3a 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -567,11 +567,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 19b875a8e..6815a4bf2 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 446c9cc2e..efb261ff4 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 @@
ed0a1fe1e7b73e6e7d5df19014373eee50c384f8f4386d30edd2db2cf678b9aa packages/webui/README.md
32d01fbac94fff0fed337aa47182f78a623eaf3ad5845a880a3dccfbe1c70586 packages/webui/tests/board-held.test.mjs
fc3cc8fa7388a6bae669bbaf60c21947f803620ff140af7e33d798a326716fc4 packages/webui/tests/browser-edge.test.mjs