Skip to content

Commit 9f2745a

Browse files
committed
fix: correctly sequence review retries and follow up guidance
1 parent f8fed44 commit 9f2745a

6 files changed

Lines changed: 85 additions & 24 deletions

File tree

api/src/orchestrator/orchestrator.service.ts

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ import { buildPrBody } from '../summary/summary.utility.js';
3333
import { WorkspaceService } from '../workspace/workspace.service.js';
3434
import { ReviewService } from '../review/review.service.js';
3535
import { buildReviewPrompt } from '../review/review.prompts.js';
36-
import { type ReviewIssue, type ReviewResult } from '../review/review.model.js';
36+
import {
37+
type ReviewIssue,
38+
type ReviewResult,
39+
UNPARSEABLE_REVIEW_TITLE,
40+
} from '../review/review.model.js';
3741
import {
3842
failedDimensions,
3943
formatIssues,
@@ -1408,27 +1412,30 @@ export class OrchestratorService {
14081412
const maxPasses = this.review.maxPasses;
14091413
const cycle = job.reviewCycle;
14101414

1411-
// Count only passes in the current cycle so the cap applies per-cycle and task
1412-
// retries continue from where they left off rather than restarting from pass 1.
1413-
const [priorPasses, priorUnparseable] = await Promise.all([
1414-
this.prisma.reviewPass.count({ where: { jobId, cycle } }),
1415-
this.prisma.reviewPass.count({ where: { jobId, cycle, confidence: 0 } }),
1416-
]);
1417-
const pass = priorPasses + 1;
1418-
1419-
// Fetch issues from the immediately preceding pass (if any) so the reviewer
1420-
// can explicitly verify each was resolved in addition to doing a full fresh review.
1421-
const priorPassRecord =
1422-
pass > 1
1423-
? await this.prisma.reviewPass.findFirst({
1424-
where: { jobId, cycle },
1425-
orderBy: { passNumber: 'desc' },
1426-
select: { issues: true },
1427-
})
1428-
: null;
1415+
// One ordered read of this cycle's passes backs three things: the pass number, the
1416+
// immediately-preceding pass's issues (so the reviewer can verify each was resolved), and the
1417+
// count of *consecutive* unparseable passes right before this one. Consecutiveness matters —
1418+
// a single stray unparseable pass among otherwise-valid ones must not exhaust the retry budget,
1419+
// since the model has clearly shown it can still emit a valid verdict. Detected via the
1420+
// sentinel issue title rather than `confidence: 0` (a validly-parsed review can also lack a
1421+
// confidence and default to 0).
1422+
const cyclePasses = await this.prisma.reviewPass.findMany({
1423+
where: { jobId, cycle },
1424+
orderBy: { passNumber: 'desc' },
1425+
select: { issues: true },
1426+
});
1427+
const pass = cyclePasses.length + 1;
1428+
1429+
let priorUnparseable = 0;
1430+
for (const p of cyclePasses) {
1431+
if (!p.issues.includes(UNPARSEABLE_REVIEW_TITLE)) {
1432+
break;
1433+
}
1434+
priorUnparseable++;
1435+
}
14291436

1430-
const priorIssues = priorPassRecord
1431-
? (JSON.parse(priorPassRecord.issues) as ReviewIssue[])
1437+
const priorIssues = cyclePasses[0]
1438+
? (JSON.parse(cyclePasses[0].issues) as ReviewIssue[])
14321439
: undefined;
14331440

14341441
// Only include PR feedback submitted after the last IMPLEMENT run — older
@@ -1538,7 +1545,7 @@ export class OrchestratorService {
15381545
issues: [
15391546
{
15401547
severity: 'high',
1541-
title: 'Unparseable review output',
1548+
title: UNPARSEABLE_REVIEW_TITLE,
15421549
detail: res.stdout.slice(0, 800),
15431550
},
15441551
],

api/src/review/review.model.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ export type ReviewVerdict = 'PASS' | 'FAIL';
22

33
export type IssueSeverity = 'low' | 'medium' | 'high' | 'critical';
44

5+
/**
6+
* Title of the sentinel issue recorded when a review pass can't be parsed into a verdict. Used both
7+
* to build that fallback issue and to detect unparseable passes in the retry cap — keep them in sync
8+
* (and decoupled from `confidence: 0`, which a validly-parsed but confidence-less review can also be).
9+
*/
10+
export const UNPARSEABLE_REVIEW_TITLE = 'Unparseable review output';
11+
512
/**
613
* Rubric dimensions the reviewer grades independently. Each is a hard gate: the
714
* verdict can only be PASS if every dimension holds. Confidence is advisory only.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { buildReviewPrompt } from './review.prompts.js';
2+
import type { ReviewPromptContext } from './review.model.js';
3+
4+
const baseCtx: ReviewPromptContext = {
5+
repoFullName: 'o/r',
6+
issueTitle: 'Title',
7+
issueBody: 'Body',
8+
plan: 'the plan',
9+
baseBranch: 'main',
10+
changedFiles: ['src/a.ts'],
11+
threshold: 85,
12+
};
13+
14+
describe('buildReviewPrompt parseRetry guidance', () => {
15+
it('omits retry guidance on a first pass', () => {
16+
const p = buildReviewPrompt(baseCtx);
17+
expect(p).not.toContain('RETRY');
18+
});
19+
20+
it('injects schema-conformance guidance when retrying after an unparseable pass', () => {
21+
const p = buildReviewPrompt({ ...baseCtx, parseRetry: true });
22+
expect(p).toContain('RETRY');
23+
// The divergences that actually caused the failure must be called out by name.
24+
expect(p).toContain('"PASS" or "FAIL"');
25+
expect(p).toContain('NOT a boolean');
26+
expect(p).toContain('`confidence` MUST be present');
27+
expect(p).toContain('issues');
28+
expect(p).toContain('rationale'); // explicitly warns against this stray key
29+
});
30+
});

api/src/review/review.prompts.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,12 @@ If the server fails to start, skip the browser step and note it in your summary
8888

8989
if (ctx.parseRetry) {
9090
parts.push(
91-
`IMPORTANT: Your previous response did not contain a valid \`\`\`json block and could not be parsed. ` +
92-
`This time you MUST start your response with the JSON block above — do not write any preamble or narrative before it.`,
91+
`IMPORTANT — RETRY: your previous response could not be parsed against the required schema, so this review is being re-run. The verdict was discarded; none of that prior analysis was recorded. Conform EXACTLY this time:\n` +
92+
`- Start the response with a single \`\`\`json fenced block — no preamble, narrative, or prose before it.\n` +
93+
`- \`verdict\` MUST be the string "PASS" or "FAIL" (uppercase) — NOT a boolean (\`true\`/\`false\`), number, or any other word.\n` +
94+
`- \`confidence\` MUST be present, as an integer 0–100.\n` +
95+
`- \`dimensions\` MUST contain all four boolean keys: \`correctness\`, \`tests\`, \`planCoverage\`, \`security\`.\n` +
96+
`- Put every finding inside the \`issues\` array using the exact \`{severity,title,detail,file?}\` shape — do NOT invent other keys (e.g. \`rationale\`, \`findings\`) for them. Free-form reasoning, if any, goes AFTER the closing \`\`\`.`,
9397
);
9498
}
9599

api/src/review/review.utility.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ describe('parseReview', () => {
6262
it('returns null when no JSON can be recovered', () => {
6363
expect(parseReview('no structured output here')).toBeNull();
6464
});
65+
66+
// The contract is strict: an off-schema review (here: boolean verdict + missing confidence) is
67+
// rejected so the orchestrator re-runs it, rather than being coerced into a possibly-misread shape.
68+
it('returns null for an off-schema verdict (must re-run, not salvage)', () => {
69+
expect(
70+
parseReview(
71+
'```json\n{"verdict":false,"dimensions":{"correctness":false,"tests":true,"planCoverage":false,"security":true}}\n```',
72+
),
73+
).toBeNull();
74+
});
6575
});
6676

6777
describe('meetsThreshold', () => {

api/src/review/review.utility.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ const dimensionsSchema = z.preprocess(
5555
}),
5656
);
5757

58+
// The review contract is strict: a pass that doesn't conform (missing confidence, non-enum
59+
// verdict, findings under the wrong key, …) is NOT coerced into shape — `safeParse` fails, the
60+
// pass is recorded as unparseable, and the orchestrator RE-RUNS the review.
5861
const reviewSchema = z.object({
5962
confidence: z.coerce.number().int().min(0).max(100),
6063
verdict: z.enum(['PASS', 'FAIL']).optional(),

0 commit comments

Comments
 (0)