fix(webui): retry missing requests on thread open and observed state (#797)
This commit is contained in:
1 parent
97d670da94
commit
16384bb120
4 files changed
+84
-3
No files matched your search
@@ -6316,3 +6316,4 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
|
||||
2026-11-06 UTC | #797 Files polish r2 — review r1 F1/F2 ordering fix | Before (86383a9d1): the per-key sequence fenced only cache writes, so an older same-key read in another live container painted stale files after a newer mount settled (F1), and a pending newer mount kept a completed result out of the cache, so a remount flashed "Reading files…" (F2). After: each mount takes a start order; a settle paints and caches only if no later-started mount for the key has committed, otherwise it shows the newer cached result; a pending mount never blocks an earlier completion. Reviewer probes ported as two tests; on 86383a9d1 they fail 2/13 for behavior, on the fix w15-files-browser 13/13 and the touched caller browser files 93/0/0/0 (Node 26 serial). Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-r2-evidence/.
|
||||
|
||||
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/.
|
||||
@@ -2105,3 +2105,4 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-11-06 UTC | Codex implementation sub-agent | #797 Files heading and remount polish on files-polish from 4cc3d05b3 | Single caller heading and fenced sign-out-cleared DOM cache; Node 26 authorized browser gates 91/0/0/0, exact-base behavioral controls and fixture correction recorded in /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-report.md. Queue refused linked worktree; canonical queue reported nothing for this seat. One local candidate for independent review; no push, merge or deployment.
|
||||
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/.
|
||||
@@ -203,7 +203,8 @@
|
||||
window.fetch = async (url, init) => {
|
||||
const path = typeof url === 'string' && /^\/api\/requests\/([A-Za-z0-9][A-Za-z0-9._-]{0,159})(?:\/workspace)?$/.exec(url);
|
||||
const c = current, s = path && (!init?.method || init.method === 'GET') && c?.kind === 'thread' && !out() ? state(c.key) : null;
|
||||
if (!s) return fetchPage(url, init);
|
||||
// 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;
|
||||
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);
|
||||
@@ -215,6 +216,13 @@
|
||||
}
|
||||
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.
|
||||
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;
|
||||
});
|
||||
function requestRows(rows) {
|
||||
const ids = new Set();
|
||||
if (!window.MosaicRequests || !roomOwner()) return ids;
|
||||
@@ -619,8 +627,15 @@
|
||||
if (catalog) current.legacyDefault = false;
|
||||
const rows = after ? [...s.rows, ...page.rows] : page.rows;
|
||||
s.rows = [...new Map(rows.map(m => [m.id, m])).values()].slice(-200);
|
||||
// Only a different newest message on a caught-up thread retries missing requests:
|
||||
// repaints and timers do not, and paging through old history is not a thread change.
|
||||
// Missing requests are retried on an observed thread change, never on a timer: the owner
|
||||
// opening the thread (navigated), a different newest message on a caught-up thread, or a
|
||||
// mosaic-request-state for the id. Repaints, the refresh timer and history paging are not.
|
||||
// The thread page reads only messages, so a request opened with no new message stays hidden
|
||||
// until one of those happens or the page reloads.
|
||||
if (navigated) {
|
||||
s.requestEpoch = (s.requestEpoch ?? 0) + 1;
|
||||
s.missingRequests?.clear();
|
||||
}
|
||||
if (!page.hasMore) {
|
||||
const tail = s.rows.at(-1)?.id ?? null;
|
||||
if (s.requestTail !== undefined && s.requestTail !== tail) {
|
||||
@@ -629,6 +644,11 @@
|
||||
}
|
||||
s.requestTail = tail;
|
||||
}
|
||||
// Keep misses only for ids still in the retained rows, so memory stays within the row cap.
|
||||
if (s.missingRequests?.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);
|
||||
}
|
||||
s.after = page.after ?? s.after; s.more = page.hasMore; s.loaded = true;
|
||||
if (s.receiptId && s.rows.some(m => m.id === s.receiptId)) {
|
||||
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
|
||||
|
||||
@@ -168,4 +168,63 @@ test('missing request ids stop all thread-page polling until the thread gains a
|
||||
rows.push({ id: '40000000-0000-4000-8000-000000000004', from_role: 'assistant', body: 'Request opened', at: '2026-11-06T12:01:00Z' });
|
||||
await render();
|
||||
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')`);
|
||||
// ids[2] is still remembered as missing. An aborted read keeps the native AbortError.
|
||||
assert.equal(await b.evaluate(`(async()=>{const a=new AbortController();a.abort();try{await fetch('/api/requests/${ids[2]}',{signal:a.signal});return 'resolved';}catch(e){return e.name;}})()`), 'AbortError');
|
||||
// Event-only open: the newest message is unchanged, so refreshes alone keep the miss.
|
||||
available.add(ids[2]);
|
||||
const idle = calls.length;
|
||||
for (let i = 0; i < 2; i++) { await render(); await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[1])})`); }
|
||||
await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))');
|
||||
assert.deepEqual(calls.slice(idle).filter(c => c.id === ids[2]), [], 'timer refreshes do not retry a remembered miss');
|
||||
// A request state for that id observed anywhere on the page clears the miss for the next refresh.
|
||||
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-request-state',{detail:{state:{version:1,request:${JSON.stringify(ids[2])},revision:1,phase:'discovery'},observedAt:new Date().toISOString()}}));true`);
|
||||
await render();
|
||||
await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[2])}) && document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-missing-2.html')`);
|
||||
});
|
||||
|
||||
test('opening a thread again retries its missing requests without a new message', 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-missing-1'];
|
||||
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]]);
|
||||
let b;
|
||||
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' });
|
||||
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 () => { 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);
|
||||
await wait(`document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-real.html')`);
|
||||
await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))');
|
||||
available.add(ids[1]);
|
||||
const before = calls.length;
|
||||
await render(false);
|
||||
await wait(`document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-real.html')`);
|
||||
await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))');
|
||||
assert.deepEqual(calls.slice(before).filter(c => c.id === ids[1]), [], 'a refresh keeps the miss');
|
||||
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')`);
|
||||
});
|
||||
Reference in new issue
Block a user