authorgravatar for admin@snappeh.comTristan Knight <admin@snappeh.com> 2026-09-13 18:11:40+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 14:11:40-03:00
logdd6ba84dc96f83000388e14b90f6533d5f63315d
treefea9377a4fb07772d9662167e6ff59a3bb186259
parent9bf349cf6b3988dd01e2d9871b99e1be883aaa41
signature Signed by PGP key B5690EEEBB952194

fix(server): fall back when new worktrees are unavailable (#6208)

Preflight repository and base commit availability before creating the thread. Fall back to the project checkout for non-Git directories and repositories without a usable base commit, while preserving valid worktree setup.

4 files changed, 300 insertions(+), 32 deletions(-)

apps/server/src/git/GitWorkflowService.test.ts+31
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { assert, describe, expect, it, vi } from "@effect/vitest";1import { assert, describe, expect, it, vi } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
46
5import { VcsRepositoryDetectionError } from "@t3tools/contracts";7import { VcsRepositoryDetectionError } from "@t3tools/contracts";
68
...@@ -24,6 +26,35 @@ function makeLayer(input: {...@@ -24,6 +26,35 @@ function makeLayer(input: {
24}26}
2527
26describe("GitWorkflowService", () => {28describe("GitWorkflowService", () => {
29 it.effect("reports a non-Git VCS repository as not a Git repository", () =>
30 Effect.gen(function* () {
31 const workflow = yield* GitWorkflowService.GitWorkflowService;
32 const isRepository = yield* workflow.isRepository("/jj-repo");
33
34 assert.equal(isRepository, false);
35 }).pipe(
36 Effect.provide(
37 makeLayer({
38 detect: () =>
39 Effect.succeed({
40 kind: "jj",
41 repository: {
42 kind: "jj",
43 rootPath: "/jj-repo",
44 metadataPath: "/jj-repo/.jj",
45 freshness: {
46 source: "live-local",
47 observedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"),
48 expiresAt: Option.none(),
49 },
50 },
51 driver: {} as VcsDriverRegistry.VcsDriverHandle["driver"],
52 }),
53 }),
54 ),
55 ),
56 );
57
27 it.effect("returns an empty local status when no VCS repository is detected", () =>58 it.effect("returns an empty local status when no VCS repository is detected", () =>
28 Effect.gen(function* () {59 Effect.gen(function* () {
29 const workflow = yield* GitWorkflowService.GitWorkflowService;60 const workflow = yield* GitWorkflowService.GitWorkflowService;
apps/server/src/git/GitWorkflowService.ts+30
...@@ -35,6 +35,11 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";...@@ -35,6 +35,11 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
35export class GitWorkflowService extends Context.Service<35export class GitWorkflowService extends Context.Service<
36 GitWorkflowService,36 GitWorkflowService,
37 {37 {
38 readonly isRepository: (cwd: string) => Effect.Effect<boolean, GitManagerServiceError>;
39 readonly hasCommit: (input: {
40 readonly cwd: string;
41 readonly refName: string;
42 }) => Effect.Effect<boolean, GitCommandError>;
38 readonly status: (43 readonly status: (
39 input: VcsStatusInput,44 input: VcsStatusInput,
40 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;45 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
...@@ -263,6 +268,31 @@ export const make = Effect.gen(function* () {...@@ -263,6 +268,31 @@ export const make = Effect.gen(function* () {
263 ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input)));268 ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input)));
264269
265 return GitWorkflowService.of({270 return GitWorkflowService.of({
271 isRepository: (cwd) =>
272 registry.detect({ cwd }).pipe(
273 Effect.map((handle) => handle?.kind === "git"),
274 Effect.mapError(
275 (cause) =>
276 new GitManagerError({
277 operation: "GitWorkflowService.isRepository",
278 cwd,
279 detail: "Failed to detect a VCS repository for this Git workflow.",
280 cause,
281 }),
282 ),
283 ),
284 hasCommit: (input) =>
285 ensureGitCommand("GitWorkflowService.hasCommit", input.cwd).pipe(
286 Effect.andThen(
287 git.execute({
288 operation: "GitWorkflowService.hasCommit",
289 cwd: input.cwd,
290 args: ["rev-parse", "--verify", `${input.refName}^{commit}`],
291 allowNonZeroExit: true,
292 }),
293 ),
294 Effect.map((result) => result.exitCode === 0),
295 ),
266 status: (input) =>296 status: (input) =>
267 detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe(297 detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe(
268 Effect.flatMap((isGitRepository) =>298 Effect.flatMap((isGitRepository) =>
apps/server/src/server.test.ts+192
...@@ -88,6 +88,13 @@ import * as Socket from "effect/unstable/socket/Socket";...@@ -88,6 +88,13 @@ import * as Socket from "effect/unstable/socket/Socket";
88import { vi } from "vite-plus/test";88import { vi } from "vite-plus/test";
8989
90const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");90const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
91const SUCCESSFUL_GIT_EXECUTION = {
92 exitCode: ChildProcessSpawner.ExitCode(0),
93 stdout: "",
94 stderr: "",
95 stdoutTruncated: false,
96 stderrTruncated: false,
97};
91const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect(98const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect(
92 Schema.fromJsonString(OrchestrationThreadDetailSnapshot),99 Schema.fromJsonString(OrchestrationThreadDetailSnapshot),
93);100);
...@@ -10558,7 +10565,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -10558,7 +10565,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1055810565
10559 yield* buildAppUnderTest({10566 yield* buildAppUnderTest({
10560 layers: {10567 layers: {
10568 vcsDriver: {
10569 isInsideWorkTree: () => Effect.succeed(true),
10570 },
10561 gitVcsDriver: {10571 gitVcsDriver: {
10572 execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION),
10562 remoteExists,10573 remoteExists,
10563 fetchRemote,10574 fetchRemote,
10564 remoteBranchExists,10575 remoteBranchExists,
...@@ -10722,7 +10733,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -10722,7 +10733,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1072210733
10723 yield* buildAppUnderTest({10734 yield* buildAppUnderTest({
10724 layers: {10735 layers: {
10736 vcsDriver: {
10737 isInsideWorkTree: () => Effect.succeed(true),
10738 },
10725 gitVcsDriver: {10739 gitVcsDriver: {
10740 execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION),
10726 remoteExists,10741 remoteExists,
10727 fetchRemote,10742 fetchRemote,
10728 remoteBranchExists,10743 remoteBranchExists,
...@@ -10804,6 +10819,167 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -10804,6 +10819,167 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10804 }).pipe(Effect.provide(NodeHttpServer.layerTest)),10819 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
10805 );10820 );
1080610821
10822 it.effect("falls back to the project checkout when worktree mode targets a non-repository", () =>
10823 Effect.gen(function* () {
10824 const dispatchedCommands: Array<OrchestrationCommand> = [];
10825 const createWorktree = vi.fn(
10826 (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[0]) =>
10827 Effect.die(new Error("createWorktree must not run for a non-repository")),
10828 );
10829
10830 yield* buildAppUnderTest({
10831 layers: {
10832 gitVcsDriver: {
10833 execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION),
10834 createWorktree,
10835 },
10836 orchestrationEngine: {
10837 dispatch: (command) =>
10838 Effect.sync(() => {
10839 dispatchedCommands.push(command);
10840 return { sequence: dispatchedCommands.length };
10841 }),
10842 readEvents: () => Stream.empty,
10843 },
10844 },
10845 });
10846
10847 const createdAt = "2026-01-01T00:00:00.000Z";
10848 const wsUrl = yield* getWsServerUrl("/ws");
10849 const response = yield* Effect.scoped(
10850 withWsRpcClient(wsUrl, (client) =>
10851 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
10852 type: "thread.turn.start",
10853 commandId: CommandId.make("cmd-bootstrap-turn-start-non-repo"),
10854 threadId: ThreadId.make("thread-bootstrap-non-repo"),
10855 message: {
10856 messageId: MessageId.make("msg-bootstrap-non-repo"),
10857 role: "user",
10858 text: "hello",
10859 attachments: [],
10860 },
10861 modelSelection: defaultModelSelection,
10862 runtimeMode: "full-access",
10863 interactionMode: "default",
10864 bootstrap: {
10865 createThread: {
10866 projectId: defaultProjectId,
10867 title: "Bootstrap Thread",
10868 modelSelection: defaultModelSelection,
10869 runtimeMode: "full-access",
10870 interactionMode: "default",
10871 branch: null,
10872 worktreePath: null,
10873 createdAt,
10874 },
10875 prepareWorktree: {
10876 projectCwd: "/tmp/project",
10877 baseBranch: "main",
10878 branch: "t3code/bootstrap-refName",
10879 },
10880 runSetupScript: true,
10881 },
10882 createdAt,
10883 }),
10884 ),
10885 );
10886
10887 assert.equal(response.sequence, 2);
10888 assert.equal(createWorktree.mock.calls.length, 0);
10889 assert.deepEqual(
10890 dispatchedCommands.map((command) => command.type),
10891 ["thread.create", "thread.turn.start"],
10892 );
10893 const finalCommand = dispatchedCommands[1];
10894 assertTrue(finalCommand?.type === "thread.turn.start");
10895 if (finalCommand?.type === "thread.turn.start") {
10896 assert.equal(finalCommand.bootstrap, undefined);
10897 }
10898 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
10899 );
10900
10901 it.effect("falls back to the project checkout when the worktree base has no commit", () =>
10902 Effect.gen(function* () {
10903 const dispatchedCommands: Array<OrchestrationCommand> = [];
10904 const createWorktree = vi.fn(
10905 (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[0]) =>
10906 Effect.die(new Error("createWorktree must not run without a base commit")),
10907 );
10908
10909 yield* buildAppUnderTest({
10910 layers: {
10911 vcsDriver: {
10912 isInsideWorkTree: () => Effect.succeed(true),
10913 },
10914 gitVcsDriver: {
10915 execute: () =>
10916 Effect.succeed({
10917 ...SUCCESSFUL_GIT_EXECUTION,
10918 exitCode: ChildProcessSpawner.ExitCode(128),
10919 stderr: "fatal: Needed a single revision",
10920 }),
10921 createWorktree,
10922 },
10923 orchestrationEngine: {
10924 dispatch: (command) =>
10925 Effect.sync(() => {
10926 dispatchedCommands.push(command);
10927 return { sequence: dispatchedCommands.length };
10928 }),
10929 readEvents: () => Stream.empty,
10930 },
10931 },
10932 });
10933
10934 const createdAt = "2026-01-01T00:00:00.000Z";
10935 const wsUrl = yield* getWsServerUrl("/ws");
10936 const response = yield* Effect.scoped(
10937 withWsRpcClient(wsUrl, (client) =>
10938 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
10939 type: "thread.turn.start",
10940 commandId: CommandId.make("cmd-bootstrap-turn-start-unborn-head"),
10941 threadId: ThreadId.make("thread-bootstrap-unborn-head"),
10942 message: {
10943 messageId: MessageId.make("msg-bootstrap-unborn-head"),
10944 role: "user",
10945 text: "hello",
10946 attachments: [],
10947 },
10948 modelSelection: defaultModelSelection,
10949 runtimeMode: "full-access",
10950 interactionMode: "default",
10951 bootstrap: {
10952 createThread: {
10953 projectId: defaultProjectId,
10954 title: "Bootstrap Thread",
10955 modelSelection: defaultModelSelection,
10956 runtimeMode: "full-access",
10957 interactionMode: "default",
10958 branch: "main",
10959 worktreePath: null,
10960 createdAt,
10961 },
10962 prepareWorktree: {
10963 projectCwd: "/tmp/project",
10964 baseBranch: "main",
10965 branch: "t3code/bootstrap-refName",
10966 },
10967 runSetupScript: true,
10968 },
10969 createdAt,
10970 }),
10971 ),
10972 );
10973
10974 assert.equal(response.sequence, 2);
10975 assert.equal(createWorktree.mock.calls.length, 0);
10976 assert.deepEqual(
10977 dispatchedCommands.map((command) => command.type),
10978 ["thread.create", "thread.turn.start"],
10979 );
10980 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
10981 );
10982
10807 it.effect("records setup-script failures without aborting bootstrap turn start", () =>10983 it.effect("records setup-script failures without aborting bootstrap turn start", () =>
10808 Effect.gen(function* () {10984 Effect.gen(function* () {
10809 const dispatchedCommands: Array<OrchestrationCommand> = [];10985 const dispatchedCommands: Array<OrchestrationCommand> = [];
...@@ -10834,7 +11010,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -10834,7 +11010,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1083411010
10835 yield* buildAppUnderTest({11011 yield* buildAppUnderTest({
10836 layers: {11012 layers: {
11013 vcsDriver: {
11014 isInsideWorkTree: () => Effect.succeed(true),
11015 },
10837 gitVcsDriver: {11016 gitVcsDriver: {
11017 execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION),
10838 createWorktree,11018 createWorktree,
10839 },11019 },
10840 orchestrationEngine: {11020 orchestrationEngine: {
...@@ -10939,7 +11119,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -10939,7 +11119,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1093911119
10940 yield* buildAppUnderTest({11120 yield* buildAppUnderTest({
10941 layers: {11121 layers: {
11122 vcsDriver: {
11123 isInsideWorkTree: () => Effect.succeed(true),
11124 },
10942 gitVcsDriver: {11125 gitVcsDriver: {
11126 execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION),
10943 createWorktree,11127 createWorktree,
10944 },11128 },
10945 orchestrationEngine: {11129 orchestrationEngine: {
...@@ -11044,7 +11228,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -11044,7 +11228,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1104411228
11045 const config = yield* buildAppUnderTest({11229 const config = yield* buildAppUnderTest({
11046 layers: {11230 layers: {
11231 vcsDriver: {
11232 isInsideWorkTree: () => Effect.succeed(true),
11233 },
11047 gitVcsDriver: {11234 gitVcsDriver: {
11235 execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION),
11048 createWorktree,11236 createWorktree,
11049 },11237 },
11050 orchestrationEngine: {11238 orchestrationEngine: {
...@@ -11253,7 +11441,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -11253,7 +11441,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1125311441
11254 yield* buildAppUnderTest({11442 yield* buildAppUnderTest({
11255 layers: {11443 layers: {
11444 vcsDriver: {
11445 isInsideWorkTree: () => Effect.succeed(true),
11446 },
11256 gitVcsDriver: {11447 gitVcsDriver: {
11448 execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION),
11257 createWorktree,11449 createWorktree,
11258 },11450 },
11259 orchestrationEngine: {11451 orchestrationEngine: {
apps/server/src/ws.ts+47-32
...@@ -1122,6 +1122,49 @@ const makeWsRpcLayer = (...@@ -1122,6 +1122,49 @@ const makeWsRpcLayer = (
1122 });1122 });
11231123
1124 const bootstrapProgram = Effect.gen(function* () {1124 const bootstrapProgram = Effect.gen(function* () {
1125 const prepareWorktree = bootstrap?.prepareWorktree;
1126 let shouldPrepareWorktree = prepareWorktree
1127 ? yield* gitWorkflow.isRepository(prepareWorktree.projectCwd)
1128 : false;
1129 let worktreeBaseRef = prepareWorktree?.baseBranch ?? null;
1130
1131 if (prepareWorktree && shouldPrepareWorktree) {
1132 // "Start from origin" is a stored default; repos without the
1133 // requested remote branch fall back to the local base branch.
1134 const startFromOrigin =
1135 prepareWorktree.startFromOrigin === true &&
1136 (yield* gitWorkflow.remoteExists({
1137 cwd: prepareWorktree.projectCwd,
1138 remoteName: "origin",
1139 }));
1140 if (startFromOrigin) {
1141 yield* gitWorkflow.fetchRemote({
1142 cwd: prepareWorktree.projectCwd,
1143 remoteName: "origin",
1144 });
1145 const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({
1146 cwd: prepareWorktree.projectCwd,
1147 refName: prepareWorktree.baseBranch,
1148 remoteName: "origin",
1149 });
1150 if (remoteBaseExists) {
1151 const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({
1152 cwd: prepareWorktree.projectCwd,
1153 refName: prepareWorktree.baseBranch,
1154 fallbackRemoteName: "origin",
1155 });
1156 worktreeBaseRef = resolvedRemoteBase.commitSha;
1157 }
1158 }
1159
1160 const resolvedWorktreeBaseRef = worktreeBaseRef ?? prepareWorktree.baseBranch;
1161 shouldPrepareWorktree = yield* gitWorkflow.hasCommit({
1162 cwd: prepareWorktree.projectCwd,
1163 refName: resolvedWorktreeBaseRef,
1164 });
1165 worktreeBaseRef = resolvedWorktreeBaseRef;
1166 }
1167
1125 if (bootstrap?.createThread) {1168 if (bootstrap?.createThread) {
1126 const created = yield* dispatchFromClient({1169 const created = yield* dispatchFromClient({
1127 type: "thread.create",1170 type: "thread.create",
...@@ -1144,40 +1187,12 @@ const makeWsRpcLayer = (...@@ -1144,40 +1187,12 @@ const makeWsRpcLayer = (
1144 createdThread = true;1187 createdThread = true;
1145 }1188 }
11461189
1147 if (bootstrap?.prepareWorktree) {1190 if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) {
1148 let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch;
1149 // "Start from origin" is a stored default; repos without the
1150 // requested remote branch fall back to the local base branch.
1151 const startFromOrigin =
1152 bootstrap.prepareWorktree.startFromOrigin === true &&
1153 (yield* gitWorkflow.remoteExists({
1154 cwd: bootstrap.prepareWorktree.projectCwd,
1155 remoteName: "origin",
1156 }));
1157 if (startFromOrigin) {
1158 yield* gitWorkflow.fetchRemote({
1159 cwd: bootstrap.prepareWorktree.projectCwd,
1160 remoteName: "origin",
1161 });
1162 const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({
1163 cwd: bootstrap.prepareWorktree.projectCwd,
1164 refName: bootstrap.prepareWorktree.baseBranch,
1165 remoteName: "origin",
1166 });
1167 if (remoteBaseExists) {
1168 const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({
1169 cwd: bootstrap.prepareWorktree.projectCwd,
1170 refName: bootstrap.prepareWorktree.baseBranch,
1171 fallbackRemoteName: "origin",
1172 });
1173 worktreeBaseRef = resolvedRemoteBase.commitSha;
1174 }
1175 }
1176 const worktree = yield* gitWorkflow.createWorktree({1191 const worktree = yield* gitWorkflow.createWorktree({
1177 cwd: bootstrap.prepareWorktree.projectCwd,1192 cwd: prepareWorktree.projectCwd,
1178 refName: worktreeBaseRef,1193 refName: worktreeBaseRef,
1179 newRefName: bootstrap.prepareWorktree.branch,1194 newRefName: prepareWorktree.branch,
1180 baseRefName: bootstrap.prepareWorktree.baseBranch,1195 baseRefName: prepareWorktree.baseBranch,
1181 path: null,1196 path: null,
1182 });1197 });
1183 targetWorktreePath = worktree.worktree.path;1198 targetWorktreePath = worktree.worktree.path;