fix(webui): fence in-flight request misses on an observed state (#797)

A mosaic-request-state for an id now advances that id's own generation on every thread retaining it, so a 404 still in flight from before the observation cannot record the miss afterwards. Generations are pruned with the misses to retained rows. Adds a held-404 regression that fails on 16384bb12.
This commit is contained in:
jason.woltje committed 2026-11-06 11:15:15 -06:00
1 parent 16384bb120
commit 9d3eb9e115
4 files changed
+69 -7

No files matched your search

+1
View File
@@ -6317,3 +6317,4 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
2026-11-06 UTC | #797 round A task evidence and request polling | Before: advance_request omitted missing per-task push/review chains, the coder continuation seeded the first task regardless of evidence, and missing request IDs were polled repeatedly. After: the broker names all incomplete task chains and supplies the same read-only result to continuation selection; scoped harness serialization exposes the refusal with the existing 300-character bound; thread-page readers suppress confirmed missing IDs until a new caught-up thread message. The owner authorized the narrow harness socket addition. Exact-base controls: five behavioral failures in the initial control and one in the bounded harness control. Node 26 serial named-file gate: 142 pass, 0 fail, 0 skip. An intermediate rerun was deliberately stopped after the owner clarified the bound; its log is retained. Queue refused this linked worktree. Local candidate for independent review; no push or deployment. Report and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-report.md and /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-evidence/.
2026-11-06 UTC | #797 round A fix r2 (Sol r1 P2 + two P3) | Before (97d670da9): a remembered request-not-found was retried only when the newest message on a caught-up thread changed, so a request opened with no new message stayed hidden; misses grew with paged history; an aborted read got a fabricated 404. After: misses are also cleared when the owner opens the thread (navigated) and when a mosaic-request-state for the id is seen anywhere on the page; timer refreshes and repaints still never retry. Misses are pruned to ids in the retained rows (200-row cap). An already-aborted read bypasses the memory and keeps the native AbortError. Remaining limit: the thread page reads only messages, so an event-only open with the thread left open and no navigation shows at the next message, navigation or reload. Closing that needs a server-visible thread signal, outside this change. New tests fail on 97d670da9 chat.js (2/2); webui chat-sessions-access, chat and browser files 40/0/0 (Node 26 serial). Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-r2-evidence/.
2026-11-06 UTC | #797 round A fix r3 (Sol r2 P2) | Before (16384bb12): a mosaic-request-state for an id advanced the thread's epoch only when a remembered miss was deleted, so a 404 still in flight from before the observation could record the miss afterwards and timer refreshes kept fabricating 404s for an open request. After: each observed state advances that id's own generation on every thread that retains it; an in-flight 404 records a miss only if both the thread epoch and the id generation are unchanged. Other ids' slow misses still settle. Generations are pruned to retained rows with the misses. New held-404 test fails on 16384bb12 chat.js at recovery; webui chat-sessions-access, chat and browser files 41/0/0 (Node 26 serial). Lead ruling: no third review round. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-r3-evidence/.
+1
View File
@@ -2106,3 +2106,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | files-polish r2 on 86383a9d1 for Sol r1 F1/F2 | Per-key commit order fences live paints and caches the newest completed result; reviewer probes ported; r1 negative 11/2, w15-files 13/13, caller browser gate 93/0/0/0. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-r2-evidence/.
2026-11-06 UTC | Codex implementation sub-agent | #797 roundA-fix on 6b84fe136 | Named per-task push/review gaps, shared the check with coder continuation and stopped missing-request thread polling; owner authorized bounded harness socket reason forwarding. Exact-base behavioral negatives and Node 26 six-file gate 142/0/0/0 retained in /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-evidence/; local candidate for independent review, no push or deployment. Queue refused linked worktree.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | roundA-fix r2 on 97d670da9 for Sol r1 P2/P3 | Missing-request memory now also clears on thread open and on an observed request state, is pruned to retained rows, and keeps AbortError; event-only open without navigation documented as a remaining limit. Negative 0/2 on 97d670da9, webui gate 40/0/0. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-r2-evidence/.
2026-11-06 UTC | mvp3-lead-797 (Claude, #797 lane) | roundA-fix r3 on 16384bb12 for Sol r2 P2 | A request state observed before an in-flight 404 settles now fences that miss via a per-id generation. Negative fails on 16384bb12, webui gate 41/0/0. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/roundA-fix-r3-evidence/.
+14 -7
View File
@@ -205,23 +205,28 @@
const c = current, s = path && (!init?.method || init.method === 'GET') && c?.kind === 'thread' && !out() ? state(c.key) : null;
// An already-aborted read keeps the native AbortError, so it bypasses the remembered miss.
if (!s || init?.signal?.aborted) return fetchPage(url, init);
const id = path[1], epoch = s.requestEpoch ?? 0;
const id = path[1], epoch = s.requestEpoch ?? 0, seen = s.requestSeen?.get(id) ?? 0;
if (s.missingRequests?.has(id)) return new Response(JSON.stringify({ error: 'request-not-found' }), { status: 404, headers: { 'content-type': 'application/json' } });
const response = await fetchPage(url, init);
if (response.status === 404) {
const body = await response.clone().json().catch(() => null);
if (body?.error === 'request-not-found' && !out() && (s.requestEpoch ?? 0) === epoch) {
if (body?.error === 'request-not-found' && !out() && (s.requestEpoch ?? 0) === epoch && (s.requestSeen?.get(id) ?? 0) === seen) {
(s.missingRequests ??= new Set()).add(id);
}
}
return response;
};
// A request read elsewhere on this page (any requests.js panel) proves the id now exists, so
// every open thread forgets that miss and the next refresh reads it again.
// every open thread forgets that miss and the next refresh reads it again. The id's own
// generation also advances, so a 404 still in flight from before this observation cannot
// record the miss after it. Only that id moves; other ids' slow misses still settle.
document.addEventListener('mosaic-request-state', event => {
const id = event.detail?.state?.request;
if (typeof id !== 'string' || out()) return;
for (const s of states.values()) if (s.missingRequests?.delete(id)) s.requestEpoch = (s.requestEpoch ?? 0) + 1;
for (const s of states.values()) {
if (s.missingRequests?.delete(id)) s.requestEpoch = (s.requestEpoch ?? 0) + 1;
if (s.rows?.some(m => m.request === id)) (s.requestSeen ??= new Map()).set(id, (s.requestSeen.get(id) ?? 0) + 1);
}
});
function requestRows(rows) {
const ids = new Set();
@@ -644,10 +649,12 @@
}
s.requestTail = tail;
}
// Keep misses only for ids still in the retained rows, so memory stays within the row cap.
if (s.missingRequests?.size) {
// Keep misses and observation generations only for ids still in the retained rows, so
// memory stays within the row cap.
if (s.missingRequests?.size || s.requestSeen?.size) {
const held = new Set(s.rows.map(m => m.request));
for (const id of s.missingRequests) if (!held.has(id)) s.missingRequests.delete(id);
for (const id of s.missingRequests ?? []) if (!held.has(id)) s.missingRequests.delete(id);
for (const id of s.requestSeen?.keys() ?? []) if (!held.has(id)) s.requestSeen.delete(id);
}
s.after = page.after ?? s.after; s.more = page.hasMore; s.loaded = true;
if (s.receiptId && s.rows.some(m => m.id === s.receiptId)) {
@@ -228,3 +228,56 @@ test('opening a thread again retries its missing requests without a new message'
await render(true);
await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[1])}) && document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-missing-1.html')`);
});
test('a request state observed before an in-flight 404 settles keeps that miss from being remembered', async t => {
const project = '10000000-0000-4000-8000-000000000001', thread = '20000000-0000-4000-8000-000000000001', session = '30000000-0000-4000-8000-000000000001';
const ids = ['request-real', 'request-late'];
const rows = ids.map((request, i) => ({ id: `40000000-0000-4000-8000-00000000000${i + 1}`, from_role: 'human', body: 'Build it', request, at: '2026-11-06T12:00:00Z' }));
const calls = [], available = new Set([ids[0]]), held = [];
let b, hold = true;
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://fixture');
const json = (status, value) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); };
if (url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<!doctype html><body><div id="view"></div><script src="/files.js"></script><script src="/chat.js"></script><script src="/requests.js"></script></body>'); }
if (['/files.js', '/chat.js', '/requests.js'].includes(url.pathname)) { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(readFileSync(new URL('../src/public' + url.pathname, import.meta.url))); }
if (url.pathname === '/api/config') return json(200, { requests: true, requestWrites: true });
if (url.pathname === '/api/chat/projects') return json(200, { rows: [{ id: project, name: 'Project', repo: 'timer' }] });
if (url.pathname === '/api/chat/projects/archived') return json(200, { rows: [] });
if (url.pathname === '/api/chat/threads') return json(200, { rows: url.searchParams.has('shared') ? [] : [{ id: thread, project, name: 'Thread', instance: 'assistant' }] });
if (url.pathname.endsWith('/sessions')) return json(200, { rows: [{ id: session, name: 'Default', default: true, state: 'active', revision: 1 }] });
if (url.pathname.endsWith('/shares')) return json(200, { rows: [] });
if (url.pathname.includes('/messages')) return json(200, { rows, after: rows.at(-1).id, hasMore: false });
const match = /^\/api\/requests\/([^/]+)(?:\/(workspace|draft))?$/.exec(url.pathname);
if (match) {
const [, id, part] = match; calls.push({ id, part: part ?? 'get' });
// The late id's first reads are answered request-not-found only after the test releases them.
if (id === ids[1] && hold) return held.push(() => json(404, { error: 'request-not-found' }));
if (!available.has(id)) return json(404, { error: 'request-not-found' });
if (part === 'workspace') return json(200, { available: true, entries: [{ name: `draft-${id}.html` }], truncated: false });
if (part === 'draft') return json(200, { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 0 } });
return json(200, { version: 1, request: id, revision: 1, phase: 'discovery', project: 'timer', mode: 'guided', mode_revision: 1, snapshot: null, candidate: null, tasks: [], delivery: null, question: null, wait: null });
}
return json(404, { error: 'not-configured' });
});
t.after(async () => { for (const send of held.splice(0)) send(); await b?.close(); await new Promise(resolve => server.close(resolve)); });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
b = await browser({ machine: false }); await b.navigate(`http://127.0.0.1:${server.address().port}/`);
const path = `/projects/threads/${thread}`;
const render = navigated => b.evaluate(`window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#view'),canSend:true,navigated:${navigated}})`);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,20));}throw Error('fixture wait failed: '+${JSON.stringify(expression)});})()`);
await render(true);
for (let i = 0; i < 150 && held.length === 0; i++) await new Promise(r => setTimeout(r, 20));
await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))');
assert.ok(held.length > 0, 'the late id has reads in flight');
// The request opens and the page observes its state while the older 404s are still in flight.
available.add(ids[1]); hold = false;
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-request-state',{detail:{state:{version:1,request:${JSON.stringify(ids[1])},revision:1,phase:'discovery'},observedAt:new Date().toISOString()}}));true`);
for (const send of held.splice(0)) send();
await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))');
// An ordinary refresh, with no navigation or new message, reads the real request and workspace.
const before = calls.length;
await render(false);
await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[1])}) && document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-late.html')`);
assert.ok(calls.slice(before).some(c => c.id === ids[1] && c.part === 'get'));
assert.ok(calls.slice(before).some(c => c.id === ids[1] && c.part === 'workspace'));
});