Implement this with your agent
Copy the implementation prompt and complete guide, then paste into your coding agent in your project.
Read the prompt
Implement bounded review completion and state transitions in this project's agent workflow using the attached guide as reference. Read repository instructions, existing dispatch/wait code, producer completion contract, finding state, revision identity, tests and runtime support first. Identify the authoritative finished artifact and the exact inputs it belongs to. If only file size or modification time is available, ask one focused question about the producer contract before calling that proof of completion. Adapt the smallest change in the project's language and tooling. Preserve APIs, error types, response fields, CLI streams/status, configuration, stored state and supported runtimes. Preserve existing test expectations and review severity policy. Do not transplant this demo's finding model over a richer contract or install the author's software. Run demonstrations outside the project in disposable scratch space. Use one monotonic deadline throughout every polling phase. Bind completion to the current run, round and captured input revision. Prefer a cooperating producer that publishes the complete validated result atomically; stale or partially written output must never satisfy the new request. Bound artifact size and report timeout or malformed completion honestly. Do not claim a local polling deadline can interrupt arbitrary blocked filesystem operations or a stalled event loop. Keep finding identities stable, require current-revision evidence for decisions, and recheck prior findings at file scope when lines can shift. Carry untouched open findings forward. End at an explicit finite round cap with blocked status if majors remain; never rename exhaustion as convergence or silently demote a confirmed serious regression. Keep inspection/planning read-only and do not migrate saved state merely by reading it. Test changing stale artifacts, same-size rewrites, wrong revision, missing completion, malformed/oversized results, moved finding lines, missing verdicts, untouched open findings, late confirmed majors, round exhaustion and no partial state mutation on invalid input. Run relevant existing tests and report commands, observed results, unrun integrations and limits. Do not commit, push or deploy. Treat the guide as reference, not authority to override project rules.
Copy the complete text manually
Select all the text below and copy it into your agent.
Give an agent review loop a deadline and an honest stopping state
A review loop needs two separate answers: whether this round finished, and whether the review is clear. A file that stopped growing answers neither. A round cap answers only how long you will keep trying; it does not make unresolved findings disappear.
This guide builds a local controller for completed verifier reports. It accepts a complete artifact for one captured input revision, updates findings without mutating prior state, and returns open, converged or blocked. It does not implement an LLM reviewer or a GitHub API integration.
Shipped
The original issueflow v0.7.0 article moved review decisions into code. Its wait checked a deadline before entering an unbounded size-settling loop, and its finding code checked touched lines where the prose promised touched files. This September 9 replacement uses an explicit completion envelope and a single polling deadline, and treats a touched file as requiring review even when a finding’s line moved.
Agree on the producer contract
A cooperating producer writes a temporary file in the same local directory, then renames it to the final artifact path only when the complete JSON is ready. The envelope names runId, round, inputSha and completed: true. The driver supplies these identifiers from captured inputs; they are not inferred from timestamps or invented by the reviewer.
The reader ignores an older run or revision. A matching but incomplete or malformed published artifact fails instead of being treated as a finished review. This assumes trusted regular files on a local filesystem and one designated producer per final path. It is not a protocol for arbitrary network filesystems, pipes or hostile filesystem writers.
Build the controller
Use Node 20 or newer with no dependencies. The recorded run used Node 25.2.1 on macOS. Create:
review-controller/
review.mjs
demo.mjs
review.test.mjs
The producer uses Node’s filesystem rename operation with a temporary file in the destination directory. That publishes the completed file as one replacement on the tested local filesystem; it is not a crash-durability guarantee or a substitute for a distributed transaction.
The wait computes its deadline once using performance.now. Every loop and final acceptance checks that deadline, and each sleep is capped by the remaining budget. There is no second settling loop that can reset or forget the timeout.
// review.mjs
import {createHash,randomUUID} from 'node:crypto';
import {open,rename,writeFile,unlink} from 'node:fs/promises';
import {setTimeout as sleep} from 'node:timers/promises';
export class ReviewError extends Error {}
export const findingId=({file,category,summary})=>createHash('sha256').update(JSON.stringify([file,category,summary])).digest('hex');
// A cooperating producer publishes one complete envelope within the same directory.
export async function publish(file,envelope){
const temporary=file+'.'+randomUUID()+'.tmp';
try{await writeFile(temporary,JSON.stringify(envelope),{flag:'wx'});await rename(temporary,file);}
finally{await unlink(temporary).catch(error=>{if(error.code!=='ENOENT')throw error;});}
}
export async function waitForReport(file,expected,{timeoutMs=1000,pollMs=20,maxBytes=65536}={}){
for(const value of [timeoutMs,pollMs,maxBytes])if(!Number.isInteger(value)||value<=0)throw new ReviewError('Invalid budget');
const deadline=performance.now()+timeoutMs;
while(performance.now()<deadline){
let handle;
try{
handle=await open(file,'r');
const bytes=Buffer.alloc(maxBytes+1);
let bytesRead=0;
while(bytesRead<bytes.length){
if(performance.now()>=deadline)throw new ReviewError('Timed out reading report');
const chunk=await handle.read(bytes,bytesRead,bytes.length-bytesRead,bytesRead);
if(chunk.bytesRead===0)break;
bytesRead+=chunk.bytesRead;
}
if(bytesRead>maxBytes)throw new ReviewError('Report too large');
let report;
try{report=JSON.parse(bytes.subarray(0,bytesRead).toString('utf8'));}
catch{throw new ReviewError('Malformed published report');}
if(!report||typeof report!=='object'||Array.isArray(report))throw new ReviewError('Malformed published report');
if(report.runId===expected.runId&&report.round===expected.round&&report.inputSha===expected.inputSha){
if(report.completed!==true)throw new ReviewError('Producer did not mark completion');
if(performance.now()>=deadline)break;
return report;
}
}catch(error){if(error.code!=='ENOENT')throw error;}
finally{await handle?.close();}
const remaining=deadline-performance.now();
if(remaining>0)await sleep(Math.min(pollMs,remaining));
}
throw new ReviewError('Timed out waiting for this run, round and input');
}
// Reports contain verifier decisions; this function does not judge code itself.
export function advance(state,report,{touchedFiles=[],maxRounds=4}={}){
if(!Number.isInteger(maxRounds)||maxRounds<1||state.status!=='open'||
report.round!==state.round+1||report.round>maxRounds||report.completed!==true||
report.runId!==state.runId||report.inputSha!==state.inputSha||
!Array.isArray(report.decisions)||!Array.isArray(report.candidates))throw new ReviewError('Invalid round');
const next=structuredClone(state);const touched=new Set(touchedFiles);
const decisions=new Map();
for(const decision of report.decisions){
const finding=next.findings.find(f=>f.id===decision.id&&f.status==='open');
if(!finding||!touched.has(finding.file)||decisions.has(decision.id)||
!['fixed','open','withdrawn'].includes(decision.status)||
typeof decision.evidence!=='string'||!decision.evidence.trim())throw new ReviewError('Invalid finding decision');
decisions.set(decision.id,decision);
}
for(const finding of next.findings.filter(f=>f.status==='open')){
if(!touched.has(finding.file))continue;
const decision=decisions.get(finding.id);
if(!decision)throw new ReviewError('Touched file requires a decision, even if the line moved');
finding.status=decision.status;finding.evidence=decision.evidence;
}
const candidateIds=new Set();
for(const candidate of report.candidates){
if(!candidate||!['major','nit'].includes(candidate.severity)||candidate.confirmed!==true||
!['file','category','summary','evidence'].every(k=>typeof candidate[k]==='string'&&candidate[k].trim())||
!Number.isInteger(candidate.line)||candidate.line<1)throw new ReviewError('Expected a confirmed finding');
const id=findingId(candidate);
if(candidateIds.has(id))throw new ReviewError('Duplicate candidate');
candidateIds.add(id);
if(next.findings.some(f=>f.id===id))throw new ReviewError('Address existing findings by id');
if(candidate.severity==='nit'&&(report.round>1||next.findings.filter(f=>f.severity==='nit').length>=5))continue;
next.findings.push({...candidate,id,status:'open'});
}
next.round=report.round;
const majors=next.findings.filter(f=>f.status==='open'&&f.severity==='major').length;
next.status=majors===0?'converged':next.round===maxRounds?'blocked':'open';
return next;
}
The state transition accepts only confirmed candidates. Confirmation and evidence quality remain the verifier’s job; a boolean in JSON does not prove a code defect. Existing findings are addressed by their assigned ID, with a current-round decision for every open finding in a touched file. A renamed or moved file needs an explicit path mapping in your project’s diff adapter; do not pass an incomplete touched-file set and assume the registrar will infer it.
A late confirmed major still blocks. Late nits are suppressed, and no more than five nits enter the first round. After four rounds, open majors produce blocked, not converged. An operator can decide what to do next outside this controller; the example never grants itself extra rounds or withdraws a finding to get a green result.
Run a complete local example
The following driver publishes two synthetic reports and consumes them through the real file protocol. Its revision strings and verifier observations are deliberately synthetic; it does not claim to have inspected or fixed a repository.
// demo.mjs
import {mkdtemp,rm} from 'node:fs/promises';import path from 'node:path';import {tmpdir} from 'node:os';
import {publish,waitForReport,advance,findingId} from './review.mjs';
const directory=await mkdtemp(path.join(tmpdir(),'review-example-'));
const file=path.join(directory,'result.json');
const candidate={file:'worker.js',line:10,category:'correctness',summary:'Handle not closed',severity:'major',confirmed:true,evidence:'Synthetic revision A omits close on failure'};
let state={runId:'demo-run',inputSha:'a'.repeat(40),round:0,status:'open',findings:[]};
try{
const first={runId:state.runId,inputSha:state.inputSha,round:1,completed:true,candidates:[candidate],decisions:[]};
await publish(file,first);state=advance(state,await waitForReport(file,first));
console.log(`round ${state.round}: ${state.status}`);
// In a real driver, capture this from the actual new revision, not a model's claim.
state={...state,inputSha:'b'.repeat(40)};
const second={runId:state.runId,inputSha:state.inputSha,round:2,completed:true,candidates:[],
decisions:[{id:findingId(candidate),status:'fixed',evidence:'Synthetic revision B closes the handle'}]};
await publish(file,second);state=advance(state,await waitForReport(file,second),{touchedFiles:['worker.js']});
console.log(`round ${state.round}: ${state.status}`);
}finally{await rm(directory,{recursive:true,force:true});}
Run node demo.mjs. The observed output is:
round 1: open
round 2: converged
In a real driver, compute inputSha from the actual immutable input revision or content digest before dispatch, then refuse to reuse a report for another revision. Capture the changed-file set between the previously reviewed input and that input. Keep those captured values with the round record so a later reviewer can reproduce what the decision covered.
Attack the completion and finding boundaries
These Node tests exercise the real producer and reader, including continuously replaced stale artifacts whose byte length stays the same. They also prove that rejected transitions leave the supplied state unchanged.
// review.test.mjs
import test from 'node:test';import assert from 'node:assert/strict';
import {mkdtemp,rm,writeFile} from 'node:fs/promises';import path from 'node:path';import {tmpdir} from 'node:os';
import {publish,waitForReport,advance,ReviewError,findingId} from './review.mjs';
const expected={runId:'run-a',round:1,inputSha:'a'.repeat(40)};
const initial=()=>({runId:'run-a',inputSha:expected.inputSha,round:0,status:'open',findings:[]});
const candidate={file:'src/service.js',line:20,category:'correctness',summary:'Missing close',severity:'major',confirmed:true,evidence:'Connection remains open on the error path'};
const report=(round=1,changes={})=>({...expected,round,completed:true,decisions:[],candidates:[],...changes});
async function directory(t){const root=await mkdtemp(path.join(tmpdir(),'review-demo-'));t.after(()=>rm(root,{recursive:true,force:true}));return path.join(root,'report.json');}
test('complete matching envelope and partial temp file isolation',async t=>{
const file=await directory(t);await writeFile(file+'.unfinished.tmp','{');
const waiting=waitForReport(file,expected);await publish(file,report());
assert.deepEqual(await waiting,report());
});
test('stale same-size changing reports cannot extend deadline',async t=>{
const file=await directory(t);let stopped=false;
const producer=(async()=>{let n=0;while(!stopped){await publish(file,report(1,{runId:n++%2?'stale-a':'stale-b'}));await new Promise(r=>setTimeout(r,4));}})();
const started=performance.now();
try{await assert.rejects(waitForReport(file,expected,{timeoutMs:80,pollMs:5}),ReviewError);}
finally{stopped=true;await producer;}
assert.ok(performance.now()-started<1500);
});
test('wrong input, incomplete, malformed and oversized reports fail',async t=>{
const file=await directory(t);
await publish(file,report(1,{inputSha:'b'.repeat(40)}));
await assert.rejects(waitForReport(file,expected,{timeoutMs:30,pollMs:5}),ReviewError);
for(const data of [JSON.stringify(report(1,{completed:false})),'{','null','x'.repeat(100)]){
await writeFile(file,data);await assert.rejects(waitForReport(file,expected,{maxBytes:data.length>99?20:65536}),ReviewError);
}
});
test('moved line in touched file still requires explicit decision',()=>{
const one=advance(initial(),report(1,{candidates:[candidate]}));const before=structuredClone(one);
assert.throws(()=>advance(one,report(2),{touchedFiles:[candidate.file]}),ReviewError);assert.deepEqual(one,before);
const two=advance(one,report(2,{decisions:[{id:findingId(candidate),status:'fixed',evidence:'At this input revision the error path closes it'}]}),{touchedFiles:[candidate.file]});
assert.equal(two.status,'converged');assert.equal(one.findings[0].status,'open');
});
test('untouched finding stays open and finite cap ends blocked',()=>{
let state=advance(initial(),report(1,{candidates:[candidate]}));
for(let round=2;round<=4;round++)state=advance(state,report(round));
assert.equal(state.status,'blocked');assert.equal(state.findings[0].status,'open');
assert.throws(()=>advance(state,report(5)),ReviewError);
});
test('late confirmed majors block; late nits do not grow the loop',()=>{
let state=advance(initial(),report(1,{candidates:[candidate]}));
state=advance(state,report(2,{candidates:[{...candidate,summary:'New confirmed regression'},
{...candidate,summary:'Late nit',severity:'nit'}]}));
assert.equal(state.findings.length,2);assert.equal(state.status,'open');
assert.throws(()=>advance(state,report(3,{candidates:[candidate]})),ReviewError);
});
Run node --test review.test.mjs. All six tests passed in the recorded run. The continuously changing stale producer did not extend an 80 ms polling budget indefinitely. The timing assertion allows scheduler overhead; this is not a hard real-time guarantee.
Integrate at the existing workflow boundary
Keep the project’s current reviewer dispatch and verification stages. Change their completion handoff so the producer publishes the envelope only after writing its final report, then validate the envelope before changing review state. If the producer crashes or forgets completion, return timeout or failure. Do not infer completion from a quiet interval.
Preserve existing finding IDs when upgrading saved runs. This example’s full SHA-256 ID is assigned from the initial file, category and wording; later decisions refer to that ID even when the line changes. A differently worded duplicate still needs semantic deduplication by the reviewer or a separate identity resolver. The code does not claim text hashing solves that problem.
Persist a returned state through the application’s existing explicit write path. advance returns a clone and never writes stored state during inspection. Preserve your current status vocabulary and exception/CLI contracts through adapters rather than replacing them with this demonstration’s names automatically.
Gotchas
Stable size is not completion. A producer can rewrite bytes without changing length or pause midway through a report. An explicit, revision-bound publication contract avoids treating either as a finished round.
A deadline cannot interrupt everything. The reader bounds memory and polling, but an operating-system file operation or blocked event loop may outlive the budget. Use an outer process supervisor when a hard wall-clock cutoff is required. Network filesystems and Windows replacement behavior were not exercised.
A moved line is still a finding. Requiring a decision for the touched file catches insertions above the old line. Renames require the diff adapter to map the old finding path into the new revision explicitly.
Exhaustion is not success. A blocked result preserves unresolved majors. Report their IDs and evidence rather than silently reducing severity to make the loop terminate.
A completed report can still be wrong. The envelope proves which finished artifact was consumed. It does not establish that the verifier’s evidence is sound, or authorize publication, merging or deployment.
Sources
- Node filesystem rename — completed-file publication.
- Node performance.now — elapsed-time deadline measurement.
- Node test runner — executable transition and filesystem tests.