Skip to content

Commit 8096868

Browse files
authored
fix(reviewer): a parse failure is an infra retry, not a quality reject → false STUCK (INT-2521) (#267)
When the reviewer RAN but adapter.parseReviewerOutput threw on malformed output, the catch returned decision:'reject' — which discards the worker's work AND counts toward the rejection→STUCK limit, turning a reviewer-side parse bug into a FALSE STUCK. (An earlier 'revise' attempt was rejected: the CLI review reads a non-reject decision as exit-0 success, and it spends the worker's revision budget on a reviewer-side problem.) Now it throws a 'reviewer-stage:'-marked error (original preserved as cause): the pipeline classifies it infra_error (backoff retry, no STUCK) and the CLI review exits non-zero via its catch. Rate-limit / infra errors still propagate first, unchanged. Found by the INT-2521 harness audit (finding P6). Tests: runReviewer rejects with the reviewer-stage error when parseReviewerOutput throws (real call path); isInfraError matches 'reviewer-stage:' but not prose. tsc clean + full vitest 1718. openswarm review: APPROVE.
1 parent ebf28a4 commit 8096868

4 files changed

Lines changed: 41 additions & 8 deletions

File tree

src/adapters/errorClassification.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ describe('isInfraError (INT-2010)', () => {
5656
expect(isInfraError(new Error('git-tracker: diff since snapshot failed: fatal: bad object'))).toBe(true);
5757
});
5858

59+
it('recognises a reviewer-stage parse failure as infra, not a quality reject (INT-2521)', () => {
60+
expect(isInfraError(new Error('reviewer-stage: produced no parseable verdict: TypeError x'))).toBe(true);
61+
// …but a task/review that merely discusses the reviewer stage is NOT infra:
62+
expect(isInfraError(new Error('the reviewer stage should reject empty diffs'))).toBe(false);
63+
});
64+
5965
it('recognises local server capacity failures (5xx / loading / overloaded) (INT-2520)', () => {
6066
expect(isInfraError(new Error('Local API error (503): model is loading'))).toBe(true);
6167
expect(isInfraError(new Error('Local API error (502): bad gateway'))).toBe(true);

src/adapters/errorClassification.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const INFRA_ERROR_PATTERNS = [
3434
'socket hang up',
3535
'network error',
3636
'git-tracker:', // git snapshot/diff failed mid-run — infra, not a task verdict (colon-anchored to avoid prose) (INT-2521)
37+
'reviewer-stage:', // reviewer ran but its output couldn't be parsed into a verdict — infra, not a quality reject (INT-2521)
3738
'fetch failed', // undici: the real code hides in error.cause.code (checked below)
3839
'terminated', // undici mid-stream socket drop
3940
'unauthorized',

src/agents/reviewer.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,31 @@
22
// Purpose: Unit tests for reviewer module
33
// Test Status: Complete
44

5-
import { describe, it, expect } from 'vitest';
6-
import { formatReviewFeedback, buildRevisionPrompt, type ReviewerOptions } from './reviewer.js';
5+
import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest';
6+
import { formatReviewFeedback, buildRevisionPrompt, runReviewer, type ReviewerOptions } from './reviewer.js';
77
import type { WorkerResult, ReviewResult } from './agentPair.js';
8+
import * as adapters from '../adapters/index.js';
9+
import { initLocale } from '../locale/index.js';
10+
11+
describe('runReviewer parse failure (INT-2521)', () => {
12+
beforeAll(() => { initLocale('en'); });
13+
afterEach(() => { vi.restoreAllMocks(); });
14+
15+
it('rejects with a reviewer-stage infra error (not a quality reject) when parse throws', async () => {
16+
// The reviewer RAN but its output couldn't be parsed → NOT a 'reject' (false STUCK)
17+
// and NOT a 'revise' (CLI exit-0 success). It must throw the infra-marked error.
18+
vi.spyOn(adapters, 'spawnCli').mockResolvedValue({ exitCode: 0, stdout: 'garbage', stderr: '', durationMs: 1 } as never);
19+
vi.spyOn(adapters, 'getAdapter').mockReturnValue({
20+
name: 'mock',
21+
getDefaultModel: async () => 'm',
22+
parseReviewerOutput: () => { throw new TypeError('cannot read property decision of undefined'); },
23+
} as never);
24+
const wr: WorkerResult = { success: true, summary: 's', filesChanged: ['a.ts'], commands: [], output: '' };
25+
await expect(
26+
runReviewer({ taskTitle: 't', taskDescription: 'd', workerResult: wr, projectPath: '/tmp/x' }),
27+
).rejects.toThrow(/reviewer-stage: produced no parseable verdict/);
28+
});
29+
});
830

931
describe('reviewer', () => {
1032
describe('formatReviewFeedback', () => {

src/agents/reviewer.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,12 +262,16 @@ export async function runReviewer(options: ReviewerOptions): Promise<ReviewResul
262262
// as 'infra_error' instead of letting it masquerade as a 'reject' that
263263
// increments the rejection-limit STUCK counter. (INT-2010)
264264
if (isInfraError(error)) throw error;
265-
return {
266-
decision: 'reject',
267-
feedback: `Reviewer execution failed: ${error instanceof Error ? error.message : String(error)}`,
268-
issues: ['Error occurred during reviewer agent execution'],
269-
suggestions: ['Manual review required'],
270-
};
265+
// Whatever is left ran the reviewer but produced NO usable verdict — most often
266+
// adapter.parseReviewerOutput throwing on malformed output. This is NOT a quality
267+
// 'reject' (a 'reject' discards the worker's work AND counts toward the
268+
// rejection→STUCK limit, turning a reviewer-side parse bug into a false STUCK),
269+
// and NOT a 'revise' (which the CLI reads as an exit-0 success and which spends
270+
// the worker's revision budget on a reviewer-side problem). Throw an infra-marked
271+
// error → the pipeline classifies infra_error (backoff retry, no STUCK) and the
272+
// CLI exits non-zero. (INT-2521)
273+
const msg = error instanceof Error ? error.message : String(error);
274+
throw new Error(`reviewer-stage: produced no parseable verdict: ${msg}`, { cause: error });
271275
}
272276
}
273277

0 commit comments

Comments
 (0)