From 69a3327d3110cc9a2a5cdd767b4e988fda53cfa0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:42:36 +0000 Subject: [PATCH 1/5] test(server): new worktree threads in a repository with no commits Reproduces #22017: launching a new-worktree thread in a repository whose main branch has no commit fails preparation with ref_not_found. AI-assisted; verified with the tests and tools listed above. --- .../ThreadLaunchService.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index a0a438bf97301..6dbf1fb85f385 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1177,6 +1177,53 @@ it.effect.each(["stable", "upstream/release"])( ), ); +it.effect("starts a new worktree thread in a repository with no commits", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver.GitVcsDriver; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "launch-unborn-" }); + const cwd = `${directory}/repo`; + yield* fs.makeDirectory(cwd); + yield* git.execute({ operation: "launch-test", cwd, args: ["init", "-b", "main"] }); + + const harness = makeHarness({ + hasCommit: ({ refName }) => + git + .execute({ + operation: "launch-test", + cwd, + args: ["rev-parse", "--verify", `${refName}^{commit}`], + }) + .pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ), + createWorktree: (input, options) => + git.createWorktree({ ...input, cwd, path: `${directory}/worktree` }, options), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:unborn", + thread: "thread:launch:unborn", + message: "Say hello", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + const projection = yield* preparationSettled(launched.threadId); + assert.equal(projection.runs[0]?.status, "starting"); + }).pipe(Effect.provide(harness.layer)); + }).pipe( + Effect.provide( + GitVcsDriver.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "launch-unborn-git-" })), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), +); + it.effect("fetches the origin base from the remote it already resolved", () => { let fetchInput: Deferred.Deferred | null = null; const harness = makeHarness({ From 416d018ce59100c4c232f6c39007bff50dd07557 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:52:46 +0000 Subject: [PATCH 2/5] fix(server): new worktree threads start in a repository with no commits A repository with no commits has no base to check a worktree out from, so provisioning failed with ref_not_found before the agent ran. When neither HEAD nor the start ref resolves to a commit, launch in the project folder instead, as a non-Git project does. AI-assisted; verified with the tests and tools listed above. --- .../ThreadLaunchService.test.ts | 2 +- .../orchestration-v2/ThreadLaunchService.ts | 98 +++++++++++-------- 2 files changed, 59 insertions(+), 41 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 6dbf1fb85f385..54c0e243d2a20 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -210,7 +210,7 @@ function makeHarness(options: HarnessOptions = {}) { fetchWorktreeBaseCommit: options.fetchWorktreeBaseCommit ?? (() => Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" })), - hasCommit: options.hasCommit ?? (() => Effect.succeed(false)), + hasCommit: options.hasCommit ?? (({ refName }) => Effect.succeed(refName === "HEAD")), currentBranch: () => Effect.succeed(null), resolveWorktreeBaseRemote: () => Effect.succeed({ remoteName: "origin", branchName: "main" }), remoteExists: options.remoteExists ?? (() => Effect.succeed(true)), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index c65754dece315..009da29713d39 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -539,47 +539,65 @@ const make = Effect.gen(function* () { } } if (shouldFetch) yield* setupTracker.stageStatus(threadId, "fetch", "done"); - if ( - branch !== null && - isTemporaryWorktreeBranch(branch) && - (yield* git - .hasCommit({ - cwd: project.workspaceRoot, - refName: `refs/heads/${WORKTREE_BRANCH_PREFIX}`, - }) - .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)))) - ) { - branch = flattenTemporaryWorktreeBranchName(branch); - } - yield* setupTracker.stageStatus(threadId, "checkout", "running"); - const worktree = yield* git - .createWorktree( - { - cwd: project.workspaceRoot, - refName: startRef, - newRefName: branch!, - baseRefName, - path: null, - }, - { - progress: { - onWorktreeClaimed: (path) => - Effect.sync(() => { - createdWorktreePath = path; - createdBranch = branch; - }), - onCheckoutProgress: (progress) => - setupTracker.stage(threadId, "checkout", { percent: progress.percent }), + const unborn = yield* Effect.gen(function* () { + const cwd = project.workspaceRoot; + if (yield* git.hasCommit({ cwd, refName: "HEAD" })) return false; + return !(yield* git.hasCommit({ cwd, refName: startRef === "-" ? "@{-1}" : startRef })); + }).pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); + if (unborn) { + // A repository with no commits has nothing to check out; run in the project folder. + branch = yield* git + .currentBranch(project.workspaceRoot) + .pipe(Effect.orElseSucceed(() => null)); + yield* setupTracker.update(threadId, (snapshot) => ({ ...snapshot, branch })); + yield* setupTracker.stageStatus(threadId, "checkout", "skipped"); + } else { + if ( + branch !== null && + isTemporaryWorktreeBranch(branch) && + (yield* git + .hasCommit({ + cwd: project.workspaceRoot, + refName: `refs/heads/${WORKTREE_BRANCH_PREFIX}`, + }) + .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId)))) + ) { + branch = flattenTemporaryWorktreeBranchName(branch); + } + yield* setupTracker.stageStatus(threadId, "checkout", "running"); + const worktree = yield* git + .createWorktree( + { + cwd: project.workspaceRoot, + refName: startRef, + newRefName: branch!, + baseRefName, + path: null, }, - }, - ) - .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); - worktreePath = worktree.worktree.path; - branch = worktree.worktree.refName; - createdWorktreePath = worktreePath; - createdBranch = branch; - yield* setupTracker.update(threadId, (snapshot) => ({ ...snapshot, worktreePath, branch })); - yield* setupTracker.stageStatus(threadId, "checkout", "done"); + { + progress: { + onWorktreeClaimed: (path) => + Effect.sync(() => { + createdWorktreePath = path; + createdBranch = branch; + }), + onCheckoutProgress: (progress) => + setupTracker.stage(threadId, "checkout", { percent: progress.percent }), + }, + }, + ) + .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); + worktreePath = worktree.worktree.path; + branch = worktree.worktree.refName; + createdWorktreePath = worktreePath; + createdBranch = branch; + yield* setupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + worktreePath, + branch, + })); + yield* setupTracker.stageStatus(threadId, "checkout", "done"); + } } // Do not rewrite reused worktrees; that could undo the original branch rename. From 2219b7e63cd4b95e56afe1547e9b04217422ece0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 08:02:28 +0000 Subject: [PATCH 3/5] fix(server): commit-search bases still get a worktree when HEAD is unborn hasCommit appends ^{commit}, which turns a :/text commit search into a search for different text, so the no-commits check misread a valid commit-search base as an empty repository. Skip the check for :/ bases, as checkWorktreeBase already does. AI-assisted; verified with the tests and tools listed above. --- .../ThreadLaunchService.test.ts | 18 ++++++++++++++++++ .../orchestration-v2/ThreadLaunchService.ts | 4 +++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 54c0e243d2a20..254104267f366 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1224,6 +1224,24 @@ it.effect("starts a new worktree thread in a repository with no commits", () => ), ); +it.effect("checks out a commit-search base when the current branch has no commits", () => { + const harness = makeHarness({ hasCommit: () => Effect.succeed(false) }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:commit-search", + thread: "thread:launch:commit-search", + message: "Say hello", + workspace: { type: "worktree", baseRef: ":/initial", branch: "feature" }, + }), + ); + const projection = yield* preparationSettled(launched.threadId); + assert.equal(projection.thread.worktreePath, "/repo-worktrees/feature"); + assert.equal(harness.createWorktree.mock.calls[0]?.[0]?.refName, ":/initial"); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("fetches the origin base from the remote it already resolved", () => { let fetchInput: Deferred.Deferred | null = null; const harness = makeHarness({ diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 009da29713d39..afa1e16a4d6e0 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -541,7 +541,9 @@ const make = Effect.gen(function* () { if (shouldFetch) yield* setupTracker.stageStatus(threadId, "fetch", "done"); const unborn = yield* Effect.gen(function* () { const cwd = project.workspaceRoot; - if (yield* git.hasCommit({ cwd, refName: "HEAD" })) return false; + // `:/text` is a commit search, which cannot be asked for a commit type. + if (startRef.startsWith(":") || (yield* git.hasCommit({ cwd, refName: "HEAD" }))) + return false; return !(yield* git.hasCommit({ cwd, refName: startRef === "-" ? "@{-1}" : startRef })); }).pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); if (unborn) { From ca8f60fa2fd0052cbf0c6658b745d5572ac5a631 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:32:22 +0000 Subject: [PATCH 4/5] fix(server): fall back to the project folder only when no branch has commits Bases such as main...other resolve for git worktree add but not for rev-parse --verify, so an orphan HEAD beside real branches wrongly took the project-folder fallback. Fall back only when the repository has no local branches, and let Git resolve the base otherwise, as on main. Co-Authored-By: Claude Opus 5.5 --- .../ThreadLaunchService.test.ts | 31 ++++++++++++++++--- .../orchestration-v2/ThreadLaunchService.ts | 2 ++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 4c0570e63f200..af0c0628d9daf 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1289,14 +1289,33 @@ it.effect.each(["stable", "upstream/release"])( ), ); -it.effect("starts a new worktree thread in a repository with no commits", () => +it.effect.each([ + { name: "a repository with no commits", setup: [], baseRef: "main", worktree: false }, + { + name: "an orphan branch beside branches with commits", + setup: [ + ["commit", "--allow-empty", "-m", "initial"], + ["branch", "other"], + ["checkout", "--orphan", "empty"], + ], + baseRef: "main...other", + worktree: true, + }, +])("launches a new worktree thread from $name", (scenario) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const git = yield* GitVcsDriver.GitVcsDriver; const directory = yield* fs.makeTempDirectoryScoped({ prefix: "launch-unborn-" }); const cwd = `${directory}/repo`; yield* fs.makeDirectory(cwd); - yield* git.execute({ operation: "launch-test", cwd, args: ["init", "-b", "main"] }); + for (const args of [ + ["init", "-b", "main"], + ["config", "user.email", "test@example.com"], + ["config", "user.name", "Test"], + ...scenario.setup, + ]) { + yield* git.execute({ operation: "launch-test", cwd, args }); + } const harness = makeHarness({ hasCommit: ({ refName }) => @@ -1310,6 +1329,7 @@ it.effect("starts a new worktree thread in a repository with no commits", () => Effect.as(true), Effect.orElseSucceed(() => false), ), + git: { listLocalBranchNames: () => git.listLocalBranchNames(cwd) }, createWorktree: (input, options) => git.createWorktree({ ...input, cwd, path: `${directory}/worktree` }, options), }); @@ -1320,11 +1340,14 @@ it.effect("starts a new worktree thread in a repository with no commits", () => command: "command:launch:unborn", thread: "thread:launch:unborn", message: "Say hello", - workspace: { type: "worktree", baseRef: "main" }, + workspace: { type: "worktree", baseRef: scenario.baseRef, branch: "feature" }, }), ); const projection = yield* preparationSettled(launched.threadId); - assert.equal(projection.thread.worktreePath, null); + assert.equal( + projection.thread.worktreePath, + scenario.worktree ? `${directory}/worktree` : null, + ); assert.equal(projection.runs[0]?.status, "starting"); }).pipe(Effect.provide(harness.layer)); }).pipe( diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 3c069701d675c..e56892c5a4a1a 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -559,6 +559,8 @@ const make = Effect.gen(function* () { // `:/text` is a commit search, which cannot be asked for a commit type. if (startRef.startsWith(":") || (yield* git.hasCommit({ cwd, refName: "HEAD" }))) return false; + // Any local branch means Git can still resolve bases like `main...other` that rev-parse rejects. + if ((yield* git.listLocalBranchNames(cwd)).length > 0) return false; return !(yield* git.hasCommit({ cwd, refName: startRef === "-" ? "@{-1}" : startRef })); }).pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); if (unborn) { From feee9a2ac3a08672b2d6705fa28792b93b4a6f19 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:44:30 +0000 Subject: [PATCH 5/5] fix(server): fall back to the project folder only for the unborn branch itself No local branches still leaves remote-tracking refs that Git resolves in bases like origin/main...origin/other. Take the project-folder fallback only when the base is the unborn branch HEAD points at (or HEAD) and it has no commit; Git resolves every other base, as on main. Co-Authored-By: Claude Opus 5.5 --- .../ThreadLaunchService.test.ts | 15 ++++++++++---- .../orchestration-v2/ThreadLaunchService.ts | 20 +++++++++---------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index af0c0628d9daf..a5c6be3f4cb77 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1292,13 +1292,15 @@ it.effect.each(["stable", "upstream/release"])( it.effect.each([ { name: "a repository with no commits", setup: [], baseRef: "main", worktree: false }, { - name: "an orphan branch beside branches with commits", + name: "an orphan branch beside remote-tracking branches", setup: [ ["commit", "--allow-empty", "-m", "initial"], - ["branch", "other"], + ["update-ref", "refs/remotes/origin/main", "HEAD"], + ["update-ref", "refs/remotes/origin/other", "HEAD"], ["checkout", "--orphan", "empty"], + ["branch", "-D", "main"], ], - baseRef: "main...other", + baseRef: "origin/main...origin/other", worktree: true, }, ])("launches a new worktree thread from $name", (scenario) => @@ -1329,7 +1331,12 @@ it.effect.each([ Effect.as(true), Effect.orElseSucceed(() => false), ), - git: { listLocalBranchNames: () => git.listLocalBranchNames(cwd) }, + git: { + currentBranch: () => + git + .execute({ operation: "launch-test", cwd, args: ["symbolic-ref", "--short", "HEAD"] }) + .pipe(Effect.map((result) => result.stdout.trim())), + }, createWorktree: (input, options) => git.createWorktree({ ...input, cwd, path: `${directory}/worktree` }, options), }); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index e56892c5a4a1a..160c028088070 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -556,18 +556,16 @@ const make = Effect.gen(function* () { if (shouldFetch) yield* setupTracker.stageStatus(threadId, "fetch", "done"); const unborn = yield* Effect.gen(function* () { const cwd = project.workspaceRoot; - // `:/text` is a commit search, which cannot be asked for a commit type. - if (startRef.startsWith(":") || (yield* git.hasCommit({ cwd, refName: "HEAD" }))) - return false; - // Any local branch means Git can still resolve bases like `main...other` that rev-parse rejects. - if ((yield* git.listLocalBranchNames(cwd)).length > 0) return false; - return !(yield* git.hasCommit({ cwd, refName: startRef === "-" ? "@{-1}" : startRef })); + if (yield* git.hasCommit({ cwd, refName: "HEAD" })) return null; + // Only the unborn branch itself has nothing to check out; Git resolves any other base. + const current = yield* git.currentBranch(cwd); + if (startRef !== "HEAD" && startRef !== current) return null; + if (yield* git.hasCommit({ cwd, refName: startRef })) return null; + return { branch: current }; }).pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); - if (unborn) { - // A repository with no commits has nothing to check out; run in the project folder. - branch = yield* git - .currentBranch(project.workspaceRoot) - .pipe(Effect.orElseSucceed(() => null)); + if (unborn !== null) { + // A branch with no commits has nothing to check out; run in the project folder. + branch = unborn.branch; yield* setupTracker.update(threadId, (snapshot) => ({ ...snapshot, branch })); yield* setupTracker.stageStatus(threadId, "checkout", "skipped"); yield* setupTracker.stageStatus(threadId, "submodules", "skipped");