diff --git a/apps/dashboard/src/lib/project-sync-status.test.ts b/apps/dashboard/src/lib/project-sync-status.test.ts index 36b601758..98c0edeff 100644 --- a/apps/dashboard/src/lib/project-sync-status.test.ts +++ b/apps/dashboard/src/lib/project-sync-status.test.ts @@ -102,6 +102,23 @@ describe('getProjectSyncView', () => { canSync: false, }); }); + + it('surfaces a needs-human-merge conflict without suggesting force push', () => { + const view = getProjectSyncView({ + configured: true, + available: true, + sync_status: 'needs_human_merge', + block_reason: 'Results branch agentv/results/v1 diverged and could not be auto-merged', + }); + expect(view).toMatchObject({ + state: 'needs_human_merge', + label: 'Needs human merge', + tone: 'danger', + canSync: false, + }); + expect(view.nextAction).not.toMatch(/force/i); + expect(view.nextAction).toMatch(/pull request/i); + }); }); describe('buildProjectSyncFeedback', () => { diff --git a/apps/dashboard/src/lib/project-sync-status.ts b/apps/dashboard/src/lib/project-sync-status.ts index 6920ae6a0..4e3444b98 100644 --- a/apps/dashboard/src/lib/project-sync-status.ts +++ b/apps/dashboard/src/lib/project-sync-status.ts @@ -9,6 +9,7 @@ export type ProjectSyncState = | 'dirty' | 'conflicted' | 'push_conflict' + | 'needs_human_merge' | 'syncing'; export type ProjectSyncTone = 'neutral' | 'good' | 'info' | 'warn' | 'danger'; @@ -122,6 +123,20 @@ export function getProjectSyncView( } const state = status.sync_status ?? 'clean'; + if (state === 'needs_human_merge') { + return { + state: 'needs_human_merge', + label: 'Needs human merge', + actionLabel: 'Sync Project', + tone: 'danger', + summary: + status.block_reason ?? + 'The results branch diverged and a genuine content conflict could not be auto-merged.', + nextAction: + 'The remote branch is unchanged and no history was rewritten. Resolve the conflict with a GitHub pull request, then sync again.', + canSync: false, + }; + } if (state === 'push_conflict') { return { state: 'push_conflict', @@ -132,9 +147,7 @@ export function getProjectSyncView( 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.', + 'Sync stopped before changing the results branch. Refresh status, then retry — results sync auto-merges concurrent writes and never force-pushes.', canSync: false, }; } diff --git a/apps/dashboard/src/lib/types.ts b/apps/dashboard/src/lib/types.ts index 3c03e1ca0..09c7f54cb 100644 --- a/apps/dashboard/src/lib/types.ts +++ b/apps/dashboard/src/lib/types.ts @@ -470,6 +470,7 @@ export interface RemoteStatusResponse { | 'dirty' | 'conflicted' | 'push_conflict' + | 'needs_human_merge' | 'syncing'; branch?: string; upstream?: string; diff --git a/packages/core/src/evaluation/results-repo.ts b/packages/core/src/evaluation/results-repo.ts index 3f1d070b9..eba76ace3 100644 --- a/packages/core/src/evaluation/results-repo.ts +++ b/packages/core/src/evaluation/results-repo.ts @@ -66,6 +66,137 @@ const RESULTS_REPO_GENESIS_MESSAGE = 'chore(results): initialize AgentV results const RESULTS_REPO_GENESIS_DATE = '@0 +0000'; const RESULT_INDEX_FILENAME = 'index.jsonl'; +// Artifact-aware merge config for the AgentV-owned results checkout. These two +// pieces let `git merge` reconcile concurrent result writes automatically so +// results sync never has to force-push (see resolveResultBranchPushConflict): +// - `.gitattributes` (committed on the results branch) maps the append-only +// run index to git's stock `union` driver and the editable JSON overlay to +// our `agentv-json` driver. +// - `merge.agentv-json.driver` (registered once in the checkout's local git +// config) points at a tiny 3-way JSON set/field union script. +// Run bundles under runs///** are uniquely pathed, so a 3-way merge +// never conflicts on them and they need no attribute. +const RESULTS_REPO_GITATTRIBUTES_FILE = '.gitattributes'; +const RESULTS_REPO_GITATTRIBUTES_CONTENT = `# Managed by AgentV. Artifact-aware merge so results sync never force-pushes. +# Append-only run index: union concurrent appends (lines are orthogonal). +index.jsonl merge=union +# Editable run overlay (tags/feedback): 3-way JSON set/field union via the +# agentv-json driver; a genuine scalar conflict falls through to a human merge. +metadata/runs/**/*.json merge=agentv-json +`; +const RESULTS_JSON_MERGE_DRIVER_NAME = 'agentv-json'; +// Materialized into the results checkout's git dir and invoked by git as +// `node