From 09f57976c19c3516b5045cc7d98876dc1164b96f Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Wed, 11 Nov 2026 15:41:55 +0530 Subject: [PATCH 1/2] fix(server): reject worktree launches in non-Git projects --- .../src/mcp/toolkits/project/handlers.ts | 13 ++-- .../ThreadLaunchService.test.ts | 59 +++++++++++++++++++ .../orchestration-v2/ThreadLaunchService.ts | 35 +++++++++-- 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index ab27b6b5bc4b4..2fbdf272cd098 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -163,11 +163,14 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (error) => new OrchestratorMcpFailure({ code: "invalid_request", - message: `${error.message} No thread was created. Pass a branch, tag, or commit that exists there${ - strategy.startFromOrigin === true - ? "; the project has no usable remote to fetch it from." - : ", or set startFromOrigin:true if it exists only on a remote." - }`, + message: + error._tag === "ThreadLaunchNotRepositoryError" + ? `${error.message} No thread was created. Use workspaceStrategy: {type: "root"}, or pass the projectId of a Git repository.` + : `${error.message} No thread was created. Pass a branch, tag, or commit that exists there${ + strategy.startFromOrigin === true + ? "; the project has no usable remote to fetch it from." + : ", or set startFromOrigin:true if it exists only on a remote." + }`, }), ), ); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index bfa50b9793490..af3b1eb027397 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -203,6 +203,7 @@ function makeHarness(options: HarnessOptions = {}) { listShells: () => Effect.die("unused"), }), Layer.mock(GitWorkflow.GitWorkflowService)({ + repositoryRoot: () => Effect.succeed(project.workspaceRoot), createWorktree, renameBranch, fetchWorktreeBaseCommit: @@ -2406,6 +2407,64 @@ it.effect.each([0, 1])("releases an async setup before its completion with exit }), ); +it.effect.each(["origin/master", ":/initial"])( + "refuses a worktree in a non-Git project before checking base %s", + (baseRef) => + Effect.gen(function* () { + const harness = makeHarness({ + git: { repositoryRoot: () => Effect.succeed(null) }, + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const result = yield* launches + .checkWorktreeBase({ + projectId, + workspaceStrategy: { type: "worktree", baseRef, startFromOrigin: true }, + }) + .pipe(Effect.result); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, ThreadLaunch.ThreadLaunchNotRepositoryError); + assert.deepInclude(result.failure, { + projectId, + projectTitle: project.title, + workspaceRoot: project.workspaceRoot, + }); + } + yield* launches.checkWorktreeBase({ projectId, workspaceStrategy: { type: "root" } }); + yield* launches.checkWorktreeBase({ + projectId, + workspaceStrategy: { type: "existing_worktree", worktreePath: "/existing-worktree" }, + }); + }).pipe(Effect.provide(harness.layer)); + }), +); + +it.effect("leaves uncertain Git detection failures to workspace preparation", () => + Effect.gen(function* () { + const harness = makeHarness({ + git: { + repositoryRoot: () => + Effect.fail( + new GitCommandError({ + operation: "detect-repository", + command: "git", + cwd: project.workspaceRoot, + detail: "Detection failed.", + }), + ), + }, + hasCommit: () => Effect.succeed(true), + }); + yield* ThreadLaunch.ThreadLaunchService.pipe( + Effect.flatMap((launches) => + launches.checkWorktreeBase({ projectId, workspaceStrategy: worktreeStrategy }), + ), + Effect.provide(harness.layer), + ); + }), +); + it.effect.each([ { name: "is missing", baseRef: "t3/renamed", startFromOrigin: false, origin: true }, { diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 6397bfdb3bc72..412cb05c9d4da 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -166,6 +166,19 @@ export class ThreadLaunchBaseRefError extends Schema.TaggedError()( + "ThreadLaunchNotRepositoryError", + { + projectId: ProjectId, + projectTitle: Schema.String, + workspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `Project "${this.projectTitle}" (${this.workspaceRoot}) is not a Git repository.`; + } +} + export class ThreadLaunchService extends Context.Service< ThreadLaunchService, { @@ -178,7 +191,7 @@ export class ThreadLaunchService extends Context.Service< /** Check before creation for early errors; launch otherwise accepts and reports base failures inside the thread. */ readonly checkWorktreeBase: ( input: Pick, - ) => Effect.Effect; + ) => Effect.Effect; readonly retryPreparation: ( input: ThreadLaunchPreparationInput, ) => Effect.Effect< @@ -263,13 +276,25 @@ const make = Effect.gen(function* () { "ThreadLaunchService.checkWorktreeBase", )(function* (input) { const strategy = input.workspaceStrategy; + if (strategy.type !== "worktree") return; + const project = Option.getOrUndefined( + yield* projects.getById(input.projectId).pipe(Effect.orElseSucceed(() => Option.none())), + ); + if (project === undefined) return; + const cwd = project.workspaceRoot; + const repositoryRoot = yield* git + .repositoryRoot(cwd) + .pipe(Effect.orElseSucceed(() => undefined)); + if (repositoryRoot === null) + return yield* new ThreadLaunchNotRepositoryError({ + projectId: project.id, + projectTitle: project.title, + workspaceRoot: cwd, + }); // `:/text` is a commit search, which cannot be asked for a commit type. - if (strategy.type !== "worktree" || strategy.baseRef.startsWith(":")) return; + if (strategy.baseRef.startsWith(":")) return; const { baseRef } = strategy; const unresolved = yield* Effect.gen(function* () { - const project = Option.getOrUndefined(yield* projects.getById(input.projectId)); - if (project === undefined) return undefined; - const cwd = project.workspaceRoot; // `git worktree add` reads `-` as the previous checkout. if (yield* git.hasCommit({ cwd, refName: baseRef === "-" ? "@{-1}" : baseRef })) return undefined; From 21aacf43936a30accf5eef2f0bf9eba4b0f231f0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 19:26:02 +0000 Subject: [PATCH 2/2] fix(server): let Scratch worktree launches through the Git precheck launch() turns a Scratch worktree request into a root launch, so the non-Git rejection must not stop it first. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/orchestration-v2/ThreadLaunchService.test.ts | 2 ++ apps/server/src/orchestration-v2/ThreadLaunchService.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index af3b1eb027397..05575f301fd31 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -912,6 +912,7 @@ function scratchHarness() { return Option.some(`/scratch/folder-${claimed.length}`); }), }), + git: { repositoryRoot: (cwd) => Effect.succeed(cwd === scratchRoot ? null : cwd) }, remoteExists: (input) => input.cwd === scratchRoot ? Effect.fail(notARepository("GitWorkflowService.remoteExists", input.cwd)) @@ -983,6 +984,7 @@ it.effect("launches a worktree request for Scratch at the root, in its own folde const harness = scratchHarness(); return Effect.gen(function* () { const launches = yield* ThreadLaunch.ThreadLaunchService; + yield* launches.checkWorktreeBase({ projectId, workspaceStrategy: worktreeStrategy }); const launched = yield* launches.launch( launchInput({ command: "command:launch:scratch-worktree", diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 412cb05c9d4da..2cf7030a01c3f 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -276,7 +276,9 @@ const make = Effect.gen(function* () { "ThreadLaunchService.checkWorktreeBase", )(function* (input) { const strategy = input.workspaceStrategy; - if (strategy.type !== "worktree") return; + // launch() turns a Scratch worktree request into a root launch. + if (strategy.type !== "worktree" || (yield* managedFolders.isScratchProject(input.projectId))) + return; const project = Option.getOrUndefined( yield* projects.getById(input.projectId).pipe(Effect.orElseSucceed(() => Option.none())), );