Skip to content

Commit a11c50b

Browse files
christsoclaude
andauthored
feat(results): periodic WIP checkpoints for in-progress eval runs (#1369)
* feat(results): periodic WIP checkpoints for in-progress eval runs Every ~30s, force-pushes partial run output to a unique `agentv/inflight/<hostname>/<run-dir>` branch in the configured results repository. On successful run completion the final results are published to the normal results branch and the WIP branch is deleted. On failure or abort, the WIP branch is left intact for manual recovery. Implementation: - `buildWipBranchName` / `setupWipWorktree` / `pushWipCheckpoint` / `deleteWipBranch` helpers in `packages/core/src/evaluation/results-repo.ts` - `WipCheckpointLoop` class in `apps/cli/src/commands/eval/wip-checkpoint.ts` uses `setInterval(...).unref()` so the timer never prevents process exit - Loop wired into `run-eval.ts`; gated on `auto_push` config flag - All checkpoint errors are best-effort (logged as warnings, never propagate) Closes #av-o3w Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(results): guard wip checkpoint cleanup * fix(results): base checkpoints on storage branch * fix(results): configure git identity for remote commits --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 97dfceb commit a11c50b

8 files changed

Lines changed: 789 additions & 7 deletions

File tree

apps/cli/src/commands/eval/run-eval.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ import {
3232
} from '@agentv/core';
3333

3434
import { enforceRequiredVersion } from '../../version-check.js';
35-
import { maybeAutoExportRunArtifacts } from '../results/remote.js';
35+
import {
36+
type RemoteExportStatus,
37+
getRelativeRunPath,
38+
loadNormalizedResultsConfig,
39+
maybeAutoExportRunArtifacts,
40+
} from '../results/remote.js';
3641
import {
3742
aggregateRunDir,
3843
buildTestTargetKey,
@@ -60,6 +65,7 @@ import {
6065
} from './statistics.js';
6166
import { type TargetSelection, selectMultipleTargets, selectTarget } from './targets.js';
6267
import type { TaskBundleTargetSelection } from './task-bundle.js';
68+
import { WipCheckpointLoop } from './wip-checkpoint.js';
6369

6470
const DEFAULT_WORKERS = 3;
6571

@@ -1543,6 +1549,24 @@ export async function runEvalCommand(
15431549
});
15441550
}
15451551

1552+
// Periodic WIP checkpoint loop: push partial results to a unique non-default
1553+
// branch every ~60s so pod loss doesn't discard completed-test output.
1554+
// Only active when a results repo with auto_push is configured; otherwise a no-op.
1555+
let wipLoop: WipCheckpointLoop | undefined;
1556+
let wipCleanedUp = false;
1557+
let finalExportStatus: RemoteExportStatus = 'disabled';
1558+
{
1559+
const wipConfig = await loadNormalizedResultsConfig(cwd).catch(() => undefined);
1560+
if (wipConfig?.auto_push) {
1561+
wipLoop = new WipCheckpointLoop({
1562+
config: wipConfig,
1563+
runDir,
1564+
destinationPath: getRelativeRunPath(cwd, runDir),
1565+
});
1566+
await wipLoop.start();
1567+
}
1568+
}
1569+
15461570
// Eval files run sequentially; within each file, --workers N test cases run in parallel.
15471571
// This matches industry practice (promptfoo, deepeval, OpenAI Evals) and avoids cross-file
15481572
// workspace races without any grouping complexity.
@@ -1825,7 +1849,7 @@ export async function runEvalCommand(
18251849
// Persist last run path for `agentv results` commands
18261850
await saveRunCache(cwd, outputPath).catch(() => undefined);
18271851

1828-
await maybeAutoExportRunArtifacts({
1852+
finalExportStatus = await maybeAutoExportRunArtifacts({
18291853
cwd,
18301854
run_dir: runDir,
18311855
test_files: activeTestFiles,
@@ -1873,6 +1897,17 @@ export async function runEvalCommand(
18731897
);
18741898
}
18751899

1900+
// WIP cleanup on success: remove the WIP branch only after the final
1901+
// results branch is confirmed published (or confirmed already up to date).
1902+
// If export failed, leave the remote WIP checkpoint as the durable copy.
1903+
if (
1904+
wipLoop &&
1905+
(finalExportStatus === 'published' || finalExportStatus === 'already_published')
1906+
) {
1907+
wipCleanedUp = true;
1908+
await wipLoop.stopAndDeleteWipBranch();
1909+
}
1910+
18761911
return {
18771912
executionErrorCount: summary.executionErrorCount,
18781913
outputPath,
@@ -1883,6 +1918,11 @@ export async function runEvalCommand(
18831918
budgetExceeded: runBudgetExceeded || undefined,
18841919
};
18851920
} finally {
1921+
// WIP cleanup on failure/interrupt: stop the loop but leave the remote
1922+
// WIP branch intact for manual recovery.
1923+
if (wipLoop && !wipCleanedUp) {
1924+
await wipLoop.stop().catch(() => undefined);
1925+
}
18861926
unsubscribeCodexLogs();
18871927
unsubscribePiLogs();
18881928
unsubscribeCopilotSdkLogs();
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* WIP (work-in-progress) checkpoint loop for in-progress eval runs.
3+
*
4+
* Periodically force-pushes the partial run output directory to a unique
5+
* non-default branch (`agentv/inflight/<hostname>/<run-dir-basename>`) in the
6+
* configured results repository. This protects against pod/process loss by
7+
* keeping completed-test results durable without requiring PVC or S3.
8+
*
9+
* Branch lifecycle:
10+
* 1. Start: set up a persistent git worktree for the WIP branch.
11+
* 2. Running: every ~30s, copy run dir → worktree, amend-commit, force-push.
12+
* 3. Success: after final publish to the normal results branch, delete the WIP branch.
13+
* 4. Failure: leave the WIP branch for manual recovery.
14+
*
15+
* Manual recovery from a WIP branch:
16+
* git clone <results-repo> /tmp/recovery
17+
* cd /tmp/recovery && git checkout agentv/inflight/<hostname>/<run-dir>
18+
* cp -r .agentv/results/runs/<run-dir> <project>/.agentv/results/runs/
19+
* agentv eval <eval-file> --output <project>/.agentv/results/runs/<run-dir> --resume
20+
*
21+
* All checkpoint operations are best-effort: failures are logged as warnings
22+
* and never propagate to the eval run.
23+
*/
24+
25+
import {
26+
type NormalizedResultsConfig,
27+
type WipWorktreeHandle,
28+
buildWipBranchName,
29+
deleteWipBranch,
30+
pushWipCheckpoint,
31+
setupWipWorktree,
32+
} from '@agentv/core';
33+
34+
const WIP_CHECKPOINT_INTERVAL_MS = 30_000;
35+
36+
export interface WipCheckpointLoopDependencies {
37+
readonly buildWipBranchName: (runDir: string) => string;
38+
readonly deleteWipBranch: typeof deleteWipBranch;
39+
readonly pushWipCheckpoint: typeof pushWipCheckpoint;
40+
readonly setupWipWorktree: typeof setupWipWorktree;
41+
}
42+
43+
const defaultDependencies: WipCheckpointLoopDependencies = {
44+
buildWipBranchName,
45+
deleteWipBranch,
46+
pushWipCheckpoint,
47+
setupWipWorktree,
48+
};
49+
50+
function warnCheckpointError(context: string, error: unknown): void {
51+
const message = error instanceof Error ? error.message : String(error);
52+
console.warn(`WIP checkpoint: ${context}: ${message}`);
53+
}
54+
55+
export class WipCheckpointLoop {
56+
readonly wipBranch: string;
57+
private readonly config: NormalizedResultsConfig;
58+
private readonly runDir: string;
59+
private readonly destinationPath: string;
60+
private readonly intervalMs: number;
61+
private readonly deps: WipCheckpointLoopDependencies;
62+
private handle: WipWorktreeHandle | undefined;
63+
private timer: ReturnType<typeof setInterval> | undefined;
64+
private checkpointInFlight: Promise<void> | undefined;
65+
private active = false;
66+
67+
constructor(params: {
68+
readonly config: NormalizedResultsConfig;
69+
readonly runDir: string;
70+
readonly destinationPath: string;
71+
readonly intervalMs?: number;
72+
readonly dependencies?: WipCheckpointLoopDependencies;
73+
}) {
74+
this.config = params.config;
75+
this.runDir = params.runDir;
76+
this.destinationPath = params.destinationPath;
77+
this.intervalMs = params.intervalMs ?? WIP_CHECKPOINT_INTERVAL_MS;
78+
this.deps = params.dependencies ?? defaultDependencies;
79+
this.wipBranch = this.deps.buildWipBranchName(params.runDir);
80+
}
81+
82+
async start(): Promise<void> {
83+
try {
84+
this.handle = await this.deps.setupWipWorktree({
85+
config: this.config,
86+
wipBranch: this.wipBranch,
87+
});
88+
} catch (err) {
89+
warnCheckpointError('failed to set up WIP worktree', err);
90+
return;
91+
}
92+
this.active = true;
93+
this.timer = setInterval(() => {
94+
this.runCheckpointIfIdle();
95+
}, this.intervalMs);
96+
// Unref so the timer never prevents process exit.
97+
this.timer.unref?.();
98+
}
99+
100+
private runCheckpointIfIdle(): void {
101+
if (!this.active || this.checkpointInFlight) return;
102+
this.checkpointInFlight = this.checkpoint()
103+
.catch((err) => warnCheckpointError('push failed', err))
104+
.finally(() => {
105+
this.checkpointInFlight = undefined;
106+
});
107+
}
108+
109+
private async checkpoint(): Promise<void> {
110+
if (!this.handle) return;
111+
await this.deps.pushWipCheckpoint({
112+
handle: this.handle,
113+
sourceDir: this.runDir,
114+
destinationPath: this.destinationPath,
115+
});
116+
}
117+
118+
/** Stop the loop and clean up the local worktree. Does NOT delete the remote WIP branch. */
119+
async stop(): Promise<void> {
120+
this.active = false;
121+
if (this.timer !== undefined) {
122+
clearInterval(this.timer);
123+
this.timer = undefined;
124+
}
125+
await this.checkpointInFlight;
126+
if (this.handle) {
127+
await this.handle
128+
.cleanup()
129+
.catch((err) => warnCheckpointError('worktree cleanup failed', err));
130+
this.handle = undefined;
131+
}
132+
}
133+
134+
/**
135+
* Stop the loop and delete the remote WIP branch.
136+
* Call after a successful run to keep the results repo tidy.
137+
*/
138+
async stopAndDeleteWipBranch(): Promise<void> {
139+
await this.stop();
140+
try {
141+
await this.deps.deleteWipBranch({ config: this.config, wipBranch: this.wipBranch });
142+
} catch (err) {
143+
warnCheckpointError(`failed to delete remote branch ${this.wipBranch}`, err);
144+
}
145+
}
146+
}

apps/cli/src/commands/results/remote.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ export interface RemoteExportPayload {
102102
readonly experiment?: string;
103103
}
104104

105+
export type RemoteExportStatus = 'disabled' | 'published' | 'already_published' | 'failed';
106+
105107
export interface RemoteResultsStatus extends ResultsRepoStatus {
106108
readonly run_count: number;
107109
}
@@ -120,7 +122,7 @@ function statusForResult(result: EvaluationResult): 'PASS' | 'FAIL' | 'ERROR' {
120122
return result.score >= DEFAULT_THRESHOLD ? 'PASS' : 'FAIL';
121123
}
122124

123-
function getRelativeRunPath(cwd: string, runDir: string): string {
125+
export function getRelativeRunPath(cwd: string, runDir: string): string {
124126
const relative = path.relative(path.join(cwd, '.agentv', 'results', 'runs'), runDir);
125127
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
126128
return relative;
@@ -150,7 +152,7 @@ async function maybeWarnLargeArtifact(runDir: string): Promise<void> {
150152
}
151153
}
152154

153-
async function loadNormalizedResultsConfig(
155+
export async function loadNormalizedResultsConfig(
154156
cwd: string,
155157
projectId?: string,
156158
): Promise<NormalizedResultsConfig | undefined> {
@@ -427,10 +429,12 @@ export async function clearRemoteRunTags(
427429
return deleteRemoteRunTags(config.path, meta.path);
428430
}
429431

430-
export async function maybeAutoExportRunArtifacts(payload: RemoteExportPayload): Promise<void> {
432+
export async function maybeAutoExportRunArtifacts(
433+
payload: RemoteExportPayload,
434+
): Promise<RemoteExportStatus> {
431435
const config = await loadNormalizedResultsConfig(payload.cwd);
432436
if (!config?.auto_push) {
433-
return;
437+
return 'disabled';
434438
}
435439

436440
try {
@@ -448,12 +452,14 @@ export async function maybeAutoExportRunArtifacts(payload: RemoteExportPayload):
448452

449453
if (!pushed) {
450454
console.warn('Warning: results export produced no git changes. Skipping push.');
451-
return;
455+
return 'already_published';
452456
}
453457

454458
console.log(`Results pushed to ${config.repo} (${config.path}/${relativeRunPath})`);
459+
return 'published';
455460
} catch (error) {
456461
console.warn(`Warning: skipping results export: ${getStatusMessage(error)}`);
457462
console.warn("Warning: Run 'gh auth login' if GitHub authentication is missing.");
463+
return 'failed';
458464
}
459465
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { afterEach, describe, expect, it, mock } from 'bun:test';
2+
3+
import { WipCheckpointLoop } from '../../../src/commands/eval/wip-checkpoint.js';
4+
5+
type Deferred<T> = {
6+
readonly promise: Promise<T>;
7+
readonly resolve: (value: T) => void;
8+
readonly reject: (error: unknown) => void;
9+
};
10+
11+
function deferred<T>(): Deferred<T> {
12+
let resolve!: (value: T) => void;
13+
let reject!: (error: unknown) => void;
14+
const promise = new Promise<T>((res, rej) => {
15+
resolve = res;
16+
reject = rej;
17+
});
18+
return { promise, resolve, reject };
19+
}
20+
21+
async function waitFor(predicate: () => boolean): Promise<void> {
22+
const deadline = Date.now() + 500;
23+
while (!predicate()) {
24+
if (Date.now() > deadline) {
25+
throw new Error('Timed out waiting for condition');
26+
}
27+
await new Promise((resolve) => setTimeout(resolve, 5));
28+
}
29+
}
30+
31+
const cleanupMock = mock(async () => {});
32+
const deleteWipBranchMock = mock(async () => {});
33+
let pushWipCheckpointImplementation = async () => true;
34+
const pushWipCheckpointMock = mock(async () => pushWipCheckpointImplementation());
35+
36+
afterEach(() => {
37+
cleanupMock.mockClear();
38+
deleteWipBranchMock.mockClear();
39+
pushWipCheckpointMock.mockClear();
40+
pushWipCheckpointImplementation = async () => true;
41+
});
42+
43+
describe('WipCheckpointLoop', () => {
44+
it('waits for an in-flight checkpoint before cleanup and remote branch deletion', async () => {
45+
const checkpoint = deferred<boolean>();
46+
pushWipCheckpointImplementation = async () => checkpoint.promise;
47+
48+
const loop = new WipCheckpointLoop({
49+
config: {
50+
mode: 'github',
51+
repo: 'https://github.com/example/results.git',
52+
branch: 'main',
53+
path: '/tmp/results',
54+
auto_push: true,
55+
branch_prefix: 'agentv/results',
56+
},
57+
runDir: '/tmp/run-001',
58+
destinationPath: 'default/run-001',
59+
intervalMs: 1,
60+
dependencies: {
61+
buildWipBranchName: (runDir) => `agentv/inflight/test/${runDir.split('/').pop()}`,
62+
deleteWipBranch: deleteWipBranchMock,
63+
pushWipCheckpoint: pushWipCheckpointMock,
64+
setupWipWorktree: mock(async ({ wipBranch }) => ({
65+
wipBranch,
66+
worktreeDir: '/tmp/wip-worktree',
67+
cloneDir: '/tmp/wip-clone',
68+
cleanup: cleanupMock,
69+
})),
70+
},
71+
});
72+
73+
await loop.start();
74+
await waitFor(() => pushWipCheckpointMock.mock.calls.length === 1);
75+
76+
const stopped = loop.stopAndDeleteWipBranch();
77+
await new Promise((resolve) => setTimeout(resolve, 20));
78+
79+
expect(cleanupMock).not.toHaveBeenCalled();
80+
expect(deleteWipBranchMock).not.toHaveBeenCalled();
81+
82+
checkpoint.resolve(true);
83+
await stopped;
84+
85+
expect(cleanupMock).toHaveBeenCalledTimes(1);
86+
expect(deleteWipBranchMock).toHaveBeenCalledTimes(1);
87+
});
88+
});

0 commit comments

Comments
 (0)