authorgravatar for admin@snappeh.comTristan Knight <admin@snappeh.com> 2026-10-10 11:59:50+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 05:59:50-05:00
log6266af3f1f688497ef9717b87b82eafc5240418c
tree5baf2b101d0ab2ee3ef286d99ee263bce5de6136
parentc77a7b7eebd6ea8a3a63bbcaaa7c1707fe65c86e
signature Signed by PGP key B5690EEEBB952194

fix(server): t3_thread_launch refuses a new worktree whose base ref has no commit (#17791)


6 files changed, 309 insertions(+), 1 deletions(-)

apps/server/src/git/GitWorkflowService.test.ts+48
...@@ -6,6 +6,11 @@ import * as Option from "effect/Option";...@@ -6,6 +6,11 @@ import * as Option from "effect/Option";
66
7import { VcsRepositoryDetectionError } from "@t3tools/contracts";7import { VcsRepositoryDetectionError } from "@t3tools/contracts";
88
9import * as NodeServices from "@effect/platform-node/NodeServices";
10import * as FileSystem from "effect/FileSystem";
11
12import * as ServerConfig from "../config.ts";
13import * as VcsProcess from "../vcs/VcsProcess.ts";
9import * as GitManager from "./GitManager.ts";14import * as GitManager from "./GitManager.ts";
10import * as GitWorkflowService from "./GitWorkflowService.ts";15import * as GitWorkflowService from "./GitWorkflowService.ts";
11import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";16import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
...@@ -220,4 +225,47 @@ describe("GitWorkflowService", () => {...@@ -220,4 +225,47 @@ describe("GitWorkflowService", () => {
220 ),225 ),
221 );226 );
222 });227 });
228
229 it.effect("finds a name among the refs of every namespace", () =>
230 Effect.gen(function* () {
231 const fileSystem = yield* FileSystem.FileSystem;
232 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ref-named-" });
233 const workflow = yield* GitWorkflowService.GitWorkflowService;
234 const driver = yield* GitVcsDriver.GitVcsDriver;
235 for (const args of [
236 ["init", "-b", "main"],
237 ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init"],
238 ["update-ref", "refs/remotes/upstream/feature/topic", "HEAD"],
239 ["symbolic-ref", "refs/remotes/origin/alias", "refs/heads/main"],
240 ["update-ref", "refs/mirror/origin/mirrored", "HEAD"],
241 ]) {
242 yield* driver.execute({ operation: "test.setupRepo", cwd, args });
243 }
244 const found = yield* Effect.forEach(
245 ["main", "feature/topic", "alias", "mirrored", "t3/renamed", "feature/*"],
246 (refName) => workflow.hasRefNamed({ cwd, refName }),
247 );
248 assert.deepEqual(found, [true, true, true, true, false, false]);
249 }).pipe(
250 Effect.scoped,
251 Effect.provide(
252 GitWorkflowService.layer.pipe(
253 Layer.provide(
254 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
255 resolve: () => Effect.succeed({ kind: "git" } as VcsDriverRegistry.VcsDriverHandle),
256 }),
257 ),
258 Layer.provide(Layer.mock(GitManager.GitManager)({})),
259 Layer.provideMerge(GitVcsDriver.layer),
260 Layer.provideMerge(VcsProcess.layer),
261 Layer.provideMerge(
262 ServerConfig.layerTest(process.cwd(), { prefix: "t3-ref-named-" }).pipe(
263 Layer.provide(NodeServices.layer),
264 ),
265 ),
266 Layer.provideMerge(NodeServices.layer),
267 ),
268 ),
269 ),
270 );
223});271});
apps/server/src/git/GitWorkflowService.ts+28
...@@ -40,6 +40,15 @@ export class GitWorkflowService extends Context.Service<...@@ -40,6 +40,15 @@ export class GitWorkflowService extends Context.Service<
40 readonly cwd: string;40 readonly cwd: string;
41 readonly refName: string;41 readonly refName: string;
42 }) => Effect.Effect<boolean, GitCommandError>;42 }) => Effect.Effect<boolean, GitCommandError>;
43 /**
44 * Whether a ref in any namespace ends in this name: a local branch, a
45 * remote-tracking branch of any remote, a tag. Git falls back to these
46 * when a branch name is not a revision by itself.
47 */
48 readonly hasRefNamed: (input: {
49 readonly cwd: string;
50 readonly refName: string;
51 }) => Effect.Effect<boolean, GitCommandError>;
43 readonly status: (52 readonly status: (
44 input: VcsStatusInput,53 input: VcsStatusInput,
45 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;54 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
...@@ -300,6 +309,25 @@ export const make = Effect.gen(function* () {...@@ -300,6 +309,25 @@ export const make = Effect.gen(function* () {
300 ),309 ),
301 Effect.map((result) => result.exitCode === 0),310 Effect.map((result) => result.exitCode === 0),
302 ),311 ),
312 hasRefNamed: (input) =>
313 // No ref name holds a pattern character, and one here would widen the match.
314 /[*?[\\]/.test(input.refName)
315 ? Effect.succeed(false)
316 : ensureGitCommand("GitWorkflowService.hasRefNamed", input.cwd).pipe(
317 Effect.andThen(
318 git.execute({
319 operation: "GitWorkflowService.hasRefNamed",
320 cwd: input.cwd,
321 args: [
322 "for-each-ref",
323 "--count=1",
324 "--format=%(refname)",
325 `refs/**/${input.refName}`,
326 ],
327 }),
328 ),
329 Effect.map((result) => result.stdout.trim().length > 0),
330 ),
303 status: (input) =>331 status: (input) =>
304 detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe(332 detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe(
305 Effect.flatMap((isGitRepository) =>333 Effect.flatMap((isGitRepository) =>
apps/server/src/mcp/toolkits/project/handlers.test.ts+46
...@@ -301,6 +301,7 @@ const clientLaunchHarness = (input: {...@@ -301,6 +301,7 @@ const clientLaunchHarness = (input: {
301 readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access";301 readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access";
302 readonly launched: Array<ThreadLaunch.ThreadLaunchInput>;302 readonly launched: Array<ThreadLaunch.ThreadLaunchInput>;
303 readonly workspaceRoot?: string;303 readonly workspaceRoot?: string;
304 readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"];
304}) => {305}) => {
305 const projectId = ProjectId.make("project:client-target");306 const projectId = ProjectId.make("project:client-target");
306 const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" };307 const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" };
...@@ -320,6 +321,7 @@ const clientLaunchHarness = (input: {...@@ -320,6 +321,7 @@ const clientLaunchHarness = (input: {
320 }),321 }),
321 Layer.mock(ThreadManagement.ThreadManagementService)({}),322 Layer.mock(ThreadManagement.ThreadManagementService)({}),
322 Layer.mock(ThreadLaunch.ThreadLaunchService)({323 Layer.mock(ThreadLaunch.ThreadLaunchService)({
324 checkWorktreeBase: input.checkWorktreeBase ?? (() => Effect.void),
323 launch: (launch) => {325 launch: (launch) => {
324 input.launched.push(launch);326 input.launched.push(launch);
325 return Effect.succeed({327 return Effect.succeed({
...@@ -454,3 +456,47 @@ it.effect("a launch binds only an existing checkout that is one of the project's...@@ -454,3 +456,47 @@ it.effect("a launch binds only an existing checkout that is one of the project's
454 });456 });
455 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),457 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
456);458);
459
460it.effect("a launch into a new worktree creates nothing when its base ref has no commit", () =>
461 Effect.gen(function* () {
462 const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
463 const { projectId, dependencies } = clientLaunchHarness({
464 runtimeModeCeiling: "auto",
465 launched,
466 checkWorktreeBase: ({ projectId, workspaceStrategy }) =>
467 workspaceStrategy.type === "worktree" && workspaceStrategy.baseRef === "t3/renamed"
468 ? Effect.fail(
469 new ThreadLaunch.ThreadLaunchBaseRefError({
470 projectId,
471 projectTitle: "Client target",
472 workspaceRoot: "/projects/client-target",
473 baseRef: workspaceStrategy.baseRef,
474 }),
475 )
476 : Effect.void,
477 });
478 const toolkit = yield* ProjectToolkit.pipe(
479 Effect.provide(
480 McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
481 ),
482 );
483 const launchFrom = (baseRef: string) =>
484 toolkit
485 .handle("t3_thread_launch", {
486 title: "Fix",
487 projectId,
488 workspaceStrategy: { type: "worktree", baseRef, startFromOrigin: false },
489 })
490 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));
491
492 const refused = (yield* launchFrom("t3/renamed")).at(-1)?.result;
493 expect(refused).toMatchObject({ code: "invalid_request" });
494 expect((refused as { message: string }).message).toContain(
495 'Base ref "t3/renamed" does not resolve to a commit in project "Client target" (/projects/client-target). No thread was created.',
496 );
497 expect(launched).toHaveLength(0);
498
499 expect((yield* launchFrom("main")).at(-1)?.result).toMatchObject({ projectId });
500 expect(launched).toHaveLength(1);
501 }),
502);
apps/server/src/mcp/toolkits/project/handlers.ts+21
...@@ -2,6 +2,7 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools...@@ -2,6 +2,7 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts";
5import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";6import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
6import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";7import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
7import * as Project from "../../../project/ProjectService.ts";8import * as Project from "../../../project/ProjectService.ts";
...@@ -103,6 +104,26 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {...@@ -103,6 +104,26 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
103 });104 });
104 yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath);105 yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath);
105 }106 }
107 // A launch is accepted before its worktree is provisioned, so a base
108 // ref that cannot resolve is refused while the agent can still act on it.
109 const strategy = input.workspaceStrategy;
110 if (strategy?.type === "worktree")
111 yield* ThreadLaunch.ThreadLaunchService.pipe(
112 Effect.flatMap((launches) =>
113 launches.checkWorktreeBase({ projectId, workspaceStrategy: strategy }),
114 ),
115 Effect.mapError(
116 (error) =>
117 new OrchestratorMcpFailure({
118 code: "invalid_request",
119 message: `${error.message} No thread was created. Pass a branch, tag, or commit that exists there${
120 strategy.startFromOrigin === true
121 ? "; the project has no origin remote to fetch it from."
122 : ", or set startFromOrigin:true if it exists only on origin."
123 }`,
124 }),
125 ),
126 );
106 const modelSelection =127 const modelSelection =
107 input.modelSelection ??128 input.modelSelection ??
108 caller?.modelSelection ??129 caller?.modelSelection ??
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+105
...@@ -107,6 +107,7 @@ interface HarnessOptions {...@@ -107,6 +107,7 @@ interface HarnessOptions {
107 readonly generateBranchName?: TextGeneration.TextGeneration["Service"]["generateBranchName"];107 readonly generateBranchName?: TextGeneration.TextGeneration["Service"]["generateBranchName"];
108 readonly serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];108 readonly serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
109 readonly providers?: ReadonlyArray<ServerProvider>;109 readonly providers?: ReadonlyArray<ServerProvider>;
110 readonly git?: Partial<GitWorkflow.GitWorkflowService["Service"]>;
110}111}
111112
112function makeHarness(options: HarnessOptions = {}) {113function makeHarness(options: HarnessOptions = {}) {
...@@ -175,6 +176,7 @@ function makeHarness(options: HarnessOptions = {}) {...@@ -175,6 +176,7 @@ function makeHarness(options: HarnessOptions = {}) {
175 removeWorktree,176 removeWorktree,
176 resolveRemoteTrackingCommit: () =>177 resolveRemoteTrackingCommit: () =>
177 Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }),178 Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }),
179 ...options.git,
178 }),180 }),
179 Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, {181 Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, {
180 runForThread: runSetup,182 runForThread: runSetup,
...@@ -2024,6 +2026,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch...@@ -2024,6 +2026,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch
2024 }),2026 }),
2025 ),2027 ),
2026 ),2028 ),
2029 checkWorktreeBase: launches.checkWorktreeBase,
2027 retryPreparation: launches.retryPreparation,2030 retryPreparation: launches.retryPreparation,
2028 }),2031 }),
2029 Effect.flip,2032 Effect.flip,
...@@ -2269,3 +2272,105 @@ it.effect.each([0, 1])("releases an async setup before its completion with exit...@@ -2269,3 +2272,105 @@ it.effect.each([0, 1])("releases an async setup before its completion with exit
2269 }).pipe(Effect.provide(harness.layer));2272 }).pipe(Effect.provide(harness.layer));
2270 }),2273 }),
2271);2274);
2275
2276it.effect.each([
2277 { name: "is missing", baseRef: "t3/renamed", startFromOrigin: false, origin: true },
2278 {
2279 name: "has no origin to come from",
2280 baseRef: "t3/renamed",
2281 startFromOrigin: true,
2282 origin: false,
2283 },
2284 {
2285 name: "is a previous checkout there never was",
2286 baseRef: "-",
2287 startFromOrigin: false,
2288 origin: true,
2289 },
2290])("refuses a new worktree whose base ref $name", (testCase) =>
2291 Effect.gen(function* () {
2292 const harness = makeHarness({
2293 hasCommit: ({ refName }) => Effect.succeed(refName === "HEAD"),
2294 git: {
2295 remoteExists: () => Effect.succeed(testCase.origin),
2296 hasRefNamed: () => Effect.succeed(false),
2297 },
2298 });
2299 yield* Effect.gen(function* () {
2300 const launches = yield* ThreadLaunch.ThreadLaunchService;
2301 const error = yield* launches
2302 .checkWorktreeBase({
2303 projectId,
2304 workspaceStrategy: {
2305 type: "worktree",
2306 baseRef: testCase.baseRef,
2307 startFromOrigin: testCase.startFromOrigin,
2308 },
2309 })
2310 .pipe(Effect.flip);
2311 assert.deepInclude(error, {
2312 _tag: "ThreadLaunchBaseRefError",
2313 projectId,
2314 baseRef: testCase.baseRef,
2315 });
2316 assert.equal(
2317 error.message,
2318 `Base ref "${testCase.baseRef}" does not resolve to a commit in project "Project" (/repo).`,
2319 );
2320 }).pipe(Effect.provide(harness.layer));
2321 }),
2322);
2323
2324it.effect.each([
2325 { name: "a base ref that resolves", baseRef: "main", commits: ["main", "HEAD"] },
2326 // Provisioning fetches it; the ref may exist nowhere else yet.
2327 {
2328 name: "a base ref only origin has",
2329 baseRef: "remote-only",
2330 startFromOrigin: true,
2331 commits: ["HEAD"],
2332 },
2333 // `git worktree add` starts from a remote-tracking branch of that name.
2334 {
2335 name: "a base ref a remote branch carries",
2336 baseRef: "remote-only",
2337 commits: ["HEAD"],
2338 remoteBranch: true,
2339 },
2340 // Provisioning runs such a repository without a worktree.
2341 { name: "a repository with no commits yet", baseRef: "main", commits: [] },
2342 { name: "the previous checkout", baseRef: "-", commits: ["@{-1}", "HEAD"] },
2343 { name: "a commit search", baseRef: ":/fix", commits: ["HEAD"] },
2344 { name: "a folder that is not a repository", baseRef: "main", commits: null },
2345])("leaves $name to the launch", (testCase) =>
2346 Effect.gen(function* () {
2347 const { commits } = testCase;
2348 const harness = makeHarness({
2349 git: { hasRefNamed: () => Effect.succeed(testCase.remoteBranch === true) },
2350 hasCommit: ({ refName }) =>
2351 commits === null
2352 ? Effect.fail(
2353 new GitCommandError({
2354 operation: "GitWorkflowService.hasCommit",
2355 command: "git rev-parse",
2356 cwd: "/repo",
2357 detail: "Not a Git repository.",
2358 }),
2359 )
2360 : Effect.succeed(commits.includes(refName)),
2361 });
2362 yield* Effect.gen(function* () {
2363 const launches = yield* ThreadLaunch.ThreadLaunchService;
2364 yield* launches.checkWorktreeBase({
2365 projectId,
2366 workspaceStrategy: {
2367 type: "worktree",
2368 baseRef: testCase.baseRef,
2369 ...(testCase.startFromOrigin === undefined
2370 ? {}
2371 : { startFromOrigin: testCase.startFromOrigin }),
2372 },
2373 });
2374 }).pipe(Effect.provide(harness.layer));
2375 }),
2376);
apps/server/src/orchestration-v2/ThreadLaunchService.ts+61-1
...@@ -147,12 +147,35 @@ export class ThreadLaunchError extends Schema.TaggedError<ThreadLaunchError>()(...@@ -147,12 +147,35 @@ export class ThreadLaunchError extends Schema.TaggedError<ThreadLaunchError>()(
147 }147 }
148}148}
149149
150/** A new worktree was asked to start from a ref that gives it no commit. */
151export class ThreadLaunchBaseRefError extends Schema.TaggedError<ThreadLaunchBaseRefError>()(
152 "ThreadLaunchBaseRefError",
153 {
154 projectId: ProjectId,
155 projectTitle: Schema.String,
156 workspaceRoot: Schema.String,
157 baseRef: Schema.String,
158 },
159) {
160 override get message(): string {
161 return `Base ref "${this.baseRef}" does not resolve to a commit in project "${this.projectTitle}" (${this.workspaceRoot}).`;
162 }
163}
164
150export class ThreadLaunchService extends Context.Service<165export class ThreadLaunchService extends Context.Service<
151 ThreadLaunchService,166 ThreadLaunchService,
152 {167 {
153 readonly launch: (168 readonly launch: (
154 input: ThreadLaunchInput,169 input: ThreadLaunchInput,
155 ) => Effect.Effect<ThreadLaunchResult, ThreadLaunchError>;170 ) => Effect.Effect<ThreadLaunchResult, ThreadLaunchError>;
171 /**
172 * Fails when a new worktree's base ref cannot give provisioning a commit.
173 * `launch` accepts such a launch and reports the failure inside the
174 * thread; a caller that needs it before a thread exists checks first.
175 */
176 readonly checkWorktreeBase: (
177 input: Pick<ThreadLaunchInput, "projectId" | "workspaceStrategy">,
178 ) => Effect.Effect<void, ThreadLaunchBaseRefError>;
156 /** Dispatches prepared-run.retry and prepares the run's workspace again. */179 /** Dispatches prepared-run.retry and prepares the run's workspace again. */
157 readonly retryPreparation: (180 readonly retryPreparation: (
158 input: ThreadLaunchRetryInput,181 input: ThreadLaunchRetryInput,
...@@ -229,6 +252,43 @@ const make = Effect.gen(function* () {...@@ -229,6 +252,43 @@ const make = Effect.gen(function* () {
229 }252 }
230 });253 });
231254
255 const checkWorktreeBase: ThreadLaunchService["Service"]["checkWorktreeBase"] = Effect.fn(
256 "ThreadLaunchService.checkWorktreeBase",
257 )(function* (input) {
258 const strategy = input.workspaceStrategy;
259 // `:/text` is a commit search, which cannot be asked for a commit type.
260 if (strategy.type !== "worktree" || strategy.baseRef.startsWith(":")) return;
261 const { baseRef } = strategy;
262 // Anything this cannot establish is left for the launch to report: a
263 // missing project, a folder that is not a repository, a failing Git.
264 const unresolved = yield* Effect.gen(function* () {
265 const project = Option.getOrUndefined(yield* projects.getById(input.projectId));
266 if (project === undefined) return undefined;
267 const cwd = project.workspaceRoot;
268 // `git worktree add` reads `-` as the previous checkout.
269 if (yield* git.hasCommit({ cwd, refName: baseRef === "-" ? "@{-1}" : baseRef }))
270 return undefined;
271 // Provisioning fetches the base from origin when it can, and runs a
272 // repository with no commits yet without a worktree.
273 if (
274 strategy.startFromOrigin === true &&
275 (yield* git.remoteExists({ cwd, remoteName: "origin" }))
276 )
277 return undefined;
278 // `git worktree add` also starts from a remote-tracking branch of that
279 // name; any ref carrying it is reason enough to let Git decide.
280 if (yield* git.hasRefNamed({ cwd, refName: baseRef })) return undefined;
281 return (yield* git.hasCommit({ cwd, refName: "HEAD" })) ? project : undefined;
282 }).pipe(Effect.orElseSucceed(() => undefined));
283 if (unresolved !== undefined)
284 return yield* new ThreadLaunchBaseRefError({
285 projectId: unresolved.id,
286 projectTitle: unresolved.title,
287 workspaceRoot: unresolved.workspaceRoot,
288 baseRef,
289 });
290 });
291
232 const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* (292 const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* (
233 input: PreparationInput,293 input: PreparationInput,
234 threadId: ThreadId,294 threadId: ThreadId,
...@@ -977,7 +1037,7 @@ const make = Effect.gen(function* () {...@@ -977,7 +1037,7 @@ const make = Effect.gen(function* () {
977 );1037 );
978 };1038 };
9791039
980 return ThreadLaunchService.of({ launch, retryPreparation });1040 return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation });
981});1041});
9821042
983export const layer = Layer.effect(ThreadLaunchService, make);1043export const layer = Layer.effect(ThreadLaunchService, make);