Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1440,6 +1440,65 @@ effectIt.effect("does not resume a continuation stopped while recovery was in fl
}),
);

effectIt.effect.each([["node_modules"], ["node_modules", ".next"]])(
"recreates a managed worktree folder that holds only %s",
(leftovers) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-start-" });
const worktreePath = path.join(workspaceRoot, "project", "worktree");
// What a junction-skipping `git worktree remove` leaves behind: no `.git`, no files.
for (const leftover of leftovers) {
yield* fileSystem.makeDirectory(path.join(worktreePath, leftover), { recursive: true });
}
const harness = makeLocalCommandHarness({
text: "Continue",
worktreePath,
worktreesDir: workspaceRoot,
fileSystem,
failReadsAfterRunning: true,
});
// `git worktree add` refuses a non-empty target, so the leftover must be gone first.
harness.createWorktree.mockImplementation(() =>
fileSystem.exists(worktreePath).pipe(
Effect.tap((exists) => Effect.sync(() => expect(exists).toBe(false))),
Effect.as({} as never),
Effect.orDie,
),
);
yield* harness.start;
expect(harness.createWorktree).toHaveBeenCalledWith({
cwd: "/tmp/native-account-project",
refName: "feature/restore",
path: worktreePath,
});
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

effectIt.effect.each(["notes.md", ".next"])(
"leaves a managed worktree folder holding a %s file untouched",
(userFile) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-start-" });
const worktreePath = path.join(workspaceRoot, "project", "worktree");
yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), { recursive: true });
yield* fileSystem.writeFileString(path.join(worktreePath, userFile), "keep me");
const harness = makeLocalCommandHarness({
text: "Continue",
worktreePath,
worktreesDir: workspaceRoot,
fileSystem,
failReadsAfterRunning: true,
});
yield* harness.start;
expect(harness.createWorktree).not.toHaveBeenCalled();
expect(yield* fileSystem.readFileString(path.join(worktreePath, userFile))).toBe("keep me");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

effectIt.effect.each(["cleanup-first", "startup-first"])(
"coordinates provider startup with %s",
(order) =>
Expand Down
36 changes: 30 additions & 6 deletions apps/server/src/orchestration-v2/ProviderTurnStartService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ const isProviderAdapterOpenSessionError = Schema.is(
ProviderAdapter.ProviderAdapterOpenSessionError,
);

// Folders `git worktree remove` can leave behind when they are junctions or locked.
const LEFTOVER_WORKTREE_ENTRIES = new Set(["node_modules", ".next"]);

/**
* Claude refuses these before it reads the prompt: replacing a process running background work,
* launch arguments whose appended system prompt or settings cannot be read, or MCP registration
Expand Down Expand Up @@ -638,7 +641,29 @@ export const layer: Layer.Layer<
const exists = yield* fileSystem
.exists(worktreePath)
.pipe(Effect.orElseSucceed(() => true));
if (!exists && (yield* isManagedWorktreePath(worktreePath))) {
const managed = yield* isManagedWorktreePath(worktreePath);
// A `git worktree remove` that skips a junctioned `node_modules` leaves a folder with no
// checkout. Only those leftover directories are cleared, never user files.
const leftover =
exists &&
managed &&
!(yield* fileSystem
.exists(path.join(worktreePath, ".git"))
.pipe(Effect.orElseSucceed(() => true))) &&
(yield* fileSystem.readDirectory(worktreePath).pipe(
Effect.flatMap((entries) =>
Effect.forEach(entries, (entry) =>
LEFTOVER_WORKTREE_ENTRIES.has(entry)
? fileSystem
.stat(path.join(worktreePath, entry))
.pipe(Effect.map((info) => info.type === "Directory"))
: Effect.succeed(false),
),
),
Effect.map((leftovers) => leftovers.every(Boolean)),
Effect.orElseSucceed(() => false),
));
if (managed && (!exists || leftover)) {
const project = yield* projects.getById(projection.thread.projectId).pipe(
Effect.map(Option.getOrUndefined),
Effect.orElseSucceed(() => undefined),
Expand All @@ -650,11 +675,10 @@ export const layer: Layer.Layer<
branch,
});
const recreateResult = yield* Effect.result(
recreateMissingWorktree({
cwd: project.workspaceRoot,
worktreePath,
branch,
}).pipe(
(leftover ? fileSystem.remove(worktreePath, { recursive: true }) : Effect.void).pipe(
Effect.andThen(
recreateMissingWorktree({ cwd: project.workspaceRoot, worktreePath, branch }),
),
Effect.mapError((cause) =>
cause._tag === "WorktreeRecreateNoBaseError"
? cause
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/storageCleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,25 @@ it.live("preserves late tracked edits when submodules were never initialized", (
),
);

it.live("keeps a managed worktree folder left with only node_modules", () =>
runCleanupTest(
Effect.gen(function* () {
const { service, fs, repo, worktree, command } = yield* cleanupFixture();
yield* fs.makeDirectory(`${worktree}/node_modules/pkg`, { recursive: true });
for (const entry of yield* fs.readDirectory(worktree)) {
if (entry !== "node_modules") yield* fs.remove(`${worktree}/${entry}`, { recursive: true });
}
yield* command(repo, ["worktree", "prune"]);
const report = yield* service.runNow;
expect(report.entries.find((entry) => entry.path === worktree)).toMatchObject({
outcome: "kept",
reason: "Not a linked worktree",
});
expect(yield* fs.exists(`${worktree}/node_modules/pkg`)).toBe(true);
}),
),
);

describe("subagent worktree ownership", () => {
it.effect.each([
"child",
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/storageCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,7 +846,8 @@ const make = Effect.gen(function* () {
return "worktree is a symbolic link";
if (!roots.some((root) => inside(root, realPath)))
return "outside the managed worktree folder";
if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File")
const gitPath = path.join(worktreePath, ".git");
if (!(yield* fs.exists(gitPath)) || (yield* fs.stat(gitPath)).type !== "File")
return "not a linked worktree";
return null;
});
Expand Down
Loading