From 9b84634ffe6c6a227cdaf63b57a2e34733f2cec7 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 12:59:13 -0700
Subject: [PATCH 01/23] Attach selected code lines to review feedback
---
docs/implementation/read-only-review.md | 10 +++
runner/review.ts | 20 ++++-
runner/store.ts | 7 +-
test/browser/review.spec.ts | 29 +++++++
test/review.test.ts | 16 ++++
web/public/app.js | 108 +++++++++++++++++++++++-
web/public/index.html | 2 +
web/public/style.css | 11 +++
8 files changed, 194 insertions(+), 9 deletions(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index 269faae..9d318ec 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -50,3 +50,13 @@ Review round 5 found a symlinked-ancestor escape and raw-spelling collisions in
Review round 6 reproduced destination-inside-source mutation; planting now resolves the existing destination ancestor and rejects canonical source descendants before creating files, including `.git` and symlink aliases. The Git-environment finding was declined: `gitRaw` already passes `{cwd, env, ...}` to `execFileSync`. A new end-to-end test with inherited `GIT_DIR`/`GIT_WORK_TREE` passed before any production change. The summary's snapshot-race claim supplied no concrete interleaving; the service already checks revision/snapshot/review version before publishing a view and writes use atomic store CAS, covered by concurrent-write tests.
Review round 7 reproduced oversized-dimension raster previews and dangling SQLite sidecar symlinks. Preview metadata is now parsed from the bounded buffer with image-size: at most 8,192 pixels per side, 4 million pixels per image and 16 million pixels across unique preview blobs. Unknown dimensions omit the preview; existing compressed/response byte limits remain. Sidecars use lstat so dangling links are rejected. Both new regressions failed before the fixes. The summary mentioned rename-scope/literal-path concerns without specific findings; no additional behavior was inferred from that shorthand.
+
+## Feedback on selected code
+
+Click a changed line number, Shift-click to extend a range, or highlight code within one changed block. The selection toolbar offers Ask and Request change; each attaches the full selected lines to that item's composer. Question/change-request drafts retain separate attachments. Remove snippet returns to item-level feedback.
+
+The server validates the segment and range against the current review token and constructs the saved reference itself: path, old/new side, exact text, line range, base and head commits. Limits are 200 lines and 16,000 characters. References on unassigned changes require assigning them to a plan item first. Existing item-level notes remain compatible without a database migration.
+
+Saved references navigate to the selected lines while the reviewed commits and assignment still match. Otherwise they are labeled Outdated and open the original captured snippet, never silently pointing at new code. This deliberately marks references outdated on any base/head change, even if the snippet is unchanged. Refresh invalidates transient selections; outdated draft attachments must be removed and reselected before submission. No agent or GitHub comment is invoked.
+
+Snippet-feedback validation: 176 unit/integration tests and 15 browser tests pass (baseline 174 and 13), plus typecheck and diff checks. Coverage includes clicked lines, Shift-click ranges, DOM text highlighting, independent question/change drafts, removal, persistence, navigation, removed-side references, forged/invalid references and outdated snapshots. The actual PR #597 walkthrough was also opened in Chromium and the selected-line/composer layout visually inspected without saving test notes to its database.
diff --git a/runner/review.ts b/runner/review.ts
index 38ce52b..61974c2 100644
--- a/runner/review.ts
+++ b/runner/review.ts
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import { basename } from 'node:path';
import { isDeepStrictEqual } from 'node:util';
-import { Store, type ReviewState } from './store.ts';
+import { Store, type ReviewState, type SnippetReference } from './store.ts';
import type { PlanIdentity } from '../core/identity.ts';
import { readHistory } from '../git/history.ts';
import { linkHistory } from '../core/linking.ts';
@@ -52,7 +52,7 @@ export class ReviewService {
if (states[item.id] === 'approved' && (segments.some(segment => segment.row === 'Ambiguous' && segment.owners.includes(item.id)) || item.depends_on.some(id => states[id] === 'stale'))) states[item.id] = 'stale';
}
const expected: ReviewState = { revision: plan.revision, snapshotId: snapshot.id, reviewVersion };
- const notes = this.store.getReviewNotes(identity);
+ const notes = this.store.getReviewNotes(identity).map(note => ({ ...note, outdated: !!note.reference && (note.reference.head !== history.head || note.reference.base !== history.base || !segments.some(segment => segment.key === note.reference!.key && segment.row === note.item)) }));
if (this.store.reviewVersion(identity) !== reviewVersion || this.store.getPlan(identity).revision !== plan.revision || this.store.getSnapshot(identity).id !== snapshot.id) throw new Error('Stale review state. Reload before writing.');
const items = plan.items.map(item => {
const owned = segments.filter(segment => segment.row === item.id);
@@ -92,7 +92,21 @@ export class ReviewService {
const storedKey = choiceKeys(view.segments, identity)[view.segments.indexOf(segment)]!;
this.store.saveReview(identity, view.expected, [], [{ key: storedKey, action: command.action, item }]);
} else if (command.action === 'note' && typeof command.item === 'string' && typeof command.text === 'string' && (command.kind === 'question' || command.kind === 'change')) {
- this.store.addReviewNote(identity, view.expected, command.item, command.kind, command.text);
+ let reference: SnippetReference | undefined;
+ if (command.reference !== undefined) {
+ if (!command.reference || typeof command.reference !== 'object') throw new Error('Invalid snippet reference.');
+ const input = command.reference as Record;
+ const segment = view.segments.find(s => s.key === input.key && s.row === command.item && s.kind !== 'file');
+ const start = input.start as number, end = input.end as number;
+ if (!segment || !Number.isSafeInteger(start) || !Number.isSafeInteger(end)) throw new Error('Invalid snippet reference.');
+ const first = segment.operation === '+' ? segment.newLine : segment.oldLine;
+ const lines = segment.content.split('\n'); if (lines.at(-1) === '') lines.pop();
+ if (first === null || start < first || end < start || end >= first + lines.length || end - start >= 200) throw new Error('Select up to 200 lines within one changed block.');
+ const text = lines.slice(start-first, end-first+1).join('\n');
+ if (text.length > 16000) throw new Error('Selected snippet exceeds 16000 characters.');
+ reference = { key: segment.key, path: segment.operation === '-' ? segment.oldPath ?? segment.path : segment.path, side: segment.operation === '+' ? 'new' : 'old', start, end, text, head: view.snapshot.head, base: view.snapshot.base };
+ }
+ this.store.addReviewNote(identity, view.expected, command.item, command.kind, command.text, reference);
} else throw new Error('Unknown review command.');
return this.load();
}
diff --git a/runner/store.ts b/runner/store.ts
index cc96c5e..020f79c 100644
--- a/runner/store.ts
+++ b/runner/store.ts
@@ -12,7 +12,8 @@ export function requireSupportedNode(version = process.versions.node): void {
}
export interface Snapshot { id: string; base: string; head: string }
export interface ReviewState { revision: number; snapshotId: string; reviewVersion?: number }
-export interface ReviewNote { id: string; item: string; kind: 'question' | 'change'; text: string; createdAt: string; revision: number; snapshotId: string }
+export interface SnippetReference { key: string; path: string; side: 'old' | 'new'; start: number; end: number; text: string; head: string; base: string }
+export interface ReviewNote { id: string; item: string; kind: 'question' | 'change'; text: string; reference?: SnippetReference; createdAt: string; revision: number; snapshotId: string }
export interface LedgerEntry { sha: string; owner: string | null; origin: 'owned' | 'foreign'; sourceSha: string | null }
export interface Checkpoint {
id: string; revision: number; snapshotId: string; item: string;
@@ -246,12 +247,12 @@ export class Store {
});
}
reviewVersion(identity: PlanIdentity): number { return this.#current(identityKey(identity)).review_version as number; }
- addReviewNote(identity: PlanIdentity, expected: ReviewState, item: string, kind: ReviewNote['kind'], text: string): ReviewNote {
+ addReviewNote(identity: PlanIdentity, expected: ReviewState, item: string, kind: ReviewNote['kind'], text: string, reference?: SnippetReference): ReviewNote {
const key = identityKey(identity);
return this.#transaction(() => {
this.#expect(key, expected);
if (!this.getPlan(identity).items.some(entry => entry.id === item) || !['question', 'change'].includes(kind) || typeof text !== 'string' || !text.trim() || text.length > 4000) throw new Error('Invalid review note.');
- const note = { id: randomUUID(), item, kind, text: text.trim(), createdAt: new Date().toISOString(), revision: expected.revision, snapshotId: expected.snapshotId };
+ const note = { id: randomUUID(), item, kind, text: text.trim(), ...(reference ? { reference } : {}), createdAt: new Date().toISOString(), revision: expected.revision, snapshotId: expected.snapshotId };
this.#run('INSERT INTO review_notes VALUES (?,?,?)', key, note.id, encode(note));
this.#run('UPDATE plans SET review_version=review_version+1 WHERE key=?', key);
return note;
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index 9118065..427c274 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -77,3 +77,32 @@ test('routes mixed owned and ambiguous items to attribution resolution',async({p
test('keeps review shortcuts active while a toolbar button has focus',async({page})=>{
await page.goto(app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible();await page.getByRole('button',{name:'Refresh',exact:true}).focus();await page.keyboard.press('n');await expect(page.getByRole('heading',{name:'Document retry behavior'})).toBeVisible();
});
+test('attaches clicked lines to a question, persists and navigates the reference, and shows archived evidence',async({page})=>{
+ await page.goto(app.url);
+ const line=page.locator('.added [data-line]').first();await line.click();
+ await page.getByRole('button',{name:'Ask about selection',exact:true}).click();
+ await expect(page.locator('#attachment')).toContainText('retry.ts');
+ await page.getByLabel('Question about this item').fill('Why this exact line?');await page.getByRole('button',{name:'Save question',exact:true}).click();
+ await page.reload();await page.locator('.note-reference').click();await expect(page.locator('.selected-line').first()).toBeVisible();
+ execFileSync('git',['-c','core.hooksPath=/dev/null','commit','--allow-empty','-m','another revision'],{cwd:app.service.config.repository,stdio:'pipe'});
+ await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.locator('.note-reference')).toContainText('Outdated');await page.locator('.note-reference').click();await expect(page.getByRole('heading',{name:'! Outdated code reference'})).toBeVisible();await expect(page.locator('#dialog-body pre')).toContainText('Math.min');
+});
+test('supports shift ranges, highlighted lines, and independent snippet drafts',async({page})=>{
+ const repository=app.service.config.repository;
+ writeFileSync(join(repository,'retry.ts'),'export const cap = 5000;\nexport const base = 100;\nexport const delay = (n: number) => Math.min(cap, base * 2 ** n);\n');
+ execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-am','rewrite retry'],{cwd:repository,stdio:'pipe'});
+ let view=app.service.load();
+ for(const key of view.segments.filter(s=>s.path==='retry.ts'&&['Unplanned','Ambiguous'].includes(s.row)).map(s=>s.key)) view=app.service.act({action:'assign',key,item:'P1',token:view.token});
+ await page.goto(app.url);
+ const block=page.locator('.added[data-segment]').filter({hasText:'export const cap'});
+ await block.locator('[data-line]').nth(0).click();await block.locator('[data-line]').nth(2).click({modifiers:['Shift']});
+ await expect(page.locator('#selection-label')).toContainText('L1–3');
+ await page.getByRole('button',{name:'Request change to selection',exact:true}).click();await expect(page.locator('#attachment pre')).toContainText('export const delay');
+ await page.getByLabel('Change to request').fill('Please explain these constants.');
+ await page.getByRole('button',{name:'Ask',exact:true}).click();await expect(page.locator('#attachment')).toBeHidden();
+ await block.locator('.code-lines').evaluate(element=>{const lines=element.querySelectorAll('[data-code-line]');const range=document.createRange();range.setStart(lines[0]!.firstChild!,0);range.setEnd(lines[1]!.firstChild!,6);const selection=window.getSelection()!;selection.removeAllRanges();selection.addRange(range);element.dispatchEvent(new MouseEvent('mouseup',{bubbles:true}));});
+ await expect(page.locator('#selection-label')).toContainText('L1–2');await page.getByRole('button',{name:'Ask about selection',exact:true}).click();await expect(page.locator('#attachment pre')).not.toContainText('export const delay');
+ await page.getByRole('button',{name:'Remove snippet',exact:true}).click();await expect(page.locator('#attachment')).toBeHidden();
+ await page.getByRole('button',{name:'Request change',exact:true}).click();await expect(page.locator('#attachment pre')).toContainText('export const delay');
+ await page.getByRole('button',{name:'Save change request',exact:true}).click();await expect(page.locator('.note-reference')).toContainText('L1–3');
+});
diff --git a/test/review.test.ts b/test/review.test.ts
index d882be9..718c05d 100644
--- a/test/review.test.ts
+++ b/test/review.test.ts
@@ -42,3 +42,19 @@ it('refuses no-change confirmation while the item still owns ambiguous changes',
expect(view.items[1]!.count).toBeGreaterThan(0);
expect(()=>service.act({action:'approve',item:'P2',token:view.token})).toThrow(/ambiguous/i);
});
+it('anchors notes to server-owned code and rejects invalid ranges and cross-item references',()=>{
+ const {service,config}=fixture();let view=service.load();const segment=view.segments.find(s=>s.row==='P1'&&s.kind!=='file'&&s.operation==='+')!;
+ const start=segment.newLine!;
+ expect(()=>service.act({action:'note',item:'P2',kind:'question',text:'Why?',reference:{key:segment.key,start,end:start},token:view.token})).toThrow(/Invalid snippet/);
+ expect(()=>service.act({action:'note',item:'P1',kind:'question',text:'Why?',reference:{key:segment.key,start:0,end:start},token:view.token})).toThrow(/changed block/);
+ view=service.act({action:'note',item:'P1',kind:'question',text:'Why?',reference:{key:segment.key,start,end:start,text:'forged',path:'forged'},token:view.token});
+ const ref=view.notes[0]!.reference!;expect(ref.text).toBe(segment.content.split('\n')[0]);expect(ref.path).toBe(segment.path);expect(ref.head).toBe(view.snapshot.head);expect(view.notes[0]!.outdated).toBe(false);
+ const reopened=new ReviewService(config);services.push(reopened);expect(reopened.load().notes[0]!.reference).toEqual(ref);
+});
+it('preserves removed-side snippets and marks their references outdated after HEAD changes',async()=>{
+ const {service,config}=fixture();let view=service.load();const segment=view.segments.find(s=>s.row==='P1'&&s.kind!=='file'&&s.operation==='-')!;
+ view=service.act({action:'note',item:'P1',kind:'change',text:'Keep this?',reference:{key:segment.key,start:segment.oldLine,end:segment.oldLine},token:view.token});
+ const ref=view.notes[0]!.reference!;expect(ref.side).toBe('old');
+ const {execFileSync}=await import('node:child_process');execFileSync('git',['-c','core.hooksPath=/dev/null','commit','--allow-empty','-m','new revision'],{cwd:config.repository,stdio:'pipe'});
+ view=service.load();expect(view.notes[0]!.outdated).toBe(true);expect(view.notes[0]!.reference).toEqual(ref);
+});
diff --git a/web/public/app.js b/web/public/app.js
index 92dfbf4..6998134 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -20,6 +20,8 @@ let data,
since = false,
busy = false;
const drafts = new Map();
+const attachments = new Map();
+let snippetSelection = null;
const statusClass = (text) =>
text.startsWith("✓")
? "good"
@@ -46,6 +48,9 @@ function rememberDraft() {
}
function showFailure(message) {
data = null;
+ snippetSelection = null;
+ $("selection-actions").hidden = true;
+ $("attachment").hidden = true;
$("banner").textContent = message;
$("progress").textContent = "Review unavailable";
$("item-title").textContent = "Review unavailable";
@@ -74,6 +79,7 @@ async function refresh() {
try {
rememberDraft();
data = await api("/api/review");
+ snippetSelection = null;
selected ??= data.items[0]?.id || "Unplanned";
since = data.items.find((item) => item.id === selected)?.state === "stale";
render();
@@ -103,6 +109,7 @@ async function act(command) {
function select(id) {
rememberDraft();
selected = id;
+ snippetSelection = null;
change = 0;
since = data.items.find((item) => item.id === id)?.state === "stale";
render();
@@ -201,7 +208,7 @@ function render() {
.filter((note) => note.item === selected)
.map(
(note) =>
- `You · ${note.kind === "change" ? "Change requested" : "Question"} ${esc(note.text)}
r${note.revision} · ${esc(new Date(note.createdAt).toLocaleString())}${note.kind === "change" ? " · Pending" : " · Awaiting discussion"} `,
+ `You · ${note.kind === "change" ? "Change requested" : "Question"} ${esc(note.text)}
${note.reference ? `
${esc(note.reference.path)} · ${note.reference.side === "new" ? "Added" : "Removed"} L${note.reference.start}–${note.reference.end}${note.outdated ? " · ! Outdated" : ""} ${esc(note.reference.text)} ` : ""}
r${note.revision} · ${esc(new Date(note.createdAt).toLocaleString())}${note.kind === "change" ? " · Pending" : " · Awaiting discussion"} `,
)
.join("") ||
'No conversation yet. Keep questions and requested changes beside the evidence.
'
@@ -210,6 +217,19 @@ function render() {
$("save-note").disabled = !item;
$("message").value = drafts.get(`${selected}:${mode}`) || "";
renderCode();
+ renderAttachment();
+ document.querySelectorAll("[data-note]").forEach(button => button.onclick = () => {
+ const note = data.notes.find(note => note.id === button.dataset.note);
+ const ref = note.reference;
+ if (note.outdated) {
+ showDialog(`! Outdated code reference ${esc(ref.path)} · ${ref.side} L${ref.start}–${ref.end}
Reviewed commit ${esc(ref.head)} · base ${esc(ref.base)}
${esc(ref.text)} `);
+ return;
+ }
+ select(note.item);
+ const segment = data.segments.find(s => s.key === ref.key);
+ chooseSnippet(segment, ref.start, ref.end);
+ document.querySelector(`[data-segment="${ref.key}"]`)?.scrollIntoView({block:"center"});
+ });
}
function renderCode() {
const item = data.items.find((item) => item.id === selected),
@@ -263,7 +283,7 @@ function renderCode() {
if (lines.at(-1) === "") lines.pop();
const start =
segment.operation === "+" ? segment.newLine : segment.oldLine;
- content = `${esc(provenance)}
${lines.map((_, i) => (start === null ? "" : start + i)).join("\n")} ${esc(segment.operation)}
${esc(segment.content)} `;
+ content = `${esc(provenance)}
${lines.map((_, i) => `${start + i} `).join("")}
${esc(segment.operation)}
${lines.map((line,i) => `${esc(line) || ""} `).join("\n")} `;
}
const choices = ["Unplanned", "Ambiguous"].includes(segment.row)
? `Assigning this change makes the selected item’s approval stale.
Assign to… ${data.items.map((p) => `${esc(p.id)} · ${esc(p.title)} `).join("")}Assign Accept as is `
@@ -274,6 +294,14 @@ function renderCode() {
(data.segments.length
? 'No changes in this row There are no current segments here. An item with no changes requires explicit confirmation before approval.
'
: 'No code changes yet This branch has no changes against the selected base. Open task shows the compared commits.
'));
+ paintSelection();
+ document.querySelectorAll("[data-line]").forEach(button => button.onclick = event => {
+ const key = button.closest("[data-segment]").dataset.segment;
+ const segment = data.segments.find(s => s.key === key);
+ const line = Number(button.dataset.line);
+ const anchor = event.shiftKey && snippetSelection?.key === key ? snippetSelection.anchor : line;
+ chooseSnippet(segment, Math.min(anchor,line), Math.max(anchor,line), anchor);
+ });
document.querySelectorAll("[data-assign]").forEach((button) =>
button.addEventListener("click", () => {
const index = Number(button.dataset.assign);
@@ -317,6 +345,7 @@ function setMode(value) {
$("save-note").textContent =
mode === "change" ? "Save change request" : "Save question";
$("message").value = drafts.get(`${selected}:${mode}`) || "";
+ renderAttachment();
}
function showDialog(html) {
$("dialog-body").innerHTML = html;
@@ -348,8 +377,12 @@ $("composer").onsubmit = async (event) => {
event.preventDefault();
const item = selected,
kind = mode;
- if (await act({ action: "note", item, kind, text: $("message").value })) {
+ const attached = attachments.get(`${item}:${kind}`);
+ if (attached && (attached.head !== data.snapshot.head || attached.base !== data.snapshot.base)) return;
+ if (await act({ action: "note", item, kind, text: $("message").value, ...(attached ? {reference:{key:attached.key,start:attached.start,end:attached.end}} : {}) })) {
drafts.delete(`${item}:${kind}`);
+ attachments.delete(`${item}:${kind}`);
+ renderAttachment();
$("message").value = "";
$("saved").textContent =
kind === "change"
@@ -431,3 +464,72 @@ document.addEventListener("keydown", (event) => {
if (event.key === "?") $("help").click();
});
await refresh();
+
+function chooseSnippet(segment, start, end, anchor = start) {
+ if (!data.items.some(item => item.id === selected)) {
+ $("saved").textContent = "Assign this change to a plan item before adding feedback.";
+ return;
+ }
+ const first = segment.operation === "+" ? segment.newLine : segment.oldLine;
+ const text = segment.content.split("\n").slice(start-first,end-first+1).join("\n");
+ if (end-start >= 200 || text.length > 16000) {
+ $("saved").textContent = "Select at most 200 lines and 16000 characters.";
+ return;
+ }
+ snippetSelection = {key:segment.key,start,end,anchor,text,path:segment.operation === "-" ? segment.oldPath || segment.path : segment.path,side:segment.operation === "+" ? "new" : "old",head:data.snapshot.head,base:data.snapshot.base};
+ paintSelection();
+}
+function referenceLabel(ref) {
+ return `${ref.path} · ${ref.side === "new" ? "Added" : "Removed"} L${ref.start}–${ref.end}`;
+}
+function paintSelection() {
+ const ref = snippetSelection;
+ $("selection-actions").hidden = !ref;
+ $("selection-label").textContent = ref ? referenceLabel(ref) : "";
+ document.querySelectorAll("[data-code-line], [data-line]").forEach(element => {
+ const line = Number(element.dataset.codeLine ?? element.dataset.line);
+ const active = !!ref && element.closest("[data-segment]").dataset.segment === ref.key && line >= ref.start && line <= ref.end;
+ element.classList.toggle("selected-line",active);
+ if (element.matches("button")) element.setAttribute("aria-pressed",String(active));
+ });
+}
+function renderAttachment() {
+ const ref = attachments.get(`${selected}:${mode}`);
+ $("attachment").hidden = !ref;
+ const stale = ref && (!data || ref.head !== data.snapshot.head || ref.base !== data.snapshot.base || !data.segments.some(s => s.key === ref.key && s.row === selected));
+ $("attachment").innerHTML = ref ? `${esc(referenceLabel(ref))} Commit ${esc(ref.head.slice(0,8))}${stale ? " · ! Outdated — remove and select again" : ""} ${esc(ref.text)} Remove snippet ` : "";
+ $("save-note").disabled = !!stale || !data?.items.some(item=>item.id === selected);
+ if (ref) $("remove-reference").onclick = () => {attachments.delete(`${selected}:${mode}`);renderAttachment();};
+}
+function attachSelection(kind) {
+ if (!snippetSelection) return;
+ setMode(kind);
+ attachments.set(`${selected}:${mode}`, {...snippetSelection});
+ document.body.classList.add("conversation-open");
+ document.body.classList.remove("conversation-closed");
+ renderAttachment();
+ $("message").focus();
+}
+$("snippet-ask").onclick = () => attachSelection("question");
+$("snippet-request").onclick = () => attachSelection("change");
+$("selection-clear").onclick = () => {snippetSelection=null;paintSelection();};
+function captureHighlightedLines() {
+ const selection = window.getSelection();
+ if (!selection || selection.isCollapsed || !selection.rangeCount) return;
+ const range = selection.getRangeAt(0);
+ const element = node => node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
+ const first = element(range.startContainer)?.closest("[data-code-line]");
+ const last = element(range.endContainer)?.closest("[data-code-line]");
+ if (!first || !last) return;
+ const block = first.closest("[data-segment]");
+ if (block !== last.closest("[data-segment]")) {
+ snippetSelection=null;paintSelection();
+ $("saved").textContent="Select lines within one changed block and diff side.";
+ return;
+ }
+ let end=Number(last.dataset.codeLine);
+ if (range.endOffset===0 && last!==first) end--;
+ chooseSnippet(data.segments.find(s=>s.key===block.dataset.segment),Number(first.dataset.codeLine),end);
+}
+$("code").addEventListener("mouseup",captureHighlightedLines);
+$("code").addEventListener("keyup",captureHighlightedLines);
diff --git a/web/public/index.html b/web/public/index.html
index b530d7e..031514c 100644
--- a/web/public/index.html
+++ b/web/public/index.html
@@ -81,6 +81,7 @@ Linking changes to plan items…
>← →
+ Ask about selection Request change to selection Clear selection
@@ -103,6 +104,7 @@ Linking changes to plan items…
diff --git a/web/public/style.css b/web/public/style.css
index d9267fc..0ffb9e6 100644
--- a/web/public/style.css
+++ b/web/public/style.css
@@ -677,3 +677,6 @@ dialog::backdrop {
#attachment small { display: block; color: var(--muted); }
.snippet-preview { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; font: 12px/1.5 "IBM Plex Mono", monospace; }
.note-reference { max-width: 100%; height: auto; text-align: left; overflow-wrap: anywhere; }
+.agent-answer { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--line); }
+.agent-answer p { white-space: pre-wrap; overflow-wrap: anywhere; }
+#question-provider { display: block; width: 100%; margin: 8px 0; }
diff --git a/web/server.ts b/web/server.ts
index d086f54..aebb3c0 100644
--- a/web/server.ts
+++ b/web/server.ts
@@ -3,9 +3,11 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { ReviewService, type ReviewConfig } from '../runner/review.ts';
+import { Questions, type QuestionAgent } from '../runner/questions.ts';
const publicRoot = new URL('./public/', import.meta.url);
-export async function startServer(config: ReviewConfig, port = 4318) {
+export async function startServer(config: ReviewConfig, port = 4318, questionAgent?: QuestionAgent) {
const service = new ReviewService(config), token = randomBytes(32).toString('hex');
+ const questions=new Questions(service,questionAgent);
const server = createServer(async (req, res) => {
const address = server.address(); const actualPort = address && typeof address !== 'string' ? address.port : port;
const origin = `http://127.0.0.1:${actualPort}`;
@@ -18,12 +20,26 @@ export async function startServer(config: ReviewConfig, port = 4318) {
if (path.startsWith('/api/')) {
const supplied = req.headers['x-codeboost-token'];
if (typeof supplied !== 'string' || !/^[a-f0-9]{64}$/.test(supplied) || !timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) { json(403, { error: 'Open the private local URL printed by the CLI.' }); return; }
+ if (req.method === 'GET' && path === '/api/settings') { json(200,{questionProvider:service.store.questionProvider()});return; }
+ if (req.method === 'GET' && path === '/api/questions') { json(200,{notes:service.load().notes});return; }
if (req.method === 'GET' && path === '/api/review') { json(200, service.load()); return; }
- if (req.method !== 'POST' || path !== '/api/action' || req.headers['content-type'] !== 'application/json') { json(405, { error: 'Unsupported request.' }); return; }
+ if (req.method !== 'POST' || !['/api/action','/api/settings'].includes(path) || req.headers['content-type'] !== 'application/json') { json(405, { error: 'Unsupported request.' }); return; }
const chunks: Buffer[] = []; let size = 0;
for await (const chunk of req) { size += chunk.length; if (size > 16384) { json(413, { error: 'Request too large.' }); return; } chunks.push(chunk); }
const body = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
- json(200, service.act(JSON.parse(body))); return;
+ const input=JSON.parse(body);
+ if(path==='/api/settings') {service.store.setQuestionProvider(input.questionProvider);json(200,{questionProvider:service.store.questionProvider()});return;}
+ if(input.action==='retry-question') {
+ const view=service.load();if(input.token!==view.token)throw new Error('Stale review state. Refresh and retry.');
+ questions.start(input.id,view);json(200,service.load());return;
+ }
+ const view=service.act(input);
+ if(view.createdNoteId && input.kind==='question') {
+ try {questions.start(view.createdNoteId,view);} catch(error) {
+ // The saved question remains visible and retryable when capacity is reached.
+ }
+ }
+ json(200,service.load());return;
}
if (req.method !== 'GET') { json(405, { error: 'Method not allowed.' }); return; }
const files: Record = { '/': ['index.html', 'text/html'], '/app.js': ['app.js', 'text/javascript'], '/style.css': ['style.css', 'text/css'] };
@@ -40,5 +56,5 @@ export async function startServer(config: ReviewConfig, port = 4318) {
server.requestTimeout = 15000;
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', () => { server.removeListener('error', reject); resolve(); }); }).catch(error => { service.close(); throw error; });
const address = server.address(); if (!address || typeof address === 'string') throw new Error('Cannot determine local address.');
- return { server, service, token, url: `http://127.0.0.1:${address.port}/#${token}`, close: () => new Promise((resolve, reject) => server.close(error => { service.close(); error ? reject(error) : resolve(); })) };
+ return { server, service, token, url: `http://127.0.0.1:${address.port}/#${token}`, close: async () => { await questions.close(); await new Promise((resolve, reject) => server.close(error => { service.close(); error ? reject(error) : resolve(); })); } };
}
From fabe0cd60f543854886b9fefb388f0b74a24e432 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 13:40:11 -0700
Subject: [PATCH 03/23] Show immediate feedback when submitting review
questions
---
docs/implementation/read-only-review.md | 2 ++
test/browser/review.spec.ts | 17 +++++++++++++++++
web/public/app.js | 11 ++++++++++-
3 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index 3ab9ebe..1e63430 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -74,3 +74,5 @@ The CLI adapter runs without a shell in a fresh temporary directory. Claude uses
Codex options were checked against the installed CLI help and the official [non-interactive documentation](https://learn.chatgpt.com/docs/non-interactive-mode) and [configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference). Both installed providers passed live connection checks. A separate copy of PR #597's review database passed a real Settings → Ask → saved Claude answer browser test; the user's review state and source checkout were unchanged. Native Node startup is covered by enabling TypeScript's erasableSyntaxOnly check after the live test caught an unsupported parameter-property declaration.
Validation: 181 unit/integration tests, 19 browser tests, and typecheck pass. A delayed-initial-review regression reproduced an unresponsive Settings button; binding controls before awaiting the first load fixes it. The restarted PR #597 instance also passed an immediate Settings-open check with both provider options visible.
+
+Single-click submission follow-up: a held `/api/action` browser request reproduced the lack of immediate feedback (the submit button stayed enabled with no saving state). Question/change submission now shows Saving immediately, disables submission while a review action or refresh is in progress, and scrolls the saved note into view. Failures explicitly retain the draft for refresh/retry. The delayed-request regression checks one click produces exactly one stored question and one agent invocation; a lost first click was not independently reproduced.
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index 5e81871..ed29988 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -134,3 +134,20 @@ test('opens Settings while the initial review is still loading',async({page})=>{
await expect(page.getByLabel('Question agent',{exact:true})).toBeVisible();
} finally {release();}
});
+test('acknowledges the first Ask agent click immediately and prevents duplicate submissions',async({page})=>{
+ const config=app.service.config;await app.close();let calls=0;
+ app=await startServer(config,0,async()=>{calls++;return 'Single-click answer';});
+ await page.goto(app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible();
+ let release!:()=>void;const pending=new Promise(resolve=>{release=resolve;});let submissions=0;
+ await page.route('**/api/action',async route=>{submissions++;await pending;await route.continue();});
+ try {
+ await page.getByLabel('Question about this item').fill('Does one click submit this?');
+ await page.getByRole('button',{name:'Ask agent',exact:true}).click();
+ await expect(page.locator('#save-note')).toBeDisabled();
+ await expect(page.locator('#saved')).toHaveText('Saving question…');
+ } finally {release();}
+ await expect(page.getByText('Does one click submit this?',{exact:true})).toBeVisible();
+ await expect(page.getByText('Single-click answer',{exact:true})).toBeVisible({timeout:10000});
+ await expect(page.getByRole('button',{name:'Ask agent',exact:true})).toBeEnabled();
+ expect(submissions).toBe(1);expect(calls).toBe(1);expect(app.service.store.getReviewNotes(config.identity)).toHaveLength(1);
+});
diff --git a/web/public/app.js b/web/public/app.js
index 90748c2..60b811c 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -75,6 +75,7 @@ function showFailure(message) {
async function refresh() {
if (busy) return;
busy = true;
+ renderAttachment();
$("banner").textContent = "Linking changes to plan items…";
try {
rememberDraft();
@@ -89,11 +90,13 @@ async function refresh() {
);
} finally {
busy = false;
+ renderAttachment();
}
}
async function act(command) {
if (busy || !data) return false;
busy = true;
+ renderAttachment();
try {
rememberDraft();
data = await api("/api/action", { ...command, token: data.token });
@@ -104,6 +107,7 @@ async function act(command) {
return false;
} finally {
busy = false;
+ renderAttachment();
}
}
function select(id) {
@@ -355,11 +359,14 @@ $("ask").onclick = () => setMode("question");
$("request").onclick = () => setMode("change");
$("composer").onsubmit = async (event) => {
event.preventDefault();
+ if (busy || !data) return;
const item = selected,
kind = mode;
const attached = attachments.get(`${item}:${kind}`);
if (attached && (attached.head !== data.snapshot.head || attached.base !== data.snapshot.base)) return;
+ $("saved").textContent = kind === "change" ? "Saving change request…" : "Saving question…";
if (await act({ action: "note", item, kind, text: $("message").value, ...(attached ? {reference:{key:attached.key,start:attached.start,end:attached.end}} : {}) })) {
+ $("notes").lastElementChild?.scrollIntoView({ block: "nearest" });
drafts.delete(`${item}:${kind}`);
attachments.delete(`${item}:${kind}`);
renderAttachment();
@@ -368,6 +375,8 @@ $("composer").onsubmit = async (event) => {
kind === "change"
? "Saved for the next revision."
: "Question saved. See agent status in Conversation.";
+ } else {
+ $("saved").textContent = "Could not save. Your draft is preserved; refresh and try again.";
}
};
$("conversation-toggle").onclick = () => {
@@ -477,7 +486,7 @@ function renderAttachment() {
$("attachment").hidden = !ref;
const stale = ref && (!data || ref.head !== data.snapshot.head || ref.base !== data.snapshot.base || !data.segments.some(s => s.key === ref.key && s.row === selected));
$("attachment").innerHTML = ref ? `${esc(referenceLabel(ref))} Commit ${esc(ref.head.slice(0,8))}${stale ? " · ! Outdated — remove and select again" : ""} ${esc(ref.text)} Remove snippet ` : "";
- $("save-note").disabled = !!stale || !data?.items.some(item=>item.id === selected);
+ $("save-note").disabled = busy || !!stale || !data?.items.some(item=>item.id === selected);
if (ref) $("remove-reference").onclick = () => {attachments.delete(`${selected}:${mode}`);renderAttachment();};
}
function attachSelection(kind) {
From 3b3cfbf9862ff44003950a52c35caa09b4fcea90 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 13:53:47 -0700
Subject: [PATCH 04/23] Follow new agent answers in the conversation scroll
pane
---
docs/implementation/read-only-review.md | 2 ++
test/browser/review.spec.ts | 14 ++++++++++++++
web/public/app.js | 10 +++++++---
3 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index 1e63430..a69908e 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -76,3 +76,5 @@ Codex options were checked against the installed CLI help and the official [non-
Validation: 181 unit/integration tests, 19 browser tests, and typecheck pass. A delayed-initial-review regression reproduced an unresponsive Settings button; binding controls before awaiting the first load fixes it. The restarted PR #597 instance also passed an immediate Settings-open check with both provider options visible.
Single-click submission follow-up: a held `/api/action` browser request reproduced the lack of immediate feedback (the submit button stayed enabled with no saving state). Question/change submission now shows Saving immediately, disables submission while a review action or refresh is in progress, and scrolls the saved note into view. Failures explicitly retain the draft for refresh/retry. The delayed-request regression checks one click produces exactly one stored question and one agent invocation; a lost first click was not independently reproduced.
+
+Answer scrolling: when an asynchronous answer or failure arrives, Conversation follows the bottom if the reader was already within 32 pixels of it. Scrolling up keeps the current position through polling updates. Only the conversation list scrolls; the code pane and composer stay in place. Browser regressions cover both arrival at the bottom and arrival while reading earlier messages; the bottom-follow test failed before the fix with a 1,302-pixel gap.
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index ed29988..06a2859 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -151,3 +151,17 @@ test('acknowledges the first Ask agent click immediately and prevents duplicate
await expect(page.getByRole('button',{name:'Ask agent',exact:true})).toBeEnabled();
expect(submissions).toBe(1);expect(calls).toBe(1);expect(app.service.store.getReviewNotes(config.identity)).toHaveLength(1);
});
+for (const readingEarlier of [false,true]) test(`answer arrival ${readingEarlier ? 'preserves earlier reading position' : 'follows the conversation bottom'}`,async({page})=>{
+ const config=app.service.config;await app.close();let answer!:(text:string)=>void;
+ app=await startServer(config,0,()=>new Promise(resolve=>{answer=resolve;}));
+ for(let i=0;i<3;i++) app.service.act({action:'note',item:'P1',kind:'change',text:`Earlier message ${i}\n`+'Earlier context.\n'.repeat(20),token:app.service.load().token});
+ await page.goto(app.url);await page.getByLabel('Question about this item').fill('Explain the cap');await page.getByRole('button',{name:'Ask agent',exact:true}).click();
+ await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible();
+ const notes=page.locator('#notes');
+ await notes.evaluate((element,earlier)=>{element.scrollTop=earlier ? 80 : element.scrollHeight;},readingEarlier);
+ const before=await notes.evaluate(element=>element.scrollTop);
+ answer('Detailed answer.\n'.repeat(60)+'Answer end.');
+ await expect(page.locator('.agent-answer')).toHaveCount(1,{timeout:10000});
+ if(readingEarlier) expect(await notes.evaluate(element=>element.scrollTop)).toBeCloseTo(before,0);
+ else await expect.poll(()=>notes.evaluate(element=>element.scrollHeight-element.clientHeight-element.scrollTop)).toBeLessThan(2);
+});
diff --git a/web/public/app.js b/web/public/app.js
index 60b811c..021e406 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -530,7 +530,10 @@ function answerMarkup(note) {
const error=answer?.status==="failed"?answer.error:answer?.status==="pending"?"Agent was interrupted or timed out.":"Answer not started. Choose an agent in Settings or retry when capacity is available.";
return `! ${esc(error)}
Retry answer `;
}
-function renderNotes() {
+function renderNotes({ follow = false } = {}) {
+ const notes = $("notes");
+ const scrollTop = notes.scrollTop;
+ const atBottom = notes.scrollHeight - notes.clientHeight - scrollTop <= 32;
const item=data.items.find(item=>item.id===selected);
$("notes").innerHTML = item
? data.notes
@@ -542,6 +545,7 @@ function renderNotes() {
.join("") ||
'No conversation yet. Keep questions and requested changes beside the evidence.
'
: 'Select a plan item to add a question or request a change.
';
+ if (follow) notes.scrollTop = atBottom ? notes.scrollHeight : scrollTop;
document.querySelectorAll("[data-note]").forEach(button => button.onclick = () => {
const note = data.notes.find(note => note.id === button.dataset.note);
const ref = note.reference;
@@ -561,10 +565,10 @@ setInterval(async()=>{
if(pollingQuestions || busy || !data || !data.notes.some(n=>n.answer?.status==="pending")) return;
if(data.notes.every(n=>n.answer?.status!=="pending" || n.answer.expiresAt<=Date.now())) {
data.notes=data.notes.map(n=>n.answer?.status==="pending"?{...n,answer:{...n.answer,status:"failed",error:"Agent was interrupted or timed out. Retry the question."}}:n);
- renderNotes();return;
+ renderNotes({ follow: true });return;
}
pollingQuestions=true;
- try {const response=await api("/api/questions");if(data){data.notes=response.notes;renderNotes();}}
+ try {const response=await api("/api/questions");if(data){data.notes=response.notes;renderNotes({ follow: true });}}
catch { $("saved").textContent="Could not refresh agent answers. Use Refresh to reconnect."; }
finally {pollingQuestions=false;}
},2000);
From 8131f0cdfdfcdd4f636929c5fab4f919ba9910cc Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 13:57:24 -0700
Subject: [PATCH 05/23] Add a draggable conversation pane divider
---
docs/implementation/read-only-review.md | 2 ++
test/browser/review.spec.ts | 12 ++++++++
web/public/app.js | 39 +++++++++++++++++++++++++
web/public/index.html | 3 +-
web/public/style.css | 17 ++++++++++-
5 files changed, 71 insertions(+), 2 deletions(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index a69908e..0b7390e 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -78,3 +78,5 @@ Validation: 181 unit/integration tests, 19 browser tests, and typecheck pass. A
Single-click submission follow-up: a held `/api/action` browser request reproduced the lack of immediate feedback (the submit button stayed enabled with no saving state). Question/change submission now shows Saving immediately, disables submission while a review action or refresh is in progress, and scrolls the saved note into view. Failures explicitly retain the draft for refresh/retry. The delayed-request regression checks one click produces exactly one stored question and one agent invocation; a lost first click was not independently reproduced.
Answer scrolling: when an asynchronous answer or failure arrives, Conversation follows the bottom if the reader was already within 32 pixels of it. Scrolling up keeps the current position through polling updates. Only the conversation list scrolls; the code pane and composer stay in place. Browser regressions cover both arrival at the bottom and arrival while reading earlier messages; the bottom-follow test failed before the fix with a 1,302-pixel gap.
+
+Conversation width: drag the divider on its left edge to resize between 280 and 480 pixels. Focus the divider and use Left/Right for 20-pixel steps or Home/End for the limits. The width survives collapsing/reopening the pane within the page; reloading uses the layout default. This replaces the hard-to-discover native corner resize grip. The browser regression covers pointer dragging, keyboard limits, code-pane space, and collapse/reopen.
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index 06a2859..0e3d74f 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -165,3 +165,15 @@ for (const readingEarlier of [false,true]) test(`answer arrival ${readingEarlier
if(readingEarlier) expect(await notes.evaluate(element=>element.scrollTop)).toBeCloseTo(before,0);
else await expect.poll(()=>notes.evaluate(element=>element.scrollHeight-element.clientHeight-element.scrollTop)).toBeLessThan(2);
});
+test('resizes Conversation using its divider and keyboard',async({page})=>{
+ await page.goto(app.url);const pane=page.locator('#conversation-pane');const divider=page.getByRole('separator',{name:'Resize conversation'});
+ await expect(divider).toBeVisible();const initial=(await pane.boundingBox())!.width;const handle=(await divider.boundingBox())!;
+ await page.mouse.move(handle.x+handle.width/2,handle.y+100);await page.mouse.down();await page.mouse.move(handle.x-100,handle.y+100);await page.mouse.up();
+ expect((await pane.boundingBox())!.width).toBeGreaterThan(initial+90);
+ await divider.press('Home');await expect(divider).toHaveAttribute('aria-valuenow','280');
+ await divider.press('ArrowLeft');await expect(divider).toHaveAttribute('aria-valuenow','300');
+ await divider.press('End');await expect(divider).toHaveAttribute('aria-valuenow','480');
+ expect((await page.locator('.code-pane').boundingBox())?.width ?? 0).toBeGreaterThan(400);
+ await page.getByRole('button',{name:'Collapse conversation',exact:true}).click();await expect(divider).toBeHidden();
+ await page.getByRole('button',{name:'Conversation',exact:true}).click();await expect(divider).toBeVisible();expect((await pane.boundingBox())!.width).toBe(480);
+});
diff --git a/web/public/app.js b/web/public/app.js
index 021e406..4ef105c 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -582,4 +582,43 @@ $("settings").onclick=async()=>{
} catch(error){$("dialog-body").textContent=error.message;}
};
+const conversationResize = $("conversation-resize");
+const conversationPane = $("conversation-pane");
+function resizeConversation(width) {
+ const bounded = Math.round(Math.max(280, Math.min(480, width)));
+ conversationPane.style.width = `${bounded}px`;
+ conversationResize.setAttribute("aria-valuenow", String(bounded));
+}
+let conversationDrag;
+conversationResize.onpointerdown = event => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ conversationDrag = { x: event.clientX, width: conversationPane.getBoundingClientRect().width };
+ conversationResize.setPointerCapture(event.pointerId);
+ conversationResize.focus();
+ document.body.classList.add("resizing-conversation");
+};
+conversationResize.onpointermove = event => {
+ if (conversationDrag) resizeConversation(conversationDrag.width + conversationDrag.x - event.clientX);
+};
+function endConversationResize() {
+ conversationDrag = null;
+ document.body.classList.remove("resizing-conversation");
+}
+conversationResize.onpointerup = event => {
+ if (conversationResize.hasPointerCapture(event.pointerId)) conversationResize.releasePointerCapture(event.pointerId);
+ endConversationResize();
+};
+conversationResize.onpointercancel = endConversationResize;
+conversationResize.onlostpointercapture = endConversationResize;
+conversationResize.onkeydown = event => {
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
+ event.preventDefault();
+ resizeConversation(event.key === "Home" ? 280 : event.key === "End" ? 480 : conversationPane.getBoundingClientRect().width + (event.key === "ArrowLeft" ? 20 : -20));
+};
+new ResizeObserver(() => {
+ const width = conversationPane.getBoundingClientRect().width;
+ if (width) conversationResize.setAttribute("aria-valuenow", String(Math.round(width)));
+}).observe(conversationPane);
+
await refresh();
diff --git a/web/public/index.html b/web/public/index.html
index d8dd6b3..701c7e6 100644
--- a/web/public/index.html
+++ b/web/public/index.html
@@ -85,7 +85,8 @@ Linking changes to plan items…
-
+
+
CONVERSATION
Date: Wed, 23 Sep 2026 14:09:40 -0700
Subject: [PATCH 06/23] Use agent-oriented status messages for questions
---
test/browser/review.spec.ts | 3 ++-
web/public/app.js | 8 +++++---
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index 0e3d74f..65d3f67 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -144,9 +144,10 @@ test('acknowledges the first Ask agent click immediately and prevents duplicate
await page.getByLabel('Question about this item').fill('Does one click submit this?');
await page.getByRole('button',{name:'Ask agent',exact:true}).click();
await expect(page.locator('#save-note')).toBeDisabled();
- await expect(page.locator('#saved')).toHaveText('Saving question…');
+ await expect(page.locator('#saved')).toHaveText('Asking agent…');
} finally {release();}
await expect(page.getByText('Does one click submit this?',{exact:true})).toBeVisible();
+ await expect(page.locator('#saved')).toHaveText('Question submitted. Follow the agent’s response in Conversation.');
await expect(page.getByText('Single-click answer',{exact:true})).toBeVisible({timeout:10000});
await expect(page.getByRole('button',{name:'Ask agent',exact:true})).toBeEnabled();
expect(submissions).toBe(1);expect(calls).toBe(1);expect(app.service.store.getReviewNotes(config.identity)).toHaveLength(1);
diff --git a/web/public/app.js b/web/public/app.js
index 4ef105c..f1444f1 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -364,7 +364,7 @@ $("composer").onsubmit = async (event) => {
kind = mode;
const attached = attachments.get(`${item}:${kind}`);
if (attached && (attached.head !== data.snapshot.head || attached.base !== data.snapshot.base)) return;
- $("saved").textContent = kind === "change" ? "Saving change request…" : "Saving question…";
+ $("saved").textContent = kind === "change" ? "Saving change request…" : "Asking agent…";
if (await act({ action: "note", item, kind, text: $("message").value, ...(attached ? {reference:{key:attached.key,start:attached.start,end:attached.end}} : {}) })) {
$("notes").lastElementChild?.scrollIntoView({ block: "nearest" });
drafts.delete(`${item}:${kind}`);
@@ -374,9 +374,11 @@ $("composer").onsubmit = async (event) => {
$("saved").textContent =
kind === "change"
? "Saved for the next revision."
- : "Question saved. See agent status in Conversation.";
+ : "Question submitted. Follow the agent’s response in Conversation.";
} else {
- $("saved").textContent = "Could not save. Your draft is preserved; refresh and try again.";
+ $("saved").textContent = kind === "change"
+ ? "Could not save. Your draft is preserved; refresh and try again."
+ : "Could not ask the agent. Your question is preserved; refresh and try again.";
}
};
$("conversation-toggle").onclick = () => {
From f8589306c9ac806afcb4c3672b5b877923a9ed34 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 14:13:25 -0700
Subject: [PATCH 07/23] Show snippet actions beside the selected code
---
docs/implementation/read-only-review.md | 2 ++
test/browser/review.spec.ts | 17 +++++++++++++++++
web/public/app.js | 23 +++++++++++++++++++++++
web/public/style.css | 4 +++-
4 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index 0b7390e..d966d46 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -80,3 +80,5 @@ Single-click submission follow-up: a held `/api/action` browser request reproduc
Answer scrolling: when an asynchronous answer or failure arrives, Conversation follows the bottom if the reader was already within 32 pixels of it. Scrolling up keeps the current position through polling updates. Only the conversation list scrolls; the code pane and composer stay in place. Browser regressions cover both arrival at the bottom and arrival while reading earlier messages; the bottom-follow test failed before the fix with a 1,302-pixel gap.
Conversation width: drag the divider on its left edge to resize between 280 and 480 pixels. Focus the divider and use Left/Right for 20-pixel steps or Home/End for the limits. The width survives collapsing/reopening the pane within the page; reloading uses the layout default. This replaces the hard-to-discover native corner resize grip. The browser regression covers pointer dragging, keyboard limits, code-pane space, and collapse/reopen.
+
+Selection actions now appear in a bordered floating toolbar beside the visible selected lines, with Ask emphasized. The toolbar stays within the code viewport, leaves line numbers available for Shift-click extension, and hides when the selection scrolls out of view. Clear selection removes it. The long-diff browser regression checks proximity, viewport bounds, attachment, scrolling away/back, and clearing; existing range/highlight tests remain required.
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index 65d3f67..5eeab9b 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -178,3 +178,20 @@ test('resizes Conversation using its divider and keyboard',async({page})=>{
await page.getByRole('button',{name:'Collapse conversation',exact:true}).click();await expect(divider).toBeHidden();
await page.getByRole('button',{name:'Conversation',exact:true}).click();await expect(divider).toBeVisible();expect((await pane.boundingBox())!.width).toBe(480);
});
+test('places selection actions beside code deep in a scrolled diff',async({page})=>{
+ const repository=app.service.config.repository;
+ writeFileSync(join(repository,'retry.ts'),Array.from({length:90},(_,i)=>`export const value${i} = ${i};`).join('\n')+'\n');
+ execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-am','Long diff'],{cwd:repository,stdio:'pipe'});
+ let view=app.service.load();
+ for(const key of view.segments.filter(s=>s.path==='retry.ts'&&['Unplanned','Ambiguous'].includes(s.row)).map(s=>s.key)) view=app.service.act({action:'assign',key,item:'P1',token:view.token});
+ await page.goto(app.url);
+ const line=page.locator('.added [data-line]').nth(60);await line.scrollIntoViewIfNeeded();await line.click();
+ const actions=page.getByRole('group',{name:'Selected code'});await expect(actions).toBeVisible();
+ const selection=(await line.boundingBox())!,toolbar=(await actions.boundingBox())!,code=(await page.locator('#code').boundingBox())!;
+ expect(Math.min(Math.abs(toolbar.y+toolbar.height-selection.y),Math.abs(toolbar.y-selection.y-selection.height))).toBeLessThan(40);
+ expect(toolbar.y).toBeGreaterThanOrEqual(code.y);expect(toolbar.y+toolbar.height).toBeLessThanOrEqual(code.y+code.height);
+ await page.screenshot({path:test.info().outputPath('selection-actions.png')});
+ await page.getByRole('button',{name:'Ask about selection',exact:true}).click();await expect(page.locator('#attachment')).toContainText('value60');
+ await page.locator('#code').evaluate(element=>{element.scrollTop=0;});await expect(actions).toBeHidden();
+ await line.scrollIntoViewIfNeeded();await expect(actions).toBeVisible();await page.getByRole('button',{name:'Clear selection',exact:true}).click();await expect(actions).toBeHidden();
+});
diff --git a/web/public/app.js b/web/public/app.js
index f1444f1..a6d39f2 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -482,7 +482,30 @@ function paintSelection() {
element.classList.toggle("selected-line",active);
if (element.matches("button")) element.setAttribute("aria-pressed",String(active));
});
+ positionSelectionActions();
}
+function positionSelectionActions() {
+ const toolbar = $("selection-actions");
+ if (!snippetSelection) { toolbar.hidden = true; return; }
+ const bounds = $("code").getBoundingClientRect();
+ const visible = [...document.querySelectorAll("[data-code-line].selected-line")]
+ .map(line => line.getBoundingClientRect())
+ .filter(rect => rect.bottom > bounds.top && rect.top < bounds.bottom);
+ if (!visible.length || bounds.width < 1) { toolbar.hidden = true; return; }
+ const anchor = visible[visible.length - 1];
+ toolbar.hidden = false;
+ const inset = Math.min(140, bounds.width / 3);
+ toolbar.style.width = `${Math.min(440, bounds.width - inset - 8)}px`;
+ const height = toolbar.getBoundingClientRect().height;
+ const below = anchor.bottom + 8;
+ const top = below + height <= bounds.bottom - 8 ? below : anchor.top - height - 8;
+ toolbar.style.left = `${bounds.left + inset}px`;
+ toolbar.style.top = `${Math.max(bounds.top + 8, Math.min(top, bounds.bottom - height - 8))}px`;
+}
+$("code").addEventListener("scroll", positionSelectionActions);
+window.addEventListener("resize", positionSelectionActions);
+new ResizeObserver(positionSelectionActions).observe($("code"));
+
function renderAttachment() {
const ref = attachments.get(`${selected}:${mode}`);
$("attachment").hidden = !ref;
diff --git a/web/public/style.css b/web/public/style.css
index 143709d..ead4ccf 100644
--- a/web/public/style.css
+++ b/web/public/style.css
@@ -670,7 +670,9 @@ dialog::backdrop {
.line-number { display: block; width: 100%; height: 18px; min-height: 0; padding: 0 8px 0 0; border: 0; border-radius: 0; background: transparent; color: var(--muted); font: inherit; line-height: 18px; text-align: right; }
.code-lines [data-code-line] { display: inline-block; min-width: 100%; min-height: 18px; }
.selected-line { background: var(--selected, #213448); }
-#selection-actions { position: sticky; top: 0; z-index: 2; padding: 8px 12px; background: var(--surface); border-bottom: 1px solid var(--line); }
+#selection-actions { position: fixed; z-index: 5; padding: 8px 12px; background: var(--raised); border: 1px solid var(--primary); border-radius: 4px; }
+#selection-actions button { margin: 4px 4px 0 0; }
+#snippet-ask { background: var(--primary); color: var(--on-primary); border-color: var(--primary); }
#selection-label { display: block; overflow-wrap: anywhere; margin-bottom: 4px; }
#attachment { border: 1px solid var(--line); padding: 8px; margin-bottom: 8px; overflow-wrap: anywhere; }
#attachment small { display: block; color: var(--muted); }
From c0cabe970c2f83b8b1f0c6f9ad1f6b7a65a4ee3d Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 14:17:31 -0700
Subject: [PATCH 08/23] Clear native browser highlighting with snippet
selection
---
test/browser/review.spec.ts | 17 +++++++++++++++++
web/public/app.js | 6 +++++-
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index 5eeab9b..7acb8e4 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -195,3 +195,20 @@ test('places selection actions beside code deep in a scrolled diff',async({page}
await page.locator('#code').evaluate(element=>{element.scrollTop=0;});await expect(actions).toBeHidden();
await line.scrollIntoViewIfNeeded();await expect(actions).toBeVisible();await page.getByRole('button',{name:'Clear selection',exact:true}).click();await expect(actions).toBeHidden();
});
+test('Clear selection removes native text selection as well as selected lines',async({page})=>{
+ await page.goto(app.url);
+ await page.locator('.added .code-lines').first().evaluate(element=>{
+ const line=element.querySelector('[data-code-line]')!;
+ const range=document.createRange();range.selectNodeContents(line);
+ const selection=window.getSelection()!;selection.removeAllRanges();selection.addRange(range);
+ element.dispatchEvent(new MouseEvent('mouseup',{bubbles:true}));
+ });
+ await expect(page.locator('.selected-line').first()).toBeVisible();
+ expect(await page.evaluate(()=>window.getSelection()?.toString())).not.toBe('');
+ await page.getByRole('button',{name:'Clear selection',exact:true}).click();
+ await expect(page.getByRole('group',{name:'Selected code'})).toBeHidden();
+ await expect(page.locator('.selected-line')).toHaveCount(0);
+ expect(await page.evaluate(()=>window.getSelection()?.toString())).toBe('');
+ await page.locator('#code').press('ArrowRight');
+ await expect(page.getByRole('group',{name:'Selected code'})).toBeHidden();
+});
diff --git a/web/public/app.js b/web/public/app.js
index a6d39f2..20dd8f8 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -525,7 +525,11 @@ function attachSelection(kind) {
}
$("snippet-ask").onclick = () => attachSelection("question");
$("snippet-request").onclick = () => attachSelection("change");
-$("selection-clear").onclick = () => {snippetSelection=null;paintSelection();};
+$("selection-clear").onclick = () => {
+ window.getSelection()?.removeAllRanges();
+ snippetSelection = null;
+ paintSelection();
+};
function captureHighlightedLines() {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.rangeCount) return;
From 81d684df0a5a368889bb398afa55d506f3e8ff5d Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 14:33:11 -0700
Subject: [PATCH 09/23] Ignore stale question polls after newer review actions
---
docs/implementation/read-only-review.md | 2 ++
test/browser/review.spec.ts | 11 +++++++++++
web/public/app.js | 8 ++++++--
3 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index d966d46..48fcaa2 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -82,3 +82,5 @@ Answer scrolling: when an asynchronous answer or failure arrives, Conversation f
Conversation width: drag the divider on its left edge to resize between 280 and 480 pixels. Focus the divider and use Left/Right for 20-pixel steps or Home/End for the limits. The width survives collapsing/reopening the pane within the page; reloading uses the layout default. This replaces the hard-to-discover native corner resize grip. The browser regression covers pointer dragging, keyboard limits, code-pane space, and collapse/reopen.
Selection actions now appear in a bordered floating toolbar beside the visible selected lines, with Ask emphasized. The toolbar stays within the code viewport, leaves line numbers available for Shift-click extension, and hides when the selection scrolls out of view. Clear selection removes it. The long-diff browser regression checks proximity, viewport bounds, attachment, scrolling away/back, and clearing; existing range/highlight tests remain required.
+
+PR #11 review round 1 reproduced a stale-poll race: a held question-list response hid a question submitted after the poll began. Review actions and refreshes now advance a generation counter; earlier poll successes and errors are ignored. The browser regression failed with one visible note instead of two before the fix.
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index 7acb8e4..34dfbcd 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -212,3 +212,14 @@ test('Clear selection removes native text selection as well as selected lines',a
await page.locator('#code').press('ArrowRight');
await expect(page.getByRole('group',{name:'Selected code'})).toBeHidden();
});
+test('ignores question polls started before a newer submission',async({page})=>{
+ const config=app.service.config;await app.close();app=await startServer(config,0,()=>new Promise(()=>{}));
+ await page.goto(app.url);await page.getByLabel('Question about this item').fill('First question');await page.getByRole('button',{name:'Ask agent',exact:true}).click();await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible();
+ let release!:()=>void,arrived!:()=>void;const held=new Promise(r=>release=r),captured=new Promise(r=>arrived=r);
+ await page.route('**/api/questions',async route=>{const response=await route.fetch();arrived();await held;await route.fulfill({response});});
+ await captured;
+ await page.getByLabel('Question about this item').fill('Second question');await page.getByRole('button',{name:'Ask agent',exact:true}).click();await expect(page.locator('#notes .note')).toHaveCount(2);
+ release();await page.waitForResponse(response=>response.url().endsWith('/api/questions'));
+ await page.waitForTimeout(100);
+ expect(await page.locator('#notes .note').count()).toBe(2);
+});
diff --git a/web/public/app.js b/web/public/app.js
index 20dd8f8..69e09ba 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -19,6 +19,7 @@ let data,
mode = "question",
since = false,
busy = false;
+let reviewGeneration = 0;
const drafts = new Map();
const attachments = new Map();
let snippetSelection = null;
@@ -75,6 +76,7 @@ function showFailure(message) {
async function refresh() {
if (busy) return;
busy = true;
+ reviewGeneration++;
renderAttachment();
$("banner").textContent = "Linking changes to plan items…";
try {
@@ -96,6 +98,7 @@ async function refresh() {
async function act(command) {
if (busy || !data) return false;
busy = true;
+ reviewGeneration++;
renderAttachment();
try {
rememberDraft();
@@ -597,8 +600,9 @@ setInterval(async()=>{
renderNotes({ follow: true });return;
}
pollingQuestions=true;
- try {const response=await api("/api/questions");if(data){data.notes=response.notes;renderNotes({ follow: true });}}
- catch { $("saved").textContent="Could not refresh agent answers. Use Refresh to reconnect."; }
+ const generation = reviewGeneration;
+ try {const response=await api("/api/questions");if(data && generation === reviewGeneration){data.notes=response.notes;renderNotes({ follow: true });}}
+ catch { if (generation === reviewGeneration) $("saved").textContent="Could not refresh agent answers. Use Refresh to reconnect."; }
finally {pollingQuestions=false;}
},2000);
$("settings").onclick=async()=>{
From 8e06335e4f53e96341a3ddad62e9906849ea7ebf Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 14:39:25 -0700
Subject: [PATCH 10/23] Replace stale question retries with current-review
guidance
---
docs/implementation/read-only-review.md | 2 ++
test/browser/review.spec.ts | 10 ++++++++++
web/public/app.js | 1 +
3 files changed, 13 insertions(+)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index 48fcaa2..bdad8c2 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -84,3 +84,5 @@ Conversation width: drag the divider on its left edge to resize between 280 and
Selection actions now appear in a bordered floating toolbar beside the visible selected lines, with Ask emphasized. The toolbar stays within the code viewport, leaves line numbers available for Shift-click extension, and hides when the selection scrolls out of view. Clear selection removes it. The long-diff browser regression checks proximity, viewport bounds, attachment, scrolling away/back, and clearing; existing range/highlight tests remain required.
PR #11 review round 1 reproduced a stale-poll race: a held question-list response hid a question submitted after the poll began. Review actions and refreshes now advance a generation counter; earlier poll successes and errors are ignored. The browser regression failed with one visible note instead of two before the fix.
+
+PR #11 review round 2 confirmed the poll fix and raised an outdated-retry issue in its summary. A browser regression reproduced the retry button on an older snapshot; such unanswered questions now instruct the reviewer to ask again against current code. Completed historical answers retain their outdated label. The summary's timeout-reporting concern supplied no concrete failure; timeout and interrupted-lease regressions remain in the validation suite.
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index 34dfbcd..55a6e03 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -223,3 +223,13 @@ test('ignores question polls started before a newer submission',async({page})=>{
await page.waitForTimeout(100);
expect(await page.locator('#notes .note').count()).toBe(2);
});
+test('outdated questions explain how to continue without offering a broken retry',async({page})=>{
+ const service=app.service;
+ service.act({action:'note',item:'P1',kind:'question',text:'Old question',token:service.load().token});
+ execFileSync('git',['-c','core.hooksPath=/dev/null','commit','--allow-empty','-m','New snapshot'],{cwd:service.config.repository,stdio:'pipe'});
+ await page.goto(app.url);
+ await expect(page.getByText('Old question',{exact:true})).toBeVisible();
+ await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toHaveCount(0);
+ await expect(page.getByText('This question refers to an earlier review. Ask again against the current code.',{exact:true})).toBeVisible();
+ await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible();
+});
diff --git a/web/public/app.js b/web/public/app.js
index 69e09ba..f85b351 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -558,6 +558,7 @@ function answerMarkup(note) {
if(note.kind!=="question") return "";
const answer=note.answer;
if(answer?.status==="complete") return `${answer.provider === "claude" ? "Claude Code" : answer.provider === "codex" ? "Codex" : "Agent"} ${esc(answer.text)}
${note.answerOutdated ? '! Answer refers to an earlier review snapshot. ' : ""} `;
+ if(note.answerOutdated) return 'This question refers to an earlier review. Ask again against the current code.
';
if(answer?.status==="pending" && answer.expiresAt>Date.now()) return 'Agent · Answering…
';
const error=answer?.status==="failed"?answer.error:answer?.status==="pending"?"Agent was interrupted or timed out.":"Answer not started. Choose an agent in Settings or retry when capacity is available.";
return `! ${esc(error)}
Retry answer `;
From 5d15f754aa98cc43e89015caf4c3b68da487e9b5 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 14:44:19 -0700
Subject: [PATCH 11/23] Reject retries while the original question job is
active
---
docs/implementation/read-only-review.md | 2 ++
runner/questions.ts | 1 +
test/questions.test.ts | 10 ++++++++++
3 files changed, 13 insertions(+)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index bdad8c2..34b13f7 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -86,3 +86,5 @@ Selection actions now appear in a bordered floating toolbar beside the visible s
PR #11 review round 1 reproduced a stale-poll race: a held question-list response hid a question submitted after the poll began. Review actions and refreshes now advance a generation counter; earlier poll successes and errors are ignored. The browser regression failed with one visible note instead of two before the fix.
PR #11 review round 2 confirmed the poll fix and raised an outdated-retry issue in its summary. A browser regression reproduced the retry button on an older snapshot; such unanswered questions now instruct the reviewer to ask again against current code. Completed historical answers retain their outdated label. The summary's timeout-reporting concern supplied no concrete failure; timeout and interrupted-lease regressions remain in the validation suite.
+
+PR #11 review round 3 reproduced retrying a locally active job after its persisted lease expires (for example after a clock jump). The manager now rejects a retry while that note is in its running map, before touching the persisted attempt. The regression advances wall time without advancing the deadline timer and checks the original attempt, single invocation, and shutdown cancellation remain intact.
diff --git a/runner/questions.ts b/runner/questions.ts
index 2c9ca71..8cba58e 100644
--- a/runner/questions.ts
+++ b/runner/questions.ts
@@ -22,6 +22,7 @@ export class Questions {
private agent?: QuestionAgent;
constructor(service: ReviewService, agent?: QuestionAgent) { this.service=service; this.agent=agent; }
start(id: string, view: ReturnType) {
+ if (this.running.has(id)) throw new Error('Agent is already answering this question.');
const note = view.notes.find(n=>n.id===id && n.kind==='question');
if (!note) throw new Error('Question not found.');
if (note.snapshotId!==view.snapshot.id || note.revision!==view.plan.revision) throw new Error('This question belongs to an older review. Ask again against the current code.');
diff --git a/test/questions.test.ts b/test/questions.test.ts
index ecd42da..62afdbd 100644
--- a/test/questions.test.ts
+++ b/test/questions.test.ts
@@ -46,3 +46,13 @@ it('times out an unresponsive agent and allows expired pending attempts to be re
service.store.beginAnswer(service.config.identity,asked.createdNoteId!,'interrupted');await vi.advanceTimersByTimeAsync(125001);service.store.beginAnswer(service.config.identity,asked.createdNoteId!,'replacement');service.store.finishAnswer(service.config.identity,asked.createdNoteId!,'interrupted',{status:'complete',text:'Old answer'});expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.attempt).toBe('replacement');
} finally {vi.useRealTimers();}
});
+it('does not replace a locally running job when its persisted lease expires',async()=>{
+ const service=fixture(),asked=question(service);let calls=0,signal:AbortSignal|undefined;
+ const manager=new Questions(service,(_prompt,currentSignal)=>{calls++;signal=currentSignal;return new Promise(()=>{});});managers.push(manager);
+ manager.start(asked.createdNoteId!,asked);
+ const original=service.store.getReviewNotes(service.config.identity)[0]!.answer!.attempt;
+ const later=Date.now()+130000;vi.spyOn(Date,'now').mockReturnValue(later);
+ expect(()=>manager.start(asked.createdNoteId!,asked)).toThrow(/already answering/);
+ expect(calls).toBe(1);expect(service.store.getReviewNotes(service.config.identity)[0]!.answer!.attempt).toBe(original);
+ await manager.close();expect(signal?.aborted).toBe(true);expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.error).toMatch(/Server stopped/);
+});
From 4bdbaf43e3d0f2e17cadc9d6f11a2a9e14a422d1 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 14:50:22 -0700
Subject: [PATCH 12/23] Close question admission before draining active jobs
---
docs/implementation/read-only-review.md | 2 ++
runner/questions.ts | 4 +++-
test/questions.test.ts | 11 +++++++++++
3 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index 34b13f7..1501457 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -88,3 +88,5 @@ PR #11 review round 1 reproduced a stale-poll race: a held question-list respons
PR #11 review round 2 confirmed the poll fix and raised an outdated-retry issue in its summary. A browser regression reproduced the retry button on an older snapshot; such unanswered questions now instruct the reviewer to ask again against current code. Completed historical answers retain their outdated label. The summary's timeout-reporting concern supplied no concrete failure; timeout and interrupted-lease regressions remain in the validation suite.
PR #11 review round 3 reproduced retrying a locally active job after its persisted lease expires (for example after a clock jump). The manager now rejects a retry while that note is in its running map, before touching the persisted attempt. The regression advances wall time without advancing the deadline timer and checks the original attempt, single invocation, and shutdown cancellation remain intact.
+
+PR #11 review round 4 reproduced new question work entering during shutdown. Questions now marks itself closing synchronously before cancelling/draining jobs; start rejects before any store write or agent invocation, during and after shutdown. The regression failed before the guard and checks that the unstarted note has no attempt recorded.
diff --git a/runner/questions.ts b/runner/questions.ts
index 8cba58e..64e4f24 100644
--- a/runner/questions.ts
+++ b/runner/questions.ts
@@ -18,10 +18,12 @@ export function questionPrompt(view: ReturnType, note: Re
}
export class Questions {
private running = new Map}>();
+ private closing = false;
private service: ReviewService;
private agent?: QuestionAgent;
constructor(service: ReviewService, agent?: QuestionAgent) { this.service=service; this.agent=agent; }
start(id: string, view: ReturnType) {
+ if (this.closing) throw new Error('Server is stopping. Reconnect before asking again.');
if (this.running.has(id)) throw new Error('Agent is already answering this question.');
const note = view.notes.find(n=>n.id===id && n.kind==='question');
if (!note) throw new Error('Question not found.');
@@ -46,5 +48,5 @@ export class Questions {
this.running.set(id,{controller,done});
void done.finally(()=>this.running.delete(id));
}
- async close() {for(const job of this.running.values())job.controller.abort(new Error('Server stopped. Retry the question.'));await Promise.all([...this.running.values()].map(job=>job.done));}
+ async close() {this.closing = true;for(const job of this.running.values())job.controller.abort(new Error('Server stopped. Retry the question.'));await Promise.all([...this.running.values()].map(job=>job.done));}
}
diff --git a/test/questions.test.ts b/test/questions.test.ts
index 62afdbd..7b0e53a 100644
--- a/test/questions.test.ts
+++ b/test/questions.test.ts
@@ -56,3 +56,14 @@ it('does not replace a locally running job when its persisted lease expires',asy
expect(calls).toBe(1);expect(service.store.getReviewNotes(service.config.identity)[0]!.answer!.attempt).toBe(original);
await manager.close();expect(signal?.aborted).toBe(true);expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.error).toMatch(/Server stopped/);
});
+it('rejects new work as soon as shutdown begins',async()=>{
+ const service=fixture(),first=question(service),second=question(service);let calls=0;
+ const manager=new Questions(service,()=>{calls++;return new Promise(()=>{});});managers.push(manager);
+ manager.start(first.createdNoteId!,first);
+ const closing=manager.close();
+ expect(()=>manager.start(second.createdNoteId!,second)).toThrow(/stopping/);
+ await closing;
+ expect(()=>manager.start(second.createdNoteId!,second)).toThrow(/stopping/);
+ expect(calls).toBe(1);
+ expect(service.store.getReviewNotes(service.config.identity).find(note=>note.id===second.createdNoteId)?.answer).toBeUndefined();
+});
From 35d819faecd632abaabf1a50723db12a037194ca Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 14:55:41 -0700
Subject: [PATCH 13/23] Preserve timeout and shutdown reasons in question
adapters
---
docs/implementation/read-only-review.md | 2 ++
runner/question-agent.ts | 2 +-
test/question-agent.test.ts | 18 ++++++++++++++++++
3 files changed, 21 insertions(+), 1 deletion(-)
create mode 100644 test/question-agent.test.ts
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index 1501457..0731cf3 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -90,3 +90,5 @@ PR #11 review round 2 confirmed the poll fix and raised an outdated-retry issue
PR #11 review round 3 reproduced retrying a locally active job after its persisted lease expires (for example after a clock jump). The manager now rejects a retry while that note is in its running map, before touching the persisted attempt. The regression advances wall time without advancing the deadline timer and checks the original attempt, single invocation, and shutdown cancellation remain intact.
PR #11 review round 4 reproduced new question work entering during shutdown. Questions now marks itself closing synchronously before cancelling/draining jobs; start rejects before any store write or agent invocation, during and after shutdown. The regression failed before the guard and checks that the unstarted note has no attempt recorded.
+
+PR #11 round 5 confirmed the shutdown fix and made the timeout-summary concern concrete: the CLI adapter replaced the abort reason with generic cancellation. Direct adapter regressions reproduced this for timeout and shutdown reasons. The adapter now preserves Error-valued abort reasons; provider launch errors remain sanitized.
diff --git a/runner/question-agent.ts b/runner/question-agent.ts
index 5fb2cfe..59f0345 100644
--- a/runner/question-agent.ts
+++ b/runner/question-agent.ts
@@ -21,7 +21,7 @@ export function cliQuestionAgent(provider: Provider): QuestionAgent {
const chunks:Buffer[]=[];let bytes=0,diagnostic='';
child.stdout.on('data',(chunk:Buffer)=>{bytes+=chunk.length;if(bytes>1024*1024){child.kill('SIGKILL');reject(new Error('Agent output exceeded its limit.'));}else chunks.push(chunk);});
child.stderr.on('data',(chunk:Buffer)=>{diagnostic=(diagnostic+chunk.toString()).slice(-2000);});
- child.on('error',error=>reject(new Error(signal.aborted?'Agent cancelled.':`Could not start ${provider}. Check that its CLI is installed and signed in. (${error.name})`)));
+ child.on('error',error=>reject(signal.aborted && signal.reason instanceof Error ? signal.reason : new Error(signal.aborted?'Agent cancelled.':`Could not start ${provider}. Check that its CLI is installed and signed in. (${error.name})`)));
child.on('close',code=>code===0?resolve(Buffer.concat(chunks).toString('utf8')):reject(new Error(`${provider} exited with status ${code}. Check its login and usage limits.${/auth|login|sign.in/i.test(diagnostic)?' Authentication may be required.':''}`)));
child.stdin.on('error',()=>{});child.stdin.end(prompt);
});
diff --git a/test/question-agent.test.ts b/test/question-agent.test.ts
new file mode 100644
index 0000000..b96fa0c
--- /dev/null
+++ b/test/question-agent.test.ts
@@ -0,0 +1,18 @@
+import { EventEmitter } from 'node:events';
+import { afterEach, expect, it, vi } from 'vitest';
+import { spawn } from 'node:child_process';
+import { cliQuestionAgent } from '../runner/question-agent.ts';
+vi.mock('node:child_process',()=>({spawn:vi.fn()}));
+afterEach(()=>vi.clearAllMocks());
+it.each(['Agent timed out. Try again.','Server stopped. Retry the question.'])('preserves the cancellation reason: %s',async message=>{
+ vi.mocked(spawn).mockImplementation(((_command:unknown,_args:unknown,options:{signal:AbortSignal})=>{
+ const child=Object.assign(new EventEmitter(),{stdout:new EventEmitter(),stderr:new EventEmitter(),stdin:{on:vi.fn(),end:vi.fn()},kill:vi.fn()});
+ options.signal.addEventListener('abort',()=>child.emit('error',new Error('The operation was aborted')),{once:true});
+ return child;
+ }) as unknown as typeof spawn);
+ const controller=new AbortController();const answer=cliQuestionAgent('codex')('Question',controller.signal);
+ const result=answer.catch(error=>error);
+ await vi.waitFor(()=>expect(spawn).toHaveBeenCalledOnce());
+ controller.abort(new Error(message));
+ expect((await result).message).toBe(message);
+});
From 2a1877a3c2fdf0a129f9d84e083aac2a432eab88 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 15:04:20 -0700
Subject: [PATCH 14/23] Track cancelled question invocations through process
closure
---
docs/implementation/read-only-review.md | 4 ++++
runner/question-agent.ts | 8 ++++----
runner/questions.ts | 11 ++++++++---
test/browser/review.spec.ts | 2 +-
test/question-agent.test.ts | 7 ++++++-
test/questions.test.ts | 23 +++++++++++++++++++----
6 files changed, 42 insertions(+), 13 deletions(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index 0731cf3..0db4582 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -92,3 +92,7 @@ PR #11 review round 3 reproduced retrying a locally active job after its persist
PR #11 review round 4 reproduced new question work entering during shutdown. Questions now marks itself closing synchronously before cancelling/draining jobs; start rejects before any store write or agent invocation, during and after shutdown. The regression failed before the guard and checks that the unstarted note has no attempt recorded.
PR #11 round 5 confirmed the shutdown fix and made the timeout-summary concern concrete: the CLI adapter replaced the abort reason with generic cancellation. Direct adapter regressions reproduced this for timeout and shutdown reasons. The adapter now preserves Error-valued abort reasons; provider launch errors remain sanitized.
+
+PR #11 round 6 reproduced releasing a concurrency slot while a cancelled invocation was still unsettled. Cancellation now persists the visible failure promptly but retains the job until the invocation settles; retries remain blocked and shutdown awaits it. The CLI adapter defers rejection and temporary-directory cleanup until child close, including abort and output-limit failures. Regressions cover late settlement and abort-error-before-close ordering. Injected agents must settle after cancellation; production adapters terminate with SIGKILL and await closure.
+
+The review's polling-efficiency observation is tracked separately in issue #12: answer polling currently rebuilds the full review, and the follow-up will measure and remove that work while preserving snapshot metadata and stale-response protection.
diff --git a/runner/question-agent.ts b/runner/question-agent.ts
index 59f0345..35cc364 100644
--- a/runner/question-agent.ts
+++ b/runner/question-agent.ts
@@ -18,11 +18,11 @@ export function cliQuestionAgent(provider: Provider): QuestionAgent {
const stdout=await new Promise((resolve,reject)=>{
const env={...process.env};delete env.CLAUDECODE;delete env.NODE_OPTIONS;
const child=spawn(provider,agentArguments(provider),{cwd,env,stdio:['pipe','pipe','pipe'],signal,killSignal:'SIGKILL'});
- const chunks:Buffer[]=[];let bytes=0,diagnostic='';
- child.stdout.on('data',(chunk:Buffer)=>{bytes+=chunk.length;if(bytes>1024*1024){child.kill('SIGKILL');reject(new Error('Agent output exceeded its limit.'));}else chunks.push(chunk);});
+ const chunks:Buffer[]=[];let bytes=0,diagnostic='';let failure:Error|undefined;
+ child.stdout.on('data',(chunk:Buffer)=>{bytes+=chunk.length;if(bytes>1024*1024){failure ??= new Error('Agent output exceeded its limit.');child.kill('SIGKILL');}else chunks.push(chunk);});
child.stderr.on('data',(chunk:Buffer)=>{diagnostic=(diagnostic+chunk.toString()).slice(-2000);});
- child.on('error',error=>reject(signal.aborted && signal.reason instanceof Error ? signal.reason : new Error(signal.aborted?'Agent cancelled.':`Could not start ${provider}. Check that its CLI is installed and signed in. (${error.name})`)));
- child.on('close',code=>code===0?resolve(Buffer.concat(chunks).toString('utf8')):reject(new Error(`${provider} exited with status ${code}. Check its login and usage limits.${/auth|login|sign.in/i.test(diagnostic)?' Authentication may be required.':''}`)));
+ child.on('error',error=>{failure = signal.aborted && signal.reason instanceof Error ? signal.reason : new Error(signal.aborted?'Agent cancelled.':`Could not start ${provider}. Check that its CLI is installed and signed in. (${error.name})`);});
+ child.on('close',code=>failure?reject(failure):code===0?resolve(Buffer.concat(chunks).toString('utf8')):reject(new Error(`${provider} exited with status ${code}. Check its login and usage limits.${/auth|login|sign.in/i.test(diagnostic)?' Authentication may be required.':''}`)));
child.stdin.on('error',()=>{});child.stdin.end(prompt);
});
if(provider==='claude') {
diff --git a/runner/questions.ts b/runner/questions.ts
index 64e4f24..3dcfb74 100644
--- a/runner/questions.ts
+++ b/runner/questions.ts
@@ -34,19 +34,24 @@ export class Questions {
this.service.store.beginAnswer(this.service.config.identity,id,attempt,provider??undefined);
if(this.running.size>=2){this.service.store.finishAnswer(this.service.config.identity,id,attempt,{status:'failed',error:'Two questions are already running. Retry when one finishes.'});return;}
const timeout=setTimeout(()=>controller.abort(new Error('Agent timed out. Try again.')),120_000);
+ let invocation: Promise | undefined;
const done=(async()=>{
try {
if(!agent) throw new Error('Choose a question agent in Settings, then retry.');
const aborted = new Promise((_,reject)=>controller.signal.addEventListener('abort',()=>reject(controller.signal.reason),{once:true}));
- const text=await Promise.race([agent(questionPrompt(view,note),controller.signal),aborted]);
+ invocation = agent(questionPrompt(view,note),controller.signal);
+ const text=await Promise.race([invocation,aborted]);
if(typeof text!=='string'||!text.trim()||text.length>24000) throw new Error('Agent returned an empty or oversized answer.');
this.service.store.finishAnswer(this.service.config.identity,id,attempt,{status:'complete',text:text.trim()});
} catch(error) {
this.service.store.finishAnswer(this.service.config.identity,id,attempt,{status:'failed',error:(error instanceof Error?error.message:'Agent failed.').slice(0,1000)});
} finally {clearTimeout(timeout);}
})();
- this.running.set(id,{controller,done});
- void done.finally(()=>this.running.delete(id));
+ const settled = done.finally(async () => {
+ await invocation?.catch(() => {});
+ this.running.delete(id);
+ });
+ this.running.set(id,{controller,done:settled});
}
async close() {this.closing = true;for(const job of this.running.values())job.controller.abort(new Error('Server stopped. Retry the question.'));await Promise.all([...this.running.values()].map(job=>job.done));}
}
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index 55a6e03..edf4f00 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -213,7 +213,7 @@ test('Clear selection removes native text selection as well as selected lines',a
await expect(page.getByRole('group',{name:'Selected code'})).toBeHidden();
});
test('ignores question polls started before a newer submission',async({page})=>{
- const config=app.service.config;await app.close();app=await startServer(config,0,()=>new Promise(()=>{}));
+ const config=app.service.config;await app.close();app=await startServer(config,0,(_prompt,signal)=>new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true})));
await page.goto(app.url);await page.getByLabel('Question about this item').fill('First question');await page.getByRole('button',{name:'Ask agent',exact:true}).click();await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible();
let release!:()=>void,arrived!:()=>void;const held=new Promise(r=>release=r),captured=new Promise(r=>arrived=r);
await page.route('**/api/questions',async route=>{const response=await route.fetch();arrived();await held;await route.fulfill({response});});
diff --git a/test/question-agent.test.ts b/test/question-agent.test.ts
index b96fa0c..7ad4874 100644
--- a/test/question-agent.test.ts
+++ b/test/question-agent.test.ts
@@ -5,14 +5,19 @@ import { cliQuestionAgent } from '../runner/question-agent.ts';
vi.mock('node:child_process',()=>({spawn:vi.fn()}));
afterEach(()=>vi.clearAllMocks());
it.each(['Agent timed out. Try again.','Server stopped. Retry the question.'])('preserves the cancellation reason: %s',async message=>{
+ let childProcess:EventEmitter;
vi.mocked(spawn).mockImplementation(((_command:unknown,_args:unknown,options:{signal:AbortSignal})=>{
const child=Object.assign(new EventEmitter(),{stdout:new EventEmitter(),stderr:new EventEmitter(),stdin:{on:vi.fn(),end:vi.fn()},kill:vi.fn()});
+ childProcess=child;
options.signal.addEventListener('abort',()=>child.emit('error',new Error('The operation was aborted')),{once:true});
return child;
}) as unknown as typeof spawn);
const controller=new AbortController();const answer=cliQuestionAgent('codex')('Question',controller.signal);
- const result=answer.catch(error=>error);
+ let settled=false;
+ const result=answer.catch(error=>error).finally(()=>{settled=true;});
await vi.waitFor(()=>expect(spawn).toHaveBeenCalledOnce());
controller.abort(new Error(message));
+ await new Promise(resolve=>setTimeout(resolve,20));expect(settled).toBe(false);
+ childProcess!.emit('close',null);
expect((await result).message).toBe(message);
});
diff --git a/test/questions.test.ts b/test/questions.test.ts
index 7b0e53a..1a4adc7 100644
--- a/test/questions.test.ts
+++ b/test/questions.test.ts
@@ -10,6 +10,7 @@ import { agentArguments } from '../runner/question-agent.ts';
vi.setConfig({testTimeout:15000});
const roots:string[]=[], services:ReviewService[]=[], managers:Questions[]=[];
afterEach(async()=>{for(const manager of managers.splice(0))await manager.close();services.splice(0).forEach(s=>s.close());roots.splice(0).forEach(root=>rmSync(root,{recursive:true,force:true}));vi.restoreAllMocks();});
+function waitForAbort(_prompt:string,signal:AbortSignal):Promise{return new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}));}
function fixture(){const root=mkdtempSync(join(tmpdir(),'codeboost-answers-'));roots.push(root);const service=new ReviewService(createDemo(join(root,'demo')));services.push(service);return service;}
function question(service:ReviewService){const view=service.load();const segment=view.segments.find(s=>s.row==='P1'&&s.operation==='+')!;return service.act({action:'note',item:'P1',kind:'question',text:'Why cap the retry delay?',reference:{key:segment.key,start:segment.newLine,end:segment.newLine},token:view.token});}
it('persists answers with plan, code, selected snippet and prior conversation context',async()=>{
@@ -30,7 +31,7 @@ it('fails visibly and retries without duplicating the question or accepting stal
expect(service.store.getReviewNotes(service.config.identity)).toHaveLength(1);expect(service.store.getReviewNotes(service.config.identity)[0]!.answer!.text).toBe('Recovered answer');
});
it('prevents duplicate invocations and records interruption when the server stops',async()=>{
- const service=fixture(),asked=question(service);const manager=new Questions(service,()=>new Promise(()=>{}));managers.push(manager);manager.start(asked.createdNoteId!,asked);
+ const service=fixture(),asked=question(service);const manager=new Questions(service,waitForAbort);managers.push(manager);manager.start(asked.createdNoteId!,asked);
expect(()=>manager.start(asked.createdNoteId!,asked)).toThrow(/already answering/);await manager.close();
expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.error).toMatch(/Server stopped/);
});
@@ -40,7 +41,7 @@ it('persists provider selection and restricts commands to fixed provider launch
const codex=agentArguments('codex');expect(codex).toContain('read-only');expect(codex).toContain('features.shell_tool=false');expect(codex).toContain('features.plugins=false');
});
it('times out an unresponsive agent and allows expired pending attempts to be recovered',async()=>{
- const service=fixture(),asked=question(service);const manager=new Questions(service,()=>new Promise(()=>{}));managers.push(manager);
+ const service=fixture(),asked=question(service);const manager=new Questions(service,waitForAbort);managers.push(manager);
vi.useFakeTimers();
try {manager.start(asked.createdNoteId!,asked);await vi.advanceTimersByTimeAsync(120001);expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.error).toMatch(/timed out/);
service.store.beginAnswer(service.config.identity,asked.createdNoteId!,'interrupted');await vi.advanceTimersByTimeAsync(125001);service.store.beginAnswer(service.config.identity,asked.createdNoteId!,'replacement');service.store.finishAnswer(service.config.identity,asked.createdNoteId!,'interrupted',{status:'complete',text:'Old answer'});expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.attempt).toBe('replacement');
@@ -48,7 +49,7 @@ it('times out an unresponsive agent and allows expired pending attempts to be re
});
it('does not replace a locally running job when its persisted lease expires',async()=>{
const service=fixture(),asked=question(service);let calls=0,signal:AbortSignal|undefined;
- const manager=new Questions(service,(_prompt,currentSignal)=>{calls++;signal=currentSignal;return new Promise(()=>{});});managers.push(manager);
+ const manager=new Questions(service,(_prompt,currentSignal)=>{calls++;signal=currentSignal;return waitForAbort(_prompt,currentSignal);});managers.push(manager);
manager.start(asked.createdNoteId!,asked);
const original=service.store.getReviewNotes(service.config.identity)[0]!.answer!.attempt;
const later=Date.now()+130000;vi.spyOn(Date,'now').mockReturnValue(later);
@@ -58,7 +59,7 @@ it('does not replace a locally running job when its persisted lease expires',asy
});
it('rejects new work as soon as shutdown begins',async()=>{
const service=fixture(),first=question(service),second=question(service);let calls=0;
- const manager=new Questions(service,()=>{calls++;return new Promise(()=>{});});managers.push(manager);
+ const manager=new Questions(service,(prompt,signal)=>{calls++;return waitForAbort(prompt,signal);});managers.push(manager);
manager.start(first.createdNoteId!,first);
const closing=manager.close();
expect(()=>manager.start(second.createdNoteId!,second)).toThrow(/stopping/);
@@ -67,3 +68,17 @@ it('rejects new work as soon as shutdown begins',async()=>{
expect(calls).toBe(1);
expect(service.store.getReviewNotes(service.config.identity).find(note=>note.id===second.createdNoteId)?.answer).toBeUndefined();
});
+it('keeps cancelled invocations tracked until they settle',async()=>{
+ const service=fixture(),asked=question(service);let settle!:(value:string)=>void;
+ const manager=new Questions(service,()=>new Promise(resolve=>settle=resolve));managers.push(manager);
+ vi.useFakeTimers();
+ try {
+ manager.start(asked.createdNoteId!,asked);await vi.advanceTimersByTimeAsync(120001);
+ expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.status).toBe('failed');
+ expect(()=>manager.start(asked.createdNoteId!,asked)).toThrow(/already answering/);
+ let closed=false;const closing=manager.close().then(()=>{closed=true;});
+ await Promise.resolve();await Promise.resolve();expect(closed).toBe(false);
+ settle('Late answer');await closing;expect(closed).toBe(true);
+ expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.status).toBe('failed');
+ } finally {settle('Cleanup');vi.useRealTimers();}
+});
From fe4aa655e0acda6f9723e21ac3b39d859e275023 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 15:14:06 -0700
Subject: [PATCH 15/23] Preserve in-flight drafts and reject stale snippet
retries
---
docs/implementation/read-only-review.md | 2 ++
runner/questions.ts | 2 +-
test/browser/review.spec.ts | 12 ++++++++++++
test/questions.test.ts | 13 +++++++++++++
web/public/app.js | 21 ++++++++++++++-------
5 files changed, 42 insertions(+), 8 deletions(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index 0db4582..a7f4390 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -96,3 +96,5 @@ PR #11 round 5 confirmed the shutdown fix and made the timeout-summary concern c
PR #11 round 6 reproduced releasing a concurrency slot while a cancelled invocation was still unsettled. Cancellation now persists the visible failure promptly but retains the job until the invocation settles; retries remain blocked and shutdown awaits it. The CLI adapter defers rejection and temporary-directory cleanup until child close, including abort and output-limit failures. Regressions cover late settlement and abort-error-before-close ordering. Injected agents must settle after cancellation; production adapters terminate with SIGKILL and await closure.
The review's polling-efficiency observation is tracked separately in issue #12: answer polling currently rebuilds the full review, and the follow-up will measure and remove that work while preserving snapshot metadata and stale-response protection.
+
+PR #11 round 7 confirmed cancellation tracking and described outdated snippet retries in its summary. Direct UI reassignment of owned code is already rejected; a persisted assignment change reproduced the stale-reference case, now guarded in both manager and retry UI. The summary also mentioned in-flight composer state: browser tests reproduced lost typing during submission. Action responses now capture current drafts before rendering and clear only the unchanged submitted draft/attachment, preserving edits and drafts on other items.
diff --git a/runner/questions.ts b/runner/questions.ts
index 3dcfb74..6f0b68b 100644
--- a/runner/questions.ts
+++ b/runner/questions.ts
@@ -27,7 +27,7 @@ export class Questions {
if (this.running.has(id)) throw new Error('Agent is already answering this question.');
const note = view.notes.find(n=>n.id===id && n.kind==='question');
if (!note) throw new Error('Question not found.');
- if (note.snapshotId!==view.snapshot.id || note.revision!==view.plan.revision) throw new Error('This question belongs to an older review. Ask again against the current code.');
+ if (note.outdated || note.snapshotId!==view.snapshot.id || note.revision!==view.plan.revision) throw new Error('This question belongs to an older review. Ask again against the current code.');
const provider=this.service.store.questionProvider();
const agent=this.agent ?? (provider ? cliQuestionAgent(provider) : undefined);
const attempt=randomUUID(), controller=new AbortController();
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index edf4f00..bad6e1c 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -233,3 +233,15 @@ test('outdated questions explain how to continue without offering a broken retry
await expect(page.getByText('This question refers to an earlier review. Ask again against the current code.',{exact:true})).toBeVisible();
await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible();
});
+for(const switchItem of [false,true]) test(`preserves edits made while a question submission is in flight (switch item: ${switchItem})`,async({page})=>{
+ await page.goto(app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible();
+ let release!:()=>void;const held=new Promise(resolve=>release=resolve);
+ await page.route('**/api/action',async route=>{await held;await route.continue();});
+ await page.getByLabel('Question about this item').fill('Submitted question');await page.getByRole('button',{name:'Ask agent',exact:true}).click();
+ await page.getByLabel('Question about this item').fill('New unsent draft');
+ if(switchItem){await page.getByRole('button',{name:/P2 Document retry behavior/}).click();await page.getByLabel('Question about this item').fill('Other item draft');}
+ release();
+ if(switchItem){await expect(page.locator('#saved')).toContainText('Question submitted');await expect(page.getByLabel('Question about this item')).toHaveValue('Other item draft');await page.getByRole('button',{name:/P1 Bound exponential retries/}).click();}
+ await expect(page.getByText('Submitted question',{exact:true})).toBeVisible();
+ await expect(page.getByLabel('Question about this item')).toHaveValue('New unsent draft');
+});
diff --git a/test/questions.test.ts b/test/questions.test.ts
index 1a4adc7..c68db6e 100644
--- a/test/questions.test.ts
+++ b/test/questions.test.ts
@@ -5,6 +5,7 @@ import { join } from 'node:path';
import { createDemo } from '../scripts/demo.ts';
import { ReviewService } from '../runner/review.ts';
import { Questions } from '../runner/questions.ts';
+import { choiceKeys } from '../core/approvals.ts';
import { agentArguments } from '../runner/question-agent.ts';
// Real-Git context reads match the existing review integration suite budget.
vi.setConfig({testTimeout:15000});
@@ -82,3 +83,15 @@ it('keeps cancelled invocations tracked until they settle',async()=>{
expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.status).toBe('failed');
} finally {settle('Cleanup');vi.useRealTimers();}
});
+it('rejects questions whose snippet was reassigned without changing the snapshot',()=>{
+ const service=fixture();const initial=service.load(),foreign=initial.segments.find(s=>s.row==='Unplanned'&&s.operation==='+')!;
+ const assigned=service.act({action:'assign',item:'P1',key:foreign.key,token:initial.token});
+ const asked=service.act({action:'note',item:'P1',kind:'question',text:'Explain this',reference:{key:foreign.key,start:foreign.newLine,end:foreign.newLine},token:assigned.token});
+ const note=asked.notes.find(note=>note.id===asked.createdNoteId)!;
+ const index=initial.segments.findIndex(segment=>segment.key===foreign.key);
+ service.store.saveReview(service.config.identity,asked.expected,[],[{action:'assign',item:'P2',key:choiceKeys(initial.segments,service.config.identity)[index]!}]);
+ const moved=service.load();
+ expect(moved.snapshot.id).toBe(asked.snapshot.id);expect(moved.notes.find(n=>n.id===note.id)?.outdated).toBe(true);
+ const agent=vi.fn(async()=>'Should not run');const manager=new Questions(service,agent);managers.push(manager);
+ expect(()=>manager.start(note.id,moved)).toThrow(/older review|outdated/);expect(agent).not.toHaveBeenCalled();
+});
diff --git a/web/public/app.js b/web/public/app.js
index f85b351..60b9442 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -102,10 +102,13 @@ async function act(command) {
renderAttachment();
try {
rememberDraft();
- data = await api("/api/action", { ...command, token: data.token });
+ const updated = await api("/api/action", { ...command, token: data.token });
+ rememberDraft();
+ data = updated;
render();
return true;
} catch (error) {
+ rememberDraft();
showFailure(`${error.message} Refresh to review the latest state.`);
return false;
} finally {
@@ -365,15 +368,19 @@ $("composer").onsubmit = async (event) => {
if (busy || !data) return;
const item = selected,
kind = mode;
+ const submittedText = $("message").value;
const attached = attachments.get(`${item}:${kind}`);
if (attached && (attached.head !== data.snapshot.head || attached.base !== data.snapshot.base)) return;
$("saved").textContent = kind === "change" ? "Saving change request…" : "Asking agent…";
- if (await act({ action: "note", item, kind, text: $("message").value, ...(attached ? {reference:{key:attached.key,start:attached.start,end:attached.end}} : {}) })) {
- $("notes").lastElementChild?.scrollIntoView({ block: "nearest" });
- drafts.delete(`${item}:${kind}`);
- attachments.delete(`${item}:${kind}`);
+ if (await act({ action: "note", item, kind, text: submittedText, ...(attached ? {reference:{key:attached.key,start:attached.start,end:attached.end}} : {}) })) {
+ if (selected === item) $("notes").lastElementChild?.scrollIntoView({ block: "nearest" });
+ const unchanged = drafts.get(`${item}:${kind}`) === submittedText && attachments.get(`${item}:${kind}`) === attached;
+ if (unchanged) {
+ drafts.delete(`${item}:${kind}`);
+ attachments.delete(`${item}:${kind}`);
+ }
renderAttachment();
- $("message").value = "";
+ $("message").value = drafts.get(`${selected}:${mode}`) || "";
$("saved").textContent =
kind === "change"
? "Saved for the next revision."
@@ -558,7 +565,7 @@ function answerMarkup(note) {
if(note.kind!=="question") return "";
const answer=note.answer;
if(answer?.status==="complete") return `${answer.provider === "claude" ? "Claude Code" : answer.provider === "codex" ? "Codex" : "Agent"} ${esc(answer.text)}
${note.answerOutdated ? '! Answer refers to an earlier review snapshot. ' : ""} `;
- if(note.answerOutdated) return 'This question refers to an earlier review. Ask again against the current code.
';
+ if(note.answerOutdated || note.outdated) return 'This question refers to an earlier review. Ask again against the current code.
';
if(answer?.status==="pending" && answer.expiresAt>Date.now()) return 'Agent · Answering…
';
const error=answer?.status==="failed"?answer.error:answer?.status==="pending"?"Agent was interrupted or timed out.":"Answer not started. Choose an agent in Settings or retry when capacity is available.";
return `! ${esc(error)}
Retry answer `;
From 9efbe9fbbe71cb73a43336f8c7d6f418fa06e873 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 15:23:15 -0700
Subject: [PATCH 16/23] Warn when completed answers reference reassigned code
---
docs/implementation/read-only-review.md | 2 ++
test/browser/review.spec.ts | 13 +++++++++++++
web/public/app.js | 2 +-
3 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index a7f4390..8d2f069 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -98,3 +98,5 @@ PR #11 round 6 reproduced releasing a concurrency slot while a cancelled invocat
The review's polling-efficiency observation is tracked separately in issue #12: answer polling currently rebuilds the full review, and the follow-up will measure and remove that work while preserving snapshot metadata and stale-response protection.
PR #11 round 7 confirmed cancellation tracking and described outdated snippet retries in its summary. Direct UI reassignment of owned code is already rejected; a persisted assignment change reproduced the stale-reference case, now guarded in both manager and retry UI. The summary also mentioned in-flight composer state: browser tests reproduced lost typing during submission. Action responses now capture current drafts before rendering and clear only the unchanged submitted draft/attachment, preserving edits and drafts on other items.
+
+PR #11 round 8 returned no inline findings and identified one related summary gap: completed answers only warned for an older snapshot or plan revision, not a snippet invalidated by reassignment. Completed answers now show the same historical-context warning for either condition. A browser regression preserves the historical answer while checking the warning after a persisted assignment change.
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index bad6e1c..c9b8d08 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { createDemo } from '../../scripts/demo.ts';
+import { choiceKeys } from '../../core/approvals.ts';
import { startServer } from '../../web/server.ts';
let root: string, app: Awaited>;
test.beforeEach(async () => { root=mkdtempSync(join(tmpdir(),'codeboost-browser-'));app=await startServer(createDemo(join(root,'demo')),0); });
@@ -245,3 +246,15 @@ for(const switchItem of [false,true]) test(`preserves edits made while a questio
await expect(page.getByText('Submitted question',{exact:true})).toBeVisible();
await expect(page.getByLabel('Question about this item')).toHaveValue('New unsent draft');
});
+test('warns on completed answers when a snippet assignment changes',async({page})=>{
+ const service=app.service,identity=service.config.identity,initial=service.load();
+ const foreign=initial.segments.find(s=>s.row==='Unplanned'&&s.operation==='+')!;
+ const assigned=service.act({action:'assign',item:'P1',key:foreign.key,token:initial.token});
+ const asked=service.act({action:'note',item:'P1',kind:'question',text:'Explain assigned code',reference:{key:foreign.key,start:foreign.newLine,end:foreign.newLine},token:assigned.token});
+ service.store.beginAnswer(identity,asked.createdNoteId!,'completed');service.store.finishAnswer(identity,asked.createdNoteId!,'completed',{status:'complete',text:'Historical answer'});
+ const index=initial.segments.findIndex(s=>s.key===foreign.key);
+ service.store.saveReview(identity,asked.expected,[],[{action:'assign',item:'P2',key:choiceKeys(initial.segments,identity)[index]!}]);
+ await page.goto(app.url);
+ await expect(page.locator('.agent-answer')).toContainText('Historical answer');
+ await expect(page.locator('.agent-answer')).toContainText('Answer refers to earlier code or review context.');
+});
diff --git a/web/public/app.js b/web/public/app.js
index 60b9442..eebd3fb 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -564,7 +564,7 @@ $("code").addEventListener("keyup",captureHighlightedLines);
function answerMarkup(note) {
if(note.kind!=="question") return "";
const answer=note.answer;
- if(answer?.status==="complete") return `${answer.provider === "claude" ? "Claude Code" : answer.provider === "codex" ? "Codex" : "Agent"} ${esc(answer.text)}
${note.answerOutdated ? '! Answer refers to an earlier review snapshot. ' : ""} `;
+ if(answer?.status==="complete") return `${answer.provider === "claude" ? "Claude Code" : answer.provider === "codex" ? "Codex" : "Agent"} ${esc(answer.text)}
${note.answerOutdated || note.outdated ? '! Answer refers to earlier code or review context. ' : ""} `;
if(note.answerOutdated || note.outdated) return 'This question refers to an earlier review. Ask again against the current code.
';
if(answer?.status==="pending" && answer.expiresAt>Date.now()) return 'Agent · Answering…
';
const error=answer?.status==="failed"?answer.error:answer?.status==="pending"?"Agent was interrupted or timed out.":"Answer not started. Choose an agent in Settings or retry when capacity is available.";
From e7d3adb7787a19a78854faa5272ccf089ac39e0c Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 15:32:20 -0700
Subject: [PATCH 17/23] Drain review requests before stopping question agents
---
docs/implementation/read-only-review.md | 4 ++++
test/browser/review.spec.ts | 24 ++++++++++++++++++++++++
web/server.ts | 6 +++++-
3 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index 8d2f069..fa68559 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -100,3 +100,7 @@ The review's polling-efficiency observation is tracked separately in issue #12:
PR #11 round 7 confirmed cancellation tracking and described outdated snippet retries in its summary. Direct UI reassignment of owned code is already rejected; a persisted assignment change reproduced the stale-reference case, now guarded in both manager and retry UI. The summary also mentioned in-flight composer state: browser tests reproduced lost typing during submission. Action responses now capture current drafts before rendering and clear only the unchanged submitted draft/attachment, preserving edits and drafts on other items.
PR #11 round 8 returned no inline findings and identified one related summary gap: completed answers only warned for an older snapshot or plan revision, not a snippet invalidated by reassignment. Completed answers now show the same historical-context warning for either condition. A browser regression preserves the historical answer while checking the warning after a persisted assignment change.
+
+PR #11 round 9 returned no inline findings and identified shutdown ordering in its summary. Server shutdown now stops accepting connections and drains in-flight HTTP requests before closing the question manager and database. A partial-body request regression proves a question already admitted during shutdown starts its agent and is persisted as interrupted rather than left unanswered.
+
+Current PR validation: 187 unit/integration tests, 31 browser tests, typecheck, and diff checks. Earlier counts above identify the stage when each behavior was added.
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index c9b8d08..2afdf97 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -3,8 +3,10 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
+import { request as httpRequest } from 'node:http';
import { createDemo } from '../../scripts/demo.ts';
import { choiceKeys } from '../../core/approvals.ts';
+import { ReviewService } from '../../runner/review.ts';
import { startServer } from '../../web/server.ts';
let root: string, app: Awaited>;
test.beforeEach(async () => { root=mkdtempSync(join(tmpdir(),'codeboost-browser-'));app=await startServer(createDemo(join(root,'demo')),0); });
@@ -258,3 +260,25 @@ test('warns on completed answers when a snippet assignment changes',async({page}
await expect(page.locator('.agent-answer')).toContainText('Historical answer');
await expect(page.locator('.agent-answer')).toContainText('Answer refers to earlier code or review context.');
});
+test('drains an in-flight question request before closing its agent manager',async()=>{
+ const config=app.service.config;await app.close();let calls=0;
+ app=await startServer(config,0,(_prompt,signal)=>{calls++;return new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}));});
+ const view=app.service.load(),body=JSON.stringify({action:'note',item:'P1',kind:'question',text:'Question during shutdown',token:view.token});
+ const endpoint=new URL('/api/action',app.url);
+ let response='';
+ const completed=new Promise((resolve,reject)=>{
+ const req=httpRequest(endpoint,{method:'POST',headers:{'x-codeboost-token':app.token,'content-type':'application/json','content-length':Buffer.byteLength(body)}},res=>{
+ res.setEncoding('utf8');res.on('data',chunk=>response+=chunk);res.on('end',resolve);
+ });
+ req.on('error',reject);req.write(body.slice(0,1));
+ setTimeout(()=>req.end(body.slice(1)),50);
+ });
+ await new Promise(resolve=>setTimeout(resolve,10));
+ await Promise.all([app.close(),completed]);
+ const reopened=new ReviewService(config);
+ try {
+ const note=reopened.load().notes.find(note=>note.text==='Question during shutdown');
+ expect(calls).toBe(1);expect(note?.answer?.status).toBe('failed');expect(note?.answer?.error).toMatch(/Server stopped/);
+ expect(JSON.parse(response).notes.some((candidate:{text:string})=>candidate.text==='Question during shutdown')).toBe(true);
+ } finally {reopened.close();app=await startServer(config,0);}
+});
diff --git a/web/server.ts b/web/server.ts
index aebb3c0..e09d081 100644
--- a/web/server.ts
+++ b/web/server.ts
@@ -56,5 +56,9 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge
server.requestTimeout = 15000;
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', () => { server.removeListener('error', reject); resolve(); }); }).catch(error => { service.close(); throw error; });
const address = server.address(); if (!address || typeof address === 'string') throw new Error('Cannot determine local address.');
- return { server, service, token, url: `http://127.0.0.1:${address.port}/#${token}`, close: async () => { await questions.close(); await new Promise((resolve, reject) => server.close(error => { service.close(); error ? reject(error) : resolve(); })); } };
+ return { server, service, token, url: `http://127.0.0.1:${address.port}/#${token}`, close: async () => {
+ await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
+ await questions.close();
+ service.close();
+ } };
}
From ccf161e0db486ca1e4e0f426e16526ae04184752 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 15:39:10 -0700
Subject: [PATCH 18/23] Add async lifecycle review rules for agents
---
AGENTS.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 47 insertions(+)
create mode 100644 AGENTS.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..fcc6971
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,47 @@
+# Repository agent instructions
+
+Follow the repository conventions in `CLAUDE.md`. Read `DESIGN.md` before making visual or interaction changes.
+
+## Async jobs and polling
+
+For features with background jobs, polling, retries, cancellation, or shutdown:
+
+- Define the lifecycle states and ownership before implementation: pending, running, completed, failed, cancelled, stale, and closing.
+- Treat persisted state, in-memory jobs, subprocesses, HTTP requests, and rendered UI as separate state holders. Define how each transitions and settles.
+- Never apply a background response without proving it is still current. Use a generation, attempt ID, version, or guarded merge so older polling responses cannot overwrite newer actions.
+- Do not release a concurrency slot when cancellation is requested. Keep the job tracked until its underlying invocation or subprocess has terminated.
+- Do not let a retry replace a locally active job, even when its persisted lease has expired or wall-clock time changes.
+- Validate retry context against the current snapshot, plan revision, assignment, and referenced code. If any context is stale, disable retry and require a new request.
+- Preserve the original timeout, cancellation, and shutdown reason through every layer. Do not replace actionable errors with generic cancellation text.
+- Begin shutdown by rejecting new work at the outer admission boundary. Drain already-admitted HTTP requests, then cancel and await jobs, then close storage.
+- Polling endpoints should read only the state they need. Do not rebuild Git history or the full review merely to retrieve background-job status.
+
+## Async review UI
+
+- A background response must not erase text, selections, attachments, navigation changes, or other input made after the request started.
+- Clear a submitted draft only if its current value and attachment still match what was submitted. Treat this as compare-and-swap behavior.
+- Preserve completed historical results, but visibly mark them stale when their snapshot, plan revision, assignment, or referenced code no longer matches.
+- When polling updates one part of the screen, update only that state. Preserve scroll position unless the user was already following the bottom.
+
+## Required race regressions
+
+Before opening or updating a PR for asynchronous behavior, test every applicable interleaving with controllable promises, clocks, and partial requests:
+
+- Poll starts, then a user action completes, then the old poll returns.
+- A job lease expires, then retry is attempted while the original job still runs.
+- Timeout fires, then the provider remains unsettled temporarily, then retry is attempted.
+- Shutdown starts, then a new request arrives.
+- A request is partially received, then shutdown starts, then the request completes.
+- An abort error fires, then subprocess close arrives later.
+- Submit starts, then the user edits the composer or switches items, then the response returns.
+- Referenced code is reassigned or the snapshot changes, then retry or rendering occurs.
+
+Every reproduced race requires a failing-before and passing-after regression. Assert both the visible result and the durable state when they can diverge.
+
+## Review readiness
+
+- Run final validation against the exact pushed head after the last change.
+- Report current test counts separately from historical milestone counts.
+- Before requesting automated review, report the current head, CI state, mergeability, unresolved threads, and deferred follow-up issues.
+- Reproduce summary-only review concerns or turn them into a concrete follow-up issue. Do not repeatedly patch vague wording without a failure case.
+- For each review round, record what changed, what was declined and why, and the regression evidence. Re-request review until a round returns no new findings.
From 9051902221684a2e933a75c0c25b870e4632a625 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 15:51:11 -0700
Subject: [PATCH 19/23] Hide retries while timed-out agents are settling
---
docs/implementation/read-only-review.md | 4 +++-
runner/questions.ts | 1 +
test/browser/review.spec.ts | 14 +++++++++++++-
web/public/app.js | 9 +++------
web/server.ts | 2 +-
5 files changed, 21 insertions(+), 9 deletions(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index fa68559..bf1e8db 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -103,4 +103,6 @@ PR #11 round 8 returned no inline findings and identified one related summary ga
PR #11 round 9 returned no inline findings and identified shutdown ordering in its summary. Server shutdown now stops accepting connections and drains in-flight HTTP requests before closing the question manager and database. A partial-body request regression proves a question already admitted during shutdown starts its agent and is persisted as interrupted rather than left unanswered.
-Current PR validation: 187 unit/integration tests, 31 browser tests, typecheck, and diff checks. Earlier counts above identify the stage when each behavior was added.
+Current PR validation: 187 unit/integration tests, 32 browser tests, typecheck, and diff checks. Earlier counts above identify the stage when each behavior was added.
+
+The documentation-only review after adding `AGENTS.md` exposed one more concrete lifecycle race: an expired persisted attempt could show Retry while its cancelled provider was still settling. Question polling now exposes whether the local invocation remains active, displays Finishing cancellation, and keeps polling without exposing Retry until settlement. A browser regression advances the persisted clock while leaving the invocation unresolved, then confirms Retry appears only after settlement.
diff --git a/runner/questions.ts b/runner/questions.ts
index 6f0b68b..0ac383f 100644
--- a/runner/questions.ts
+++ b/runner/questions.ts
@@ -22,6 +22,7 @@ export class Questions {
private service: ReviewService;
private agent?: QuestionAgent;
constructor(service: ReviewService, agent?: QuestionAgent) { this.service=service; this.agent=agent; }
+ isRunning(id: string) { return this.running.has(id); }
start(id: string, view: ReturnType) {
if (this.closing) throw new Error('Server is stopping. Reconnect before asking again.');
if (this.running.has(id)) throw new Error('Agent is already answering this question.');
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index 2afdf97..78d061d 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -126,7 +126,7 @@ test('shows interrupted questions as retryable without polling indefinitely',asy
const now=Date.now;Date.now=()=>now()-200000;
try {service.store.beginAnswer(service.config.identity,asked.createdNoteId!,'interrupted-attempt');} finally {Date.now=now;}
let polls=0;page.on('request',request=>{if(request.url().endsWith('/api/questions'))polls++;});
- await page.clock.install();await page.goto(app.url);await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toBeVisible();await page.clock.fastForward(3000);await expect(page.getByText(/Agent was interrupted or timed out/)).toBeVisible();expect(polls).toBe(0);
+ await page.clock.install();await page.goto(app.url);await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toBeVisible();await page.clock.fastForward(3000);await expect(page.getByText(/Agent was interrupted or timed out/)).toBeVisible();expect(polls).toBe(1);
});
test('opens Settings while the initial review is still loading',async({page})=>{
let release!:()=>void;const ready=new Promise(resolve=>{release=resolve;});
@@ -282,3 +282,15 @@ test('drains an in-flight question request before closing its agent manager',asy
expect(JSON.parse(response).notes.some((candidate:{text:string})=>candidate.text==='Question during shutdown')).toBe(true);
} finally {reopened.close();app=await startServer(config,0);}
});
+test('hides Retry until a timed-out invocation has actually settled',async({page})=>{
+ const config=app.service.config;await app.close();let settle!:(answer:string)=>void;
+ app=await startServer(config,0,()=>new Promise(resolve=>settle=resolve));
+ await page.goto(app.url);await page.getByLabel('Question about this item').fill('Slow question');await page.getByRole('button',{name:'Ask agent',exact:true}).click();
+ await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible();
+ const note=app.service.store.getReviewNotes(config.identity).find(note=>note.text==='Slow question')!;
+ app.service.store.finishAnswer(config.identity,note.id,note.answer!.attempt,{status:'failed',error:'Agent timed out. Try again.'});
+ await expect(page.getByText('Agent · Finishing cancellation…',{exact:true})).toBeVisible({timeout:10000});
+ await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toHaveCount(0);
+ settle('Late answer');
+ await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toBeVisible({timeout:10000});
+});
diff --git a/web/public/app.js b/web/public/app.js
index eebd3fb..2ef5fa1 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -567,6 +567,7 @@ function answerMarkup(note) {
if(answer?.status==="complete") return `${answer.provider === "claude" ? "Claude Code" : answer.provider === "codex" ? "Codex" : "Agent"} ${esc(answer.text)}
${note.answerOutdated || note.outdated ? '! Answer refers to earlier code or review context. ' : ""} `;
if(note.answerOutdated || note.outdated) return 'This question refers to an earlier review. Ask again against the current code.
';
if(answer?.status==="pending" && answer.expiresAt>Date.now()) return 'Agent · Answering…
';
+ if(note.answerActive) return 'Agent · Finishing cancellation…
';
const error=answer?.status==="failed"?answer.error:answer?.status==="pending"?"Agent was interrupted or timed out.":"Answer not started. Choose an agent in Settings or retry when capacity is available.";
return `! ${esc(error)}
Retry answer `;
}
@@ -602,14 +603,10 @@ function renderNotes({ follow = false } = {}) {
}
let pollingQuestions=false;
setInterval(async()=>{
- if(pollingQuestions || busy || !data || !data.notes.some(n=>n.answer?.status==="pending")) return;
- if(data.notes.every(n=>n.answer?.status!=="pending" || n.answer.expiresAt<=Date.now())) {
- data.notes=data.notes.map(n=>n.answer?.status==="pending"?{...n,answer:{...n.answer,status:"failed",error:"Agent was interrupted or timed out. Retry the question."}}:n);
- renderNotes({ follow: true });return;
- }
+ if(pollingQuestions || busy || !data || !data.notes.some(n=>n.answer?.status==="pending" || n.answerActive)) return;
pollingQuestions=true;
const generation = reviewGeneration;
- try {const response=await api("/api/questions");if(data && generation === reviewGeneration){data.notes=response.notes;renderNotes({ follow: true });}}
+ try {const response=await api("/api/questions");if(data && generation === reviewGeneration){data.notes=response.notes.map(note=>note.answer?.status==="pending" && note.answer.expiresAt<=Date.now() && !note.answerActive ? {...note,answer:{...note.answer,status:"failed",error:"Agent was interrupted or timed out. Retry the question."}} : note);renderNotes({ follow: true });}}
catch { if (generation === reviewGeneration) $("saved").textContent="Could not refresh agent answers. Use Refresh to reconnect."; }
finally {pollingQuestions=false;}
},2000);
diff --git a/web/server.ts b/web/server.ts
index e09d081..8407c5a 100644
--- a/web/server.ts
+++ b/web/server.ts
@@ -21,7 +21,7 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge
const supplied = req.headers['x-codeboost-token'];
if (typeof supplied !== 'string' || !/^[a-f0-9]{64}$/.test(supplied) || !timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) { json(403, { error: 'Open the private local URL printed by the CLI.' }); return; }
if (req.method === 'GET' && path === '/api/settings') { json(200,{questionProvider:service.store.questionProvider()});return; }
- if (req.method === 'GET' && path === '/api/questions') { json(200,{notes:service.load().notes});return; }
+ if (req.method === 'GET' && path === '/api/questions') { json(200,{notes:service.load().notes.map(note=>({...note,answerActive:questions.isRunning(note.id)}))});return; }
if (req.method === 'GET' && path === '/api/review') { json(200, service.load()); return; }
if (req.method !== 'POST' || !['/api/action','/api/settings'].includes(path) || req.headers['content-type'] !== 'application/json') { json(405, { error: 'Unsupported request.' }); return; }
const chunks: Buffer[] = []; let size = 0;
From 39ab8620af9235e505d27ed66eaa789b821bf1d9 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 15:58:41 -0700
Subject: [PATCH 20/23] Preserve active question state on reload
---
docs/implementation/read-only-review.md | 2 ++
test/browser/review.spec.ts | 1 +
web/server.ts | 9 +++++----
3 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index bf1e8db..c7d1b99 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -106,3 +106,5 @@ PR #11 round 9 returned no inline findings and identified shutdown ordering in i
Current PR validation: 187 unit/integration tests, 32 browser tests, typecheck, and diff checks. Earlier counts above identify the stage when each behavior was added.
The documentation-only review after adding `AGENTS.md` exposed one more concrete lifecycle race: an expired persisted attempt could show Retry while its cancelled provider was still settling. Question polling now exposes whether the local invocation remains active, displays Finishing cancellation, and keeps polling without exposing Retry until settlement. A browser regression advances the persisted clock while leaving the invocation unresolved, then confirms Retry appears only after settlement.
+
+The follow-up review exposed the same active-job marker missing from the initial review response. Reloading during provider settlement could therefore expose Retry and stop polling. Initial loads and action responses now include the marker used by question polling; the browser regression reloads during settlement and confirms Retry remains hidden until the invocation finishes.
diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts
index 78d061d..e5ec184 100644
--- a/test/browser/review.spec.ts
+++ b/test/browser/review.spec.ts
@@ -289,6 +289,7 @@ test('hides Retry until a timed-out invocation has actually settled',async({page
await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible();
const note=app.service.store.getReviewNotes(config.identity).find(note=>note.text==='Slow question')!;
app.service.store.finishAnswer(config.identity,note.id,note.answer!.attempt,{status:'failed',error:'Agent timed out. Try again.'});
+ await page.reload();
await expect(page.getByText('Agent · Finishing cancellation…',{exact:true})).toBeVisible({timeout:10000});
await expect(page.getByRole('button',{name:'Retry answer',exact:true})).toHaveCount(0);
settle('Late answer');
diff --git a/web/server.ts b/web/server.ts
index 8407c5a..4d5b2ee 100644
--- a/web/server.ts
+++ b/web/server.ts
@@ -8,6 +8,7 @@ const publicRoot = new URL('./public/', import.meta.url);
export async function startServer(config: ReviewConfig, port = 4318, questionAgent?: QuestionAgent) {
const service = new ReviewService(config), token = randomBytes(32).toString('hex');
const questions=new Questions(service,questionAgent);
+ const load=()=>{const view=service.load();return {...view,notes:view.notes.map(note=>({...note,answerActive:questions.isRunning(note.id)}))};};
const server = createServer(async (req, res) => {
const address = server.address(); const actualPort = address && typeof address !== 'string' ? address.port : port;
const origin = `http://127.0.0.1:${actualPort}`;
@@ -21,8 +22,8 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge
const supplied = req.headers['x-codeboost-token'];
if (typeof supplied !== 'string' || !/^[a-f0-9]{64}$/.test(supplied) || !timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) { json(403, { error: 'Open the private local URL printed by the CLI.' }); return; }
if (req.method === 'GET' && path === '/api/settings') { json(200,{questionProvider:service.store.questionProvider()});return; }
- if (req.method === 'GET' && path === '/api/questions') { json(200,{notes:service.load().notes.map(note=>({...note,answerActive:questions.isRunning(note.id)}))});return; }
- if (req.method === 'GET' && path === '/api/review') { json(200, service.load()); return; }
+ if (req.method === 'GET' && path === '/api/questions') { json(200,{notes:load().notes});return; }
+ if (req.method === 'GET' && path === '/api/review') { json(200, load()); return; }
if (req.method !== 'POST' || !['/api/action','/api/settings'].includes(path) || req.headers['content-type'] !== 'application/json') { json(405, { error: 'Unsupported request.' }); return; }
const chunks: Buffer[] = []; let size = 0;
for await (const chunk of req) { size += chunk.length; if (size > 16384) { json(413, { error: 'Request too large.' }); return; } chunks.push(chunk); }
@@ -31,7 +32,7 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge
if(path==='/api/settings') {service.store.setQuestionProvider(input.questionProvider);json(200,{questionProvider:service.store.questionProvider()});return;}
if(input.action==='retry-question') {
const view=service.load();if(input.token!==view.token)throw new Error('Stale review state. Refresh and retry.');
- questions.start(input.id,view);json(200,service.load());return;
+ questions.start(input.id,view);json(200,load());return;
}
const view=service.act(input);
if(view.createdNoteId && input.kind==='question') {
@@ -39,7 +40,7 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge
// The saved question remains visible and retryable when capacity is reached.
}
}
- json(200,service.load());return;
+ json(200,load());return;
}
if (req.method !== 'GET') { json(405, { error: 'Method not allowed.' }); return; }
const files: Record = { '/': ['index.html', 'text/html'], '/app.js': ['app.js', 'text/javascript'], '/style.css': ['style.css', 'text/css'] };
From 8b5b48b51409d71c944dbfc60964d039fc4541fc Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 16:08:34 -0700
Subject: [PATCH 21/23] Track answer assignment context
---
docs/implementation/read-only-review.md | 4 +++-
runner/questions.ts | 2 +-
runner/review.ts | 3 ++-
runner/store.ts | 6 +++---
test/questions.test.ts | 10 ++++++++++
5 files changed, 19 insertions(+), 6 deletions(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index c7d1b99..f751022 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -103,8 +103,10 @@ PR #11 round 8 returned no inline findings and identified one related summary ga
PR #11 round 9 returned no inline findings and identified shutdown ordering in its summary. Server shutdown now stops accepting connections and drains in-flight HTTP requests before closing the question manager and database. A partial-body request regression proves a question already admitted during shutdown starts its agent and is persisted as interrupted rather than left unanswered.
-Current PR validation: 187 unit/integration tests, 32 browser tests, typecheck, and diff checks. Earlier counts above identify the stage when each behavior was added.
+Current PR validation: 188 unit/integration tests, 32 browser tests, typecheck, and diff checks. Earlier counts above identify the stage when each behavior was added.
The documentation-only review after adding `AGENTS.md` exposed one more concrete lifecycle race: an expired persisted attempt could show Retry while its cancelled provider was still settling. Question polling now exposes whether the local invocation remains active, displays Finishing cancellation, and keeps polling without exposing Retry until settlement. A browser regression advances the persisted clock while leaving the invocation unresolved, then confirms Retry appears only after settlement.
The follow-up review exposed the same active-job marker missing from the initial review response. Reloading during provider settlement could therefore expose Retry and stop polling. Initial loads and action responses now include the marker used by question polling; the browser regression reloads during settlement and confirms Retry remains hidden until the invocation finishes.
+
+The next review made assignment drift concrete for item-level answers without snippet references. Each answer now records a hash of the changed segments supplied for its item. Moving code into or out of that item preserves the answer but labels it as earlier review context; a regression assigns new code without changing the snapshot or plan revision and checks the historical marker.
diff --git a/runner/questions.ts b/runner/questions.ts
index 0ac383f..29ef270 100644
--- a/runner/questions.ts
+++ b/runner/questions.ts
@@ -32,7 +32,7 @@ export class Questions {
const provider=this.service.store.questionProvider();
const agent=this.agent ?? (provider ? cliQuestionAgent(provider) : undefined);
const attempt=randomUUID(), controller=new AbortController();
- this.service.store.beginAnswer(this.service.config.identity,id,attempt,provider??undefined);
+ this.service.store.beginAnswer(this.service.config.identity,id,attempt,provider??undefined,note.contextId);
if(this.running.size>=2){this.service.store.finishAnswer(this.service.config.identity,id,attempt,{status:'failed',error:'Two questions are already running. Retry when one finishes.'});return;}
const timeout=setTimeout(()=>controller.abort(new Error('Agent timed out. Try again.')),120_000);
let invocation: Promise | undefined;
diff --git a/runner/review.ts b/runner/review.ts
index e34f25b..8f9bd00 100644
--- a/runner/review.ts
+++ b/runner/review.ts
@@ -52,7 +52,8 @@ export class ReviewService {
if (states[item.id] === 'approved' && (segments.some(segment => segment.row === 'Ambiguous' && segment.owners.includes(item.id)) || item.depends_on.some(id => states[id] === 'stale'))) states[item.id] = 'stale';
}
const expected: ReviewState = { revision: plan.revision, snapshotId: snapshot.id, reviewVersion };
- const notes = this.store.getReviewNotes(identity).map(note => ({ ...note, answerOutdated: note.snapshotId!==snapshot.id || note.revision!==plan.revision, outdated: !!note.reference && (note.reference.head !== history.head || note.reference.base !== history.base || !segments.some(segment => segment.key === note.reference!.key && segment.row === note.item)) }));
+ const contextIds = new Map(plan.items.map(item => [item.id,createHash('sha256').update(JSON.stringify(segments.filter(segment=>segment.row===item.id).map(segment=>segment.key))).digest('hex')]));
+ const notes = this.store.getReviewNotes(identity).map(note => {const contextId=contextIds.get(note.item)!;return { ...note, contextId, answerOutdated: note.snapshotId!==snapshot.id || note.revision!==plan.revision || (!!note.answer?.contextId && note.answer.contextId!==contextId), outdated: !!note.reference && (note.reference.head !== history.head || note.reference.base !== history.base || !segments.some(segment => segment.key === note.reference!.key && segment.row === note.item)) };});
if (this.store.reviewVersion(identity) !== reviewVersion || this.store.getPlan(identity).revision !== plan.revision || this.store.getSnapshot(identity).id !== snapshot.id) throw new Error('Stale review state. Reload before writing.');
const items = plan.items.map(item => {
const owned = segments.filter(segment => segment.row === item.id);
diff --git a/runner/store.ts b/runner/store.ts
index 904a617..899a5ea 100644
--- a/runner/store.ts
+++ b/runner/store.ts
@@ -13,7 +13,7 @@ export function requireSupportedNode(version = process.versions.node): void {
export interface Snapshot { id: string; base: string; head: string }
export interface ReviewState { revision: number; snapshotId: string; reviewVersion?: number }
export interface SnippetReference { key: string; path: string; side: 'old' | 'new'; start: number; end: number; text: string; head: string; base: string }
-export interface QuestionAnswer { provider?: 'claude' | 'codex'; attempt: string; status: 'pending' | 'complete' | 'failed'; expiresAt: number; text?: string; error?: string }
+export interface QuestionAnswer { provider?: 'claude' | 'codex'; attempt: string; contextId?: string; status: 'pending' | 'complete' | 'failed'; expiresAt: number; text?: string; error?: string }
export interface ReviewNote { id: string; item: string; kind: 'question' | 'change'; text: string; reference?: SnippetReference; answer?: QuestionAnswer; createdAt: string; revision: number; snapshotId: string }
export interface LedgerEntry { sha: string; owner: string | null; origin: 'owned' | 'foreign'; sourceSha: string | null }
export interface Checkpoint {
@@ -268,7 +268,7 @@ export class Store {
return note;
});
}
- beginAnswer(identity: PlanIdentity, id: string, attempt: string, provider?: 'claude' | 'codex'): void {
+ beginAnswer(identity: PlanIdentity, id: string, attempt: string, provider?: 'claude' | 'codex', contextId?: string): void {
const key=identityKey(identity);
this.#transaction(()=>{
const row=this.#get('SELECT data FROM review_notes WHERE key=? AND id=?',key,id);
@@ -276,7 +276,7 @@ export class Store {
const note=decode(row.data);
if(note.kind!=='question' || note.answer?.status==='complete') throw new Error('Question already answered.');
if(note.answer?.status==='pending' && note.answer.expiresAt>Date.now()) throw new Error('Agent is already answering this question.');
- note.answer={attempt,status:'pending',expiresAt:Date.now()+125000,...(provider?{provider}:{})};
+ note.answer={attempt,status:'pending',expiresAt:Date.now()+125000,...(provider?{provider}:{}),...(contextId?{contextId}:{})};
this.#run('UPDATE review_notes SET data=? WHERE key=? AND id=?',encode(note),key,id);
});
}
diff --git a/test/questions.test.ts b/test/questions.test.ts
index c68db6e..34b5dc0 100644
--- a/test/questions.test.ts
+++ b/test/questions.test.ts
@@ -95,3 +95,13 @@ it('rejects questions whose snippet was reassigned without changing the snapshot
const agent=vi.fn(async()=>'Should not run');const manager=new Questions(service,agent);managers.push(manager);
expect(()=>manager.start(note.id,moved)).toThrow(/older review|outdated/);expect(agent).not.toHaveBeenCalled();
});
+it('marks an item-level answer historical when assigned code changes',async()=>{
+ const service=fixture(),initial=service.load(),foreign=initial.segments.find(s=>s.row==='Unplanned')!;
+ const asked=service.act({action:'note',item:'P1',kind:'question',text:'Is this item complete?',token:initial.token});
+ const manager=new Questions(service,async()=>'It is complete for the supplied changes.');managers.push(manager);
+ manager.start(asked.createdNoteId!,asked);await vi.waitFor(()=>expect(service.store.getReviewNotes(service.config.identity)[0]?.answer?.status).toBe('complete'));
+ const index=initial.segments.findIndex(segment=>segment.key===foreign.key);
+ service.store.saveReview(service.config.identity,asked.expected,[],[{action:'assign',item:'P1',key:choiceKeys(initial.segments,service.config.identity)[index]!}]);
+ const changed=service.load(),note=changed.notes.find(note=>note.id===asked.createdNoteId)!;
+ expect(changed.snapshot.id).toBe(asked.snapshot.id);expect(note.answer?.text).toContain('complete');expect(note.answerOutdated).toBe(true);
+});
From a89dd113ccadb55999df94fbdaa429e1f1d3914e Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 16:15:32 -0700
Subject: [PATCH 22/23] Reject retries after answer context changes
---
docs/implementation/read-only-review.md | 2 +-
runner/questions.ts | 2 +-
test/questions.test.ts | 9 +++++----
3 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md
index f751022..57e2a82 100644
--- a/docs/implementation/read-only-review.md
+++ b/docs/implementation/read-only-review.md
@@ -109,4 +109,4 @@ The documentation-only review after adding `AGENTS.md` exposed one more concrete
The follow-up review exposed the same active-job marker missing from the initial review response. Reloading during provider settlement could therefore expose Retry and stop polling. Initial loads and action responses now include the marker used by question polling; the browser regression reloads during settlement and confirms Retry remains hidden until the invocation finishes.
-The next review made assignment drift concrete for item-level answers without snippet references. Each answer now records a hash of the changed segments supplied for its item. Moving code into or out of that item preserves the answer but labels it as earlier review context; a regression assigns new code without changing the snapshot or plan revision and checks the historical marker.
+The next review made assignment drift concrete for item-level answers without snippet references. Each answer now records a hash of the changed segments supplied for its item. Moving code into or out of that item preserves the answer but labels it as earlier review context and rejects retrying the old question; a regression assigns new code without changing the snapshot or plan revision, checks the historical marker, and proves no second agent invocation starts.
diff --git a/runner/questions.ts b/runner/questions.ts
index 29ef270..748df3a 100644
--- a/runner/questions.ts
+++ b/runner/questions.ts
@@ -28,7 +28,7 @@ export class Questions {
if (this.running.has(id)) throw new Error('Agent is already answering this question.');
const note = view.notes.find(n=>n.id===id && n.kind==='question');
if (!note) throw new Error('Question not found.');
- if (note.outdated || note.snapshotId!==view.snapshot.id || note.revision!==view.plan.revision) throw new Error('This question belongs to an older review. Ask again against the current code.');
+ if (note.outdated || note.answerOutdated || note.snapshotId!==view.snapshot.id || note.revision!==view.plan.revision) throw new Error('This question belongs to an older review. Ask again against the current code.');
const provider=this.service.store.questionProvider();
const agent=this.agent ?? (provider ? cliQuestionAgent(provider) : undefined);
const attempt=randomUUID(), controller=new AbortController();
diff --git a/test/questions.test.ts b/test/questions.test.ts
index 34b5dc0..ab6a25e 100644
--- a/test/questions.test.ts
+++ b/test/questions.test.ts
@@ -95,13 +95,14 @@ it('rejects questions whose snippet was reassigned without changing the snapshot
const agent=vi.fn(async()=>'Should not run');const manager=new Questions(service,agent);managers.push(manager);
expect(()=>manager.start(note.id,moved)).toThrow(/older review|outdated/);expect(agent).not.toHaveBeenCalled();
});
-it('marks an item-level answer historical when assigned code changes',async()=>{
+it('marks an item-level attempt historical and rejects retry when assigned code changes',async()=>{
const service=fixture(),initial=service.load(),foreign=initial.segments.find(s=>s.row==='Unplanned')!;
const asked=service.act({action:'note',item:'P1',kind:'question',text:'Is this item complete?',token:initial.token});
- const manager=new Questions(service,async()=>'It is complete for the supplied changes.');managers.push(manager);
- manager.start(asked.createdNoteId!,asked);await vi.waitFor(()=>expect(service.store.getReviewNotes(service.config.identity)[0]?.answer?.status).toBe('complete'));
+ const agent=vi.fn(async()=>{throw new Error('Provider failed');}),manager=new Questions(service,agent);managers.push(manager);
+ manager.start(asked.createdNoteId!,asked);await vi.waitFor(()=>expect(service.store.getReviewNotes(service.config.identity)[0]?.answer?.status).toBe('failed'));
const index=initial.segments.findIndex(segment=>segment.key===foreign.key);
service.store.saveReview(service.config.identity,asked.expected,[],[{action:'assign',item:'P1',key:choiceKeys(initial.segments,service.config.identity)[index]!}]);
const changed=service.load(),note=changed.notes.find(note=>note.id===asked.createdNoteId)!;
- expect(changed.snapshot.id).toBe(asked.snapshot.id);expect(note.answer?.text).toContain('complete');expect(note.answerOutdated).toBe(true);
+ expect(changed.snapshot.id).toBe(asked.snapshot.id);expect(note.answerOutdated).toBe(true);
+ expect(()=>manager.start(note.id,changed)).toThrow(/older review/);expect(agent).toHaveBeenCalledTimes(1);
});
From f2e55387ca620440195580674af15a1303786552 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Wed, 23 Sep 2026 16:20:29 -0700
Subject: [PATCH 23/23] Capture reusable rules when closing PRs
---
AGENTS.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/AGENTS.md b/AGENTS.md
index fcc6971..4aabe2d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,3 +45,4 @@ Every reproduced race requires a failing-before and passing-after regression. As
- Before requesting automated review, report the current head, CI state, mergeability, unresolved threads, and deferred follow-up issues.
- Reproduce summary-only review concerns or turn them into a concrete follow-up issue. Do not repeatedly patch vague wording without a failure case.
- For each review round, record what changed, what was declined and why, and the regression evidence. Re-request review until a round returns no new findings.
+- Before closing or merging a PR, extract the highest-value, broadly reusable lessons from its review and add concise rules to this file. Omit one-off implementation details and rules already covered here.