diff --git a/src/adapters/errorClassification.test.ts b/src/adapters/errorClassification.test.ts index 4b93175..b680782 100644 --- a/src/adapters/errorClassification.test.ts +++ b/src/adapters/errorClassification.test.ts @@ -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); diff --git a/src/adapters/errorClassification.ts b/src/adapters/errorClassification.ts index 4603027..83bd839 100644 --- a/src/adapters/errorClassification.ts +++ b/src/adapters/errorClassification.ts @@ -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', diff --git a/src/support/gitTracker.test.ts b/src/support/gitTracker.test.ts index ffb2e9e..be0ebd0 100644 --- a/src/support/gitTracker.test.ts +++ b/src/support/gitTracker.test.ts @@ -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. diff --git a/src/support/gitTracker.ts b/src/support/gitTracker.ts index 955440a..3f8a77e 100644 --- a/src/support/gitTracker.ts +++ b/src/support/gitTracker.ts @@ -74,7 +74,10 @@ export async function takeSnapshot(projectPath: string): Promise { 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 ''; } } @@ -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}`); } } @@ -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);