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
33 changes: 28 additions & 5 deletions apps/cli/src/commands/results/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type NormalizedResultsConfig,
type ResultsConfig,
type ResultsRepoStatus,
directPushResults,
directPushResultsWithDetails,
directorySizeBytes,
getProject,
getProjectForPath,
Expand Down Expand Up @@ -155,6 +155,7 @@ export interface ResultsPublishOverrides {
readonly remote?: string;
readonly auto_push?: boolean;
readonly require_push?: boolean;
readonly push_conflict_policy?: 'block' | 'backup_and_force_push';
}

const REMOTE_RUN_PREFIX = 'remote::';
Expand Down Expand Up @@ -224,14 +225,18 @@ export async function loadNormalizedResultsConfig(
...(project.results.remote !== undefined && { remote: project.results.remote }),
...(project.results.path !== undefined && { path: project.results.path }),
...((project.results.sync?.autoPush !== undefined ||
project.results.sync?.requirePush !== undefined) && {
project.results.sync?.requirePush !== undefined ||
project.results.sync?.pushConflictPolicy !== undefined) && {
sync: {
...(project.results.sync?.autoPush !== undefined && {
auto_push: project.results.sync.autoPush,
}),
...(project.results.sync?.requirePush !== undefined && {
require_push: project.results.sync.requirePush,
}),
...(project.results.sync?.pushConflictPolicy !== undefined && {
push_conflict_policy: project.results.sync.pushConflictPolicy,
}),
},
}),
...(project.results.branchPrefix !== undefined && {
Expand Down Expand Up @@ -284,15 +289,20 @@ export async function loadNormalizedResultsConfig(
: {}),
...((overrides.auto_push !== undefined ||
overrides.require_push !== undefined ||
overrides.push_conflict_policy !== undefined ||
baseConfig?.auto_push !== undefined ||
baseConfig?.require_push !== undefined) && {
baseConfig?.require_push !== undefined ||
baseConfig?.push_conflict_policy !== undefined) && {
sync: {
...((overrides.auto_push ?? baseConfig?.auto_push) !== undefined && {
auto_push: overrides.auto_push ?? baseConfig?.auto_push,
}),
...((overrides.require_push ?? baseConfig?.require_push) !== undefined && {
require_push: overrides.require_push ?? baseConfig?.require_push,
}),
...((overrides.push_conflict_policy ?? baseConfig?.push_conflict_policy) !== undefined && {
push_conflict_policy: overrides.push_conflict_policy ?? baseConfig?.push_conflict_policy,
}),
},
}),
...(baseConfig?.branch_prefix ? { branch_prefix: baseConfig.branch_prefix } : {}),
Expand Down Expand Up @@ -605,14 +615,22 @@ export async function maybeAutoExportRunArtifacts(
const relativeRunPath = getRelativeRunPath(payload.cwd, payload.run_dir);
const commitTitle = buildCommitTitle(payload);

const pushed = await directPushResults({
const pushResult = await directPushResultsWithDetails({
config,
sourceDir: payload.run_dir,
destinationPath: relativeRunPath,
commitMessage: commitTitle,
});

if (!pushed) {
if (pushResult.blocked) {
if (config.require_push) {
throw new Error(pushResult.block_reason ?? 'Results branch push conflict');
}
console.warn(`Warning: skipping results export: ${pushResult.block_reason}`);
return 'failed';
}

if (!pushResult.changed) {
console.warn('Warning: results export produced no git changes.');
return 'already_published';
}
Expand All @@ -621,6 +639,11 @@ export async function maybeAutoExportRunArtifacts(
console.log(
`Results ${pushLabel} to ${config.repo} (${config.branch ?? 'default branch'}:${relativeRunPath})`,
);
if (pushResult.backup_ref) {
console.log(
`Backed up previous remote ${pushResult.target_branch ?? config.branch ?? 'results branch'} at ${pushResult.backup_ref}`,
);
}
return 'published';
} catch (error) {
if (config.require_push) {
Expand Down
29 changes: 29 additions & 0 deletions apps/dashboard/src/lib/project-sync-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,23 @@ describe('getProjectSyncView', () => {
canSync: false,
});
});

it('surfaces result branch push conflicts without resolution controls', () => {
expect(
getProjectSyncView({
configured: true,
available: true,
sync_status: 'push_conflict',
push_conflict_policy: 'block',
block_reason: 'Results branch push conflict on agentv/results/v1',
}),
).toMatchObject({
state: 'push_conflict',
label: 'Push conflict',
tone: 'danger',
canSync: false,
});
});
});

describe('buildProjectSyncFeedback', () => {
Expand Down Expand Up @@ -140,6 +157,18 @@ describe('buildProjectSyncFeedback', () => {
});
});

it('keeps push conflict feedback explicit', () => {
expect(
buildProjectSyncFeedback({
configured: true,
available: true,
sync_status: 'push_conflict',
blocked: true,
block_reason: 'Results branch push conflict on agentv/results/v1',
}).message,
).toContain('Sync stopped: Results branch push conflict on agentv/results/v1.');
});

it('builds actionable sync failure feedback without hiding cached remote runs', () => {
expect(
buildProjectSyncErrorFeedback(new Error('GitHub authentication failed'), {
Expand Down
25 changes: 24 additions & 1 deletion apps/dashboard/src/lib/project-sync-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type ProjectSyncState =
| 'ahead'
| 'dirty'
| 'conflicted'
| 'push_conflict'
| 'syncing';

export type ProjectSyncTone = 'neutral' | 'good' | 'info' | 'warn' | 'danger';
Expand Down Expand Up @@ -121,6 +122,23 @@ export function getProjectSyncView(
}

const state = status.sync_status ?? 'clean';
if (state === 'push_conflict') {
return {
state: 'push_conflict',
label: 'Push conflict',
actionLabel: 'Sync Project',
tone: 'danger',
summary:
status.block_reason ??
'The remote results branch changed before local results could be pushed.',
nextAction:
status.push_conflict_policy === 'backup_and_force_push'
? 'Sync stopped before changing the results branch. Refresh status, then retry if this server should replace the remote branch.'
: 'Sync stopped before changing the results branch. Opt in to backup_and_force_push only if this server should replace the remote branch.',
canSync: false,
};
}

if (state === 'conflicted' || state === 'diverged') {
return {
state: 'conflicted',
Expand Down Expand Up @@ -240,7 +258,12 @@ export function buildProjectSyncFeedback(status: RemoteStatusResponse): {
kind: 'success' | 'warning';
message: string;
} {
if (status.blocked || status.sync_status === 'conflicted' || status.sync_status === 'diverged') {
if (
status.blocked ||
status.sync_status === 'conflicted' ||
status.sync_status === 'diverged' ||
status.sync_status === 'push_conflict'
) {
const repo = status.repo ? ` for ${status.repo}` : '';
const reason = status.block_reason ?? 'Sync stopped before changing the results repo.';
return {
Expand Down
10 changes: 10 additions & 0 deletions apps/dashboard/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ export interface RemoteStatusResponse {
local_dir?: string;
path?: string;
auto_push?: boolean;
push_conflict_policy?: 'block' | 'backup_and_force_push';
branch_prefix?: string;
run_count?: number;
last_synced_at?: string;
Expand All @@ -468,6 +469,7 @@ export interface RemoteStatusResponse {
| 'diverged'
| 'dirty'
| 'conflicted'
| 'push_conflict'
| 'syncing';
branch?: string;
upstream?: string;
Expand All @@ -482,6 +484,14 @@ export interface RemoteStatusResponse {
pull_performed?: boolean;
push_performed?: boolean;
commit_created?: boolean;
target_branch?: string;
remote_commit?: string;
local_commit?: string;
backup_ref?: string;
backup_commit?: string;
previous_remote_commit?: string;
force_pushed_commit?: string;
lease_commit?: string;
}

// ── Project types ──────────────────────────────────────────────────────
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/content/docs/docs/tools/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,10 @@ projects:
sync:
auto_push: false
require_push: false
push_conflict_policy: block
```

`results.repo.remote` is the Git remote URL AgentV fetches and pushes. `results.repo.path: .` stores completed run artifacts on a dedicated branch of the source repository without checking out that branch in the source worktree. AgentV manages the local Git remote alias for that URL, so the normal config stays portable across machines. When `results.repo.remote` is omitted, `results.repo.path` means an existing local Git checkout whose object database and refs AgentV should write to, and the branch defaults to `agentv/results/v1`. AgentV creates the branch automatically on first publish and commits only AgentV result paths into it. `sync.auto_push: false` keeps the result commit local; set it to `true` to push the branch best-effort after each completed run. `sync.require_push: true` is for CI workflows where a push failure should fail the command after local artifacts are written.
`results.repo.remote` is the Git remote URL AgentV fetches and pushes. `results.repo.path: .` stores completed run artifacts on a dedicated branch of the source repository without checking out that branch in the source worktree. AgentV manages the local Git remote alias for that URL, so the normal config stays portable across machines. When `results.repo.remote` is omitted, `results.repo.path` means an existing local Git checkout whose object database and refs AgentV should write to, and the branch defaults to `agentv/results/v1`. AgentV creates the branch automatically on first publish and commits only AgentV result paths into it. `sync.auto_push: false` keeps the result commit local; set it to `true` to push the branch best-effort after each completed run. `sync.require_push: true` is for CI workflows where a push failure should fail the command after local artifacts are written. `sync.push_conflict_policy` defaults to `block`; set it to `backup_and_force_push` only for a single-writer Dashboard/server that should replace the remote results branch after creating a remote backup ref.

For a separate results repository, use `results.repo.remote` and an optional managed clone `results.repo.path`:

Expand All @@ -311,6 +312,7 @@ projects:
path: /home/entity/projects/EntityProcess/agentv-examples-eval-results
sync:
auto_push: true
push_conflict_policy: block
```

`results.repo.remote` is the Git remote URL used for clone and push operations, so use HTTPS when credentials are HTTP-token based and SSH when the runtime has SSH keys configured. When `results.repo.remote` is set, `results.repo.path` is the filesystem location of the local clone AgentV manages for that remote results repo. Omit `results.repo.remote` only when `results.repo.path` points at an already-existing local checkout such as `.`.
Expand All @@ -326,6 +328,7 @@ results:
sync:
auto_push: false
require_push: false
push_conflict_policy: block
```

Project-local `.agentv/config.yaml` is for portable eval defaults such as `execution`, `eval_patterns`, and `dashboard`. Do not put `projects` in project-local config; AgentV warns and ignores it there. `results_by_project` is deprecated; use `projects[].results` in `$AGENTV_HOME/config.yaml`.
Expand Down Expand Up @@ -421,6 +424,7 @@ After sync, newly fetched remote runs appear in the list with a **remote** sourc
- A clean clone that is behind the remote is fast-forwarded.
- Safe uncommitted changes under the configured results repo's owned result and metadata paths, such as remote tag overlays under `metadata/runs/**`, are committed and pushed when `sync.auto_push: true`.
- A local results repo that is ahead is pushed when `sync.auto_push: true` and the committed paths are all under `.agentv/results/**`.
- Dirty non-results files, dirty metadata plus remote changes, diverged history, unresolved conflicts, missing upstream branches, non-results commits ahead, and rejected pushes are blocked instead of reset.
- Dirty non-results files, dirty metadata plus remote changes, unresolved conflicts, missing upstream branches, non-results commits ahead, and rejected pushes are blocked instead of reset.
- Non-fast-forward result branch pushes use `sync.push_conflict_policy`. The default `block` policy reports a `push_conflict` with the target branch and local/remote commits. The explicit `backup_and_force_push` policy fetches the target branch, creates `agentv/backups/<timestamp>-<target_branch_slug>-<remote_short_sha>` from the current remote commit, then force-pushes with a lease tied to that same commit. Backup refs are siblings under `agentv/backups/`, not nested below the target branch.

When sync is blocked, Dashboard keeps the local clone intact and shows the `block_reason`, `dirty_paths` or `conflicted_paths`, `git_status`, and a compact `git_diff_summary` so you can resolve the results repo manually before syncing again.
2 changes: 1 addition & 1 deletion apps/web/src/content/docs/docs/tools/results.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ The CLI contract is deliberately narrow: `agentv results` manages local result a

Use these supported remote workflows instead:

- **Automatic publishing:** configure `projects[].results` or top-level `results`; new `agentv eval` and `agentv pipeline bench` runs publish completed artifacts after the run completes. Use `repo.remote` with `repo.path: .` and `repo.branch: agentv/results/v1` to store primary result records on a dedicated branch of the source repo without requiring a machine-local Git remote name. AgentV reserves `agentv/results/v1` for primary results and `agentv/artifacts/v1` for heavy artifact payloads. When `index.jsonl` rows point trace or transcript payloads at `agentv/artifacts/v1`, automatic publishing stores those bytes on that artifact branch in the same remote and publishes pointer keys such as `runs/<run-path>/<pointer.path>`. The configured results branch remains the metadata/control plane (`index.jsonl`, `benchmark.json`, tags, and pointers) instead of duplicating canonical trace/transcript payload bodies. Local pre-publish run workspaces can still contain those files beside the manifest so local tools keep working. Mutable run tags are stored as `tags.json` with a `tag_revision`; there is no tag event log in the normal results layout. `results.repo.path` without `results.repo.remote` means an existing local Git checkout, distinct from `workspace.repos[].repo`, which is a portable repository identity. AgentV manages any local Git remote alias internally. Set `sync.auto_push: true` to push after publish, or `sync.require_push: true` in CI to fail when that push fails. While an eval is still running, [WIP checkpoints](/docs/tools/wip-checkpoints/) can keep partial run output durable on `agentv/wip/...` branches when auto-push is enabled.
- **Automatic publishing:** configure `projects[].results` or top-level `results`; new `agentv eval` and `agentv pipeline bench` runs publish completed artifacts after the run completes. Use `repo.remote` with `repo.path: .` and `repo.branch: agentv/results/v1` to store primary result records on a dedicated branch of the source repo without requiring a machine-local Git remote name. AgentV reserves `agentv/results/v1` for primary results and `agentv/artifacts/v1` for heavy artifact payloads. When `index.jsonl` rows point trace or transcript payloads at `agentv/artifacts/v1`, automatic publishing stores those bytes on that artifact branch in the same remote and publishes pointer keys such as `runs/<run-path>/<pointer.path>`. The configured results branch remains the metadata/control plane (`index.jsonl`, `benchmark.json`, tags, and pointers) instead of duplicating canonical trace/transcript payload bodies. Local pre-publish run workspaces can still contain those files beside the manifest so local tools keep working. Mutable run tags are stored as `tags.json` with a `tag_revision`; there is no tag event log in the normal results layout. `results.repo.path` without `results.repo.remote` means an existing local Git checkout, distinct from `workspace.repos[].repo`, which is a portable repository identity. AgentV manages any local Git remote alias internally. Set `sync.auto_push: true` to push after publish, or `sync.require_push: true` in CI to fail when that push fails. Non-fast-forward result branch pushes block by default with `sync.push_conflict_policy: block`; `backup_and_force_push` is an explicit single-writer opt-in that first creates an `agentv/backups/<timestamp>-<target_branch_slug>-<remote_short_sha>` remote backup branch and then force-pushes with a lease. While an eval is still running, [WIP checkpoints](/docs/tools/wip-checkpoints/) can keep partial run output durable on `agentv/wip/...` branches when auto-push is enabled.
- **Manual Dashboard sync:** run `agentv dashboard`, open the project, and use **Sync Project**.
- **Manual API sync:** while Dashboard is running, call `GET /api/projects/:projectId/remote/status` or `POST /api/projects/:projectId/remote/sync` for project-scoped automation. Single-project sessions also expose `GET /api/remote/status` and `POST /api/remote/sync`.
- **Git escape hatch:** for advanced recovery, inspect or repair the configured `projects[].results.repo.path` clone with `git` directly, then sync again.
Loading
Loading