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
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ eval start
+---------------------------+
| 3. Repo materialization | For each workspace.repos entry:
| a. resolve acquisition | - registered project, configured mirror,
| b. git clone/fetch | AgentV cache, or remote fallback
| c. git checkout <ref> | - check out commit or HEAD
| b. materialize bytes | resolver source, AgentV cache, or remote fallback
| c. checkout if Git | - check out commit or HEAD for Git sources
+---------------------------+
|
v
Expand Down Expand Up @@ -120,8 +120,8 @@ For each materialized repo, AgentV resolves acquisition in this order:

| Order | Source | How it is used |
|-------|--------|----------------|
| 1 | Pattern resolver | The first non-`default` `repo_resolvers[]` entry whose `repos` pattern matches the repo URL or identity. If it returns `handled:false`, AgentV continues to the default resolver. |
| 2 | Default resolver | The resolver named `default`, if configured. It must not declare `repos`; it is the unconditional project default. If it returns `handled:false`, AgentV continues to the built-in git resolver. |
| 1 | Pattern resolver | The first non-`default` `repo_resolvers[]` entry whose `repos` pattern matches the repo URL or identity. If it returns `status: "skip"`, AgentV continues to the default resolver. |
| 2 | Default resolver | The resolver named `default`, if configured. It must not declare `repos`; it is the unconditional project default. If it returns `status: "skip"`, AgentV continues to the built-in git resolver. |
| 3 | Registered project | A project in `$AGENTV_HOME/projects.yaml` whose `origin` matches the repo identity. AgentV seeds its mirror cache from that local checkout, then clones the cache into the workspace and resets `origin` to the declared repo URL. |
| 4 | Configured mirror | A path listed under `git_cache.mirrors`. AgentV seeds its mirror cache from that checkout or bare mirror, then clones the cache into the workspace. |
| 5 | Mirror cache | An AgentV-owned bare cache under `$AGENTV_DATA_DIR/git-cache/<hash>`. Cache population is locked, cloned into a temporary path, verified, and atomically renamed before use. |
Expand All @@ -136,8 +136,7 @@ local checkout is moved, deleted, or garbage-collected.

Use `repo_resolvers` when repo bytes come from a project-specific source that
AgentV core should not understand, such as an internal snapshot bundle. Put that
logic in a resolver script and return a local git source for AgentV to clone and
check out normally:
logic in a resolver script and return a local acquisition path:

```yaml
# .agentv/config.yaml
Expand All @@ -163,18 +162,26 @@ resolver writes JSON on stdout:

```json
{
"handled": true,
"source": {
"type": "git",
"path": "/tmp/source.git",
"origin": "https://github.com/example/repo.git"
}
"status": "handled",
"path": "/tmp/source.git"
}
```

Only `source.type: "git"` is supported. Resolver scripts should prepare or
locate source directories independently from the final workspace; AgentV still
materializes the repo into every suite or attempt workspace it creates.
Return `{ "status": "skip" }` when the resolver does not own that repo.
`path` can point at a Git source or a plain directory snapshot. AgentV
classifies the path at runtime.

For Git sources, AgentV clones from `path`, applies `sparse` when present,
resolves `commit`, walks `ancestor`, and checks out the resulting commit.
Resolver stdout does not set or override `origin`; the resolver chooses
acquisition bytes only.

For plain directory snapshots, AgentV copies the directory contents into
`workspace.repos[].path` without requiring `.git`. Because the snapshot has no
Git history, `commit`, `ancestor`, and `sparse` are ignored for that repo.
Workspace resets restore the snapshot by copying the same resolver directory
source again. Targets and graders see a normal filesystem directory at the
configured workspace path.

### Configured mirrors

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,9 @@ new acquisition technology plugs in without touching it. `commit` is an immutabl
**before hooks** (ADR 0016 pt10).
2. **Acquisition = harness resolver in machine config (`$AGENTV_HOME/config.yaml`),
keyed on `repo`**, ordered backends: (1) local checkout auto-adopt via origin-match
→ `git clone --reference`; (2) bare mirror clone-cache (`--reference`, shared objects);
(3) snapshot artifact (WTG `download-release-deps` reframed); (4) remote clone;
→ mirror cache; (2) configured local mirror; (3) custom command resolver returning
a flat `{status,path}` acquisition source, including Git sources or static directory
snapshots; (4) AgentV mirror cache and remote clone;
(5) *future* Docker image (SWE-bench/margin/Inspect — same identity key, new backend;
adopt Inspect's `image`/`build`/`x-local` distinction + per-config init caching).
3. **`--reference` (mirror cache) is the workhorse**: shallow-speed WITH full history, so
Expand Down
85 changes: 70 additions & 15 deletions packages/core/src/evaluation/workspace/repo-manager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFile, spawn } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
import { cp, mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';

Expand Down Expand Up @@ -47,7 +47,8 @@ interface AcquisitionSource {
| 'registered-project'
| 'mirror-cache'
| 'remote'
| 'repo-resolver';
| 'repo-resolver-git'
| 'repo-resolver-directory';
readonly sourceUrl: string;
readonly originUrl: string;
}
Expand Down Expand Up @@ -133,6 +134,7 @@ export class RepoManager {
private readonly heartbeatMs: number;
private readonly timeoutMs: number;
private readonly projectConfigDir?: string;
private readonly materializedAcquisitions = new Map<string, AcquisitionSource>();

constructor(verbose = false, options: RepoManagerOptions = {}) {
this.verbose = verbose;
Expand Down Expand Up @@ -650,12 +652,8 @@ export class RepoManager {
workspacePath,
this.timeoutMs,
);
if (result.handled) {
return {
kind: 'repo-resolver',
sourceUrl: result.source.path,
originUrl: result.source.origin ?? originUrl,
};
if (result.status === 'handled') {
return this.repoResolverAcquisitionSource(result.path, originUrl);
}

if (selection.kind === 'pattern') {
Expand All @@ -667,19 +665,46 @@ export class RepoManager {
workspacePath,
this.timeoutMs,
);
if (defaultResult.handled) {
return {
kind: 'repo-resolver',
sourceUrl: defaultResult.source.path,
originUrl: defaultResult.source.origin ?? originUrl,
};
if (defaultResult.status === 'handled') {
return this.repoResolverAcquisitionSource(defaultResult.path, originUrl);
}
}
}

return undefined;
}

private async repoResolverAcquisitionSource(
sourcePath: string,
originUrl: string,
): Promise<AcquisitionSource> {
return {
kind: (await this.isGitCloneSource(sourcePath))
? 'repo-resolver-git'
: 'repo-resolver-directory',
sourceUrl: sourcePath,
originUrl,
};
}

private async isGitCloneSource(sourcePath: string): Promise<boolean> {
try {
await stat(sourcePath);
} catch {
return false;
}

try {
await this.runGit(['rev-parse', '--git-dir'], {
cwd: sourcePath,
timeout: 10_000,
});
return true;
} catch {
return false;
}
}

private assertNoUserOwnedAlternates(targetDir: string, acquisition: AcquisitionSource): void {
const alternatesPath = path.join(targetDir, '.git', 'objects', 'info', 'alternates');
if (!existsSync(alternatesPath)) return;
Expand Down Expand Up @@ -757,6 +782,7 @@ export class RepoManager {

const targetDir = path.join(workspacePath, repo.path);
const acquisition = await this.resolveAcquisition(repo, workspacePath);
this.materializedAcquisitions.set(targetDir, acquisition);
const startedAt = Date.now();

if (this.verbose) {
Expand All @@ -765,6 +791,16 @@ export class RepoManager {
);
}

if (acquisition.kind === 'repo-resolver-directory') {
await this.copyDirectorySnapshot(acquisition.sourceUrl, targetDir);
if (this.verbose) {
console.log(
`[repo] materialize done path=${repo.path} target=${targetDir} durationMs=${Date.now() - startedAt}`,
);
}
return;
}

// Plain local clones hardlink objects on the same filesystem and copy
// otherwise; unlike --reference, neither path leaves alternates behind.
const cloneArgs = ['clone', '--progress', '--no-checkout'];
Expand All @@ -776,7 +812,10 @@ export class RepoManager {
});
this.assertNoUserOwnedAlternates(targetDir, acquisition);

if (acquisition.sourceUrl !== acquisition.originUrl) {
if (
acquisition.kind !== 'repo-resolver-git' &&
acquisition.sourceUrl !== acquisition.originUrl
) {
assertSafeGitOperand(acquisition.originUrl, 'repo origin URL');
await this.runGit(['remote', 'set-url', 'origin', acquisition.originUrl], { cwd: targetDir });
}
Expand Down Expand Up @@ -829,9 +868,25 @@ export class RepoManager {
for (const repo of repos) {
if (!repo.path || !repo.repo) continue;
const targetDir = path.join(workspacePath, repo.path);
const acquisition = this.materializedAcquisitions.get(targetDir);
if (acquisition?.kind === 'repo-resolver-directory') {
await this.copyDirectorySnapshot(acquisition.sourceUrl, targetDir);
continue;
}
const resetSha = await this.resolveCheckoutCommit(repo, targetDir);
await this.runGit(['reset', '--hard', resetSha], { cwd: targetDir });
await this.runGit(['clean', cleanFlag], { cwd: targetDir });
}
}

private async copyDirectorySnapshot(sourceDir: string, targetDir: string): Promise<void> {
assertSafeGitOperand(sourceDir, 'repo resolver directory source');
const sourceStat = await stat(sourceDir);
if (!sourceStat.isDirectory()) {
throw new Error(`Repo resolver directory source is not a directory: ${sourceDir}`);
}
await rm(targetDir, { recursive: true, force: true });
await mkdir(path.dirname(targetDir), { recursive: true });
await cp(sourceDir, targetDir, { recursive: true, force: true });
}
}
49 changes: 15 additions & 34 deletions packages/core/src/evaluation/workspace/repo-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import path from 'node:path';
import micromatch from 'micromatch';

import { getAgentvDataDir } from '../../paths.js';
import type { JsonObject, JsonValue, RepoConfig } from '../types.js';
import type { JsonObject, RepoConfig } from '../types.js';
import { isJsonObject } from '../types.js';
import { getRepoCheckoutRef } from './repo-checkout.js';
import { normalizeRepoIdentity, resolveRepoCloneUrl } from './repo-identity.js';
Expand Down Expand Up @@ -34,19 +34,13 @@ export interface RepoResolverRequest {
readonly config: JsonObject;
}

export interface RepoResolverGitSource {
readonly type: 'git';
readonly path: string;
readonly origin?: string;
}

export interface RepoResolverHandledResult {
readonly handled: true;
readonly source: RepoResolverGitSource;
readonly status: 'handled';
readonly path: string;
}

export interface RepoResolverUnhandledResult {
readonly handled: false;
readonly status: 'skip';
}

export type RepoResolverResult = RepoResolverHandledResult | RepoResolverUnhandledResult;
Expand Down Expand Up @@ -308,35 +302,22 @@ function parseRepoResolverOutput(stdout: string, resolverName: string): RepoReso
throw new Error(`Repo resolver '${resolverName}' stdout must be a JSON object.`);
}

const output = parsed as Record<string, JsonValue>;
if (output.handled === false) {
return { handled: false };
const output = parsed as Record<string, unknown>;
if (output.status === 'skip') {
return { status: 'skip' };
}
if (output.handled !== true) {
throw new Error(`Repo resolver '${resolverName}' stdout must set handled to true or false.`);
if (output.status !== 'handled') {
throw new Error(
`Repo resolver '${resolverName}' stdout must set status to 'handled' or 'skip'.`,
);
}

if (!isJsonObject(output.source)) {
throw new Error(`Repo resolver '${resolverName}' handled the repo but did not return source.`);
}

const source = output.source as Record<string, JsonValue>;
if (source.type !== 'git') {
throw new Error(`Repo resolver '${resolverName}' returned unsupported source.type.`);
}
if (typeof source.path !== 'string' || source.path.trim().length === 0) {
throw new Error(`Repo resolver '${resolverName}' source.path must be a non-empty string.`);
}
if (source.origin !== undefined && typeof source.origin !== 'string') {
throw new Error(`Repo resolver '${resolverName}' source.origin must be a string when set.`);
if (typeof output.path !== 'string' || output.path.trim().length === 0) {
throw new Error(`Repo resolver '${resolverName}' path must be a non-empty string.`);
}

return {
handled: true,
source: {
type: 'git',
path: source.path,
...(typeof source.origin === 'string' && { origin: source.origin }),
},
status: 'handled',
path: output.path,
};
}
Loading
Loading