Files
PaperClipAI/server/src/__tests__/workspace-worktree-concurrency.test.ts
T
DottaandClaude Opus 5.5 d5cbe70636 fix(workspaces): create git worktrees one at a time per repository; retry another process's config lock (#20039)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Tasks in a project with git_worktree isolation each get their own
worktree, created with `git worktree add -b <branch> <path> <base>`
> - That command writes the repository's shared `.git/config` to record
the new branch's upstream
> - When two tasks start at once on one repository, both write
`.git/config`, and one fails with "could not lock config file"
> - The failed attempt already created its branch, so the retry attached
that branch without upstream tracking and without runtime ownership
> - This pull request serializes worktree creation per repository and
retries another process's brief config lock cleanly
> - The benefit is that simultaneous tasks on one repository start
without a failed attempt or a degraded branch

## Linked Issues or Issue Description

**What happened?**
In a TTFT measurement with a fresh clone of the paperclip repository as
the project workspace, two agents started tasks at the same time. The
Codex task's `git worktree add -b` failed twice ("error: could not lock
config file .git/config: File exists"). It then succeeded by attaching
the branch the failed attempt had created.

**Steps to reproduce**
1. Use a project whose primary workspace is a git clone, with
git_worktree isolation and a remote-tracking base ref.
2. Start several tasks in that project at the same moment.
3. Some worktree creations fail with "could not lock config file". The
task falls back to attaching the existing branch, without upstream or
branch ownership.

**Expected behavior**
Every simultaneous task gets a fresh worktree on its own new branch,
with upstream tracking, owned by the runtime.

**Root cause**
`git worktree add -b` writes `.git/config`, which git locks with
`config.lock`. Concurrent creates on one repository contend for that
lock. On a lock failure, git has already created the branch but no
worktree.

## What Changed

- `executeGitCommand` (in `workspace-runtime.ts`) runs every `git
worktree add` from `recordGitOperation`. The recorded and unrecorded
paths both use it.
- Creates in this process run one at a time per repository, keyed by the
realpath of `git rev-parse --git-common-dir`. Linked worktrees and the
primary checkout share one key.
- A create that still meets a config lock held by another process
retries after 100, 250, 500, 1000 and 2000 ms. Before each retry it
removes the branch that same `-b` command created. Git refuses an
existing branch with "already exists" before it touches config, so a
branch present after a config-lock failure is one this command made. The
retry then creates the branch again with its upstream.

## Verification

- `workspace-worktree-concurrency.test.ts`:
- Three rounds of ten simultaneous `realizeExecutionWorkspace` calls on
a fresh clone with base `origin/main`. Every workspace is `created:
true` and `branchCreatedByRuntime: true`, on its own branch. Without the
serialization it failed 3 of 3 runs.
- A create that meets `.git/config.lock`, held for 300 ms as another
process would, succeeds with the branch's upstream recorded.
- `workspace-runtime.test.ts`: 179 tests pass.

## Risks

- Worktree creation on one repository is now serial within a process.
Each create takes about 0.1–1 s, so ten simultaneous tasks add a few
seconds at most. Creates on different repositories still run in
parallel.
- The branch deletion runs only after a config-lock failure of a `-b`
create, when the branch is provably new.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file editing, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:44:27 -06:00

144 lines
7.7 KiB
TypeScript

import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { realizeExecutionWorkspace, worktreeCreateLimits } from "../services/workspace-runtime.js";
const execFileAsync = promisify(execFile);
const git = async (cwd: string, args: string[]) => (await execFileAsync("git", args, { cwd })).stdout.trim();
describe("concurrent git worktree creation", () => {
const roots: string[] = [];
afterEach(async () => {
for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true });
});
/** A clone of an origin repository: each new branch from origin/main records its upstream in .git/config. */
async function clonedRepository() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-race-"));
roots.push(root);
const origin = path.join(root, "origin");
await fs.mkdir(origin);
await git(origin, ["init", "--initial-branch", "main"]);
await git(origin, ["config", "user.email", "paperclip@example.com"]);
await git(origin, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(origin, "README.md"), "hello\n");
await git(origin, ["add", "README.md"]);
await git(origin, ["commit", "-m", "Initial commit"]);
await git(root, ["clone", "--quiet", origin, "repo"]);
return path.join(root, "repo");
}
it("creates every worktree when several tasks start on one repository at once", async () => {
// Several rounds of simultaneous creates make the shared .git/config race certain without serialization.
for (let round = 0; round < 3; round += 1) await createConcurrently(await clonedRepository(), round);
}, 120_000);
it("waits out another process's brief config lock instead of failing the create", async () => {
const repoRoot = await clonedRepository();
const lock = path.join(repoRoot, ".git", "config.lock");
await fs.writeFile(lock, "");
const release = setTimeout(() => { void fs.rm(lock, { force: true }); }, 300);
try {
const workspace = await realizeExecutionWorkspace({
base: { baseCwd: repoRoot, source: "project_primary", projectId: "project-1", workspaceId: "workspace-1",
repoUrl: null, repoRef: "origin/main" },
config: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}-{{slug}}" } },
issue: { id: "issue-lock", identifier: "PAP-900", title: "Locked config" },
agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1" },
});
expect(workspace).toMatchObject({ created: true, branchCreatedByRuntime: true });
expect(await git(repoRoot, ["config", "--get", `branch.${workspace.branchName}.remote`])).toBe("origin");
} finally {
clearTimeout(release);
}
}, 60_000);
const realize = (repoRoot: string, identifier: string) => realizeExecutionWorkspace({
base: { baseCwd: repoRoot, source: "project_primary", projectId: "project-1", workspaceId: "workspace-1",
repoUrl: null, repoRef: "origin/main" },
config: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}-{{slug}}" } },
issue: { id: `issue-${identifier}`, identifier, title: `Task ${identifier}` },
agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1" },
});
it("leaves no half-created branch when the lock outlasts every retry, so a later retry creates it cleanly", async () => {
const repoRoot = await clonedRepository();
const lock = path.join(repoRoot, ".git", "config.lock");
await fs.writeFile(lock, "");
await expect(realize(repoRoot, "PAP-910")).rejects.toThrow(/could not lock config file/);
await fs.rm(lock, { force: true });
const workspace = await realize(repoRoot, "PAP-910");
expect(workspace).toMatchObject({ created: true, branchCreatedByRuntime: true });
expect(await git(repoRoot, ["config", "--get", `branch.${workspace.branchName}.remote`])).toBe("origin");
}, 60_000);
it("stops a stuck create at its deadline so later creates on the repository proceed", async () => {
const repoRoot = await clonedRepository();
const hooks = path.join(path.dirname(repoRoot), "hooks");
await fs.mkdir(hooks);
await fs.writeFile(path.join(hooks, "post-checkout"), "#!/bin/sh\nsleep 30\n", { mode: 0o755 });
await git(repoRoot, ["config", "core.hooksPath", hooks]);
const original = worktreeCreateLimits.timeoutMs;
worktreeCreateLimits.timeoutMs = 1_000;
try {
const stuck = realize(repoRoot, "PAP-920");
const startedAt = Date.now();
await expect(stuck).rejects.toThrow(/timed out/);
await git(repoRoot, ["config", "--unset", "core.hooksPath"]);
const next = await realize(repoRoot, "PAP-921");
expect(next).toMatchObject({ created: true });
expect(Date.now() - startedAt).toBeLessThan(20_000);
} finally {
worktreeCreateLimits.timeoutMs = original;
}
}, 60_000);
it("stops a checkout helper that ignores SIGTERM before the next create runs", async () => {
const repoRoot = await clonedRepository();
const hooks = path.join(path.dirname(repoRoot), "hooks");
const pidFile = path.join(path.dirname(repoRoot), "hook.pid");
await fs.mkdir(hooks);
await fs.writeFile(path.join(hooks, "post-checkout"), `#!/bin/sh\ntrap '' TERM\necho $$ > '${pidFile}'\nsleep 30\n`, { mode: 0o755 });
await git(repoRoot, ["config", "core.hooksPath", hooks]);
const original = worktreeCreateLimits.timeoutMs;
worktreeCreateLimits.timeoutMs = 1_000;
try {
await expect(realize(repoRoot, "PAP-930")).rejects.toThrow(/timed out/);
const pid = Number((await fs.readFile(pidFile, "utf8")).trim());
// Gone or a zombie (exited, not yet reaped) both mean it no longer runs.
const running = async () => {
const state = await execFileAsync("ps", ["-o", "stat=", "-p", String(pid)]).then(result => result.stdout.trim(), () => "");
return state !== "" && !state.startsWith("Z");
};
const deadline = Date.now() + 5_000;
while (await running() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100));
expect(await running()).toBe(false);
} finally {
worktreeCreateLimits.timeoutMs = original;
}
}, 60_000);
async function createConcurrently(repoRoot: string, round: number) {
const tasks = Array.from({ length: 10 }, (_, index) => round * 10 + index + 1);
const workspaces = await Promise.all(tasks.map(index => realizeExecutionWorkspace({
base: { baseCwd: repoRoot, source: "project_primary", projectId: "project-1", workspaceId: "workspace-1",
repoUrl: null, repoRef: "origin/main" },
config: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}-{{slug}}" } },
issue: { id: `issue-${index}`, identifier: `PAP-${index}`, title: `Concurrent task ${index}` },
agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1" },
})));
for (const [position, workspace] of workspaces.entries()) {
// Each task got a fresh branch it owns, not a fallback attach after a failed create.
expect(workspace).toMatchObject({ created: true, branchCreatedByRuntime: true });
expect(await git(workspace.cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).toBe(workspace.branchName);
expect(workspace.branchName).toContain(`PAP-${tasks[position]}`);
// The degraded state this guards against: a branch without its upstream.
expect(await git(repoRoot, ["config", "--get", `branch.${workspace.branchName}.remote`])).toBe("origin");
}
expect(new Set(workspaces.map(workspace => workspace.cwd)).size).toBe(tasks.length);
}
});