6.5 KiB
Row 172: a Seen click during a Board read waits for the read, and the row 165 test stops racing its reload
Sage, 2026-10-19. Author and gate owner Sage. Reviewer Darkwing. Brief
docs/plans/2026-10-19_board-held-signout-flake.md at blob e07d6e9d (commit 164e1f97f). Issue #1670,
Vikunja P19 366, ruling mos-0858.
Base 164e1f97f. Candidate 15c3d8ca1, one commit, built in the detached worktree
/mnt/storage/scratch/sage-r172/cand. The commit is in the canonical object store, so git show 15c3d8ca1
works from the canonical checkout. candidate.diff is that output. candidate-manifest.sha256 lists the
three changed paths.
Cause 1: the page dropped a Seen click during a Board read
The brief named board-held.test.mjs:220 as the module-read step. It isn't. :220 is the out() after the
Seen click at :219, so every failure so far stopped at the board POST step: row 170 gate runs 1 and 3, row 134
gate run 1, and the traced run below.
app.js at the base had if (!seen || busy) return; in the [data-seen] click handler. refresh() holds
busy for the whole /api/board read and the conversation poll after it. The sign-out test's stub fires the
10 s poll after 150 ms, so a read is in flight for much of the test, and under load each read lasts longer.
A click inside one returned without a word: no /api/seen, so no redirect answer, so no sign-out line.
The trace, trace-fail-seen.json, came from an instrumented copy of the test in
/mnt/storage/scratch/sage-r172/wt (base 04155deff, the same app.js), run in a loop under the suite's own
load until it failed (loop run 3). It records every fetch's path, start and end, and the sign-out event. It
doesn't record the click: the click time comes from the page clock minus the wait. The wait gave up at
5191 ms with the page clock at 5792 ms, so the click was at about 595 ms. A /api/board read ran from 580 to
615 ms. No /api/seen fetch and no sign-out event appear anywhere.
This is a product defect. On the live Board a Seen click during the 10 s poll's read does nothing, and a slow machine scan makes that window wider.
Change 1 (packages/webui/src/public/app.js)
refresh()keeps the read in flight asreading, a promise cleared in its own.finally.- The Seen handler returns only when
writingis set. Otherwise it setswriting, disables the button, waitswhile (reading), looks the row up again from the fresh data and posts.busycovers the POST as before. A second click while one waits or posts is dropped, so one person's click never turns into two toggles. api()already throws signedOut once the page is signed out, so the handler needs no extra check.- The wait helper's 100 x 50 ms budget is unchanged.
Test 1 (packages/webui/tests/board-held.test.mjs)
The stub gains window.__hold: the next fetch of that path waits until the test calls the resolvers in
window.__held. The new test, placed before the sign-out test:
- Opens agent1 in the inspector, holds
/api/board, clicks Refresh, waits until the read is held. - Clicks Seen. After 200 ms no
/api/seenhas gone out. - Holds
/api/seenand releases the read. The click's POST goes out and is held. - Clicks Seen again while the POST is held, waits 200 ms, releases.
- The Seen count is 1, and exactly one
/api/seenand no other Board read follow the held read.
On the base app.js the committed test fails at :185, window.__held.length === 1: the click is dropped, so
no POST is held (Darkwing's review run, #1670 comment 29207). /mnt/storage/scratch/sage-r172/base-control.log
is from an earlier draft of the test, before the hold on /api/seen, and fails at that draft's :183
#seen-count === "1" wait. On the candidate it passed 3 of 3 on its own.
Cause 2: the row 165 test killed its own wait with a reload
Run 2 of the first 10-run loop on the candidate failed in instances-browser.test.mjs:17 with
{"code":-32000,"message":"Inspected target navigated or closed"}. The test took 7635 ms against 7099 ms in
run 1, so it died at the end. The last step called location.reload() from an evaluate, then waited. The
evaluate returns before the reload commits. Under load the wait's evaluate starts on the old document and the
reload destroys its context. With the reload delayed 300 ms the test fails the same way every time
(/mnt/storage/scratch/sage-r172/reload-probe-delayed.log).
It blocks this row's acceptance (10 webui runs in a row) and lives in the same package's tests, so it's fixed here. Row 165 is done and nobody else holds the file.
Change 2 (packages/webui/tests/instances-browser.test.mjs)
The step calls b.navigate with the same page and &read=2 in the query. A query change is a cross-document
load, and navigate waits for Page.navigate and then readyState === "complete" before the wait starts.
The assertion after it is unchanged. Passed 3 of 3 on its own.
Mutants (on board-held.test.mjs, mutants.py, logs in /mnt/storage/scratch/sage-r172/mutants/)
| Mutant | Change | Result |
|---|---|---|
| M1 | drop while (reading) await reading; |
killed, the new test fails |
| M2 | if (!seen || writing || busy) return;, the old drop |
killed, __held.length === 1 times out |
| M3 | if (!seen) return;, no writing guard |
killed, the second click posts again |
| M4 | reading never cleared |
killed, 2 fails, the sign-out test hangs and the timeout catches it |
app.js was restored byte for byte afterwards and the orphan sweep found nothing.
Acceptance loop
cand-loop.sh runs node --test tests/*.test.mjs in the candidate's webui 10 times, stopping on the first
failure. Attempt 1 (logs in cand-loop-attempt1/) failed on run 2 with cause 2. Attempt 2 ran after the fix,
from run 1: 10 of 10 green, 239 pass, 0 fail, 1 skipped each run (cand-loop-summary.txt, cand-loop/).
The files it ran match candidate-manifest.sha256.
Gate
/mnt/storage/scratch/sage-r172/gate.sh from the template, on the candidate worktree at 15c3d8ca1, dirty 0,
manifest checked first. GATE_PASSED (gate-run1.txt, logs in out/): 15 package suites and 10 script suites,
webui 239 pass 0 fail. The compose network was removed and no container from the gate is left.
For the reviewer
candidate-manifest.sha256againstgit show 15c3d8ca1.- The cause against
trace-fail-seen.json: the click time, the/api/boardread around it, no/api/seen. - That a Seen click can't post twice, and that
readingalways clears (M3, M4). - That the row 165 step's new query doesn't change what its last assertion checks.