Skip to content

Commit 885cf09

Browse files
committed
Poll question answers without rebuilding reviews
1 parent b11b93f commit 885cf09

4 files changed

Lines changed: 29 additions & 2 deletions

File tree

‎docs/implementation/read-only-review.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,11 @@ The browser owns unsent text, mode, navigation, and attachments; the refreshed r
120120
The regression gate is `npm run test:browser -- --grep 'during refresh'`. It checks visible drafts and refreshed durable approval/snapshot/plan state, and confirms no draft was accidentally saved as a note. Issues #10 (review edge cases), #12 (polling efficiency), and #3 (human go/no-go experiment) remain separate work.
121121

122122
PR #14 review round 1 found that retained-draft rows omitted the regular rows' `aria-current` state. A browser assertion reproduced the missing attribute. Retained rows now expose their selected state; the regression checks selection, navigation away, and selection again. No findings were declined.
123+
124+
## Lightweight answer polling (#12)
125+
126+
The answer polling endpoint previously called `ReviewService.load()` every two seconds while a question was pending. A browser regression measured one full review load for a single poll before the fix. Polling now reads persisted question attempts directly from SQLite and adds only the in-memory active marker. The endpoint returns each question's ID, answer state, and active state; it does not rebuild Git history, linkage, previews, or the review token.
127+
128+
The displayed review remains the owner of note text, ordering, snapshot, plan revision, assignment, and reference-validity metadata. Poll responses update only `answer` and `answerActive` for IDs already displayed. The existing generation guard rejects responses started before an action or Refresh, and unknown note IDs are ignored. Explicit Refresh and mutating actions still call the full review service to validate current repository state.
129+
130+
The focused browser regression observes a completed answer and its durable SQLite state with zero review loads, verifies that the status payload omits note text, then proves explicit Refresh still performs a full load. Existing regressions continue to cover a poll returning after a newer submission and cancellation remaining active after a persisted attempt fails.

‎test/browser/review.spec.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,22 @@ test('ignores question polls started before a newer submission',async({page})=>{
226226
await page.waitForTimeout(100);
227227
expect(await page.locator('#notes .note').count()).toBe(2);
228228
});
229+
test('polls persisted answer status without rebuilding the review',async({page})=>{
230+
const config=app.service.config;await app.close();let settle!:(answer:string)=>void;
231+
app=await startServer(config,0,()=>new Promise(resolve=>settle=resolve));
232+
await page.goto(app.url);await page.getByLabel('Question about this item').fill('Why is this bounded?');await page.getByRole('button',{name:'Ask agent',exact:true}).click();
233+
await expect(page.getByText('Agent · Answering…',{exact:true})).toBeVisible();
234+
const service=app.service,load=service.load.bind(service);let reviewLoads=0;
235+
service.load=()=>{reviewLoads++;return load();};
236+
const polled=page.waitForResponse(response=>response.url().endsWith('/api/questions'));
237+
settle('The cap bounds retry latency.');
238+
const statusResponse=await polled,statusBody=await statusResponse.json();
239+
await expect(page.getByText('The cap bounds retry latency.',{exact:true})).toBeVisible({timeout:10000});
240+
expect(reviewLoads).toBe(0);
241+
expect(statusBody.notes.find((note:{id:string})=>note.id)).not.toHaveProperty('text');
242+
expect(service.store.getReviewNotes(config.identity).find(note=>note.text==='Why is this bounded?')?.answer?.status).toBe('complete');
243+
await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect.poll(()=>reviewLoads).toBeGreaterThan(0);
244+
});
229245
test('outdated questions explain how to continue without offering a broken retry',async({page})=>{
230246
const service=app.service;
231247
service.act({action:'note',item:'P1',kind:'question',text:'Old question',token:service.load().token});

‎web/public/app.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,7 @@ setInterval(async()=>{
616616
if(pollingQuestions || busy || !data || !data.notes.some(n=>n.answer?.status==="pending" || n.answerActive)) return;
617617
pollingQuestions=true;
618618
const generation = reviewGeneration;
619-
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 });}}
619+
try {const response=await api("/api/questions");if(data && generation === reviewGeneration){const statuses=new Map(response.notes.map(note=>[note.id,note]));data.notes=data.notes.map(note=>{const status=statuses.get(note.id);if(!status)return note;const answer=status.answer?.status==="pending" && status.answer.expiresAt<=Date.now() && !status.answerActive ? {...status.answer,status:"failed",error:"Agent was interrupted or timed out. Retry the question."} : status.answer;return {...note,answer,answerActive:status.answerActive};});renderNotes({ follow: true });}}
620620
catch { if (generation === reviewGeneration) $("saved").textContent="Could not refresh agent answers. Use Refresh to reconnect."; }
621621
finally {pollingQuestions=false;}
622622
},2000);

‎web/server.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge
99
const service = new ReviewService(config), token = randomBytes(32).toString('hex');
1010
const questions=new Questions(service,questionAgent);
1111
const load=()=>{const view=service.load();return {...view,notes:view.notes.map(note=>({...note,answerActive:questions.isRunning(note.id)}))};};
12+
const answerStatuses=()=>service.store.getReviewNotes(config.identity)
13+
.filter(note=>note.kind==='question')
14+
.map(note=>({id:note.id,answer:note.answer,answerActive:questions.isRunning(note.id)}));
1215
const server = createServer(async (req, res) => {
1316
const address = server.address(); const actualPort = address && typeof address !== 'string' ? address.port : port;
1417
const origin = `http://127.0.0.1:${actualPort}`;
@@ -22,7 +25,7 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge
2225
const supplied = req.headers['x-codeboost-token'];
2326
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; }
2427
if (req.method === 'GET' && path === '/api/settings') { json(200,{questionProvider:service.store.questionProvider()});return; }
25-
if (req.method === 'GET' && path === '/api/questions') { json(200,{notes:load().notes});return; }
28+
if (req.method === 'GET' && path === '/api/questions') { json(200,{notes:answerStatuses()});return; }
2629
if (req.method === 'GET' && path === '/api/review') { json(200, load()); return; }
2730
if (req.method !== 'POST' || !['/api/action','/api/settings'].includes(path) || req.headers['content-type'] !== 'application/json') { json(405, { error: 'Unsupported request.' }); return; }
2831
const chunks: Buffer[] = []; let size = 0;

0 commit comments

Comments
 (0)