Skip to content

Commit 65b285a

Browse files
committed
fix(server): run a worktree request against a non-repository project as a root launch
A scheduled task that asked for a new worktree on the Scratch project could never start. Scratch is a plain folder rather than a checkout, so git refused every provisioning step: "Failed to resolve the VCS driver for this Git command." Preparation runs in a background fiber, so the launch itself succeeded and the task recorded a successful run while the thread died with the provider never started. Scratch threads already get a dated folder of their own when they launch at the project root, but that only fired for an exact root strategy. A worktree request bypassed it and went straight to git. Resolving the strategy before it is recorded on the run makes both paths agree: a worktree request on a project with no repository becomes a root launch and claims a folder like any other. Detection failures keep the requested strategy, so the real git error still surfaces rather than being swallowed. Commit made by Space Bunny Free in opencode on behalf of Kriday.
1 parent 3e6b450 commit 65b285a

2 files changed

Lines changed: 72 additions & 3 deletions

File tree

‎apps/server/src/orchestration-v2/ThreadLaunchService.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
CommandId,
1818
DEFAULT_SERVER_SETTINGS,
1919
GitCommandError,
20+
GitManagerError,
2021
MessageId,
2122
ProjectId,
2223
ProviderDriverKind,
@@ -99,6 +100,7 @@ interface HarnessOptions {
99100
readonly managedFolders?: Layer.Layer<ManagedProjectFolders.ManagedProjectFolders>;
100101
readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"];
101102
readonly fetchRemote?: GitWorkflow.GitWorkflowService["Service"]["fetchRemote"];
103+
readonly isRepository?: GitWorkflow.GitWorkflowService["Service"]["isRepository"];
102104
readonly renameBranch?: GitWorkflow.GitWorkflowService["Service"]["renameBranch"];
103105
readonly runSetup?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"];
104106
readonly generateTitle?: TextGeneration.TextGeneration["Service"]["generateThreadTitle"];
@@ -167,6 +169,7 @@ function makeHarness(options: HarnessOptions = {}) {
167169
createWorktree,
168170
renameBranch,
169171
fetchRemote: options.fetchRemote ?? (() => Effect.void),
172+
isRepository: options.isRepository ?? (() => Effect.succeed(true)),
170173
remoteExists: () => Effect.succeed(true),
171174
remoteBranchExists: () => Effect.succeed(true),
172175
removeWorktree,
@@ -1085,6 +1088,62 @@ it.effect("runs a Scratch thread launched at the root in its own folder", () =>
10851088
}),
10861089
);
10871090

1091+
it.effect("runs a worktree request against a non-repository project as a root launch", () =>
1092+
Effect.gen(function* () {
1093+
// Only `projectId` stands in for the Scratch project here.
1094+
const harness = makeHarness({
1095+
isRepository: () => Effect.succeed(false),
1096+
managedFolders: Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
1097+
namedProjectsRoot: "/projects",
1098+
folderForThread: (input) =>
1099+
Effect.sync(() =>
1100+
input.projectId === projectId ? Option.some(`/scratch/folder-worktree`) : Option.none(),
1101+
),
1102+
}),
1103+
});
1104+
yield* Effect.gen(function* () {
1105+
const launches = yield* ThreadLaunch.ThreadLaunchService;
1106+
const launched = yield* launches.launch(
1107+
launchInput({
1108+
command: "command:launch:scratch-worktree",
1109+
thread: "thread:launch:scratch-worktree",
1110+
message: "Review this",
1111+
workspace: { type: "worktree", baseRef: "main", startFromOrigin: true },
1112+
}),
1113+
);
1114+
assert.equal(launched.projection.thread.worktreePath, "/scratch/folder-worktree");
1115+
assert.isNull(launched.projection.thread.branch);
1116+
yield* waitUntil(() => Effect.sync(() => harness.runSetup.mock.calls.length === 1));
1117+
assert.equal(harness.runSetup.mock.calls[0]?.[0]?.worktreePath, "/scratch/folder-worktree");
1118+
assert.equal(harness.createWorktree.mock.calls.length, 0);
1119+
}).pipe(Effect.provide(harness.layer));
1120+
}),
1121+
);
1122+
1123+
it.effect("keeps a worktree request when repository detection fails", () =>
1124+
Effect.gen(function* () {
1125+
const harness = makeHarness({
1126+
isRepository: () =>
1127+
Effect.fail(
1128+
new GitManagerError({ operation: "isRepository", cwd: "/repo", detail: "unreadable" }),
1129+
),
1130+
});
1131+
yield* Effect.gen(function* () {
1132+
const launches = yield* ThreadLaunch.ThreadLaunchService;
1133+
yield* launches.launch(
1134+
launchInput({
1135+
command: "command:launch:detect-failed",
1136+
thread: "thread:launch:detect-failed",
1137+
message: "Build the feature",
1138+
workspace: { type: "worktree", baseRef: "main" },
1139+
}),
1140+
);
1141+
yield* waitUntil(() => Effect.sync(() => harness.createWorktree.mock.calls.length === 1));
1142+
assert.equal(harness.createWorktree.mock.calls.length, 1);
1143+
}).pipe(Effect.provide(harness.layer));
1144+
}),
1145+
);
1146+
10881147
it.effect("names the worktree itself when the client provides no branch", () =>
10891148
Effect.gen(function* () {
10901149
const harness = makeHarness();

‎apps/server/src/orchestration-v2/ThreadLaunchService.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -726,10 +726,20 @@ const make = Effect.gen(function* () {
726726
yield* validateReusableThread(input, candidateThreadId);
727727
}
728728

729+
// A worktree needs a repository. The Scratch project is a folder, not a
730+
// checkout, so a worktree request there becomes a root launch, which the
731+
// block below answers with a folder of its own. Resolved before the
732+
// strategy is recorded on the run, so a retry replays what happened.
733+
const requestedStrategy: ThreadLaunchWorkspaceStrategy =
734+
input.workspaceStrategy.type === "worktree" &&
735+
!(yield* git.isRepository(project.workspaceRoot).pipe(Effect.orElseSucceed(() => true)))
736+
? { type: "root" }
737+
: input.workspaceStrategy;
738+
729739
// A Scratch thread launched at the project root runs in a folder of its
730740
// own. Only the first attempt claims one; a retry replays its create.
731741
const workspaceStrategy: ThreadLaunchWorkspaceStrategy =
732-
input.workspaceStrategy.type === "root" && Option.isNone(launchReceipt)
742+
requestedStrategy.type === "root" && Option.isNone(launchReceipt)
733743
? Option.match(
734744
yield* managedFolders
735745
.folderForThread({
@@ -739,11 +749,11 @@ const make = Effect.gen(function* () {
739749
})
740750
.pipe(Effect.mapError(mapError(input, "provision-worktree", candidateThreadId))),
741751
{
742-
onNone: () => input.workspaceStrategy,
752+
onNone: () => requestedStrategy,
743753
onSome: (worktreePath) => ({ type: "existing_worktree", worktreePath }),
744754
},
745755
)
746-
: input.workspaceStrategy;
756+
: requestedStrategy;
747757
const initialBranch = workspaceStrategy.branch ?? null;
748758
const initialWorktreePath =
749759
workspaceStrategy.type === "existing_worktree" ? workspaceStrategy.worktreePath : null;

0 commit comments

Comments
 (0)