feat: implement Cloud Git Worktree Pool in agent-runner to isolate parallel sessions

This commit is contained in:
2026-06-02 14:37:51 -07:00
parent 4b70b6abe5
commit 1a138b6d90

View File

@@ -24,41 +24,100 @@ const PORT = process.env.PORT || 3333;
// Build ToolContext from environment variables // Build ToolContext from environment variables
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function ensureWorkspace(repo?: string): string { function ensureWorkspace(repo?: string, sessionId?: string): string {
const base = process.env.WORKSPACE_BASE || "/workspaces"; const base = process.env.WORKSPACE_BASE || "/workspaces";
if (!repo) { if (!repo) {
const dir = path.join(base, "default"); const dir = path.join(base, "default");
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
return dir; return dir;
} }
const dir = path.join(base, repo.replace("/", "_")); const mainRepoDir = path.join(base, repo.replace("/", "_"));
const gitea = { const gitea = {
apiUrl: process.env.GITEA_API_URL || "", apiUrl: process.env.GITEA_API_URL || "",
apiToken: process.env.GITEA_API_TOKEN || "", apiToken: process.env.GITEA_API_TOKEN || "",
username: process.env.GITEA_USERNAME || "", username: process.env.GITEA_USERNAME || "",
}; };
if (!fs.existsSync(path.join(dir, ".git"))) {
fs.mkdirSync(dir, { recursive: true }); // 1. Ensure main repo clone exists
if (!fs.existsSync(path.join(mainRepoDir, ".git"))) {
fs.mkdirSync(mainRepoDir, { recursive: true });
const authedUrl = `${gitea.apiUrl}/${repo}.git`.replace( const authedUrl = `${gitea.apiUrl}/${repo}.git`.replace(
"https://", "https://",
`https://${gitea.username}:${gitea.apiToken}@`, `https://${gitea.username}:${gitea.apiToken}@`,
); );
try { try {
execSync(`git clone "${authedUrl}" "${dir}"`, { stdio: "pipe" }); execSync(`git clone "${authedUrl}" "${mainRepoDir}"`, { stdio: "pipe" });
} catch { } catch {
// Repo may not exist yet — just init // Repo may not exist yet — just init
execSync(`git init`, { cwd: dir, stdio: "pipe" }); execSync(`git init`, { cwd: mainRepoDir, stdio: "pipe" });
execSync(`git remote add origin "${authedUrl}"`, { execSync(`git remote add origin "${authedUrl}"`, {
cwd: dir, cwd: mainRepoDir,
stdio: "pipe", stdio: "pipe",
}); });
} }
} }
return dir;
// 2. If no sessionId, fall back to main repo clone directly
if (!sessionId) {
return mainRepoDir;
} }
function buildContext(repo?: string): ToolContext { // 3. Isolated Worktree Directory per task session
const workspaceRoot = ensureWorkspace(repo); const taskWorktreePath = path.join(base, "tasks", sessionId);
fs.mkdirSync(path.join(base, "tasks"), { recursive: true });
// 4. Create isolated worktree if not yet active
if (!fs.existsSync(path.join(taskWorktreePath, ".git"))) {
// Clean up any stale directory from previous failed runs before adding worktree
if (fs.existsSync(taskWorktreePath)) {
try {
fs.rmSync(taskWorktreePath, { recursive: true, force: true });
} catch {}
}
try {
console.log(
`[worktree] Adding isolated git worktree for session ${sessionId} at ${taskWorktreePath}...`,
);
// Check if the branch task-sessionId already exists in the main repository
let branchExists = false;
try {
const branches = execSync(`git branch --list "task-${sessionId}"`, {
cwd: mainRepoDir,
}).toString();
branchExists = branches.trim().length > 0;
} catch {
branchExists = false;
}
if (branchExists) {
// Checkout the existing branch into the new worktree path
execSync(
`git worktree add -f "${taskWorktreePath}" "task-${sessionId}"`,
{ cwd: mainRepoDir, stdio: "pipe" },
);
} else {
// Create and checkout a new isolated branch
execSync(
`git worktree add -f -b "task-${sessionId}" "${taskWorktreePath}"`,
{ cwd: mainRepoDir, stdio: "pipe" },
);
}
} catch (e: any) {
console.error(
"[worktree] Failed to add git worktree, falling back to main clone:",
e.message || String(e),
);
return mainRepoDir;
}
}
return taskWorktreePath;
}
function buildContext(repo?: string, sessionId?: string): ToolContext {
const workspaceRoot = ensureWorkspace(repo, sessionId);
return { return {
workspaceRoot, workspaceRoot,
@@ -77,6 +136,39 @@ function buildContext(repo?: string): ToolContext {
}; };
} }
function cleanupWorkspace(repo: string, sessionId: string) {
const base = process.env.WORKSPACE_BASE || "/workspaces";
const mainRepoDir = path.join(base, repo.replace("/", "_"));
const taskWorktreePath = path.join(base, "tasks", sessionId);
if (fs.existsSync(taskWorktreePath)) {
try {
console.log(
`[worktree] Pruning and removing git worktree for session ${sessionId}...`,
);
// 1. Tell git to remove the worktree references
execSync(`git worktree remove --force "${taskWorktreePath}"`, {
cwd: mainRepoDir,
stdio: "pipe",
});
// 2. Delete the temporary branch from the main repository index
execSync(`git branch -D "task-${sessionId}"`, {
cwd: mainRepoDir,
stdio: "pipe",
});
// 3. Force clean directory
if (fs.existsSync(taskWorktreePath)) {
fs.rmSync(taskWorktreePath, { recursive: true, force: true });
}
} catch (e: any) {
console.warn(
`[worktree] Non-fatal cleanup error for session ${sessionId}:`,
e.message || String(e),
);
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Routes // Routes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -244,7 +336,7 @@ app.post("/agent/execute", async (req: Request, res: Response) => {
// Build workspace context — clone/update the Gitea repo if provided // Build workspace context — clone/update the Gitea repo if provided
let ctx: ReturnType<typeof buildContext>; let ctx: ReturnType<typeof buildContext>;
try { try {
ctx = buildContext(giteaRepo); ctx = buildContext(giteaRepo, sessionId);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
console.error("[agent/execute] buildContext failed:", msg); console.error("[agent/execute] buildContext failed:", msg);
@@ -332,6 +424,9 @@ app.post("/agent/execute", async (req: Request, res: Response) => {
}) })
.finally(() => { .finally(() => {
activeSessions.delete(sessionId); activeSessions.delete(sessionId);
if (giteaRepo && sessionId) {
cleanupWorkspace(giteaRepo, sessionId);
}
}); });
}); });