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..05575f301fd31 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: @@ -911,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)) @@ -982,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", @@ -2406,6 +2409,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..2cf7030a01c3f 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,27 @@ const make = Effect.gen(function* () { "ThreadLaunchService.checkWorktreeBase", )(function* (input) { const strategy = input.workspaceStrategy; + // 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())), + ); + 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;