From 25ff7cd3f45cc079f104757bfeeec0d0a55f325a Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 22 Oct 2026 11:39:02 -0500 Subject: [PATCH] row 173 (#1671): a Seen click that waited out a Board read posts what its button said, read from data-intent before the wait; a fresh row that already holds it (another viewer's mark) posts nothing and announces 'already seen' or 'already waiting'; board-held test covers both; BUILD.md design and mutant plan; no runs yet (sage-2799) --- agents/dewey/work/queue-173/BUILD.md | 63 ++++++++++++++++++++++++ packages/webui/src/public/app.js | 8 ++- packages/webui/tests/board-held.test.mjs | 33 ++++++++++++- 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 agents/dewey/work/queue-173/BUILD.md diff --git a/agents/dewey/work/queue-173/BUILD.md b/agents/dewey/work/queue-173/BUILD.md new file mode 100644 index 000000000..f0b2756e7 --- /dev/null +++ b/agents/dewey/work/queue-173/BUILD.md @@ -0,0 +1,63 @@ +# Row 173 (#1671): a Seen click that waited out a Board read keeps the meaning it was clicked with + +Brief: docs/plans/2026-10-19_seen-click-intent.md (blob 225ac823b). Branch dewey/row173 off aca16404c. +Queue reviewer: Darkwing. Gate (queue row): Darkwing approves on #1671 naming the candidate manifest; webui and +every scripts/test-*.sh green on Sage's gate rerun. + +## Design + +- Intent. `seenButton` draws `data-intent="seen"` on a Seen button and `data-intent="unsee"` on an Unsee + button, from the same `r.seen` that picks the label. The click handler reads it into `mark` before + `writing = true` and before the wait, so the fresh row can't change it. I chose a `data-` value over reading the + label text so a later relabel can't change what a click posts. +- The fresh row already holds the intent (`r.seen === mark`). This happens when another viewer marked or unmarked + the row while the read was in flight. Nothing is posted, the button is re-enabled, and the page announces + ` already seen` or ` already waiting`. + - Why post nothing. The route takes a repeat without harm: `markSeen` with `seen:true` rewrites the same mark, + and `seen:false` deletes a key that is already gone (control-board/src/scan.mjs:255-256). So posting the same + value would also be correct. It would cost a write and a rescan, though, and the read that just finished has + already drawn the row. The brief allows either. + - An unseen row can still hold an old mark for an earlier lastActivity. Posting nothing on Unsee leaves that + mark, which is harmless because `scanAgent` only counts a mark equal to the row's lastActivity (scan.mjs:340). +- Otherwise the click posts `seen: mark` and announces from `mark`, not from the fresh row. +- Unchanged: the row 172 wait, the single-click guard (`writing`), the no-row branch, and the `/api/seen` route + (out of scope). +- `seen.disabled = false` in the new branch matches the no-row branch. After the read, `accept` redraws the + inspector and the cards, so `seen` is usually a detached button and the line has no visible effect. No test + can tell it apart; see M5. + +## Test + +packages/webui/tests/board-held.test.mjs gains one test: "a Seen or Unsee click that waited out a Board read +keeps its meaning: another viewer's mark in that read posts nothing". `setup` returns `boardURL: f.boardURL` so +the test can post to the board as the other viewer. +- Steps, for each of Seen and Unsee: + - it holds `/api/board` with `window.__hold` and clicks Refresh; + - while the read is held, it posts the other viewer's mark straight to the board; + - it clicks the inspector's button and releases the read. +- Checks: the announcement (`agent1 already seen`, then `agent1 already waiting`), `#seen-count` (1, then 0), and + that the page sent no `/api/seen` at all. +- The held fetch hasn't reached the board when the other viewer posts (the stub awaits the hold before calling + the real fetch), so the released answer carries the other viewer's mark. + +Hash pins and readers checked (git grep, with a positive control for 64-hex): nothing pins app.js. shell.test +counts existing app.js strings only. browser.test, sso-browser.test and the other board-held tests click +`[data-seen]` buttons the page draws, so they get `data-intent` with them. + +## Mutants (run on CLEAR only; each one edit to app.js, then board-held.test.mjs whole) + +| id | edit | expected kill | +|----|------|---------------| +| M1 | post `seen: !r.seen` and drop the `r.seen === mark` branch (the old toggle) | new test (a post, and "returned to waiting") | +| M2 | drop the `r.seen === mark` branch only (post the repeat) | new test (a post, and "marked seen") | +| M3 | `seenButton` swaps the intent (`r.seen ? 'seen' : 'unsee'`) | row 172 test (`#seen-count` never 1) and the new test | +| M4 | the new branch returns without `announce` | new test (announcement wait times out) | +| M5 | drop `seen.disabled = false` in the new branch | equivalent: the button is redrawn by `accept`; named, not run | + +Each run: `timeout -k 10 180 node --test --test-force-exit packages/webui/tests/board-held.test.mjs`; a kill +counts only as an assertion or a named wait failing in the named test. + +## Runs + +None yet. Gander holds the gate slot (row 253 run 5) from 16:32Z. The claim, the push and every run wait for +Sage's CLEAR. diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js index 9739c9a8f..2acb019e6 100644 --- a/packages/webui/src/public/app.js +++ b/packages/webui/src/public/app.js @@ -66,7 +66,7 @@ $('mode').onchange = e => { mode = e.target.value; theme(); }; function seenButton(r) { if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return ''; - return ``; + return ``; } const historyButton = r => ``; const openButton = r => ``; @@ -415,12 +415,16 @@ if (!seen || writing) return; // A Board read in flight (reading) holds busy. The click waits for that read and posts on the row it // drew; dropping it left the click doing nothing (row 172). A second click while one waits or posts is dropped. + // The click means what the button said when clicked (Seen marks, Unsee returns the row to waiting), read before + // the wait. If the fresh row already holds that, another viewer got there first: nothing is posted (row 173). + const mark = seen.dataset.intent === 'seen'; writing = true; seen.disabled = true; try { while (reading) await reading; const r = row(seen.dataset.seen); if (!r) { seen.disabled = false; return; } + if (r.seen === mark) { seen.disabled = false; announce(`${r.agent} already ${mark ? 'seen' : 'waiting'}`); return; } busy = true; - try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: !r.seen })); announce(`${r.agent} ${r.seen ? 'returned to waiting' : 'marked seen'}`); } + try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: mark })); announce(`${r.agent} ${mark ? 'marked seen' : 'returned to waiting'}`); } catch (err) { error(err); seen.disabled = false; } finally { busy = false; schedule(); } } finally { writing = false; } diff --git a/packages/webui/tests/board-held.test.mjs b/packages/webui/tests/board-held.test.mjs index 588024b3a..014b7aacc 100644 --- a/packages/webui/tests/board-held.test.mjs +++ b/packages/webui/tests/board-held.test.mjs @@ -82,7 +82,7 @@ async function setup(machine, fast = false) { 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, polls, settle, done }; + return { b, base, boardURL: f.boardURL, wait, text, shown, reqs, polls, settle, done }; } const HELD = 'document.querySelectorAll("#held table tbody tr").length === 2'; @@ -191,6 +191,37 @@ test('a Seen click while a Board read is in flight waits for that read, then pos } finally { await done(); } }); +// Row 173 (#1671): the click took its meaning from the fresh row, so another viewer's mark landing in the read +// it waited for turned "Seen" into an unsee. The click means what its button said; a fresh row that already +// holds that posts nothing and says so. The other viewer posts straight to the board while the read is held. +test('a Seen or Unsee click that waited out a Board read keeps its meaning: another viewer\'s mark in that read posts nothing', { timeout: 120000 }, async () => { + const { b, base, boardURL, wait, text, reqs, done } = await setup(false); + const posts = async () => (await reqs()).filter(r => r.path === '/api/seen').length; + const other = async seen => { + const r = (await (await fetch(`${boardURL}/api/board`)).json()).sessions.find(s => s.project === 'proj' && s.agent === 'agent1'); + const res = await fetch(`${boardURL}/api/seen`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ project: 'proj', agent: 'agent1', lastActivity: r.lastActivity, seen }) }); + assert.equal(res.status, 200); + }; + try { + await b.navigate(base); + await wait(`${SESSIONS} && ${HELD}`); + await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`); + await wait('document.querySelector("#inspection [data-seen]")'); + for (const [label, seen, count, said] of [['Seen', true, '1', 'agent1 already seen'], ['Unsee', false, '0', 'agent1 already waiting']]) { + assert.equal(await text('#inspection [data-seen]'), label); + await b.evaluate(`window.__hold = '/api/board', document.querySelector('#refresh').click(), true`); + await wait('window.__held.length === 1'); + // The held fetch has not reached the board yet, so its answer carries the other viewer's mark. + await other(seen); + await b.evaluate(`document.querySelector('#inspection [data-seen]').click(), true`); + await b.evaluate('window.__held.splice(0).forEach(r => r()), true'); + await wait(`document.querySelector("#announce").textContent === ${JSON.stringify(said)}`); + assert.equal(await text('#seen-count'), count); + assert.equal(await posts(), 0); + } + } finally { await done(); } +}); + 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 () => { // 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);