Skip to content

Commit 2a1877a

Browse files
committed
Track cancelled question invocations through process closure
1 parent 35d819f commit 2a1877a

6 files changed

Lines changed: 42 additions & 13 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,7 @@ PR #11 review round 3 reproduced retrying a locally active job after its persist
9292
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.
9393

9494
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.
95+
96+
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.
97+
98+
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.

‎runner/question-agent.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ export function cliQuestionAgent(provider: Provider): QuestionAgent {
1818
const stdout=await new Promise<string>((resolve,reject)=>{
1919
const env={...process.env};delete env.CLAUDECODE;delete env.NODE_OPTIONS;
2020
const child=spawn(provider,agentArguments(provider),{cwd,env,stdio:['pipe','pipe','pipe'],signal,killSignal:'SIGKILL'});
21-
const chunks:Buffer[]=[];let bytes=0,diagnostic='';
22-
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);});
21+
const chunks:Buffer[]=[];let bytes=0,diagnostic='';let failure:Error|undefined;
22+
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);});
2323
child.stderr.on('data',(chunk:Buffer)=>{diagnostic=(diagnostic+chunk.toString()).slice(-2000);});
24-
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})`)));
25-
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.':''}`)));
24+
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})`);});
25+
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.':''}`)));
2626
child.stdin.on('error',()=>{});child.stdin.end(prompt);
2727
});
2828
if(provider==='claude') {

‎runner/questions.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,24 @@ export class Questions {
3434
this.service.store.beginAnswer(this.service.config.identity,id,attempt,provider??undefined);
3535
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;}
3636
const timeout=setTimeout(()=>controller.abort(new Error('Agent timed out. Try again.')),120_000);
37+
let invocation: Promise<string> | undefined;
3738
const done=(async()=>{
3839
try {
3940
if(!agent) throw new Error('Choose a question agent in Settings, then retry.');
4041
const aborted = new Promise<never>((_,reject)=>controller.signal.addEventListener('abort',()=>reject(controller.signal.reason),{once:true}));
41-
const text=await Promise.race([agent(questionPrompt(view,note),controller.signal),aborted]);
42+
invocation = agent(questionPrompt(view,note),controller.signal);
43+
const text=await Promise.race([invocation,aborted]);
4244
if(typeof text!=='string'||!text.trim()||text.length>24000) throw new Error('Agent returned an empty or oversized answer.');
4345
this.service.store.finishAnswer(this.service.config.identity,id,attempt,{status:'complete',text:text.trim()});
4446
} catch(error) {
4547
this.service.store.finishAnswer(this.service.config.identity,id,attempt,{status:'failed',error:(error instanceof Error?error.message:'Agent failed.').slice(0,1000)});
4648
} finally {clearTimeout(timeout);}
4749
})();
48-
this.running.set(id,{controller,done});
49-
void done.finally(()=>this.running.delete(id));
50+
const settled = done.finally(async () => {
51+
await invocation?.catch(() => {});
52+
this.running.delete(id);
53+
});
54+
this.running.set(id,{controller,done:settled});
5055
}
5156
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));}
5257
}

‎test/browser/review.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ test('Clear selection removes native text selection as well as selected lines',a
213213
await expect(page.getByRole('group',{name:'Selected code'})).toBeHidden();
214214
});
215215
test('ignores question polls started before a newer submission',async({page})=>{
216-
const config=app.service.config;await app.close();app=await startServer(config,0,()=>new Promise(()=>{}));
216+
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})));
217217
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();
218218
let release!:()=>void,arrived!:()=>void;const held=new Promise<void>(r=>release=r),captured=new Promise<void>(r=>arrived=r);
219219
await page.route('**/api/questions',async route=>{const response=await route.fetch();arrived();await held;await route.fulfill({response});});

‎test/question-agent.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ import { cliQuestionAgent } from '../runner/question-agent.ts';
55
vi.mock('node:child_process',()=>({spawn:vi.fn()}));
66
afterEach(()=>vi.clearAllMocks());
77
it.each(['Agent timed out. Try again.','Server stopped. Retry the question.'])('preserves the cancellation reason: %s',async message=>{
8+
let childProcess:EventEmitter;
89
vi.mocked(spawn).mockImplementation(((_command:unknown,_args:unknown,options:{signal:AbortSignal})=>{
910
const child=Object.assign(new EventEmitter(),{stdout:new EventEmitter(),stderr:new EventEmitter(),stdin:{on:vi.fn(),end:vi.fn()},kill:vi.fn()});
11+
childProcess=child;
1012
options.signal.addEventListener('abort',()=>child.emit('error',new Error('The operation was aborted')),{once:true});
1113
return child;
1214
}) as unknown as typeof spawn);
1315
const controller=new AbortController();const answer=cliQuestionAgent('codex')('Question',controller.signal);
14-
const result=answer.catch(error=>error);
16+
let settled=false;
17+
const result=answer.catch(error=>error).finally(()=>{settled=true;});
1518
await vi.waitFor(()=>expect(spawn).toHaveBeenCalledOnce());
1619
controller.abort(new Error(message));
20+
await new Promise(resolve=>setTimeout(resolve,20));expect(settled).toBe(false);
21+
childProcess!.emit('close',null);
1722
expect((await result).message).toBe(message);
1823
});

‎test/questions.test.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { agentArguments } from '../runner/question-agent.ts';
1010
vi.setConfig({testTimeout:15000});
1111
const roots:string[]=[], services:ReviewService[]=[], managers:Questions[]=[];
1212
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();});
13+
function waitForAbort(_prompt:string,signal:AbortSignal):Promise<string>{return new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}));}
1314
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;}
1415
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});}
1516
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
3031
expect(service.store.getReviewNotes(service.config.identity)).toHaveLength(1);expect(service.store.getReviewNotes(service.config.identity)[0]!.answer!.text).toBe('Recovered answer');
3132
});
3233
it('prevents duplicate invocations and records interruption when the server stops',async()=>{
33-
const service=fixture(),asked=question(service);const manager=new Questions(service,()=>new Promise(()=>{}));managers.push(manager);manager.start(asked.createdNoteId!,asked);
34+
const service=fixture(),asked=question(service);const manager=new Questions(service,waitForAbort);managers.push(manager);manager.start(asked.createdNoteId!,asked);
3435
expect(()=>manager.start(asked.createdNoteId!,asked)).toThrow(/already answering/);await manager.close();
3536
expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.error).toMatch(/Server stopped/);
3637
});
@@ -40,15 +41,15 @@ it('persists provider selection and restricts commands to fixed provider launch
4041
const codex=agentArguments('codex');expect(codex).toContain('read-only');expect(codex).toContain('features.shell_tool=false');expect(codex).toContain('features.plugins=false');
4142
});
4243
it('times out an unresponsive agent and allows expired pending attempts to be recovered',async()=>{
43-
const service=fixture(),asked=question(service);const manager=new Questions(service,()=>new Promise(()=>{}));managers.push(manager);
44+
const service=fixture(),asked=question(service);const manager=new Questions(service,waitForAbort);managers.push(manager);
4445
vi.useFakeTimers();
4546
try {manager.start(asked.createdNoteId!,asked);await vi.advanceTimersByTimeAsync(120001);expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.error).toMatch(/timed out/);
4647
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');
4748
} finally {vi.useRealTimers();}
4849
});
4950
it('does not replace a locally running job when its persisted lease expires',async()=>{
5051
const service=fixture(),asked=question(service);let calls=0,signal:AbortSignal|undefined;
51-
const manager=new Questions(service,(_prompt,currentSignal)=>{calls++;signal=currentSignal;return new Promise(()=>{});});managers.push(manager);
52+
const manager=new Questions(service,(_prompt,currentSignal)=>{calls++;signal=currentSignal;return waitForAbort(_prompt,currentSignal);});managers.push(manager);
5253
manager.start(asked.createdNoteId!,asked);
5354
const original=service.store.getReviewNotes(service.config.identity)[0]!.answer!.attempt;
5455
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
5859
});
5960
it('rejects new work as soon as shutdown begins',async()=>{
6061
const service=fixture(),first=question(service),second=question(service);let calls=0;
61-
const manager=new Questions(service,()=>{calls++;return new Promise(()=>{});});managers.push(manager);
62+
const manager=new Questions(service,(prompt,signal)=>{calls++;return waitForAbort(prompt,signal);});managers.push(manager);
6263
manager.start(first.createdNoteId!,first);
6364
const closing=manager.close();
6465
expect(()=>manager.start(second.createdNoteId!,second)).toThrow(/stopping/);
@@ -67,3 +68,17 @@ it('rejects new work as soon as shutdown begins',async()=>{
6768
expect(calls).toBe(1);
6869
expect(service.store.getReviewNotes(service.config.identity).find(note=>note.id===second.createdNoteId)?.answer).toBeUndefined();
6970
});
71+
it('keeps cancelled invocations tracked until they settle',async()=>{
72+
const service=fixture(),asked=question(service);let settle!:(value:string)=>void;
73+
const manager=new Questions(service,()=>new Promise(resolve=>settle=resolve));managers.push(manager);
74+
vi.useFakeTimers();
75+
try {
76+
manager.start(asked.createdNoteId!,asked);await vi.advanceTimersByTimeAsync(120001);
77+
expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.status).toBe('failed');
78+
expect(()=>manager.start(asked.createdNoteId!,asked)).toThrow(/already answering/);
79+
let closed=false;const closing=manager.close().then(()=>{closed=true;});
80+
await Promise.resolve();await Promise.resolve();expect(closed).toBe(false);
81+
settle('Late answer');await closing;expect(closed).toBe(true);
82+
expect(service.store.getReviewNotes(service.config.identity)[0]!.answer?.status).toBe('failed');
83+
} finally {settle('Cleanup');vi.useRealTimers();}
84+
});

0 commit comments

Comments
 (0)