From 97d670da94a720ef31dcdb2123e78fd15bbe8bb2 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 6 Nov 2026 10:49:33 -0600 Subject: [PATCH] fix(bus): name task evidence gaps and stop request polling (#797) --- BUILD-LOG.md | 2 + docs/SESSIONS.md | 1 + packages/bus/src/broker.mjs | 22 ++++- packages/bus/src/request-continuation.mjs | 16 +++- .../bus/tests/request-continuation.test.mjs | 82 +++++++++++++++++++ packages/harness/src/runner.mjs | 5 +- packages/harness/src/tools.mjs | 2 +- packages/harness/tests/request-tools.test.mjs | 21 +++++ packages/webui/src/public/chat.js | 28 +++++++ .../webui/tests/chat-sessions-access.test.mjs | 51 ++++++++++++ 10 files changed, 220 insertions(+), 10 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 769c4c6d3..9d566f817 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6314,3 +6314,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that 2026-11-06 UTC | #797 Files polish — implementation and author verification | Before: caller and Files module each rendered a heading, and request redraws replaced settled content with a loading message. After: callers retain the single heading; cloned DOM content is cached per deduplicated request list or validated thread key, fenced against older mounts and cleared at sign-out. Exact-base negative control: 8 behavioral failures, 3 passes. Node 26 serial authorized browser gate: 91 pass, 0 fail, 0 cancelled, 0 skipped. An initial detached-container test timed out because its fixture awaited its own held request; the same timeout reproduced on base and the fixture was corrected. All evidence and limits: /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-evidence/ and /mnt/storage/scratch/mosaic-lead/mvp3/files-polish-report.md. Local implementation candidate for independent review; no push, merge or deployment. 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/. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index bb3661ae5..ff393d6a4 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2104,3 +2104,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-06 UTC | Terra implementation sub-agent | #797 write_record JSON object compatibility on fix/write-record-object from 13f81354e | Strict parsed JSON objects and scoped agent-visible validation details; exact-base seven-test negative, Node 26 serial gates and base-reproduced CLI skip recorded in /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-report.md. Queue refused linked worktree; local commit for independent review, no push or deployment. 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. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 090effd59..dd7249d94 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -831,6 +831,24 @@ export class Broker { if (!created.length) fail('request-evidence-required'); const tasks = [...new Set(created.map(e => e.subject))].sort(), evidence = created.map(e => e.id); if (state.phase !== 'planning' && digestPrd(tasks) !== digestPrd([...state.tasks].sort())) fail('request-evidence-required'); + // The same read-only chain result also selects the next task in a coder continuation. + const chains = new Map(); + if (state.phase !== 'planning') { + for (const task of tasks) { + const history = events.filter(e => e.subject === task); + const push = history.findLast(e => e.kind === 'action.allowed' && e.body.action === 'git.push.working'); + const review = push && history.findLast(e => e.kind === 'review.requested' && e.seq > push.seq && e.body.commit === push.body.commit && e.body.project === state.project && e.body.to === staff.reviewer); + if (push && push.actor_role === staff.coder && push.body.project === state.project && review) chains.set(task, { push, review }); + } + const missing = tasks.filter(task => !chains.has(task)); + if (missing.length) { + const reason = `Tasks without their own push_working and matching request_review: ${missing.join(', ')}. Each task_ref needs its own push_working and request_review; a push for one task does not cover another. Push and request review for each listed task, then retry advance_request.`; + const error = new BusError('request-evidence-required', reason); + error.message = `${error.code}: ${reason}`; + error.missingTasks = missing; + throw error; + } + } let changes = false; for (const task of tasks) { const current = this.#store.get('SELECT fields FROM task_current WHERE business=? AND task_ref=?', state.business, task); @@ -841,9 +859,7 @@ export class Broker { if (!assigned || assigned.actor_role !== state.pm || assigned.body.role !== staff.coder || !fields.assignees?.includes(b.roles[staff.coder]?.tracker?.botId)) fail('request-evidence-required'); evidence.push(assigned.id); if (state.phase === 'planning') continue; - const push = history.findLast(e => e.kind === 'action.allowed' && e.body.action === 'git.push.working'); - const review = push && history.findLast(e => e.kind === 'review.requested' && e.seq > push.seq && e.body.commit === push.body.commit && e.body.project === state.project && e.body.to === staff.reviewer); - if (!push || push.actor_role !== staff.coder || push.body.project !== state.project || !review) fail('request-evidence-required'); + const { push, review } = chains.get(task); evidence.push(push.id, review.id); if (state.phase === 'implementation') continue; const verdict = history.findLast(e => e.kind === 'review.verdict' && e.body.request === review.id && e.body.commit === push.body.commit && e.actor_role === staff.reviewer); diff --git a/packages/bus/src/request-continuation.mjs b/packages/bus/src/request-continuation.mjs index 0415295a1..e44992d03 100644 --- a/packages/bus/src/request-continuation.mjs +++ b/packages/bus/src/request-continuation.mjs @@ -141,12 +141,20 @@ export function createContinuations({ journal, services }) { request: state.request, revision: state.revision, target, step, phase: state.phase, project: state.project, evidence: evidence(state) }); function send(state, intent, context, progress = null, quote = null) { const control = { request: state.request, revision: state.revision, op: `continue-${intent.key.slice(0, 32)}` }; + let pushTask = state.tasks[0]; + if (intent.step === 'push_working' && services.progress) { + try { services.progress(state); } + catch (error) { + if (error.code !== 'request-evidence-required') throw error; + if (Array.isArray(error.missingTasks)) pushTask = state.tasks.find(task => error.missingTasks.includes(task)) ?? pushTask; + } + } const next = { intake_propose: { args: control, requires: ['source_project','project','name','tracker_project'] }, submit_prd: { args: control, requires: ['prd_version','records_commit','prd_path','requirement_ids'] }, resolve_decision: { args: { id: state.wait?.subject }, requires: ['choice','reason'] }, task_create: { args: { request: state.request, op: control.op, project_key: state.project, requirement_snapshot: state.snapshot }, requires: ['title','requirement'] }, - push_working: { args: { task_ref: state.tasks[0], project: state.project }, requires: ['branch'] }, + push_working: { args: { task_ref: pushTask, project: state.project }, requires: ['branch'] }, review_verdict: { args: { task_ref: state.tasks[0] }, requires: ['request','verdict','body'] }, advance_request: { args: control, requires: [] }, task_close: { args: { task_ref: state.tasks[0] }, requires: ['verdict'] }, @@ -157,10 +165,10 @@ export function createContinuations({ journal, services }) { submit_prd: 'Read the committed spec and discovery records. Finish the product PRD and requirements with write_record, then submit_prd with their exact records commit, path, version and ids. Use request_staff for missing technical help.', resolve_decision: 'Read the exact PRD candidate and requirements with intake_status(path), one selected path at a time. Resolve only the cited PRD decision through resolve_decision; ask for changes if evidence is incomplete.', task_create: 'Read list_requirements for this project and snapshot. Check existing task/create receipts before creating or retrying. Each task_create cites exactly one requirement id; every approved requirement needs a cited task, even when one description claims several. Create and assign the scoped tasks within the approved task limit; if coverage would exceed it, ask the owner to reconcile the plan. Request_staff for coder/reviewer sessions, then advance_request. Planning checks coverage and assignment; a push or review cannot clear missing coverage. Read the broker message naming uncovered ids after a refusal and correct the plan before retrying.', - push_working: 'Check the assigned tasks and existing receipts. Implement the approved requirements, push_working, and request_review for each latest commit.', + push_working: 'Check the assigned tasks and existing receipts. Implement the approved requirements. Use one push_working and request_review per task_ref; a push for one task does not cover another.', review_verdict: 'Fetch each current review request with fetch_review, run acceptance checks, before the verdict, write a docs/*.md verification document whose entire content is raw JSON (no Markdown fences or prose): {"version":1,"task_ref":"...","review_request":"...","commit":"...","results":"..."}. Results must be a nonempty string. Return review_verdict with a raw JSON body containing exactly {"verification_record":{"path":"...","commit":"..."},"results":"..."}, using the write_record receipt and matching results.', - advance_request: 'Check the existing task, push and review evidence, then call advance_request at the current intake_status revision. Never claim a success phase.', - task_close: 'Close each task with task_close citing the latest approve verdict. Read the review verdict JSON for verification_record and results, then call deliver_request for the reviewed HTML and that reviewer-authored verification record; closing alone does not complete delivery.', + advance_request: 'Check the existing task, push and review evidence, then call advance_request at the current intake_status revision. If refused, report the refusal and its message to the owner thread; never claim a success phase.', + task_close: 'Close each task with task_close citing the latest approve verdict. Read the review verdict JSON for verification_record and results, then call deliver_request for the reviewed HTML and that reviewer-authored verification record. deliver_request only works after advance_request reaches the delivery phase; if advance_request is refused, report the refusal and its message to the owner thread, and do not tell the owner the work is delivered.', send_message: 'Deliver the artifact and task/review evidence through the existing chief → assistant → user path, with send_message in_reply_to the original source_message so it returns to the originating session. Never coordinate the assistant directly with PM or reviewer.' }[intent.step]); const body = lead + quoteBlock(intent.step, quote, CONTINUATION_BODY_MAX - Buffer.byteLength(lead)); return services.continuationMessage(state, intent.target, body, context); diff --git a/packages/bus/tests/request-continuation.test.mjs b/packages/bus/tests/request-continuation.test.mjs index 495bc2cb4..0e2ed40b7 100644 --- a/packages/bus/tests/request-continuation.test.mjs +++ b/packages/bus/tests/request-continuation.test.mjs @@ -1012,3 +1012,85 @@ test('archive allows accepted delivery and preserves its completed state during assert.deepEqual(await f.call('human', 'request.get', { request: f.request }), completed); assert.ok(completed.delivery); }); + +// #797: each request task needs its own latest push and matching review request. +async function fourTaskRequest(t) { + const f = await requestFixture(t), sub = await f.submit(); f.approve(sub); await f.activate(sub); + const { createDigest, createMarker } = await import('../src/broker.mjs'); + const state = f.journal.readRequest('demo', f.request); + const tasks = [41, 42, 43, 44].map(n => { + const task_ref = `vikunja:2/${n}`; + const prepared = { op: `round-a-${n}`, request: f.request, requirement: 'REQ-POMO-1', project_key: 'timer', requirement_snapshot: state.snapshot, + project: 2, creator: 3, decision: null, view: { ids: { todo: 1, review: 2, done: 3 } }, labels: [], relations: [] }; + prepared.body = { title: `Timer ${n}`, description: createMarker(prepared) }; prepared.body_digest = createDigest(prepared.body); + const { receipt } = f.bus.broker.prepareTaskCreate(f.caps.pm, prepared); + const fields = { ...prepared.body, project_id: 2, bucket: 1, done: false, assignees: [5], due_date: null, priority: 0 }; + const snapshot = { task_ref, fields, digest: createDigest(fields), updated: new Date().toISOString() }; + f.bus.broker.recordRecoveredCreate({ receipt, task_ref, snapshot: { ...snapshot, creator: 3 } }); + f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot], events: [{ kind: 'task.assigned', subject: task_ref, body: { role: 'coder', previous: [] } }] }); + return task_ref; + }); + const push = (task_ref, commit = 'b'.repeat(40)) => f.bus.broker.recordPush(f.caps.coder, { task_ref, project: 'timer', branch: 'timer', ref: `work/vikunja-2-${task_ref.split('/')[1]}`, commit, previous: null }); + const review = (task_ref, commit = 'b'.repeat(40)) => f.call('coder', 'review.request', { task_ref, commit, to: 'reviewer' }); + return { ...f, tasks, push, review }; +} + +function checkMissing(error, missing, present) { + assert.equal(error.code, 'request-evidence-required'); + assert.equal(typeof error.reason, 'string'); + for (const task of missing) assert.equal(error.reason.split(task).length - 1, 1, task); + for (const task of present) assert.ok(!error.reason.includes(task), task); + return true; +} + +test('four tasks name exactly the missing per-task chains in implementation and review', async t => { + const f = await fourTaskRequest(t); + await advance(f, 'start-four'); + for (const task of f.tasks.slice(0, 2)) { f.push(task); f.review(task); } + await assert.rejects(advance(f, 'missing-two'), e => { + assert.ok(f.tasks.slice(2).every(task => e.message.includes(task))); + return checkMissing(e, f.tasks.slice(2), f.tasks.slice(0, 2)); + }); + const { Client } = await import('../src/client.mjs'); + const client = new Client({ path: f.bus.path, cap: f.caps.pm }); + const current = f.journal.readRequest('demo', f.request); + await assert.rejects(client.call('request.advance', { request: f.request, revision: current.revision, op: 'socket-missing' }), e => checkMissing(e, f.tasks.slice(2), f.tasks.slice(0, 2))); + for (const task of f.tasks.slice(2)) { f.push(task); f.review(task); } + assert.equal((await advance(f, 'all-four')).phase, 'review'); + for (const task of f.tasks.slice(2)) f.push(task, 'c'.repeat(40)); + await assert.rejects(advance(f, 'review-missing-two'), e => checkMissing(e, f.tasks.slice(2), f.tasks.slice(0, 2))); + for (const task of f.tasks) { + const commit = f.tasks.slice(2).includes(task) ? 'c'.repeat(40) : 'b'.repeat(40); + const review = f.review(task, commit); + f.call('reviewer', 'review.verdict', { task_ref: task, request: review.request, verdict: 'approve', body: 'Reviewed' }); + } + assert.equal((await advance(f, 'delivery-four')).phase, 'delivery'); + for (const task of f.tasks.slice(2)) f.push(task, 'd'.repeat(40)); + await assert.rejects(advance(f, 'delivery-missing-two'), e => checkMissing(e, f.tasks.slice(2), f.tasks.slice(0, 2))); +}); + +test('coder continuation seeds the first incomplete task or the first task when all chains exist', async t => { + for (const completed of [2, 4]) { + const f = await fourTaskRequest(t); + for (const task of f.tasks.slice(0, completed)) { f.push(task); f.review(task); } + const s = await advance(f, `seed-${completed}`); + const body = continuationBody(f, 'push_working', s.revision); + const next = JSON.parse(body.split('\n')[1]).next; + assert.equal(next.args.task_ref, f.tasks[completed === 4 ? 0 : 2]); + assert.match(body, /one push_working.*request_review per task_ref/i); + assert.match(body, /push for one task does not cover another/i); + } +}); + +test('delivery continuation reports advance refusal and requires delivery phase', async t => { + const f = await planned(t); await advance(f, 'start-delivery-hint'); + const commit = 'a'.repeat(40); f.push(commit); const r = f.review(commit); + await advance(f, 'review-delivery-hint'); + f.call('reviewer', 'review.verdict', { task_ref: f.task_ref, request: r.request, verdict: 'approve', body: 'Reviewed' }); + const s = await advance(f, 'delivery-hint'); + const intent = intents(f).find(i => i.result.intent.step === 'task_close' && i.result.intent.revision === s.revision); + const body = messages(f, intent.result.intent.key).find(m => m.to_role === 'pm').body; + assert.match(body, /deliver_request.*advance_request.*delivery phase/i); + assert.match(body, /refus.*message.*owner thread/i); + assert.match(body, /do not tell the owner.*delivered/i); +}); diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs index f10a1d674..e37279d42 100644 --- a/packages/harness/src/runner.mjs +++ b/packages/harness/src/runner.mjs @@ -359,8 +359,9 @@ export function toolServer({ path, tools, client, local }) { answer({ ok: true, result }); } catch (e) { answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed", - ...(tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string" - ? { reason: e.reason.slice(0, 300) } : {}) }); + ...(typeof e.reason === "string" && request.tool === "advance_request" && tool.verb === "request.advance" && e.code === "request-evidence-required" + ? { reason: e.reason.slice(0, 300) } : tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string" + ? { reason: e.reason.slice(0, 300) } : {}) }); } }); }); diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index 63fe2b41b..614dba9c5 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -429,7 +429,7 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) { if (!input.includes("\n")) return; try { const r = JSON.parse(input); - r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: already running; wait for the existing run" : `refused: ${r.error}${tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`)); + r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: already running; wait for the existing run" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`)); } catch { end(unknown("tool reply isn't JSON")); } diff --git a/packages/harness/tests/request-tools.test.mjs b/packages/harness/tests/request-tools.test.mjs index 6190d951d..ab033698e 100644 --- a/packages/harness/tests/request-tools.test.mjs +++ b/packages/harness/tests/request-tools.test.mjs @@ -375,3 +375,24 @@ test('a relay through a removed role no longer carries or authorizes the origina assert.equal(received.request, null); await assert.rejects(x.tool('intake_open', { request: x.source.request }), /request-origin-refused/); }); + +test('advance_request exposes the named task evidence refusal to the PM tool', async t => { + const root = scratch(t, 'advance-reason-'), path = join(root, 'tool.sock'); + const reason = 'Tasks without their own push_working and matching request_review: vikunja:2/43, vikunja:2/44, ' + Array.from({ length: 60 }, (_, i) => `vikunja:2/${i + 100}`).join(', ') + '.'; + assert.ok(reason.length > 300); + // Exercise the real bus client and runner socket, including the existing 300-character bound. + const busPath = join(root, 'bus.sock'); + const bus = createServer(socket => socket.on('data', data => { + const request = JSON.parse(data.toString()); + const error = request.args?.other ? 'project-record-invalid' : 'request-evidence-required'; + socket.end(JSON.stringify({ ok: false, error, reason }) + '\n'); + })); + await new Promise(resolve => bus.listen(busPath, resolve)); + t.after(() => new Promise(resolve => bus.close(resolve))); + const { Client } = await import('../../bus/src/client.mjs'); + const server = await runner.toolServer({ path, tools: [{ name: 'advance_request', verb: 'request.advance' }, { name: 'other_tool', verb: 'request.advance' }], local: {}, client: new Client({ path: busPath, cap: 'fixture' }) }); + t.after(() => new Promise(resolve => server.close(resolve))); + await assert.rejects(callTool(path, 'advance_request', {}), { message: `refused: request-evidence-required: ${reason.slice(0, 300)}` }); + await assert.rejects(callTool(path, 'advance_request', { other: true }), { message: 'refused: project-record-invalid' }); + await assert.rejects(callTool(path, 'other_tool', {}), { message: 'refused: request-evidence-required' }); +}); diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 8f8aa16e5..bb9373531 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -197,6 +197,24 @@ // requests.js fills with the intake panel. Older rows stay plain, so a long thread reads few requests. const REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/; const REQUEST_ROWS = 3; + // The request panel, Files and summary each read independently. Share only confirmed + // request-not-found responses while a thread is open, without changing other fetch paths. + const fetchPage = window.fetch.bind(window); + 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); + 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); + if (response.status === 404) { + const body = await response.clone().json().catch(() => null); + if (body?.error === 'request-not-found' && !out() && (s.requestEpoch ?? 0) === epoch) { + (s.missingRequests ??= new Set()).add(id); + } + } + return response; + }; function requestRows(rows) { const ids = new Set(); if (!window.MosaicRequests || !roomOwner()) return ids; @@ -601,6 +619,16 @@ 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. + if (!page.hasMore) { + const tail = s.rows.at(-1)?.id ?? null; + if (s.requestTail !== undefined && s.requestTail !== tail) { + s.requestEpoch = (s.requestEpoch ?? 0) + 1; + s.missingRequests?.clear(); + } + s.requestTail = tail; + } 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; diff --git a/packages/webui/tests/chat-sessions-access.test.mjs b/packages/webui/tests/chat-sessions-access.test.mjs index 5e0b84eb9..da342102e 100644 --- a/packages/webui/tests/chat-sessions-access.test.mjs +++ b/packages/webui/tests/chat-sessions-access.test.mjs @@ -118,3 +118,54 @@ test('272 fallback guard: owner route retains chooser and explicit default-sessi assert.equal(calls.find(c=>c.verb==='thread.messages').args.session,session); assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/existing account history/); }); + +test('missing request ids stop all thread-page polling until the thread gains a 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', 'request-missing-2']; + 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('
'); } + 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 = () => b.evaluate(`window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#view'),canSend:true,navigated:false})`); + 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(); + await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[0])}) && document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-real.html')`); + // Give all three parallel sources time to settle, then compare the fixture's network log. + await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))'); + const before = calls.length; + for (let i = 0; i < 3; i++) { await render(); await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[0])})`); } + await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))'); + const later = calls.slice(before); + assert.deepEqual(later.filter(c => ids.slice(1).includes(c.id)), [], 'no get or workspace fetch for either missing request'); + assert.ok(later.some(c => c.id === ids[0] && c.part === 'get')); + assert.ok(later.some(c => c.id === ids[0] && c.part === 'workspace')); + available.add(ids[1]); + 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')`); +});