diff --git a/src/runners/claude-code.ts b/src/runners/claude-code.ts index 75fc715..0ebff72 100644 --- a/src/runners/claude-code.ts +++ b/src/runners/claude-code.ts @@ -1,6 +1,4 @@ import { spawn } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { AgentResult } from "../types.js"; import { getDiff, getDiffStats } from "../utils/git.js"; import type { Runner, RunnerOptions } from "./base.js"; @@ -22,15 +20,6 @@ export const claudeCodeRunner: Runner = { async run(id: number, opts: RunnerOptions): Promise { const start = Date.now(); - // Backup the .git pointer file — agents sometimes delete it during long runs - const gitFilePath = join(opts.worktreePath, ".git"); - let gitFileBackup: string | null = null; - try { - gitFileBackup = await readFile(gitFilePath, "utf-8"); - } catch { - // Not a worktree or .git is a directory — skip backup - } - return new Promise((resolve) => { let output = ""; let error = ""; @@ -57,7 +46,15 @@ export const claudeCodeRunner: Runner = { const child = spawn("claude", args, { cwd: opts.worktreePath, stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, + env: { + ...process.env, + // Disable git auto-gc to prevent worktree pruning during parallel agent runs. + // When gc --auto triggers, it calls "git worktree prune" which can delete + // metadata for other concurrent agents' worktrees. + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "gc.auto", + GIT_CONFIG_VALUE_0: "0", + }, }); child.stdout.on("data", (data: Buffer) => { @@ -97,15 +94,6 @@ export const claudeCodeRunner: Runner = { if (settled) return; settled = true; - // Restore .git file if the agent deleted it during execution - if (gitFileBackup) { - try { - await readFile(gitFilePath, "utf-8"); - } catch { - await writeFile(gitFilePath, gitFileBackup).catch(() => {}); - } - } - const duration = Date.now() - start; const diff = await getDiff(opts.worktreePath); const stats = await getDiffStats(opts.worktreePath); diff --git a/src/utils/git.ts b/src/utils/git.ts index cb44c4a..9001d5a 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { access, mkdtemp, rm } from "node:fs/promises"; +import { access, mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -31,6 +31,13 @@ export async function createWorktree(id: number): Promise { cwd: repoRoot, }); + // Lock the worktree to prevent git gc --auto from pruning it while agents run. + // Without this, concurrent agents' git commits can trigger gc which prunes + // other worktrees' metadata from .git/worktrees/. + await exec("git", ["worktree", "lock", "--reason", "thinktank agent in use", dir], { + cwd: repoRoot, + }); + // Symlink node_modules from the main repo so tests and tools work in worktrees. // Git worktrees don't include gitignored directories like node_modules. const mainNodeModules = join(repoRoot, "node_modules"); @@ -49,6 +56,9 @@ export async function createWorktree(id: number): Promise { export async function removeWorktree(worktreePath: string): Promise { const repoRoot = await getMainRepoRoot(); + // Unlock the worktree before removal (it was locked during creation) + await exec("git", ["worktree", "unlock", worktreePath], { cwd: repoRoot }).catch(() => {}); + // Remove node_modules symlink/junction BEFORE removing worktree. // On Windows, rm -rf follows junctions and deletes the target. try { @@ -77,9 +87,14 @@ export async function removeWorktree(worktreePath: string): Promise { export async function getDiff(worktreePath: string): Promise { const absPath = resolve(worktreePath); try { - // Verify worktree is still a git repo before running git commands + // Verify worktree .git file AND its metadata directory still exist. + // git gc --auto can prune .git/worktrees/NAME/ even if the .git pointer file remains. await access(join(absPath, ".git")); - await exec("git", ["rev-parse", "--git-dir"], { cwd: absPath }); + const gitContent = await readFile(join(absPath, ".git"), "utf-8"); + const gitdirMatch = gitContent.match(/gitdir:\s*(.+)/); + if (gitdirMatch?.[1]) { + await access(gitdirMatch[1].trim()); + } await exec("git", ["add", "-A"], { cwd: absPath }); await exec("git", ["reset", "HEAD", "--", "node_modules"], { cwd: absPath }).catch(() => {}); @@ -99,6 +114,11 @@ export async function getDiffStats( const absPath = resolve(worktreePath); try { await access(join(absPath, ".git")); + const gitContent = await readFile(join(absPath, ".git"), "utf-8"); + const gitdirMatch = gitContent.match(/gitdir:\s*(.+)/); + if (gitdirMatch?.[1]) { + await access(gitdirMatch[1].trim()); + } await exec("git", ["add", "-A"], { cwd: absPath }); await exec("git", ["reset", "HEAD", "--", "node_modules"], { cwd: absPath }).catch(() => {}); const { stdout } = await exec("git", ["diff", "--cached", "--stat", "HEAD"], {