fix(bus): name task evidence gaps and stop request polling (#797)

This commit is contained in:
jason.woltje committed 2026-11-06 10:49:33 -06:00
1 parent 6b84fe136e
commit 97d670da94
10 files changed
+220 -10

No files matched your search

+2
View File
@@ -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 — 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 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/.
+1
View File
@@ -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 | 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 | 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 | 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.
+19 -3
View File
@@ -831,6 +831,24 @@ export class Broker {
if (!created.length) fail('request-evidence-required'); if (!created.length) fail('request-evidence-required');
const tasks = [...new Set(created.map(e => e.subject))].sort(), evidence = created.map(e => e.id); 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'); 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; let changes = false;
for (const task of tasks) { for (const task of tasks) {
const current = this.#store.get('SELECT fields FROM task_current WHERE business=? AND task_ref=?', state.business, task); 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'); 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); evidence.push(assigned.id);
if (state.phase === 'planning') continue; if (state.phase === 'planning') continue;
const push = history.findLast(e => e.kind === 'action.allowed' && e.body.action === 'git.push.working'); const { push, review } = chains.get(task);
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');
evidence.push(push.id, review.id); evidence.push(push.id, review.id);
if (state.phase === 'implementation') continue; 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); 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);
+12 -4
View File
@@ -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) }); 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) { function send(state, intent, context, progress = null, quote = null) {
const control = { request: state.request, revision: state.revision, op: `continue-${intent.key.slice(0, 32)}` }; 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 = { const next = {
intake_propose: { args: control, requires: ['source_project','project','name','tracker_project'] }, intake_propose: { args: control, requires: ['source_project','project','name','tracker_project'] },
submit_prd: { args: control, requires: ['prd_version','records_commit','prd_path','requirement_ids'] }, submit_prd: { args: control, requires: ['prd_version','records_commit','prd_path','requirement_ids'] },
resolve_decision: { args: { id: state.wait?.subject }, requires: ['choice','reason'] }, 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'] }, 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'] }, review_verdict: { args: { task_ref: state.tasks[0] }, requires: ['request','verdict','body'] },
advance_request: { args: control, requires: [] }, advance_request: { args: control, requires: [] },
task_close: { args: { task_ref: state.tasks[0] }, requires: ['verdict'] }, 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.', 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.', 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.', 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.', 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.', 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; closing alone does not complete delivery.', 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]); 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)); const body = lead + quoteBlock(intent.step, quote, CONTINUATION_BODY_MAX - Buffer.byteLength(lead));
return services.continuationMessage(state, intent.target, body, context); return services.continuationMessage(state, intent.target, body, context);
@@ -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.deepEqual(await f.call('human', 'request.get', { request: f.request }), completed);
assert.ok(completed.delivery); 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);
});
+3 -2
View File
@@ -359,8 +359,9 @@ export function toolServer({ path, tools, client, local }) {
answer({ ok: true, result }); answer({ ok: true, result });
} catch (e) { } catch (e) {
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed", 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" ...(typeof e.reason === "string" && request.tool === "advance_request" && tool.verb === "request.advance" && e.code === "request-evidence-required"
? { reason: e.reason.slice(0, 300) } : {}) }); ? { 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) } : {}) });
} }
}); });
}); });
+1 -1
View File
@@ -429,7 +429,7 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
if (!input.includes("\n")) return; if (!input.includes("\n")) return;
try { try {
const r = JSON.parse(input); 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 { } catch {
end(unknown("tool reply isn't JSON")); end(unknown("tool reply isn't JSON"));
} }
@@ -375,3 +375,24 @@ test('a relay through a removed role no longer carries or authorizes the origina
assert.equal(received.request, null); assert.equal(received.request, null);
await assert.rejects(x.tool('intake_open', { request: x.source.request }), /request-origin-refused/); 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' });
});
+28
View File
@@ -197,6 +197,24 @@
// requests.js fills with the intake panel. Older rows stay plain, so a long thread reads few requests. // 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_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
const REQUEST_ROWS = 3; 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) { function requestRows(rows) {
const ids = new Set(); const ids = new Set();
if (!window.MosaicRequests || !roomOwner()) return ids; if (!window.MosaicRequests || !roomOwner()) return ids;
@@ -601,6 +619,16 @@
if (catalog) current.legacyDefault = false; if (catalog) current.legacyDefault = false;
const rows = after ? [...s.rows, ...page.rows] : page.rows; const rows = after ? [...s.rows, ...page.rows] : page.rows;
s.rows = [...new Map(rows.map(m => [m.id, m])).values()].slice(-200); 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; s.after = page.after ?? s.after; s.more = page.hasMore; s.loaded = true;
if (s.receiptId && s.rows.some(m => m.id === s.receiptId)) { if (s.receiptId && s.rows.some(m => m.id === s.receiptId)) {
s.receipt = 'Message shown in this conversation.'; s.receiptId = null; s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
@@ -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.equal(calls.find(c=>c.verb==='thread.messages').args.session,session);
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/existing account history/); 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('<!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 = () => 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')`);
});