fix(server): t3_thread_launch refuses an explicit branch that already exists#31279
Problem
t3_thread_launch (and t3 thread start --worktree <base> --branch <name>) accepted a new worktree whose explicit branch already existed, returned a threadId and runId, and the thread then failed in the background with git worktree add failed (branch_already_exists). The caller could not correct the name in the same turn, and a failed thread was left behind. Reproduced on main 935818e422 with an isolated server for a branch checked out in another worktree (feature/taken), an idle branch (feature/idle), and the project's own checkout (main).
Change
ThreadLaunchService.checkWorktreeBase, the checkt3_thread_launchruns before it accepts a launch, now refuses an explicit worktree branch that is already in the project's local branch list (listLocalBranchNames, exact name match) with a newThreadLaunchBranchExistsErrornaming the branch. This includes explicitt3/…/t3code/…names; generated names (nobranchpassed) are not checked. As before, a failure to read the project or git lets the launch through.- The MCP handler picks its hint by error: the branch case reads
Branch "<name>" already exists in project "<title>". No thread was created. Pass a branch name that does not exist yet, or omit branch to get a generated name.Base-ref refusals keep their wording. GitVcsDriverCore.createWorktreereports its branch-creation step asgit branch failedinstead ofgit worktree add failed, for launches that do not go through the pre-check.
Scope and approval
Fixes pingdotgg/t3code#31263
Verification
Focused tests (vp test run on the three suites: 294 passed; vp run check:changed passes):
ThreadLaunchService.test.ts›refuses a new worktree whose explicit branch already exists: real temp repo; refusesfeature/idle,feature/taken(in another worktree),main, an existingt3code/<uuid>, and a taken branch with a:/textbase; acceptsfeature/freshandfeature/tagged(only a tag namedrefs/heads/feature/taggedexists). Fails on main: the existing branches are accepted.handlers.test.ts: the branch refusal carries the new hint, base-ref refusals keep theirs, and no launch is started.GitVcsDriverCore.test.ts: two cases pinned the branch step's oldgit worktree add failedtext and now expectgit branch failed.
Runtime evidence (server-only fix, no rendered surface, so no captures). Same driver on each side, each against a fresh database created by that side's own dev server, synthetic repo only:
--branch | Before (main 935818e422) | After (358c74a7ec) |
|---|---|---|
feature/taken (checked out in another worktree) | exit 0, thread created, then fails git worktree add failed (branch_already_exists) | exit 1, Branch "feature/taken" already exists…, no thread |
feature/idle | exit 0, thread created, then the same failure | exit 1, no thread |
main (project checkout) | exit 0, thread created, then the same failure | exit 1, no thread |
feature/fresh (control) | completed | completed |
Before transcript (main 935818e422)
| 1 | # head 935818e422 |
| 2 | Added project 780ed0c1-b9ae-455c-a5e4-d2f4997f592a (repo) at repo. |
| 3 | ### attempt branch=feature/taken |
| 4 | {"threadId":"mcp:0aca4327-fe91-4752-9052-7f38758d3170","projectId":"780ed0c1-b9ae-455c-a5e4-d2f4997f592a","runId":"run:thread:mcp%3A0aca4327-fe91-4752-9052-7f38758d3170:ordinal:1"} |
| 5 | exit=0 |
| 6 | ### attempt branch=feature/idle |
| 7 | {"threadId":"mcp:ddf1cc09-9767-45bb-8b4f-52dec3a61ab8","projectId":"780ed0c1-b9ae-455c-a5e4-d2f4997f592a","runId":"run:thread:mcp%3Addf1cc09-9767-45bb-8b4f-52dec3a61ab8:ordinal:1"} |
| 8 | exit=0 |
| 9 | ### attempt branch=main |
| 10 | {"threadId":"mcp:bc47fed1-2685-4e6d-9056-84bc3a0680bb","projectId":"780ed0c1-b9ae-455c-a5e4-d2f4997f592a","runId":"run:thread:mcp%3Abc47fed1-2685-4e6d-9056-84bc3a0680bb:ordinal:1"} |
| 11 | exit=0 |
| 12 | ### attempt branch=feature/fresh |
| 13 | {"threadId":"mcp:8d81be93-b1ef-435a-861d-e1ed5a3b56df","projectId":"780ed0c1-b9ae-455c-a5e4-d2f4997f592a","runId":"run:thread:mcp%3A8d81be93-b1ef-435a-861d-e1ed5a3b56df:ordinal:1"} |
| 14 | exit=0 |
| 15 | ### threads |
| 16 | mcp:8d81be93-b1ef-435a-861d-e1ed5a3b56df completed |
| 17 | mcp:bc47fed1-2685-4e6d-9056-84bc3a0680bb failed |
| 18 | mcp:ddf1cc09-9767-45bb-8b4f-52dec3a61ab8 failed |
| 19 | mcp:0aca4327-fe91-4752-9052-7f38758d3170 failed |
| 20 | ### git worktree list |
| 21 | repo 9f861e9 [main] |
| 22 | <t3-home>/worktrees/repo/feature-fresh 9f861e9 [feature/fresh] |
| 23 | wt 9f861e9 [feature/taken] |
| 24 | + feature/fresh |
| 25 | feature/idle |
| 26 | + feature/taken |
| 27 | * main |
| 28 | ### recorded thread failures |
| 29 | mcp:0aca4327-fe91-4752-9052-7f38758d3170|Workspace preparation failed during provision worktree: Git command failed in GitVcsDriver.createWorktree.createBranch (repo): git worktree add failed (branch_already_exists) |
| 30 | mcp:ddf1cc09-9767-45bb-8b4f-52dec3a61ab8|Workspace preparation failed during provision worktree: Git command failed in GitVcsDriver.createWorktree.createBranch (repo): git worktree add failed (branch_already_exists) |
| 31 | mcp:bc47fed1-2685-4e6d-9056-84bc3a0680bb|Workspace preparation failed during provision worktree: Git command failed in GitVcsDriver.createWorktree.createBranch (repo): git worktree add failed (branch_already_exists) |
After transcript (358c74a7ec)
| 1 | # head 358c74a7ec |
| 2 | Added project 28906850-595e-47d9-a41b-91b48c554eaf (repo) at repo. |
| 3 | ### attempt branch=feature/taken |
| 4 | Branch "feature/taken" already exists in project "repo". No thread was created. Pass a branch name that does not exist yet, or omit branch to get a generated name. |
| 5 | exit=1 |
| 6 | ### attempt branch=feature/idle |
| 7 | Branch "feature/idle" already exists in project "repo". No thread was created. Pass a branch name that does not exist yet, or omit branch to get a generated name. |
| 8 | exit=1 |
| 9 | ### attempt branch=main |
| 10 | Branch "main" already exists in project "repo". No thread was created. Pass a branch name that does not exist yet, or omit branch to get a generated name. |
| 11 | exit=1 |
| 12 | ### attempt branch=feature/fresh |
| 13 | {"threadId":"mcp:fa628281-8d3c-438a-92db-c4d7886d07f4","projectId":"28906850-595e-47d9-a41b-91b48c554eaf","runId":"run:thread:mcp%3Afa628281-8d3c-438a-92db-c4d7886d07f4:ordinal:1"} |
| 14 | exit=0 |
| 15 | ### threads |
| 16 | mcp:fa628281-8d3c-438a-92db-c4d7886d07f4 completed |
| 17 | ### git worktree list |
| 18 | repo f7e5271 [main] |
| 19 | <t3-home>/worktrees/repo/feature-fresh f7e5271 [feature/fresh] |
| 20 | wt f7e5271 [feature/taken] |
| 21 | + feature/fresh |
| 22 | feature/idle |
| 23 | + feature/taken |
| 24 | * main |
| 25 | ### recorded thread failures |
Driver (run with PROVIDER and MODEL set, against a dev server started with T3CODE_DEV_SEED=0)
| 1 | #!/usr/bin/env bash |
| 2 | # Driver for pingdotgg/t3code#31263. Synthetic repo only. |
| 3 | # Usage: repro-31263.sh <checkout> <t3-home-of-running-dev-server> <scratch-dir> |
| 4 | # Start the server first: T3CODE_DEV_SEED=0 vp run dev:server --home-dir <t3-home> |
| 5 | set -u |
| 6 | CHECKOUT=$1; HOME_DIR=$2; R=$3 |
| 7 | T3="node $CHECKOUT/apps/server/src/bin.ts" |
| 8 | mkdir -p "$R" && cd "$R" && git init -q -b main repo && cd repo |
| 9 | git -c user.email=a@b -c user.name=t commit -q --allow-empty -m init |
| 10 | git worktree add -q -b feature/taken ../wt # taken: checked out in another worktree |
| 11 | git branch feature/idle # idle: exists, not checked out |
| 12 | echo "# head $(git -C "$CHECKOUT" rev-parse --short HEAD)" |
| 13 | $T3 project add . --base-dir "$HOME_DIR" |
| 14 | for br in feature/taken feature/idle main feature/fresh; do |
| 15 | echo "### attempt branch=$br" |
| 16 | $T3 thread start --base-dir "$HOME_DIR" --json --worktree main --branch "$br" \ |
| 17 | --provider "${PROVIDER:?set PROVIDER}" --model "${MODEL:?set MODEL}" . "reply ok" |
| 18 | echo "exit=$?" |
| 19 | done |
| 20 | sleep 8 |
| 21 | echo "### threads" |
| 22 | $T3 thread list --base-dir "$HOME_DIR" --json | python3 -c 'import json,sys; [print(t["threadId"], t["status"]) for t in json.load(sys.stdin)]' |
| 23 | echo "### git worktree list"; git worktree list; git branch |
| 24 | echo "### recorded thread failures" |
| 25 | sqlite3 -readonly "$HOME_DIR/userdata/statev2.sqlite" \ |
| 26 | "select json_extract(payload_json,'\$.threadId'), json_extract(payload_json,'\$.failure.message') from orchestration_v2_projection_turn_items where json_extract(payload_json,'\$.type')='error'" |
Not checked: macOS case-insensitive branch names (e.g. Feature/Idle when feature/idle exists) still pass the exact-name check and fail in provisioning, as on main.
AI-assisted; verified with the tests and tools listed above.
🤖 Generated with Claude Code
Checks
Added 5 commits
- Merge main into fix/issue-31263358c74a
- fix(server): refuse an existing explicit branch even when it looks temporary86ebcd6
- fix(server): t3_thread_launch refuses an explicit branch that already exists92e15ef
- fix(server): match an existing launch branch by exact namea744fc3
- test(server): reproduce worktree launches onto an existing branch (#31263)fd9a40e
Approvability
Verdict: Approved at 9d2c911
Macroscope's review found this PR approvable — New MCP worktree launches reject explicitly requested branch names that already exist before creating a thread, with actionable guidance. The check only reads local branches; generated-name behavior and accepted-launch replays remain unchanged.
You can add or adjust custom eligibility rules. Learn more.
Thread transfer impact
✅ Thread transfer remains within every enforced ceiling.
| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |
|---|---|---|---|---|---|---|
| Codex | Total thread wire | 5.0 KiB | 5.0 KiB | 0 B (0.0%) | 6.8 KiB | ✅ |
| Codex | Thread snapshot wire | 3.8 KiB | 3.8 KiB | 0 B (0.0%) | 4.9 KiB | ✅ |
| Codex | Live turn WebSocket wire | 1.2 KiB | 1.2 KiB | 0 B (0.0%) | 2.0 KiB | ✅ |
| Codex | Live turn WebSocket decoded | 21.5 KiB | 21.5 KiB | 0 B (0.0%) | 24.4 KiB | ✅ |
| Codex | Live turn messages | 1 | 1 | 0 (0.0%) | 8 | ✅ |
| Claude | Total thread wire | 5.0 KiB | 5.0 KiB | 0 B (0.0%) | 6.8 KiB | ✅ |
| Claude | Thread snapshot wire | 3.8 KiB | 3.8 KiB | 0 B (0.0%) | 4.9 KiB | ✅ |
| Claude | Live turn WebSocket wire | 1.2 KiB | 1.2 KiB | 0 B (0.0%) | 2.0 KiB | ✅ |
| Claude | Live turn WebSocket decoded | 21.9 KiB | 21.9 KiB | 0 B (0.0%) | 24.4 KiB | ✅ |
| Claude | Live turn messages | 1 | 1 | 0 (0.0%) | 8 | ✅ |
Baseline: 1a2a4a5 · PR result: 9d2c911 · Source CI: success
Scenario and decoded snapshot size
10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
- Codex decoded thread snapshot: 108.5 KiB
- Claude decoded thread snapshot: 108.8 KiB
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.
No actionable comments were generated in the recent review.
Walkthrough
-
Worktree launch validation now rejects requested branch names that already exist, including when the base ref uses a search prefix. Tool responses distinguish branch conflicts from invalid base refs. Branch-creation failures now report
git branch failed.Changes: Worktree Launch Validation
Layer / File(s) Summary Pre-launch branch and base-ref checks
apps/server/src/orchestration-v2/ThreadLaunchService.ts,apps/server/src/orchestration-v2/ThreadLaunchService.test.tsThe launch service checks requested branch names against local branches. It checks base refs when applicable. Tests cover existing branches, fresh names, tags, and search-style base refs. Caller-facing launch errors
apps/server/src/mcp/toolkits/project/handlers.ts,apps/server/src/mcp/toolkits/project/handlers.test.tsThe launch tool returns separate guidance for existing branches and invalid base refs. Tests verify both refusals and that neither creates a thread. Branch-creation failure diagnostics
apps/server/src/vcs/GitVcsDriverCore.ts,apps/server/src/vcs/GitVcsDriverCore.test.tsBranch-creation failures report git branch failed. Tests update the expected detail for bare or unborn repositories and a missing ref.Estimated code review effort: 3 (Moderate) | ~20 minutes
Change: Bug fix · Severity of issue fixed: Medium
Possibly related issues
- Issue 29297 — The change rejects exact existing branch names, but does not address Git ref-namespace conflicts.
- Issue 29515 — The change rejects an existing explicit branch before launch, but does not remove branches left by failed checkouts.
- Issue 26577 — The change rejects an existing local branch before launch, but does not address the reported delay generally.
Possibly related PRs
- pingdotgg/t3code#29328 — Both PRs add pre-launch rejection for conflicting explicit worktree branch names. This PR checks exact-name matches;
#29328checks ref-namespace prefix conflicts.
Suggested reviewers:
tris203
Merge Risk: External image Minimal · up to 9d2c9
-
Ready to merge after normal checks. No change-introduced issue requiring a fix was established.
Pre-merge checks: Passed 4
-
Check name Status Explanation Title check Passed The title clearly describes the main change: rejecting an explicit branch that already exists during worktree launch. Description check Passed The description covers the required Problem, Change, Scope and approval, and Verification sections. It explains the issue and fix, links the issue, reports focused tests and runtime results, and notes… Linked Issues check Passed Open issue #31263requires an explicit existing branch to be rejected before launch, with the branch named in the error and no failed thread created. The reviewed implementation checks explicit branch…Out of Scope Changes check Passed The whole-PR changes support issue #31263. The current regression test narrows its assertions but retains coverage for the requested branch conflict behavior. The handler message and `git branch faile…Full details: Description check
-
Explanation
The description covers the required Problem, Change, Scope and approval, and Verification sections. It explains the issue and fix, links the issue, reports focused tests and runtime results, and notes an untested case.
Full details: Linked Issues check
-
Explanation
Open issue
#31263requires an explicit existing branch to be rejected before launch, with the branch named in the error and no failed thread created. The reviewed implementation checks explicit branch names against local branches and returnsThreadLaunchBranchExistsError; the handler provides branch-specific guidance. The current regression test still covers existing idle and checked-out branches, a generated-name branch, a fresh branch, and a tag-only name. The incremental test edits do not change the branch-check implementation. The case-insensitive limitation does not contradict the issue's exact-name requirement.
Full details: Out of Scope Changes check
-
Explanation
The whole-PR changes support issue
#31263. The current regression test narrows its assertions but retains coverage for the requested branch conflict behavior. The handler message andgit branch failederror detail address the issue's incorrect or incomplete error reporting. No unrelated whole-PR change is established by the available summary and focused diff.
-
Finishing Touches
-
Generate docstrings
-
- Commit to this branch
- Create a new PR
Generate unit tests (beta)
-
- Commit to this branch
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
External image Recent review info
-
External image Run configuration
-
- Source mode: Auto
- Repository: pingdotgg/t3code
- Branch: fix/issue-31263
- File: .coderabbit.config.ts
- Commit: 9d2c911
- Layers: Repository TS config
- Review profile: CHILL
- Plan: Team
- Run ID:
42d6336a-0f2f-4881-8f5f-76f5c3f71cd3
External image Commits
-
Reviewing files that changed from the base of the PR and between 358c74a7ecd7c428b15c3604a7ed07e0a3fffd95 and 9d2c911b27d85bba9d5002e17deead519837d517.
External image Files selected for processing: 4
-
apps/server/src/mcp/toolkits/project/handlers.tsapps/server/src/orchestration-v2/ThreadLaunchService.test.tsapps/server/src/vcs/GitVcsDriverCore.test.tsapps/server/src/vcs/GitVcsDriverCore.ts
External image Files skipped from review as they are similar to previous changes: 1
-
- apps/server/src/vcs/GitVcsDriverCore.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.
External image Recent review info
-
External image Run configuration
-
- Source mode: Auto
- Repository: pingdotgg/t3code
- Branch: fix/issue-31263
- File: .coderabbit.config.ts
- Commit: 9d2c911
- Layers: Repository TS config
- Review profile: CHILL
- Plan: Team
- Run ID:
42d6336a-0f2f-4881-8f5f-76f5c3f71cd3
External image Commits
-
Reviewing files that changed from the base of the PR and between 358c74a7ecd7c428b15c3604a7ed07e0a3fffd95 and 9d2c911b27d85bba9d5002e17deead519837d517.
External image Files selected for processing: 4
-
apps/server/src/mcp/toolkits/project/handlers.tsapps/server/src/orchestration-v2/ThreadLaunchService.test.tsapps/server/src/vcs/GitVcsDriverCore.test.tsapps/server/src/vcs/GitVcsDriverCore.ts
External image Files skipped from review as they are similar to previous changes: 1
-
- apps/server/src/vcs/GitVcsDriverCore.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.
-
Wouldn't it make more sense to just make this work? a new worktree on an existing branch should work, as long as the branch is not checked out in another worktree.
Added 2 commits
Evidence
On both commits, start a fresh isolated development server, seed an initial Git commit on main, create an occupied feature/taken worktree and an idle feature/idle branch, then register the repository through the CLI. Run t3 thread start --json --worktree main --branch <branch> --provider <configured-provider> --model <configured-model> . "reply ok" for feature/taken, feature/idle, main, and unused feature/unused. List threads before and after each launch and inspect accepted threads.
Environment on both sides: Linux, real development server and CLI, fresh database, equivalent synthetic Git seed, development replay provider, 1440×900 dark terminal. Videos replay timestamped command output with JSON fields projected for readability; full redacted transcripts and original terminal recordings are linked below.
| Before | After |
|---|---|
| 1a2a4a5e96 | 9d2c911b27 |
| Before recording | After recording |
| All three existing branches return exit 0 and thread/run IDs, then create failed threads. | All three return exit 1 immediately with the branch name and actionable guidance; thread lists remain empty. |
| Unused branch provisions a worktree and reaches running status. | Unused branch provisions a worktree and reaches running status. |
| Full transcript, terminal recording, persisted provisioning failures | Full transcript, terminal recording, empty provisioning-failure export |
Scripts: shared seed and CLI driver, recording renderer, read-only evidence collector. Uploaded using ghdrop.
AI-assisted; verified with the tests and tools listed above.
Not shown:
- Client rendering was not exercised; this proof directly captures the real server behavior through the CLI and persisted provisioning events.
- The unused-branch control verified successful launch, worktree provisioning, and running status; natural-language turn completion was not verified.
- Automatic branch naming and project/Git lookup-failure fallback were not exercised; the reproduction covers explicit existing branches and an explicit unused branch.
+1, I hit this too, and a stable explicit branch name makes it worse.
When an earlier worktree for a branch was removed but its local branch was left behind, t3_thread_launch with workspaceStrategy: {type: "worktree", baseRef: <branch>, branch: <branch>, startFromOrigin: true} is accepted. The thread then fails during provisioning with branch_already_exists, and Retry replays the same name, so it fails every time until the local branch is deleted by hand.
Refusing at launch, as this PR does, lets the caller handle it instead of being left with a failed thread.
This fixes the failed-thread half of #31263. One launch it now refuses is one that wants the existing branch: baseRef and branch both name a local branch that is not checked out anywhere, as when opening a thread on an open PR's branch (repro).
createWorktree already checks out an existing branch when newRefName is omitted, so checkWorktreeBase could let that case through and provisioning could pass refName: branch with no newRefName. A branch checked out in another worktree, or one pointing at a different commit than baseRef, would still be refused as here. Fine as a follow-up if you would rather keep this PR to the refusal.