diff --git a/CONCEPTS.md b/CONCEPTS.md index 97c88b942..3014bf601 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -33,3 +33,11 @@ Shared domain vocabulary for this project — entities, named processes, and sta **Gate policy** — The explicit rule that decides whether repeated attempts pass CI, such as `all_attempts_successful`, `any_attempt_successful`, `attempt_success_rate_at_least`, or `mean_pass_rate_at_least`. Without a repeat-run gate policy, AgentV preserves the normal single-run gate behavior and treats repeat statistics as report data. **Flaky eval outcome** — A repeat-run aggregate whose attempts disagree, or whose failure classification points at verifier, infrastructure, or timeout instability rather than a stable model-quality failure. + +## Release Channels + +**Stable release** — A package publication channel whose surfaces are treated as compatibility commitments for normal users. + +**Next tag** — A prerelease package channel used to validate upcoming AgentV surfaces before they become stable compatibility commitments. + +Next-tag-only surfaces may be hard-corrected before stable release when preserving them would encode an unsafe or misleading contract. Stable-release surfaces need an explicit compatibility or migration strategy. diff --git a/apps/cli/src/commands/results/remote.ts b/apps/cli/src/commands/results/remote.ts index 22c5c5e3c..43410cb08 100644 --- a/apps/cli/src/commands/results/remote.ts +++ b/apps/cli/src/commands/results/remote.ts @@ -156,7 +156,7 @@ export interface ResultsPublishOverrides { readonly remote?: string; readonly auto_push?: boolean; readonly require_push?: boolean; - readonly push_conflict_policy?: 'block' | 'backup_and_force_push'; + readonly push_conflict_policy?: 'block'; } const REMOTE_RUN_PREFIX = 'remote::'; diff --git a/apps/dashboard/src/lib/project-sync-status.test.ts b/apps/dashboard/src/lib/project-sync-status.test.ts index eb1f34069..73c773ad0 100644 --- a/apps/dashboard/src/lib/project-sync-status.test.ts +++ b/apps/dashboard/src/lib/project-sync-status.test.ts @@ -205,6 +205,21 @@ describe('buildProjectSyncFeedback', () => { expect(feedback.message).toContain('pulled remote results'); }); + it('surfaces auto-merged remote changes in successful sync feedback', () => { + const feedback = buildProjectSyncFeedback({ + configured: true, + available: true, + sync_status: 'clean', + auto_merged_remote: true, + push_performed: true, + run_count: 2, + }); + + expect(feedback.kind).toBe('success'); + expect(feedback.message).toContain('Merged remote (auto)'); + expect(feedback.message).toContain('pushed local results'); + }); + it('keeps blocked sync feedback explicit', () => { expect( buildProjectSyncFeedback({ diff --git a/apps/dashboard/src/lib/project-sync-status.ts b/apps/dashboard/src/lib/project-sync-status.ts index 544191cf1..e6e45c229 100644 --- a/apps/dashboard/src/lib/project-sync-status.ts +++ b/apps/dashboard/src/lib/project-sync-status.ts @@ -334,6 +334,7 @@ export function buildProjectSyncFeedback(status: RemoteStatusResponse): { const actions = [ status.commit_created ? 'committed pending metadata' : undefined, status.pull_performed ? 'pulled remote results' : undefined, + status.auto_merged_remote ? 'Merged remote (auto)' : undefined, status.push_performed ? 'pushed local results' : undefined, ].filter((action): action is string => action !== undefined); diff --git a/apps/dashboard/src/lib/types.ts b/apps/dashboard/src/lib/types.ts index 12b3aa34a..d86ca3c9f 100644 --- a/apps/dashboard/src/lib/types.ts +++ b/apps/dashboard/src/lib/types.ts @@ -471,7 +471,7 @@ export interface RemoteStatusResponse { local_dir?: string; path?: string; auto_push?: boolean; - push_conflict_policy?: 'block' | 'backup_and_force_push'; + push_conflict_policy?: 'block'; branch_prefix?: string; run_count?: number; last_synced_at?: string; @@ -500,6 +500,7 @@ export interface RemoteStatusResponse { pull_performed?: boolean; push_performed?: boolean; commit_created?: boolean; + auto_merged_remote?: boolean; target_branch?: string; remote_commit?: string; local_commit?: string; diff --git a/apps/web/src/content/docs/docs/tools/dashboard.mdx b/apps/web/src/content/docs/docs/tools/dashboard.mdx index e96125b39..22da74d25 100644 --- a/apps/web/src/content/docs/docs/tools/dashboard.mdx +++ b/apps/web/src/content/docs/docs/tools/dashboard.mdx @@ -295,7 +295,7 @@ projects: 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. `sync.push_conflict_policy` defaults to `block`; the `backup_and_force_push` value is deprecated and no longer force-pushes. Non-fast-forward result branch pushes are auto-merged with artifact-aware Git merge drivers and pushed as a fast-forward, so the canonical results branch is never force-pushed or rewritten. Genuine overlay conflicts route to a timestamped temp branch plus a GitHub compare link for a human merge instead. +`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`; the removed `backup_and_force_push` value is rejected with migration guidance because AgentV never force-pushes result branches. Non-fast-forward result branch pushes are auto-merged with artifact-aware Git merge drivers and pushed as a fast-forward, so the canonical results branch is never force-pushed or rewritten. Genuine overlay conflicts route to a timestamped temp branch plus a GitHub compare link for a human merge instead. For a separate results repository, use `results.repo.remote` and an optional managed clone `results.repo.path`: @@ -425,7 +425,7 @@ After sync, newly fetched remote runs appear in the list with a **remote** sourc - 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, unresolved conflicts, missing upstream branches, non-results commits ahead, and rejected pushes are blocked instead of reset. -- Non-fast-forward result branch pushes never force-push. AgentV runs a bounded fetch → merge → push loop that absorbs concurrent remote writes with a real merge commit using artifact-aware Git merge drivers (union for the append-only `index.jsonl`, a JSON-union driver for tag and feedback overlays), so the common append-mostly case auto-merges and pushes as a fast-forward. The `sync.push_conflict_policy: backup_and_force_push` value is deprecated and no longer force-pushes; it now auto-merges like the default and emits a one-time deprecation notice. +- Non-fast-forward result branch pushes never force-push. AgentV runs a bounded fetch → merge → push loop that absorbs concurrent remote writes with a real merge commit using artifact-aware Git merge drivers (union for the append-only `index.jsonl`, a JSON-union driver for tag and feedback overlays), so the common append-mostly case auto-merges and pushes as a fast-forward. When Dashboard sync absorbs concurrent remote changes this way, the success feedback includes **Merged remote (auto)**. The removed `sync.push_conflict_policy: backup_and_force_push` value is rejected with migration guidance; remove the field or set it to `block`. - When a genuine overlay conflict cannot be auto-merged, AgentV does not touch the canonical branch. It pushes the local work to a fresh timestamped `agentv/results-sync/--` branch and reports `needs_human_merge` with a `pending_merge` block (temp branch, target branch, and a GitHub compare URL when the remote is on GitHub). The toolbar shows a **Pending merge** card: open the link to merge the branch into the canonical target on GitHub (GitHub's pull request is the conflict surface — AgentV builds no merge UI), then click **I merged it — resync**. That resumes canonical sync by fast-forward-pulling the merged target. A premature click is a safe no-op — local work stays intact and the next sync re-creates a temp 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. diff --git a/apps/web/src/content/docs/docs/tools/results.mdx b/apps/web/src/content/docs/docs/tools/results.mdx index 9d6bfed72..ac371163c 100644 --- a/apps/web/src/content/docs/docs/tools/results.mdx +++ b/apps/web/src/content/docs/docs/tools/results.mdx @@ -239,7 +239,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//`. 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 never force-push: AgentV auto-merges concurrent remote writes with artifact-aware Git merge drivers (a union driver for the append-only `index.jsonl`, a JSON-union driver for tag and feedback overlays) and pushes the merge as a fast-forward, and routes a genuine overlay conflict to a timestamped `agentv/results-sync/...` branch plus a GitHub compare/PR link for a human merge. `sync.push_conflict_policy: backup_and_force_push` is deprecated and no longer force-pushes — it now auto-merges like the default `block` and emits a one-time deprecation notice. 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//`. 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 never force-push: AgentV auto-merges concurrent remote writes with artifact-aware Git merge drivers (a union driver for the append-only `index.jsonl`, a JSON-union driver for tag and feedback overlays) and pushes the merge as a fast-forward, and routes a genuine overlay conflict to a timestamped `agentv/results-sync/...` branch plus a GitHub compare/PR link for a human merge. The removed `sync.push_conflict_policy: backup_and_force_push` value is rejected with migration guidance; remove the field or set it to `block`. 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. diff --git a/docs/adr/2026-06-24-no-force-push-results-sync.md b/docs/adr/2026-06-24-no-force-push-results-sync.md index 3ed7d98cc..93e0d04c0 100644 --- a/docs/adr/2026-06-24-no-force-push-results-sync.md +++ b/docs/adr/2026-06-24-no-force-push-results-sync.md @@ -69,9 +69,11 @@ all of it and is safe: a premature OK just pulls a target lacking the local work re-diverges on the next push, and re-creates a temp branch — no data loss, no force push. -`backup_and_force_push` is **deprecated, not removed**: the config value still -validates but now auto-merges like the default and emits a one-time deprecation -notice, so shipped surfaces referencing it keep working. +`backup_and_force_push` is **hard-deprecated/removed** from supported config: +the value shipped only on the `next` npm tag before stable release, so AgentV +now rejects it with migration guidance instead of preserving a compatibility +alias. Remove the field or set `sync.push_conflict_policy: block`; AgentV never +force-pushes result branches. ## Consequences @@ -112,8 +114,9 @@ notice, so shipped surfaces referencing it keep working. Delivered in phases under epic av-raf (all non-breaking): - Phase 0 — `.gitattributes` + `agentv-json` merge driver registration (#1506). -- Phase 1 — bounded `fetch → merge → push` loop replacing the force-push path; - `backup_and_force_push` deprecated (#1506). +- Phase 1 — bounded `fetch → merge → push` loop replacing the force-push path + (#1506); `backup_and_force_push` hard-deprecated before stable release + (#1510). - Phase 2 — temp-branch fallback + `confirm-merge` (OK-to-resync) API (#1507). - Phase 3 — Dashboard **Pending merge** card with the GitHub link + resync button (#1508). diff --git a/docs/solutions/conventions/hard-correct-next-tag-only-surfaces.md b/docs/solutions/conventions/hard-correct-next-tag-only-surfaces.md new file mode 100644 index 000000000..c96dfdf22 --- /dev/null +++ b/docs/solutions/conventions/hard-correct-next-tag-only-surfaces.md @@ -0,0 +1,75 @@ +--- +title: Hard-correct next-tag-only surfaces before stable release +date: 2026-06-25 +category: conventions +module: Release compatibility +problem_type: convention +component: development_workflow +severity: medium +applies_when: + - Removing or renaming a config value, wire field, CLI flag, or public API surface + - Deciding whether a shipped-looking surface needs backward compatibility +tags: [release-channel, compatibility, deprecation, config-schema] +--- + +# Hard-correct next-tag-only surfaces before stable release + +## Context + +AgentV briefly exposed `results.sync.push_conflict_policy: backup_and_force_push` +on the npm `next` tag while replacing force-push results sync with a no-force +merge loop. Treating that as a stable shipped surface would have kept a +misleading compatibility alias around even though the value contradicted the new +product invariant: AgentV never force-pushes result branches. + +## Guidance + +When checking whether a config value or public surface has shipped, distinguish +release channels: + +- Stable npm releases require normal compatibility handling: preserve behavior, + soft-deprecate, or provide an explicit migration path. +- `next`-only releases can be hard-corrected before the surface reaches stable, + especially when preserving the surface would encode a dangerous or misleading + contract. + +For removed config values, make the correction explicit: + +```yaml +results: + sync: + # Remove unsupported aliases and use the stable default. + push_conflict_policy: block +``` + +If existing local registries or generated config may contain the removed value, +either reject it with migration guidance or drop it during a registry migration +that rewrites the supported shape on the next save. + +## Why This Matters + +Pre-release tags are useful for discovering wrong API names and unsafe contracts. +If every `next` exposure becomes permanent compatibility debt, the project loses +the ability to correct those mistakes before stable release. The compatibility +bar should protect stable users without forcing unsafe pre-release names into +the long-term schema. + +## When to Apply + +- A value, flag, or field appeared only on npm `next` or another prerelease + channel. +- The replacement behavior is already stable and safer. +- Keeping the old surface would confuse users about current behavior or + preserve a hazardous name. + +## Examples + +`backup_and_force_push` should not remain a supported +`results.sync.push_conflict_policy` value after the force-push implementation is +removed. Even though it appeared on a published `next` tarball, the stable +migration is to remove the field or set it to `block`; AgentV's actual behavior +is a no-force-push merge loop. + +## Related + +- docs/adr/2026-06-24-no-force-push-results-sync.md diff --git a/packages/core/src/evaluation/loaders/config-loader.ts b/packages/core/src/evaluation/loaders/config-loader.ts index ebe5d653c..d0408aa1c 100644 --- a/packages/core/src/evaluation/loaders/config-loader.ts +++ b/packages/core/src/evaluation/loaders/config-loader.ts @@ -35,7 +35,7 @@ export type ExecutionDefaults = { readonly pool_slots?: number; }; -export type ResultPushConflictPolicy = 'block' | 'backup_and_force_push'; +export type ResultPushConflictPolicy = 'block'; export type ResultsConfig = { readonly mode?: 'github'; @@ -782,21 +782,20 @@ export function parseResultsConfig(raw: unknown, configPath: string): ResultsCon logWarning(`Invalid results.sync.require_push in ${configPath}, expected boolean`); return undefined; } - if ( - syncObj.push_conflict_policy !== undefined && - syncObj.push_conflict_policy !== 'block' && - syncObj.push_conflict_policy !== 'backup_and_force_push' - ) { + if (syncObj.push_conflict_policy === 'backup_and_force_push') { logWarning( - `Invalid results.sync.push_conflict_policy in ${configPath}, expected 'block' or 'backup_and_force_push'`, + `results.sync.push_conflict_policy: 'backup_and_force_push' in ${configPath} is no longer supported. Remove the field or set it to 'block'; AgentV never force-pushes result branches.`, ); return undefined; } + if (syncObj.push_conflict_policy !== undefined && syncObj.push_conflict_policy !== 'block') { + logWarning(`Invalid results.sync.push_conflict_policy in ${configPath}, expected 'block'`); + return undefined; + } sync = { ...(typeof syncObj.auto_push === 'boolean' && { auto_push: syncObj.auto_push }), ...(typeof syncObj.require_push === 'boolean' && { require_push: syncObj.require_push }), - ...((syncObj.push_conflict_policy === 'block' || - syncObj.push_conflict_policy === 'backup_and_force_push') && { + ...(syncObj.push_conflict_policy === 'block' && { push_conflict_policy: syncObj.push_conflict_policy, }), }; diff --git a/packages/core/src/evaluation/results-repo.ts b/packages/core/src/evaluation/results-repo.ts index b07a0e58e..5c0d38372 100644 --- a/packages/core/src/evaluation/results-repo.ts +++ b/packages/core/src/evaluation/results-repo.ts @@ -256,6 +256,7 @@ export interface ResultsRepoStatus { readonly pull_performed?: boolean; readonly push_performed?: boolean; readonly commit_created?: boolean; + readonly auto_merged_remote?: boolean; readonly target_branch?: string; readonly remote_commit?: string; readonly local_commit?: string; @@ -300,6 +301,7 @@ export interface DirectPushResultsResult { readonly block_reason?: string; readonly sync_status?: ResultsRepoSyncStatus; readonly push_conflict_policy: ResultPushConflictPolicy; + readonly auto_merged_remote?: boolean; readonly target_branch?: string; readonly remote_commit?: string; readonly local_commit?: string; @@ -386,7 +388,18 @@ export function normalizeResultsConfig( (repoUrl && useStorageBranchWorktree ? MANAGED_RESULTS_REMOTE : 'origin'); const autoPush = config.sync?.auto_push ?? config.auto_push === true; const requirePush = config.sync?.require_push === true; - const pushConflictPolicy = config.sync?.push_conflict_policy ?? 'block'; + const configuredPushConflictPolicy = ( + config.sync as { push_conflict_policy?: unknown } | undefined + )?.push_conflict_policy; + if (configuredPushConflictPolicy === 'backup_and_force_push') { + throw new Error( + "results.sync.push_conflict_policy: 'backup_and_force_push' is no longer supported. Remove the field or set it to 'block'; AgentV never force-pushes result branches.", + ); + } + if (configuredPushConflictPolicy !== undefined && configuredPushConflictPolicy !== 'block') { + throw new Error("results.sync.push_conflict_policy must be 'block'"); + } + const pushConflictPolicy = configuredPushConflictPolicy ?? 'block'; const resolvedRepoPath = repoPath ? resolveLocalPath(repoPath, baseDir) : undefined; const resolvedPath = explicitClonePath ? resolveLocalPath(explicitClonePath, baseDir) @@ -1437,6 +1450,7 @@ type ResultsBranchPushDetails = { readonly previousRemoteCommit?: string; readonly forcePushedCommit?: string; readonly leaseCommit?: string; + readonly autoMergedRemote?: boolean; readonly pendingMerge?: PendingMergeDetails; }; @@ -1484,6 +1498,7 @@ function pushDetailsToWire( | 'previous_remote_commit' | 'force_pushed_commit' | 'lease_commit' + | 'auto_merged_remote' | 'pending_merge' > { if (!details) { @@ -1503,6 +1518,7 @@ function pushDetailsToWire( force_pushed_commit: details.forcePushedCommit, }), ...(details.leaseCommit !== undefined && { lease_commit: details.leaseCommit }), + ...(details.autoMergedRemote === true && { auto_merged_remote: true }), ...(details.pendingMerge !== undefined && { pending_merge: pendingMergeToWire(details.pendingMerge), }), @@ -1663,23 +1679,6 @@ function isNonFastForwardPushError(error: unknown): boolean { // race where another writer advances the remote between our fetch and push. const RESULTS_PUSH_MERGE_MAX_ATTEMPTS = 5; -let warnedForcePushPolicyDeprecation = false; -// `backup_and_force_push` is retained for config back-compat but no longer force -// pushes. Layer 1's auto-merge loop handles the common case and genuine -// conflicts go to a human GitHub merge, so the policy is a no-op alias for the -// safe default. Warn once per process so existing configs surface the change. -function warnDeprecatedForcePushPolicyOnce(): void { - if (warnedForcePushPolicyDeprecation) { - return; - } - warnedForcePushPolicyDeprecation = true; - console.warn( - "[agentv] results.sync.push_conflict_policy: 'backup_and_force_push' is deprecated and no " + - 'longer force-pushes. Results sync now auto-merges concurrent writes and routes genuine ' + - 'conflicts to a human GitHub merge.', - ); -} - async function isAncestorCommit( repoDir: string, ancestor: string, @@ -1917,9 +1916,6 @@ async function resolveResultBranchPushConflict(params: { readonly targetBranch: string; readonly sourceRef: string; }): Promise { - if (params.normalized.push_conflict_policy === 'backup_and_force_push') { - warnDeprecatedForcePushPolicyOnce(); - } await ensureResultsMergeConfig(params.repoDir); const { repoDir, targetBranch } = params; @@ -2017,6 +2013,7 @@ async function resolveResultBranchPushConflict(params: { } let pushSpec = branchCheckedOut ? 'HEAD' : localCommit; + let autoMergedRemote = false; // Diverged (neither side is an ancestor): commit a real 3-way merge using // the artifact-aware drivers. If `git merge` reports a genuine conflict, no // history is rewritten and we route to a human GitHub merge. @@ -2031,6 +2028,7 @@ async function resolveResultBranchPushConflict(params: { ); } pushSpec = 'HEAD'; + autoMergedRemote = true; } else { const base = await getMergeBaseCommit(repoDir, localCommit, remoteCommit); if (!base) { @@ -2065,6 +2063,7 @@ async function resolveResultBranchPushConflict(params: { ); pushSpec = mergeCommitOut.trim(); await runGit(['update-ref', localRef, pushSpec, localCommit], { cwd: repoDir }); + autoMergedRemote = true; } } @@ -2083,7 +2082,11 @@ async function resolveResultBranchPushConflict(params: { const pushedCommit = (await getCommitSha(repoDir, pushSpec)) ?? localCommit; return { blocked: false, - details: { ...details, localCommit: pushedCommit }, + details: { + ...details, + localCommit: pushedCommit, + ...(autoMergedRemote && { autoMergedRemote }), + }, }; } @@ -3546,10 +3549,13 @@ function mergeDirectPushResults( if (blocked) { return blocked; } - const detailed = [...results].reverse().find((result) => result.backup_ref !== undefined); + const detailed = [...results] + .reverse() + .find((result) => result.backup_ref !== undefined || result.auto_merged_remote === true); return { changed: results.some((result) => result.changed), push_conflict_policy: normalized.push_conflict_policy, + ...(detailed?.auto_merged_remote === true && { auto_merged_remote: true }), ...(detailed?.backup_ref !== undefined && { backup_ref: detailed.backup_ref, }), diff --git a/packages/core/src/evaluation/validation/config-validator.ts b/packages/core/src/evaluation/validation/config-validator.ts index 23834b24e..2ef12bfe5 100644 --- a/packages/core/src/evaluation/validation/config-validator.ts +++ b/packages/core/src/evaluation/validation/config-validator.ts @@ -448,16 +448,22 @@ function validateResultsSyncAndBranchPrefix( `Field '${location}.sync.require_push' must be a boolean`, ); } - if ( + if (syncRecord.push_conflict_policy === 'backup_and_force_push') { + addError( + errors, + filePath, + `${location}.sync.push_conflict_policy`, + `Field '${location}.sync.push_conflict_policy' uses removed value 'backup_and_force_push'; remove it or set it to 'block'. AgentV never force-pushes result branches.`, + ); + } else if ( syncRecord.push_conflict_policy !== undefined && - syncRecord.push_conflict_policy !== 'block' && - syncRecord.push_conflict_policy !== 'backup_and_force_push' + syncRecord.push_conflict_policy !== 'block' ) { addError( errors, filePath, `${location}.sync.push_conflict_policy`, - `Field '${location}.sync.push_conflict_policy' must be 'block' or 'backup_and_force_push'`, + `Field '${location}.sync.push_conflict_policy' must be 'block'`, ); } } diff --git a/packages/core/src/projects.ts b/packages/core/src/projects.ts index 71c933d0c..e5272284a 100644 --- a/packages/core/src/projects.ts +++ b/packages/core/src/projects.ts @@ -63,7 +63,7 @@ import { getAgentvConfigDir } from './paths.js'; export interface ProjectResultsSyncConfig { autoPush?: boolean; requirePush?: boolean; - pushConflictPolicy?: 'block' | 'backup_and_force_push'; + pushConflictPolicy?: 'block'; } export interface ProjectResultsConfig { @@ -105,7 +105,7 @@ export function getProjectsRegistryPath(): string { interface ProjectResultsSyncYaml { auto_push?: boolean; require_push?: boolean; - push_conflict_policy?: 'block' | 'backup_and_force_push'; + push_conflict_policy?: 'block' | string; } interface ProjectResultsYaml { @@ -150,6 +150,18 @@ function readTrimmedString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +let warnedRemovedBackupAndForcePushPolicy = false; + +function warnRemovedBackupAndForcePushPolicy(): void { + if (warnedRemovedBackupAndForcePushPolicy) { + return; + } + warnedRemovedBackupAndForcePushPolicy = true; + console.warn( + "[agentv] projects[].results.sync.push_conflict_policy: 'backup_and_force_push' is no longer supported and was ignored while loading the project registry. Remove the field or set it to 'block'; AgentV never force-pushes result branches.", + ); +} + function fromYaml(raw: unknown): ProjectEntry | null { if (!raw || typeof raw !== 'object') return null; const e = raw as Partial; @@ -208,8 +220,7 @@ function fromYaml(raw: unknown): ProjectEntry | null { ...(typeof sync.require_push === 'boolean' ? { requirePush: sync.require_push } : {}), - ...(sync.push_conflict_policy === 'block' || - sync.push_conflict_policy === 'backup_and_force_push' + ...(sync.push_conflict_policy === 'block' ? { pushConflictPolicy: sync.push_conflict_policy } : {}), }, @@ -219,6 +230,9 @@ function fromYaml(raw: unknown): ProjectEntry | null { ? { branchPrefix: r.branch_prefix.trim() } : {}), }; + if (sync?.push_conflict_policy === 'backup_and_force_push') { + warnRemovedBackupAndForcePushPolicy(); + } } } return entry; diff --git a/packages/core/test/evaluation/loaders/config-loader.test.ts b/packages/core/test/evaluation/loaders/config-loader.test.ts index 764836ad7..1ec20fe88 100644 --- a/packages/core/test/evaluation/loaders/config-loader.test.ts +++ b/packages/core/test/evaluation/loaders/config-loader.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test'; +import { describe, expect, it, spyOn } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -238,7 +238,7 @@ describe('parseResultsConfig', () => { sync: { auto_push: false, require_push: true, - push_conflict_policy: 'backup_and_force_push', + push_conflict_policy: 'block', }, }, '/tmp/.agentv/config.yaml', @@ -252,11 +252,33 @@ describe('parseResultsConfig', () => { sync: { auto_push: false, require_push: true, - push_conflict_policy: 'backup_and_force_push', + push_conflict_policy: 'block', }, }); }); + it('rejects removed backup_and_force_push sync policy with migration guidance', () => { + const warn = spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const result = parseResultsConfig( + { + repo_path: '.', + sync: { + auto_push: true, + push_conflict_policy: 'backup_and_force_push', + }, + }, + '/tmp/.agentv/config.yaml', + ); + + expect(result).toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('is no longer supported')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("set it to 'block'")); + } finally { + warn.mockRestore(); + } + }); + it('parses nested repo config for a managed results clone', () => { const result = parseResultsConfig( { diff --git a/packages/core/test/evaluation/results-repo.test.ts b/packages/core/test/evaluation/results-repo.test.ts index 280089205..2ccf92d4e 100644 --- a/packages/core/test/evaluation/results-repo.test.ts +++ b/packages/core/test/evaluation/results-repo.test.ts @@ -1296,6 +1296,7 @@ describe('results repo write path', () => { expect(result.changed).toBe(true); expect(result.blocked).toBeFalsy(); + expect(result.auto_merged_remote).toBe(true); expect(result.backup_ref).toBeUndefined(); expect(result.force_pushed_commit).toBeUndefined(); @@ -1327,51 +1328,26 @@ describe('results repo write path', () => { ).toBe(''); }, 30000); - it('deprecates backup_and_force_push by auto-merging instead of force pushing', async () => { - const { remoteDir, seedDir } = initializeRemoteRepo(rootDir); - const storageBranch = initializeRemoteStorageBranch(seedDir, DEFAULT_RESULTS_BRANCH); - const cloneDir = path.join(rootDir, 'results-clone-push-deprecated-policy'); - const fixture = await createStaleResultBranchPushFixture({ - rootDir, - remoteDir, - seedDir, - cloneDir, - storageBranch, - }); - - const result = await directPushResultsWithDetails({ - config: { - ...fixture.config, - sync: { auto_push: true, push_conflict_policy: 'backup_and_force_push' }, - }, - sourceDir: fixture.localSourceDir, - destinationPath: fixture.localDestinationPath, - commitMessage: 'feat(results): deprecated force policy auto-merges', - }); + it('rejects removed backup_and_force_push policy before syncing', async () => { + const sourceDir = path.join(rootDir, 'removed-policy-source'); + writeRunArtifacts(sourceDir, 'removed-policy', '2026-06-23T10:30:00.000Z'); - expect(result.changed).toBe(true); - expect(result.blocked).toBeFalsy(); - // The deprecated policy no longer force-pushes or backs anything up. - expect(result.backup_ref).toBeUndefined(); - expect(result.force_pushed_commit).toBeUndefined(); - expect( - git(`git --git-dir "${remoteDir}" for-each-ref refs/heads/agentv/backups`, rootDir), - ).toBe(''); - - const finalTip = git(`git --git-dir "${remoteDir}" rev-parse ${storageBranch}`, rootDir); - expect(() => - git( - `git --git-dir "${remoteDir}" merge-base --is-ancestor ${fixture.remoteAdvancedCommit} ${finalTip}`, - rootDir, - ), - ).not.toThrow(); - const remoteFiles = git( - `git --git-dir "${remoteDir}" ls-tree -r --name-only ${storageBranch}`, - rootDir, - ); - expect(remoteFiles).toContain(`runs/${fixture.localDestinationPath}/benchmark.json`); - expect(remoteFiles).toContain('runs/remote-only/2026-06-23T09-30-00-000Z/benchmark.json'); - }, 30000); + await expect( + directPushResultsWithDetails({ + config: { + repo_path: rootDir, + branch: DEFAULT_RESULTS_BRANCH, + sync: { + auto_push: true, + push_conflict_policy: 'backup_and_force_push', + }, + } as unknown as ResultsConfig, + sourceDir, + destinationPath: path.join('removed-policy', '2026-06-23T10-30-00-000Z'), + commitMessage: 'feat(results): removed policy', + }), + ).rejects.toThrow(/backup_and_force_push.*no longer supported/); + }); it('retries a benign push race without force', async () => { const { remoteDir, seedDir } = initializeRemoteRepo(rootDir); @@ -1804,6 +1780,7 @@ describe('results repo write path', () => { branch: storageBranch, upstream: `agentv-results/${storageBranch}`, }); + expect(status.auto_merged_remote).toBeUndefined(); expect(git(`git show ${storageBranch}:REMOTE_BRANCH.md`, cloneDir)).toBe( 'branch remote update', ); @@ -2011,6 +1988,7 @@ describe('results repo write path', () => { expect(status.sync_status).toBe('clean'); expect(status.blocked).toBe(false); expect(status.push_performed).toBe(true); + expect(status.auto_merged_remote).toBe(true); const finalTip = git(`git --git-dir "${remoteDir}" rev-parse main`, rootDir); const parents = git(`git --git-dir "${remoteDir}" rev-list --parents -n 1 main`, rootDir).split( diff --git a/packages/core/test/evaluation/validation/config-validator.test.ts b/packages/core/test/evaluation/validation/config-validator.test.ts index db907ff50..3a606fdb9 100644 --- a/packages/core/test/evaluation/validation/config-validator.test.ts +++ b/packages/core/test/evaluation/validation/config-validator.test.ts @@ -283,6 +283,33 @@ describe('validateConfigFile', () => { ); }); + it('reports backup_and_force_push as a removed push conflict policy', async () => { + const filePath = path.join(tempDir, 'removed-push-policy.yaml'); + await writeFile( + filePath, + `projects: + - id: demo + name: Demo + repo: + path: /tmp/demo + results: + repo: + path: . + sync: + push_conflict_policy: backup_and_force_push +`, + ); + + const result = await validateConfigFile(filePath, { scope: 'global' }); + + expect(result.valid).toBe(false); + const error = result.errors.find( + (entry) => entry.location === 'projects[0].results.sync.push_conflict_policy', + ); + expect(error?.message).toContain('uses removed value'); + expect(error?.message).toContain("set it to 'block'"); + }); + it.each([ { field: 'repository', diff --git a/packages/core/test/projects.test.ts b/packages/core/test/projects.test.ts index 91de59568..dc68ca175 100644 --- a/packages/core/test/projects.test.ts +++ b/packages/core/test/projects.test.ts @@ -161,7 +161,7 @@ describe('projects registry', () => { }); }); - it('round-trips project results config through YAML', () => { + it('drops removed project results push policy while preserving other sync config', () => { const registryPath = getProjectsRegistryPath(); mkdirSync(path.dirname(registryPath), { recursive: true }); writeFileSync( @@ -184,14 +184,21 @@ describe('projects registry', () => { 'utf-8', ); + const warn = spyOn(console, 'warn').mockImplementation(() => undefined); const registry = loadProjectRegistry(); - expect(registry.projects[0].results).toEqual({ - repoUrl: 'https://github.com/EntityProcess/results-project-runs.git', - branch: 'agentv-results', - path: '/srv/agentv/results/results-project', - sync: { autoPush: true, pushConflictPolicy: 'backup_and_force_push' }, - branchPrefix: 'eval-results', - }); + try { + expect(registry.projects[0].results).toEqual({ + repoUrl: 'https://github.com/EntityProcess/results-project-runs.git', + branch: 'agentv-results', + path: '/srv/agentv/results/results-project', + sync: { autoPush: true }, + branchPrefix: 'eval-results', + }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('backup_and_force_push')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('was ignored')); + } finally { + warn.mockRestore(); + } saveProjectRegistry(registry); const yamlOnDisk = readFileSync(registryPath, 'utf-8'); @@ -202,7 +209,7 @@ describe('projects registry', () => { expect(yamlOnDisk).toContain('branch: agentv-results'); expect(yamlOnDisk).toContain('path: /srv/agentv/results/results-project'); expect(yamlOnDisk).toContain('auto_push: true'); - expect(yamlOnDisk).toContain('push_conflict_policy: backup_and_force_push'); + expect(yamlOnDisk).not.toContain('push_conflict_policy:'); expect(yamlOnDisk).toContain('branch_prefix: eval-results'); expect(yamlOnDisk).not.toContain('repo_url:'); expect(yamlOnDisk).not.toContain('localPath:');