Skip to content

Commit 7184b2b

Browse files
authored
fix(results): retry interrupted direct result pushes
1 parent 245441f commit 7184b2b

2 files changed

Lines changed: 94 additions & 21 deletions

File tree

packages/core/src/evaluation/results-repo.ts

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,39 @@ export async function createDraftResultsPr(params: {
985985

986986
const DIRECT_PUSH_MAX_RETRIES = 3;
987987

988+
async function hasUnpushedCommits(repoDir: string, baseBranch: string): Promise<boolean> {
989+
const { stdout } = await runGit(['rev-list', '--count', `origin/${baseBranch}..HEAD`], {
990+
cwd: repoDir,
991+
check: false,
992+
});
993+
return Number.parseInt(stdout.trim(), 10) > 0;
994+
}
995+
996+
async function pushDirectResultsToBase(params: {
997+
readonly normalized: Required<ResultsConfig>;
998+
readonly repoDir: string;
999+
readonly baseBranch: string;
1000+
}): Promise<void> {
1001+
for (let attempt = 1; attempt <= DIRECT_PUSH_MAX_RETRIES; attempt++) {
1002+
try {
1003+
await runGit(['push', 'origin', `HEAD:${params.baseBranch}`], { cwd: params.repoDir });
1004+
updateStatusFile(params.normalized, {
1005+
last_synced_at: new Date().toISOString(),
1006+
last_error: undefined,
1007+
});
1008+
return;
1009+
} catch (error) {
1010+
const message = error instanceof Error ? error.message : String(error);
1011+
if (attempt < DIRECT_PUSH_MAX_RETRIES && message.includes('non-fast-forward')) {
1012+
await fetchResultsRepo(params.repoDir);
1013+
await runGit(['rebase', `origin/${params.baseBranch}`], { cwd: params.repoDir });
1014+
} else {
1015+
throw error;
1016+
}
1017+
}
1018+
}
1019+
}
1020+
9881021
/**
9891022
* Push results directly to the base branch of the results repo.
9901023
* Handles non-fast-forward conflicts by fetching, rebasing, and retrying.
@@ -1020,33 +1053,23 @@ export async function directPushResults(params: {
10201053
check: false,
10211054
});
10221055
if (status.trim().length === 0) {
1056+
if (await hasUnpushedCommits(repoDir, baseBranch)) {
1057+
const aheadPaths = await getAheadPaths(repoDir, `origin/${baseBranch}`);
1058+
if (!areSafeResultsRepoPaths(aheadPaths)) {
1059+
throw new Error('Results repo has non-results committed changes');
1060+
}
1061+
await pushDirectResultsToBase({ normalized, repoDir, baseBranch });
1062+
return true;
1063+
}
10231064
return false;
10241065
}
10251066

10261067
await runGit(['commit', '-m', params.commitMessage, '-m', `Agentv-Run: ${targetRunId}`], {
10271068
cwd: repoDir,
10281069
});
10291070

1030-
for (let attempt = 1; attempt <= DIRECT_PUSH_MAX_RETRIES; attempt++) {
1031-
try {
1032-
await runGit(['push', 'origin', `HEAD:${baseBranch}`], { cwd: repoDir });
1033-
updateStatusFile(normalized, {
1034-
last_synced_at: new Date().toISOString(),
1035-
last_error: undefined,
1036-
});
1037-
return true;
1038-
} catch (error) {
1039-
const message = error instanceof Error ? error.message : String(error);
1040-
if (attempt < DIRECT_PUSH_MAX_RETRIES && message.includes('non-fast-forward')) {
1041-
await fetchResultsRepo(repoDir);
1042-
await runGit(['rebase', `origin/${baseBranch}`], { cwd: repoDir });
1043-
} else {
1044-
throw error;
1045-
}
1046-
}
1047-
}
1048-
1049-
return false;
1071+
await pushDirectResultsToBase({ normalized, repoDir, baseBranch });
1072+
return true;
10501073
}
10511074

10521075
export interface GitListedRun {

packages/core/test/evaluation/results-repo.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execSync } from 'node:child_process';
2-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
import os from 'node:os';
44
import path from 'node:path';
55

@@ -308,6 +308,56 @@ describe('results repo write path', () => {
308308
rmSync(rootDir, { recursive: true, force: true });
309309
});
310310

311+
it('retries an interrupted direct push without dropping the committed run', async () => {
312+
const { remoteDir } = initializeRemoteRepo(rootDir);
313+
const cloneDir = path.join(rootDir, 'results-clone');
314+
const sourceDir = path.join(rootDir, 'source-run');
315+
const runTimestamp = '2026-05-22T11-00-00-000Z';
316+
const destinationPath = path.join('retry', runTimestamp);
317+
const config = createResultsConfig(remoteDir, cloneDir);
318+
const hookPath = path.join(remoteDir, 'hooks', 'pre-receive');
319+
writeRunArtifacts(sourceDir, 'retry', '2026-05-22T11:00:00.000Z');
320+
321+
await ensureResultsRepoClone(config);
322+
git('git config user.email "test@example.com"', cloneDir);
323+
git('git config user.name "Test User"', cloneDir);
324+
325+
writeFileSync(hookPath, '#!/usr/bin/env sh\necho "simulated interrupted push" >&2\nexit 1\n');
326+
chmodSync(hookPath, 0o755);
327+
328+
await expect(
329+
directPushResults({
330+
config,
331+
sourceDir,
332+
destinationPath,
333+
commitMessage: 'feat(results): retry - 1/1 PASS (1.000)',
334+
}),
335+
).rejects.toThrow(/simulated interrupted push/);
336+
expect(git('git rev-list --count origin/main..HEAD', cloneDir)).toBe('1');
337+
expect(git(`git --git-dir "${remoteDir}" ls-tree -r --name-only main`, rootDir)).not.toContain(
338+
`.agentv/results/runs/retry/${runTimestamp}/benchmark.json`,
339+
);
340+
341+
rmSync(hookPath, { force: true });
342+
343+
await expect(
344+
directPushResults({
345+
config,
346+
sourceDir,
347+
destinationPath,
348+
commitMessage: 'feat(results): retry - 1/1 PASS (1.000)',
349+
}),
350+
).resolves.toBe(true);
351+
352+
expect(git('git rev-list --count origin/main..HEAD', cloneDir)).toBe('0');
353+
expect(git(`git --git-dir "${remoteDir}" ls-tree -r --name-only main`, rootDir)).toContain(
354+
`.agentv/results/runs/retry/${runTimestamp}/benchmark.json`,
355+
);
356+
expect(git(`git --git-dir "${remoteDir}" log -1 --pretty=%B main`, rootDir)).toContain(
357+
`Agentv-Run: retry::${runTimestamp}`,
358+
);
359+
}, 20000);
360+
311361
it('commits pushed runs into the configured clone with an Agentv-Run trailer', async () => {
312362
const { remoteDir } = initializeRemoteRepo(rootDir);
313363
const cloneDir = path.join(rootDir, 'results-clone');

0 commit comments

Comments
 (0)