Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
This happened when one agent thread created another thread through the T3 MCP tool t3_thread_launch, not through the app's New Thread flow.
-
In an agent thread, ask the agent to start a new thread in a new worktree. The agent calls
t3_thread_launchwithworkspaceStrategy: { type: "worktree", baseRef: "main", branch: "fix/7-x" }, picking the branch name itself. -
T3 Code derives the worktree folder from that name by turning
/into-:<worktrees>/<repo>/fix-7-x. If a worktree for branchfix-7-xalready uses that folder, the launch can't succeed.What happened in my case: the agent's first launch with
fix/7-xfailed for a different reason (an oldfixbranch blockedfix/…, see [Bug]: Worktree launch with an explicit branch name fails opaquely when a branch already owns its prefix (e.g. werkstatt vs werkstatt/baseline) #29297). The agent then relaunched the same task asfix-7-x, which worked. Later I pressed Retry on the first thread, and it hit the folder the relaunch now owned. -
The tool call returns success, because provisioning runs in the background. In the new thread,
git branchcreatesfix/7-x, thengit worktree addfails withpath_already_exists. -
Press Retry on the new thread. It fails with
branch_already_exists, becausefix/7-xfrom step 3 is still there. Every later Retry fails the same way.
Any git worktree add failure after the branch step leaves the branch behind the same way. The MCP route with an agent-chosen branch is just the easiest way to hit it, since app-created threads get random t3/<id> names that don't collide.
Expected behavior
- The agent that called
t3_thread_launchfinds out the launch failed, and why, in the tool result, so it can fix the problem itself (for example by picking another branch name). - A failed launch leaves the repo as it found it, so Retry, or a relaunch, can succeed once the cause is fixed.
Actual behavior
GitVcsDriverCore.createWorktree creates the branch first (git branch --no-track -- <new> <start>), then runs git worktree add -- <path> <new>. When worktree add fails, nothing deletes the branch it just created. Every retry then fails at the branch step:
Workspace preparation failed during provision worktree: Git command failed in GitVcsDriver.createWorktree.createBranch (<repo>): git worktree add failed (branch_already_exists)
The thread can't be recovered from the UI. In one repo I ended up with three leftover branches in one afternoon (no commits, no worktree), each blocking retry of its thread until deleted by hand.
Server trace for one attempt (times UTC):
15:25:47 GitVcsDriver.createWorktree.createBranch Success <- branch fix/7-company-name-fallback created
15:25:47 GitManager.createWorktree Failure git worktree add failed (path_already_exists)
15:26:28 createWorktree (Retry) Failure ...createBranch: git worktree add failed (branch_already_exists)
Impact
Major degradation or frequent failure
Version or commit
Desktop 0.1.3 (Beta). Code path unchanged on main at ac8dce0.
Environment
macOS 15.7, git 2.50.1. Threads launched by an agent through the T3 MCP with explicit branch names.
Logs or stack traces
See above. Related, but not this bug:
- fix: prevent Antigravity session resume wedge and use atomic git worktree add -b #28354, item 2, describes the same orphan branch but bundles it with unrelated Antigravity fixes.
- fix(server): worktree launches refuse branch names an existing branch blocks #29328 (fixes [Bug]: Worktree launch with an explicit branch name fails opaquely when a branch already owns its prefix (e.g. werkstatt vs werkstatt/baseline) #29297) rejects branch names that clash with an existing branch before launch.
- fix(server): launching a worktree thread on a taken branch name no longer fails #29228 renames a taken branch name to the next free one.
- fix(server): new worktrees no longer fail when their generated folder already exists #28944 picks a free folder when the generated one is taken. It removes the folder on failure, but not the branch.
None of these remove the branch when worktree add fails, so any other checkout failure still blocks Retry.
Workaround
git branch -D <leftover-branch> in the project repo, then Retry. If the folder clash remains, archive the thread instead.
Possible fix
The underlying problem is that t3_thread_launch reports success before the worktree exists. Provisioning runs in the background, so a failure only shows up on the new thread. The agent that made the call never hears about it, and the user is left with a broken thread to clean up by hand.
My proposed shape for a fix:
- Check before creating anything. Before the tool accepts a launch, check that the requested branch can be created: it isn't already checked out somewhere, it doesn't clash with an existing name (
fixvsfix/…, which fix(server): worktree launches refuse branch names an existing branch blocks #29328 covers), and its derived folder is free (or a free one gets picked, as in fix(server): new worktrees no longer fail when their generated folder already exists #28944). If a check fails, return a tool error that names the problem, and create no thread. - Return only once the worktree exists. Have the tool call wait until the branch and worktree are created, or until provisioning fails, instead of returning as soon as the launch is queued. The calling agent then gets "created" or "failed: " in the same tool result, and can recover on its own. A checkout can take minutes in a large repo, and the worktree add timeout is deliberately off, so this may need an upper bound on the wait. If the bound is hit, the result could say "still preparing" and point the agent to the thread.
- Clean up after a failed launch. If provisioning fails, remove what the launch created: the branch (the diff below), the claimed folder, and the empty thread itself. If the thread should stay so its error is visible, the calling agent can already archive it with
t3_thread_organize, as long as step 2 tells it which thread failed.
Step 3's branch cleanup is useful on its own, because it also fixes Retry for launches started from the app. I have it working with a test against a real repo: the test fails on main (expected 'fix/retry' to equal '') and passes with the change. When git worktree add fails, the change deletes the branch createWorktree just created. It moves the two read-only lookups ahead of branch creation, so the checkout is the only step that can fail while the new branch exists. A branch that already existed is never touched, because createBranchAt fails before the cleanup is set up. I can open it as a PR if wanted. Steps 1 and 2 change the tool's behavior, so I'd want a maintainer's view on them first.
Diff for the branch cleanup (apps/server/src/vcs/GitVcsDriverCore.ts)
diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts
index a084664bc4..b6d3a07190 100644
--- a/apps/server/src/vcs/GitVcsDriverCore.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.ts
@@ -4151,14 +4151,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
}
worktreePath = path.join(parentDir, repoName, directoryName);
}
- if (input.newRefName) {
- // Kept apart from the checkout so the shared-config lock only covers the tracking write.
- yield* createBranchAt(
- "GitVcsDriver.createWorktree.createBranch",
- { cwd: input.cwd, branch: input.newRefName, startPoint: input.refName },
- "git worktree add failed",
- );
- }
const args = ["worktree", "add", "--", worktreePath, input.newRefName ?? input.refName];
const progress = options?.progress;
const onCheckoutProgress = progress?.onCheckoutProgress;
@@ -4174,6 +4166,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
{ concurrency: "unbounded" },
);
const checkoutWorkers = configuredCheckoutWorkers ?? "0";
+ if (input.newRefName) {
+ // Kept apart from the checkout so the shared-config lock only covers the tracking write.
+ yield* createBranchAt(
+ "GitVcsDriver.createWorktree.createBranch",
+ { cwd: input.cwd, branch: input.newRefName, startPoint: input.refName },
+ "git worktree add failed",
+ );
+ }
+ const newRefName = input.newRefName;
+ // A branch left behind by a failed checkout makes every retry fail with branch_already_exists.
+ const deleteCreatedBranch = newRefName
+ ? executeGit(
+ "GitVcsDriver.createWorktree.deleteCreatedBranch",
+ input.cwd,
+ ["branch", "-D", "--", newRefName],
+ { timeoutMs: 10_000, writesSharedConfig: true },
+ ).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("left a branch behind after a failed worktree checkout", {
+ branch: newRefName,
+ cause,
+ }),
+ ),
+ )
+ : Effect.void;
yield* executeGitWithStableDiagnostics(
"GitVcsDriver.createWorktree",
input.cwd,
@@ -4197,7 +4214,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
}
: {}),
},
- );
+ ).pipe(Effect.onError(() => deleteCreatedBranch));
if (progress?.onWorktreeClaimed) {
yield* progress.onWorktreeClaimed(worktreePath);
@@ -4282,7 +4299,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
);
}
- const newRefName = input.newRefName;
if (newRefName && base) {
yield* Effect.gen(function* () {
yield* writeConfigValue({Test, beside the other real-repo createWorktree cases in GitVcsDriverCore.test.ts:
it.effect("removes the branch it created when the checkout fails, so a retry works", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
yield* initRepoWithCommit(cwd);
const path = yield* Path.Path;
const driver = yield* GitVcsDriver.GitVcsDriver;
const takenPath = yield* makeTmpDir("git-worktrees-");
yield* writeTextFile(takenPath, "occupied.txt", "not a worktree\n");
const error = yield* driver
.createWorktree({ cwd, path: takenPath, refName: "HEAD", newRefName: "fix/retry" })
.pipe(Effect.flip);
assert.equal(error.operation, "GitVcsDriver.createWorktree");
assert.equal(yield* git(cwd, ["branch", "--list", "fix/retry"]), "");
const retried = yield* driver.createWorktree({
cwd,
path: path.join(yield* makeTmpDir("git-worktrees-"), "retry"),
refName: "HEAD",
newRefName: "fix/retry",
});
assert.equal(retried.worktree.refName, "fix/retry");
}),
);Not checked: a cancelled checkout. If an interrupted worktree add leaves a partial worktree registered, git branch -D refuses to delete the checked-out branch, so it would stay and only a warning is logged.
Investigated and drafted with Claude Opus 5.5 in Claude Code (via T3 Code).
Activity
juliusmarminge commentedon Nov 12, 2026
This is the same problem as #28373, so I'm closing it in favour of that one. If it is different, say how and it will be reopened.