fix: run AgentOS workspaces on remote runner
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"@code/primitives": "workspace:*",
|
||||
"@flue/runtime": "latest",
|
||||
"@rivet-dev/agentos": "0.2.14",
|
||||
"@rivet-dev/agentos-core": "0.2.14",
|
||||
"convex": "catalog:",
|
||||
"hono": "catalog:",
|
||||
"rivetkit": "2.3.9",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Resolver } from "node:dns/promises";
|
||||
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { agentOS, setup } from "@rivet-dev/agentos";
|
||||
import { createHostDirBackend } from "@rivet-dev/agentos-core";
|
||||
import { createClient } from "@rivet-dev/agentos/client";
|
||||
import { Effect } from "effect";
|
||||
|
||||
@@ -17,6 +16,7 @@ import type {
|
||||
ExecutionEvent,
|
||||
WorkAttemptExecutionResult,
|
||||
} from "../../../primitives/src/execution-runtime";
|
||||
import { HostRepositoryWorkspace } from "./host-repository";
|
||||
|
||||
const codexConfig = makeCodexAgentOsConfig();
|
||||
const workspace = agentOS<undefined, { token: string }>({
|
||||
@@ -34,9 +34,6 @@ const workspace = agentOS<undefined, { token: string }>({
|
||||
|
||||
export const runtimeRegistry = setup({ use: { workspace } });
|
||||
|
||||
const shellQuote = (value: string): string =>
|
||||
`'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
|
||||
const event = (
|
||||
sequence: number,
|
||||
kind: ExecutionEvent["kind"],
|
||||
@@ -50,16 +47,6 @@ const event = (
|
||||
sequence,
|
||||
});
|
||||
|
||||
const requireSuccess = (
|
||||
result: { exitCode: number; stderr: string; stdout: string },
|
||||
operation: string
|
||||
) => {
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`${operation} failed: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
};
|
||||
|
||||
const executionError = (
|
||||
message: string,
|
||||
reason: WorkAttemptExecutionError["reason"],
|
||||
@@ -86,33 +73,6 @@ const classifyRuntimeFailure = (cause: unknown): WorkAttemptExecutionError => {
|
||||
return executionError(message, "HarnessFailed", false);
|
||||
};
|
||||
|
||||
const gitAuthEnv = (
|
||||
username: string,
|
||||
credential: string,
|
||||
repositoryUrl: string,
|
||||
repositoryAddress: string
|
||||
) => {
|
||||
const { hostname } = new URL(repositoryUrl);
|
||||
return {
|
||||
GIT_CONFIG_COUNT: "2",
|
||||
GIT_CONFIG_KEY_0: "http.extraHeader",
|
||||
GIT_CONFIG_KEY_1: "http.curloptResolve",
|
||||
GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${username}:${credential}`).toString("base64")}`,
|
||||
GIT_CONFIG_VALUE_1: `${hostname}:443:${repositoryAddress}`,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveRepositoryAddress = async (repositoryUrl: string) => {
|
||||
const { hostname } = new URL(repositoryUrl);
|
||||
const resolver = new Resolver();
|
||||
resolver.setServers(["1.1.1.1", "8.8.8.8"]);
|
||||
const [address] = await resolver.resolve4(hostname);
|
||||
if (!address) {
|
||||
throw new Error(`Repository host ${hostname} did not resolve`);
|
||||
}
|
||||
return address;
|
||||
};
|
||||
|
||||
export const executeAgentOsAttempt = async (
|
||||
rawInput: unknown
|
||||
): Promise<WorkAttemptExecutionResult> => {
|
||||
@@ -133,39 +93,26 @@ export const executeAgentOsAttempt = async (
|
||||
workspaceKey: input.workspaceKey,
|
||||
}),
|
||||
];
|
||||
const authEnv = gitAuthEnv(
|
||||
input.auth.username ??
|
||||
(input.auth.provider === "github" ? "x-access-token" : "git"),
|
||||
input.auth.credential,
|
||||
input.repositoryUrl,
|
||||
await resolveRepositoryAddress(input.repositoryUrl)
|
||||
const hostRepository = new HostRepositoryWorkspace();
|
||||
const prepared = await hostRepository.prepare(input);
|
||||
events.push(
|
||||
event(
|
||||
1,
|
||||
prepared.cloned ? "repository.cloning" : "runtime.preparing",
|
||||
prepared.cloned
|
||||
? "Project repository cloned on the execution host"
|
||||
: "Host repository checkout reused"
|
||||
)
|
||||
);
|
||||
|
||||
await vm.mkdir("/workspace", { recursive: true });
|
||||
if (!(await vm.exists("/workspace/repository/.git"))) {
|
||||
events.push(event(1, "repository.cloning", "Cloning project repository"));
|
||||
const clone = await vm.execArgv(
|
||||
"git",
|
||||
["clone", input.repositoryUrl, "/workspace/repository"],
|
||||
{
|
||||
cwd: "/workspace",
|
||||
env: authEnv,
|
||||
}
|
||||
);
|
||||
requireSuccess(clone, "Repository clone");
|
||||
}
|
||||
|
||||
const checkout = await vm.exec(
|
||||
`git fetch origin ${shellQuote(input.baseBranch)} && git checkout -B ${shellQuote(`zopu/${input.runId}`)} ${shellQuote(`origin/${input.baseBranch}`)}`,
|
||||
{ cwd: "/workspace/repository", env: authEnv }
|
||||
);
|
||||
requireSuccess(checkout, "Repository checkout");
|
||||
const baseRevision = requireSuccess(
|
||||
await vm.execArgv("git", ["rev-parse", "HEAD"], {
|
||||
cwd: "/workspace/repository",
|
||||
await vm.mountFs({
|
||||
path: "/workspace/repository",
|
||||
plugin: createHostDirBackend({
|
||||
hostPath: prepared.checkoutPath,
|
||||
readOnly: false,
|
||||
}),
|
||||
"Base revision lookup"
|
||||
);
|
||||
readOnly: false,
|
||||
});
|
||||
const { baseRevision } = prepared;
|
||||
events.push(
|
||||
event(2, "repository.ready", "Repository checkout is ready", {
|
||||
baseRevision,
|
||||
@@ -210,56 +157,12 @@ export const executeAgentOsAttempt = async (
|
||||
})
|
||||
);
|
||||
|
||||
const status = requireSuccess(
|
||||
await vm.execArgv("git", ["status", "--porcelain"], {
|
||||
cwd: "/workspace/repository",
|
||||
}),
|
||||
"Changed file lookup"
|
||||
);
|
||||
const diff = requireSuccess(
|
||||
await vm.execArgv("git", ["diff", "--binary", "HEAD"], {
|
||||
cwd: "/workspace/repository",
|
||||
}),
|
||||
"Diff collection"
|
||||
);
|
||||
const changedFiles = status
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => line.slice(3).trim());
|
||||
let candidateRevision = baseRevision;
|
||||
if (changedFiles.length > 0) {
|
||||
requireSuccess(
|
||||
await vm.execArgv("git", ["add", "-A"], {
|
||||
cwd: "/workspace/repository",
|
||||
}),
|
||||
"Candidate staging"
|
||||
);
|
||||
const tree = requireSuccess(
|
||||
await vm.execArgv("git", ["write-tree"], {
|
||||
cwd: "/workspace/repository",
|
||||
}),
|
||||
"Candidate tree creation"
|
||||
);
|
||||
candidateRevision = requireSuccess(
|
||||
await vm.exec(
|
||||
`printf %s ${shellQuote(`Zopu candidate for ${input.attemptId}`)} | git commit-tree ${shellQuote(tree)} -p ${shellQuote(baseRevision)}`,
|
||||
{
|
||||
cwd: "/workspace/repository",
|
||||
env: {
|
||||
GIT_AUTHOR_EMAIL: "agent@zopu.dev",
|
||||
GIT_AUTHOR_NAME: "Zopu Agent",
|
||||
GIT_COMMITTER_EMAIL: "agent@zopu.dev",
|
||||
GIT_COMMITTER_NAME: "Zopu Agent",
|
||||
},
|
||||
}
|
||||
),
|
||||
"Candidate revision creation"
|
||||
);
|
||||
requireSuccess(
|
||||
await vm.execArgv("git", ["reset"], { cwd: "/workspace/repository" }),
|
||||
"Candidate index reset"
|
||||
);
|
||||
}
|
||||
const collected = await HostRepositoryWorkspace.collect({
|
||||
attemptId: input.attemptId,
|
||||
baseRevision,
|
||||
checkoutPath: prepared.checkoutPath,
|
||||
});
|
||||
const { candidateRevision, changedFiles, diff } = collected;
|
||||
events.push(
|
||||
event(
|
||||
5,
|
||||
|
||||
232
packages/agents/src/runtime/host-repository.ts
Normal file
232
packages/agents/src/runtime/host-repository.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { RepositoryExecutionAuth } from "../../../primitives/src/execution-runtime";
|
||||
|
||||
interface PrepareRepositoryInput {
|
||||
auth: Pick<RepositoryExecutionAuth, "credential" | "provider" | "username">;
|
||||
baseBranch: string;
|
||||
repositoryUrl: string;
|
||||
runId: string;
|
||||
workspaceKey: string;
|
||||
}
|
||||
|
||||
interface PreparedRepository {
|
||||
baseRevision: string;
|
||||
checkoutPath: string;
|
||||
cloned: boolean;
|
||||
}
|
||||
|
||||
interface CollectRepositoryInput {
|
||||
attemptId: string;
|
||||
baseRevision: string;
|
||||
checkoutPath: string;
|
||||
}
|
||||
|
||||
interface CollectedRepository {
|
||||
candidateRevision: string;
|
||||
changedFiles: string[];
|
||||
diff: string;
|
||||
}
|
||||
|
||||
interface GitResult {
|
||||
exitCode: number;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
const requireSuccess = (result: GitResult, operation: string): string => {
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`${operation} failed: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
};
|
||||
|
||||
const authEnvironment = (
|
||||
auth: PrepareRepositoryInput["auth"]
|
||||
): Record<string, string> => ({
|
||||
GIT_CONFIG_COUNT: "1",
|
||||
GIT_CONFIG_KEY_0: "http.extraHeader",
|
||||
GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(
|
||||
`${auth.username ?? (auth.provider === "github" ? "x-access-token" : "git")}:${auth.credential}`
|
||||
).toString("base64")}`,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
});
|
||||
|
||||
const changedFilePath = (line: string): string => {
|
||||
const filePath = line.slice(3).trim();
|
||||
const renameSeparator = " -> ";
|
||||
const renameIndex = filePath.lastIndexOf(renameSeparator);
|
||||
return renameIndex === -1
|
||||
? filePath
|
||||
: filePath.slice(renameIndex + renameSeparator.length);
|
||||
};
|
||||
|
||||
const runGit = async (
|
||||
cwd: string,
|
||||
args: readonly string[],
|
||||
env: Record<string, string> = {}
|
||||
): Promise<GitResult> => {
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
(entry): entry is [string, string] => entry[1] !== undefined
|
||||
)
|
||||
);
|
||||
const child = Bun.spawn(["git", ...args], {
|
||||
cwd,
|
||||
env: { ...environment, ...env },
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
});
|
||||
const [exitCode, stderr, stdout] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stderr).text(),
|
||||
new Response(child.stdout).text(),
|
||||
]);
|
||||
return { exitCode, stderr, stdout };
|
||||
};
|
||||
|
||||
export class HostRepositoryWorkspace {
|
||||
readonly #root: string;
|
||||
|
||||
constructor(
|
||||
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces"
|
||||
) {
|
||||
this.#root = root;
|
||||
}
|
||||
|
||||
async prepare(input: PrepareRepositoryInput): Promise<PreparedRepository> {
|
||||
const checkoutPath = path.join(
|
||||
this.#root,
|
||||
createHash("sha256")
|
||||
.update(input.workspaceKey)
|
||||
.digest("hex")
|
||||
.slice(0, 32),
|
||||
"repository"
|
||||
);
|
||||
await mkdir(path.dirname(checkoutPath), { recursive: true });
|
||||
const cloned = !(await Bun.file(
|
||||
path.join(checkoutPath, ".git", "HEAD")
|
||||
).exists());
|
||||
const authEnv = authEnvironment(input.auth);
|
||||
|
||||
if (cloned) {
|
||||
requireSuccess(
|
||||
await runGit(
|
||||
this.#root,
|
||||
["clone", input.repositoryUrl, checkoutPath],
|
||||
authEnv
|
||||
),
|
||||
"Repository clone"
|
||||
);
|
||||
} else {
|
||||
requireSuccess(
|
||||
await runGit(checkoutPath, [
|
||||
"remote",
|
||||
"set-url",
|
||||
"origin",
|
||||
input.repositoryUrl,
|
||||
]),
|
||||
"Repository remote update"
|
||||
);
|
||||
}
|
||||
|
||||
requireSuccess(
|
||||
await runGit(
|
||||
checkoutPath,
|
||||
["fetch", "origin", input.baseBranch],
|
||||
authEnv
|
||||
),
|
||||
"Repository fetch"
|
||||
);
|
||||
requireSuccess(
|
||||
await runGit(checkoutPath, [
|
||||
"checkout",
|
||||
"-B",
|
||||
`zopu/${input.runId}`,
|
||||
`origin/${input.baseBranch}`,
|
||||
]),
|
||||
"Repository checkout"
|
||||
);
|
||||
requireSuccess(
|
||||
await runGit(checkoutPath, ["reset", "--hard", "HEAD"]),
|
||||
"Repository reset"
|
||||
);
|
||||
requireSuccess(
|
||||
await runGit(checkoutPath, ["clean", "-fdx"]),
|
||||
"Repository clean"
|
||||
);
|
||||
|
||||
return {
|
||||
baseRevision: requireSuccess(
|
||||
await runGit(checkoutPath, ["rev-parse", "HEAD"]),
|
||||
"Base revision lookup"
|
||||
),
|
||||
checkoutPath,
|
||||
cloned,
|
||||
};
|
||||
}
|
||||
|
||||
static async collect(
|
||||
input: CollectRepositoryInput
|
||||
): Promise<CollectedRepository> {
|
||||
const status = requireSuccess(
|
||||
await runGit(input.checkoutPath, ["status", "--porcelain"]),
|
||||
"Changed file lookup"
|
||||
);
|
||||
let diff = "";
|
||||
const changedFiles = status
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map(changedFilePath);
|
||||
let candidateRevision = input.baseRevision;
|
||||
|
||||
if (changedFiles.length > 0) {
|
||||
requireSuccess(
|
||||
await runGit(input.checkoutPath, ["add", "-A"]),
|
||||
"Candidate staging"
|
||||
);
|
||||
diff = requireSuccess(
|
||||
await runGit(input.checkoutPath, [
|
||||
"diff",
|
||||
"--binary",
|
||||
"--cached",
|
||||
"--no-ext-diff",
|
||||
input.baseRevision,
|
||||
]),
|
||||
"Diff collection"
|
||||
);
|
||||
const tree = requireSuccess(
|
||||
await runGit(input.checkoutPath, ["write-tree"]),
|
||||
"Candidate tree creation"
|
||||
);
|
||||
candidateRevision = requireSuccess(
|
||||
await runGit(
|
||||
input.checkoutPath,
|
||||
[
|
||||
"commit-tree",
|
||||
tree,
|
||||
"-p",
|
||||
input.baseRevision,
|
||||
"-m",
|
||||
`Zopu candidate for ${input.attemptId}`,
|
||||
],
|
||||
{
|
||||
GIT_AUTHOR_EMAIL: "agent@zopu.dev",
|
||||
GIT_AUTHOR_NAME: "Zopu Agent",
|
||||
GIT_COMMITTER_EMAIL: "agent@zopu.dev",
|
||||
GIT_COMMITTER_NAME: "Zopu Agent",
|
||||
}
|
||||
),
|
||||
"Candidate revision creation"
|
||||
);
|
||||
requireSuccess(
|
||||
await runGit(input.checkoutPath, ["reset"]),
|
||||
"Candidate index reset"
|
||||
);
|
||||
}
|
||||
|
||||
return { candidateRevision, changedFiles, diff };
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentos-software/codex": "0.2.7",
|
||||
"@agentos-software/codex": "0.0.0-agent-acp-codex-session-benchmark.fd745e7",
|
||||
"@agentos-software/git": "0.3.3",
|
||||
"@rivet-dev/agentos": "0.2.14",
|
||||
"effect": "catalog:"
|
||||
|
||||
@@ -84,7 +84,7 @@ export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Software bundle for a Codex-capable VM: the Codex harness plus git. */
|
||||
export const codexSoftware = [codex, getExecutableGitSoftware()] as const;
|
||||
export const codexSoftware = [...codex, getExecutableGitSoftware()] as const;
|
||||
|
||||
/** Model-provider env that Codex reads inside the VM. Pass this to the session's
|
||||
* `env` when opening a Codex session (`agent: "codex"`). */
|
||||
@@ -99,7 +99,9 @@ export const codexSessionEnv = (
|
||||
model: CodexModelEnv,
|
||||
extra: Readonly<Record<string, string>> = {}
|
||||
): Record<string, string> => ({
|
||||
CODEX_HOME: "/tmp/codex-home",
|
||||
CODEX_MODEL: model.model,
|
||||
HOME: "/tmp",
|
||||
OPENAI_API_KEY: model.apiKey,
|
||||
OPENAI_BASE_URL: model.baseUrl,
|
||||
...extra,
|
||||
|
||||
Reference in New Issue
Block a user