Run fixed Zopu worktrees with Pi
This commit is contained in:
@@ -14,7 +14,6 @@
|
||||
"run:work-planner": "bun --env-file=../../.env flue run work-planner"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentos-software/codex-cli": "0.3.4",
|
||||
"@agentos-software/git": "0.3.3",
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
|
||||
@@ -5,8 +5,9 @@ import { createClient } from "@rivet-dev/agentos/client";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import {
|
||||
codexSessionEnv,
|
||||
makeCodexAgentOsConfig,
|
||||
makePiAgentOsConfig,
|
||||
makePiHomeFiles,
|
||||
piSessionEnv,
|
||||
} from "../../../primitives/src/agent-os";
|
||||
import {
|
||||
decodeWorkAttemptExecutionInput,
|
||||
@@ -18,7 +19,7 @@ import type {
|
||||
} from "../../../primitives/src/execution-runtime";
|
||||
import { HostRepositoryWorkspace } from "./host-repository";
|
||||
|
||||
const codexConfig = makeCodexAgentOsConfig();
|
||||
const piConfig = makePiAgentOsConfig();
|
||||
const workspace = agentOS<undefined, { token: string }>({
|
||||
onBeforeConnect: (_context, params) => {
|
||||
if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
|
||||
@@ -28,8 +29,8 @@ const workspace = agentOS<undefined, { token: string }>({
|
||||
options: {
|
||||
actionTimeout: 10 * 60 * 1000,
|
||||
},
|
||||
permissions: codexConfig.permissions,
|
||||
software: codexConfig.software,
|
||||
permissions: piConfig.permissions,
|
||||
software: piConfig.software,
|
||||
});
|
||||
|
||||
export const runtimeRegistry = setup({ use: { workspace } });
|
||||
@@ -94,24 +95,61 @@ export const executeAgentOsAttempt = async (
|
||||
}),
|
||||
];
|
||||
const hostRepository = new HostRepositoryWorkspace();
|
||||
const prepared = await hostRepository.prepare(input);
|
||||
const prepared = await hostRepository.prepare({
|
||||
attemptId: input.attemptId,
|
||||
piHomeFiles: makePiHomeFiles({
|
||||
api: env.AGENT_MODEL_API,
|
||||
apiKeyEnvironmentVariable: "AGENT_MODEL_API_KEY",
|
||||
baseUrl: env.AGENT_MODEL_BASE_URL,
|
||||
contextWindow: env.AGENT_MODEL_CONTEXT_WINDOW,
|
||||
maxTokens: env.AGENT_MODEL_MAX_TOKENS,
|
||||
model: env.AGENT_MODEL_NAME,
|
||||
provider: env.AGENT_MODEL_PROVIDER,
|
||||
}),
|
||||
});
|
||||
events.push(
|
||||
event(
|
||||
1,
|
||||
prepared.cloned ? "repository.cloning" : "runtime.preparing",
|
||||
prepared.cloned
|
||||
? "Project repository cloned on the execution host"
|
||||
: "Host repository checkout reused"
|
||||
"runtime.preparing",
|
||||
prepared.created
|
||||
? "Isolated Zopu worktree created on the execution host"
|
||||
: "Isolated Zopu worktree recreated"
|
||||
)
|
||||
);
|
||||
await vm.mountFs({
|
||||
path: "/workspace/repository",
|
||||
plugin: createHostDirBackend({
|
||||
hostPath: prepared.checkoutPath,
|
||||
await Promise.all([
|
||||
vm.mountFs({
|
||||
path: "/workspace/repository",
|
||||
plugin: createHostDirBackend({
|
||||
hostPath: prepared.checkoutPath,
|
||||
readOnly: false,
|
||||
}),
|
||||
readOnly: false,
|
||||
}),
|
||||
readOnly: false,
|
||||
});
|
||||
vm.mountFs({
|
||||
path: `${prepared.sourceRepositoryPath}/.git`,
|
||||
plugin: createHostDirBackend({
|
||||
hostPath: `${prepared.sourceRepositoryPath}/.git`,
|
||||
readOnly: false,
|
||||
}),
|
||||
readOnly: false,
|
||||
}),
|
||||
vm.mountFs({
|
||||
path: "/home/zopu",
|
||||
plugin: createHostDirBackend({
|
||||
hostPath: prepared.piHomePath,
|
||||
readOnly: false,
|
||||
}),
|
||||
readOnly: false,
|
||||
}),
|
||||
vm.mountFs({
|
||||
path: "/opt/zopu-tools",
|
||||
plugin: createHostDirBackend({
|
||||
hostPath: prepared.toolsPath,
|
||||
readOnly: true,
|
||||
}),
|
||||
readOnly: true,
|
||||
}),
|
||||
]);
|
||||
const { baseRevision } = prepared;
|
||||
events.push(
|
||||
event(2, "repository.ready", "Repository checkout is ready", {
|
||||
@@ -119,22 +157,19 @@ export const executeAgentOsAttempt = async (
|
||||
})
|
||||
);
|
||||
|
||||
const sessionId = `codex-${input.attemptId}`;
|
||||
const sessionId = `pi-${input.attemptId}`;
|
||||
await vm.openSession({
|
||||
additionalDirectories: [`${prepared.sourceRepositoryPath}/.git`],
|
||||
additionalInstructions:
|
||||
"Work only inside /workspace/repository. Do not reveal credentials. Make the requested change and run focused verification. Do not push or open a pull request.",
|
||||
agent: "codex",
|
||||
"Work only inside /workspace/repository. This is an isolated worktree of the Zopu product repository. Read AGENTS.md and the relevant product specifications before changing code. Never reveal credentials, modify the read-only base checkout, push, or open a pull request. Implement the requested change and run focused verification.",
|
||||
agent: "pi",
|
||||
cwd: "/workspace/repository",
|
||||
env: codexSessionEnv({
|
||||
apiKey: env.AGENT_MODEL_API_KEY,
|
||||
baseUrl: env.AGENT_MODEL_BASE_URL,
|
||||
model: env.AGENT_MODEL_NAME,
|
||||
}),
|
||||
env: piSessionEnv(env.AGENT_MODEL_API_KEY),
|
||||
permissionPolicy: "allow_all",
|
||||
sessionId,
|
||||
});
|
||||
events.push(
|
||||
event(3, "harness.started", "Codex implementation session started")
|
||||
event(3, "harness.started", "Pi implementation session started")
|
||||
);
|
||||
const promptResult = await vm.prompt({
|
||||
content: [{ text: input.prompt, type: "text" }],
|
||||
@@ -145,14 +180,14 @@ export const executeAgentOsAttempt = async (
|
||||
const reason =
|
||||
promptResult.stopReason === "cancelled" ? "Cancelled" : "HarnessFailed";
|
||||
throw executionError(
|
||||
`Codex stopped with ${promptResult.stopReason}`,
|
||||
`Pi stopped with ${promptResult.stopReason}`,
|
||||
reason,
|
||||
promptResult.stopReason === "max_tokens" ||
|
||||
promptResult.stopReason === "max_turn_requests"
|
||||
);
|
||||
}
|
||||
events.push(
|
||||
event(4, "harness.progress", "Codex implementation turn completed", {
|
||||
event(4, "harness.progress", "Pi implementation turn completed", {
|
||||
stopReason: promptResult.stopReason,
|
||||
})
|
||||
);
|
||||
@@ -184,8 +219,8 @@ export const executeAgentOsAttempt = async (
|
||||
events,
|
||||
summary:
|
||||
changedFiles.length > 0
|
||||
? `Codex changed ${changedFiles.length} file(s)`
|
||||
: "Codex completed without repository changes",
|
||||
? `Pi changed ${changedFiles.length} file(s)`
|
||||
: "Pi completed without repository changes",
|
||||
};
|
||||
} catch (error) {
|
||||
throw classifyRuntimeFailure(error);
|
||||
@@ -205,5 +240,5 @@ export const cancelAgentOsAttempt = async (
|
||||
.getOrCreate([workspaceKey], {
|
||||
params: { token: env.RIVET_WORKSPACE_TOKEN },
|
||||
})
|
||||
.cancelPrompt({ sessionId: `codex-${attemptId}` });
|
||||
.cancelPrompt({ sessionId: `pi-${attemptId}` });
|
||||
};
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { once } from "node:events";
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
copyFile,
|
||||
mkdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { RepositoryExecutionAuth } from "../../../primitives/src/execution-runtime";
|
||||
import type { PiHomeFiles } from "../../../primitives/src/agent-os";
|
||||
|
||||
interface PrepareRepositoryInput {
|
||||
auth: Pick<RepositoryExecutionAuth, "credential" | "provider" | "username">;
|
||||
baseBranch: string;
|
||||
repositoryUrl: string;
|
||||
runId: string;
|
||||
workspaceKey: string;
|
||||
attemptId: string;
|
||||
piHomeFiles: PiHomeFiles;
|
||||
}
|
||||
|
||||
interface PreparedRepository {
|
||||
baseRevision: string;
|
||||
checkoutPath: string;
|
||||
cloned: boolean;
|
||||
created: boolean;
|
||||
piHomePath: string;
|
||||
sourceRepositoryPath: string;
|
||||
toolsPath: string;
|
||||
}
|
||||
|
||||
interface CollectRepositoryInput {
|
||||
@@ -30,30 +39,19 @@ interface CollectedRepository {
|
||||
diff: string;
|
||||
}
|
||||
|
||||
interface GitResult {
|
||||
interface ProcessResult {
|
||||
exitCode: number;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
const requireSuccess = (result: GitResult, operation: string): string => {
|
||||
const requireSuccess = (result: ProcessResult, 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 = " -> ";
|
||||
@@ -63,99 +61,138 @@ const changedFilePath = (line: string): string => {
|
||||
: filePath.slice(renameIndex + renameSeparator.length);
|
||||
};
|
||||
|
||||
const runGit = async (
|
||||
cwd: string,
|
||||
const runProcess = async (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
cwd: string,
|
||||
env: Record<string, string> = {}
|
||||
): Promise<GitResult> => {
|
||||
): Promise<ProcessResult> => {
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
(entry): entry is [string, string] => entry[1] !== undefined
|
||||
)
|
||||
);
|
||||
const child = Bun.spawn(["git", ...args], {
|
||||
const child = spawn(command, 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 };
|
||||
const stderr: Uint8Array[] = [];
|
||||
const stdout: Uint8Array[] = [];
|
||||
child.stderr.on("data", (chunk: Uint8Array) => stderr.push(chunk));
|
||||
child.stdout.on("data", (chunk: Uint8Array) => stdout.push(chunk));
|
||||
const [exitCode] = await once(child, "close");
|
||||
return {
|
||||
exitCode: typeof exitCode === "number" ? exitCode : 1,
|
||||
stderr: Buffer.concat(stderr).toString(),
|
||||
stdout: Buffer.concat(stdout).toString(),
|
||||
};
|
||||
};
|
||||
|
||||
const runGit = (cwd: string, args: readonly string[]) =>
|
||||
runProcess("git", args, cwd);
|
||||
|
||||
const pathExists = async (target: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export class HostRepositoryWorkspace {
|
||||
readonly #root: string;
|
||||
readonly #sourceRepositoryPath: string;
|
||||
readonly #installDependencies: boolean;
|
||||
|
||||
constructor(
|
||||
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces"
|
||||
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces",
|
||||
sourceRepositoryPath = process.env.ZOPU_SOURCE_REPOSITORY ??
|
||||
"/opt/zopu-source",
|
||||
installDependencies = true
|
||||
) {
|
||||
this.#root = root;
|
||||
this.#sourceRepositoryPath = sourceRepositoryPath;
|
||||
this.#installDependencies = installDependencies;
|
||||
}
|
||||
|
||||
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"
|
||||
if (!(await pathExists(path.join(this.#sourceRepositoryPath, ".git")))) {
|
||||
throw new Error(
|
||||
`Fixed Zopu source repository is unavailable at ${this.#sourceRepositoryPath}`
|
||||
);
|
||||
}
|
||||
|
||||
const identity = createHash("sha256")
|
||||
.update(input.attemptId)
|
||||
.digest("hex")
|
||||
.slice(0, 24);
|
||||
const workspacePath = path.join(this.#root, identity);
|
||||
const checkoutPath = path.join(workspacePath, "repository");
|
||||
const toolsPath = path.join(workspacePath, "tools");
|
||||
const piHomePath = path.join(workspacePath, "home");
|
||||
const branch = `zopu/attempt-${identity}`;
|
||||
const created = !(await pathExists(path.join(checkoutPath, ".git")));
|
||||
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
if (!created) {
|
||||
requireSuccess(
|
||||
await runGit(this.#sourceRepositoryPath, [
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
checkoutPath,
|
||||
]),
|
||||
"Existing worktree removal"
|
||||
);
|
||||
}
|
||||
await rm(checkoutPath, { force: true, recursive: true });
|
||||
requireSuccess(
|
||||
await runGit(
|
||||
checkoutPath,
|
||||
["fetch", "origin", input.baseBranch],
|
||||
authEnv
|
||||
),
|
||||
"Repository fetch"
|
||||
);
|
||||
requireSuccess(
|
||||
await runGit(checkoutPath, [
|
||||
"checkout",
|
||||
await runGit(this.#sourceRepositoryPath, [
|
||||
"worktree",
|
||||
"add",
|
||||
"-B",
|
||||
`zopu/${input.runId}`,
|
||||
`origin/${input.baseBranch}`,
|
||||
branch,
|
||||
checkoutPath,
|
||||
"HEAD",
|
||||
]),
|
||||
"Repository checkout"
|
||||
"Zopu worktree creation"
|
||||
);
|
||||
requireSuccess(
|
||||
await runGit(checkoutPath, ["reset", "--hard", "HEAD"]),
|
||||
"Repository reset"
|
||||
|
||||
const sourceEnvPath = path.join(this.#sourceRepositoryPath, ".env");
|
||||
if (await pathExists(sourceEnvPath)) {
|
||||
await copyFile(sourceEnvPath, path.join(checkoutPath, ".env"));
|
||||
}
|
||||
if (this.#installDependencies) {
|
||||
requireSuccess(
|
||||
await runProcess(
|
||||
"bun",
|
||||
["install", "--frozen-lockfile"],
|
||||
checkoutPath,
|
||||
{ CI: "1" }
|
||||
),
|
||||
"Workspace dependency installation"
|
||||
);
|
||||
}
|
||||
|
||||
const toolsBinPath = path.join(toolsPath, "bin");
|
||||
await mkdir(toolsBinPath, { recursive: true });
|
||||
const bunExecutable = execFileSync("which", ["bun"], {
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
const bunPath = path.join(toolsBinPath, "bun");
|
||||
await copyFile(bunExecutable, bunPath);
|
||||
await chmod(bunPath, 0o755);
|
||||
|
||||
const piAgentPath = path.join(piHomePath, ".pi", "agent");
|
||||
await mkdir(piAgentPath, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(piAgentPath, "models.json"),
|
||||
input.piHomeFiles.models
|
||||
);
|
||||
requireSuccess(
|
||||
await runGit(checkoutPath, ["clean", "-fdx"]),
|
||||
"Repository clean"
|
||||
await writeFile(
|
||||
path.join(piAgentPath, "settings.json"),
|
||||
input.piHomeFiles.settings
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -164,7 +201,10 @@ export class HostRepositoryWorkspace {
|
||||
"Base revision lookup"
|
||||
),
|
||||
checkoutPath,
|
||||
cloned,
|
||||
created,
|
||||
piHomePath,
|
||||
sourceRepositoryPath: this.#sourceRepositoryPath,
|
||||
toolsPath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,8 +242,8 @@ export class HostRepositoryWorkspace {
|
||||
"Candidate tree creation"
|
||||
);
|
||||
candidateRevision = requireSuccess(
|
||||
await runGit(
|
||||
input.checkoutPath,
|
||||
await runProcess(
|
||||
"git",
|
||||
[
|
||||
"commit-tree",
|
||||
tree,
|
||||
@@ -212,6 +252,7 @@ export class HostRepositoryWorkspace {
|
||||
"-m",
|
||||
`Zopu candidate for ${input.attemptId}`,
|
||||
],
|
||||
input.checkoutPath,
|
||||
{
|
||||
GIT_AUTHOR_EMAIL: "agent@zopu.dev",
|
||||
GIT_AUTHOR_NAME: "Zopu Agent",
|
||||
|
||||
Reference in New Issue
Block a user