diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index ae4f43b5fb37..36182a983de2 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -30,6 +30,7 @@ import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; +import { WorktreeInventorySection } from "./components/WorktreeInventorySection"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; import { planMobileScopedSettingsClear, @@ -230,6 +231,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { (target) => target.environment.serverConfig.environment.capabilities.threadRestartContinuation === true, ); + const worktreeTargets = targets.filter( + (target) => + target.environment.serverConfig.environment.capabilities.worktreeManagement === true, + ); const disabledFor = (key: string) => disabled || (projectSelected && @@ -378,7 +383,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { onValueChange={(value) => write({ defaultAutoPull: value })} /> - + write({ newWorktreesStartFromOrigin: value })} /> + {/* Follows the filter: each selected environment, narrowed to + the selected project. Older servers have no inventory. */} + {worktreeTargets.map((target) => ( + 1 + ? `Worktrees on ${target.environment.label}` + : "Worktrees" + } + environmentId={target.environment.environmentId} + projectId={target.projectId} + /> + ))} ) : null} diff --git a/apps/mobile/src/features/settings/components/WorktreeInventorySection.tsx b/apps/mobile/src/features/settings/components/WorktreeInventorySection.tsx new file mode 100644 index 000000000000..0a9119e6d4cc --- /dev/null +++ b/apps/mobile/src/features/settings/components/WorktreeInventorySection.tsx @@ -0,0 +1,266 @@ +import { + confirmWorktreeRemoval, + formatWorktreeAge, + groupWorktreesByProject, + NO_CONFIRMED_WORKTREE_REMOVALS, + primaryLinkedThread, + visibleWorktrees, + worktreeBranchLabel, + worktreeGroupSummary, + worktreeIgnoredNote, + worktreeInventoryRefreshKey, + worktreeRemovalConfirmation, + worktreeRemovalOutcome, + worktreeStateLabel, +} from "@t3tools/client-runtime/state/worktrees"; +import type { EnvironmentId, ProjectId, WorktreeInfo } from "@t3tools/contracts"; +import { useEffect, useRef, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { Alert, Pressable, View } from "react-native"; + +import { SymbolView } from "../../../components/AppSymbol"; +import { AppText as Text } from "../../../components/AppText"; +import { showConfirmDialog } from "../../../components/ConfirmDialogHost"; +import { cn } from "../../../lib/cn"; +import { useEnvironmentQuery } from "../../../state/query"; +import { useAtomCommand } from "../../../state/use-atom-command"; +import { worktreeEnvironment } from "../../../state/worktrees"; +import { SettingsSection } from "./SettingsSection"; + +function threadLine(worktree: WorktreeInfo): string { + const { thread, otherCount } = primaryLinkedThread(worktree); + if (thread === null) return "No linked threads"; + const title = thread.title || "Untitled thread"; + const label = thread.status === "archived" ? `Archived: ${title}` : title; + return otherCount > 0 ? `${label} +${otherCount}` : label; +} + +/** Two lines per worktree: branch and state, then thread and age. */ +function WorktreeRow(props: { + readonly worktree: WorktreeInfo; + readonly nowMs: number; + readonly removing: boolean; + readonly disabled: boolean; + readonly separated: boolean; + readonly onRemove: (worktree: WorktreeInfo) => void; +}) { + const { worktree } = props; + const state = worktreeStateLabel(worktree); + const detail = [ + state === null ? worktreeIgnoredNote(worktree) : null, + worktree.lastActivityAt ? formatWorktreeAge(worktree.lastActivityAt, props.nowMs) : null, + ] + .filter((part) => part !== null) + .join(" ยท "); + return ( + + + + {worktreeBranchLabel(worktree)} + + {props.removing ? ( + Removing + ) : state === null ? ( + props.onRemove(worktree)} + className="active:opacity-70" + > + Remove + + ) : ( + + {state.text} + + )} + + + + {threadLine(worktree)} + + {detail.length > 0 ? ( + {detail} + ) : null} + + + ); +} + +/** + * One environment's managed worktrees, optionally narrowed to a project's + * repository. Labels, grouping and the reread decision come from the shared + * client runtime, so they match the web settings list. + */ +export function WorktreeInventorySection(props: { + readonly title: string; + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId | null; +}) { + const { environmentId, projectId } = props; + const canPrune = useAtomValue(worktreeEnvironment.prune.permissionAtom(environmentId)); + const inventory = useEnvironmentQuery( + worktreeEnvironment.list({ environmentId, input: projectId === null ? {} : { projectId } }), + ); + const { refresh: refreshInventory, isPending: inventoryPending } = inventory; + const listedRevision = inventory.data?.revision; + const streamRevision = useEnvironmentQuery( + worktreeEnvironment.changes({ environmentId, input: {} }), + ).data?.revision; + const pruneWorktrees = useAtomCommand(worktreeEnvironment.prune, { + label: "remove worktree", + reportFailure: true, + }); + const [pendingPath, setPendingPath] = useState(null); + const [removals, setRemovals] = useState(NO_CONFIRMED_WORKTREE_REMOVALS); + const [nowMs, setNowMs] = useState(() => Date.now()); + const lastRefreshKey = useRef(null); + const groups = groupWorktreesByProject(visibleWorktrees(inventory.data, removals)); + + useEffect(() => { + const refreshKey = worktreeInventoryRefreshKey({ + listedRevision, + streamRevision, + isPending: inventoryPending, + lastRefreshKey: lastRefreshKey.current, + }); + if (refreshKey === null) return; + lastRefreshKey.current = refreshKey; + refreshInventory(); + }, [refreshInventory, inventoryPending, listedRevision, streamRevision]); + + const refresh = () => { + setNowMs(Date.now()); + refreshInventory(); + }; + + const remove = (worktree: WorktreeInfo, allowIgnoredFiles: boolean) => { + setPendingPath(worktree.path); + void pruneWorktrees({ + environmentId, + input: { + projectId: worktree.projectId, + paths: [worktree.path], + ...(allowIgnoredFiles ? { allowIgnoredFiles } : {}), + }, + }) + .then((result) => { + if (result._tag !== "Success") return; + const outcome = worktreeRemovalOutcome(result.value); + if (outcome.removed) { + setRemovals((current) => confirmWorktreeRemoval(current, listedRevision, worktree.path)); + } else { + Alert.alert(`Kept ${worktreeBranchLabel(worktree)}`, outcome.message); + } + }) + .finally(() => { + setPendingPath(null); + refresh(); + }); + }; + + const confirmRemove = (worktree: WorktreeInfo) => { + if (!canPrune || pendingPath !== null) return; + const confirmation = worktreeRemovalConfirmation(worktree); + // The ignored paths are part of what the user agrees to delete. + const message = [ + confirmation.message, + ...confirmation.ignoredFiles, + ...(confirmation.ignoredMoreCount > 0 ? [`and ${confirmation.ignoredMoreCount} more`] : []), + ].join("\n"); + const onConfirm = () => remove(worktree, confirmation.allowIgnoredFiles); + if (process.env.EXPO_OS === "ios") { + Alert.alert(confirmation.title, message, [ + { text: "Cancel", style: "cancel" }, + { text: "Remove worktree", style: "destructive", onPress: onConfirm }, + ]); + return; + } + showConfirmDialog({ + title: confirmation.title, + message, + confirmText: "Remove worktree", + destructive: true, + onConfirm, + }); + }; + + return ( + + + + } + > + {/* Rows from the last read stay up while a refresh runs or fails. */} + {inventory.error !== null ? ( + + + Couldn't read worktrees. Retry + + + ) : null} + {inventory.data === null ? ( + inventory.error === null ? ( + Reading worktrees + ) : null + ) : groups.length === 0 ? ( + No worktrees + ) : ( + groups.map((group, groupIndex) => ( + 0 && "border-t border-border-subtle")} + > + + + {group.projectTitle} + + + {worktreeGroupSummary(group)} + + + {group.worktrees.map((worktree, index) => ( + 0} + onRemove={confirmRemove} + /> + ))} + + )) + )} + + ); +} diff --git a/apps/mobile/src/state/worktrees.ts b/apps/mobile/src/state/worktrees.ts new file mode 100644 index 000000000000..3b1e0acb275a --- /dev/null +++ b/apps/mobile/src/state/worktrees.ts @@ -0,0 +1,5 @@ +import { createWorktreeEnvironmentAtoms } from "@t3tools/client-runtime/state/worktrees"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const worktreeEnvironment = createWorktreeEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 11756026f6d1..0cbdc01e4dd5 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -171,6 +171,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, + [WS_METHODS.vcsListWorktrees]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeWorktreeInventory]: AuthOrchestrationReadScope, [WS_METHODS.reviewGetDiffPreview]: AuthFilesystemReadScope, [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 60589ac9106f..7ca28d731440 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -251,6 +251,7 @@ export const make = Effect.gen(function* () { serverResolvedCommandContext: true, environmentIcon: true, projectCloneTracking: true, + worktreeManagement: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), // V2 restart recovery uses the environment-owned opt-in. The old diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 79053fbb65eb..3a8daf17d425 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -56,6 +56,7 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; +import * as WorktreeLifecycle from "../vcs/WorktreeLifecycle.ts"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as GitLabSourceControlProvider from "@t3tools/source-control-gitlab/server/GitLabSourceControlProvider"; import { @@ -837,6 +838,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { let remoteReads = 0; const workflowContext = yield* Layer.build( GitWorkflowService.layer.pipe( + Layer.provide(WorktreeLifecycle.layer), Layer.provide( Layer.succeed(GitManager.GitManager, { ...manager, diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 4617965803b9..5e95421bebc8 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -15,6 +15,7 @@ import * as GitManager from "./GitManager.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as WorktreeLifecycle from "../vcs/WorktreeLifecycle.ts"; function layer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; @@ -27,6 +28,7 @@ function layer(input: { ), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), Layer.provide(Layer.mock(GitManager.GitManager)({})), + Layer.provide(WorktreeLifecycle.layer), ); } @@ -136,6 +138,7 @@ describe("GitWorkflowService", () => { status, }), ), + Layer.provide(WorktreeLifecycle.layer), ); return Effect.gen(function* () { @@ -250,6 +253,7 @@ describe("GitWorkflowService", () => { Effect.scoped, Effect.provide( GitWorkflowService.layer.pipe( + Layer.provide(WorktreeLifecycle.layer), Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ resolve: () => Effect.succeed({ kind: "git" } as VcsDriverRegistry.VcsDriverHandle), diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index f7eddd7f3c3f..deae9db40147 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -31,6 +31,7 @@ import { import * as GitManager from "./GitManager.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as WorktreeLifecycle from "../vcs/WorktreeLifecycle.ts"; export class GitWorkflowService extends Context.Service< GitWorkflowService, @@ -103,6 +104,11 @@ export class GitWorkflowService extends Context.Service< { readonly commitSha: string; readonly remoteRefName: string }, GitCommandError >; + /** + * Rollback only: undoes a worktree the same operation just created. It + * runs none of the removal safeguards, so every other removal goes + * through WorktreeService. + */ readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; @@ -168,6 +174,7 @@ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; const gitManager = yield* GitManager.GitManager; + const worktreeLifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( operation: string, @@ -363,10 +370,11 @@ export const make = Effect.gen(function* () { "GitWorkflowService.resolvePullRequest", gitManager.resolvePullRequest, ), - preparePullRequestThread: routeGitManager( - "GitWorkflowService.preparePullRequestThread", - gitManager.preparePullRequestThread, - ), + preparePullRequestThread: (input) => + routeGitManager( + "GitWorkflowService.preparePullRequestThread", + gitManager.preparePullRequestThread, + )(input).pipe(Effect.tap(() => worktreeLifecycle.markInventoryChanged)), listRefs: (input) => detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe( Effect.flatMap((isGitRepository) => @@ -376,6 +384,7 @@ export const make = Effect.gen(function* () { createWorktree: (input, options) => ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( Effect.andThen(gitManager.createWorktree(input, options)), + Effect.tap(() => worktreeLifecycle.markInventoryChanged), ), listLocalBranchNames: (cwd) => ensureGitCommand("GitWorkflowService.listLocalBranchNames", cwd).pipe( @@ -400,6 +409,7 @@ export const make = Effect.gen(function* () { removeWorktree: (input) => ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), + Effect.tap(() => worktreeLifecycle.markInventoryChanged), ), pruneWorktrees: (input) => ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe( diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index c6fabe18c6fb..ab8e479287ec 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -2,23 +2,58 @@ import { OrchestratorMcpFailure } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as GitWorkflow from "../../../git/GitWorkflowService.ts"; import * as Project from "../../../project/ProjectService.ts"; -import { readThread, unavailable } from "../../threadAccess.ts"; +import { + assertFullAccess, + loadCaller, + readThread, + resolveProjectId, + unavailable, +} from "../../threadAccess.ts"; import * as Effect from "effect/Effect"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import * as WorktreeMcpService from "../../WorktreeMcpService.ts"; +import * as WorktreeService from "../../../vcs/WorktreeService.ts"; import { WorktreeToolkit } from "./tools.ts"; +const requireWorktreeCapability = Effect.gen(function* () { + const context = yield* McpInvocationContext.McpInvocationContext; + if (!context.capabilities.has("worktree")) + return yield* new OrchestratorMcpFailure({ + code: "capability_denied", + message: "This credential cannot inspect worktrees.", + }); +}); + const handlers = { + t3_worktree_inventory: McpToolAccess.readsAsCaller(() => + Effect.gen(function* () { + yield* requireWorktreeCapability; + const projectId = yield* resolveProjectId(yield* loadCaller(), undefined); + const worktrees = yield* WorktreeService.WorktreeService; + return yield* worktrees.listWorktrees({ projectId }).pipe(Effect.mapError(unavailable)); + }), + ), + t3_worktree_remove: McpToolAccess.actsAsCaller((input) => + Effect.gen(function* () { + yield* requireWorktreeCapability; + const context = yield* loadCaller(); + yield* assertFullAccess( + context, + "Removing worktrees requires a full-access/default calling thread.", + ); + const projectId = yield* resolveProjectId(context, undefined); + const worktrees = yield* WorktreeService.WorktreeService; + // Scoped to the caller's project; ignored files stay unless the agent opts in. + return yield* worktrees + .pruneWorktrees({ ...input, projectId }) + .pipe(Effect.mapError(unavailable)); + }), + ), t3_worktree_list: McpToolAccess.reads((input) => Effect.gen(function* () { - const context = yield* McpInvocationContext.McpInvocationContext; - if (!context.capabilities.has("worktree")) - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: "This credential cannot inspect worktrees.", - }); + yield* requireWorktreeCapability; const { threadId, ...refs } = input; const { projection: { thread }, diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 91257c2fdb80..e25d7e5d2fdc 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -13,6 +13,7 @@ import { HttpBody, HttpClient, HttpRouter } from "effect/http"; import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts"; import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; +import * as WorktreeService from "../../../vcs/WorktreeService.ts"; import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; @@ -35,6 +36,7 @@ import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts"; const layerStubServices = Layer.mergeAll( + Layer.mock(WorktreeService.WorktreeService)({}), Layer.mock(Orchestrator.OrchestratorV2)({}), Layer.mock(ProjectionStore.ProjectionStoreV2)({}), Layer.mock(DeviceService.DeviceService)({}), @@ -152,6 +154,14 @@ it.effect("production mcp layer lists worktree tools over http", () => const status = tools.find((tool) => tool.name === "t3_worktree_status"); expect(status?.annotations?.readOnlyHint).toBe(true); expect(status?.annotations?.destructiveHint).toBe(false); + // Reading the managed inventory is safe to auto-approve; removing a + // checkout is not. + const inventory = tools.find((tool) => tool.name === "t3_worktree_inventory"); + expect(inventory?.annotations?.readOnlyHint).toBe(true); + expect(inventory?.annotations?.destructiveHint).toBe(false); + const remove = tools.find((tool) => tool.name === "t3_worktree_remove"); + expect(remove?.annotations?.readOnlyHint).toBe(false); + expect(remove?.annotations?.destructiveHint).toBe(true); // MCP requires every tool input schema to be a top-level object schema. // A non-object schema (e.g. the anyOf produced by an empty diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index fda1fc9fa3c8..5d13aad54cea 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -3,6 +3,9 @@ import { OrchestratorMcpFailure, VcsListRefsInput, VcsListRefsResult, + VcsListWorktreesResult, + VcsPruneWorktreesInput, + VcsPruneWorktreesResult, WorktreeMcpHandoffInput, WorktreeMcpHandoffResult, WorktreeMcpStatusResult, @@ -16,6 +19,7 @@ import { Tool, Toolkit } from "effect/ai"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as WorktreeMcpService from "../../WorktreeMcpService.ts"; +import * as WorktreeService from "../../../vcs/WorktreeService.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, @@ -82,8 +86,49 @@ const WorktreeListTool = Tool.make("t3_worktree_list", { }) .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false); + +const worktreeInventoryDependencies = [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + WorktreeService.WorktreeService, +]; + +const WorktreeInventoryTool = Tool.make("t3_worktree_inventory", { + description: + "Needs an agent running inside a T3 thread. List the app-managed git worktrees of this thread's project, including detached ones: path, branch, linked threads, changed and ignored file counts, unpushed commits, and pruneBlockers, the reasons t3_worktree_remove would keep each one. Reads local state only and does not fetch. Use t3_worktree_list for branch refs.", + success: VcsListWorktreesResult, + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: worktreeInventoryDependencies, +}) + .annotate(Tool.Title, "List managed worktrees") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const WorktreeRemoveTool = Tool.make("t3_worktree_remove", { + description: + "Needs an agent running inside a T3 thread. Remove managed worktree checkouts of this thread's project by path, as listed by t3_worktree_inventory. Branches, checkpoints and thread history are kept, and a linked thread's checkout is recreated from its branch on its next turn. Never forces: a worktree with a running or queued turn, a live session or terminal, an open thread, uncommitted changes or unpushed commits is skipped and returned with its reason, so this thread's own worktree cannot be removed. A worktree holding ignored files (other than node_modules) is skipped unless allowIgnoredFiles is true; set it only after the user has seen those paths and agreed to delete them. Requires a full-access/default caller.", + parameters: Schema.Struct({ + paths: VcsPruneWorktreesInput.fields.paths, + allowIgnoredFiles: VcsPruneWorktreesInput.fields.allowIgnoredFiles, + }), + success: VcsPruneWorktreesResult, + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: worktreeInventoryDependencies, +}) + .annotate(Tool.Title, "Remove managed worktrees") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, false); + export const WorktreeToolkit = Toolkit.make( WorktreeHandoffTool, WorktreeStatusTool, WorktreeListTool, + WorktreeInventoryTool, + WorktreeRemoveTool, ); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 26a4b007b8b6..b1459af42e4c 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -161,6 +161,9 @@ const RPC_AGGREGATES = { [WS_METHODS.gitResolvePullRequest]: "git", [WS_METHODS.gitPreparePullRequestThread]: "git", [WS_METHODS.vcsListRefs]: "vcs", + [WS_METHODS.vcsListWorktrees]: "vcs", + [WS_METHODS.subscribeWorktreeInventory]: "vcs", + [WS_METHODS.vcsPruneWorktrees]: "vcs", [WS_METHODS.vcsCreateWorktree]: "vcs", [WS_METHODS.vcsRemoveWorktree]: "vcs", [WS_METHODS.vcsCreateRef]: "vcs", diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index da89b3bc6227..975de7c180b1 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -38,7 +38,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; +import * as WorktreeRevivalServiceTestkit from "../vcs/WorktreeRevivalService.testkit.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; @@ -143,7 +143,7 @@ const layerLive = RuntimeLayer.layer.pipe( Layer.provide(layerProviderInstanceRegistry), Layer.provide(ResetCreditCoordinator.layer), Layer.provide(layerBackgroundPolicy), - Layer.provide(ProviderTurnStartServiceTestkit.layer), + Layer.provide(WorktreeRevivalServiceTestkit.layerNoop), Layer.provide(layerPlatformTest), Layer.provide(McpProviderSessions.layer), ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 79e46c25dc8c..8904ec3d8d1f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -970,6 +970,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_thread_transfers", "mcp__t3-code__t3_worktree_status", "mcp__t3-code__t3_worktree_list", + "mcp__t3-code__t3_worktree_inventory", "mcp__t3-code__t3_project_list", "mcp__t3-code__t3_project_read", "mcp__t3-code__t3_thread_search", diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index c17888f5e053..67c51076395a 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -38,7 +38,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; +import * as WorktreeRevivalServiceTestkit from "../vcs/WorktreeRevivalService.testkit.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; @@ -115,7 +115,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi ); const layerLive = RuntimeLayer.layer.pipe( - Layer.provide(ProviderTurnStartServiceTestkit.layer), + Layer.provide(WorktreeRevivalServiceTestkit.layerNoop), Layer.provide(McpSessionRegistryTestkit.layer), Layer.provide(SqlitePersistence.layerMemory), Layer.provide(layerCheckpointStore), diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index ad0c6703c1d9..b546e8e61a17 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -33,13 +33,13 @@ import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry. import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as WorktreeRevivalServiceTestkit from "../vcs/WorktreeRevivalService.testkit.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; import { continueRestartedRun } from "./RestartContinuation.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; -import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -111,7 +111,7 @@ const layerTest = Layer.mergeAll(RuntimeLayer.layer, RuntimeLayer.layerEventSink normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot), }), ), - Layer.provide(ProviderTurnStartServiceTestkit.layer), + Layer.provide(WorktreeRevivalServiceTestkit.layerNoop), Layer.provide( Layer.succeed(ProjectEnrichmentService.ProjectEnrichmentService, { peek: () => diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index f3d3bb75ced6..aa34f18cf0e0 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -38,7 +38,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; +import * as WorktreeRevivalServiceTestkit from "../vcs/WorktreeRevivalService.testkit.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; import { GROK_MODEL_SELECTION } from "./testkit/fixtures/shared.ts"; @@ -114,7 +114,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi ); const layerLive = RuntimeLayer.layer.pipe( - Layer.provide(ProviderTurnStartServiceTestkit.layer), + Layer.provide(WorktreeRevivalServiceTestkit.layerNoop), Layer.provide(McpSessionRegistryTestkit.layer), Layer.provide(SqlitePersistence.layerMemory), Layer.provide(layerCheckpointStore), diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 0a39c5cf374a..bf245f0944ad 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -66,7 +66,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; -import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; +import * as WorktreeRevivalServiceTestkit from "../vcs/WorktreeRevivalService.testkit.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -207,7 +207,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi ), ); const layerOrchestration = RuntimeLayer.layer.pipe( - Layer.provide(ProviderTurnStartServiceTestkit.layer), + Layer.provide(WorktreeRevivalServiceTestkit.layerNoop), Layer.provide(layerMcpRegistry), Layer.provide(SqlitePersistence.layerMemory), Layer.provide(CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry))), diff --git a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts index c5eeeee6698e..698fb3724820 100644 --- a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts +++ b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts @@ -25,6 +25,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as WorktreeRevivalServiceTestkit from "../vcs/WorktreeRevivalService.testkit.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; @@ -172,6 +173,7 @@ const layerRuntime = (dbPath: string) => { pruneWorktrees: () => Effect.void, }), ), + Layer.provide(WorktreeRevivalServiceTestkit.layerNoop), Layer.provide(layerPlatform), ); }; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index d6c12fab7ee1..bcb80a1e69e8 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -13,19 +13,21 @@ import { RunId, ThreadId, ProjectId, + WorktreeMutationError, + type OrchestrationV2Run, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, OrchestrationV2DomainEvent, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as GitWorkflow from "../git/GitWorkflowService.ts"; -import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import * as EventSink from "./EventSink.ts"; @@ -37,6 +39,7 @@ import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnStart from "./ProviderTurnStartService.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; +import * as WorktreeRevivalService from "../vcs/WorktreeRevivalService.ts"; const isDomainEvent = Schema.is(OrchestrationV2DomainEvent); @@ -56,12 +59,7 @@ it("does not commit running state when inherited background routing cannot be re "checkpoint_scope_provider_turn_start_projection_failure", ); const projection = { - thread: { - id: threadId, - projectId: ProjectId.make("project_provider_turn_start_projection_failure"), - branch: "feature/restore", - worktreePath: "/tmp/missing-provider-turn-start-worktree", - }, + thread: { id: threadId, branch: null, worktreePath: null }, runs: [ { id: runId, @@ -87,22 +85,12 @@ it("does not commit running state when inherited background routing cannot be re Effect.succeed({ committed: true, storedEvents: [] } as never), ); const startRootRun = vi.fn(() => Effect.void); - const pruneWorktrees = vi.fn(() => Effect.void); - const createWorktree = vi.fn(() => Effect.succeed({} as never)); const layer = ProviderTurnStart.layer.pipe( Layer.provide( Layer.mergeAll( Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, - Layer.succeed(FileSystem.FileSystem, { exists: () => Effect.succeed(false) } as never), - Layer.mock(GitWorkflow.GitWorkflowService)({ pruneWorktrees, createWorktree }), - Layer.mock(ProjectService.ProjectService)({ - getById: () => - Effect.succeed( - Option.some({ workspaceRoot: "/tmp/provider-turn-start-project" } as never), - ), - }), Layer.mock(ProjectionStore.ProjectionStoreV2)({ getTurnStartContext: () => { projectionReadCount += 1; @@ -131,6 +119,7 @@ it("does not commit running state when inherited background routing cannot be re }), Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }), Layer.mock(RuntimePolicy.RuntimePolicyV2)({}), + Layer.mock(WorktreeRevivalService.WorktreeRevivalService)({}), ), ), ); @@ -142,12 +131,6 @@ it("does not commit running state when inherited background routing cannot be re expect(error._tag).toBe("ProviderTurnStartError"); expect(projectionReadCount).toBe(2); - expect(pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-turn-start-project" }); - expect(createWorktree).toHaveBeenCalledWith({ - cwd: "/tmp/provider-turn-start-project", - refName: "feature/restore", - path: "/tmp/missing-provider-turn-start-worktree", - }); expect(writeIfRunCurrent).not.toHaveBeenCalled(); expect(startRootRun).not.toHaveBeenCalled(); }).pipe(Effect.provide(layer), Effect.runPromise); @@ -491,9 +474,7 @@ function makeLocalCommandHarness(input: { }), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, - FileSystem.layerNoop({}), - Layer.mock(GitWorkflow.GitWorkflowService)({}), - Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(WorktreeRevivalService.WorktreeRevivalService)({}), Layer.mock(ProjectionStore.ProjectionStoreV2)({ getTurnStartContext: () => Effect.succeed({ @@ -855,3 +836,359 @@ for (const previousMessages of [[], ["/compact", " /COMPACT "]]) { }), ); } + +/** + * A worktree thread whose run is starting. `setGeneration` and `setRunStatus` + * let one layer instance observe several starts, since the per-runtime + * worktree generation lives in that instance. + */ +function makeWorktreeTurnStartFixture(input: { + readonly revival: "revived" | "unchanged" | "failed"; + readonly revivalGate?: Effect.Effect; + /** Whether `get` reports the session as already open. */ + readonly liveSession?: boolean; + /** Whether the adapter shares one session across provider threads. */ + readonly sharedSession?: boolean; +}) { + const threadId = ThreadId.make(`thread_worktree_turn_start_${input.revival}`); + const runId = RunId.make(`run_worktree_turn_start_${input.revival}`); + const attemptId = RunAttemptId.make(`attempt_worktree_turn_start_${input.revival}`); + const rootNodeId = NodeId.make(`node_worktree_turn_start_${input.revival}`); + const providerThreadId = ProviderThreadId.make( + `provider_thread_worktree_turn_start_${input.revival}`, + ); + const providerSessionId = ProviderSessionId.make( + `provider_session_worktree_turn_start_${input.revival}`, + ); + const messageId = MessageId.make(`message_worktree_turn_start_${input.revival}`); + const checkpointScopeId = CheckpointScopeId.make( + `checkpoint_scope_worktree_turn_start_${input.revival}`, + ); + const providerInstanceId = ProviderInstanceId.make(`provider_instance_${input.revival}`); + const order: string[] = []; + const providerThread = { + id: providerThreadId, + providerSessionId, + providerInstanceId, + nativeThreadRef: null, + handoffIds: [], + forkedFrom: null, + appThreadId: threadId, + }; + const projection = { + thread: { + id: threadId, + projectId: ProjectId.make(`project_worktree_turn_start_${input.revival}`), + branch: "feature/revival", + worktreePath: "/tmp/t3-worktrees/feature-revival", + }, + runs: [ + { + id: runId, + status: "starting", + rootNodeId, + activeAttemptId: attemptId, + providerThreadId, + userMessageId: messageId, + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "test-model" }, + ordinal: 1, + }, + ], + nodes: [{ id: rootNodeId, checkpointScopeId }], + attempts: [{ id: attemptId, providerTurnId: null }], + providerThreads: [providerThread], + providerSessions: [{ id: providerSessionId }], + providerTurns: [], + messages: [ + { + id: messageId, + text: "continue", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + ], + checkpointScopes: [{ id: checkpointScopeId }], + contextHandoffs: [], + contextTransfers: [], + turnItems: [], + subagents: [], + } as unknown as OrchestrationV2ThreadProjection; + let runStatus: OrchestrationV2Run["status"] = "starting"; + let generation = 0; + let boundWorktreePath = projection.thread.worktreePath; + let boundBranch = projection.thread.branch; + const currentProjection = (): OrchestrationV2ThreadProjection => ({ + ...projection, + runs: projection.runs.map((candidate) => + candidate.id === runId ? { ...candidate, status: runStatus } : candidate, + ), + }); + const session = { + driver: "claudeAgent", + instanceId: providerInstanceId, + providerSessionId, + providerSession: { + id: providerSessionId, + capabilities: { + sessions: { supportsMultipleProviderThreadsPerSession: input.sharedSession === true }, + }, + }, + ensureThread: () => Effect.succeed(providerThread), + resumeThread: () => Effect.succeed(providerThread), + forkThread: () => Effect.succeed(providerThread), + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime; + const open = vi.fn(() => + Effect.sync(() => { + order.push("open"); + return session; + }), + ); + const close = vi.fn(() => + Effect.sync(() => { + order.push("close"); + }), + ); + const get = vi.fn(() => + Effect.succeed( + input.liveSession === true + ? Option.some(session) + : Option.none(), + ), + ); + const reviveForThread = vi.fn(() => + Effect.sync(() => { + order.push("revive"); + }).pipe( + Effect.andThen(input.revivalGate ?? Effect.void), + Effect.andThen( + input.revival === "failed" + ? Effect.fail( + new WorktreeMutationError({ + operation: "revive", + stage: "missing_branch", + branch: "feature/revival", + }), + ) + : Effect.sync(() => ({ revived: input.revival === "revived", generation })), + ), + ), + ); + const startRootRun = vi.fn(() => + Effect.sync(() => { + order.push("start-root-run"); + }), + ); + const events: Array = []; + const layer = ProviderTurnStart.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), + Layer.mock(EventSink.EventSinkV2)({ + writeIfRunCurrent: ({ events: written }) => + Effect.sync(() => { + events.push(...written); + return { committed: true, storedEvents: [] } as never; + }), + }), + IdAllocator.layer, + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getTurnStartContext: () => + Effect.sync(() => ({ ...currentProjection(), hasConversation: true })), + getRuntimeRecoveryProjection: () => Effect.sync(currentProjection), + getThreadShell: () => + Effect.sync( + () => + ({ + ...projection.thread, + worktreePath: boundWorktreePath, + branch: boundBranch, + }) as unknown as OrchestrationV2ThreadShell, + ), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open, close, get }), + Layer.mock(ProviderAuthService.ProviderAuthService)({}), + Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }), + Layer.mock(RuntimePolicy.RuntimePolicyV2)({ + resolve: () => + Effect.succeed({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/tmp/t3-worktrees/feature-revival", + }), + }), + Layer.mock(WorktreeRevivalService.WorktreeRevivalService)({ reviveForThread }), + ), + ), + ); + return { + layer, + order, + events, + open, + close, + startRootRun, + start: Effect.flatMap(ProviderTurnStart.ProviderTurnStartServiceV2, (service) => + service.start({ threadId, runId }), + ), + setRunStatus: (status: OrchestrationV2Run["status"]) => { + runStatus = status; + }, + setGeneration: (next: number) => { + generation = next; + }, + /** Moves the thread to another checkout, as a metadata update would. */ + rebindWorktree: (worktreePath: string) => { + boundWorktreePath = worktreePath; + }, + renameBranch: (branch: string) => { + boundBranch = branch; + }, + }; +} + +effectIt.effect("restarts a per-thread provider session after reviving its worktree", () => + Effect.gen(function* () { + const fixture = makeWorktreeTurnStartFixture({ revival: "revived", liveSession: true }); + + yield* fixture.start.pipe(Effect.provide(fixture.layer)); + + expect(fixture.order).toEqual(["revive", "close", "open", "start-root-run"]); + }), +); + +effectIt.effect("keeps a shared provider session open after reviving a worktree", () => + Effect.gen(function* () { + const fixture = makeWorktreeTurnStartFixture({ + revival: "revived", + liveSession: true, + sharedSession: true, + }); + + yield* fixture.start.pipe(Effect.provide(fixture.layer)); + + expect(fixture.order).toEqual(["revive", "open", "start-root-run"]); + expect(fixture.close).not.toHaveBeenCalled(); + }), +); + +effectIt.effect("restarts a live session after another thread recreated its worktree", () => + Effect.gen(function* () { + const fixture = makeWorktreeTurnStartFixture({ revival: "unchanged", liveSession: true }); + + yield* Effect.gen(function* () { + yield* fixture.start; + fixture.setGeneration(1); + yield* fixture.start; + }).pipe(Effect.provide(fixture.layer)); + + expect(fixture.order).toEqual([ + "revive", + "open", + "start-root-run", + "revive", + "close", + "open", + "start-root-run", + ]); + }), +); + +effectIt.effect("fails the run with the reason when its worktree cannot be restored", () => + Effect.gen(function* () { + const fixture = makeWorktreeTurnStartFixture({ revival: "failed", liveSession: true }); + + yield* fixture.start.pipe(Effect.provide(fixture.layer)); + + expect(fixture.order).toEqual(["revive"]); + expect(fixture.events).toMatchObject([ + { + type: "turn-item.updated", + payload: { + type: "error", + title: "Worktree could not be restored", + failure: { + message: "Cannot recreate the worktree: branch 'feature/revival' no longer exists.", + }, + }, + }, + { type: "run.updated", payload: { status: "failed" } }, + { type: "run-attempt.updated", payload: { status: "failed" } }, + { type: "node.updated", payload: { status: "failed" } }, + ]); + }), +); + +effectIt.effect("does not open a provider session for a run superseded during revival", () => + Effect.gen(function* () { + const revivalStarted = yield* Deferred.make(); + const releaseRevival = yield* Deferred.make(); + const fixture = makeWorktreeTurnStartFixture({ + revival: "revived", + liveSession: true, + revivalGate: Deferred.succeed(revivalStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRevival)), + ), + }); + + const start = yield* fixture.start.pipe(Effect.provide(fixture.layer), Effect.forkChild); + yield* Deferred.await(revivalStarted); + fixture.setRunStatus("cancelled"); + yield* Deferred.succeed(releaseRevival, undefined); + yield* Fiber.join(start); + + expect(fixture.order).toEqual(["revive"]); + expect(fixture.close).not.toHaveBeenCalled(); + expect(fixture.open).not.toHaveBeenCalled(); + }), +); + +effectIt.effect("starts the turn when its branch is renamed during revival", () => + Effect.gen(function* () { + const revivalStarted = yield* Deferred.make(); + const releaseRevival = yield* Deferred.make(); + const fixture = makeWorktreeTurnStartFixture({ + revival: "unchanged", + revivalGate: Deferred.succeed(revivalStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRevival)), + ), + }); + const start = yield* fixture.start.pipe(Effect.provide(fixture.layer), Effect.forkChild); + yield* Deferred.await(revivalStarted); + fixture.renameBranch("feature/descriptive-name"); + yield* Deferred.succeed(releaseRevival, undefined); + yield* Fiber.join(start); + expect(fixture.startRootRun).toHaveBeenCalledOnce(); + }), +); + +effectIt.effect("fails the run when the thread moves to another worktree during revival", () => + Effect.gen(function* () { + const revivalStarted = yield* Deferred.make(); + const releaseRevival = yield* Deferred.make(); + const fixture = makeWorktreeTurnStartFixture({ + revival: "revived", + revivalGate: Deferred.succeed(revivalStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRevival)), + ), + }); + + const start = yield* fixture.start.pipe(Effect.provide(fixture.layer), Effect.forkChild); + yield* Deferred.await(revivalStarted); + fixture.rebindWorktree("/tmp/t3-worktrees/another"); + yield* Deferred.succeed(releaseRevival, undefined); + yield* Fiber.join(start); + + expect(fixture.open).not.toHaveBeenCalled(); + expect(fixture.events).toMatchObject([ + { + type: "turn-item.updated", + payload: { type: "error", title: "Workspace changed while the turn was starting" }, + }, + { type: "run.updated", payload: { status: "failed" } }, + { type: "run-attempt.updated", payload: { status: "failed" } }, + { type: "node.updated", payload: { status: "failed" } }, + ]); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts deleted file mode 100644 index 699adafaeb33..000000000000 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as GitWorkflow from "../git/GitWorkflowService.ts"; -import * as ProjectService from "../project/ProjectService.ts"; - -export const layer = Layer.merge( - Layer.mock(GitWorkflow.GitWorkflowService)({ - pruneWorktrees: () => Effect.void, - createWorktree: () => Effect.succeed({} as never), - }), - Layer.mock(ProjectService.ProjectService)({ - getById: () => Effect.succeed(Option.none()), - }), -); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index e2c905b1e17a..45726a1fa7b7 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -13,17 +13,13 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; -import * as GitWorkflowService from "../git/GitWorkflowService.ts"; -import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as EventSink from "./EventSink.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; @@ -49,6 +45,7 @@ import { pendingRestartCancelledBackgroundWork, restartCancelledBackgroundWorkNote, } from "./RestartBackgroundNote.ts"; +import * as WorktreeRevivalService from "../vcs/WorktreeRevivalService.ts"; export class ProviderTurnStartError extends Schema.TaggedError()( "ProviderTurnStartError", @@ -89,28 +86,31 @@ export const layer: Layer.Layer< | EventSink.EventSinkV2 | ContextHandoffService.ContextHandoffServiceV2 | IdAllocator.IdAllocatorV2 - | FileSystem.FileSystem - | GitWorkflowService.GitWorkflowService - | ProjectService.ProjectService | ProviderAuthService.ProviderAuthService | ProjectionStore.ProjectionStoreV2 | ProviderSessionManager.ProviderSessionManagerV2 | RunExecutionService.RunExecutionServiceV2 | RuntimePolicy.RuntimePolicyV2 + | WorktreeRevivalService.WorktreeRevivalService > = Layer.effect( ProviderTurnStartServiceV2, Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; - const fileSystem = yield* FileSystem.FileSystem; - const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; - const projects = yield* ProjectService.ProjectService; const providerAuth = yield* ProviderAuthService.ProviderAuthService; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const runExecution = yield* RunExecutionService.RunExecutionServiceV2; const runtimePolicy = yield* RuntimePolicy.RuntimePolicyV2; + const worktreeRevival = yield* WorktreeRevivalService.WorktreeRevivalService; + // The worktree generation each live provider runtime was opened against. A + // per-thread runtime whose worktree was recreated since then still holds the + // deleted directory as its cwd and must restart before its next turn. + const worktreeGenerationBySession = new WeakMap< + ProviderAdapter.ProviderAdapterV2SessionRuntime, + { readonly path: string; readonly generation: number } + >(); // These callbacks outlive startup while a run drains background work. Build // them outside start's scope so they cannot retain its full thread history. @@ -456,43 +456,6 @@ export const layer: Layer.Layer< return; } } - const { worktreePath, branch } = projection.thread; - if (worktreePath !== null && branch !== null) { - const exists = yield* fileSystem - .exists(worktreePath) - .pipe(Effect.orElseSucceed(() => true)); - if (!exists) { - const project = yield* projects.getById(projection.thread.projectId).pipe( - Effect.map(Option.getOrUndefined), - Effect.orElseSucceed(() => undefined), - ); - if (project !== undefined) { - yield* Effect.logWarning("provider turn start recreating missing worktree", { - threadId: projection.thread.id, - worktreePath, - branch, - }); - yield* gitWorkflow.pruneWorktrees({ cwd: project.workspaceRoot }).pipe( - Effect.andThen( - gitWorkflow.createWorktree({ - cwd: project.workspaceRoot, - refName: branch, - path: worktreePath, - }), - ), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("provider turn start failed to recreate worktree", { - threadId: projection.thread.id, - worktreePath, - cause: Cause.pretty(cause), - }), - ), - ); - } - } - } const selectInheritedBackgroundItems = ( current: ProjectionStore.ProjectionRuntimeRecoveryState, ): ReturnType => @@ -521,6 +484,91 @@ export const layer: Layer.Layer< thread: projection.thread, modelSelection: run.modelSelection, }); + let observedWorktreeGeneration: + | { readonly path: string; readonly generation: number } + | undefined; + if (projection.thread.worktreePath !== null && projection.thread.branch !== null) { + const worktreePath = projection.thread.worktreePath; + const revivalResult = yield* Effect.result( + worktreeRevival.reviveForThread({ + threadId: projection.thread.id, + projectId: projection.thread.projectId, + worktreePath, + branch: projection.thread.branch, + }), + ); + if (revivalResult._tag === "Failure") { + // A missing worktree that cannot be recreated, or a required setup + // script that failed, leaves nothing for the provider to run in. + // Settle the run with the reason; the next message tries again. + const revivalError = revivalResult.failure; + yield* settleRunBeforeStart({ + signal: "worktree-revival-failure", + status: "failed", + now: yield* DateTime.now, + providerInstanceId: run.providerInstanceId, + itemProviderThreadId: providerThread.id, + item: { + type: "error", + title: "Worktree could not be restored", + failure: makeProviderFailure({ + cause: revivalError, + message: revivalError.message, + class: "validation_error", + }), + }, + }); + return; + } + const revival = revivalResult.success; + // Revival can recreate the worktree and wait for project setup, so the + // run may have been cancelled or superseded meanwhile. + if (!(yield* isCurrentAttemptInStatus("starting"))) return; + // The thread can also have been moved to another checkout during that + // wait. Nothing was prepared there, so the next message starts over. + const reboundThread = yield* projectionStore + .getThreadShell(projection.thread.id) + .pipe(Effect.orElseSucceed(() => null)); + if (reboundThread !== null && reboundThread.worktreePath !== worktreePath) { + yield* settleRunBeforeStart({ + signal: "worktree-rebound", + status: "failed", + now: yield* DateTime.now, + providerInstanceId: run.providerInstanceId, + itemProviderThreadId: providerThread.id, + item: { + type: "error", + title: "Workspace changed while the turn was starting", + failure: makeProviderFailure({ + cause: "The thread's worktree changed before the provider started.", + message: "The thread moved to another worktree. Send the message again.", + class: "validation_error", + }), + }, + }); + return; + } + observedWorktreeGeneration = { path: worktreePath, generation: revival.generation }; + const liveSession = yield* providerSessions.get(providerSessionId); + const previousWorktreeGeneration = Option.isSome(liveSession) + ? worktreeGenerationBySession.get(liveSession.value) + : undefined; + // Only a per-thread provider process holds the worktree as its cwd. + // A session shared across threads (Codex) passes cwd per native + // thread, and closing it would fail every sibling's running turn. + const holdsWorktreeAsCwd = + Option.isSome(liveSession) && + !liveSession.value.providerSession.capabilities.sessions + .supportsMultipleProviderThreadsPerSession; + const worktreeChanged = + revival.revived || + (previousWorktreeGeneration !== undefined && + (previousWorktreeGeneration.path !== worktreePath || + previousWorktreeGeneration.generation !== revival.generation)); + if (holdsWorktreeAsCwd && worktreeChanged) { + yield* providerSessions.close(providerSessionId); + } + } const existingSessionProjection = projection.providerSessions.find( (candidate) => candidate.id === providerSessionId, ); @@ -587,6 +635,9 @@ export const layer: Layer.Layer< return; } const session = sessionResult.success; + if (observedWorktreeGeneration !== undefined) { + worktreeGenerationBySession.set(session, observedWorktreeGeneration); + } // Only the provider's own thread load fails the run on the last attempt; // store, id and handoff failures around it keep their typed errors. const loadFromProvider = ( diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 3d4bf4c2a64a..a18aed253a68 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -54,6 +54,7 @@ import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry. import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as WorktreeRevivalServiceTestkit from "../vcs/WorktreeRevivalService.testkit.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as Orchestrator from "./Orchestrator.ts"; @@ -272,6 +273,7 @@ const layerTest = Layer.mergeAll( EffectOutbox.layer, ThreadCommandExecutor.layer, ).pipe( + Layer.provide(WorktreeRevivalServiceTestkit.layerNoop), Layer.provide(McpSessionRegistryTestkit.layer), Layer.provide(McpProviderSessions.layer), Layer.provide(SqlitePersistence.layerMemory), @@ -285,6 +287,7 @@ const layerTest = Layer.mergeAll( ); const layerLegacyImportTest = RuntimeLayer.layer.pipe( + Layer.provide(WorktreeRevivalServiceTestkit.layerNoop), Layer.provide(McpSessionRegistryTestkit.layer), Layer.provide(McpProviderSessions.layer), Layer.provideMerge(SqlitePersistence.layerMemory), @@ -303,6 +306,7 @@ const layerProjectDeletionTest = Layer.mergeAll( RuntimeLayer.layerEventSink, ThreadCommandExecutor.layer, ).pipe( + Layer.provide(WorktreeRevivalServiceTestkit.layerNoop), Layer.provide( Layer.mock(ProjectEnrichmentService.ProjectEnrichmentService)({ peek: () => @@ -446,6 +450,7 @@ const layerSharedApplicationDataPlaneTest = Layer.mergeAll( RuntimeLayer.layerEventSink, RuntimeLayer.layerEventInfrastructure, ).pipe( + Layer.provide(WorktreeRevivalServiceTestkit.layerNoop), Layer.provide( Layer.mock(WorkspacePaths.WorkspacePaths)({ normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 27c1cc28ae5a..67d1f0755fbd 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -44,7 +44,7 @@ import * as ProviderSessionManager from "../ProviderSessionManager.ts"; import * as ProviderSwitchService from "../ProviderSwitchService.ts"; import * as ProviderTurnControlService from "../ProviderTurnControlService.ts"; import * as ProviderTurnStartService from "../ProviderTurnStartService.ts"; -import * as ProviderTurnStartServiceTestkit from "../ProviderTurnStartService.testkit.ts"; +import * as WorktreeRevivalServiceTestkit from "../../vcs/WorktreeRevivalService.testkit.ts"; import * as McpAppModelContext from "../../mcpApps/McpAppModelContext.ts"; import * as RunExecutionService from "../RunExecutionService.ts"; import * as RunFinalizationService from "../RunFinalizationService.ts"; @@ -499,7 +499,7 @@ export function layerWithRegistry( layerEffectWorkerProvided, layerEventSinkProvided, layerContinuationWorkerProvided, - ).pipe(Layer.provide(ProviderTurnStartServiceTestkit.layer), Layer.provide(NodeServices.layer)); + ).pipe(Layer.provide(WorktreeRevivalServiceTestkit.layerNoop), Layer.provide(NodeServices.layer)); // Build the daemon from the exact worker instance exposed alongside the // orchestrator. Keeping this acquisition in the replay layer makes the diff --git a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs index 203360ee9215..61acb1c8d06c 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs +++ b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs @@ -7,15 +7,11 @@ const mode = process.argv[3]; const withHandoff = mode.startsWith("handoff"); const require = NodeModule.createRequire(root + "/apps/server/package.json"); const load = (name) => import(NodeURL.pathToFileURL(require.resolve("effect/" + name))); -const [Effect, Layer, FileSystem] = await Promise.all([ - load("Effect"), - load("Layer"), - load("FileSystem"), -]); +const [Effect, Layer] = await Promise.all([load("Effect"), load("Layer")]); const app = (file) => import(NodeURL.pathToFileURL(root + "/apps/server/src/" + file + ".ts")); const providerCore = (file) => import(NodeURL.pathToFileURL(root + "/packages/provider-core/src/server/" + file + ".ts")); -const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project, Auth] = +const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Revival, Auth] = await Promise.all([ app("orchestration-v2/ProviderTurnStartService"), app("orchestration-v2/ProjectionStore"), @@ -25,8 +21,7 @@ const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project providerCore("IdAllocator"), app("orchestration-v2/EventSink"), app("orchestration-v2/ContextHandoffService"), - app("git/GitWorkflowService"), - app("project/ProjectService"), + app("vcs/WorktreeRevivalService"), app("provider/ProviderAuthService"), ]); let current; @@ -47,9 +42,7 @@ const session = { const dependencies = Layer.mergeAll( Layer.mock(Handoff.ContextHandoffServiceV2)({}), Id.layer, - FileSystem.layerNoop({}), - Layer.mock(Git.GitWorkflowService)({}), - Layer.mock(Project.ProjectService)({}), + Layer.mock(Revival.WorktreeRevivalService)({}), Layer.mock(Auth.ProviderAuthService)({}), Layer.mock(Projection.ProjectionStoreV2)({ getThreadProjection: () => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3760500e4056..33c033918dce 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -98,6 +98,9 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; +import * as WorktreeLifecycle from "./vcs/WorktreeLifecycle.ts"; +import * as WorktreeRevival from "./vcs/WorktreeRevivalService.ts"; +import * as WorktreeService from "./vcs/WorktreeService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; @@ -362,6 +365,7 @@ const layerGit = Layer.empty.pipe( const layerGitWorkflow = GitWorkflowService.layer.pipe( Layer.provideMerge(layerVcsDriverRegistry), Layer.provideMerge(layerGit), + Layer.provideMerge(WorktreeLifecycle.layer), ); const layerSourceControlRepositoryService = SourceControlRepositoryService.layer.pipe( @@ -480,6 +484,13 @@ const layerScheduledTaskWebhookOrigin = Layer.effect( }), ); +const layerWorktreeRevival = WorktreeRevival.layer.pipe( + Layer.provideMerge(RuntimeLayer.layerProjectService), + Layer.provideMerge(RuntimeLayer.layerProjectSetupScriptRunner), + Layer.provideMerge(layerGit), + Layer.provideMerge(WorktreeLifecycle.layer), +); + const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( Layer.provide(layerScheduledTaskWebhookOrigin), Layer.provide(ProviderEventIngestor.layerAnalytics), @@ -493,6 +504,7 @@ const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( Layer.provide(RuntimeLayer.layerProjectService), ), ), + Layer.provideMerge(layerWorktreeRevival), ); const layerOrchestrationApplication = CheckpointDiffQuery.layer.pipe( @@ -500,6 +512,13 @@ const layerOrchestrationApplication = CheckpointDiffQuery.layer.pipe( Layer.provideMerge(layerOrchestrationV2Runtime), ); +// The one owner of worktree inventory and removal safety. Storage cleanup, the +// WebSocket handlers and the MCP tools all resolve this instance. +const layerWorktreeManagement = WorktreeService.layer.pipe( + Layer.provide(ProjectionStoreV2.layer), + Layer.provideMerge(WorktreeLifecycle.layer), +); + // Automatic thread settlement (#8600): a server-owned sweep evaluates // inactivity and merged pull requests, then settles through the orchestrator // so every client sees the same shelf. @@ -578,6 +597,8 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ).pipe( // Core Services Layer.provideMerge(layerOrchestrationApplication), + // Reads projects, Git and terminals from the layers provided below. + Layer.provideMerge(layerWorktreeManagement), Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), Layer.provideMerge(layerServerSettings), diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index a8fb530d3f05..b2191800c88e 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -18,6 +18,8 @@ import { import * as ServerConfig from "./config.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as WorktreeLifecycle from "./vcs/WorktreeLifecycle.ts"; +import * as WorktreeService from "./vcs/WorktreeService.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Settings from "./serverSettings.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; @@ -26,159 +28,12 @@ import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { ServerActivation } from "./serverActivation.ts"; import { describe, expect, it } from "@effect/vitest"; -import { - ProjectId, - ProviderInstanceId, - RunId, - RuntimeRequestId, - ThreadId, - type OrchestrationV2ThreadShell, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import { - storageCleanupActivityAt, - storageCleanupPullRequestMerged, - storageCleanupThreadIdle, -} from "./storageCleanup.ts"; +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { storageCleanupPullRequestMerged } from "./storageCleanup.ts"; +import { makeThreadShell as shell } from "./vcs/worktreeThreadState.testkit.ts"; -const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); -const DAY_MS = 24 * 60 * 60 * 1_000; const decodeCleanupReport = Schema.decodeSync(StorageCleanupReport); -function at(offsetMs: number): DateTime.Utc { - return DateTime.makeUnsafe(NOW_MS + offsetMs); -} - -function shell(overrides: Partial = {}): OrchestrationV2ThreadShell { - return { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Thread", - providerInstanceId: ProviderInstanceId.make("codex"), - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - worktreePath: null, - activeProviderThreadId: null, - lineage: { - rootThreadId: ThreadId.make("thread-1"), - parentThreadId: null, - relationshipToParent: null, - }, - forkedFrom: null, - createdBy: "user", - creationSource: "web", - activeRunId: null, - latestVisibleMessage: null, - hasActionableProposedPlan: false, - itemCount: 0, - visibleItemCount: 0, - lastVisitedAt: null, - deletedAt: null, - branch: null, - linkedPullRequest: null, - status: "idle", - activityRunStatus: null, - pendingRuntimeRequest: null, - pendingBackgroundTasks: [], - latestRunId: null, - latestRunRequestedAt: null, - latestRunStartedAt: null, - latestRunCompletedAt: null, - latestUserMessageAt: null, - createdAt: at(-30 * DAY_MS), - updatedAt: at(-10 * DAY_MS), - archivedAt: null, - settledOverride: null, - settledAt: null, - snoozedUntil: null, - snoozedAt: null, - pinnedAt: null, - ...overrides, - }; -} - -describe("V2 storage cleanup eligibility", () => { - const candidate = () => shell({ branch: "feature", worktreePath: "/worktrees/feature" }); - - it("allows an idle worktree and rejects the project checkout", () => { - expect(storageCleanupThreadIdle(candidate(), NOW_MS)).toBe(true); - expect(storageCleanupThreadIdle(shell(), NOW_MS)).toBe(false); - }); - - it.each(["running", "starting", "preparing", "waiting", "queued"] as const)( - "retains a worktree while its thread is %s", - (status) => { - expect(storageCleanupThreadIdle(candidateWithStatus(status), NOW_MS)).toBe(false); - }, - ); - - it.each(["idle", "completed", "interrupted", "failed", "cancelled", "rolled_back"] as const)( - "allows cleanup once its thread is %s", - (status) => { - expect(storageCleanupThreadIdle(candidateWithStatus(status), NOW_MS)).toBe(true); - }, - ); - - it.each(["completed", "interrupted", "cancelled", "rolled_back"] as const)( - "retains %s while background work is pending", - (status) => { - expect( - storageCleanupThreadIdle( - { - ...candidateWithStatus(status), - pendingBackgroundTasks: [{ taskId: "task-1", kind: "command" }], - }, - NOW_MS, - ), - ).toBe(false); - }, - ); - - it.each(["completed", "interrupted", "cancelled", "rolled_back"] as const)( - "retains %s while a runtime request is pending", - (status) => { - expect( - storageCleanupThreadIdle( - { - ...candidateWithStatus(status), - pendingRuntimeRequest: { - id: RuntimeRequestId.make("request-1"), - kind: "command", - createdAt: at(0), - }, - }, - NOW_MS, - ), - ).toBe(false); - }, - ); - - it("retains an active run even if the shell status is idle", () => { - expect( - storageCleanupThreadIdle({ ...candidate(), activeRunId: RunId.make("run") }, NOW_MS), - ).toBe(false); - }); - - it("retains a queued prompt before the new run has been projected", () => { - expect( - storageCleanupThreadIdle({ ...candidate(), latestUserMessageAt: at(-1_000) }, NOW_MS), - ).toBe(false); - }); - - it("uses V2 run activity instead of metadata refreshes for retention", () => { - const thread = candidate(); - const runTime = at(-3 * DAY_MS); - expect( - storageCleanupActivityAt({ ...thread, latestRunCompletedAt: runTime, updatedAt: at(0) }), - ).toBe(DateTime.toEpochMillis(runTime)); - }); - - function candidateWithStatus(status: OrchestrationV2ThreadShell["status"]) { - return { ...candidate(), status }; - } -}); - describe("merged pull request cleanup", () => { const HEAD_SHA = "a".repeat(40); const integrated = { @@ -248,15 +103,23 @@ const cleanupFixture = Effect.gen(function* () { }; let threads = [shell({ branch: "feature", worktreePath: worktree })]; let editBeforeRemoval = false; - const context = yield* Layer.build(StorageCleanup.layer).pipe( + const lateEdit = Effect.suspend(() => + editBeforeRemoval ? fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n") : Effect.void, + ); + // Removal safety is WorktreeService's, so cleanup runs over the real one. + const context = yield* Layer.build( + StorageCleanup.layer.pipe( + Layer.provideMerge(WorktreeService.layer), + Layer.provideMerge(WorktreeLifecycle.layer), + ), + ).pipe( Effect.provideService(GitVcsDriver.GitVcsDriver, { ...git, execute: (input) => - Effect.gen(function* () { - if (editBeforeRemoval && input.args.includes("remove")) - yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); - return yield* git.execute(input); - }), + (input.args.includes("remove") ? lateEdit : Effect.void).pipe( + Effect.andThen(git.execute(input)), + ), + removeWorktree: (input) => lateEdit.pipe(Effect.andThen(git.removeWorktree(input))), }), Effect.provideService(Settings.ServerSettingsService, { getSettings: Effect.sync(() => settings), @@ -267,7 +130,10 @@ const cleanupFixture = Effect.gen(function* () { } as unknown as ProjectStore.ProjectStoreV2["Service"]), Effect.provideService(ProjectionStore.ProjectionStoreV2, { getShellSnapshot: (input?: { location?: string }) => - Effect.sync(() => ({ threads: input?.location === "archive" ? [] : threads })), + Effect.sync(() => ({ + threads: input?.location === "archive" ? [] : threads, + archivedThreads: [], + })), } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), Effect.provideService(Orchestrator.OrchestratorV2, { streamDomainEvents: Stream.empty, @@ -284,10 +150,12 @@ const cleanupFixture = Effect.gen(function* () { ); return { service: Context.get(context, StorageCleanup.StorageCleanup), + lifecycle: Context.get(context, WorktreeLifecycle.WorktreeLifecycle), editBeforeRemoval: () => { editBeforeRemoval = true; }, fs, + repo, worktree, config, command, @@ -355,6 +223,19 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("keeps the branch and tells the worktree inventory about the removal", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command, lifecycle } = yield* cleanupFixture; + const before = yield* lifecycle.revision; + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(yield* fs.exists(worktree)).toBe(false); + expect(yield* lifecycle.revision).toBeGreaterThan(before); + // Revival recreates the checkout from this branch on the thread's next turn. + yield* command(repo, ["show-ref", "--verify", "--quiet", "refs/heads/feature"]); + }), + ), + ); it.live("still removes a worktree when measuring its size fails", () => runCleanupTest( Effect.gen(function* () { @@ -404,7 +285,7 @@ describe("storage cleanup reports and local file policies", () => { const report = yield* Fiber.join(cleanup); expect(report.entries[0]).toMatchObject({ outcome: "kept", - reason: "Thread activity or shared worktree changed since check", + reason: "Thread is running or has pending work", bytes: null, }); expect(report.bytesFreed).toBe(0); @@ -488,7 +369,8 @@ describe("storage cleanup reports and local file policies", () => { const report = yield* fixture.service.runNow; expect(report.entries[0]).toMatchObject({ outcome: "failed", bytes: null, files: null }); expect(report.bytesFreed).toBe(0); - expect(report.entries[0]?.reason).toContain("contains modified or untracked files"); + // Git refuses the removal; its stderr stays out of the report. + expect(report.entries[0]?.reason).toContain("Git worktree remove failed"); expect(yield* fixture.fs.readFileString(`${fixture.worktree}/tracked.txt`)).toBe( "late edit\n", ); @@ -644,7 +526,7 @@ describe("storage cleanup reports and local file policies", () => { yield* command(worktree, ["worktree", "lock", worktree]); const report = yield* service.runNow; expect(report.entries[0]).toMatchObject({ outcome: "failed" }); - expect(report.entries[0]?.reason).toContain("git worktree remove: fatal:"); + expect(report.entries[0]?.reason).toContain("Git worktree remove failed"); expect(report.entries.map((entry) => entry.kind)).toEqual([ "worktree", "browser-artifacts", diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 481abe7da8cc..78b399da8ecf 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -1,17 +1,12 @@ -import { - GitCommandError, - OrchestrationV2AppThreadJson, - OrchestrationV2ProviderSessionJson, -} from "@t3tools/contracts"; +import { GitCommandError, OrchestrationV2AppThreadJson } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import type { - OrchestrationV2ThreadShell, ProjectId, ServerSettings, ServerSettingsError, - TerminalSummary, WorktreeCleanupRules, + WorktreePruneSkipReason, StorageCleanupReport, StorageCleanupReportEntry, } from "@t3tools/contracts"; @@ -37,21 +32,15 @@ import * as GitManager from "./git/GitManager.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; -import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementService.ts"; import { forkParked } from "./serverActivation.ts"; import * as Settings from "./serverSettings.ts"; -import * as TerminalManager from "./terminal/Manager.ts"; -import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; -import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as WorktreeService from "./vcs/WorktreeService.ts"; +import { storageCleanupActivityAt, storageCleanupThreadIdle } from "./vcs/worktreeThreadState.ts"; const decodeCleanupThread = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2AppThreadJson), ); -const decodeCleanupSession = Schema.decodeUnknownEffect( - Schema.fromJsonString(OrchestrationV2ProviderSessionJson), -); const DAY_MS = 86_400_000; @@ -65,6 +54,25 @@ function cleanupFailureReason(error: { readonly message: string }) { : (error.message.split(/\r?\n/)[0] ?? error.message); } +/** Report wording for why WorktreeService kept a checkout a rule selected. */ +const REMOVAL_SKIP_REASON: Record = { + running: "thread is running or has pending work", + session: "provider session is still open", + terminal: "open terminal", + open_thread: "a linked thread is still open", + dirty: "has uncommitted changes", + submodules: "has submodule repositories", + unpushed: "has commits that are not pushed or merged", + unrestorable_thread: "a linked thread could not get this checkout back", + status_unavailable: "Git status could not be read", + ignored_files: "has ignored files", + protected_path: "not a removable managed worktree", + unknown_worktree: "Git does not register this worktree for the project", + changed: "repository, branch or commit changed since check", + policy_changed: "thread activity or cleanup settings changed since check", + remove_failed: "git worktree remove failed", +}; + const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) => rules.worktreeAfterDays !== null || rules.worktreeOnMerge || @@ -97,37 +105,6 @@ function sameProjectWorktreePolicies(left: ServerSettings, right: ServerSettings ); } -/** Live sessions keep their cwd even when no turn is currently running. */ -export function storageCleanupThreadIdle(thread: OrchestrationV2ThreadShell, now: number): boolean { - return ( - thread.branch !== null && - thread.worktreePath !== null && - thread.activeRunId === null && - (thread.status === "idle" || - thread.status === "completed" || - thread.status === "interrupted" || - thread.status === "failed" || - thread.status === "cancelled" || - thread.status === "rolled_back") && - (thread.pendingBackgroundTasks?.length ?? 0) === 0 && - thread.pendingRuntimeRequest === null && - !threadHasQueuedTurnStart(thread, now) - ); -} - -/** PR metadata refreshes must not reset the inactivity clock. */ -export function storageCleanupActivityAt(thread: OrchestrationV2ThreadShell): number { - return Math.max( - ...[ - thread.createdAt, - thread.latestUserMessageAt, - thread.latestRunRequestedAt, - thread.latestRunStartedAt, - thread.latestRunCompletedAt, - ].flatMap((value) => (value == null ? [] : [DateTime.toEpochMillis(value)])), - ); -} - /** * Whether the host's pull request proves this worktree's head was merged. A * squash or rebase merge leaves the head outside the default branch, so the @@ -173,16 +150,9 @@ const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const git = yield* GitVcsDriver.GitVcsDriver; const gitManager = yield* GitManager.GitManager; - const terminals = yield* TerminalManager.TerminalManager; + const worktrees = yield* WorktreeService.WorktreeService; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const liveTerminals = new Map>(); - const noteTerminal = (terminal: TerminalSummary) => { - const threadTerminals = - liveTerminals.get(terminal.threadId) ?? new Map(); - threadTerminals.set(terminal.terminalId, terminal); - liveTerminals.set(terminal.threadId, threadTerminals); - }; const inside = (root: string, target: string) => { const relative = path.relative(root, target); @@ -193,20 +163,6 @@ const make = Effect.gen(function* () { !path.isAbsolute(relative) ); }; - const hasTerminal = (worktreePath: string) => - [...liveTerminals.values()] - .flatMap((entries) => [...entries.values()]) - .some((terminal) => { - if (terminal.status !== "starting" && terminal.status !== "running") return false; - const cwd = path.resolve(terminal.cwd); - return ( - (terminal.worktreePath !== null && - path.resolve(terminal.worktreePath) === worktreePath) || - cwd === worktreePath || - inside(worktreePath, cwd) - ); - }); - const measureWorktree = (worktreePath: string) => Effect.gen(function* () { const root = yield* fs.realPath(worktreePath); @@ -255,104 +211,11 @@ const make = Effect.gen(function* () { return { projects, threads: [...active.threads, ...archived.threads] }; }); - // Local threads under another project need not have a worktreePath of their own. - const containsProjectRoot = Effect.fn("StorageCleanup.containsProjectRoot")(function* ( - worktreePath: string, - projects: ReadonlyArray<{ readonly workspaceRoot: string }>, - ) { - for (const project of projects) { - const projectPath = path.resolve(project.workspaceRoot); - if (projectPath === worktreePath || inside(worktreePath, projectPath)) return true; - const realPath = yield* fs - .realPath(projectPath) - .pipe(Effect.orElseSucceed(() => projectPath)); - if (realPath === worktreePath || inside(worktreePath, realPath)) return true; - } - return false; - }); - - const localChangesReason = Effect.fn("StorageCleanup.localChangesReason")(function* ( - cwd: string, - rules: WorktreeCleanupRules, - ) { - const status = yield* git.execute({ - operation: "StorageCleanup.localChanges", - cwd, - args: [ - "status", - "--porcelain=v1", - "-z", - "--ignore-submodules=none", - rules.worktreeKeepWhen === "tracked-changes" - ? "--untracked-files=no" - : "--untracked-files=all", - ], - maxOutputBytes: 64 * 1024, - }); - if (status.stdoutTruncated) return "local changes exceed the inspection limit"; - const records = status.stdout.split("\0").filter(Boolean); - let count = 0; - for (let i = 0; i < records.length; i++) { - count++; - if (/^[RC]|^.[RC]/.test(records[i]!)) i++; - } - if (count > 0) return `has uncommitted changes (${count} ${count === 1 ? "file" : "files"})`; - if (rules.worktreeKeepWhen !== "any-local-files") return null; - const ignored = yield* git.execute({ - operation: "StorageCleanup.ignoredFiles", - cwd, - args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], - maxOutputBytes: 64 * 1024, - }); - if (ignored.stdoutTruncated) return "ignored files exceed the inspection limit"; - const file = ignored.stdout - .split("\0") - .find((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)); - return file === undefined ? null : `has ignored files (${file})`; - }); - - const cleanupGit = Effect.fn("StorageCleanup.cleanupGit")(function* ( - cwd: string, - args: string[], - command: string, - ) { - const result = yield* git.execute({ - operation: "StorageCleanup.cleanupGit", - cwd, - args, - allowNonZeroExit: true, - timeoutMs: 300_000, - }); - if (result.exitCode !== 0) - return yield* Effect.fail( - new GitCommandError({ - operation: "StorageCleanup.cleanupGit", - command, - cwd, - detail: result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", - }), - ); - }); - const cleanWorktrees = Effect.fn("StorageCleanup.cleanWorktrees")(function* ( serverSettings: ServerSettings, now: number, entries: StorageCleanupReportEntry[], ) { - const roots: Array = []; - for (const directory of managedWorktreesDirectories( - serverSettings, - config.worktreesDir, - path, - yield* HostProcess.HomeDirectory, - )) { - // An unmounted drive only skips its own worktrees. - const root = yield* fs.exists(directory).pipe( - Effect.flatMap((exists) => (exists ? fs.realPath(directory) : Effect.succeed(null))), - Effect.orElseSucceed(() => null), - ); - if (root !== null && !isFilesystemRoot(root, path)) roots.push(root); - } const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>` SELECT t.payload_json, p.workspace_root AS "workspaceRoot" FROM orchestration_v2_projection_threads t @@ -393,33 +256,22 @@ const make = Effect.gen(function* () { files: null, }; const keep = (reason: string) => entries.push({ ...entry, reason: sentence(reason) }); + // This service decides which worktrees a rule applies to and reports + // why. Whether a checkout can go at all (its path, sessions, terminals, + // running turns, local files) is WorktreeService's decision, made under + // the checkout's lease. yield* Effect.gen(function* () { if (!(yield* fs.exists(worktreePath))) return; if (deleted && !settings.worktreeOnDelete) return keep("no rules apply"); const shared = groups.get(worktreePath)?.length ?? 0; if (shared > 1) return keep(`shared by ${shared} threads`); if (project === undefined) return keep("project is unavailable"); + const branch = thread.branch; + if (branch === null) return keep("thread has no branch"); if (!deleted && !storageCleanupThreadIdle(thread, now)) return keep("thread is running or has pending work"); - if (hasTerminal(worktreePath)) return keep("open terminal"); - // Roots are canonical, so compare canonical paths. A symlinked parent - // (a linked drive) is fine; a symlinked worktree directory is not. - const realPath = yield* fs.realPath(worktreePath); - const realParent = yield* fs.realPath(path.dirname(worktreePath)); - if (realPath !== path.join(realParent, path.basename(worktreePath))) - return keep("worktree is a symbolic link"); - if (!roots.some((root) => inside(root, realPath))) - return keep("outside the managed worktree folder"); - if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) - return keep("contains a project checkout"); - // A linked worktree has a .git file. Never remove a main checkout. - if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") - return keep("not a linked worktree"); const status = yield* git.statusDetailsLocal(worktreePath); - if (!status.isRepo || status.branch !== thread.branch) - return keep("repository or branch changed"); - const changes = yield* localChangesReason(worktreePath, settings); - if (changes !== null) return keep(changes); + if (!status.isRepo || status.branch !== branch) return keep("repository or branch changed"); const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); const old = !deleted && @@ -432,15 +284,15 @@ const make = Effect.gen(function* () { if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { const repositoryCwd = path.resolve(project.workspaceRoot); const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd); - const branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); - if (branch === null) return keep("default branch is unavailable"); - const defaultRef = `refs/remotes/${remote}/${branch}`; + const defaultBranch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); + if (defaultBranch === null) return keep("default branch is unavailable"); + const defaultRef = `refs/remotes/${remote}/${defaultBranch}`; const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set(); if (!refreshed.has(defaultRef)) { yield* git.fetchRemoteTrackingBranch({ cwd: repositoryCwd, remoteName: remote, - remoteBranch: branch, + remoteBranch: defaultBranch, }); refreshed.add(defaultRef); refreshedDefaultRefs.set(repositoryCwd, refreshed); @@ -458,14 +310,14 @@ const make = Effect.gen(function* () { const integrated = ancestor.exitCode === 0; eligible = integrated && settings.worktreeUnchanged; if (eligible) removalReason = "no commits beyond the default branch"; - if (!eligible && settings.worktreeOnMerge && thread.branch !== null) { + if (!eligible && settings.worktreeOnMerge) { const pullRequest = yield* gitManager.branchPullRequest( - { cwd: worktreePath, branch: thread.branch }, + { cwd: worktreePath, branch }, { refresh: true }, ); eligible = storageCleanupPullRequestMerged(pullRequest, { - branch: thread.branch, - defaultBranch: branch, + branch, + defaultBranch, headSha: head.commitSha, integrated, }); @@ -481,89 +333,72 @@ const make = Effect.gen(function* () { : "has commits beyond the default branch", ); const bytes = yield* measureWorktree(worktreePath); - // Re-read after Git/host calls and size measurement so a queued turn, - // resumed session or new thread sharing this path cancels the removal. - const latestSnapshot = yield* readThreads(); - if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) - return keep("contains a project checkout"); - const latest = latestSnapshot.threads.filter( - (entry) => - entry.worktreePath !== null && path.resolve(entry.worktreePath) === worktreePath, - ); - if (hasTerminal(worktreePath)) return keep("open terminal"); - if (deleted) { - if ( - latest.length > 0 || - !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) - .worktreeOnDelete - ) - return keep("thread or cleanup settings changed since check"); - // V2 deletion queues durable cleanup. Do not remove its checkout until - // every effect has finished successfully or was explicitly cancelled. - const pendingCleanup = yield* sql` - SELECT 1 FROM orchestration_v2_effect_outbox - WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 - `; - if (pendingCleanup.length > 0) return keep("thread deletion is still pending"); - } else if ( - latest.length !== 1 || - latest[0]!.id !== thread.id || - !storageCleanupThreadIdle(latest[0]!, now) || - storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) - ) - return keep("thread activity or shared worktree changed since check"); - // Sessions can outlive their run and can be shared across app threads. - const sessionRows = yield* sql<{ payload_json: string }>` - SELECT payload_json FROM orchestration_v2_projection_provider_sessions - WHERE status != 'stopped' - `; - const sessions = yield* Effect.forEach(sessionRows, (row) => - decodeCleanupSession(row.payload_json), - ); - if ( - sessions.some((session) => { - const cwd = path.resolve(session.cwd); - return cwd === worktreePath || inside(worktreePath, cwd); - }) - ) - return keep("provider session is still open"); - const finalStatus = yield* git.statusDetailsLocal(worktreePath); - if (!finalStatus.isRepo || finalStatus.branch !== thread.branch) - return keep("repository or branch changed since check"); - if ( - (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !== - head.commitSha - ) - return keep("commit changed since check"); - const finalChanges = yield* localChangesReason(worktreePath, settings); - if (finalChanges !== null) return keep(finalChanges); - const current = resolveWorktreeCleanup( - yield* settingsService.getSettings, - thread.projectId, - ); - if ( - Object.keys(settings).some( + // Runs under the lease, after the Git and host calls and the size + // measurement above, so a new thread sharing this path, fresh + // activity or a changed rule cancels the removal. + const stillApplies = Effect.gen(function* () { + const latest = (yield* readThreads()).threads.filter( + (entry) => + entry.worktreePath !== null && path.resolve(entry.worktreePath) === worktreePath, + ); + if (deleted) { + if (latest.length > 0) return false; + // V2 deletion queues durable cleanup. Do not remove its checkout until + // every effect has finished successfully or was explicitly cancelled. + const pendingCleanup = yield* sql` + SELECT 1 FROM orchestration_v2_effect_outbox + WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 + `; + if (pendingCleanup.length > 0) return false; + } else if ( + latest.length !== 1 || + latest[0]!.id !== thread.id || + !storageCleanupThreadIdle(latest[0]!, now) || + storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) + ) { + return false; + } + const current = resolveWorktreeCleanup( + yield* settingsService.getSettings, + thread.projectId, + ); + return Object.keys(settings).every( (key) => - current[key as keyof typeof settings] !== settings[key as keyof typeof settings], - ) - ) - return keep("settings changed since check"); - // Clean only untracked files; Git must still refuse removal if a tracked - // edit arrives after our last status check. - if (settings.worktreeKeepWhen === "tracked-changes") - yield* cleanupGit(worktreePath, ["clean", "-ffdx"], "git clean"); - yield* cleanupGit( - project.workspaceRoot, - ["-c", "status.showUntrackedFiles=normal", "worktree", "remove", worktreePath], - "git worktree remove", + current[key as keyof typeof settings] === settings[key as keyof typeof settings], + ); + }).pipe( + Effect.catch((error) => + Effect.logDebug("storage cleanup recheck failed", { threadId: thread.id, error }).pipe( + Effect.as(false), + ), + ), ); + // The branch and path are kept: WorktreeRevivalService recreates the + // checkout from that branch before the thread's next turn. + const removal = yield* worktrees.removeIfSafe({ + path: worktreePath, + workspaceRoot: project.workspaceRoot, + intent: "policy", + expected: { branch, headSha: head.commitSha }, + keepWhen: settings.worktreeKeepWhen, + recheck: stillApplies, + }); + if (removal.outcome === "skipped") { + const reason = sentence( + removal.detail === undefined || removal.detail === REMOVAL_SKIP_REASON[removal.reason] + ? REMOVAL_SKIP_REASON[removal.reason] + : `${REMOVAL_SKIP_REASON[removal.reason]} (${removal.detail})`, + ); + entries.push({ + ...entry, + outcome: removal.reason === "remove_failed" ? "failed" : "kept", + reason, + }); + return; + } entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes }); - yield* gitManager.invalidateStatus(project.workspaceRoot); - // Preserve branch and path: ProviderTurnStartService recreates the checkout - // from that branch when the thread is resumed. yield* Effect.logInfo("storage cleanup removed worktree", { threadId: thread.id }); }).pipe( - (effect) => withWorkspaceLease(worktreePath, effect), Effect.catch((error) => Effect.gen(function* () { entries.push({ @@ -733,21 +568,6 @@ const make = Effect.gen(function* () { }); const start = Effect.fn("StorageCleanup.start")(function* () { - const unsubscribe = yield* terminals.subscribeMetadata((event) => - Effect.sync(() => { - if (event.type === "snapshot") { - liveTerminals.clear(); - for (const terminal of event.terminals) noteTerminal(terminal); - } else if (event.type === "upsert") { - noteTerminal(event.terminal); - } else { - const threadTerminals = liveTerminals.get(event.threadId); - threadTerminals?.delete(event.terminalId); - if (threadTerminals?.size === 0) liveTerminals.delete(event.threadId); - } - }), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); const changes = yield* settingsService.subscribeChanges; const events = engine.streamDomainEvents; let lastSettings = yield* settingsService.getSettings.pipe(Effect.orDie); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 9889f33d4a8f..9a20b4d89463 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -40,6 +40,7 @@ import * as ServerConfig from "../config.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { resolveWorkspaceLeasePath, withWorkspaceLease } from "../workspace/workspaceLease.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; @@ -119,6 +120,8 @@ class FakePtyAdapter { private readonly mode: "sync" | "async"; private nextPid = 9000; exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined; + /** Runs once before the next spawn, so a test can hold a terminal mid-startup. */ + beforeNextSpawn: Effect.Effect | undefined; constructor(mode: "sync" | "async" = "sync") { this.mode = mode; @@ -127,6 +130,11 @@ class FakePtyAdapter { spawn( input: PtyAdapter.PtySpawnInput, ): Effect.Effect { + const gate = this.beforeNextSpawn; + if (gate) { + this.beforeNextSpawn = undefined; + return gate.pipe(Effect.andThen(Effect.suspend(() => this.spawn(input)))); + } this.spawnInputs.push(input); const failure = this.spawnFailures.shift(); if (failure) { @@ -922,6 +930,43 @@ it.layer( }), ); + it.effect.each([false, true])( + "holds the checkout lease during terminal startup (nested repository: %s)", + (nestedRepository) => + Effect.gen(function* () { + const ptyAdapter = new FakePtyAdapter(); + const { manager, baseDir } = yield* createManager(5, { ptyAdapter }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const checkout = path.join(baseDir, "checkout"); + const nestedCwd = path.join(checkout, "src"); + yield* makeDirectory(nestedCwd); + yield* fs.writeFileString(path.join(checkout, ".git"), "gitdir: /repo/.git/worktrees/x\n"); + if (nestedRepository) yield* makeDirectory(path.join(nestedCwd, ".git")); + const spawnStarted = yield* Deferred.make(); + const releaseSpawn = yield* Deferred.make(); + ptyAdapter.beforeNextSpawn = Deferred.succeed(spawnStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSpawn)), + ); + + const opening = yield* manager.open(openInput({ cwd: nestedCwd })).pipe(Effect.forkChild); + yield* Deferred.await(spawnStarted); + // Worktree removal leases the checkout root. Starting immediately, it would + // take a free lease before startup finishes, so it must find the lease held. + const checkoutLease = yield* resolveWorkspaceLeasePath(checkout); + const removal = yield* withWorkspaceLease( + checkoutLease, + Effect.sync(() => ptyAdapter.processes.length), + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(releaseSpawn, undefined); + + const snapshot = yield* Fiber.join(opening); + assert.equal(yield* Fiber.join(removal), 1); + assert.equal(snapshot.cwd, nestedCwd); + assert.equal(ptyAdapter.spawnInputs[0]?.cwd, nestedCwd); + }), + ); + it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => Effect.gen(function* () { const { manager, getEvents, baseDir } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index a109cf5d7540..a13312488b0e 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -6,7 +6,7 @@ * * @module TerminalManager */ -import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; +import { resolveWorkspaceLeasePath, withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { DEFAULT_TERMINAL_ID, TerminalCwdError, @@ -2715,12 +2715,46 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return snapshot(liveSession); }); - const openLocked = (input: TerminalOpenInput) => - withWorkspaceLease( - path.resolve(input.worktreePath ?? input.cwd), - openWithWorkspaceLease(input), + // Raw terminal calls may name a subdirectory or nested repository. Lease all + // enclosing Git roots so checkout removal waits for PTY startup. Client calls + // that supply worktreePath already identify the enclosing checkout. + const workspaceLeasePaths = Effect.fn("terminal.workspaceLeasePaths")( + function* (input: { readonly cwd: string; readonly worktreePath?: string | null | undefined }) { + if (input.worktreePath != null) return [yield* resolveWorkspaceLeasePath(input.worktreePath)]; + const cwd = yield* resolveWorkspaceLeasePath(input.cwd); + const roots: string[] = []; + let directory = cwd; + while (true) { + const marker = yield* fileSystem.stat(path.join(directory, ".git")).pipe(Effect.option); + if ( + Option.isSome(marker) && + (marker.value.type === "File" || marker.value.type === "Directory") + ) { + roots.push(directory); + } + const parent = path.dirname(directory); + if (parent === directory) return roots.length > 0 ? roots.sort() : [cwd]; + directory = parent; + } + }, + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const withTerminalWorkspaceLease = ( + input: { readonly cwd: string; readonly worktreePath?: string | null | undefined }, + effect: Effect.Effect, + ) => + workspaceLeasePaths(input).pipe( + // A consistent acquisition order prevents nested checkouts deadlocking. + Effect.flatMap((roots) => + roots.reduceRight((locked, root) => withWorkspaceLease(root, locked), effect), + ), ); + const openLocked = (input: TerminalOpenInput) => + withTerminalWorkspaceLease(input, openWithWorkspaceLease(input)); + const open: TerminalManager["Service"]["open"] = (input) => withThreadLock( input.threadId, @@ -3113,10 +3147,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func input.threadId, resolveLaunchInputEnvironment(input).pipe( Effect.flatMap((resolved) => - withWorkspaceLease( - path.resolve(resolved.worktreePath ?? resolved.cwd), - restartResolved(resolved), - ), + withTerminalWorkspaceLease(resolved, restartResolved(resolved)), ), ), ); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 599269791edf..a5a306852a0f 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -38,6 +38,7 @@ import { splitNullSeparatedGitStdoutPaths, } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; +import type { GitWorktree } from "./GitWorktree.ts"; import * as VcsProcess from "./VcsProcess.ts"; export interface ExecuteGitInput { @@ -317,6 +318,9 @@ export class GitVcsDriver extends Context.Service< cwd: string, options?: GitRemoteStatusOptions, ) => Effect.Effect; + readonly listWorkspaces: ( + cwd: string, + ) => Effect.Effect, GitCommandError>; readonly prepareCommitContext: ( cwd: string, filePaths?: readonly string[], diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index fc0cdef01e5b..2a1c202b5bb2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -339,6 +339,35 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () }).pipe(Effect.provide(layer)); }); +it.effect("rejects truncated core worktree listings", () => { + const outputLimit = 16 * 1024 * 1024; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + makeSuccessfulHandle( + `worktree /repo\0HEAD deadbeef\0branch refs/heads/main\0\0${"x".repeat(outputLimit)}`, + ), + ), + ); + const nodeServicesLayer = Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const layer = GitVcsDriver.layer.pipe( + Layer.provide(layerServerConfig), + Layer.provideMerge(nodeServicesLayer), + ); + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const error = yield* driver.listWorkspaces("/repo").pipe(Effect.flip); + + assert.instanceOf(error, GitCommandError); + assert.equal(error.operation, "GitVcsDriver.listWorkspaces"); + assert.equal(error.cwd, "/repo"); + assert.include(error.detail, "output exceeded"); + }).pipe(Effect.provide(layer)); +}); + it.effect("invalidates origin remote cache when a driver mutation adds origin", () => Effect.gen(function* () { const driver = yield* GitVcsDriver.GitVcsDriver; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 53e7f6418058..a0362a5b0fc0 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -44,6 +44,7 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import * as ServerConfig from "../config.ts"; +import { parseGitWorktreeBranchPaths, parseGitWorktreeListPorcelain } from "./GitWorktree.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const gitProcesses = Semaphore.makeUnsafe(8); @@ -79,6 +80,7 @@ const REVIEW_DIFF_ARGS = [ "--minimal", ...PATCH_RENDER_PREFIX_ARGS, ]; +const WORKTREE_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -288,37 +290,6 @@ function paginateBranches(input: { }; } -function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { - const worktreePaths = new Map(); - let currentPath: string | null = null; - let currentBranch: string | null = null; - let currentPrunable = false; - - const flush = () => { - if (currentPath !== null && currentBranch !== null && !currentPrunable) { - worktreePaths.set(currentBranch, currentPath); - } - currentPath = null; - currentBranch = null; - currentPrunable = false; - }; - - for (const field of stdout.split("\0")) { - if (field === "") { - flush(); - } else if (field.startsWith("worktree ")) { - currentPath = field.slice("worktree ".length); - } else if (field.startsWith("branch refs/heads/")) { - currentBranch = field.slice("branch refs/heads/".length); - } else if (field === "prunable" || field.startsWith("prunable ")) { - currentPrunable = true; - } - } - flush(); - - return worktreePaths; -} - function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { const parts = input.split("\0"); if (parts.length === 0) return []; @@ -1163,6 +1134,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, }); + const listWorkspaces: GitVcsDriver.GitVcsDriver["Service"]["listWorkspaces"] = Effect.fn( + "GitVcsDriver.listWorkspaces", + )(function* (cwd) { + const args = ["worktree", "list", "--porcelain", "-z"] as const; + const result = yield* executeGit("GitVcsDriver.listWorkspaces", cwd, args, { + timeoutMs: 30_000, + maxOutputBytes: WORKTREE_LIST_MAX_OUTPUT_BYTES, + }); + if (result.stdoutTruncated) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.listWorkspaces", cwd, args }), + detail: `Git worktree output exceeded ${WORKTREE_LIST_MAX_OUTPUT_BYTES} bytes.`, + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); + } + return parseGitWorktreeListPorcelain(result.stdout); + }); + const runGit = ( operation: string, cwd: string, @@ -3159,7 +3149,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : null; const parsedWorktreeEntries = worktreeListResult.exitCode === 0 - ? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map( + ? [...parseGitWorktreeBranchPaths(worktreeListResult.stdout)].map( ([branchName, worktreePath]) => [branchName, path.normalize(path.resolve(worktreePath))] as const, ) @@ -4010,22 +4000,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) => - runGitStdout("GitVcsDriver.listWorktreePaths", cwd, [ - "worktree", - "list", - "--porcelain", - "-z", - ]).pipe( - // One record per worktree, each ended by an empty field. A `prunable` - // record's directory is gone, and another checkout may now sit there. - Effect.map((stdout) => - stdout.split("\0\0").flatMap((record) => { - const fields = record.split("\0"); - const worktree = fields.find((field) => field.startsWith("worktree ")); - return worktree === undefined || fields.some((field) => field.startsWith("prunable")) - ? [] - : [path.resolve(cwd, worktree.slice("worktree ".length))]; - }), + // A prunable registration's directory is gone; another checkout may now sit there. + listWorkspaces(cwd).pipe( + Effect.map((entries) => + entries.flatMap((entry) => (entry.prunable ? [] : [path.resolve(cwd, entry.path)])), ), ); @@ -4061,6 +4039,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusDetails, statusDetailsLocal, statusDetailsRemote, + listWorkspaces, prepareCommitContext, commit: (cwd, subject, body, options) => withListRefsInvalidation(cwd, commit(cwd, subject, body, options)), diff --git a/apps/server/src/vcs/GitVcsDriverWorktree.test.ts b/apps/server/src/vcs/GitVcsDriverWorktree.test.ts new file mode 100644 index 000000000000..70276b84947b --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriverWorktree.test.ts @@ -0,0 +1,66 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; + +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-worktree-driver-test-", +}); +const serverConfigLiveLayer = serverConfigLayer.pipe(Layer.provide(NodeServices.layer)); +const testLayer = GitVcsDriver.layer.pipe( + Layer.provide(serverConfigLiveLayer), + Layer.provideMerge(NodeServices.layer), +); + +it.effect("lists registered Git workspaces including the primary checkout", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-git-worktree-repo-" }); + const worktreePath = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-git-worktree-root-" }), + "feature", + ); + + const run = (args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriverWorktreeTest.git", + cwd: repositoryRoot, + args, + }); + + yield* run(["init", "-b", "main"]); + yield* run(["config", "user.email", "test@example.com"]); + yield* run(["config", "user.name", "T3 Test"]); + yield* fs.writeFileString(path.join(repositoryRoot, "README.md"), "hello\n"); + yield* run(["add", "README.md"]); + yield* run(["commit", "-m", "initial"]); + yield* driver.createWorktree({ + cwd: repositoryRoot, + refName: "main", + newRefName: "feature/worktree", + path: worktreePath, + }); + + const workspaces = yield* driver.listWorkspaces(repositoryRoot); + const head = (yield* driver.resolveCommit({ cwd: repositoryRoot, revision: "HEAD" })).commitSha; + assert.deepInclude(workspaces, { + path: repositoryRoot, + refName: "main", + headCommit: head, + prunable: false, + }); + assert.deepInclude(workspaces, { + path: worktreePath, + refName: "feature/worktree", + headCommit: head, + prunable: false, + }); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/vcs/GitWorktree.test.ts b/apps/server/src/vcs/GitWorktree.test.ts new file mode 100644 index 000000000000..ac214f0a23ff --- /dev/null +++ b/apps/server/src/vcs/GitWorktree.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseGitWorktreeBranchPaths, parseGitWorktreeListPorcelain } from "./GitWorktree.ts"; + +describe("Git worktree porcelain parsing", () => { + it("parses NUL-terminated porcelain and filters detached or prunable branch paths", () => { + const stdout = [ + "worktree /repo/main", + "HEAD 1111111", + "branch refs/heads/main", + "", + "worktree /repo/line\nbreak", + "HEAD 2222222", + "branch refs/heads/feature/demo", + "", + "worktree /repo/detached", + "HEAD 3333333", + "detached", + "", + "worktree /repo/stale", + "HEAD 4444444", + "branch refs/heads/stale", + "prunable gitdir file points to non-existent location", + "", + ].join("\0"); + + expect(parseGitWorktreeListPorcelain(stdout)).toEqual([ + { path: "/repo/main", refName: "main", headCommit: "1111111", prunable: false }, + { + path: "/repo/line\nbreak", + refName: "feature/demo", + headCommit: "2222222", + prunable: false, + }, + { path: "/repo/detached", refName: null, headCommit: "3333333", prunable: false }, + { path: "/repo/stale", refName: "stale", headCommit: "4444444", prunable: true }, + ]); + expect(parseGitWorktreeBranchPaths(stdout)).toEqual( + new Map([ + ["main", "/repo/main"], + ["feature/demo", "/repo/line\nbreak"], + ]), + ); + }); +}); diff --git a/apps/server/src/vcs/GitWorktree.ts b/apps/server/src/vcs/GitWorktree.ts new file mode 100644 index 000000000000..879a6c247c97 --- /dev/null +++ b/apps/server/src/vcs/GitWorktree.ts @@ -0,0 +1,65 @@ +/** A checkout registered in local Git metadata. */ +export interface GitWorktree { + readonly path: string; + readonly refName: string | null; + readonly headCommit: string | null; + readonly prunable: boolean; +} + +/** + * Parse `git worktree list --porcelain -z`. Fields are NUL-terminated and + * records end with an extra NUL, so paths may contain newlines. `-z` needs + * Git 2.36, which listRefs already requires. + */ +export function parseGitWorktreeListPorcelain(stdout: string): GitWorktree[] { + const entries: GitWorktree[] = []; + let currentPath: string | null = null; + let currentRefName: string | null = null; + let currentHeadCommit: string | null = null; + let currentPrunable = false; + + const flush = () => { + if (currentPath !== null) { + entries.push({ + path: currentPath, + refName: currentRefName, + headCommit: currentHeadCommit, + prunable: currentPrunable, + }); + } + currentPath = null; + currentRefName = null; + currentHeadCommit = null; + currentPrunable = false; + }; + + for (const field of stdout.split("\0")) { + if (field === "") { + flush(); + } else if (field.startsWith("worktree ")) { + // Be tolerant of malformed output containing a new record without the + // expected empty separator. + if (currentPath !== null) flush(); + currentPath = field.slice("worktree ".length); + } else if (field.startsWith("HEAD ")) { + currentHeadCommit = field.slice("HEAD ".length); + } else if (field.startsWith("branch refs/heads/")) { + currentRefName = field.slice("branch refs/heads/".length); + } else if (field === "prunable" || field.startsWith("prunable ")) { + currentPrunable = true; + } + } + flush(); + + return entries; +} + +export function parseGitWorktreeBranchPaths(stdout: string): ReadonlyMap { + const worktreePaths = new Map(); + for (const entry of parseGitWorktreeListPorcelain(stdout)) { + if (entry.refName !== null && !entry.prunable) { + worktreePaths.set(entry.refName, entry.path); + } + } + return worktreePaths; +} diff --git a/apps/server/src/vcs/WorktreeLifecycle.test.ts b/apps/server/src/vcs/WorktreeLifecycle.test.ts new file mode 100644 index 000000000000..6ab9262f00bd --- /dev/null +++ b/apps/server/src/vcs/WorktreeLifecycle.test.ts @@ -0,0 +1,20 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import * as WorktreeLifecycle from "./WorktreeLifecycle.ts"; + +it.effect("publishes the current inventory revision to a new subscriber", () => + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; + + const initialChange = yield* Stream.runHead(lifecycle.changes); + assert.deepEqual(Option.getOrThrow(initialChange), { revision: 0 }); + + yield* lifecycle.markInventoryChanged; + const changed = yield* Stream.runHead(lifecycle.changes); + assert.deepEqual(Option.getOrThrow(changed), { revision: 1 }); + assert.equal(yield* lifecycle.revision, 1); + }).pipe(Effect.provide(WorktreeLifecycle.layer)), +); diff --git a/apps/server/src/vcs/WorktreeLifecycle.ts b/apps/server/src/vcs/WorktreeLifecycle.ts new file mode 100644 index 000000000000..92be7e84d545 --- /dev/null +++ b/apps/server/src/vcs/WorktreeLifecycle.ts @@ -0,0 +1,39 @@ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type { WorktreeInventoryChange } from "@t3tools/contracts"; + +/** + * The inventory revision that settings clients subscribe to. Every service + * that adds or removes a worktree bumps it, so this stays a leaf with no + * dependencies. Mutations coordinate through the per-path workspace lease, + * not here. + */ +export class WorktreeLifecycle extends Context.Service< + WorktreeLifecycle, + { + readonly markInventoryChanged: Effect.Effect; + readonly revision: Effect.Effect; + readonly changes: Stream.Stream; + } +>()("t3/vcs/WorktreeLifecycle") {} + +const make = Effect.gen(function* () { + // Seeded from the clock so a restarted server never repeats a revision a + // client still holds a list for: any difference tells it to read again. + const revision = yield* SubscriptionRef.make(yield* Clock.currentTimeMillis); + + return WorktreeLifecycle.of({ + markInventoryChanged: SubscriptionRef.update(revision, (current) => current + 1), + revision: SubscriptionRef.get(revision), + changes: SubscriptionRef.changes(revision).pipe( + Stream.map((currentRevision) => ({ revision: currentRevision })), + ), + }); +}); + +export const layer = Layer.effect(WorktreeLifecycle, make); diff --git a/apps/server/src/vcs/WorktreeManagedRoots.test.ts b/apps/server/src/vcs/WorktreeManagedRoots.test.ts new file mode 100644 index 000000000000..0541c301dae0 --- /dev/null +++ b/apps/server/src/vcs/WorktreeManagedRoots.test.ts @@ -0,0 +1,268 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { ThreadId } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as WorktreeRevivalService from "./WorktreeRevivalService.ts"; +import * as WorktreeService from "./WorktreeService.ts"; +import { + addWorktree, + initializeRepository, + makeHarness, + makeProject, + projectId, +} from "./WorktreeService.testkit.ts"; + +it.effect( + "lists, removes and revives default, current and previous worktrees after settings change", + () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const settings = yield* ServerSettings.ServerSettingsService; + const worktrees = yield* WorktreeService.WorktreeService; + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const previousRoot = yield* fs.makeTempDirectoryScoped({ prefix: "previous-worktrees-" }); + const currentRoot = yield* fs.makeTempDirectoryScoped({ prefix: "current-worktrees-" }); + const paths = [ + yield* addWorktree(repositoryRoot, "default"), + yield* addWorktree(repositoryRoot, "previous", { root: previousRoot }), + yield* addWorktree(repositoryRoot, "current", { root: currentRoot }), + ]; + yield* settings.updateSettings({ worktreesDirectory: previousRoot }); + assert.sameMembers( + (yield* worktrees.listWorktrees({})).worktrees.map((entry) => entry.path), + paths.slice(0, 2), + ); + yield* settings.updateSettings({ + worktreesDirectory: currentRoot, + }); + const inventory = yield* worktrees.listWorktrees({}); + assert.sameMembers( + inventory.worktrees.map((entry) => entry.path), + paths, + ); + const removed = yield* worktrees.pruneWorktrees({ projectId, paths }); + assert.sameMembers( + removed.removed.map((entry) => entry.path), + paths, + ); + assert.deepEqual(removed.skipped, []); + for (const entry of inventory.worktrees) { + assert.isFalse(yield* fs.exists(entry.path)); + const restored = yield* revival.reviveForThread({ + threadId: ThreadId.make(`revive-${entry.path}`), + projectId, + worktreePath: entry.path, + branch: entry.branch ?? assert.fail("Expected a named worktree branch"), + }); + assert.deepEqual(restored, { revived: true, generation: 1 }); + assert.isTrue(yield* fs.exists(entry.path)); + } + assert.sameMembers( + (yield* worktrees.listWorktrees({})).worktrees.map((entry) => entry.path), + paths, + ); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect( + "canonicalizes configured symlink roots and expands previous roots relative to home", + () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const settings = yield* ServerSettings.ServerSettingsService; + const worktrees = yield* WorktreeService.WorktreeService; + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "worktrees-home-" }); + const currentRoot = path.join(home, "current"); + const previousRoot = path.join(home, "previous"); + const rootAlias = path.join(home, "current-link"); + yield* fs.makeDirectory(currentRoot); + yield* fs.makeDirectory(previousRoot); + yield* fs.symlink(currentRoot, rootAlias); + const current = yield* addWorktree(repositoryRoot, "current-alias", { root: currentRoot }); + const previous = yield* addWorktree(repositoryRoot, "previous-home", { root: previousRoot }); + yield* settings.updateSettings({ worktreesDirectory: "~/previous" }); + yield* settings.updateSettings({ worktreesDirectory: currentRoot }); + yield* settings.updateSettings({ worktreesDirectory: rootAlias }); + + yield* Effect.gen(function* () { + const inventory = yield* worktrees.listWorktrees({}); + assert.sameMembers( + inventory.worktrees.map((entry) => entry.path), + [current, previous], + ); + const aliasedPath = path.join(rootAlias, "current-alias"); + const removed = yield* worktrees.pruneWorktrees({ + projectId, + paths: [aliasedPath, previous], + }); + assert.sameMembers( + removed.removed.map((entry) => entry.path), + [current, previous], + ); + assert.deepEqual(removed.skipped, []); + for (const [worktreePath, branch] of [ + [aliasedPath, "feature/current-alias"], + [previous, "feature/previous-home"], + ] as const) { + const result = yield* revival.reviveForThread({ + threadId: ThreadId.make(`revive-${worktreePath}`), + projectId, + worktreePath, + branch, + }); + assert.isTrue(result.revived); + assert.isTrue(yield* fs.exists(worktreePath)); + } + }).pipe(Effect.provideService(HostProcess.HomeDirectory, home)); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect( + "rejects filesystem roots, symlinks to roots and non-absolute configured directories", + () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const settings = yield* ServerSettings.ServerSettingsService; + const worktrees = yield* WorktreeService.WorktreeService; + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const externalRoot = yield* fs.makeTempDirectoryScoped({ prefix: "unmanaged-worktrees-" }); + const external = yield* addWorktree(repositoryRoot, "external", { root: externalRoot }); + const rootAlias = path.join(externalRoot, "filesystem-root"); + yield* fs.symlink(path.parse(externalRoot).root, rootAlias); + yield* settings.updateSettings({ worktreesDirectory: path.parse(externalRoot).root }); + yield* settings.updateSettings({ worktreesDirectory: "relative/worktrees" }); + yield* settings.updateSettings({ worktreesDirectory: rootAlias }); + assert.deepEqual((yield* worktrees.listWorktrees({})).worktrees, []); + const removal = yield* worktrees.removeIfSafe({ + intent: "manual", + workspaceRoot: repositoryRoot, + path: external, + }); + assert.deepEqual(removal, { outcome: "skipped", reason: "protected_path" }); + assert.isTrue(yield* fs.exists(external)); + yield* fs.remove(external, { recursive: true }); + const error = yield* revival + .reviveForThread({ + threadId: ThreadId.make("outside-managed-root"), + projectId, + worktreePath: external, + branch: "feature/external", + }) + .pipe(Effect.flip); + assert.equal(error.stage, "outside_managed_root"); + assert.isFalse(yield* fs.exists(external)); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect( + "protects configured roots and checkouts containing them even inside another managed root", + () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const settings = yield* ServerSettings.ServerSettingsService; + const worktrees = yield* WorktreeService.WorktreeService; + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const nestedRoot = yield* addWorktree(repositoryRoot, "nested-root"); + yield* settings.updateSettings({ worktreesDirectory: path.join(nestedRoot, "child-root") }); + assert.deepEqual((yield* worktrees.listWorktrees({})).worktrees, []); + assert.deepEqual( + yield* worktrees.removeIfSafe({ + intent: "manual", + workspaceRoot: repositoryRoot, + path: nestedRoot, + }), + { outcome: "skipped", reason: "protected_path" }, + ); + yield* settings.updateSettings({ worktreesDirectory: nestedRoot }); + assert.deepEqual((yield* worktrees.listWorktrees({})).worktrees, []); + assert.deepEqual( + yield* worktrees.removeIfSafe({ + intent: "manual", + workspaceRoot: repositoryRoot, + path: nestedRoot, + }), + { outcome: "skipped", reason: "protected_path" }, + ); + assert.isTrue(yield* fs.exists(nestedRoot)); + const missingRoot = path.join(config.worktreesDir, "missing-root"); + yield* settings.updateSettings({ worktreesDirectory: missingRoot }); + const error = yield* revival + .reviveForThread({ + threadId: ThreadId.make("managed-root-itself"), + projectId, + worktreePath: missingRoot, + branch: "feature/nested-root", + }) + .pipe(Effect.flip); + assert.equal(error.stage, "outside_managed_root"); + assert.isFalse(yield* fs.exists(missingRoot)); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect("rejects checkout paths that escape a configured root through a symlink", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const settings = yield* ServerSettings.ServerSettingsService; + const worktrees = yield* WorktreeService.WorktreeService; + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const configuredRoot = yield* fs.makeTempDirectoryScoped({ prefix: "configured-worktrees-" }); + const externalRoot = yield* fs.makeTempDirectoryScoped({ prefix: "external-worktrees-" }); + const external = yield* addWorktree(repositoryRoot, "escape", { root: externalRoot }); + const escapeLink = path.join(configuredRoot, "escape-link"); + yield* fs.symlink(externalRoot, escapeLink); + const escapedPath = path.join(escapeLink, "escape"); + yield* settings.updateSettings({ worktreesDirectory: configuredRoot }); + assert.deepEqual((yield* worktrees.listWorktrees({})).worktrees, []); + assert.deepEqual( + yield* worktrees.removeIfSafe({ + intent: "manual", + workspaceRoot: repositoryRoot, + path: escapedPath, + }), + { outcome: "skipped", reason: "protected_path" }, + ); + assert.isTrue(yield* fs.exists(external)); + yield* fs.remove(external, { recursive: true }); + const error = yield* revival + .reviveForThread({ + threadId: ThreadId.make("escaped-root"), + projectId, + worktreePath: escapedPath, + branch: "feature/escape", + }) + .pipe(Effect.flip); + assert.equal(error.stage, "outside_managed_root"); + assert.isFalse(yield* fs.exists(external)); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/vcs/WorktreeRevivalService.test.ts b/apps/server/src/vcs/WorktreeRevivalService.test.ts new file mode 100644 index 000000000000..3d94ae10698d --- /dev/null +++ b/apps/server/src/vcs/WorktreeRevivalService.test.ts @@ -0,0 +1,1039 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { + GitCommandError, + ProjectId, + ThreadId, + type Project, + type WorktreeSubmodules, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as NodePtyAdapter from "../terminal/NodePtyAdapter.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as WorktreeLifecycle from "./WorktreeLifecycle.ts"; +import * as WorktreeRevivalService from "./WorktreeRevivalService.ts"; + +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-worktree-revival-test-", +}); +const serverConfigLiveLayer = serverConfigLayer.pipe(Layer.provide(NodeServices.layer)); +const gitLayer = GitVcsDriver.layer.pipe( + Layer.provide(serverConfigLiveLayer), + Layer.provideMerge(NodeServices.layer), +); + +const projectId = ProjectId.make("project-worktree-revival"); +const threadId = ThreadId.make("thread-worktree-revival"); + +const makeProject = (workspaceRoot: string): Project => ({ + id: projectId, + title: "Revival project", + workspaceRoot, + defaultModelSelection: null, + scripts: [ + { + id: "setup", + name: "Setup", + command: "vp i", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +const initializeRepository = Effect.fn("WorktreeRevivalServiceTest.initializeRepository")( + function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-worktree-revival-repo-", + }); + + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.init", + cwd: repositoryRoot, + args: ["init", "-b", "main"], + }); + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.userEmail", + cwd: repositoryRoot, + args: ["config", "user.email", "test@example.com"], + }); + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.userName", + cwd: repositoryRoot, + args: ["config", "user.name", "T3 Test"], + }); + yield* fs.writeFileString(path.join(repositoryRoot, "README.md"), "hello\n"); + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.add", + cwd: repositoryRoot, + args: ["add", "README.md"], + }); + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.commit", + cwd: repositoryRoot, + args: ["commit", "-m", "initial"], + }); + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.branch", + cwd: repositoryRoot, + args: ["branch", "feature/revival"], + }); + + return repositoryRoot; + }, +); + +const makeRevivalLayer = ( + project: Project, + runForThread: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"], + options: { + readonly config?: ServerConfig.ServerConfig["Service"]; + readonly projects?: readonly Project[]; + readonly settings?: Parameters[0]; + readonly git?: GitVcsDriver.GitVcsDriver["Service"]; + } = {}, +) => + WorktreeRevivalService.layer.pipe( + Layer.provide( + Layer.mergeAll( + options.config === undefined + ? serverConfigLiveLayer + : Layer.succeed(ServerConfig.ServerConfig, options.config), + NodeServices.layer, + options.git === undefined + ? gitLayer + : Layer.succeed(GitVcsDriver.GitVcsDriver, options.git), + WorktreeLifecycle.layer, + ServerSettings.layerTest(options.settings), + Layer.mock(ProjectService.ProjectService)({ + getById: (requestedProjectId) => + Effect.succeed( + Option.fromNullishOr( + (options.projects ?? [project]).find( + (project) => project.id === requestedProjectId, + ), + ), + ), + snapshot: Effect.succeed({ + projects: options.projects ?? [project], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + }), + Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { runForThread }), + ), + ), + ); + +it.effect( + "rejects a missing destination whose existing symlink ancestor escapes the managed root", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const outsideRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-worktree-revival-outside-", + }); + const symlinkPath = path.join(config.worktreesDir, "outside-link"); + const requestedPath = path.join(symlinkPath, "revived"); + const escapedPath = path.join(outsideRoot, "revived"); + yield* fs.symlink(outsideRoot, symlinkPath); + + const error = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + return yield* revival.reviveForThread({ + threadId, + projectId, + worktreePath: requestedPath, + branch: "feature/revival", + }); + }).pipe( + Effect.provide( + makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.succeed({ status: "no-script" }), + ), + ), + Effect.flip, + ); + + assert.equal(error._tag, "WorktreeMutationError"); + assert.deepInclude(error, { stage: "outside_managed_root" }); + assert.isFalse(yield* fs.exists(escapedPath)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("revives worktrees whose first path segment starts with two dots", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "..cache", "revived"); + + const result = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + return yield* revival.reviveForThread({ + threadId, + projectId, + worktreePath, + branch: "feature/revival", + }); + }).pipe( + Effect.provide( + makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.succeed({ status: "no-script" }), + ), + ), + ); + + assert.isTrue(result.revived); + assert.isTrue(yield* fs.exists(worktreePath)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +const addWorktree = Effect.fn("WorktreeRevivalServiceTest.addWorktree")(function* ( + repositoryRoot: string, + worktreePath: string, +) { + const git = yield* GitVcsDriver.GitVcsDriver; + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.worktreeAdd", + cwd: repositoryRoot, + args: ["worktree", "add", worktreePath, "feature/revival"], + }); +}); + +it.effect("leaves an existing worktree alone when its HEAD is detached", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const git = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "detached"); + yield* addWorktree(repositoryRoot, worktreePath); + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.detach", + cwd: worktreePath, + args: ["checkout", "--detach"], + }); + + const result = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + return yield* revival.reviveForThread({ + threadId, + projectId, + worktreePath, + branch: "feature/revival", + }); + }).pipe( + Effect.provide( + makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.succeed({ status: "no-script" }), + ), + ), + ); + + assert.deepEqual(result, { revived: false, generation: 0 }); + const head = yield* git.execute({ + operation: "WorktreeRevivalServiceTest.symbolicRef", + cwd: worktreePath, + args: ["symbolic-ref", "-q", "HEAD"], + allowNonZeroExit: true, + }); + assert.notEqual(head.exitCode, 0, "HEAD must stay detached"); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("leaves an existing checkout outside the managed root alone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repositoryRoot = yield* initializeRepository(); + const outsideRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-worktree-revival-outside-", + }); + const worktreePath = path.join(outsideRoot, "checkout"); + yield* addWorktree(repositoryRoot, worktreePath); + + const result = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + return yield* revival.reviveForThread({ + threadId, + projectId, + worktreePath, + branch: "feature/revival", + }); + }).pipe( + Effect.provide( + makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.succeed({ status: "no-script" }), + ), + ), + ); + + assert.deepEqual(result, { revived: false, generation: 0 }); + assert.isTrue(yield* fs.exists(path.join(worktreePath, "README.md"))); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("leaves a complete checkout when the turn start is cancelled during creation", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "interruption", "revived"); + const project = makeProject(repositoryRoot); + const createEntered = yield* Deferred.make(); + const releaseCreate = yield* Deferred.make(); + let setupAttempts = 0; + const runSetup = () => + Effect.sync(() => { + setupAttempts++; + return { status: "no-script" as const }; + }); + const layer = WorktreeRevivalService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ServerConfig.ServerConfig, config), + NodeServices.layer, + ServerSettings.layerTest(), + Layer.succeed(GitVcsDriver.GitVcsDriver, { + ...driver, + createWorktree: (input, options) => + Deferred.succeed(createEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseCreate)), + Effect.andThen(driver.createWorktree(input, options)), + ), + }), + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.some(project)), + snapshot: Effect.succeed({ + projects: [project], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + }), + Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { + runForThread: runSetup, + }), + ), + ), + ); + + yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const lifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; + const revivalFiber = yield* revival + .reviveForThread({ threadId, projectId, worktreePath, branch: "feature/revival" }) + .pipe(Effect.forkChild); + yield* Deferred.await(createEntered); + const interruptionFiber = yield* Fiber.interrupt(revivalFiber).pipe(Effect.forkChild); + yield* Deferred.succeed(releaseCreate, undefined); + yield* Fiber.join(interruptionFiber); + + assert.isTrue(yield* fs.exists(path.join(worktreePath, "README.md"))); + assert.equal(yield* lifecycle.revision, 1); + }).pipe(Effect.provide(layer.pipe(Layer.provideMerge(WorktreeLifecycle.layer)))); + assert.equal(setupAttempts, 0); + yield* Effect.flatMap(WorktreeRevivalService.WorktreeRevivalService, (service) => + service.reviveForThread({ threadId, projectId, worktreePath, branch: "feature/revival" }), + ).pipe(Effect.provide(makeRevivalLayer(project, runSetup, { config }))); + assert.equal( + setupAttempts, + 1, + "creation cancellation must retain pending setup across restart", + ); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("retries setup after a recreated worktree's first setup attempt fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "failure"); + const setupFailure = new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ + threadId, + projectId, + projectCwd: repositoryRoot, + worktreePath, + operation: "openTerminal", + cause: "simulated setup failure", + }); + let setupAttempts = 0; + const layer = makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.suspend(() => { + setupAttempts += 1; + return setupAttempts === 1 + ? Effect.fail(setupFailure) + : Effect.succeed({ status: "no-script" as const }); + }), + ); + + const { error, retry } = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const error = yield* revival + .reviveForThread({ + threadId, + projectId, + worktreePath, + branch: "feature/revival", + }) + .pipe(Effect.flip); + const retry = yield* revival.reviveForThread({ + threadId, + projectId, + worktreePath, + branch: "feature/revival", + }); + return { error, retry }; + }).pipe(Effect.provide(layer)); + + assert.equal(error._tag, "WorktreeMutationError"); + assert.equal(error.message, "Failed to run the project setup script after revival."); + assert.strictEqual(error.cause, setupFailure); + assert.isFalse(retry.revived); + assert.equal(retry.generation, 1); + assert.equal(setupAttempts, 2); + assert.isTrue(yield* fs.exists(worktreePath)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +/** A setup script that has started and settles when `completion` does. */ +const startedSetup = ( + worktreePath: string, + async: boolean, + completion: Effect.Effect, +) => + ({ + status: "started", + scriptId: "setup", + scriptName: "Setup", + scriptCommand: "vp i", + terminalId: "setup-setup", + cwd: worktreePath, + async, + completion, + }) as const; + +it.effect( + "keeps a required setup running once when the turn start that began it is cancelled", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "required"); + const setupStarted = yield* Deferred.make(); + const setupFinished = + yield* Deferred.make(); + let setupRuns = 0; + const layer = makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.sync(() => { + setupRuns += 1; + }).pipe( + Effect.andThen(Deferred.succeed(setupStarted, undefined)), + Effect.as(startedSetup(worktreePath, false, Deferred.await(setupFinished))), + ), + ); + const input = { threadId, projectId, worktreePath, branch: "feature/revival" }; + + const result = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const cancelled = yield* revival.reviveForThread(input).pipe(Effect.forkChild); + yield* Deferred.await(setupStarted); + yield* Fiber.interrupt(cancelled); + const next = yield* revival.reviveForThread(input).pipe(Effect.forkChild); + yield* Deferred.succeed(setupFinished, { exitCode: 0, durationMs: 1 }); + return yield* Fiber.join(next); + }).pipe(Effect.provide(layer)); + + assert.deepEqual(result, { revived: false, generation: 1 }); + assert.equal(setupRuns, 1); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("fails a revival whose required setup exits non-zero and reruns it next time", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "exit-code"); + const exitCodes = [1, 0]; + const layer = makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.sync(() => + startedSetup( + worktreePath, + false, + Effect.succeed({ exitCode: exitCodes.shift() ?? 0, durationMs: 1 }), + ), + ), + ); + const input = { threadId, projectId, worktreePath, branch: "feature/revival" }; + + const { error, retry } = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const error = yield* revival.reviveForThread(input).pipe(Effect.flip); + const retry = yield* revival.reviveForThread(input); + return { error, retry }; + }).pipe(Effect.provide(layer)); + + assert.equal(error.stage, "setup_exit_nonzero"); + assert.equal(error.exitCode, 1); + assert.equal(error.message, "Project setup exited with 1 after worktree revival."); + assert.deepEqual(retry, { revived: false, generation: 1 }); + assert.deepEqual(exitCodes, []); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("does not wait for a setup script that lets the agent start alongside it", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "async"); + const layer = makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.succeed(startedSetup(worktreePath, true, Effect.never)), + ); + + const result = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + return yield* revival.reviveForThread({ + threadId, + projectId, + worktreePath, + branch: "feature/revival", + }); + }).pipe(Effect.provide(layer)); + + assert.deepEqual(result, { revived: true, generation: 1 }); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("retries required setup after restart and remembers success on the next restart", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "restart"); + let attempts = 0; + const layer = makeRevivalLayer( + makeProject(repositoryRoot), + () => + Effect.sync(() => + startedSetup( + worktreePath, + false, + Effect.succeed({ + exitCode: ++attempts === 1 ? 1 : 0, + durationMs: 1, + }), + ), + ), + { config }, + ); + const start = Effect.flatMap(WorktreeRevivalService.WorktreeRevivalService, (service) => + service.reviveForThread({ threadId, projectId, worktreePath, branch: "feature/revival" }), + ); + const error = yield* start.pipe(Effect.flip, Effect.provide(layer)); + assert.equal(error.stage, "setup_exit_nonzero"); + const retry = yield* start.pipe(Effect.provide(Layer.fresh(layer))); + assert.deepEqual(retry, { revived: false, generation: 0 }); + assert.equal(attempts, 2); + yield* start.pipe(Effect.provide(Layer.fresh(layer))); + assert.equal(attempts, 2, "successful setup must survive another restart"); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("remembers async setup startup across restart without waiting for completion", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "async-restart"); + let attempts = 0; + const layer = makeRevivalLayer( + makeProject(repositoryRoot), + () => + Effect.sync(() => { + attempts++; + return startedSetup(worktreePath, true, Effect.never); + }), + { config }, + ); + const start = Effect.flatMap(WorktreeRevivalService.WorktreeRevivalService, (service) => + service.reviveForThread({ threadId, projectId, worktreePath, branch: "feature/revival" }), + ); + yield* start.pipe(Effect.provide(layer)); + yield* start.pipe(Effect.provide(Layer.fresh(layer))); + assert.equal(attempts, 1); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("invalidates a failed creation and retains setup readiness for its checkout", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "create-failure"); + let attempts = 0; + const runSetup = () => + Effect.sync(() => { + attempts++; + return { status: "no-script" as const }; + }); + const project = makeProject(repositoryRoot); + const start = Effect.flatMap(WorktreeRevivalService.WorktreeRevivalService, (service) => + service.reviveForThread({ threadId, projectId, worktreePath, branch: "feature/revival" }), + ); + const failureLayer = makeRevivalLayer(project, runSetup, { + config, + git: { + ...driver, + createWorktree: (input, options) => + driver.createWorktree(input, options).pipe( + Effect.andThen( + Effect.fail( + new GitCommandError({ + operation: "createWorktree", + command: "git", + cwd: repositoryRoot, + detail: "simulated creation failure", + cause: "failure after checkout creation", + }), + ), + ), + ), + }, + }); + yield* Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; + const revisionBefore = yield* lifecycle.revision; + const error = yield* start.pipe(Effect.flip); + assert.equal(error.stage, "create_worktree"); + assert.equal(attempts, 0); + assert.isAbove(yield* lifecycle.revision, revisionBefore); + const retry = yield* start; + assert.deepEqual(retry, { revived: false, generation: 1 }); + assert.equal(attempts, 1); + }).pipe(Effect.provide(failureLayer.pipe(Layer.provideMerge(WorktreeLifecycle.layer)))); + yield* start.pipe(Effect.provide(makeRevivalLayer(project, runSetup, { config }))); + assert.equal(attempts, 1, "successful setup must survive a restart"); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("does not retry async setup after its later non-zero exit", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "async-failure"); + const finished = yield* Deferred.make(); + const observed = yield* Deferred.make(); + let attempts = 0; + const layer = makeRevivalLayer( + makeProject(repositoryRoot), + () => + Effect.sync(() => { + attempts++; + return startedSetup( + worktreePath, + true, + Deferred.await(finished).pipe(Effect.tap(() => Deferred.succeed(observed, undefined))), + ); + }), + { config }, + ); + const start = Effect.flatMap(WorktreeRevivalService.WorktreeRevivalService, (service) => + service.reviveForThread({ threadId, projectId, worktreePath, branch: "feature/revival" }), + ); + yield* Effect.gen(function* () { + yield* start; + yield* Deferred.succeed(finished, { exitCode: 1, durationMs: 1 }); + yield* Deferred.await(observed); + yield* start; + assert.equal(attempts, 1); + }).pipe(Effect.provide(layer)); + yield* start.pipe(Effect.provide(Layer.fresh(layer))); + assert.equal(attempts, 1); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("an older setup completion cannot clear a replacement checkout's pending setup", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "replacement"); + const firstStarted = yield* Deferred.make(); + const secondStarted = yield* Deferred.make(); + const firstFinished = + yield* Deferred.make(); + let attempts = 0; + const layer = makeRevivalLayer( + makeProject(repositoryRoot), + () => + Effect.suspend(() => { + attempts++; + if (attempts === 1) + return Deferred.succeed(firstStarted, undefined).pipe( + Effect.as(startedSetup(worktreePath, false, Deferred.await(firstFinished))), + ); + if (attempts === 2) + return Deferred.succeed(secondStarted, undefined).pipe( + Effect.as(startedSetup(worktreePath, false, Effect.never)), + ); + return Effect.succeed( + startedSetup(worktreePath, false, Effect.succeed({ exitCode: 0, durationMs: 1 })), + ); + }), + { config }, + ); + const input = { threadId, projectId, worktreePath, branch: "feature/revival" }; + yield* Effect.gen(function* () { + const service = yield* WorktreeRevivalService.WorktreeRevivalService; + const first = yield* service.reviveForThread(input).pipe(Effect.forkChild); + yield* Deferred.await(firstStarted); + yield* fs.remove(worktreePath, { recursive: true }); + const second = yield* service.reviveForThread(input).pipe(Effect.forkChild); + yield* Deferred.await(secondStarted); + yield* Deferred.succeed(firstFinished, { exitCode: 0, durationMs: 1 }); + const stale = yield* Fiber.join(first).pipe(Effect.flip); + assert.equal(stale.stage, "setup_readiness_changed"); + assert.equal( + stale.message, + "Worktree readiness changed during project setup. Retry the turn.", + ); + yield* Fiber.interrupt(second); + }).pipe(Effect.provide(layer)); + const retry = yield* Effect.flatMap(WorktreeRevivalService.WorktreeRevivalService, (service) => + service.reviveForThread(input), + ).pipe(Effect.provide(Layer.fresh(layer))); + assert.deepEqual(retry, { revived: false, generation: 0 }); + assert.equal(attempts, 3); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "retries setup in the same shell until the checkout is replaced, then starts a fresh shell", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-revival-shell-" }); + const attemptsPath = path.join(scratch, "attempts"); + const scriptPath = path.join(scratch, "setup.cjs"); + yield* fs.writeFileString( + scriptPath, + [ + 'const fs = require("node:fs");', + "const cwd = process.cwd();", + `const attemptsPath = ${JSON.stringify(attemptsPath)};`, + "const attempts = fs.existsSync(attemptsPath) ? Number(fs.readFileSync(attemptsPath)) : 0;", + "fs.writeFileSync(attemptsPath, String(attempts + 1));", + "if (attempts < 2) process.exit(1);", + 'fs.writeFileSync("ready", cwd);', + ].join("\n"), + ); + const worktreePath = path.join(config.worktreesDir, "setup", "replacement-shell"); + const project = { + ...makeProject(repositoryRoot), + scripts: [ + { + id: "setup", + name: "Setup", + icon: "configure", + runOnWorktreeCreate: true, + async: false, + command: `'${process.execPath.replaceAll("'", "'\\''")}' '${scriptPath.replaceAll("'", "'\\''")}'`, + }, + ], + } satisfies Project; + const nativePty = yield* NodePtyAdapter.make(); + let spawns = 0; + const terminal = yield* TerminalManager.makeWithOptions({ + logsDir: path.join(scratch, "logs"), + shellResolver: () => "/bin/bash", + env: { ...process.env, SHELL: "/bin/bash" }, + ptyAdapter: { + spawn: (input) => + nativePty.spawn({ ...input, args: ["--noprofile", "--norc"] }).pipe( + Effect.tap(() => + Effect.sync(() => { + spawns++; + }), + ), + ), + }, + }); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provideService(TerminalManager.TerminalManager, terminal), + Effect.provideService(HostProcess.Environment, { ...process.env, SHELL: "/bin/bash" }), + Effect.provide( + Layer.mergeAll(Layer.mock(ProjectService.ProjectService)({}), ServerSettings.layerTest()), + ), + ); + yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const input = { threadId, projectId, worktreePath, branch: "feature/revival" }; + for (let attempt = 0; attempt < 2; attempt++) { + const error = yield* revival.reviveForThread(input).pipe(Effect.flip); + assert.equal(error.stage, "setup_exit_nonzero"); + assert.equal(error.exitCode, 1); + } + assert.equal(spawns, 1); + yield* fs.remove(worktreePath, { recursive: true }); + const replacement = yield* revival.reviveForThread(input); + assert.deepEqual(replacement, { revived: true, generation: 2 }); + assert.equal(spawns, 2); + assert.equal( + yield* fs.readFileString(path.join(worktreePath, "ready")), + yield* fs.realPath(worktreePath), + ); + yield* revival.reviveForThread(input); + assert.equal(yield* fs.readFileString(attemptsPath), "3"); + }).pipe(Effect.provide(makeRevivalLayer(project, runner.runForThread, { config }))); + }).pipe( + Effect.provide( + Layer.mergeAll( + serverConfigLiveLayer, + NodeServices.layer, + gitLayer, + ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)), + ), + ), + ), +); + +it.effect( + "keeps separate setup readiness for nested projects and aliases of the same Git repository", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const nestedRoot = path.join(repositoryRoot, "subproject"); + yield* fs.makeDirectory(nestedRoot); + const aliasRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-revival-alias-" }); + const aliasPath = path.join(aliasRoot, "repository"); + yield* fs.symlink(repositoryRoot, aliasPath); + const worktreePath = path.join(config.worktreesDir, "setup", "shared-projects"); + const firstProject = makeProject(repositoryRoot); + const secondProject = { ...makeProject(nestedRoot), id: ProjectId.make("second-project") }; + const aliasProject = { + ...makeProject(path.join(aliasPath, "subproject")), + id: ProjectId.make("alias-project"), + }; + const unrelatedProject = { + ...makeProject(yield* initializeRepository()), + id: ProjectId.make("unrelated-project"), + }; + const nonGitRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-revival-nongit-" }); + const nonGitProject = { ...makeProject(nonGitRoot), id: ProjectId.make("nongit-project") }; + const missingProject = { + ...makeProject(path.join(nonGitRoot, "missing")), + id: ProjectId.make("missing-project"), + }; + const projects = [ + firstProject, + secondProject, + aliasProject, + unrelatedProject, + nonGitProject, + missingProject, + ]; + const attempts: Array = []; + const layer = makeRevivalLayer( + firstProject, + (input) => + Effect.sync(() => { + attempts.push(input.projectId); + return startedSetup( + worktreePath, + false, + Effect.succeed({ + exitCode: attempts.length === 2 ? 1 : 0, + durationMs: 1, + }), + ); + }), + { config, projects }, + ); + const start = (projectId: ProjectId) => + Effect.flatMap(WorktreeRevivalService.WorktreeRevivalService, (service) => + service.reviveForThread({ threadId, projectId, worktreePath, branch: "feature/revival" }), + ).pipe(Effect.provide(Layer.fresh(layer))); + yield* start(firstProject.id); + const error = yield* start(secondProject.id).pipe(Effect.flip); + assert.deepInclude(error, { stage: "setup_exit_nonzero", exitCode: 1 }); + yield* start(secondProject.id); + yield* start(aliasProject.id); + yield* start(firstProject.id); + yield* start(secondProject.id); + yield* start(aliasProject.id); + yield* start(unrelatedProject.id); + yield* start(nonGitProject.id); + yield* start(missingProject.id); + assert.deepEqual(attempts, [ + firstProject.id, + secondProject.id, + secondProject.id, + aliasProject.id, + ]); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("rejects a missing checkout that contains a configured managed root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "ancestor"); + const error = yield* Effect.flatMap(WorktreeRevivalService.WorktreeRevivalService, (service) => + service.reviveForThread({ threadId, projectId, worktreePath, branch: "feature/revival" }), + ).pipe( + Effect.flip, + Effect.provide( + makeRevivalLayer( + makeProject(repositoryRoot), + () => Effect.succeed({ status: "no-script" }), + { + config, + settings: { worktreesDirectory: path.join(worktreePath, "managed-root") }, + }, + ), + ), + ); + assert.equal(error.stage, "outside_managed_root"); + assert.isFalse(yield* fs.exists(worktreePath)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("resolves environment and project submodule settings before the checkout's t3.json", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const driver = yield* GitVcsDriver.GitVcsDriver; + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) delete process.env.GIT_ALLOW_PROTOCOL; + else process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + }), + ); + const nested = yield* initializeRepository(); + const inner = yield* initializeRepository(); + const repositoryRoot = yield* initializeRepository(); + const git = (cwd: string, args: readonly string[]) => + driver.execute({ + operation: "WorktreeRevivalServiceTest.submodules", + cwd, + args, + }); + yield* git(inner, ["submodule", "add", nested, "nested"]); + yield* git(inner, ["commit", "-am", "add nested submodule"]); + yield* git(repositoryRoot, ["submodule", "add", inner, "inner"]); + yield* git(repositoryRoot, ["commit", "-am", "add inner submodule"]); + const cases: readonly { + name: string; + file: WorktreeSubmodules | undefined; + environment: WorktreeSubmodules | null; + project?: WorktreeSubmodules; + populated: boolean; + }[] = [ + { name: "environment-none", file: "recursive", environment: "none", populated: false }, + { name: "environment-recursive", file: "none", environment: "recursive", populated: true }, + { + name: "project-none", + file: "recursive", + environment: "recursive", + project: "none", + populated: false, + }, + { + name: "project-recursive", + file: "none", + environment: "none", + project: "recursive", + populated: true, + }, + { name: "file-none", file: "none", environment: null, populated: false }, + { name: "default-recursive", file: undefined, environment: null, populated: true }, + ]; + for (const testCase of cases) { + yield* fs.writeFileString( + path.join(repositoryRoot, "t3.json"), + JSON.stringify(testCase.file === undefined ? {} : { worktreeSubmodules: testCase.file }), + ); + yield* git(repositoryRoot, ["add", "t3.json"]); + yield* git(repositoryRoot, ["commit", "--allow-empty", "-m", testCase.name]); + const branch = `feature/${testCase.name}`; + yield* git(repositoryRoot, ["branch", branch]); + const worktreePath = path.join(config.worktreesDir, testCase.name); + yield* Effect.flatMap(WorktreeRevivalService.WorktreeRevivalService, (service) => + service.reviveForThread({ threadId, projectId, worktreePath, branch }), + ).pipe( + Effect.provide( + makeRevivalLayer( + makeProject(repositoryRoot), + () => Effect.succeed({ status: "no-script" }), + { + config, + settings: { + worktreeSubmodules: testCase.environment, + projectSettingsOverrides: + testCase.project === undefined + ? {} + : { + [projectId]: { worktreeSubmodules: testCase.project }, + }, + }, + }, + ), + ), + ); + assert.equal( + yield* fs.exists(path.join(worktreePath, "inner", "README.md")), + testCase.populated, + testCase.name, + ); + assert.equal( + yield* fs.exists(path.join(worktreePath, "inner", "nested", "README.md")), + testCase.populated, + testCase.name, + ); + } + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); diff --git a/apps/server/src/vcs/WorktreeRevivalService.testkit.ts b/apps/server/src/vcs/WorktreeRevivalService.testkit.ts new file mode 100644 index 000000000000..7bd8932b2de5 --- /dev/null +++ b/apps/server/src/vcs/WorktreeRevivalService.testkit.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as WorktreeRevivalService from "./WorktreeRevivalService.ts"; + +export const layerNoop = Layer.succeed( + WorktreeRevivalService.WorktreeRevivalService, + WorktreeRevivalService.WorktreeRevivalService.of({ + reviveForThread: () => Effect.succeed({ revived: false, generation: 0 }), + }), +); diff --git a/apps/server/src/vcs/WorktreeRevivalService.ts b/apps/server/src/vcs/WorktreeRevivalService.ts new file mode 100644 index 000000000000..ba7866d33990 --- /dev/null +++ b/apps/server/src/vcs/WorktreeRevivalService.ts @@ -0,0 +1,714 @@ +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Hex from "effect/encoding/Hex"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +import { + type ProjectId, + type ThreadId, + type WorktreeMutationErrorStage, + WorktreeMutationError, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { resolveWorkspaceLeasePath, withWorkspaceLease } from "../workspace/workspaceLease.ts"; +import { isFilesystemRoot, managedWorktreesDirectories } from "../worktreesDirectory.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as WorktreeLifecycle from "./WorktreeLifecycle.ts"; + +const PROJECT_SCAN_CONCURRENCY = 4; + +function isPathInside( + root: string, + candidate: string, + path: { + readonly relative: (from: string, to: string) => string; + readonly isAbsolute: (value: string) => boolean; + readonly sep: string; + }, +): boolean { + const relative = path.relative(root, candidate); + return ( + relative.length > 0 && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +function mutationError( + stage: WorktreeMutationErrorStage, + cause?: unknown, + context: { + readonly path?: string; + readonly conflictingPath?: string; + readonly workspaceRoot?: string; + readonly branch?: string; + readonly projectId?: ProjectId; + readonly exitCode?: number | null; + } = {}, +): WorktreeMutationError { + return new WorktreeMutationError({ + operation: "revive", + stage, + ...(context.path === undefined ? {} : { path: context.path }), + ...(context.conflictingPath === undefined ? {} : { conflictingPath: context.conflictingPath }), + ...(context.workspaceRoot === undefined ? {} : { workspaceRoot: context.workspaceRoot }), + ...(context.branch === undefined ? {} : { branch: context.branch }), + ...(context.projectId === undefined ? {} : { projectId: context.projectId }), + ...(context.exitCode === undefined ? {} : { exitCode: context.exitCode }), + ...(cause === undefined ? {} : { cause }), + }); +} + +export interface WorktreeRevivalForThreadInput { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly worktreePath: string; + readonly branch: string; +} + +interface WorktreeRevivalInput { + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly worktreePath: string; + readonly branch: string; +} + +export class WorktreeRevivalService extends Context.Service< + WorktreeRevivalService, + { + /** + * Makes sure a thread's worktree exists before its turn starts, and that + * project setup has let the agent in. `generation` advances when recreation + * may have replaced the directory, even if Git then fails, so a caller can + * restart a provider process that still holds the old directory. + */ + readonly reviveForThread: ( + input: WorktreeRevivalForThreadInput, + ) => Effect.Effect< + { readonly revived: boolean; readonly generation: number }, + WorktreeMutationError + >; + } +>()("t3/vcs/WorktreeRevivalService") {} + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const git = yield* GitVcsDriver.GitVcsDriver; + const lifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; + const projectsService = yield* ProjectService.ProjectService; + const setupScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const settings = yield* ServerSettings.ServerSettingsService; + const generationByWorktreePath = yield* Ref.make(new Map()); + // Project setup for a recreated worktree: one run per project, worktree, and + // generation, shared by every turn start that needs it. + const setupRuns = yield* Ref.make( + new Map< + string, + { + readonly token: string; + readonly outcome: Deferred.Deferred; + } + >(), + ); + // Setup runs are forked here so they outlive the turn start that began them. + const serviceScope = yield* Effect.scope; + + const setupKey = (projectId: ProjectId, worktreePath: string) => `${projectId}\0${worktreePath}`; + const pendingSetupPath = (projectId: ProjectId, worktreePath: string) => + crypto.digest("SHA-256", new TextEncoder().encode(setupKey(projectId, worktreePath))).pipe( + Effect.map((digest) => + path.join(config.stateDir, "worktree-revival-setup", Hex.encode(digest)), + ), + Effect.mapError((cause) => + mutationError("run_setup", cause, { projectId, path: worktreePath }), + ), + ); + const readPendingSetup = (projectId: ProjectId, worktreePath: string) => + pendingSetupPath(projectId, worktreePath).pipe( + Effect.flatMap((markerPath) => + fs.readFileString(markerPath).pipe( + Effect.catchIf( + (cause) => cause.reason._tag === "NotFound", + () => Effect.void, + ), + Effect.mapError((cause) => + mutationError("run_setup", cause, { projectId, path: worktreePath }), + ), + ), + ), + ); + const currentGeneration = (worktreePath: string) => + Ref.get(generationByWorktreePath).pipe( + Effect.map((generations) => generations.get(worktreePath) ?? 0), + ); + const advanceGeneration = (worktreePath: string) => + Ref.modify(generationByWorktreePath, (generations) => { + const generation = (generations.get(worktreePath) ?? 0) + 1; + const next = new Map(generations); + next.set(worktreePath, generation); + return [generation, next] as const; + }); + + // Canonicalize through symlinks so configured roots, Git metadata, and V2 + // thread paths compare equal on hosts such as macOS (/var vs /private/var). + // The same key the workspace lease uses: a missing checkout resolves through + // its deepest existing ancestor. + const canonicalizePath = (value: string) => + resolveWorkspaceLeasePath(value).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + const readManagedWorktreesRoots = Effect.fn("WorktreeRevivalService.readManagedWorktreesRoots")( + function* () { + const directories = yield* settings.getSettings.pipe( + Effect.orElseSucceed(() => ({ worktreesDirectory: "", previousWorktreesDirectories: [] })), + ); + const roots = yield* Effect.forEach( + managedWorktreesDirectories( + directories, + config.worktreesDir, + path, + yield* HostProcess.HomeDirectory, + ), + canonicalizePath, + ); + return [...new Set(roots.filter((root) => !isFilesystemRoot(root, path)))]; + }, + ); + + const resolveManagedWorkspaceRoot = Effect.fn( + "WorktreeRevivalService.resolveManagedWorkspaceRoot", + )(function* (input: WorktreeRevivalInput) { + const requestedWorkspaceRoot = yield* canonicalizePath(input.workspaceRoot); + const projectSnapshot = yield* projectsService.snapshot.pipe( + Effect.mapError((cause) => + mutationError("load_projects", cause, { + workspaceRoot: requestedWorkspaceRoot, + branch: input.branch, + }), + ), + ); + const projectRoots = yield* Effect.forEach( + projectSnapshot.projects, + (project) => canonicalizePath(project.workspaceRoot), + { concurrency: PROJECT_SCAN_CONCURRENCY }, + ); + if (!projectRoots.includes(requestedWorkspaceRoot)) { + return yield* mutationError("unmanaged_workspace", undefined, { + workspaceRoot: requestedWorkspaceRoot, + branch: input.branch, + }); + } + // Inventory groups projects by Git's common directory, including nested + // project roots and aliases. A non-Git or missing project has no shared + // checkout readiness and must not prevent another project's revival. + const repositoryKeys = yield* Effect.forEach( + projectRoots, + (workspaceRoot) => + Effect.gen(function* () { + const result = yield* git.execute({ + operation: "WorktreeRevivalService.repositoryKey", + cwd: workspaceRoot, + args: ["rev-parse", "--git-common-dir"], + env: { LC_ALL: "C" }, + allowNonZeroExit: true, + timeoutMs: 15_000, + }); + if (result.exitCode !== 0) return null; + const commonDir = result.stdout.trim(); + return commonDir.length === 0 + ? workspaceRoot + : yield* canonicalizePath(path.resolve(workspaceRoot, commonDir)); + }).pipe(Effect.orElseSucceed(() => null)), + { concurrency: PROJECT_SCAN_CONCURRENCY }, + ); + const repositoryKey = repositoryKeys[projectRoots.indexOf(requestedWorkspaceRoot)] ?? null; + return { + workspaceRoot: requestedWorkspaceRoot, + projectIds: projectSnapshot.projects + .filter((_, index) => repositoryKey !== null && repositoryKeys[index] === repositoryKey) + .map((project) => project.id), + }; + }); + + const listCanonicalWorkspaces = Effect.fn("WorktreeRevivalService.listCanonicalWorkspaces")( + function* (workspaceRoot: string, branch: string) { + const entries = yield* git.listWorkspaces(workspaceRoot).pipe( + Effect.mapError((cause) => + mutationError("inspect_registrations", cause, { + workspaceRoot, + branch, + }), + ), + ); + return yield* Effect.forEach(entries, (entry) => + canonicalizePath(entry.path).pipe(Effect.map((path) => ({ ...entry, path }))), + ); + }, + ); + + const validateBranchExists = Effect.fn("WorktreeRevivalService.validateBranchExists")(function* ( + workspaceRoot: string, + branch: string, + ) { + const branchFormat = yield* git + .execute({ + operation: "WorktreeRevivalService.validateBranch", + cwd: workspaceRoot, + args: ["check-ref-format", "--branch", branch], + env: { LC_ALL: "C" }, + allowNonZeroExit: true, + timeoutMs: 5_000, + }) + .pipe( + Effect.mapError((cause) => + mutationError("validate_branch", cause, { + workspaceRoot, + branch, + }), + ), + ); + if (branchFormat.exitCode !== 0) { + return yield* mutationError("invalid_branch", undefined, { workspaceRoot, branch }); + } + + const branchExists = yield* git + .execute({ + operation: "WorktreeRevivalService.branchExists", + cwd: workspaceRoot, + args: ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + env: { LC_ALL: "C" }, + allowNonZeroExit: true, + timeoutMs: 5_000, + }) + .pipe( + Effect.mapError((cause) => + mutationError("check_branch", cause, { + workspaceRoot, + branch, + }), + ), + ); + if (branchExists.exitCode !== 0) { + return yield* mutationError("missing_branch", undefined, { workspaceRoot, branch }); + } + }); + + /** Recreates a missing managed checkout. The caller holds its workspace lease. */ + const reviveWorktreeUnlocked = Effect.fn("WorktreeRevivalService.reviveWorktree")(function* ( + input: WorktreeRevivalInput, + ) { + const { workspaceRoot, projectIds } = yield* resolveManagedWorkspaceRoot(input); + const worktreePath = yield* canonicalizePath(input.worktreePath); + const exists = yield* fs.exists(worktreePath).pipe( + Effect.mapError((cause) => + mutationError("inspect_target_path", cause, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }), + ), + ); + // An existing directory is left alone wherever it lives and whatever it + // has checked out. A detached HEAD after a stopped rebase or a switched + // branch is working state the thread runs in, not damage to repair. The + // checks below only guard creating a worktree that went missing. + if (exists) { + return { + revived: false, + generation: yield* currentGeneration(worktreePath), + worktreePath, + }; + } + const managedWorktreesRoots = yield* readManagedWorktreesRoots(); + if ( + managedWorktreesRoots.some( + (root) => root === worktreePath || isPathInside(worktreePath, root, path), + ) || + !managedWorktreesRoots.some((root) => isPathInside(root, worktreePath, path)) + ) { + return yield* mutationError("outside_managed_root", undefined, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }); + } + + let registrations = yield* listCanonicalWorkspaces(workspaceRoot, input.branch); + let targetRegistration = registrations.find((entry) => entry.path === worktreePath); + + if (targetRegistration !== undefined && targetRegistration.refName !== input.branch) { + return yield* mutationError("registered_different_ref", undefined, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }); + } + + const branchRegisteredElsewhere = registrations.find( + (entry) => entry.refName === input.branch && entry.path !== worktreePath && !entry.prunable, + ); + if (branchRegisteredElsewhere !== undefined) { + return yield* mutationError("branch_in_use", undefined, { + path: worktreePath, + conflictingPath: branchRegisteredElsewhere.path, + workspaceRoot, + branch: input.branch, + }); + } + + // A deleted directory can leave a stale registration in Git's metadata. + // Prune only stale registrations, then inspect the registration table + // again before creating anything at the requested path. + const needsGitPrune = + targetRegistration !== undefined || registrations.some((entry) => entry.prunable); + if (needsGitPrune) { + yield* git + .execute({ + operation: "WorktreeRevivalService.prune", + cwd: workspaceRoot, + args: ["worktree", "prune"], + env: { LC_ALL: "C" }, + timeoutMs: 15_000, + }) + .pipe( + Effect.mapError((cause) => + mutationError("prune_metadata", cause, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }), + ), + ); + registrations = yield* listCanonicalWorkspaces(workspaceRoot, input.branch); + targetRegistration = registrations.find((entry) => entry.path === worktreePath); + if (targetRegistration !== undefined) { + return yield* mutationError("stale_registration_remaining", undefined, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }); + } + const branchStillRegisteredElsewhere = registrations.find( + (entry) => entry.refName === input.branch && entry.path !== worktreePath && !entry.prunable, + ); + if (branchStillRegisteredElsewhere !== undefined) { + return yield* mutationError("branch_in_use", undefined, { + path: worktreePath, + conflictingPath: branchStillRegisteredElsewhere.path, + workspaceRoot, + branch: input.branch, + }); + } + } + + const existsAfterPrune = yield* fs.exists(worktreePath).pipe( + Effect.mapError((cause) => + mutationError("inspect_target_path", cause, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }), + ), + ); + if (existsAfterPrune) { + return yield* mutationError("target_appeared", undefined, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }); + } + + yield* validateBranchExists(workspaceRoot, input.branch); + const submodules = yield* settings.getSettings.pipe( + Effect.map( + (settings) => resolveProjectSettings(settings, input.projectId).settings.worktreeSubmodules, + ), + Effect.orElseSucceed(() => null), + ); + // A cancelled turn start must not stop `git worktree add` halfway: the + // next turn would take the partial directory for a healthy checkout. + const generation = yield* Effect.gen(function* () { + // Persist readiness before Git can leave a checkout behind. Each project + // sharing this repository owns its setup; success for one cannot clear + // another's pending work. Tokens distinguish recreations across restarts. + const token = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => + mutationError("run_setup", cause, { projectId: input.projectId, path: worktreePath }), + ), + ); + yield* Effect.forEach(new Set([...projectIds, input.projectId]), (projectId) => + Effect.gen(function* () { + const filePath = yield* pendingSetupPath(projectId, worktreePath); + yield* writeFileStringAtomically({ + filePath, + contents: token, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => + mutationError("run_setup", cause, { projectId, path: worktreePath }), + ), + ); + }), + ); + return yield* git + .createWorktree( + { cwd: workspaceRoot, refName: input.branch, path: worktreePath }, + { submodules }, + ) + .pipe( + Effect.mapError((cause) => + mutationError("create_worktree", cause, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }), + ), + ); + }).pipe( + Effect.ensuring( + advanceGeneration(worktreePath).pipe(Effect.andThen(lifecycle.markInventoryChanged)), + ), + Effect.andThen(currentGeneration(worktreePath)), + Effect.uninterruptible, + ); + + const finalExists = yield* fs.exists(worktreePath).pipe( + Effect.mapError((cause) => + mutationError("verify_worktree", cause, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }), + ), + ); + const finalRegistrations = yield* listCanonicalWorkspaces(workspaceRoot, input.branch); + const finalRegistration = finalRegistrations.find((entry) => entry.path === worktreePath); + if (!finalExists || finalRegistration?.refName !== input.branch || finalRegistration.prunable) { + return yield* mutationError("worktree_verification_failed", undefined, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }); + } + + yield* Effect.logInfo("worktree.revived", { + worktreePath, + branch: input.branch, + }); + return { revived: true, generation, worktreePath }; + }); + + const loadProject = Effect.fn("WorktreeRevivalService.loadProject")(function* ( + input: WorktreeRevivalForThreadInput, + ) { + const project = yield* projectsService.getById(input.projectId).pipe( + Effect.mapError((cause) => + mutationError("load_project", cause, { + path: input.worktreePath, + branch: input.branch, + projectId: input.projectId, + }), + ), + ); + if (Option.isNone(project)) { + return yield* mutationError("project_not_found", undefined, { + path: input.worktreePath, + branch: input.branch, + projectId: input.projectId, + }); + } + return project.value; + }); + + /** + * Runs the project's setup script in a recreated worktree and resolves + * `outcome` once the agent may start. As at thread launch, an async script + * only has to start, while a script marked `async: false` has to exit 0. + */ + const runSetup = Effect.fn("WorktreeRevivalService.runSetup")(function* ( + input: WorktreeRevivalForThreadInput, + worktreePath: string, + token: string, + outcome: Deferred.Deferred, + ) { + const project = yield* loadProject(input); + const setupFailed = (cause: unknown) => + mutationError("run_setup", cause, { + path: input.worktreePath, + workspaceRoot: project.workspaceRoot, + branch: input.branch, + }); + const permitStartup = withWorkspaceLease( + worktreePath, + Effect.gen(function* () { + if ((yield* readPendingSetup(input.projectId, worktreePath)) !== token) { + return yield* mutationError("setup_readiness_changed", undefined, { + projectId: input.projectId, + path: worktreePath, + }); + } + yield* fs + .remove(yield* pendingSetupPath(input.projectId, worktreePath)) + .pipe(Effect.mapError(setupFailed)); + yield* Deferred.succeed(outcome, undefined); + }), + ).pipe(Effect.uninterruptible); + const setup = yield* setupScripts + .runForThread({ + threadId: input.threadId, + projectId: input.projectId, + projectCwd: project.workspaceRoot, + worktreePath, + // Retries reuse this checkout's shell. A replacement token starts a + // fresh shell even when the previous shell still holds the deleted cwd. + preferredTerminalId: `setup-revival-${token}`, + project: { + id: project.id, + workspaceRoot: project.workspaceRoot, + scripts: project.scripts, + }, + // Reports the script's exit code back so a required script can be awaited. + observeCompletion: {}, + }) + .pipe(Effect.mapError(setupFailed)); + if (setup.status !== "started" || setup.completion === undefined) { + yield* permitStartup; + return; + } + if (setup.async) yield* permitStartup; + // Awaiting completion also releases the script's terminal subscription. + const completion = yield* setup.completion; + if (!setup.async && completion.exitCode !== 0) { + return yield* mutationError("setup_exit_nonzero", undefined, { + projectId: input.projectId, + path: worktreePath, + workspaceRoot: project.workspaceRoot, + branch: input.branch, + exitCode: completion.exitCode, + }); + } + if (!setup.async) yield* permitStartup; + }); + + /** + * Waits until project setup in a recreated worktree lets the agent start. + * Concurrent turn starts share one run per worktree generation. The run is + * forked into the service scope because a cancelled turn start must not + * stop observing the script, or the next turn would wait on it forever. A + * failed run is forgotten so the next turn tries setup again. + */ + const ensureSetup = Effect.fn("WorktreeRevivalService.ensureSetup")(function* ( + input: WorktreeRevivalForThreadInput, + worktreePath: string, + token: string, + ) { + const key = setupKey(input.projectId, worktreePath); + const fresh = yield* Deferred.make(); + // Registering the run and forking it cannot be split by an interrupt: a + // registered outcome nobody completes would hold every later turn. The + // lease also prevents a delayed caller for an older token from replacing + // the current run. The script stays interruptible and runs outside it. + const outcome = yield* withWorkspaceLease( + worktreePath, + Effect.gen(function* () { + const pending = yield* readPendingSetup(input.projectId, worktreePath); + if (pending !== token) { + const current = (yield* Ref.get(setupRuns)).get(key); + if (pending === undefined && current?.token === token) return current.outcome; + return yield* mutationError("setup_readiness_changed", undefined, { + projectId: input.projectId, + path: worktreePath, + }); + } + const registered = yield* Ref.modify(setupRuns, (runs) => { + const current = runs.get(key); + if (current !== undefined && current.token === token) { + return [current.outcome, runs] as const; + } + return [fresh, new Map(runs).set(key, { token, outcome: fresh })] as const; + }); + if (registered === fresh) { + yield* Effect.interruptible(runSetup(input, worktreePath, token, fresh)).pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + if (Exit.isFailure(exit)) { + yield* Ref.update(setupRuns, (runs) => { + if (runs.get(key)?.outcome !== fresh) return runs; + const next = new Map(runs); + next.delete(key); + return next; + }); + } + yield* Deferred.done(fresh, exit); + }), + ), + Effect.forkIn(serviceScope), + ); + } + return registered; + }), + ).pipe(Effect.uninterruptible); + yield* Deferred.await(outcome); + }); + + /** + * The existence check and the recreation both run under the checkout's + * workspace lease, the one a removal holds for its final checks. A turn that + * finds a removal in progress waits for it and then recreates the checkout + * from its branch. An existing directory is used as is. Setup for a + * recreated worktree runs outside the lease: a slow script must not hold it, + * and the script's own terminal takes the same lease to start. + */ + const reviveForThread: WorktreeRevivalService["Service"]["reviveForThread"] = Effect.fn( + "WorktreeRevivalService.reviveForThread", + )(function* (input) { + const worktreePath = yield* canonicalizePath(input.worktreePath); + const revival = yield* withWorkspaceLease( + worktreePath, + Effect.gen(function* () { + const revival = yield* Effect.gen(function* () { + if (yield* fs.exists(worktreePath).pipe(Effect.orElseSucceed(() => false))) { + return { revived: false, generation: yield* currentGeneration(worktreePath) }; + } + const project = yield* loadProject(input); + return yield* reviveWorktreeUnlocked({ + projectId: input.projectId, + workspaceRoot: project.workspaceRoot, + worktreePath, + branch: input.branch, + }); + }); + return { ...revival, setupToken: yield* readPendingSetup(input.projectId, worktreePath) }; + }), + ); + // Existing directories without a revival marker retain their launch setup. + if (revival.setupToken !== undefined) { + yield* ensureSetup(input, worktreePath, revival.setupToken); + } + return { revived: revival.revived, generation: revival.generation }; + }); + + return WorktreeRevivalService.of({ reviveForThread }); +}); + +export const layer = Layer.effect(WorktreeRevivalService, make); diff --git a/apps/server/src/vcs/WorktreeService.test.ts b/apps/server/src/vcs/WorktreeService.test.ts new file mode 100644 index 000000000000..0c7ee571c441 --- /dev/null +++ b/apps/server/src/vcs/WorktreeService.test.ts @@ -0,0 +1,804 @@ +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { + ProjectId, + RunId, + ThreadId, + type OrchestrationV2ThreadShell, + type TerminalSummary, +} from "@t3tools/contracts"; + +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as WorktreeLifecycle from "./WorktreeLifecycle.ts"; +import * as WorktreeRevivalService from "./WorktreeRevivalService.ts"; +import * as WorktreeService from "./WorktreeService.ts"; +import { + addWorktree, + commitIn, + initializeRepository, + makeHarness, + makeProject, + projectId, +} from "./WorktreeService.testkit.ts"; +import { makeThreadShell } from "./worktreeThreadState.testkit.ts"; + +const linkedThread = ( + worktreePath: string, + overrides: Partial = {}, +): OrchestrationV2ThreadShell => + makeThreadShell({ + id: ThreadId.make(`thread-${worktreePath}`), + projectId, + worktreePath, + branch: `feature/${worktreePath.split(/[\\/]/).at(-1)}`, + ...overrides, + }); + +const runningTerminal = (cwd: string): TerminalSummary => ({ + threadId: "thread-terminal", + terminalId: "terminal-1", + cwd, + worktreePath: null, + status: "running", + pid: 1, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: "shell", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +const insertLiveSession = Effect.fn("WorktreeServiceTest.insertLiveSession")(function* ( + cwd: string, +) { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO orchestration_v2_projection_provider_sessions + (provider_session_id, thread_id, provider, status, model, updated_at, payload_json) + VALUES + ('session-1', 'thread-session', 'codex', 'ready', NULL, '2026-01-01T00:00:00.000Z', + json_object('cwd', ${cwd})) + `; +}); + +const removeManually = Effect.fn("WorktreeServiceTest.removeManually")(function* ( + worktreePath: string, + options: { readonly allowIgnoredFiles?: boolean } = {}, +) { + const worktrees = yield* WorktreeService.WorktreeService; + const result = yield* worktrees.pruneWorktrees({ projectId, paths: [worktreePath], ...options }); + return result.skipped[0]?.reason ?? "removed"; +}); + +it.effect("keeps a worktree that is in use, has unsaved work, or has an open thread", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const settled = { settledOverride: "settled" } as const; + + const running = yield* addWorktree(repositoryRoot, "running"); + state.threads = [ + linkedThread(running, { ...settled, status: "running", activeRunId: RunId.make("run-1") }), + ]; + assert.equal(yield* removeManually(running), "running"); + + // A settled thread whose message has not started a run yet is still in use. + const queued = yield* addWorktree(repositoryRoot, "queued"); + state.threads = [ + linkedThread(queued, { ...settled, latestUserMessageAt: DateTime.makeUnsafe(0) }), + ]; + assert.equal(yield* removeManually(queued), "running"); + + const open = yield* addWorktree(repositoryRoot, "open"); + state.threads = [linkedThread(open)]; + assert.equal(yield* removeManually(open), "open_thread"); + state.threads = []; + + const dirty = yield* addWorktree(repositoryRoot, "dirty"); + yield* fs.writeFileString(path.join(dirty, "notes.txt"), "draft\n"); + assert.equal(yield* removeManually(dirty), "dirty"); + + const unpushed = yield* addWorktree(repositoryRoot, "unpushed"); + yield* commitIn(unpushed); + assert.equal(yield* removeManually(unpushed), "unpushed"); + + const terminal = yield* addWorktree(repositoryRoot, "terminal"); + yield* state.publishTerminals({ + type: "snapshot", + terminals: [runningTerminal(path.join(terminal, "src"))], + }); + assert.equal(yield* removeManually(terminal), "terminal"); + yield* state.publishTerminals({ type: "snapshot", terminals: [] }); + + const session = yield* addWorktree(repositoryRoot, "session"); + yield* insertLiveSession(session); + assert.equal(yield* removeManually(session), "session"); + + for (const kept of [running, queued, open, dirty, unpushed, terminal, session]) { + assert.isTrue(yield* fs.exists(kept), kept); + } + }).pipe(Effect.provide(layer)); +}); + +it.effect("removes a clean worktree by hand and keeps its branch", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver.GitVcsDriver; + const lifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const worktreePath = yield* addWorktree(repositoryRoot, "clean"); + state.threads = [linkedThread(worktreePath, { settledOverride: "settled" })]; + + const result = yield* (yield* WorktreeService.WorktreeService).pruneWorktrees({ + projectId, + paths: [worktreePath], + }); + + assert.deepEqual(result, { + removed: [{ path: worktreePath, workspaceRoot: repositoryRoot }], + skipped: [], + }); + assert.isFalse(yield* fs.exists(worktreePath)); + assert.equal(yield* lifecycle.revision, 1); + const branch = yield* git.execute({ + operation: "WorktreeServiceTest.branchKept", + cwd: repositoryRoot, + args: ["show-ref", "--verify", "--quiet", "refs/heads/feature/clean"], + allowNonZeroExit: true, + }); + assert.equal(branch.exitCode, 0); + }).pipe(Effect.provide(layer)); +}); + +it.effect("finishes a removal whose request is cancelled while Git is deleting", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const lifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const worktreePath = yield* addWorktree(repositoryRoot, "cancelled"); + const removalStarted = yield* Deferred.make(); + const releaseRemoval = yield* Deferred.make(); + state.beforeGitRemove = Deferred.succeed(removalStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRemoval)), + ); + + const request = yield* (yield* WorktreeService.WorktreeService) + .pruneWorktrees({ projectId, paths: [worktreePath] }) + .pipe(Effect.forkChild); + yield* Deferred.await(removalStarted); + const cancellation = yield* Fiber.interrupt(request).pipe(Effect.forkChild); + yield* Deferred.succeed(releaseRemoval, undefined); + yield* Fiber.join(cancellation); + + assert.isFalse(yield* fs.exists(worktreePath)); + assert.equal(yield* lifecycle.revision, 1); + }).pipe(Effect.provide(layer)); +}); + +it.effect("keeps a checkout when its linked thread records a different branch", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const worktreePath = yield* addWorktree(repositoryRoot, "switched"); + state.threads = [linkedThread(worktreePath, { branch: "main", settledOverride: "settled" })]; + const worktrees = yield* WorktreeService.WorktreeService; + const [listed] = (yield* worktrees.listWorktrees({})).worktrees; + assert.deepInclude(listed, { safeToPrune: false, pruneBlockers: ["unrestorable_thread"] }); + assert.equal(yield* removeManually(worktreePath), "unrestorable_thread"); + assert.isTrue(yield* fs.exists(worktreePath)); + state.threads = [linkedThread(worktreePath, { settledOverride: "settled" })]; + assert.equal(yield* removeManually(worktreePath), "removed"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("deletes ignored files only when the request opts in", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + + const withSecrets = yield* addWorktree(repositoryRoot, "secrets"); + yield* fs.writeFileString(path.join(withSecrets, ".env"), "TOKEN=1\n"); + assert.equal(yield* removeManually(withSecrets), "ignored_files"); + assert.equal(yield* removeManually(withSecrets, { allowIgnoredFiles: false }), "ignored_files"); + assert.isTrue(yield* fs.exists(path.join(withSecrets, ".env"))); + assert.equal(yield* removeManually(withSecrets, { allowIgnoredFiles: true }), "removed"); + assert.isFalse(yield* fs.exists(withSecrets)); + + // Dependency installs are reproducible and never need the opt-in. + const withDependencies = yield* addWorktree(repositoryRoot, "dependencies"); + yield* fs.makeDirectory(path.join(withDependencies, "node_modules", "pkg"), { + recursive: true, + }); + yield* fs.writeFileString(path.join(withDependencies, "node_modules", "pkg", "index.js"), ""); + assert.equal(yield* removeManually(withDependencies), "removed"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("reads and removes a worktree with hundreds of ignored files", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + // A file pattern lists every match, unlike an ignored directory. + yield* fs.writeFileString(path.join(repositoryRoot, ".git", "info", "exclude"), "*.log\n"); + const worktreePath = yield* addWorktree(repositoryRoot, "many-logs"); + const logCount = 400; + yield* Effect.forEach( + Array.from({ length: logCount }, (_, index) => index), + (index) => + fs.writeFileString( + path.join(worktreePath, `${String(index).padStart(4, "0")}-${"x".repeat(200)}.log`), + "", + ), + { concurrency: 16 }, + ); + const worktrees = yield* WorktreeService.WorktreeService; + + const [listed] = (yield* worktrees.listWorktrees({})).worktrees; + assert.deepInclude(listed, { + path: worktreePath, + ignoredFileCount: logCount, + safeToPrune: true, + pruneBlockers: [], + }); + // The row carries a sample for the confirmation, not every path. + assert.equal(listed?.ignoredFiles.length, 5); + + assert.equal(yield* removeManually(worktreePath), "ignored_files"); + assert.equal(yield* removeManually(worktreePath, { allowIgnoredFiles: true }), "removed"); + assert.isFalse(yield* fs.exists(worktreePath)); + }).pipe(Effect.provide(layer)); +}); + +it.effect("refuses the legacy forced removal of a worktree with changes", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const dirty = yield* addWorktree(repositoryRoot, "legacy-dirty"); + yield* fs.writeFileString(path.join(dirty, "notes.txt"), "draft\n"); + const withSecrets = yield* addWorktree(repositoryRoot, "legacy-secrets"); + yield* fs.writeFileString(path.join(withSecrets, ".env"), "TOKEN=1\n"); + const worktrees = yield* WorktreeService.WorktreeService; + + const dirtyError = yield* worktrees + .removeWorktree({ cwd: repositoryRoot, path: dirty, force: true }) + .pipe(Effect.flip); + const secretsError = yield* worktrees + .removeWorktree({ cwd: repositoryRoot, path: withSecrets, force: true }) + .pipe(Effect.flip); + + assert.equal(dirtyError.reason, "dirty"); + assert.equal(secretsError.reason, "ignored_files"); + assert.isTrue(yield* fs.exists(path.join(dirty, "notes.txt"))); + assert.isTrue(yield* fs.exists(path.join(withSecrets, ".env"))); + }).pipe(Effect.provide(layer)); +}); + +it.effect( + "keeps a checkout with initialized submodules, which Git only removes when forced", + () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const worktrees = yield* WorktreeService.WorktreeService; + const run = (cwd: string, args: ReadonlyArray) => + git.execute({ + operation: "WorktreeServiceTest.submodules", + cwd, + args: ["-c", "protocol.file.allow=always", "-c", "user.name=T3 Test", ...args], + }); + const library = yield* initializeRepository(); + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + yield* run(repositoryRoot, ["submodule", "add", library, "lib"]); + yield* run(repositoryRoot, ["commit", "-m", "add submodule"]); + yield* run(repositoryRoot, ["update-ref", "refs/remotes/upstream/main", "refs/heads/main"]); + const headSha = (yield* git.resolveCommit({ cwd: repositoryRoot, revision: "HEAD" })) + .commitSha; + + // Clean, but its submodule holds a commit no remote has. + const initialized = yield* addWorktree(repositoryRoot, "initialized"); + yield* run(initialized, ["submodule", "update", "--init"]); + const submodule = path.join(initialized, "lib"); + yield* run(submodule, ["checkout", "-b", "unpublished"]); + yield* run(submodule, [ + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "x", + ]); + yield* run(submodule, ["checkout", "--detach", "HEAD~1"]); + // Checked out by hand, so this worktree's Git directory has no submodule repository. + const cloned = yield* addWorktree(repositoryRoot, "cloned"); + yield* run(repositoryRoot, ["clone", library, path.join(cloned, "lib")]); + const uninitialized = yield* addWorktree(repositoryRoot, "uninitialized"); + const late = yield* addWorktree(repositoryRoot, "late"); + + const listed = (yield* worktrees.listWorktrees({})).worktrees.find( + (worktree) => worktree.path === initialized, + ); + assert.deepInclude(listed, { + dirty: false, + safeToPrune: false, + pruneBlockers: ["submodules"], + }); + assert.equal(yield* removeManually(initialized), "submodules"); + assert.equal(yield* removeManually(cloned), "submodules"); + assert.deepEqual( + yield* worktrees.removeIfSafe({ + path: initialized, + workspaceRoot: repositoryRoot, + intent: "policy", + expected: { branch: "feature/initialized", headSha }, + recheck: Effect.succeed(true), + }), + { outcome: "skipped", reason: "submodules" }, + ); + assert.isTrue(yield* fs.exists(cloned)); + yield* run(submodule, ["rev-parse", "--verify", "refs/heads/unpublished"]); + // Deinitializing keeps the repository and its unpublished branch in Git metadata. + yield* run(initialized, ["submodule", "deinit", "--force", "lib"]); + assert.isFalse(yield* fs.exists(path.join(submodule, ".git"))); + assert.equal(yield* removeManually(initialized), "submodules"); + // Initialized after the first inspection found none. + assert.deepEqual( + yield* worktrees.removeIfSafe({ + path: late, + workspaceRoot: repositoryRoot, + intent: "policy", + expected: { branch: "feature/late", headSha }, + recheck: run(late, ["submodule", "update", "--init"]).pipe(Effect.as(true), Effect.orDie), + }), + { outcome: "skipped", reason: "submodules" }, + ); + assert.isTrue(yield* fs.exists(path.join(late, "lib", ".git"))); + + assert.equal(yield* removeManually(uninitialized), "removed"); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect("refreshes inventory after cleanup changes a checkout whose removal fails", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const worktrees = yield* WorktreeService.WorktreeService; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const worktreePath = yield* addWorktree(repositoryRoot, "partial-cleanup"); + yield* fs.writeFileString(path.join(worktreePath, ".env"), "local data"); + yield* fs.writeFileString(path.join(worktreePath, "notes.txt"), "untracked data"); + const before = yield* worktrees.listWorktrees({}); + const headSha = (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha; + // An external writer changes tracked data after clean succeeds. Git then refuses removal. + state.beforeGitRemove = fs + .writeFileString(path.join(worktreePath, "README.md"), "new work") + .pipe(Effect.orDie); + const result = yield* worktrees.removeIfSafe({ + path: worktreePath, + workspaceRoot: repositoryRoot, + intent: "policy", + keepWhen: "tracked-changes", + expected: { branch: "feature/partial-cleanup", headSha }, + recheck: Effect.succeed(true), + }); + assert.deepInclude(result, { outcome: "skipped", reason: "remove_failed" }); + assert.isTrue(yield* fs.exists(worktreePath)); + assert.isFalse(yield* fs.exists(path.join(worktreePath, ".env"))); + assert.isFalse(yield* fs.exists(path.join(worktreePath, "notes.txt"))); + const after = yield* worktrees.listWorktrees({}); + assert.isAbove(after.revision, before.revision); + assert.deepInclude( + after.worktrees.find((entry) => entry.path === worktreePath), + { + dirtyFileCount: 1, + ignoredFileCount: 0, + safeToPrune: false, + }, + ); + }).pipe(Effect.provide(layer)); +}); + +it.effect("removes a detached checkout only once its commit is on the default branch", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + + const ahead = yield* addWorktree(repositoryRoot, "detached-ahead", { detached: true }); + yield* commitIn(ahead); + assert.equal(yield* removeManually(ahead), "unpushed"); + + // Revival restores a thread's branch, so its detached commit would not return. + const linked = yield* addWorktree(repositoryRoot, "detached-linked", { detached: true }); + state.threads = [linkedThread(linked, { settledOverride: "settled" })]; + assert.equal(yield* removeManually(linked), "unrestorable_thread"); + + // A thread that recorded no branch cannot be restored on a named checkout either. + const branchless = yield* addWorktree(repositoryRoot, "branchless-thread"); + state.threads = [linkedThread(branchless, { settledOverride: "settled", branch: null })]; + assert.equal(yield* removeManually(branchless), "unrestorable_thread"); + assert.isTrue(yield* fs.exists(branchless)); + state.threads = []; + + const merged = yield* addWorktree(repositoryRoot, "detached-merged", { detached: true }); + assert.equal(yield* removeManually(merged), "removed"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("never removes a checkout outside the managed directory or a project root", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initializeRepository(); + const external = path.join( + yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "t3-worktree-external-" })), + "checkout", + ); + yield* git.execute({ + operation: "WorktreeServiceTest.externalWorktree", + cwd: repositoryRoot, + args: ["worktree", "add", "-b", "feature/external", external, "main"], + }); + const asProject = yield* addWorktree(repositoryRoot, "registered-project"); + state.projects = [ + makeProject(repositoryRoot), + { ...makeProject(asProject), id: ProjectId.make("project-in-worktree") }, + ]; + + assert.equal(yield* removeManually(external), "protected_path"); + assert.equal(yield* removeManually(asProject), "protected_path"); + assert.equal(yield* removeManually(repositoryRoot), "protected_path"); + assert.isTrue(yield* fs.exists(external)); + assert.isTrue(yield* fs.exists(asProject)); + }).pipe(Effect.provide(layer)); +}); + +it.effect("counts threads of every project that shares the repository", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repositoryRoot = yield* initializeRepository(); + const nestedRoot = path.join(repositoryRoot, "packages"); + yield* fs.makeDirectory(nestedRoot); + const nestedProjectId = ProjectId.make("project-nested"); + state.projects = [ + makeProject(repositoryRoot), + { ...makeProject(nestedRoot), id: nestedProjectId, title: "Nested" }, + ]; + const worktreePath = yield* addWorktree(repositoryRoot, "shared"); + state.threads = [linkedThread(worktreePath, { projectId: nestedProjectId })]; + + // Requested through the other project: its sibling's open thread still counts. + assert.equal(yield* removeManually(worktreePath), "open_thread"); + const { worktrees } = yield* (yield* WorktreeService.WorktreeService).listWorktrees({ + projectId, + }); + assert.deepEqual( + worktrees.map((worktree) => worktree.projects.map((project) => project.projectId)), + [[projectId, nestedProjectId]], + ); + assert.deepEqual(worktrees[0]?.pruneBlockers, ["open_thread"]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("lets cleanup remove an idle open thread's clean worktree with unpushed commits", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const worktreePath = yield* addWorktree(repositoryRoot, "policy"); + const headSha = yield* commitIn(worktreePath); + state.threads = [linkedThread(worktreePath)]; + const worktrees = yield* WorktreeService.WorktreeService; + const policy = { + path: worktreePath, + workspaceRoot: repositoryRoot, + intent: "policy", + expected: { branch: "feature/policy", headSha }, + } as const; + + assert.deepEqual(yield* worktrees.removeIfSafe({ ...policy, recheck: Effect.succeed(false) }), { + outcome: "skipped", + reason: "policy_changed", + }); + assert.deepEqual( + yield* worktrees.removeIfSafe({ + ...policy, + expected: { branch: "feature/policy", headSha: "0".repeat(40) }, + recheck: Effect.succeed(true), + }), + { outcome: "skipped", reason: "changed" }, + ); + assert.isTrue(yield* fs.exists(worktreePath)); + + assert.deepEqual(yield* worktrees.removeIfSafe({ ...policy, recheck: Effect.succeed(true) }), { + outcome: "removed", + }); + assert.isFalse(yield* fs.exists(worktreePath)); + }).pipe(Effect.provide(layer)); +}); + +it.effect("keeps a checkout that is switched or committed to during the final checks", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const worktrees = yield* WorktreeService.WorktreeService; + // The policy recheck is the last awaited step before the final inspection, + // so a change made inside it lands after every earlier check has passed. + const removeWhile = Effect.fn(function* (name: string, change: ReadonlyArray) { + const worktreePath = yield* addWorktree(repositoryRoot, name); + const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); + const outcome = yield* worktrees.removeIfSafe({ + path: worktreePath, + workspaceRoot: repositoryRoot, + intent: "policy", + expected: { branch: `feature/${name}`, headSha: head.commitSha }, + recheck: git + .execute({ operation: "WorktreeServiceTest.lateChange", cwd: worktreePath, args: change }) + .pipe(Effect.as(true), Effect.orDie), + }); + return { outcome, kept: yield* fs.exists(worktreePath) }; + }); + + // Same commit, another branch. + assert.deepEqual(yield* removeWhile("late-switch", ["checkout", "-b", "feature/elsewhere"]), { + outcome: { outcome: "skipped", reason: "changed" }, + kept: true, + }); + // Same branch, a new commit. + assert.deepEqual( + yield* removeWhile("late-commit", ["commit", "--allow-empty", "-m", "late work"]), + { outcome: { outcome: "skipped", reason: "changed" }, kept: true }, + ); + }).pipe(Effect.provide(layer)); +}); + +it.effect("keeps a checkout that gains an ignored file during the final checks", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const worktreePath = yield* addWorktree(repositoryRoot, "late-secret"); + const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); + const secret = path.join(worktreePath, ".env"); + + // Written after the first inspection found no ignored files. The branch + // and commit do not move, and Git alone would delete the file. + const outcome = yield* (yield* WorktreeService.WorktreeService).removeIfSafe({ + path: worktreePath, + workspaceRoot: repositoryRoot, + intent: "policy", + expected: { branch: "feature/late-secret", headSha: head.commitSha }, + recheck: fs.writeFileString(secret, "TOKEN=1\n").pipe(Effect.as(true), Effect.orDie), + }); + + assert.deepEqual(outcome, { outcome: "skipped", reason: "ignored_files", detail: ".env" }); + assert.isTrue(yield* fs.exists(secret)); + }).pipe(Effect.provide(layer)); +}); + +it.effect("keeps cleanup away from ignored files and running threads", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const worktrees = yield* WorktreeService.WorktreeService; + const removeByPolicy = Effect.fn(function* (worktreePath: string, name: string) { + const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); + const outcome = yield* worktrees.removeIfSafe({ + path: worktreePath, + workspaceRoot: repositoryRoot, + intent: "policy", + expected: { branch: `feature/${name}`, headSha: head.commitSha }, + recheck: Effect.succeed(true), + }); + return outcome.outcome === "removed" ? "removed" : outcome.reason; + }); + + const withSecrets = yield* addWorktree(repositoryRoot, "policy-secrets"); + yield* fs.writeFileString(path.join(withSecrets, ".env"), "TOKEN=1\n"); + assert.equal(yield* removeByPolicy(withSecrets, "policy-secrets"), "ignored_files"); + + const running = yield* addWorktree(repositoryRoot, "policy-running"); + state.threads = [linkedThread(running, { status: "starting" })]; + assert.equal(yield* removeByPolicy(running, "policy-running"), "running"); + state.threads = []; + + const withDependencies = yield* addWorktree(repositoryRoot, "policy-dependencies"); + yield* fs.makeDirectory(path.join(withDependencies, "node_modules")); + yield* fs.writeFileString(path.join(withDependencies, "node_modules", "index.js"), ""); + assert.equal(yield* removeByPolicy(withDependencies, "policy-dependencies"), "removed"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("recreates the checkout for a turn that starts while it is being removed", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + const worktreePath = yield* addWorktree(repositoryRoot, "race"); + const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); + const worktrees = yield* WorktreeService.WorktreeService; + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const removalHoldsLease = yield* Deferred.make(); + const finishRemoval = yield* Deferred.make(); + + // The recheck runs under the lease, after the removal's final reads. + const removal = yield* worktrees + .removeIfSafe({ + path: worktreePath, + workspaceRoot: repositoryRoot, + intent: "policy", + expected: { branch: "feature/race", headSha: head.commitSha }, + recheck: Deferred.succeed(removalHoldsLease, undefined).pipe( + Effect.andThen(Deferred.await(finishRemoval)), + Effect.as(true), + ), + }) + .pipe(Effect.forkChild); + yield* Deferred.await(removalHoldsLease); + const turnStart = yield* revival + .reviveForThread({ + threadId: ThreadId.make("thread-race"), + projectId, + worktreePath, + branch: "feature/race", + }) + .pipe(Effect.forkChild); + yield* Deferred.succeed(finishRemoval, undefined); + + assert.deepEqual(yield* Fiber.join(removal), { outcome: "removed" }); + assert.deepEqual(yield* Fiber.join(turnStart), { revived: true, generation: 1 }); + assert.isTrue(yield* fs.exists(path.join(worktreePath, "README.md"))); + }).pipe(Effect.provide(layer)); +}); + +it.effect("lists managed worktrees with what removal would delete or keep", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initializeRepository(); + const notARepository = yield* fs.makeTempDirectoryScoped({ prefix: "t3-worktree-plain-" }); + state.projects = [ + makeProject(repositoryRoot), + { ...makeProject(notARepository), id: ProjectId.make("project-plain") }, + ]; + const detached = yield* addWorktree(repositoryRoot, "list-detached", { detached: true }); + const head = yield* git.resolveCommit({ cwd: detached, revision: "HEAD" }); + const busy = yield* addWorktree(repositoryRoot, "list-busy"); + yield* fs.writeFileString(path.join(busy, ".env"), "TOKEN=1\n"); + yield* fs.writeFileString(path.join(busy, "notes.txt"), "draft\n"); + yield* commitIn(busy); + // A pull request refresh touched the thread long after its last run. + const lastRun = DateTime.makeUnsafe("2026-05-20T00:00:00.000Z"); + state.threads = [ + linkedThread(busy, { + title: "Busy thread", + latestRunCompletedAt: lastRun, + updatedAt: DateTime.makeUnsafe("2026-06-01T00:00:00.000Z"), + }), + ]; + + const result = yield* (yield* WorktreeService.WorktreeService).listWorktrees({}); + const byPath = new Map(result.worktrees.map((worktree) => [worktree.path, worktree])); + + assert.equal(result.revision, 0); + assert.equal(result.worktrees.length, 2); + assert.deepInclude(byPath.get(detached), { + branch: null, + headShortSha: head.commitSha.slice(0, 7), + dirtyFileCount: 0, + ignoredFileCount: 0, + aheadOfDefaultCount: 0, + safeToPrune: true, + pruneBlockers: [], + }); + assert.deepInclude(byPath.get(busy), { + projectId, + branch: "feature/list-busy", + dirtyFileCount: 1, + ignoredFileCount: 1, + ignoredFiles: [".env"], + lastActivityAt: DateTime.formatIso(lastRun), + aheadOfDefaultCount: 1, + safeToPrune: false, + pruneBlockers: ["dirty", "unpushed", "open_thread"], + }); + assert.deepEqual( + byPath.get(busy)?.threads.map((thread) => [thread.title, thread.status]), + [["Busy thread", "active"]], + ); + }).pipe(Effect.provide(layer)); +}); + +it.effect("applies the cleanup policy to files written during its final checks", () => { + const { state, layer } = makeHarness(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const worktrees = yield* WorktreeService.WorktreeService; + const repositoryRoot = yield* initializeRepository(); + state.projects = [makeProject(repositoryRoot)]; + for (const keepWhen of ["any-local-files", "uncommitted-changes", "tracked-changes"] as const) { + for (const file of ["README.md", "notes.txt", ".env"] as const) { + const name = `${keepWhen}-${file.replaceAll(".", "-")}`; + const worktreePath = yield* addWorktree(repositoryRoot, name); + const { commitSha } = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); + const result = yield* worktrees.removeIfSafe({ + intent: "policy", + path: worktreePath, + workspaceRoot: repositoryRoot, + keepWhen, + expected: { branch: `feature/${name}`, headSha: commitSha }, + recheck: fs + .writeFileString(path.join(worktreePath, file), "local content\n") + .pipe(Effect.as(true), Effect.orDie), + }); + const shouldRemove = + file !== "README.md" && + (keepWhen === "tracked-changes" || + (keepWhen === "uncommitted-changes" && file === ".env")); + assert.equal(result.outcome, shouldRemove ? "removed" : "skipped", `${keepWhen}: ${file}`); + assert.equal(yield* fs.exists(worktreePath), !shouldRemove); + assert.equal( + (yield* git.resolveCommit({ + cwd: repositoryRoot, + revision: `refs/heads/feature/${name}`, + })).commitSha, + commitSha, + ); + } + } + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/vcs/WorktreeService.testkit.ts b/apps/server/src/vcs/WorktreeService.testkit.ts new file mode 100644 index 000000000000..55fafbe1fecf --- /dev/null +++ b/apps/server/src/vcs/WorktreeService.testkit.ts @@ -0,0 +1,198 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { + ProjectId, + type OrchestrationProjectShell, + type OrchestrationV2ThreadShell, + type TerminalMetadataStreamEvent, +} from "@t3tools/contracts"; + +import * as ServerConfig from "../config.ts"; +import * as GitManager from "../git/GitManager.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as Sqlite from "../persistence/Sqlite.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as WorktreeLifecycle from "./WorktreeLifecycle.ts"; +import * as WorktreeRevivalService from "./WorktreeRevivalService.ts"; +import * as WorktreeService from "./WorktreeService.ts"; + +export const projectId = ProjectId.make("project-worktrees"); + +export const makeProject = (workspaceRoot: string): OrchestrationProjectShell => ({ + id: projectId, + title: "Worktrees", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +/** + * The real service over real Git, with the projects, threads and terminals it + * reads held in `state` so a test can change them between calls. + */ +export const makeHarness = () => { + const state = { + projects: [] as OrchestrationProjectShell[], + threads: [] as OrchestrationV2ThreadShell[], + publishTerminals: (_event: TerminalMetadataStreamEvent): Effect.Effect => Effect.void, + /** Runs on every thread read, so a test can tell a sweep has begun. */ + onThreadsRead: Effect.void as Effect.Effect, + /** Runs before Git removes a worktree, so a test can hold a removal open. */ + beforeGitRemove: Effect.void as Effect.Effect, + /** Runs after each removal. */ + onRemoved: Effect.void as Effect.Effect, + }; + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-worktree-service-test-", + }).pipe(Layer.provide(NodeServices.layer)); + const dependencies = Layer.mergeAll( + serverConfigLayer, + Sqlite.layerMemory, + WorktreeLifecycle.layer, + ServerSettings.layerTest(), + Layer.mock(GitManager.GitManager)({ + invalidateStatus: () => Effect.suspend(() => state.onRemoved), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: (options) => + Effect.sync(() => + state.projects.filter( + (project) => + options?.projectIds === undefined || options.projectIds.includes(project.id), + ), + ), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getShellSnapshot: (options) => + Effect.suspend(() => state.onThreadsRead).pipe( + Effect.map(() => { + const threads = state.threads.filter( + (thread) => + options?.location === undefined || + (options.location === "archive") === (thread.archivedAt !== null), + ); + return { + schemaVersion: 1, + snapshotSequence: 1, + threads: threads.filter((thread) => thread.archivedAt === null), + archivedThreads: threads.filter((thread) => thread.archivedAt !== null), + }; + }), + ), + }), + Layer.mock(TerminalManager.TerminalManager)({ + subscribeMetadata: (listener) => + Effect.sync(() => { + state.publishTerminals = listener; + return () => undefined; + }), + }), + Layer.mock(ProjectService.ProjectService)({ + getById: (id) => + Effect.sync(() => + Option.fromNullishOr(state.projects.find((project) => project.id === id)).pipe( + Option.map((project) => ({ ...project, deletedAt: null })), + ), + ), + snapshot: Effect.sync(() => ({ + projects: state.projects.map((project) => ({ ...project, deletedAt: null })), + updatedAt: "2026-01-01T00:00:00.000Z", + })), + }), + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread: () => Effect.succeed({ status: "no-script" }), + }), + ).pipe( + Layer.provideMerge( + Layer.effect( + GitVcsDriver.GitVcsDriver, + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + return GitVcsDriver.GitVcsDriver.of({ + ...driver, + removeWorktree: (input) => + Effect.suspend(() => state.beforeGitRemove).pipe( + Effect.andThen(driver.removeWorktree(input)), + ), + }); + }), + ).pipe(Layer.provide(GitVcsDriver.layer.pipe(Layer.provide(serverConfigLayer)))), + ), + Layer.provideMerge(NodeServices.layer), + ); + const layer = Layer.mergeAll(WorktreeService.layer, WorktreeRevivalService.layer).pipe( + Layer.provideMerge(dependencies), + ); + return { state, layer }; +}; + +/** + * A repository whose primary remote is `upstream`, not `origin`, with its + * default branch at the initial commit, and `.env` and `node_modules` ignored. + */ +export const initializeRepository = Effect.fn("WorktreeServiceTest.initializeRepository")( + function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-worktree-service-repo-" }), + ); + const run = (args: ReadonlyArray) => + git.execute({ operation: "WorktreeServiceTest.git", cwd: repositoryRoot, args }); + yield* run(["init", "-b", "main"]); + yield* run(["config", "user.email", "test@example.com"]); + yield* run(["config", "user.name", "T3 Test"]); + yield* fs.writeFileString(path.join(repositoryRoot, ".gitignore"), ".env\nnode_modules\n"); + yield* fs.writeFileString(path.join(repositoryRoot, "README.md"), "hello\n"); + yield* run(["add", "."]); + yield* run(["commit", "-m", "initial"]); + yield* run(["remote", "add", "upstream", repositoryRoot]); + yield* run(["update-ref", "refs/remotes/upstream/main", "refs/heads/main"]); + yield* run(["symbolic-ref", "refs/remotes/upstream/HEAD", "refs/remotes/upstream/main"]); + return repositoryRoot; + }, +); + +export const addWorktree = Effect.fn("WorktreeServiceTest.addWorktree")(function* ( + repositoryRoot: string, + name: string, + options: { readonly detached?: boolean; readonly root?: string } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const git = yield* GitVcsDriver.GitVcsDriver; + const worktreePath = path.join(yield* fs.realPath(options.root ?? config.worktreesDir), name); + yield* git.execute({ + operation: "WorktreeServiceTest.addWorktree", + cwd: repositoryRoot, + args: + options.detached === true + ? ["worktree", "add", "--detach", worktreePath, "main"] + : ["worktree", "add", "-b", `feature/${name}`, worktreePath, "main"], + }); + return worktreePath; +}); + +export const commitIn = Effect.fn("WorktreeServiceTest.commitIn")(function* (worktreePath: string) { + const git = yield* GitVcsDriver.GitVcsDriver; + yield* git.execute({ + operation: "WorktreeServiceTest.commit", + cwd: worktreePath, + args: ["commit", "--allow-empty", "-m", "local work"], + }); + return (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha; +}); diff --git a/apps/server/src/vcs/WorktreeService.ts b/apps/server/src/vcs/WorktreeService.ts new file mode 100644 index 000000000000..c3a195a70eaa --- /dev/null +++ b/apps/server/src/vcs/WorktreeService.ts @@ -0,0 +1,1015 @@ +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { + type OrchestrationV2ThreadShell, + type ProjectId, + type TerminalSummary, + type VcsListWorktreesInput, + type VcsListWorktreesResult, + type VcsPruneWorktreesInput, + type VcsPruneWorktreesResult, + type VcsRemoveWorktreeInput, + type WorktreeInfo, + type WorktreeKeepWhen, + type WorktreeInventoryErrorStage, + type WorktreePruneBlocker, + type WorktreePruneSkip, + type WorktreeThreadStatus, + WorktreeInventoryError, + WorktreeMutationError, + WorktreePruneSkipReason, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as GitManager from "../git/GitManager.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import { resolveWorkspaceLeasePath, withWorkspaceLease } from "../workspace/workspaceLease.ts"; +import { isFilesystemRoot, managedWorktreesDirectories } from "../worktreesDirectory.ts"; +import type { GitWorktree } from "./GitWorktree.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as WorktreeLifecycle from "./WorktreeLifecycle.ts"; +import { storageCleanupActivityAt, worktreeThreadBusy } from "./worktreeThreadState.ts"; + +const WORKTREE_STATUS_CONCURRENCY = 8; +const PROJECT_SCAN_CONCURRENCY = 4; +// As large as the other Git metadata reads. A file pattern such as `*.log` +// lists every match, so a small cap left ordinary worktrees unreadable. +const STATUS_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const IGNORED_FILE_SAMPLE_SIZE = 5; + +const SKIP_REASON_MESSAGE: Record = { + running: "a thread is running or has a queued turn in it", + session: "a provider session is using it", + terminal: "a terminal is open in it", + open_thread: "a linked thread is still open", + dirty: "it has uncommitted changes", + submodules: "it has submodule repositories; preserve their work and remove it manually", + unpushed: "it has commits that are not pushed or merged", + unrestorable_thread: "a linked thread could not get this checkout back", + status_unavailable: "its Git status could not be read", + ignored_files: "it holds ignored files that removal would delete", + protected_path: "it is not a removable managed worktree", + unknown_worktree: "Git does not register it for this project", + changed: "it changed while it was being checked", + policy_changed: "the cleanup rule no longer applies", + remove_failed: "Git could not remove it", +}; + +/** A removal request that left the worktree in place, with the reason. */ +export class WorktreeRemovalSkippedError extends Schema.TaggedError()( + "WorktreeRemovalSkippedError", + { + path: Schema.String, + reason: WorktreePruneSkipReason, + detail: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return `The worktree was kept because ${SKIP_REASON_MESSAGE[this.reason]}.`; + } +} + +type RemovalOutcome = + | { readonly outcome: "removed" } + | { + readonly outcome: "skipped"; + readonly reason: WorktreePruneSkipReason; + readonly detail?: string; + }; + +/** + * `manual` is a user or agent asking for one worktree. `policy` is Storage + * cleanup acting on its configured rules: it names the branch and commit its + * eligibility was decided on and supplies `recheck`, which runs under the + * lease with the service's own final reads and must still hold. + */ +export type WorktreeRemovalInput = { + readonly path: string; + readonly workspaceRoot: string; +} & ( + | { readonly intent: "manual"; readonly allowIgnoredFiles?: boolean } + | { + readonly intent: "policy"; + readonly keepWhen?: WorktreeKeepWhen; + readonly expected: { readonly branch: string; readonly headSha: string }; + readonly recheck: Effect.Effect; + } +); + +interface ProjectReference { + readonly id: ProjectId; + readonly title: string; + readonly workspaceRoot: string; +} + +interface ProjectGroup { + readonly canonicalWorkspaceRoot: string; + readonly projects: ReadonlyArray; +} + +/** One `git status` read of a checkout, plus its submodules. */ +interface WorktreeInspection { + readonly headSha: string | null; + readonly branch: string | null; + readonly upstream: string | null; + /** null with an upstream means its remote ref is gone. */ + readonly aheadOfUpstreamCount: number | null; + readonly behindUpstreamCount: number | null; + readonly changedFileCount: number; + readonly trackedFileCount: number; + /** Ignored paths other than reproducible dependency installs. */ + readonly ignoredFiles: ReadonlyArray; + /** Git refuses an unforced removal while this holds. */ + readonly hasSubmodules: boolean; +} + +interface WorktreeUsage { + readonly threads: ReadonlyArray; + readonly hasSession: boolean; + readonly hasTerminal: boolean; +} + +/** Parses `git status --porcelain=v2 --branch -z --ignored=matching`. */ +function parseWorktreeStatus(stdout: string): Omit { + let headSha: string | null = null; + let branch: string | null = null; + let upstream: string | null = null; + let aheadOfUpstreamCount: number | null = null; + let behindUpstreamCount: number | null = null; + let changedFileCount = 0; + let trackedFileCount = 0; + const ignoredFiles: string[] = []; + const tokens = stdout.split("\0"); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === undefined || token.length === 0) continue; + if (token.startsWith("# branch.oid ")) { + const oid = token.slice("# branch.oid ".length); + headSha = oid === "(initial)" ? null : oid; + } else if (token.startsWith("# branch.head ")) { + const head = token.slice("# branch.head ".length); + branch = head === "(detached)" ? null : head; + } else if (token.startsWith("# branch.upstream ")) { + upstream = token.slice("# branch.upstream ".length); + } else if (token.startsWith("# branch.ab ")) { + const match = /^\+(\d+) -(\d+)$/.exec(token.slice("# branch.ab ".length)); + if (match !== null) { + aheadOfUpstreamCount = Number(match[1]); + behindUpstreamCount = Number(match[2]); + } + } else if (token.startsWith("! ")) { + const ignored = token.slice(2); + // Dependency installs are reproducible; every other ignored path can + // hold secrets or local data. + if (!/(^|\/)node_modules\/$/.test(ignored)) ignoredFiles.push(ignored); + } else if (!token.startsWith("#")) { + changedFileCount += 1; + if (!token.startsWith("? ")) trackedFileCount += 1; + // A rename or copy carries its original path as the following token. + if (token.startsWith("2 ")) index += 1; + } + } + return { + headSha, + branch, + upstream, + aheadOfUpstreamCount, + behindUpstreamCount, + changedFileCount, + trackedFileCount, + ignoredFiles, + }; +} + +/** A named branch whose upstream ref exists is compared with that upstream. */ +const hasUsableUpstream = (inspection: WorktreeInspection) => + inspection.branch !== null && + inspection.upstream !== null && + inspection.aheadOfUpstreamCount !== null; + +function threadStatus(thread: OrchestrationV2ThreadShell): WorktreeThreadStatus { + if (thread.deletedAt !== null) return "deleted"; + if (thread.archivedAt !== null) return "archived"; + if (thread.settledOverride === "settled") return "settled"; + return "active"; +} + +/** + * The one removal decision, listed most pressing first. Inventory rows show + * it and removal re-derives it under the lease. Ignored files are not here: + * they need the caller's opt-in rather than blocking outright. + */ +function removalBlockers(input: { + readonly intent: WorktreeRemovalInput["intent"]; + readonly keepWhen?: WorktreeKeepWhen; + readonly inspection: WorktreeInspection | null; + /** Commits missing from the default ref; only read without a usable upstream. */ + readonly aheadOfDefaultCount: number | null; + readonly usage: WorktreeUsage; + readonly now: number; +}): ReadonlyArray { + const { inspection, usage } = input; + const manual = input.intent === "manual"; + const blockers = new Set(); + if (usage.threads.some((thread) => worktreeThreadBusy(thread, input.now))) { + blockers.add("running"); + } + if (usage.hasSession) blockers.add("session"); + if (usage.hasTerminal) blockers.add("terminal"); + if (inspection === null || inspection.headSha === null) { + blockers.add("status_unavailable"); + } else { + const changedFiles = + input.keepWhen === "tracked-changes" + ? inspection.trackedFileCount + : inspection.changedFileCount; + if (changedFiles > 0) blockers.add("dirty"); + if (inspection.hasSubmodules) blockers.add("submodules"); + if (inspection.branch === null) { + // No ref keeps a detached commit, so it must already be on the default + // branch for either intent. + if (input.aheadOfDefaultCount === null) blockers.add("status_unavailable"); + else if (input.aheadOfDefaultCount > 0) blockers.add("unpushed"); + } else if (manual) { + // Cleanup may remove a checkout with unpushed commits because the branch + // is kept. A manual removal promises the work is already elsewhere. + if (hasUsableUpstream(inspection)) { + if ((inspection.aheadOfUpstreamCount ?? 0) > 0) blockers.add("unpushed"); + } else if (input.aheadOfDefaultCount === null) { + blockers.add("status_unavailable"); + } else if (input.aheadOfDefaultCount > 0) { + blockers.add("unpushed"); + } + } + } + // Revival recreates a checkout from the branch its thread recorded. A thread + // without the checkout's current branch would be restored to a different + // workspace or left with a path nothing can restore. + if ( + manual && + usage.threads.some((thread) => thread.branch === null || thread.branch !== inspection?.branch) + ) { + blockers.add("unrestorable_thread"); + } + if (manual && usage.threads.some((thread) => threadStatus(thread) === "active")) { + blockers.add("open_thread"); + } + return [...blockers]; +} + +function inventoryError( + stage: WorktreeInventoryErrorStage, + cause: unknown, + context: { readonly workspaceRoot?: string } = {}, +): WorktreeInventoryError { + return new WorktreeInventoryError({ + stage, + ...(context.workspaceRoot === undefined ? {} : { workspaceRoot: context.workspaceRoot }), + cause, + }); +} + +export class WorktreeService extends Context.Service< + WorktreeService, + { + readonly listWorktrees: ( + input: VcsListWorktreesInput, + ) => Effect.Effect; + /** Manual removal of a project's worktrees. Each path is removed or skipped with its reason. */ + readonly pruneWorktrees: ( + input: VcsPruneWorktreesInput, + ) => Effect.Effect; + /** + * The legacy single-worktree removal. It is a manual removal: `force` is + * ignored and ignored files are never opted in. + */ + readonly removeWorktree: ( + input: VcsRemoveWorktreeInput, + ) => Effect.Effect; + /** + * Removes one checkout if every safeguard holds, keeping its branch and + * checkpoint refs. Never forces Git and never fails: a refusal is a skip. + */ + readonly removeIfSafe: (input: WorktreeRemovalInput) => Effect.Effect; + } +>()("t3/vcs/WorktreeService") {} + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; + const git = yield* GitVcsDriver.GitVcsDriver; + const gitManager = yield* GitManager.GitManager; + const projectStore = yield* ProjectStore.ProjectStoreV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const terminals = yield* TerminalManager.TerminalManager; + const lifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; + const settings = yield* ServerSettings.ServerSettingsService; + + const liveTerminals = new Map>(); + const noteTerminal = (terminal: TerminalSummary) => { + const threadTerminals = + liveTerminals.get(terminal.threadId) ?? new Map(); + threadTerminals.set(terminal.terminalId, terminal); + liveTerminals.set(terminal.threadId, threadTerminals); + }; + const unsubscribeTerminals = yield* terminals.subscribeMetadata((event) => + Effect.sync(() => { + if (event.type === "snapshot") { + liveTerminals.clear(); + for (const terminal of event.terminals) noteTerminal(terminal); + } else if (event.type === "upsert") { + noteTerminal(event.terminal); + } else { + const threadTerminals = liveTerminals.get(event.threadId); + threadTerminals?.delete(event.terminalId); + if (threadTerminals?.size === 0) liveTerminals.delete(event.threadId); + } + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeTerminals)); + + // Canonicalize through symlinks so configured roots, Git metadata, thread + // paths and lease keys compare equal on hosts such as macOS (/var vs + // /private/var) or with a symlinked T3 home. + const canonicalizePath = (value: string) => + resolveWorkspaceLeasePath(value).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + const readManagedWorktreesRoots = Effect.fn("WorktreeService.readManagedWorktreesRoots")( + function* () { + const directories = yield* settings.getSettings.pipe( + Effect.orElseSucceed(() => ({ worktreesDirectory: "", previousWorktreesDirectories: [] })), + ); + const roots = yield* Effect.forEach( + managedWorktreesDirectories( + directories, + config.worktreesDir, + path, + yield* HostProcess.HomeDirectory, + ), + canonicalizePath, + ); + return [...new Set(roots.filter((root) => !isFilesystemRoot(root, path)))]; + }, + ); + + const inside = (root: string, target: string) => { + const relative = path.relative(root, target); + return ( + relative !== "" && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); + }; + const atOrInside = (root: string, target: string) => root === target || inside(root, target); + + const executeLenient = (operation: string, cwd: string, args: ReadonlyArray) => + git + .execute({ + operation, + cwd, + args, + env: { LC_ALL: "C" }, + allowNonZeroExit: true, + timeoutMs: 15_000, + }) + .pipe(Effect.map((result) => (result.exitCode === 0 ? result.stdout : null))); + + /** + * The ref a checkout's commits must reach to count as merged: the primary + * remote's default branch as last fetched. Nothing is fetched here, and a + * stale ref can only make commits look unmerged. A repository without any + * remote falls back to its local default branch. + */ + const resolveDefaultRef = Effect.fn("WorktreeService.resolveDefaultRef")(function* (cwd: string) { + const remote = yield* git.resolvePrimaryRemoteName(cwd).pipe(Effect.option); + if (Option.isSome(remote)) { + const branch = yield* git.resolveDefaultBranchName(cwd, remote.value); + return branch === null ? null : `refs/remotes/${remote.value}/${branch}`; + } + for (const candidate of ["refs/heads/main", "refs/heads/master"]) { + const result = yield* git.execute({ + operation: "WorktreeService.resolveDefaultRef.localFallback", + cwd, + args: ["show-ref", "--verify", "--quiet", candidate], + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + if (result.exitCode === 0) return candidate; + } + return null; + }); + + /** Commits reachable from `commit` that the default ref does not have. */ + const countAheadOfDefault = Effect.fn("WorktreeService.countAheadOfDefault")(function* ( + cwd: string, + commit: string, + defaultRef: string | null, + ) { + if (defaultRef === null) return null; + const stdout = yield* executeLenient("WorktreeService.countAheadOfDefault", cwd, [ + "rev-list", + "--count", + commit, + "--not", + defaultRef, + ]); + if (stdout === null) return null; + const count = Number(stdout.trim()); + return Number.isInteger(count) && count >= 0 ? count : null; + }); + + /** + * Git's own reason to refuse an unforced `worktree remove`: this checkout's + * Git directory keeps submodule repositories, as `submodule update` and + * `deinit` leave them, or a submodule path at `commit` is checked out. + * Forcing it could delete their unpublished commits. A staged submodule + * change already shows as a changed file, so the commit's gitlinks suffice. + * null when the listing is incomplete. + */ + const readHasSubmodules = Effect.fn("WorktreeService.readHasSubmodules")(function* ( + worktreePath: string, + commit: string, + ) { + const modulesPath = yield* git.execute({ + operation: "WorktreeService.readHasSubmodules.modules", + cwd: worktreePath, + args: ["rev-parse", "--git-path", "modules"], + timeoutMs: 15_000, + }); + if (modulesPath.stdoutTruncated) return null; + if (yield* fs.exists(path.resolve(worktreePath, modulesPath.stdout.trim()))) return true; + // Directories only, which is far smaller than the index on large repositories. + const tree = yield* git.execute({ + operation: "WorktreeService.readHasSubmodules.gitlinks", + cwd: worktreePath, + args: ["ls-tree", "-r", "-d", "-z", commit], + timeoutMs: 15_000, + maxOutputBytes: STATUS_MAX_OUTPUT_BYTES, + }); + if (tree.stdoutTruncated) return null; + const gitlinks = tree.stdout + .split("\0") + .flatMap((entry) => + entry.startsWith("160000 ") ? [entry.slice(entry.indexOf("\t") + 1)] : [], + ); + const checkedOut = yield* Effect.findFirst(gitlinks, (gitlink) => + fs.exists(path.join(worktreePath, gitlink, ".git")), + ); + return Option.isSome(checkedOut); + }); + + /** + * Branch, HEAD commit, upstream counters, changed files and ignored files + * from one `git status` call, then its submodules. null when either cannot + * be read in full: a partial listing could hide a file or a submodule. + */ + const inspect = Effect.fn("WorktreeService.inspect")( + function* (worktreePath: string) { + const result = yield* git.execute({ + operation: "WorktreeService.inspect", + cwd: worktreePath, + args: [ + "status", + "--porcelain=v2", + "--branch", + "-z", + "--untracked-files=normal", + "--ignore-submodules=none", + "--ignored=matching", + ], + env: { LC_ALL: "C" }, + timeoutMs: 15_000, + maxOutputBytes: STATUS_MAX_OUTPUT_BYTES, + }); + if (result.stdoutTruncated) return null; + const status = parseWorktreeStatus(result.stdout); + // Without a commit the status is already unavailable to every caller. + if (status.headSha === null) return { ...status, hasSubmodules: false }; + const hasSubmodules = yield* readHasSubmodules(worktreePath, status.headSha); + return hasSubmodules === null ? null : { ...status, hasSubmodules }; + }, + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logDebug("worktrees.inspect.status-failed", { cause }).pipe(Effect.as(null)), + ), + ); + + /** The default-ref comparison a checkout needs, measured on its captured commit. */ + const aheadOfDefaultFor = ( + worktreePath: string, + inspection: WorktreeInspection | null, + defaultRef: string | null, + ) => + inspection === null || inspection.headSha === null || hasUsableUpstream(inspection) + ? Effect.succeed(null) + : countAheadOfDefault(worktreePath, inspection.headSha, defaultRef); + + const readProjects = projectStore.listShells(); + const readThreads = projections + .getShellSnapshot() + .pipe(Effect.map((snapshot) => [...snapshot.threads, ...snapshot.archivedThreads])); + + // Sessions can outlive their run and can be shared across app threads. + const readSessionCwds = sql<{ readonly cwd: string | null }>` + SELECT json_extract(payload_json, '$.cwd') AS cwd + FROM orchestration_v2_projection_provider_sessions + WHERE status != 'stopped' + `.pipe( + Effect.flatMap((rows) => + Effect.forEach( + rows.flatMap((row) => (row.cwd === null ? [] : [row.cwd])), + canonicalizePath, + ), + ), + ); + + const readTerminalCwds = Effect.suspend(() => + Effect.forEach( + [...liveTerminals.values()] + .flatMap((entries) => [...entries.values()]) + .filter((terminal) => terminal.status === "starting" || terminal.status === "running") + .flatMap((terminal) => + terminal.worktreePath === null ? [terminal.cwd] : [terminal.cwd, terminal.worktreePath], + ), + canonicalizePath, + ), + ); + + /** Every thread, session and terminal directory, read once and matched per checkout. */ + const readUsage = Effect.fn("WorktreeService.readUsage")(function* () { + const threads = yield* readThreads; + const canonicalByRawPath = new Map(); + const threadsByPath = new Map(); + for (const thread of threads) { + if (thread.worktreePath === null) continue; + const canonicalPath = + canonicalByRawPath.get(thread.worktreePath) ?? + (yield* canonicalizePath(thread.worktreePath)); + canonicalByRawPath.set(thread.worktreePath, canonicalPath); + const bucket = threadsByPath.get(canonicalPath); + if (bucket === undefined) threadsByPath.set(canonicalPath, [thread]); + else bucket.push(thread); + } + const sessionCwds = yield* readSessionCwds; + const terminalCwds = yield* readTerminalCwds; + return (worktreePath: string): WorktreeUsage => ({ + // Every project's threads count: projects can share one repository. + threads: threadsByPath.get(worktreePath) ?? [], + hasSession: sessionCwds.some((cwd) => atOrInside(worktreePath, cwd)), + hasTerminal: terminalCwds.some((cwd) => atOrInside(worktreePath, cwd)), + }); + }); + + // Local threads under another project need not have a worktreePath of their own. + const containsProjectRoot = ( + worktreePath: string, + canonicalProjectRoots: ReadonlyArray, + ) => canonicalProjectRoots.some((root) => atOrInside(worktreePath, root)); + + const listCanonicalWorkspaces = (workspaceRoot: string) => + git + .listWorkspaces(workspaceRoot) + .pipe( + Effect.flatMap((entries) => + Effect.forEach(entries, (entry) => + canonicalizePath(entry.path).pipe( + Effect.map((canonicalPath): GitWorktree => ({ ...entry, path: canonicalPath })), + ), + ), + ), + ); + + const worktreeMtime = (worktreePath: string) => + fs.stat(worktreePath).pipe( + Effect.map((info) => + Option.match(info.mtime, { + onNone: () => null, + onSome: (mtime) => mtime.toISOString(), + }), + ), + Effect.orElseSucceed(() => null), + ); + + const listGroup = Effect.fn("WorktreeService.listGroup")(function* ( + group: ProjectGroup, + usageFor: (worktreePath: string) => WorktreeUsage, + canonicalProjectRoots: ReadonlyArray, + managedWorktreesRoots: ReadonlyArray, + now: number, + ) { + const primaryProject = group.projects[0]; + if (primaryProject === undefined) return [] as WorktreeInfo[]; + + const entries = (yield* listCanonicalWorkspaces(group.canonicalWorkspaceRoot)).filter( + (entry) => + // A registration whose directory is gone has nothing on disk to + // manage. Revival prunes it before recreating the path. + !entry.prunable && + !managedWorktreesRoots.some((root) => atOrInside(entry.path, root)) && + managedWorktreesRoots.some((root) => inside(root, entry.path)) && + !containsProjectRoot(entry.path, canonicalProjectRoots), + ); + if (entries.length === 0) return [] as WorktreeInfo[]; + const defaultRef = yield* resolveDefaultRef(group.canonicalWorkspaceRoot); + + return yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const usage = usageFor(entry.path); + const inspection = yield* inspect(entry.path); + const aheadOfDefaultCount = yield* aheadOfDefaultFor(entry.path, inspection, defaultRef); + const blockers = removalBlockers({ + intent: "manual", + inspection, + aheadOfDefaultCount, + usage, + now, + }); + // The same clock Storage cleanup ages a worktree by, so a pull + // request refresh does not make it look recently used. + const threadActivity = + usage.threads.length === 0 + ? null + : DateTime.formatIso( + DateTime.makeUnsafe(Math.max(...usage.threads.map(storageCleanupActivityAt))), + ); + return { + projectId: primaryProject.id, + projectTitle: primaryProject.title, + workspaceRoot: primaryProject.workspaceRoot, + projects: group.projects.map((project) => ({ + projectId: project.id, + projectTitle: project.title, + workspaceRoot: project.workspaceRoot, + })), + path: entry.path, + branch: inspection?.branch ?? entry.refName, + headShortSha: (inspection?.headSha ?? entry.headCommit)?.slice(0, 7) ?? null, + threads: usage.threads.map((thread) => ({ + threadId: thread.id, + title: thread.title, + status: threadStatus(thread), + })), + dirty: inspection === null ? null : inspection.changedFileCount > 0, + dirtyFileCount: inspection?.changedFileCount ?? null, + ignoredFileCount: inspection?.ignoredFiles.length ?? null, + ignoredFiles: inspection?.ignoredFiles.slice(0, IGNORED_FILE_SAMPLE_SIZE) ?? [], + hasUpstream: inspection === null ? null : inspection.upstream !== null, + upstreamGone: + inspection !== null && + inspection.upstream !== null && + inspection.aheadOfUpstreamCount === null, + aheadOfUpstreamCount: inspection?.aheadOfUpstreamCount ?? null, + behindUpstreamCount: inspection?.behindUpstreamCount ?? null, + aheadOfDefaultCount, + lastActivityAt: threadActivity ?? (yield* worktreeMtime(entry.path)), + safeToPrune: blockers.length === 0, + pruneBlockers: blockers, + } satisfies WorktreeInfo; + }), + { concurrency: WORKTREE_STATUS_CONCURRENCY }, + ); + }); + + const listWorktrees: WorktreeService["Service"]["listWorktrees"] = Effect.fn( + "WorktreeService.listWorktrees", + )(function* (input) { + // Read first, so a change that lands while listing reports a newer revision. + const revision = yield* lifecycle.revision; + const projects = yield* readProjects.pipe( + Effect.mapError((cause) => inventoryError("load_projects", cause)), + ); + const usageFor = yield* readUsage().pipe( + Effect.mapError((cause) => inventoryError("load_threads", cause)), + ); + const now = yield* Clock.currentTimeMillis; + const managedWorktreesRoots = yield* readManagedWorktreesRoots(); + + const normalizedProjects = yield* Effect.forEach( + projects, + (project) => + Effect.gen(function* () { + const canonicalWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); + // A project outside any Git repository, or whose directory is gone, + // has no worktrees to list; skip it rather than fail the inventory + // for every other project. + const commonDir = yield* executeLenient( + "WorktreeService.listWorktrees.repositoryKey", + canonicalWorkspaceRoot, + ["rev-parse", "--git-common-dir"], + ).pipe( + Effect.catch((cause) => + Effect.logWarning("worktrees.inventory.repository-probe-failed", { + projectId: project.id, + workspaceRoot: canonicalWorkspaceRoot, + cause, + }).pipe(Effect.as(null)), + ), + ); + return { + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + canonicalWorkspaceRoot, + repositoryKey: + commonDir === null + ? null + : commonDir.trim().length === 0 + ? canonicalWorkspaceRoot + : yield* canonicalizePath(path.resolve(canonicalWorkspaceRoot, commonDir.trim())), + }; + }), + { concurrency: PROJECT_SCAN_CONCURRENCY }, + ); + const canonicalProjectRoots = normalizedProjects.map( + (project) => project.canonicalWorkspaceRoot, + ); + const selectedRepositoryKeys = + input.projectId === undefined + ? null + : new Set( + normalizedProjects + .filter((project) => project.id === input.projectId) + .map((project) => project.repositoryKey), + ); + + // Projects sharing one repository list its worktrees once. + const groups = new Map(); + for (const project of normalizedProjects) { + if (project.repositoryKey === null) continue; + if (selectedRepositoryKeys !== null && !selectedRepositoryKeys.has(project.repositoryKey)) { + continue; + } + const existing = groups.get(project.repositoryKey); + groups.set(project.repositoryKey, { + canonicalWorkspaceRoot: existing?.canonicalWorkspaceRoot ?? project.canonicalWorkspaceRoot, + projects: [...(existing?.projects ?? []), project], + }); + } + + const records = yield* Effect.forEach( + [...groups.values()], + (group) => + listGroup(group, usageFor, canonicalProjectRoots, managedWorktreesRoots, now).pipe( + Effect.mapError((cause) => + inventoryError("inspect_repository", cause, { + workspaceRoot: group.canonicalWorkspaceRoot, + }), + ), + ), + { concurrency: PROJECT_SCAN_CONCURRENCY }, + ); + const worktrees = records.flat().toSorted((a, b) => { + const aMs = a.lastActivityAt === null ? 0 : Date.parse(a.lastActivityAt); + const bMs = b.lastActivityAt === null ? 0 : Date.parse(b.lastActivityAt); + return aMs - bMs; + }); + return { worktrees, revision }; + }); + + const skipped = (reason: WorktreePruneSkipReason, detail?: string): RemovalOutcome => ({ + outcome: "skipped", + reason, + ...(detail === undefined ? {} : { detail }), + }); + + /** + * Runs under the checkout's lease. Git state is read first, then threads, + * sessions and terminals in one fresh read, then the policy recheck. The + * checkout is inspected once more and the removal follows at once. + * A turn that committed before the usage read is seen here. + * One that commits after it waits on this lease in revival and recreates + * the checkout from its branch. + */ + const removeUnderLease = Effect.fn("WorktreeService.removeUnderLease")(function* ( + input: WorktreeRemovalInput, + worktreePath: string, + ) { + const keepWhen = + input.intent === "policy" ? (input.keepWhen ?? "any-local-files") : "any-local-files"; + const allowIgnoredFiles = + input.intent === "manual" ? input.allowIgnoredFiles === true : keepWhen !== "any-local-files"; + const workspaceRoot = yield* canonicalizePath(input.workspaceRoot); + const managedWorktreesRoots = yield* readManagedWorktreesRoots(); + if ( + managedWorktreesRoots.some((root) => atOrInside(worktreePath, root)) || + !managedWorktreesRoots.some((root) => inside(root, worktreePath)) || + !(yield* fs.exists(worktreePath)) + ) { + return skipped("protected_path"); + } + const canonicalProjectRoots = yield* Effect.forEach( + [{ workspaceRoot }, ...(yield* readProjects)], + (project) => canonicalizePath(project.workspaceRoot), + ); + if (containsProjectRoot(worktreePath, canonicalProjectRoots)) return skipped("protected_path"); + // A linked worktree has a .git file. Never remove a main checkout. + if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") { + return skipped("protected_path"); + } + const registration = (yield* listCanonicalWorkspaces(workspaceRoot)).find( + (entry) => entry.path === worktreePath && !entry.prunable, + ); + if (registration === undefined) return skipped("unknown_worktree"); + + const inspection = yield* inspect(worktreePath); + if ( + input.intent === "policy" && + inspection !== null && + (inspection.branch !== input.expected.branch || inspection.headSha !== input.expected.headSha) + ) { + return skipped("changed"); + } + const aheadOfDefaultCount = yield* aheadOfDefaultFor( + worktreePath, + inspection, + inspection === null ? null : yield* resolveDefaultRef(workspaceRoot), + ); + + const usage = (yield* readUsage())(worktreePath); + const [blocker] = removalBlockers({ + intent: input.intent, + keepWhen, + inspection, + aheadOfDefaultCount, + usage, + now: yield* Clock.currentTimeMillis, + }); + if (blocker !== undefined) { + const count = + keepWhen === "tracked-changes" + ? inspection?.trackedFileCount + : inspection?.changedFileCount; + return skipped( + blocker, + blocker === "dirty" && count !== undefined + ? `${count} ${count === 1 ? "file" : "files"}` + : undefined, + ); + } + if ((inspection?.ignoredFiles.length ?? 0) > 0 && !allowIgnoredFiles) { + return skipped("ignored_files", inspection?.ignoredFiles[0]); + } + if (input.intent === "policy" && !(yield* input.recheck)) return skipped("policy_changed"); + // Every check above judged the first inspection, and the reads since then + // took time. Another process may have switched the checkout, committed, + // or written an ignored file meanwhile, and Git would delete that file + // without complaint. So the checkout is inspected once more as the last + // thing read before removal, and anything unreadable keeps it. + if (inspection !== null) { + const latest = yield* inspect(worktreePath); + if (latest === null) return skipped("status_unavailable"); + if (latest.headSha !== inspection.headSha || latest.branch !== inspection.branch) { + return skipped("changed"); + } + const changedFiles = + keepWhen === "tracked-changes" ? latest.trackedFileCount : latest.changedFileCount; + if (changedFiles > 0) { + return skipped("dirty", `${changedFiles} ${changedFiles === 1 ? "file" : "files"}`); + } + if (latest.hasSubmodules) return skipped("submodules"); + if (latest.ignoredFiles.length > 0 && !allowIgnoredFiles) { + return skipped("ignored_files", latest.ignoredFiles[0]); + } + } + + // A dropped connection must not stop Git halfway through deleting the + // directory: what is left would pass for a healthy checkout on the next + // turn. The revision advances with it, so no client keeps a removed row. + const removal = yield* Effect.gen(function* () { + // Only the configured tracked-changes policy discards untracked data. + // Git's unforced removal still refuses a tracked edit made during cleanup. + if (keepWhen === "tracked-changes") { + yield* git.execute({ + operation: "WorktreeService.cleanUntracked", + cwd: worktreePath, + args: ["clean", "-ffdx"], + timeoutMs: 300_000, + }); + } + yield* git.removeWorktree({ cwd: workspaceRoot, path: worktreePath, force: false }); + }).pipe( + // Cleanup can change files even if Git subsequently refuses removal. + Effect.ensuring( + gitManager + .invalidateStatus(input.workspaceRoot) + .pipe(Effect.andThen(lifecycle.markInventoryChanged)), + ), + Effect.result, + Effect.uninterruptible, + ); + if (removal._tag === "Failure") return skipped("remove_failed", removal.failure.detail); + return { outcome: "removed" } satisfies RemovalOutcome; + }); + + const removeIfSafe: WorktreeService["Service"]["removeIfSafe"] = Effect.fn( + "WorktreeService.removeIfSafe", + )(function* (input) { + const worktreePath = yield* canonicalizePath(input.path); + const outcome = yield* withWorkspaceLease( + worktreePath, + removeUnderLease(input, worktreePath), + ).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning("worktrees.remove.check-failed", { worktreePath, cause }).pipe( + Effect.as(skipped("status_unavailable")), + ), + ), + ); + if (outcome.outcome === "removed") { + yield* Effect.logInfo("worktrees.removed", { worktreePath, intent: input.intent }); + } + return outcome; + }); + + const pruneWorktrees: WorktreeService["Service"]["pruneWorktrees"] = Effect.fn( + "WorktreeService.pruneWorktrees", + )(function* (input) { + const [project] = yield* projectStore.listShells({ projectIds: [input.projectId] }).pipe( + Effect.mapError( + (cause) => + new WorktreeMutationError({ + operation: "prune", + stage: "load_project", + projectId: input.projectId, + cause, + }), + ), + ); + if (project === undefined) { + return yield* new WorktreeMutationError({ + operation: "prune", + stage: "project_not_found", + projectId: input.projectId, + }); + } + const removed: Array = []; + const skippedPaths: WorktreePruneSkip[] = []; + // One lease per path, so a long request never holds up another checkout. + // Results carry the canonical path, which is what inventory rows are keyed on. + for (const worktreePath of new Set(yield* Effect.forEach(input.paths, canonicalizePath))) { + const outcome = yield* removeIfSafe({ + path: worktreePath, + workspaceRoot: project.workspaceRoot, + intent: "manual", + ...(input.allowIgnoredFiles === undefined + ? {} + : { allowIgnoredFiles: input.allowIgnoredFiles }), + }); + if (outcome.outcome === "removed") { + removed.push({ path: worktreePath, workspaceRoot: project.workspaceRoot }); + } else { + skippedPaths.push({ + path: worktreePath, + reason: outcome.reason, + ...(outcome.detail === undefined ? {} : { detail: outcome.detail }), + }); + } + } + return { removed, skipped: skippedPaths }; + }); + + const removeWorktree: WorktreeService["Service"]["removeWorktree"] = Effect.fn( + "WorktreeService.removeWorktree", + )(function* (input) { + const outcome = yield* removeIfSafe({ + path: input.path, + workspaceRoot: input.cwd, + intent: "manual", + }); + if (outcome.outcome === "skipped") { + return yield* new WorktreeRemovalSkippedError({ + path: input.path, + reason: outcome.reason, + ...(outcome.detail === undefined ? {} : { detail: outcome.detail }), + }); + } + }); + + return WorktreeService.of({ listWorktrees, pruneWorktrees, removeWorktree, removeIfSafe }); +}); + +export const layer = Layer.effect(WorktreeService, make); diff --git a/apps/server/src/vcs/worktreeThreadState.test.ts b/apps/server/src/vcs/worktreeThreadState.test.ts new file mode 100644 index 000000000000..67a50ec9e0ac --- /dev/null +++ b/apps/server/src/vcs/worktreeThreadState.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; +import { RunId, type OrchestrationV2ThreadShell } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { storageCleanupActivityAt, storageCleanupThreadIdle } from "./worktreeThreadState.ts"; +import { at, DAY_MS, makeThreadShell as shell, NOW_MS } from "./worktreeThreadState.testkit.ts"; + +describe("V2 storage cleanup eligibility", () => { + const candidate = () => shell({ branch: "feature", worktreePath: "/worktrees/feature" }); + + it("allows an idle worktree and rejects the project checkout", () => { + expect(storageCleanupThreadIdle(candidate(), NOW_MS)).toBe(true); + expect(storageCleanupThreadIdle(shell(), NOW_MS)).toBe(false); + }); + + it.each(["idle", "completed", "interrupted", "failed", "cancelled", "rolled_back"] as const)( + "allows cleanup after the thread is %s", + (status) => { + expect(storageCleanupThreadIdle(candidateWithStatus(status), NOW_MS)).toBe(true); + }, + ); + + it.each(["running", "starting", "preparing", "waiting", "queued"] as const)( + "retains a worktree while its thread is %s", + (status) => { + expect(storageCleanupThreadIdle(candidateWithStatus(status), NOW_MS)).toBe(false); + }, + ); + + it("retains an active run even if the shell status is idle", () => { + expect( + storageCleanupThreadIdle({ ...candidate(), activeRunId: RunId.make("run") }, NOW_MS), + ).toBe(false); + }); + + it("retains a queued prompt before the new run has been projected", () => { + expect( + storageCleanupThreadIdle({ ...candidate(), latestUserMessageAt: at(-1_000) }, NOW_MS), + ).toBe(false); + }); + + it("uses V2 run activity instead of metadata refreshes for retention", () => { + const thread = candidate(); + const runTime = at(-3 * DAY_MS); + expect( + storageCleanupActivityAt({ ...thread, latestRunCompletedAt: runTime, updatedAt: at(0) }), + ).toBe(DateTime.toEpochMillis(runTime)); + }); + + function candidateWithStatus(status: OrchestrationV2ThreadShell["status"]) { + return { ...candidate(), status }; + } +}); diff --git a/apps/server/src/vcs/worktreeThreadState.testkit.ts b/apps/server/src/vcs/worktreeThreadState.testkit.ts new file mode 100644 index 000000000000..fe3d03c8ce67 --- /dev/null +++ b/apps/server/src/vcs/worktreeThreadState.testkit.ts @@ -0,0 +1,66 @@ +import { + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +export const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); +export const DAY_MS = 24 * 60 * 60 * 1_000; + +export function at(offsetMs: number): DateTime.Utc { + return DateTime.makeUnsafe(NOW_MS + offsetMs); +} + +/** An idle thread in the project checkout, last active ten days before `NOW_MS`. */ +export function makeThreadShell( + overrides: Partial = {}, +): OrchestrationV2ThreadShell { + return { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + worktreePath: null, + activeProviderThreadId: null, + lineage: { + rootThreadId: ThreadId.make("thread-1"), + parentThreadId: null, + relationshipToParent: null, + }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + activeRunId: null, + latestVisibleMessage: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + lastVisitedAt: null, + deletedAt: null, + branch: null, + linkedPullRequest: null, + status: "idle", + activityRunStatus: null, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + latestRunId: null, + latestRunRequestedAt: null, + latestRunStartedAt: null, + latestRunCompletedAt: null, + latestUserMessageAt: null, + createdAt: at(-30 * DAY_MS), + updatedAt: at(-10 * DAY_MS), + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + ...overrides, + }; +} diff --git a/apps/server/src/vcs/worktreeThreadState.ts b/apps/server/src/vcs/worktreeThreadState.ts new file mode 100644 index 000000000000..568ead2b202e --- /dev/null +++ b/apps/server/src/vcs/worktreeThreadState.ts @@ -0,0 +1,46 @@ +import type { OrchestrationV2ThreadShell } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +import { threadHasQueuedTurnStart } from "../orchestration-v2/ThreadSettlementService.ts"; + +const IDLE_THREAD_STATUSES = new Set([ + "idle", + "completed", + "interrupted", + "failed", + "cancelled", + "rolled_back", +]); + +/** + * A thread that is running, starting, waiting on a request or background work, + * or has a turn queued. Its checkout is in use whatever its settled state, so + * this blocks manual removal and Storage cleanup alike. + */ +export function worktreeThreadBusy(thread: OrchestrationV2ThreadShell, now: number): boolean { + return ( + thread.activeRunId !== null || + !IDLE_THREAD_STATUSES.has(thread.status) || + (thread.pendingBackgroundTasks?.length ?? 0) > 0 || + thread.pendingRuntimeRequest !== null || + threadHasQueuedTurnStart(thread, now) + ); +} + +/** Live sessions keep their cwd even when no turn is currently running. */ +export function storageCleanupThreadIdle(thread: OrchestrationV2ThreadShell, now: number): boolean { + return thread.branch !== null && thread.worktreePath !== null && !worktreeThreadBusy(thread, now); +} + +/** PR metadata refreshes must not reset the inactivity clock. */ +export function storageCleanupActivityAt(thread: OrchestrationV2ThreadShell): number { + return Math.max( + ...[ + thread.createdAt, + thread.latestUserMessageAt, + thread.latestRunRequestedAt, + thread.latestRunStartedAt, + thread.latestRunCompletedAt, + ].flatMap((value) => (value == null ? [] : [DateTime.toEpochMillis(value)])), + ); +} diff --git a/apps/server/src/workspace/workspaceLease.ts b/apps/server/src/workspace/workspaceLease.ts index d075d59365bb..109813a61991 100644 --- a/apps/server/src/workspace/workspaceLease.ts +++ b/apps/server/src/workspace/workspaceLease.ts @@ -1,9 +1,37 @@ import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Semaphore from "effect/Semaphore"; const leases = new Map(); -/** Coordinates checkout removal and startup across threads using the same resolved cwd. */ +/** + * The lease key for a checkout: its real path, or for a checkout that is gone + * the real path of the deepest existing ancestor plus the missing tail. + * Terminal startup, worktree removal and worktree revival all key on this, so + * a symlinked T3 home cannot hand them different leases for one directory. + */ +export const resolveWorkspaceLeasePath = Effect.fn("resolveWorkspaceLeasePath")(function* ( + cwd: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const missingSegments: string[] = []; + let ancestor = path.resolve(cwd); + while (true) { + const real = yield* fs.realPath(ancestor).pipe(Effect.option); + if (real._tag === "Some") return path.resolve(real.value, ...missingSegments); + const parent = path.dirname(ancestor); + if (parent === ancestor) return path.resolve(cwd); + missingSegments.unshift(path.basename(ancestor)); + ancestor = parent; + } +}); + +/** + * Serializes checkout removal, revival and terminal startup on one checkout. + * Pass a key from `resolveWorkspaceLeasePath`. The lease is not reentrant. + */ export const withWorkspaceLease = ( cwd: string, effect: Effect.Effect, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c1a01b963a50..632c20e27774 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -101,6 +101,7 @@ import { type PullRequestRef, WS_METHODS, WsRpcGroup, + GitCommandError, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { @@ -195,6 +196,8 @@ import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as WorktreeLifecycle from "./vcs/WorktreeLifecycle.ts"; +import * as WorktreeService from "./vcs/WorktreeService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import { refreshPushedPullRequests } from "./git/refreshPushedPullRequests.ts"; import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; @@ -1260,6 +1263,8 @@ const layerWsRpc = ( const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; const directEndpoints = yield* DirectEndpoints.DirectEndpoints; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const worktreeLifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; + const worktrees = yield* WorktreeService.WorktreeService; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -2846,10 +2851,26 @@ const layerWsRpc = ( .preparePullRequestThread(input) .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsListRefs]: (input) => gitWorkflow.listRefs(input), + [WS_METHODS.vcsListWorktrees]: (input) => worktrees.listWorktrees(input), + [WS_METHODS.subscribeWorktreeInventory]: () => worktreeLifecycle.changes, + [WS_METHODS.vcsPruneWorktrees]: (input) => worktrees.pruneWorktrees(input), [WS_METHODS.vcsCreateWorktree]: (input) => gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsRemoveWorktree]: (input) => - gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + // Older clients send `force: true`. The service ignores it and + // applies every manual-removal safeguard. + worktrees.removeWorktree(input).pipe( + Effect.mapError( + (error) => + new GitCommandError({ + operation: "WorktreeService.removeWorktree", + command: "git worktree remove", + cwd: input.cwd, + detail: error.message, + }), + ), + Effect.tap(() => refreshGitStatus(input.cwd)), + ), [WS_METHODS.vcsCreateRef]: (input) => gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsSwitchRef]: (input) => diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 68465b06d0fd..9b8f5ac30939 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,16 +1,20 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon } from "lucide-react"; +import { Link } from "@tanstack/react-router"; import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; -import { useEffect, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useAtomValue } from "@effect/atom-react"; import type { BackgroundActivitySettings, + EnvironmentId, SourceControlProviderKind, SourceControlDiscoveryResult, SourceControlProviderAuth, SourceControlProviderDiscoveryItem, VcsDriverKind, VcsDiscoveryItem, + WorktreeInfo, } from "@t3tools/contracts"; import { getBackgroundActivityBaseProfile, @@ -22,8 +26,39 @@ import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings" import { useSettingsScope } from "./SettingsScopeContext"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { cn } from "../../lib/utils"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { useProject } from "../../state/entities"; +import { environmentServerConfigsAtom } from "../../state/server"; import { useEnvironmentQuery } from "../../state/query"; import { sourceControlEnvironment } from "../../state/sourceControl"; +import { worktreeEnvironment } from "../../state/worktrees"; +import { + confirmWorktreeRemoval, + formatWorktreeAge, + groupWorktreesByProject, + NO_CONFIRMED_WORKTREE_REMOVALS, + primaryLinkedThread, + visibleWorktrees, + worktreeBranchLabel, + worktreeGroupSummary, + worktreeIgnoredNote, + worktreeInventoryRefreshKey, + worktreeRemovalConfirmation, + worktreeRemovalOutcome, + worktreeStateLabel, + type WorktreeProjectGroup, +} from "@t3tools/client-runtime/state/worktrees"; +import { toastManager } from "../ui/toast"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; @@ -57,6 +92,7 @@ import { JujutsuIcon, type Icon, } from "../Icons"; +import { ProjectFavicon } from "../ProjectFavicon"; import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; import { GitHubAccountSettings } from "./GitHubAccountSettings"; import { GitHubTokenSettings } from "./GitHubTokenSettings"; @@ -68,6 +104,7 @@ import { SettingsPageContainer, SettingsSearchTarget, SettingsSection, + useRelativeTimeTick, useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -520,6 +557,509 @@ function EmptySourceControlDiscovery({ ); } +type WorktreeRowProps = { + readonly environmentId: EnvironmentId; + readonly worktree: WorktreeInfo; + readonly nowMs: number; + readonly onPrune: (worktree: WorktreeInfo) => void; + readonly pendingPath: string | null; + readonly canPrune: boolean; +}; + +function WorktreeThreadCell({ + environmentId, + worktree, +}: Pick) { + const { thread, otherCount } = primaryLinkedThread(worktree); + if (thread === null) { + return No linked threads; + } + const title = thread.title || "Untitled thread"; + return ( + + {thread.status === "archived" ? ( + Archived: {title} + ) : ( + + {title} + + )} + {otherCount > 0 ? ( + +{otherCount} + ) : null} + + ); +} + +/** Right-edge cell with one fixed edge: the Remove action, what keeps the worktree, or Removing. */ +function WorktreeStateCell({ + worktree, + onPrune, + pendingPath, + canPrune, +}: Pick) { + if (pendingPath === worktree.path) { + return ( + + Removing + + ); + } + const state = worktreeStateLabel(worktree); + if (state === null) { + const ignoredNote = worktreeIgnoredNote(worktree); + return ( + <> + {ignoredNote ? {ignoredNote} : null} + + + ); + } + return ( + + + {state.text} + + } + /> + {state.detail} + + ); +} + +/** Branch, thread, age and state on fixed tracks, so every row lines up down the list. */ +function WorktreeRow({ + environmentId, + worktree, + nowMs, + onPrune, + pendingPath, + canPrune, +}: WorktreeRowProps) { + const removing = pendingPath === worktree.path; + return ( +
+ + + + {worktree.branch ?? "Detached HEAD"} + + } + /> + + {worktree.path} + + + {worktree.branch === null && worktree.headShortSha !== null ? ( + + {worktree.headShortSha} + + ) : null} + + + + + + {worktree.lastActivityAt ? formatWorktreeAge(worktree.lastActivityAt, nowMs) : null} + + + + +
+ ); +} + +/** Project heading with its counts; the workspace path lives in the title tooltip. */ +function WorktreeGroupHeading({ + environmentId, + group, +}: { + readonly environmentId: EnvironmentId; + readonly group: WorktreeProjectGroup; +}) { + const otherProjectCount = group.projectTitles.length - 1; + const project = useProject({ environmentId, projectId: group.projectId }); + return ( +
+ {project ? : null} + + {group.projectTitle} + } + /> + + {group.workspaceRoot} + + + {otherProjectCount > 0 ? ( + + +{otherProjectCount} project{otherProjectCount === 1 ? "" : "s"} + + ) : null} + + {worktreeGroupSummary(group)} + +
+ ); +} + +function WorktreeList(props: { + readonly environmentId: EnvironmentId; + readonly worktrees: ReadonlyArray; + readonly onPrune: (worktree: WorktreeInfo) => void; + readonly pendingPath: string | null; + readonly canPrune: boolean; +}) { + const nowMs = useRelativeTimeTick(30_000); + return groupWorktreesByProject(props.worktrees).map((group) => ( +
+ +
+ {group.worktrees.map((worktree) => ( + + ))} +
+
+ )); +} + +/** Names what stays and, when removal deletes ignored files, which ones. */ +function WorktreePruneConfirmation({ + open, + worktree, + onOpenChange, + onOpenChangeComplete, + onConfirm, +}: { + readonly open: boolean; + readonly worktree: WorktreeInfo | null; + readonly onOpenChange: (open: boolean) => void; + readonly onOpenChangeComplete: (open: boolean) => void; + readonly onConfirm: () => void; +}) { + const confirmation = worktree === null ? null : worktreeRemovalConfirmation(worktree); + return ( + + + + {confirmation?.title} + {confirmation?.message} + + {confirmation?.allowIgnoredFiles ? ( +
    + {confirmation.ignoredFiles.map((file) => ( +
  • + {file} +
  • + ))} + {confirmation.ignoredMoreCount > 0 ? ( +
  • and {confirmation.ignoredMoreCount} more
  • + ) : null} +
+ ) : null} + + }>Cancel + + +
+
+ ); +} + +type WorktreeEnvironmentTarget = { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPrimary: boolean; +}; + +/** Worktree inventory for one environment. Mounted per environment, so state + can never leak across servers. */ +function WorktreeEnvironmentGroup({ + target, + showLabel, + refreshToken, + onPendingChange, +}: { + readonly target: WorktreeEnvironmentTarget; + readonly showLabel: boolean; + readonly refreshToken: number; + readonly onPendingChange: (environmentId: EnvironmentId, pending: boolean) => void; +}) { + const environmentId = target.environmentId; + const canPrune = useAtomValue(worktreeEnvironment.prune.permissionAtom(environmentId)); + const inventory = useEnvironmentQuery(worktreeEnvironment.list({ environmentId, input: {} })); + const { refresh: refreshInventory, isPending: inventoryPending } = inventory; + const listedRevision = inventory.data?.revision; + const streamRevision = useEnvironmentQuery( + worktreeEnvironment.changes({ environmentId, input: {} }), + ).data?.revision; + const pruneWorktrees = useAtomCommand(worktreeEnvironment.prune, { + label: "prune worktrees", + }); + const [pendingPath, setPendingPath] = useState(null); + const [pruneCandidate, setPruneCandidate] = useState(null); + const [pruneDialogOpen, setPruneDialogOpen] = useState(false); + // Hides a confirmed removal right away, before the inventory is read again. + const [removals, setRemovals] = useState(NO_CONFIRMED_WORKTREE_REMOVALS); + const worktrees = visibleWorktrees(inventory.data, removals); + const lastRefreshKey = useRef(null); + + useEffect(() => { + const refreshKey = worktreeInventoryRefreshKey({ + listedRevision, + streamRevision, + isPending: inventoryPending, + lastRefreshKey: lastRefreshKey.current, + }); + if (refreshKey === null) return; + lastRefreshKey.current = refreshKey; + refreshInventory(); + }, [refreshInventory, inventoryPending, listedRevision, streamRevision]); + + useEffect(() => { + if (refreshToken === 0) return; + refreshInventory(); + }, [refreshInventory, refreshToken]); + + useEffect(() => { + onPendingChange(environmentId, inventoryPending); + }, [environmentId, inventoryPending, onPendingChange]); + + useEffect( + () => () => { + onPendingChange(environmentId, false); + }, + [environmentId, onPendingChange], + ); + + const handlePrune = (worktree: WorktreeInfo) => { + if (!canPrune || !worktree.safeToPrune || pendingPath !== null) return; + setPruneCandidate(worktree); + setPruneDialogOpen(true); + }; + + const handleConfirmPrune = () => { + if (!canPrune || pruneCandidate === null || pendingPath !== null) return; + const worktree = pruneCandidate; + // Opt in to deleting ignored files only when this dialog listed them. + const { allowIgnoredFiles } = worktreeRemovalConfirmation(worktree); + setPendingPath(worktree.path); + setPruneDialogOpen(false); + void pruneWorktrees({ + environmentId, + input: { + projectId: worktree.projectId, + paths: [worktree.path], + ...(allowIgnoredFiles ? { allowIgnoredFiles } : {}), + }, + }) + .then((result) => { + if (result._tag !== "Success") return; + const outcome = worktreeRemovalOutcome(result.value); + if (outcome.removed) { + setRemovals((current) => confirmWorktreeRemoval(current, listedRevision, worktree.path)); + return; + } + // The row stays; the read below gives it its new reason. + toastManager.add({ + type: "warning", + title: `Kept ${worktreeBranchLabel(worktree)}`, + description: outcome.message, + }); + }) + .finally(() => { + setPendingPath(null); + refreshInventory(); + }); + }; + + return ( +
+ {showLabel ? ( +
+

{target.label}

+ {target.isPrimary ? ( + primary + ) : null} +
+ ) : null} + {/* Rows from the last read stay up while a refresh runs or fails. */} + {inventory.error !== null ? ( +

+ Couldn't read worktrees. + +

+ ) : null} + {inventory.data === null ? ( + inventory.error === null ? ( +

+ Reading worktrees +

+ ) : null + ) : worktrees.length === 0 ? ( +

No worktrees

+ ) : ( + + )} + { + if (!open) setPruneCandidate(null); + }} + onConfirm={handleConfirmPrune} + /> +
+ ); +} + +/** + * Every connected environment's managed worktrees. An environment whose + * server predates worktree management is left out rather than shown broken. + * Cleanup rules stay in Storage settings; this only links there. + */ +function WorktreeManagementSection() { + const { environments } = useEnvironments(); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const [refreshToken, setRefreshToken] = useState(0); + const [pendingEnvironmentIds, setPendingEnvironmentIds] = useState>( + () => new Set(), + ); + const handlePendingChange = useCallback((environmentId: EnvironmentId, pending: boolean) => { + setPendingEnvironmentIds((current) => { + const alreadyPending = current.has(environmentId); + if (alreadyPending === pending) return current; + const next = new Set(current); + if (pending) next.add(environmentId); + else next.delete(environmentId); + return next; + }); + }, []); + const isPending = pendingEnvironmentIds.size > 0; + const targets: WorktreeEnvironmentTarget[] = environments + .filter( + (environment) => + environment.connection.phase === "connected" && + serverConfigs.get(environment.environmentId)?.environment.capabilities + .worktreeManagement === true, + ) + .map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + isPrimary: environment.environmentId === primaryEnvironmentId, + })) + .sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary) || a.label.localeCompare(b.label)); + + return ( + } + headerAction={ +
+ + Cleanup rules + + + setRefreshToken((token) => token + 1)} + disabled={isPending} + aria-busy={isPending} + aria-label="Refresh worktrees" + > + + + } + /> + Refresh worktrees + +
+ } + > + {targets.length === 0 ? ( +

+ Connect an up-to-date server to manage its worktrees. +

+ ) : ( +
+ {targets.map((target) => ( + 1} + refreshToken={refreshToken} + onPendingChange={handlePendingChange} + /> + ))} +
+ )} +
+ ); +} + export function SourceControlSettingsPanel() { const { scope, environment, connectedEnvironments } = useSettingsScope(); // Discovery scans one machine's tools, so it shows the representative @@ -650,6 +1190,7 @@ export function SourceControlSettingsPanel() { /> )} + ); diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 17bc6407d14b..ade0b75d34fe 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -70,6 +70,7 @@ describe("searchSettings", () => { localeLowerCase.mockRestore(); } expect(searchSettings("xyzzy")).toEqual([]); + expect(searchSettings("worktrees")[0]?.id).toBe("worktrees"); }); it("keeps catalog order for multiple title matches", () => { @@ -423,6 +424,13 @@ describe("settings search targets", () => { expect(isSettingsSearchScopeAvailable(model.scope, "project")).toBe(true); }); + it("reaches the worktree inventory from every selection", () => { + const worktrees = getSettingsSearchTargetScope("worktrees")!; + for (const kind of ["all", "environment", "project", "checkout"] as const) { + expect(isSettingsSearchScopeAvailable(worktrees.scope, kind)).toBe(true); + } + }); + it("reaches source control discovery and git fetch interval from the default scope", () => { for (const id of ["source-control", "git-fetch-interval"]) { const item = getSettingsSearchTargetScope(id)!; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index aff52adeb155..8c01ccdc2108 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -45,7 +45,8 @@ export interface SettingsSearchItem { readonly targetId?: string; /** Descriptions, option labels, and aliases people may remember instead of the title. */ readonly searchTerms?: ReadonlyArray; - readonly scope?: SettingsSearchScope; + /** Defaults to its page's scope; `null` marks a row that renders at every selection. */ + readonly scope?: SettingsSearchScope | null; // Its row only renders in the desktop app, so a browser result would land on // an anchor that isn't there. readonly desktopOnly?: boolean; @@ -912,6 +913,13 @@ export const SETTINGS_SEARCH_ITEMS = [ "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint", ], }, + { + id: "worktrees", + title: "Worktrees", + to: "/settings/source-control", + // Lists every connected environment, whatever the selection. + scope: null, + }, { id: "remote-environments", title: "Environments", @@ -970,7 +978,7 @@ export function getSettingsSearchTargetScope(targetId: string) { return item ? { title: item.title, - scope: item.scope ?? SETTINGS_CATEGORY_SCOPES[item.to], + scope: item.scope === undefined ? SETTINGS_CATEGORY_SCOPES[item.to] : item.scope, ...(item.requiresThreadAutoSettlement ? { requiresThreadAutoSettlement: true } : {}), } : null; diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index b0093f4a0a12..efafeaa47b22 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -16,6 +16,7 @@ import { ThreadId, sessionGrantsScope, } from "@t3tools/contracts"; +import { worktreeRemovalOutcome } from "@t3tools/client-runtime/state/worktrees"; import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -26,12 +27,12 @@ import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; import { environmentSession, readEnvironmentScope } from "../state/session"; -import { terminalEnvironment } from "../state/terminal"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../state/server"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; +import { worktreeEnvironment } from "../state/worktrees"; import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; @@ -313,6 +314,9 @@ export function useThreadActions() { const loadSessionState = useAtomQueryRunner(environmentSession.sessionStateAtom, { reportFailure: false, }); + const pruneWorktrees = useAtomCommand(worktreeEnvironment.prune, { + reportFailure: false, + }); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); @@ -580,43 +584,32 @@ export function useThreadActions() { return deleteResult; } - const removeResult = readEnvironmentScope( - threadRef.environmentId, - AuthSourceControlWriteScope, - ) - ? await removeWorktree({ - environmentId: threadRef.environmentId, - input: { - cwd: threadProject.workspaceRoot, - path: orphanedWorktreePath, - force: true, - }, - }) - : AsyncResult.failure( + // Removal never forces: the server keeps a worktree that is still in + // use or holds unsaved or ignored files, and says why. A server without + // worktree management only has the legacy removal. + const worktreeLabel = displayWorktreePath ?? orphanedWorktreePath; + const removal = !readEnvironmentScope(threadRef.environmentId, AuthSourceControlWriteScope) + ? AsyncResult.failure( Cause.fail( new EnvironmentAuthorizationError({ message: "This connection can no longer remove worktrees.", requiredScope: AuthSourceControlWriteScope, }), ), - ); - const refreshResult = - removeResult._tag === "Success" - ? await refreshVcsStatus({ + ) + : environmentConfig?.environment.capabilities.worktreeManagement + ? await pruneWorktrees({ environmentId: threadRef.environmentId, - input: { cwd: threadProject.workspaceRoot }, + input: { projectId: thread.projectId, paths: [orphanedWorktreePath] }, }) - : null; - const cleanupFailure = - removeResult._tag === "Failure" - ? removeResult - : refreshResult?._tag === "Failure" - ? refreshResult - : null; - if (cleanupFailure) { - const removalFailed = removeResult._tag === "Failure"; - const error = squashAtomCommandFailure(cleanupFailure); - const message = error instanceof Error ? error.message : "An error occurred."; + : await removeWorktree({ + environmentId: threadRef.environmentId, + input: { cwd: threadProject.workspaceRoot, path: orphanedWorktreePath, force: true }, + }); + // The thread was deleted. Cleanup has its own toast; returning its + // failure would make callers incorrectly report a thread deletion error. + if (removal._tag === "Failure") { + const error = squashAtomCommandFailure(removal); console.error("Worktree cleanup failed after thread deletion", { threadId: threadRef.threadId, projectCwd: threadProject.workspaceRoot, @@ -626,16 +619,44 @@ export function useThreadActions() { toastManager.add( stackedThreadToast({ type: "error", - title: removalFailed - ? "Failed to delete worktree" - : "Worktree deleted, but Git status refresh failed", - description: removalFailed - ? `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}` - : message, + title: "Failed to delete worktree", + description: `Could not remove ${worktreeLabel}. ${ + error instanceof Error ? error.message : "An error occurred." + }`, + }), + ); + return deleteResult; + } + // The legacy removal has no result: it either removed the worktree or failed. + const outcome = removal.value === undefined ? null : worktreeRemovalOutcome(removal.value); + if (outcome !== null && !outcome.removed) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: `Kept worktree ${worktreeLabel}`, + description: outcome.message, + }), + ); + return deleteResult; + } + const refreshResult = await refreshVcsStatus({ + environmentId: threadRef.environmentId, + input: { cwd: threadProject.workspaceRoot }, + }); + if (refreshResult._tag === "Failure") { + const error = squashAtomCommandFailure(refreshResult); + console.error("Git status refresh failed after worktree deletion", { + threadId: threadRef.threadId, + projectCwd: threadProject.workspaceRoot, + error, + }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Worktree deleted, but Git status refresh failed", + description: error instanceof Error ? error.message : "An error occurred.", }), ); - // The thread was deleted. Cleanup has its own toast; returning its - // failure would make callers incorrectly report a thread deletion error. } return deleteResult; }, @@ -646,6 +667,7 @@ export function useThreadActions() { deleteThreadMutation, getCurrentRouteThreadRef, loadSessionState, + pruneWorktrees, refreshVcsStatus, removeWorktree, router, diff --git a/apps/web/src/state/worktrees.ts b/apps/web/src/state/worktrees.ts new file mode 100644 index 000000000000..3b1e0acb275a --- /dev/null +++ b/apps/web/src/state/worktrees.ts @@ -0,0 +1,5 @@ +import { createWorktreeEnvironmentAtoms } from "@t3tools/client-runtime/state/worktrees"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const worktreeEnvironment = createWorktreeEnvironmentAtoms(connectionAtomRuntime); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index b0452974f1c1..1734643ef2ac 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -192,6 +192,44 @@ server, but the host's own site will not show them, and the count reads **viewed The **Code** tab is a web and desktop surface. The mobile app reports a pull request's status but does not show its diff, so marks are made and read on web and desktop. +## Manage thread worktrees + +Open **Settings โ†’ Source Control โ†’ Worktrees** to see the Git worktrees T3 Code manages on each +connected machine, grouped by project. Each row shows its branch, a linked thread, when it was last +used, and either **Remove** or what keeps it. The mobile app has the same list under +**Settings โ†’ Source control**. + +The list includes the current worktree directory and directories you previously configured. + +Removing a worktree deletes its checkout and keeps its branch, its commits, and T3 checkpoint +history. **Remove** is offered only when nothing would be lost: + +- **In use:** a turn is running or queued, or a provider session or terminal is open there. +- **Open thread:** a linked thread is idle but not settled or archived. +- **Changed, unpushed, or unmerged:** it has uncommitted files, or commits its upstream does not + have. Without an upstream, and for a detached checkout, commits must be on the default branch. +- **Submodules:** it has checked-out submodules or retained submodule repositories. Git refuses + unforced removal. Preserve their commits and local files before removing the checkout manually. +- **Status unknown:** Git could not be read. +- **Linked thread:** the thread records a different branch from the checkout and could not restore it. + +Ignored files such as `.env` or build output are deleted with the checkout, so they need your +confirmation in Settings. The row shows how many there are and the confirmation lists them. +Deleting a thread does not ask about them and keeps a worktree that has them. `node_modules` is +not counted. + +[Storage cleanup](./project-settings.md#storage-cleanup) removes worktrees automatically. It uses +its own rules: it can remove a clean worktree whose thread is idle or whose branch has unpushed +commits, because the branch is kept. The configured local-file policy decides whether ignored or untracked +files keep a checkout. Tracked edits and submodule repositories always keep it. + +When a thread needs a removed worktree again, T3 Code recreates it from the kept branch before the +next turn and runs the project's setup script there. If that fails, for example because the branch +was deleted or a required setup script exits with an error, the turn stops and says why. Send the +message again to retry. + +If a missing checkout is outside those managed directories, restore it manually before retrying. + ## Troubleshooting - **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index aea6ead6b943..a65713cb135d 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -390,6 +390,10 @@ "./state/mcp-apps": { "types": "./src/state/mcpApps.ts", "default": "./src/state/mcpApps.ts" + }, + "./state/worktrees": { + "types": "./src/state/worktrees.ts", + "default": "./src/state/worktrees.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c11a49897b88..6758f7b8bb89 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -70,7 +70,8 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeWorktreeSetup | typeof WS_METHODS.subscribeProjectClones | typeof WS_METHODS.terminalAttach - | typeof WS_METHODS.terminalObserve; + | typeof WS_METHODS.terminalObserve + | typeof WS_METHODS.subscribeWorktreeInventory; export type EnvironmentStreamCommandRpcTag = | typeof WS_METHODS.chatGptHandoffSubscribe diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index b2c6933fabb7..9e29b3b0d64e 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -85,6 +85,32 @@ describe("command permissions", () => { ), ); + it.effect("requires the destination source control grant to prune worktrees", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const prune = createCommandPermissions(runtime, WS_METHODS.vcsPruneWorktrees); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(prune.permissionAtom(env))).toBe(false); + expect((yield* prune.authorize(registry, env).pipe(Effect.flip)).requiredPermission).toBe( + AuthSourceControlWriteScope, + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(true), + scopes: [AuthSourceControlWriteScope], + permissions: [AuthSourceControlWriteScope], + }), + ); + expect(registry.get(prune.permissionAtom(env))).toBe(true); + yield* prune.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(prune.permissionAtom(other))).toBe(false); + }), + ), + ); + it.effect("uses the target grant for both availability and execution", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/vcsCommandScheduler.ts b/packages/client-runtime/src/state/vcsCommandScheduler.ts index a11b157bb2d8..b69186a74ac9 100644 --- a/packages/client-runtime/src/state/vcsCommandScheduler.ts +++ b/packages/client-runtime/src/state/vcsCommandScheduler.ts @@ -11,3 +11,11 @@ export const vcsCommandConcurrency: AtomCommandConcurrency<{ mode: "serial", key: ({ environmentId, input }) => JSON.stringify([environmentId, input.cwd]), }; + +export const worktreeCommandConcurrency: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentId; + readonly input: unknown; +}> = { + mode: "serial", + key: ({ environmentId }) => environmentId, +}; diff --git a/packages/client-runtime/src/state/worktrees.test.ts b/packages/client-runtime/src/state/worktrees.test.ts new file mode 100644 index 000000000000..865848982cf9 --- /dev/null +++ b/packages/client-runtime/src/state/worktrees.test.ts @@ -0,0 +1,235 @@ +import { ProjectId, type WorktreeInfo } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + confirmWorktreeRemoval, + formatWorktreeAge, + groupWorktreesByProject, + NO_CONFIRMED_WORKTREE_REMOVALS, + visibleWorktrees, + worktreeBranchLabel, + worktreeGroupSummary, + worktreeIgnoredNote, + worktreeInventoryRefreshKey, + worktreeRemovalConfirmation, + worktreeRemovalOutcome, + worktreeStateLabel, +} from "./worktrees.ts"; + +const projectId = ProjectId.make("project-1"); + +function worktree(overrides: Partial = {}): WorktreeInfo { + return { + projectId, + projectTitle: "t3code", + workspaceRoot: "/repo", + projects: [{ projectId, projectTitle: "t3code", workspaceRoot: "/repo" }], + path: "/worktrees/feature", + branch: "feature", + headShortSha: "a7d01ef", + threads: [], + dirty: false, + dirtyFileCount: 0, + ignoredFileCount: 0, + ignoredFiles: [], + hasUpstream: true, + upstreamGone: false, + aheadOfUpstreamCount: 0, + behindUpstreamCount: 0, + aheadOfDefaultCount: null, + lastActivityAt: null, + safeToPrune: true, + pruneBlockers: [], + ...overrides, + }; +} + +const blocked = (overrides: Partial) => + worktree({ safeToPrune: false, ...overrides }); + +describe("worktree inventory refresh", () => { + const decide = ( + listedRevision: number | undefined, + streamRevision: number | undefined, + options: { readonly isPending?: boolean; readonly lastRefreshKey?: string | null } = {}, + ) => + worktreeInventoryRefreshKey({ + listedRevision, + streamRevision, + isPending: options.isPending ?? false, + lastRefreshKey: options.lastRefreshKey ?? null, + }); + + it("reads again when a change landed between the list and the subscription", () => { + expect(decide(4, 5)).toBe("4:5"); + expect(decide(5, 5)).toBeNull(); + }); + + it("waits for a read in flight, then compares its revision", () => { + expect(decide(4, 5, { isPending: true })).toBeNull(); + // The read started before the change and came back with the old revision. + expect(decide(4, 6, { lastRefreshKey: "3:5" })).toBe("4:6"); + }); + + it("reads again after a server restart, whose revisions start elsewhere", () => { + expect(decide(1_700_000_000_005, 1_700_000_900_000)).not.toBeNull(); + }); + + it("does not loop on a read that failed and left the list unchanged", () => { + expect(decide(4, 5, { lastRefreshKey: "4:5" })).toBeNull(); + }); + + it("waits until both the list and the stream have reported", () => { + expect(decide(undefined, 5)).toBeNull(); + expect(decide(4, undefined)).toBeNull(); + }); +}); + +describe("worktree rows", () => { + it("drops a confirmed removal at once and keeps every other row", () => { + const kept = worktree({ path: "/worktrees/kept" }); + const removed = worktree({ path: "/worktrees/removed" }); + const inventory = { worktrees: [kept, removed], revision: 4 }; + const removals = confirmWorktreeRemoval(NO_CONFIRMED_WORKTREE_REMOVALS, 4, removed.path); + + expect(visibleWorktrees(inventory, NO_CONFIRMED_WORKTREE_REMOVALS)).toEqual([kept, removed]); + expect(visibleWorktrees(inventory, removals)).toEqual([kept]); + expect(visibleWorktrees(null, removals)).toEqual([]); + }); + + it("shows a worktree revived at a removed path once the list is read again", () => { + const revived = worktree({ path: "/worktrees/removed" }); + const removals = confirmWorktreeRemoval(NO_CONFIRMED_WORKTREE_REMOVALS, 4, revived.path); + + expect(visibleWorktrees({ worktrees: [revived], revision: 6 }, removals)).toEqual([revived]); + }); + + it("groups by project with removable worktrees first", () => { + const other = ProjectId.make("project-2"); + const groups = groupWorktreesByProject([ + blocked({ path: "/worktrees/a", pruneBlockers: ["dirty"] }), + worktree({ path: "/worktrees/b" }), + worktree({ path: "/worktrees/c", projectId: other, projectTitle: "docs-site" }), + ]); + + expect(groups.map((group) => group.projectTitle)).toEqual(["docs-site", "t3code"]); + expect(groups[1]?.worktrees.map((entry) => entry.path)).toEqual([ + "/worktrees/b", + "/worktrees/a", + ]); + expect(worktreeGroupSummary(groups[1]!)).toBe("2, 1 removable"); + }); + + it("tells detached checkouts apart by commit", () => { + expect(worktreeBranchLabel(worktree({ branch: null }))).toBe("Detached HEAD a7d01ef"); + expect(worktreeBranchLabel(worktree())).toBe("feature"); + }); + + it("formats age without a suffix", () => { + const now = Date.parse("2026-06-10T12:00:00.000Z"); + expect(formatWorktreeAge("2026-06-10T11:59:40.000Z", now)).toBe("now"); + expect(formatWorktreeAge("2026-06-10T11:20:00.000Z", now)).toBe("40m"); + expect(formatWorktreeAge("2026-06-07T12:00:00.000Z", now)).toBe("3d"); + }); +}); + +describe("worktree state label", () => { + it("has none for a removable worktree", () => { + expect(worktreeStateLabel(worktree())).toBeNull(); + }); + + it("separates a checkout in use from an idle open thread", () => { + expect( + worktreeStateLabel(blocked({ pruneBlockers: ["running", "open_thread"] })), + ).toMatchObject({ text: "In use", tone: "neutral" }); + expect(worktreeStateLabel(blocked({ pruneBlockers: ["terminal"] }))?.text).toBe("In use"); + expect(worktreeStateLabel(blocked({ pruneBlockers: ["open_thread"] }))).toMatchObject({ + text: "Open thread", + tone: "neutral", + }); + }); + + it("counts changed files and unpushed commits separately", () => { + expect( + worktreeStateLabel( + blocked({ + pruneBlockers: ["dirty", "unpushed", "open_thread"], + dirtyFileCount: 4, + aheadOfUpstreamCount: 2, + }), + ), + ).toMatchObject({ text: "4 changed, 2 unpushed", tone: "warning" }); + }); + + it("calls commits unmerged without an upstream or on a detached checkout", () => { + expect( + worktreeStateLabel( + blocked({ + pruneBlockers: ["unpushed"], + hasUpstream: false, + aheadOfUpstreamCount: null, + aheadOfDefaultCount: 5, + }), + )?.text, + ).toBe("5 unmerged"); + expect( + worktreeStateLabel( + blocked({ + branch: null, + pruneBlockers: ["unpushed", "unrestorable_thread"], + aheadOfUpstreamCount: null, + aheadOfDefaultCount: 3, + }), + )?.text, + ).toBe("3 unmerged, Linked thread"); + }); + + it("warns about submodules Git will not remove without force", () => { + expect(worktreeStateLabel(blocked({ pruneBlockers: ["submodules"] }))).toMatchObject({ + text: "Submodules", + tone: "warning", + }); + }); + + it("reports unreadable status", () => { + expect(worktreeStateLabel(blocked({ pruneBlockers: ["status_unavailable"] }))).toMatchObject({ + text: "Status unknown", + tone: "warning", + }); + }); +}); + +describe("worktree removal", () => { + it("opts in to deleting ignored files only when the confirmation lists them", () => { + expect(worktreeRemovalConfirmation(worktree())).toMatchObject({ + title: "Remove feature?", + message: "The branch and checkpoints stay.", + allowIgnoredFiles: false, + }); + + const withIgnored = worktree({ ignoredFileCount: 7, ignoredFiles: [".env", "dist/"] }); + expect(worktreeIgnoredNote(withIgnored)).toBe("7 ignored"); + expect(worktreeRemovalConfirmation(withIgnored)).toEqual({ + title: "Remove feature?", + message: "The branch and checkpoints stay. These ignored files are deleted:", + ignoredFiles: [".env", "dist/"], + ignoredMoreCount: 5, + allowIgnoredFiles: true, + }); + }); + + it("explains why the server kept a worktree", () => { + expect( + worktreeRemovalOutcome({ + removed: [], + skipped: [{ path: "/worktrees/feature", reason: "session" }], + }), + ).toEqual({ removed: false, message: "A provider session is still using it." }); + expect( + worktreeRemovalOutcome({ + removed: [{ path: "/worktrees/feature", workspaceRoot: "/repo" }], + skipped: [], + }), + ).toEqual({ removed: true }); + }); +}); diff --git a/packages/client-runtime/src/state/worktrees.ts b/packages/client-runtime/src/state/worktrees.ts new file mode 100644 index 000000000000..6cebfeca3083 --- /dev/null +++ b/packages/client-runtime/src/state/worktrees.ts @@ -0,0 +1,331 @@ +import { + WS_METHODS, + type VcsListWorktreesResult, + type VcsPruneWorktreesResult, + type WorktreeInfo, + type WorktreePruneBlocker, + type WorktreePruneSkip, + type WorktreeThreadRef, +} from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; +import { vcsCommandScheduler, worktreeCommandConcurrency } from "./vcsCommandScheduler.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createWorktreeEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + list: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:worktrees", + tag: WS_METHODS.vcsListWorktrees, + staleTimeMs: 15_000, + }), + changes: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:vcs:worktrees:changes", + tag: WS_METHODS.subscribeWorktreeInventory, + }), + prune: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:worktrees:prune", + tag: WS_METHODS.vcsPruneWorktrees, + scheduler: vcsCommandScheduler, + concurrency: worktreeCommandConcurrency, + }), + }; +} + +/** + * Decides whether the inventory list must be read again. The list carries the + * revision it was read at and the change stream carries the latest one, so a + * change that lands between the two requests, during a read, while the view + * was closed, or across a server restart all show up as a difference. + * + * Returns the key of the read to start, which the caller stores and passes + * back as `lastRefreshKey`, or null. One read per distinct pair of revisions + * means a failing read cannot loop, and nothing is decided while a read is in + * flight: its result may already carry the latest revision. + */ +export function worktreeInventoryRefreshKey(input: { + readonly listedRevision: number | undefined; + readonly streamRevision: number | undefined; + readonly isPending: boolean; + readonly lastRefreshKey: string | null; +}): string | null { + const { listedRevision, streamRevision } = input; + if (input.isPending || listedRevision === undefined || streamRevision === undefined) return null; + if (listedRevision === streamRevision) return null; + const key = `${listedRevision}:${streamRevision}`; + return key === input.lastRefreshKey ? null : key; +} + +/** Worktrees the server confirmed removed since the list at `revision` was read. */ +export interface ConfirmedWorktreeRemovals { + readonly revision: number | undefined; + readonly paths: ReadonlySet; +} + +export const NO_CONFIRMED_WORKTREE_REMOVALS: ConfirmedWorktreeRemovals = { + revision: undefined, + paths: new Set(), +}; + +/** Records a removal confirmed against the list read at `revision`. */ +export function confirmWorktreeRemoval( + current: ConfirmedWorktreeRemovals, + revision: number | undefined, + path: string, +): ConfirmedWorktreeRemovals { + return { + revision, + paths: new Set(current.revision === revision ? [...current.paths, path] : [path]), + }; +} + +/** + * The rows to show: the last inventory read minus worktrees confirmed removed + * since. Rows the server kept stay. A newer read replaces both, so a worktree + * revived at the same path shows up again with that read. + */ +export function visibleWorktrees( + inventory: VcsListWorktreesResult | null, + removals: ConfirmedWorktreeRemovals, +): ReadonlyArray { + if (inventory === null) return []; + return removals.revision !== inventory.revision || removals.paths.size === 0 + ? inventory.worktrees + : inventory.worktrees.filter((worktree) => !removals.paths.has(worktree.path)); +} + +export interface WorktreeProjectGroup { + readonly projectId: WorktreeInfo["projectId"]; + readonly projectTitle: string; + /** Every project that shares this repository, the primary one first. */ + readonly projectTitles: ReadonlyArray; + readonly workspaceRoot: string; + /** Removable worktrees first. */ + readonly worktrees: ReadonlyArray; + readonly removableCount: number; +} + +export function groupWorktreesByProject( + worktrees: ReadonlyArray, +): ReadonlyArray { + const byProject = new Map(); + for (const worktree of worktrees) { + const group = byProject.get(worktree.projectId); + if (group === undefined) byProject.set(worktree.projectId, [worktree]); + else group.push(worktree); + } + return [...byProject.values()] + .flatMap((group): WorktreeProjectGroup[] => { + const [first] = group; + if (first === undefined) return []; + return [ + { + projectId: first.projectId, + projectTitle: first.projectTitle, + projectTitles: first.projects.map((project) => project.projectTitle), + workspaceRoot: first.workspaceRoot, + worktrees: [...group].sort((a, b) => Number(b.safeToPrune) - Number(a.safeToPrune)), + removableCount: group.filter((worktree) => worktree.safeToPrune).length, + }, + ]; + }) + .sort( + (a, b) => + a.projectTitle.localeCompare(b.projectTitle) || + a.workspaceRoot.localeCompare(b.workspaceRoot), + ); +} + +/** "8, 3 removable" */ +export function worktreeGroupSummary(group: WorktreeProjectGroup): string { + return `${group.worktrees.length}, ${group.removableCount} removable`; +} + +/** The branch, or for a detached checkout its short commit, which tells two apart. */ +export function worktreeBranchLabel(worktree: WorktreeInfo): string { + if (worktree.branch !== null) return worktree.branch; + return worktree.headShortSha === null + ? "Detached HEAD" + : `Detached HEAD ${worktree.headShortSha}`; +} + +/** Thread shown for a worktree: an open one first, then settled, then whatever is left. */ +export function primaryLinkedThread(worktree: WorktreeInfo): { + readonly thread: WorktreeThreadRef | null; + readonly otherCount: number; +} { + const linked = worktree.threads.filter((thread) => thread.status !== "deleted"); + const thread = + linked.find((candidate) => candidate.status === "active") ?? + linked.find((candidate) => candidate.status === "settled") ?? + linked[0] ?? + null; + return { thread, otherCount: thread === null ? 0 : linked.length - 1 }; +} + +/** "now", "40m", "3h", "12d", "2mo", "1y" */ +export function formatWorktreeAge(iso: string, nowMs: number): string { + const minutes = Math.max(0, Math.floor((nowMs - Date.parse(iso)) / 60_000)); + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d`; + const months = Math.floor(days / 30); + return days < 365 ? `${months}mo` : `${Math.floor(days / 365)}y`; +} + +const IN_USE_BLOCKERS: ReadonlySet = new Set([ + "running", + "session", + "terminal", +]); + +function workBlockerLabel(worktree: WorktreeInfo, blocker: WorktreePruneBlocker): string | null { + switch (blocker) { + case "dirty": + return worktree.dirtyFileCount ? `${worktree.dirtyFileCount} changed` : "Local changes"; + case "unpushed": { + // Against its upstream a branch is unpushed. Without one, or detached, + // the count is against the default branch instead. + const againstUpstream = worktree.branch !== null && worktree.aheadOfUpstreamCount !== null; + const count = againstUpstream ? worktree.aheadOfUpstreamCount : worktree.aheadOfDefaultCount; + const label = againstUpstream ? "unpushed" : "unmerged"; + return count ? `${count} ${label}` : label === "unpushed" ? "Unpushed" : "Unmerged"; + } + case "submodules": + return "Submodules"; + case "status_unavailable": + return "Status unknown"; + case "unrestorable_thread": + return "Linked thread"; + default: + return null; + } +} + +// Git refuses this removal unless forced, which T3 Code never does for it. +const SUBMODULES_MESSAGE = + "It has submodule repositories. Preserve their work and remove this checkout manually."; + +const BLOCKER_DETAIL: Record = { + running: "A turn is running or queued here.", + session: "A provider session is using this checkout.", + terminal: "A terminal is open here.", + open_thread: "A linked thread is not settled or archived.", + dirty: "It has uncommitted changes.", + submodules: SUBMODULES_MESSAGE, + unpushed: "It has commits that are not pushed or merged.", + unrestorable_thread: "A linked thread could not get this checkout back after removal.", + status_unavailable: "Git status could not be read.", +}; + +export interface WorktreeStateLabel { + /** "In use", "Open thread", "4 changed, 2 unpushed" */ + readonly text: string; + /** Unsaved or unreadable work warns; a checkout that is merely in use does not. */ + readonly tone: "neutral" | "warning"; + /** Every reason, one sentence each, for a tooltip or a detail line. */ + readonly detail: string; +} + +/** + * What keeps a worktree from being removed, or null when it can be. The most + * pressing class wins the short text: in use, then unsaved work, then an open + * thread. `detail` still names every reason. + */ +export function worktreeStateLabel(worktree: WorktreeInfo): WorktreeStateLabel | null { + if (worktree.safeToPrune) return null; + const blockers = worktree.pruneBlockers; + const detail = blockers.map((blocker) => BLOCKER_DETAIL[blocker]).join(" "); + if (blockers.some((blocker) => IN_USE_BLOCKERS.has(blocker))) { + return { text: "In use", tone: "neutral", detail }; + } + const work = blockers.flatMap((blocker) => workBlockerLabel(worktree, blocker) ?? []); + if (work.length > 0) { + return { + text: work.join(", "), + tone: blockers.every( + (blocker) => blocker === "unrestorable_thread" || blocker === "open_thread", + ) + ? "neutral" + : "warning", + detail, + }; + } + return { text: "Open thread", tone: "neutral", detail }; +} + +/** "2 ignored" for a removable worktree whose removal also deletes ignored files. */ +export function worktreeIgnoredNote(worktree: WorktreeInfo): string | null { + return worktree.ignoredFileCount ? `${worktree.ignoredFileCount} ignored` : null; +} + +export interface WorktreeRemovalConfirmation { + readonly title: string; + readonly message: string; + /** Ignored paths the removal deletes; listed under the message when not empty. */ + readonly ignoredFiles: ReadonlyArray; + /** Ignored paths beyond the listed sample. */ + readonly ignoredMoreCount: number; + /** Send `allowIgnoredFiles` only when the confirmation disclosed them. */ + readonly allowIgnoredFiles: boolean; +} + +export function worktreeRemovalConfirmation(worktree: WorktreeInfo): WorktreeRemovalConfirmation { + const ignoredCount = worktree.ignoredFileCount ?? 0; + const kept = + worktree.branch === null + ? "The commit and checkpoints stay." + : "The branch and checkpoints stay."; + return { + title: `Remove ${worktreeBranchLabel(worktree)}?`, + message: ignoredCount > 0 ? `${kept} These ignored files are deleted:` : kept, + ignoredFiles: worktree.ignoredFiles, + ignoredMoreCount: Math.max(0, ignoredCount - worktree.ignoredFiles.length), + allowIgnoredFiles: ignoredCount > 0, + }; +} + +const SKIP_MESSAGE: Record = { + running: "A turn is running or queued in it.", + session: "A provider session is still using it.", + terminal: "A terminal is open in it.", + open_thread: "A linked thread is still open.", + dirty: "It has uncommitted changes.", + submodules: SUBMODULES_MESSAGE, + unpushed: "It has commits that are not pushed or merged.", + unrestorable_thread: "A linked thread could not get this checkout back.", + status_unavailable: "Its Git status could not be read.", + ignored_files: "It holds ignored files. Remove it from Settings to review them.", + protected_path: "It is not a managed worktree.", + unknown_worktree: "Git no longer registers it for this project.", + changed: "It changed while it was being checked.", + policy_changed: "The cleanup rule no longer applies.", + remove_failed: "Git could not remove it.", +}; + +/** Why a removal request kept a worktree, as one sentence for a toast or alert. */ +function worktreeSkipMessage(skip: Pick): string { + return skip.reason === "remove_failed" && skip.detail ? skip.detail : SKIP_MESSAGE[skip.reason]; +} + +/** The outcome of a request for a single worktree. */ +export function worktreeRemovalOutcome( + result: VcsPruneWorktreesResult, +): { readonly removed: true } | { readonly removed: false; readonly message: string } { + if (result.removed.length > 0) return { removed: true }; + const [skip] = result.skipped; + return { + removed: false, + message: skip === undefined ? "The worktree was kept." : worktreeSkipMessage(skip), + }; +} diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index b9a46fa65287..aa9f55625c0c 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -301,6 +301,13 @@ export function summarizeT3ToolCalls( case "worktree-status": label = phrase("Checked", "check", `worktree status ${times}`); break; + case "worktree-inventory": + label = phrase("Listed", "list", `managed worktrees ${times}`); + break; + // The server may keep a worktree, so this is a request, not a removal. + case "worktree-remove": + label = phrase("Requested removal of", "request removal of", `managed worktrees ${times}`); + break; case "project-list": label = phrase("Listed", "list", `projects ${times}`); break; diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index c84169e6e33f..41c915baa48e 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -32,6 +32,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.gitPreparePullRequestThread]: AuthSourceControlWriteScope, [WS_METHODS.vcsCreateWorktree]: AuthSourceControlWriteScope, [WS_METHODS.vcsRemoveWorktree]: AuthSourceControlWriteScope, + [WS_METHODS.vcsPruneWorktrees]: AuthSourceControlWriteScope, [WS_METHODS.vcsCreateRef]: AuthSourceControlWriteScope, [WS_METHODS.vcsSwitchRef]: AuthSourceControlWriteScope, [WS_METHODS.vcsInit]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 2a61480a907b..017c79416991 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -177,6 +177,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.pull-request.watch and wakes agents on pull request changes. */ threadPullRequestWatch: Schema.optionalKey(Schema.Boolean), pullRequestStackActions: Schema.optionalKey(Schema.Boolean), + /** Server exposes the worktree inventory, change stream, and removal RPCs. + Same version-skew contract as threadSettlement: clients hide worktree + management for environments that lack this. */ + worktreeManagement: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d738ef79909e..5662ab0547e8 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -30,6 +30,7 @@ export * from "./server.ts"; export * from "./settings.ts"; export * from "./git.ts"; export * from "./vcs.ts"; +export * from "./worktrees.ts"; export * from "./sourceControl.ts"; export * from "./projectClone.ts"; export * from "./pullRequest.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 289b7041be16..41175cfce009 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -140,6 +140,15 @@ import { VcsStatusResult, VcsStatusStreamEvent, } from "./git.ts"; +import { + VcsListWorktreesInput, + VcsListWorktreesResult, + WorktreeInventoryChange, + WorktreeInventoryError, + VcsPruneWorktreesInput, + VcsPruneWorktreesResult, + WorktreeMutationError, +} from "./worktrees.ts"; import { ReviewDiffFileContentsInput, ReviewDiffFileContentsResult, @@ -417,6 +426,9 @@ export const WS_METHODS = { vcsPull: "vcs.pull", vcsRefreshStatus: "vcs.refreshStatus", vcsListRefs: "vcs.listRefs", + vcsListWorktrees: "vcs.listWorktrees", + subscribeWorktreeInventory: "vcs.subscribeWorktreeInventory", + vcsPruneWorktrees: "vcs.pruneWorktrees", vcsCreateWorktree: "vcs.createWorktree", vcsRemoveWorktree: "vcs.removeWorktree", vcsCreateRef: "vcs.createRef", @@ -1368,6 +1380,25 @@ const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +const WsVcsListWorktreesRpc = Rpc.make(WS_METHODS.vcsListWorktrees, { + payload: VcsListWorktreesInput, + success: VcsListWorktreesResult, + error: Schema.Union([WorktreeInventoryError, EnvironmentAuthorizationError]), +}); + +const WsSubscribeWorktreeInventoryRpc = Rpc.make(WS_METHODS.subscribeWorktreeInventory, { + payload: Schema.Struct({}), + success: WorktreeInventoryChange, + error: EnvironmentAuthorizationError, + stream: true, +}); + +const WsVcsPruneWorktreesRpc = Rpc.make(WS_METHODS.vcsPruneWorktrees, { + payload: VcsPruneWorktreesInput, + success: VcsPruneWorktreesResult, + error: Schema.Union([WorktreeMutationError, EnvironmentAuthorizationError]), +}); + const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { payload: VcsCreateWorktreeInput, success: VcsCreateWorktreeResult, @@ -1967,6 +1998,9 @@ export const WsRpcGroup = RpcGroup.make( WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, WsVcsListRefsRpc, + WsVcsListWorktreesRpc, + WsSubscribeWorktreeInventoryRpc, + WsVcsPruneWorktreesRpc, WsVcsCreateWorktreeRpc, WsVcsRemoveWorktreeRpc, WsVcsCreateRefRpc, diff --git a/packages/contracts/src/worktrees.test.ts b/packages/contracts/src/worktrees.test.ts new file mode 100644 index 000000000000..7c31d2ce4b7d --- /dev/null +++ b/packages/contracts/src/worktrees.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { + VcsPruneWorktreesInput, + WorktreeInventoryError, + WorktreeMutationError, +} from "./worktrees.ts"; + +const decodePruneWorktreesInput = Schema.decodeUnknownSync(VcsPruneWorktreesInput); + +describe("worktree filesystem paths", () => { + it("preserves leading and trailing whitespace", () => { + const worktreePath = " /tmp/worktree "; + + const prune = decodePruneWorktreesInput({ + projectId: "project-1", + paths: [worktreePath], + }); + + expect(prune.paths).toEqual([worktreePath]); + }); +}); + +describe("worktree removal request", () => { + it("does not opt in to deleting ignored files unless asked", () => { + const prune = decodePruneWorktreesInput({ projectId: "project-1", paths: ["/tmp/worktree"] }); + + expect(prune.allowIgnoredFiles).toBeUndefined(); + }); +}); + +describe("worktree errors", () => { + it("derives messages from structural stages", () => { + const inventoryError = new WorktreeInventoryError({ + stage: "inspect_repository", + workspaceRoot: "/tmp/repository", + cause: new Error("git inspection failed"), + }); + const mutationError = new WorktreeMutationError({ + operation: "revive", + stage: "branch_in_use", + branch: "feature/worktrees", + conflictingPath: "/tmp/existing", + }); + + expect(inventoryError.message).toBe( + "Failed to inspect a repository for the worktree inventory.", + ); + expect(mutationError.message).toBe( + "Cannot revive branch 'feature/worktrees': it is already checked out at '/tmp/existing'.", + ); + }); +}); diff --git a/packages/contracts/src/worktrees.ts b/packages/contracts/src/worktrees.ts new file mode 100644 index 000000000000..af021b566e93 --- /dev/null +++ b/packages/contracts/src/worktrees.ts @@ -0,0 +1,280 @@ +import * as Schema from "effect/Schema"; +import { + IsoDateTime, + NonNegativeInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; + +export const WorktreeThreadStatus = Schema.Literals(["active", "settled", "archived", "deleted"]); +export type WorktreeThreadStatus = typeof WorktreeThreadStatus.Type; + +export const WorktreeThreadRef = Schema.Struct({ + threadId: ThreadId, + title: Schema.String, + status: WorktreeThreadStatus, +}); +export type WorktreeThreadRef = typeof WorktreeThreadRef.Type; + +export const WorktreeProjectRef = Schema.Struct({ + projectId: ProjectId, + projectTitle: Schema.String, + workspaceRoot: Schema.NonEmptyString, +}); +export type WorktreeProjectRef = typeof WorktreeProjectRef.Type; + +/** + * Reasons the inventory will not remove a managed worktree by hand. `running`, + * `session`, `terminal` and `status_unavailable` also stop configured + * Storage cleanup. Its local-file policy determines whether untracked and + * ignored files keep a checkout; tracked edits always do. `submodules` stops + * both: Git refuses an unforced removal of a checkout with initialized + * submodules, and forcing it could lose their unpublished commits. + * `open_thread`, a named branch's `unpushed` commits and + * `unrestorable_thread` only stop manual removal, since cleanup keeps the branch. + * `unrestorable_thread` is a linked thread that revival could not bring back: + * the checkout is detached, or the thread recorded a different branch or none. + */ +export const WorktreePruneBlocker = Schema.Literals([ + "running", + "session", + "terminal", + "open_thread", + "dirty", + "submodules", + "unpushed", + "unrestorable_thread", + "status_unavailable", +]); +export type WorktreePruneBlocker = typeof WorktreePruneBlocker.Type; + +export const WorktreeInfo = Schema.Struct({ + /** Primary project retained for simple callers; `projects` preserves all references. */ + projectId: ProjectId, + projectTitle: Schema.String, + workspaceRoot: Schema.NonEmptyString, + projects: Schema.Array(WorktreeProjectRef), + path: Schema.NonEmptyString, + branch: Schema.NullOr(TrimmedNonEmptyString), + /** Abbreviated HEAD commit, which tells detached checkouts apart. */ + headShortSha: Schema.NullOr(TrimmedNonEmptyString), + threads: Schema.Array(WorktreeThreadRef), + /** null when the working-tree status could not be read. */ + dirty: Schema.NullOr(Schema.Boolean), + /** Number of changed/untracked files; null when status could not be read. */ + dirtyFileCount: Schema.NullOr(NonNegativeInt), + /** + * Ignored paths that removal would delete, other than reproducible + * `node_modules`. Manual removal needs `allowIgnoredFiles` when this is not + * zero. null when status could not be read. + */ + ignoredFileCount: Schema.NullOr(NonNegativeInt), + /** Up to five of those paths, relative to the worktree, for the confirmation. */ + ignoredFiles: Schema.Array(Schema.String), + hasUpstream: Schema.NullOr(Schema.Boolean), + /** Upstream is configured but its remote ref no longer exists. */ + upstreamGone: Schema.Boolean, + aheadOfUpstreamCount: Schema.NullOr(NonNegativeInt), + behindUpstreamCount: Schema.NullOr(NonNegativeInt), + /** + * Commits missing from the default branch. Only measured for a detached + * checkout or a branch without a usable upstream; null otherwise or when it + * could not be read. + */ + aheadOfDefaultCount: Schema.NullOr(NonNegativeInt), + /** Latest linked-thread activity; falls back to directory mtime without a live thread. */ + lastActivityAt: Schema.NullOr(IsoDateTime), + safeToPrune: Schema.Boolean, + pruneBlockers: Schema.Array(WorktreePruneBlocker), +}); +export type WorktreeInfo = typeof WorktreeInfo.Type; + +/** + * Why a removal request left a worktree in place. Beyond the inventory + * blockers: `ignored_files` needs the caller's opt-in, `protected_path` is a + * project root, main checkout or path outside the managed directory, + * `changed` means HEAD or the branch moved during the check, and + * `policy_changed` means a cleanup rule no longer applies. + */ +export const WorktreePruneSkipReason = Schema.Literals([ + ...WorktreePruneBlocker.literals, + "ignored_files", + "protected_path", + "unknown_worktree", + "changed", + "policy_changed", + "remove_failed", +]); +export type WorktreePruneSkipReason = typeof WorktreePruneSkipReason.Type; + +export const VcsListWorktreesInput = Schema.Struct({ + projectId: Schema.optional(ProjectId), +}); +export type VcsListWorktreesInput = typeof VcsListWorktreesInput.Type; + +export const VcsPruneWorktreesInput = Schema.Struct({ + /** The project whose repository registers these worktrees. */ + projectId: ProjectId, + paths: Schema.Array(Schema.NonEmptyString).check(Schema.isMinLength(1)), + /** + * Set only after the user confirmed the ignored paths shown for these + * worktrees. Without it a worktree holding ignored data is skipped. + */ + allowIgnoredFiles: Schema.optional(Schema.Boolean), +}); +export type VcsPruneWorktreesInput = typeof VcsPruneWorktreesInput.Type; + +export const VcsListWorktreesResult = Schema.Struct({ + worktrees: Schema.Array(WorktreeInfo), + /** Inventory revision read before listing. A different change revision + means this list may already be stale. */ + revision: NonNegativeInt, +}); +export type VcsListWorktreesResult = typeof VcsListWorktreesResult.Type; + +/** + * An invalidation signal, not a counter to order by: it increases within one + * server runtime and starts somewhere else after a restart. A subscriber + * reads the list again whenever this differs from its list's revision. + */ +export const WorktreeInventoryChange = Schema.Struct({ + revision: NonNegativeInt, +}); +export type WorktreeInventoryChange = typeof WorktreeInventoryChange.Type; + +export const WorktreePruneSkip = Schema.Struct({ + path: Schema.NonEmptyString, + reason: WorktreePruneSkipReason, + detail: Schema.optional(Schema.String), +}); +export type WorktreePruneSkip = typeof WorktreePruneSkip.Type; + +export const VcsPruneWorktreesResult = Schema.Struct({ + removed: Schema.Array( + Schema.Struct({ + path: Schema.NonEmptyString, + workspaceRoot: Schema.NonEmptyString, + }), + ), + skipped: Schema.Array(WorktreePruneSkip), +}); +export type VcsPruneWorktreesResult = typeof VcsPruneWorktreesResult.Type; + +export const WorktreeInventoryErrorStage = Schema.Literals([ + "load_projects", + "load_threads", + "inspect_repository", +]); +export type WorktreeInventoryErrorStage = typeof WorktreeInventoryErrorStage.Type; + +export class WorktreeInventoryError extends Schema.TaggedError()( + "WorktreeInventoryError", + { + stage: WorktreeInventoryErrorStage, + projectId: Schema.optional(ProjectId), + workspaceRoot: Schema.optional(Schema.NonEmptyString), + cause: Schema.Defect(), + }, +) { + override get message(): string { + switch (this.stage) { + case "load_projects": + return "Failed to load projects for the worktree inventory."; + case "load_threads": + return "Failed to load V2 thread shells for the worktree inventory."; + case "inspect_repository": + return "Failed to inspect a repository for the worktree inventory."; + } + } +} + +export const WorktreeMutationErrorStage = Schema.Literals([ + "inspect_target_path", + "load_projects", + "unmanaged_workspace", + "inspect_registrations", + "validate_branch", + "invalid_branch", + "check_branch", + "missing_branch", + "outside_managed_root", + "registered_different_ref", + "branch_in_use", + "prune_metadata", + "stale_registration_remaining", + "target_appeared", + "create_worktree", + "verify_worktree", + "worktree_verification_failed", + "load_project", + "project_not_found", + "run_setup", + "setup_exit_nonzero", + "setup_readiness_changed", +]); +export type WorktreeMutationErrorStage = typeof WorktreeMutationErrorStage.Type; + +export class WorktreeMutationError extends Schema.TaggedError()( + "WorktreeMutationError", + { + operation: Schema.Literals(["prune", "revive"]), + stage: WorktreeMutationErrorStage, + path: Schema.optional(Schema.NonEmptyString), + conflictingPath: Schema.optional(Schema.NonEmptyString), + workspaceRoot: Schema.optional(Schema.NonEmptyString), + branch: Schema.optional(TrimmedNonEmptyString), + projectId: Schema.optional(ProjectId), + exitCode: Schema.optional(Schema.NullOr(Schema.Number)), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.stage) { + case "inspect_target_path": + return "Failed to inspect the target worktree path."; + case "load_projects": + return "Failed to load projects before reviving the worktree."; + case "unmanaged_workspace": + return `Cannot revive a worktree for unmanaged workspace '${this.workspaceRoot ?? "unknown"}'.`; + case "inspect_registrations": + return "Failed to inspect Git worktree registrations."; + case "validate_branch": + return "Failed to validate the worktree branch."; + case "invalid_branch": + return `Cannot revive the worktree: '${this.branch ?? "unknown"}' is not a valid local branch name.`; + case "check_branch": + return "Failed to check whether the worktree branch exists."; + case "missing_branch": + return `Cannot recreate the worktree: branch '${this.branch ?? "unknown"}' no longer exists.`; + case "outside_managed_root": + return `Cannot revive a worktree outside the managed worktrees directories: '${this.path ?? "unknown"}'. Restore that checkout manually before sending another message.`; + case "registered_different_ref": + return `Cannot revive '${this.path ?? "unknown"}': Git already registers that path for a different ref.`; + case "branch_in_use": + return `Cannot revive branch '${this.branch ?? "unknown"}': it is already checked out at '${this.conflictingPath ?? "unknown"}'.`; + case "prune_metadata": + return "Failed to clear stale Git worktree metadata."; + case "stale_registration_remaining": + return `Cannot revive '${this.path ?? "unknown"}': Git still has a worktree registration at that path.`; + case "target_appeared": + return `Cannot revive '${this.path ?? "unknown"}': the target directory appeared before creation.`; + case "create_worktree": + return "Failed to create the revived Git worktree."; + case "verify_worktree": + return "Failed to verify the revived worktree directory."; + case "worktree_verification_failed": + return `The worktree was created but could not be verified at '${this.path ?? "unknown"}'.`; + case "load_project": + return "Failed to load the worktree's project."; + case "project_not_found": + return `Project '${this.projectId ?? "unknown"}' was not found for this worktree.`; + case "run_setup": + return "Failed to run the project setup script after revival."; + case "setup_exit_nonzero": + return `Project setup exited with ${this.exitCode ?? "no exit code"} after worktree revival.`; + case "setup_readiness_changed": + return "Worktree readiness changed during project setup. Retry the turn."; + } + } +} diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 11abf77f704b..03bf48e9adf8 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -41,6 +41,8 @@ export type T3McpToolSummaryAction = | "secret-request" | "worktree-handoff" | "worktree-list" + | "worktree-inventory" + | "worktree-remove" | "worktree-status" | "project-list" | "project-read" @@ -287,6 +289,14 @@ const T3_MCP_TOOLS: Readonly> = { t3_thread_organize: tool(["Organize", "Organizing", "Organized", "a thread"], "thread-organize"), t3_thread_update: tool(["Update", "Updating", "Updated", "T3 thread metadata"], "thread-update"), t3_worktree_list: tool(["List", "Listing", "Listed", "workspace branches"], "worktree-list"), + t3_worktree_inventory: tool( + ["List", "Listing", "Listed", "managed worktrees"], + "worktree-inventory", + ), + t3_worktree_remove: tool( + ["Remove", "Removing", "Requested removal of", "managed worktrees"], + "worktree-remove", + ), t3_preview_list: tool(["List", "Listing", "Listed", "preview tabs"], "browser", "browser"), t3_preview_close: tool(["Close", "Closing", "Closed", "a preview tab"], "browser", "browser"), t3_environment_read: tool(