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
38 changes: 38 additions & 0 deletions apps/cli/src/commands/results/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type NormalizedResultsConfig,
type ResultsConfig,
type ResultsRepoStatus,
confirmResultsMergeAndPull,
directPushResultsWithDetails,
directorySizeBytes,
getProject,
Expand Down Expand Up @@ -384,6 +385,43 @@ export async function syncRemoteResults(
}
}

/**
* The Layer 2 "OK" action: the user has merged the pending temp branch into the
* target on GitHub. Pull the merged target into the local results checkout and
* resume normal sync. Mirrors {@link syncRemoteResults}'s config-load and
* error-wrap behavior.
*/
export async function confirmRemoteResultsMerge(
cwd: string,
projectId?: string,
): Promise<RemoteResultsStatus> {
const config = await loadNormalizedResultsConfig(cwd, projectId);
if (!config) {
return {
...(await getResultsRepoSyncStatus()),
run_count: 0,
};
}

try {
const status = await confirmResultsMergeAndPull(config);
invalidateGitRunsCache(config.path);
return {
...status,
run_count: await getRemoteRunCount(config, status),
};
} catch (error) {
const status = await getResultsRepoSyncStatus(config);
return {
...status,
run_count: await getRemoteRunCount(config, status),
last_error: getStatusMessage(error),
blocked: true,
block_reason: getStatusMessage(error),
};
}
}

function dedupeSyncedRunCopies(runs: SourcedResultFileMeta[]): SourcedResultFileMeta[] {
const byRunId = new Map<string, SourcedResultFileMeta>();

Expand Down
9 changes: 9 additions & 0 deletions apps/cli/src/commands/results/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
import {
type SourcedResultFileMeta,
clearRemoteRunTags,
confirmRemoteResultsMerge,
ensureRemoteRunAvailable,
findRunById,
getRemoteResultsStatus,
Expand Down Expand Up @@ -3002,6 +3003,9 @@ export function createApp(
app.post('/api/remote/sync', async (c) =>
c.json(await syncRemoteResults(searchDir, defaultCtx.projectId)),
);
app.post('/api/remote/sync/confirm-merge', async (c) =>
c.json(await confirmRemoteResultsMerge(searchDir, defaultCtx.projectId)),
);
app.get('/api/runs', (c) => handleRuns(c, defaultCtx));
app.post('/api/runs/combine', (c) => {
if (readOnly) {
Expand Down Expand Up @@ -3122,6 +3126,11 @@ export function createApp(
ctx.json(await syncRemoteResults(dataCtx.searchDir, dataCtx.projectId)),
),
);
app.post('/api/projects/:projectId/remote/sync/confirm-merge', (c) =>
withProject(c, async (ctx, dataCtx) =>
ctx.json(await confirmRemoteResultsMerge(dataCtx.searchDir, dataCtx.projectId)),
),
);
app.get('/api/projects/:projectId/runs', (c) => withProject(c, handleRuns));
app.post('/api/projects/:projectId/runs/combine', (c) => {
if (readOnly) {
Expand Down
111 changes: 111 additions & 0 deletions apps/cli/test/commands/results/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2452,6 +2452,117 @@ describe('serve app', () => {
}, 20000);
});

describe('POST /api/remote/sync/confirm-merge', () => {
it('resumes sync by pulling the target branch (unscoped)', async () => {
const { remoteDir, cloneDir, seedDir } = initializeRemoteRepo(tempDir);
const runId = writeRemoteRunArtifact(
seedDir,
'confirm-merge-unscoped',
'2026-03-26T14-00-00-000Z',
RESULT_A,
);

writeResultsConfig(tempDir, { remote: `file://${remoteDir}`, path: cloneDir });

const app = createApp([], tempDir, tempDir, undefined, { studioDir });
const res = await app.request('/api/remote/sync/confirm-merge', { method: 'POST' });

expect(res.status).toBe(200);
const data = (await res.json()) as {
sync_status: string;
blocked?: boolean;
pull_performed?: boolean;
run_count: number;
};
expect(data.sync_status).toBe('clean');
expect(data.blocked).toBe(false);
expect(data.run_count).toBe(1);
expect(
existsSync(
path.join(
cloneDir,
'runs',
'confirm-merge-unscoped',
'2026-03-26T14-00-00-000Z',
'index.jsonl',
),
),
).toBe(true);
expect(runId).toBe('confirm-merge-unscoped::2026-03-26T14-00-00-000Z');
}, 15000);

it('resumes sync by pulling the target branch (project-scoped)', async () => {
const previousHome = process.env.AGENTV_HOME;
const homeDir = path.join(tempDir, 'agentv-home-confirm-merge-scoped');
process.env.AGENTV_HOME = homeDir;

try {
const { remoteDir, cloneDir, seedDir } = initializeRemoteRepo(tempDir);
const projectDir = path.join(tempDir, 'source-project-confirm-merge');
mkdirSync(path.join(projectDir, '.agentv'), { recursive: true });
mkdirSync(homeDir, { recursive: true });
saveProjectRegistry({
projects: [
{
id: 'project-confirm-merge',
name: 'Project Confirm Merge',
path: projectDir,
results: {
repoUrl: `file://${remoteDir}`,
path: cloneDir,
sync: { autoPush: false },
},
addedAt: '2026-01-01T00:00:00.000Z',
lastOpenedAt: '2026-01-01T00:00:00.000Z',
},
],
});
const runId = writeRemoteRunArtifact(
seedDir,
'project-confirm-merge',
'2026-03-26T15-00-00-000Z',
RESULT_A,
);

const app = createApp([], tempDir, tempDir, undefined, { studioDir });
const res = await app.request(
'/api/projects/project-confirm-merge/remote/sync/confirm-merge',
{ method: 'POST' },
);

expect(res.status).toBe(200);
const data = (await res.json()) as {
sync_status: string;
blocked?: boolean;
pull_performed?: boolean;
run_count: number;
};
expect(data).toMatchObject({
sync_status: 'clean',
blocked: false,
run_count: 1,
});
expect(
existsSync(
path.join(
cloneDir,
'runs',
'project-confirm-merge',
runId.replace('project-confirm-merge::', ''),
'index.jsonl',
),
),
).toBe(true);
} finally {
if (previousHome === undefined) {
process.env.AGENTV_HOME = undefined;
} else {
process.env.AGENTV_HOME = previousHome;
}
}
}, 15000);
});

// ── GET /api/runs/:filename ─────────────────────────────────────────

describe('GET /api/runs/:filename', () => {
Expand Down
15 changes: 10 additions & 5 deletions docs/plans/2026-06-24-001-feat-conflict-free-results-sync-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,13 @@ already manages a dedicated checkout / storage-branch worktree, so this is a one
When Layer 1 returns `needs_human_merge`:

1. **Push to a new timestamped temp branch**, never canonical:
`agentv/results/v1/sync-<utc_ts>-<rand6>` (create-only push; `<rand6>` avoids
same-second collisions between concurrent writers).
`agentv/results-sync/<utc_ts>-<branch_slug>-<rand6>` (create-only push;
`<branch_slug>` is the slugified target branch and `<rand6>` avoids same-second
collisions between concurrent writers). The name is deliberately **flat** under
a dedicated `agentv/results-sync/` namespace: a nested
`agentv/results/v1/sync-...` ref would D/F-conflict with the canonical
`agentv/results/v1` branch (git cannot store one ref as both a file and a
directory).
2. **Surface a link** in the Dashboard:
- A **compare/PR URL**. With a GitHub remote and `gh`, build
`https://github.com/<owner>/<repo>/compare/<target>...<temp_branch>?expand=1`
Expand Down Expand Up @@ -183,7 +188,7 @@ push.

#### Concurrency

Each writer uses a unique `sync-<ts>-<rand6>` branch, so temp pushes never collide,
Each writer uses a unique `agentv/results-sync/<ts>-<branch_slug>-<rand6>` branch, so temp pushes never collide,
and the runs they carry live in disjoint `runs/<exp>/<ts>/` dirs. The target branch
absorbs N temp PRs through N normal merges. The only true contention is the mutable
overlay, which Layer 1's JSON-union driver already handles for add/remove; a genuine
Expand Down Expand Up @@ -284,7 +289,7 @@ conflicts itself, which keeps the core tiny.
### Phase 2 — Temp-branch + OK-to-resync

- Core helpers: `pushResultsSyncBranch()` (create-only push to
`sync-<ts>-<rand6>`) and `pullResultsTargetBranch()` (fetch + FF/merge target into
`agentv/results-sync/<ts>-<branch_slug>-<rand6>`) and `pullResultsTargetBranch()` (fetch + FF/merge target into
the local checkout, invoked on OK).
- API: extend `POST /api/remote/sync` to return a `pending_merge` block
(`temp_branch`, `compare_url`, `contributed_run_count`); add
Expand Down Expand Up @@ -316,7 +321,7 @@ conflicts itself, which keeps the core tiny.
- Automatic merge detection (tree-equality/ancestor/deletion watching) — replaced by
an explicit OK.
- **Temp-branch deletion/cleanup.** The user owns the merge on GitHub, so deleting
the merged `sync-<ts>-<rand6>` branch belongs to that same GitHub flow
the merged `agentv/results-sync/<ts>-<branch_slug>-<rand6>` branch belongs to that same GitHub flow
(auto-delete-on-merge, or the user's manual cleanup). AgentV does not delete temp
branches and does not track them for cleanup. AgentV only creates the temp branch
and reads the target on OK.
Expand Down
Loading
Loading