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
30 changes: 9 additions & 21 deletions src/runners/claude-code.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,15 +20,6 @@ export const claudeCodeRunner: Runner = {
async run(id: number, opts: RunnerOptions): Promise<AgentResult> {
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 = "";
Expand All @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 23 additions & 3 deletions src/utils/git.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -31,6 +31,13 @@ export async function createWorktree(id: number): Promise<string> {
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");
Expand All @@ -49,6 +56,9 @@ export async function createWorktree(id: number): Promise<string> {
export async function removeWorktree(worktreePath: string): Promise<void> {
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 {
Expand Down Expand Up @@ -77,9 +87,14 @@ export async function removeWorktree(worktreePath: string): Promise<void> {
export async function getDiff(worktreePath: string): Promise<string> {
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(() => {});
Expand All @@ -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"], {
Expand Down
Loading