From 752ffd986b4c6c87533d3e6927a42ea243a7366f Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Wed, 10 Jun 2026 06:33:28 +0200 Subject: [PATCH] fix(results): retry interrupted direct result pushes --- packages/core/src/evaluation/results-repo.ts | 63 +++++++++++++------ .../core/test/evaluation/results-repo.test.ts | 52 ++++++++++++++- 2 files changed, 94 insertions(+), 21 deletions(-) diff --git a/packages/core/src/evaluation/results-repo.ts b/packages/core/src/evaluation/results-repo.ts index 9a1ab0b0..4761a33f 100644 --- a/packages/core/src/evaluation/results-repo.ts +++ b/packages/core/src/evaluation/results-repo.ts @@ -985,6 +985,39 @@ export async function createDraftResultsPr(params: { const DIRECT_PUSH_MAX_RETRIES = 3; +async function hasUnpushedCommits(repoDir: string, baseBranch: string): Promise { + const { stdout } = await runGit(['rev-list', '--count', `origin/${baseBranch}..HEAD`], { + cwd: repoDir, + check: false, + }); + return Number.parseInt(stdout.trim(), 10) > 0; +} + +async function pushDirectResultsToBase(params: { + readonly normalized: Required; + readonly repoDir: string; + readonly baseBranch: string; +}): Promise { + for (let attempt = 1; attempt <= DIRECT_PUSH_MAX_RETRIES; attempt++) { + try { + await runGit(['push', 'origin', `HEAD:${params.baseBranch}`], { cwd: params.repoDir }); + updateStatusFile(params.normalized, { + last_synced_at: new Date().toISOString(), + last_error: undefined, + }); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (attempt < DIRECT_PUSH_MAX_RETRIES && message.includes('non-fast-forward')) { + await fetchResultsRepo(params.repoDir); + await runGit(['rebase', `origin/${params.baseBranch}`], { cwd: params.repoDir }); + } else { + throw error; + } + } + } +} + /** * Push results directly to the base branch of the results repo. * Handles non-fast-forward conflicts by fetching, rebasing, and retrying. @@ -1020,6 +1053,14 @@ export async function directPushResults(params: { check: false, }); if (status.trim().length === 0) { + if (await hasUnpushedCommits(repoDir, baseBranch)) { + const aheadPaths = await getAheadPaths(repoDir, `origin/${baseBranch}`); + if (!areSafeResultsRepoPaths(aheadPaths)) { + throw new Error('Results repo has non-results committed changes'); + } + await pushDirectResultsToBase({ normalized, repoDir, baseBranch }); + return true; + } return false; } @@ -1027,26 +1068,8 @@ export async function directPushResults(params: { cwd: repoDir, }); - for (let attempt = 1; attempt <= DIRECT_PUSH_MAX_RETRIES; attempt++) { - try { - await runGit(['push', 'origin', `HEAD:${baseBranch}`], { cwd: repoDir }); - updateStatusFile(normalized, { - last_synced_at: new Date().toISOString(), - last_error: undefined, - }); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (attempt < DIRECT_PUSH_MAX_RETRIES && message.includes('non-fast-forward')) { - await fetchResultsRepo(repoDir); - await runGit(['rebase', `origin/${baseBranch}`], { cwd: repoDir }); - } else { - throw error; - } - } - } - - return false; + await pushDirectResultsToBase({ normalized, repoDir, baseBranch }); + return true; } export interface GitListedRun { diff --git a/packages/core/test/evaluation/results-repo.test.ts b/packages/core/test/evaluation/results-repo.test.ts index 0757959e..eff87f95 100644 --- a/packages/core/test/evaluation/results-repo.test.ts +++ b/packages/core/test/evaluation/results-repo.test.ts @@ -1,5 +1,5 @@ import { execSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -308,6 +308,56 @@ describe('results repo write path', () => { rmSync(rootDir, { recursive: true, force: true }); }); + it('retries an interrupted direct push without dropping the committed run', async () => { + const { remoteDir } = initializeRemoteRepo(rootDir); + const cloneDir = path.join(rootDir, 'results-clone'); + const sourceDir = path.join(rootDir, 'source-run'); + const runTimestamp = '2026-05-22T11-00-00-000Z'; + const destinationPath = path.join('retry', runTimestamp); + const config = createResultsConfig(remoteDir, cloneDir); + const hookPath = path.join(remoteDir, 'hooks', 'pre-receive'); + writeRunArtifacts(sourceDir, 'retry', '2026-05-22T11:00:00.000Z'); + + await ensureResultsRepoClone(config); + git('git config user.email "test@example.com"', cloneDir); + git('git config user.name "Test User"', cloneDir); + + writeFileSync(hookPath, '#!/usr/bin/env sh\necho "simulated interrupted push" >&2\nexit 1\n'); + chmodSync(hookPath, 0o755); + + await expect( + directPushResults({ + config, + sourceDir, + destinationPath, + commitMessage: 'feat(results): retry - 1/1 PASS (1.000)', + }), + ).rejects.toThrow(/simulated interrupted push/); + expect(git('git rev-list --count origin/main..HEAD', cloneDir)).toBe('1'); + expect(git(`git --git-dir "${remoteDir}" ls-tree -r --name-only main`, rootDir)).not.toContain( + `.agentv/results/runs/retry/${runTimestamp}/benchmark.json`, + ); + + rmSync(hookPath, { force: true }); + + await expect( + directPushResults({ + config, + sourceDir, + destinationPath, + commitMessage: 'feat(results): retry - 1/1 PASS (1.000)', + }), + ).resolves.toBe(true); + + expect(git('git rev-list --count origin/main..HEAD', cloneDir)).toBe('0'); + expect(git(`git --git-dir "${remoteDir}" ls-tree -r --name-only main`, rootDir)).toContain( + `.agentv/results/runs/retry/${runTimestamp}/benchmark.json`, + ); + expect(git(`git --git-dir "${remoteDir}" log -1 --pretty=%B main`, rootDir)).toContain( + `Agentv-Run: retry::${runTimestamp}`, + ); + }, 20000); + it('commits pushed runs into the configured clone with an Agentv-Run trailer', async () => { const { remoteDir } = initializeRemoteRepo(rootDir); const cloneDir = path.join(rootDir, 'results-clone');