Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/adapters/errorClassification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ describe('isInfraError (INT-2010)', () => {
expect(isInfraError(new TypeError('fetch failed'))).toBe(true);
});

it('recognises a git-tracker snapshot/diff failure as infra (INT-2521)', () => {
expect(isInfraError(new Error('git-tracker: diff since snapshot failed: fatal: bad object'))).toBe(true);
});

it('recognises local server capacity failures (5xx / loading / overloaded) (INT-2520)', () => {
expect(isInfraError(new Error('Local API error (503): model is loading'))).toBe(true);
expect(isInfraError(new Error('Local API error (502): bad gateway'))).toBe(true);
Expand Down
1 change: 1 addition & 0 deletions src/adapters/errorClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const INFRA_ERROR_PATTERNS = [
'enotfound',
'socket hang up',
'network error',
'git-tracker:', // git snapshot/diff failed mid-run — infra, not a task verdict (colon-anchored to avoid prose) (INT-2521)
'fetch failed', // undici: the real code hides in error.cause.code (checked below)
'terminated', // undici mid-stream socket drop
'unauthorized',
Expand Down
8 changes: 8 additions & 0 deletions src/support/gitTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ describe('gitTracker', () => {
await expect(getChangedFilesSinceSnapshot(repo, snapshot)).resolves.toContain('new-file.txt');
});

it('throws a git-tracker error (not []) when git fails, so real edits are not dropped (INT-2521)', async () => {
// An invalid snapshot tree makes `git diff` fail. Returning [] would be
// indistinguishable from "no changes" and drop the worker's real work (false
// STUCK); it must throw with the git-tracker marker → classified infra_error.
await expect(getChangedFilesSinceSnapshot(repo, '0000000000000000000000000000000000000000'))
.rejects.toThrow(/git-tracker/);
});

it('excludes pre-existing dirty files, reporting only changes after the snapshot (INT-2447)', async () => {
// Repo is ALREADY dirty before the snapshot: an untracked file + a modified
// tracked file. Previously the HEAD-only snapshot blamed the worker for both.
Expand Down
13 changes: 11 additions & 2 deletions src/support/gitTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ export async function takeSnapshot(projectPath: string): Promise<string> {
try {
return await writeWorktreeTree(projectPath);
} catch (error) {
console.error('[GitTracker] takeSnapshot error:', error);
// Best-effort: proceed without the snapshot (the reviewer still judges the real
// diff, so an empty run can't silently pass). Warn LOUDLY so the disabled
// "did real work" guard is visible rather than looking like a clean run. (INT-2521)
console.warn('[GitTracker] takeSnapshot FAILED — "did real work" guard disabled for this run:', error);
return '';
}
}
Expand All @@ -97,8 +100,13 @@ export async function getChangedFilesSinceSnapshot(
const diff = await runGitCommand(projectPath, ['diff', '--name-only', snapshotTree, currentTree]);
return diff.split('\n').filter(Boolean);
} catch (error) {
// Do NOT return [] on a git failure — indistinguishable from "no changes", it
// would drop the worker's real edits (uncredited → false STUCK) or mask them.
// Throw with the 'git-tracker' marker so isInfraError routes it → infra_error
// (backoff retry), preserving the work for the next attempt. (INT-2521)
const msg = error instanceof Error ? error.message : String(error);
console.error('[GitTracker] getChangedFilesSinceSnapshot error:', error);
return [];
throw new Error(`git-tracker: diff since snapshot failed: ${msg}`);
}
}

Expand Down Expand Up @@ -236,6 +244,7 @@ function runGitCommand(cwd: string, args: string[], env?: NodeJS.ProcessEnv): Pr
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true; // claim settlement before the late close/error handlers run
proc.kill('SIGKILL');
reject(new Error(`git ${args.join(' ')} timed out after ${GIT_CMD_TIMEOUT_MS}ms`));
}, GIT_CMD_TIMEOUT_MS);
Expand Down
Loading