From c77530e12f6f48c41d9cdee146a6324e42939499 Mon Sep 17 00:00:00 2001 From: "Rodney S. Foley" Date: Thu, 12 Nov 2026 07:45:56 -0700 Subject: [PATCH 1/5] feat: worktrees for multi-repo project folders --- .../src/features/review/ReviewSheet.tsx | 4 +- .../src/features/review/useReviewSections.ts | 17 +- .../SettingsServerControlsRouteScreen.tsx | 11 + .../components/RepoWorktreeSettings.tsx | 78 + .../src/features/settings/settings-search.ts | 2 + .../features/threads/ThreadRouteScreen.tsx | 13 +- .../features/threads/git/GitCommitSheet.tsx | 4 +- .../features/threads/git/GitConfirmSheet.tsx | 12 +- .../features/threads/git/GitOverviewSheet.tsx | 59 +- .../threads/git/RepoWorktreesGroup.tsx | 82 + .../use-selected-thread-git-actions.test.ts | 25 +- .../state/use-selected-thread-git-actions.ts | 73 +- .../use-selected-thread-git-state.test.tsx | 1 + .../state/use-selected-thread-git-state.ts | 9 +- .../src/state/use-selected-thread-worktree.ts | 54 + .../checkpointing/CheckpointDiffQuery.test.ts | 54 + .../src/checkpointing/CheckpointDiffQuery.ts | 232 +- .../src/checkpointing/CheckpointStore.ts | 17 + .../src/git/RepoWorktreeService.test.ts | 1067 +++++++++ apps/server/src/git/RepoWorktreeService.ts | 1134 +++++++++ .../server/src/mcp/WorktreeMcpService.test.ts | 25 + apps/server/src/mcp/WorktreeMcpService.ts | 131 +- .../src/mcp/toolkits/worktree/handlers.ts | 21 + .../server/src/mcp/toolkits/worktree/tools.ts | 54 + .../Adapters/CodexAdapterV2.test.ts | 42 +- .../Adapters/CodexAdapterV2.ts | 70 +- .../CheckpointCaptureService.test.ts | 8 + .../CheckpointCaptureService.ts | 69 +- .../CheckpointRestoreSafety.test.ts | 71 +- .../CheckpointRestoreSafety.ts | 374 ++- .../CheckpointRollbackService.test.ts | 179 +- .../CheckpointRollbackService.ts | 617 +++-- .../CheckpointScopeOwnership.test.ts | 2 + .../CheckpointService.test.ts | 255 +- .../src/orchestration-v2/CheckpointService.ts | 1396 ++++++++++- .../src/orchestration-v2/EffectWorker.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 101 +- .../src/orchestration-v2/ProjectionStore.ts | 70 +- .../ProviderTurnControlService.test.ts | 1 + .../ProviderTurnStartService.ts | 30 +- ...epoWorktreeCheckpoints.integration.test.ts | 2100 +++++++++++++++++ .../orchestration-v2/RepoWorktreeNote.test.ts | 69 + .../src/orchestration-v2/RepoWorktreeNote.ts | 51 + .../orchestration-v2/RunExecutionService.ts | 6 + .../RunFinalizationService.ts | 16 + .../ThreadLaunchService.test.ts | 237 +- .../orchestration-v2/ThreadLaunchService.ts | 17 +- .../src/orchestration-v2/runtimeLayer.ts | 19 +- .../testkit/OrchestratorScenario.ts | 1 + .../testkit/ProviderReplayHarness.ts | 26 +- .../src/provider/AgentContextBudget.test.ts | 2 + .../src/provider/CodexThreadRevert.test.ts | 84 +- apps/server/src/provider/CodexThreadRevert.ts | 50 + .../src/provider/agentContextBudget.json | 2 +- apps/server/src/storageCleanup.test.ts | 104 + apps/server/src/storageCleanup.ts | 126 +- apps/server/src/testUtils/gitConfig.setup.ts | 2 + apps/server/src/vcs/GitVcsDriver.test.ts | 34 + apps/server/src/vcs/GitVcsDriver.ts | 45 +- apps/server/src/vcs/VcsDriver.ts | 11 + apps/server/src/workspace/KnownWorkspaces.ts | 63 +- apps/server/src/workspace/workspaceLease.ts | 23 + apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 46 +- .../web/src/components/ChatView.logic.test.ts | 70 + apps/web/src/components/ChatView.logic.ts | 72 +- apps/web/src/components/ChatView.tsx | 60 +- apps/web/src/components/DiffPanel.tsx | 103 +- apps/web/src/components/GitActionsControl.tsx | 5 +- .../src/components/RepoWorktreeTargetMenu.tsx | 58 + .../src/components/chat/ChatBannerLane.tsx | 13 + .../components/chat/ThreadDetailsPanel.tsx | 40 +- .../src/components/chat/ThreadErrorBanner.tsx | 10 +- .../chat/ThreadRepoWorktreesSection.tsx | 99 + .../components/chat/TimelineAssistantRows.tsx | 38 +- apps/web/src/components/chat/chatViewSend.ts | 43 +- .../components/chat/useRightPanelActions.ts | 7 +- .../src/components/chat/useTimelineRewind.ts | 83 +- .../components/files/FilePreviewPanel.test.ts | 22 + .../src/components/files/FilePreviewPanel.tsx | 55 +- .../src/components/files/filePreviewMode.ts | 18 + .../settings/RepoWorktreeSettings.tsx | 98 + .../components/settings/WorktreesSettings.tsx | 2 + .../settings/settingsSearch.test.ts | 2 + .../src/components/settings/settingsSearch.ts | 19 + apps/web/src/diffPanelStore.test.ts | 12 + apps/web/src/diffPanelStore.ts | 17 + apps/web/src/hooks/useThreadActions.ts | 5 +- .../src/hooks/useThreadRepoWorktreeTarget.ts | 54 + docs/internals/glossary.md | 23 +- docs/internals/overview.md | 6 + docs/user/composer.md | 19 +- docs/user/project-settings.md | 18 + .../client-runtime/src/operations/commands.ts | 14 + .../src/state/threadCheckpoints.test.ts | 44 +- .../src/state/threadCheckpoints.ts | 30 + .../src/state/threadCommands.ts | 7 + .../src/state/threadDetail.test.ts | 55 + .../client-runtime/src/state/threadDetail.ts | 41 + packages/contracts/src/orchestrationV2.ts | 74 +- packages/contracts/src/rpc.test.ts | 35 + packages/contracts/src/settings.ts | 13 + packages/contracts/src/worktreeMcp.ts | 113 +- 103 files changed, 10317 insertions(+), 781 deletions(-) create mode 100644 apps/mobile/src/features/settings/components/RepoWorktreeSettings.tsx create mode 100644 apps/mobile/src/features/threads/git/RepoWorktreesGroup.tsx create mode 100644 apps/server/src/git/RepoWorktreeService.test.ts create mode 100644 apps/server/src/git/RepoWorktreeService.ts create mode 100644 apps/server/src/orchestration-v2/RepoWorktreeCheckpoints.integration.test.ts create mode 100644 apps/server/src/orchestration-v2/RepoWorktreeNote.test.ts create mode 100644 apps/server/src/orchestration-v2/RepoWorktreeNote.ts create mode 100644 apps/web/src/components/RepoWorktreeTargetMenu.tsx create mode 100644 apps/web/src/components/chat/ThreadRepoWorktreesSection.tsx create mode 100644 apps/web/src/components/settings/RepoWorktreeSettings.tsx create mode 100644 apps/web/src/hooks/useThreadRepoWorktreeTarget.ts diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 2673865359f0e..b7feb33cda341 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -40,7 +40,7 @@ import { useAdaptiveWorkspaceLayout, useWorkspacePaneState, } from "../layout/AdaptiveWorkspaceLayout"; -import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { useSelectedThreadSourceControl } from "../../state/use-selected-thread-worktree"; import { useReviewCacheForThread } from "./reviewState"; import { isNativeReviewDiffDrawEvent, @@ -377,7 +377,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const { draftMessage, draftContext } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); - const { selectedThreadCwd } = useSelectedThreadWorktree(); + const { sourceControlCwd: selectedThreadCwd } = useSelectedThreadSourceControl(); const columnMetrics = useNativeColumnLayoutMetrics(); const safeArea = columnMetrics?.safeArea ?? insets; const topContentInset = useContentTopInset(); diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index 4fd506732e5d3..b6af2e952b799 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -12,7 +12,7 @@ import { lastReviewGitSectionsAtom, reviewEnvironment } from "../../state/review import { lastReviewTurnDiffsAtom } from "../../state/reviewTurnDiffs"; import { environmentSession } from "../../state/session"; import { useSelectedThreadProjection } from "../../state/use-thread-detail"; -import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { useSelectedThreadSourceControl } from "../../state/use-selected-thread-worktree"; import { buildReviewSectionItems, getReadyReviewCheckpoints, @@ -52,7 +52,11 @@ export function useReviewSections(input: { }); const { canReadFiles } = fileAccess; const selectedThread = useSelectedThreadProjection(); - const { selectedThreadCwd } = useSelectedThreadWorktree(); + // Changes come from the repository worktree chosen for Git; turn diffs cover all of them. + const { sourceControlCwd: selectedThreadCwd, repoWorktree } = useSelectedThreadSourceControl(); + // Review comments carry the section title, so it names the repository their paths belong to. + const gitSectionSuffix = + repoWorktree === null ? "" : ` in ${repoWorktree.repository} · ${repoWorktree.branch}`; const diffPreview = useEnvironmentQuery( canReadFiles && enabled && environmentId !== undefined && selectedThreadCwd !== null ? reviewEnvironment.diffPreview({ @@ -81,10 +85,17 @@ export function useReviewSections(input: { ); // Turns show their last answered diff; the active turn's live query below overrides it. // Section ids and the selection do not depend on the live query, so they are resolved first. + const gitSections = + canReadFiles || fileAccess.isPending ? (diffPreview.data?.sources ?? lastGitSections) : []; const cachedSections = buildReviewSectionItems({ checkpoints: readyCheckpoints, gitSections: - canReadFiles || fileAccess.isPending ? (diffPreview.data?.sources ?? lastGitSections) : [], + gitSectionSuffix === "" + ? gitSections + : gitSections.map((section) => ({ + ...section, + title: `${section.title}${gitSectionSuffix}`, + })), turnDiffById: reviewTurnDiffsBySectionId(readyCheckpoints, lastTurnDiffs), loadingGitSections: fileAccess.isPending || diffPreview.isPending, }); diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 77b845637062e..c6840f3b9f647 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -24,6 +24,7 @@ import { import { SettingsScreen } from "./components/SettingsScreen"; import { useSettingsEnvironmentMenu } from "./components/SettingsEnvironmentFilterHeader"; import { BranchNamingSettings } from "./components/BranchNamingSettings"; +import { RepoWorktreeSettings } from "./components/RepoWorktreeSettings"; import { DatabaseStorageNotice } from "./components/DatabaseStorageNotice"; import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -67,6 +68,8 @@ const PAGE_PROJECT_KEYS: Record write({ newWorktreesStartFromOrigin: value })} /> + void; +}) { + const instructionsEdited = useRef(false); + return ( + <> + + props.onChange({ repoWorktrees: value })} + /> + + {props.enabled !== false ? ( + + + + Repository Guidance + + + {GUIDANCE_HELP} + + + { + instructionsEdited.current = true; + }} + defaultValue={props.instructions ?? ""} + placeholder={ + props.instructions === null + ? "Mixed. Enter guidance for all selected targets." + : "Start api work from develop. Start shared/framework work from team-develop." + } + editable={!props.disabled} + multiline + autoCapitalize="sentences" + className="min-h-24 rounded-xl px-3 py-2 text-base text-foreground" + onEndEditing={(event) => { + const value = event.nativeEvent.text.trim(); + if ( + !props.disabled && + instructionsEdited.current && + (props.instructions === null || value !== props.instructions) + ) + props.onChange({ repoWorktreeInstructions: value }); + instructionsEdited.current = false; + }} + /> + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts index 49e1a8dbe082e..3b1d1128ecf18 100644 --- a/apps/mobile/src/features/settings/settings-search.ts +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -104,6 +104,8 @@ const SETTINGS_SEARCH_TERMS = { "branch prefix", "submodules", "start from remote", + "multi-repository project folder", + "repository guidance", ], SettingsLabs: [ "beta", diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 6205fd1281797..de1eda86a4c68 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -88,7 +88,10 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; -import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { + useSelectedThreadSourceControl, + useSelectedThreadWorktree, +} from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships"; import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows"; @@ -538,11 +541,13 @@ function ThreadRouteContent( ] .filter(Boolean) .join(" · "); + // The Git menu follows the repository chosen for Git; files and terminals stay in the workspace. + const { sourceControlCwd } = useSelectedThreadSourceControl(); const gitStatus = useEnvironmentQuery( - selectedThread !== null && selectedThreadCwd !== null + selectedThread !== null && sourceControlCwd !== null ? vcsEnvironment.status({ environmentId: selectedThread.environmentId, - input: { cwd: selectedThreadCwd }, + input: { cwd: sourceControlCwd }, }) : null, ); @@ -564,7 +569,7 @@ function ThreadRouteContent( const gitActionProgressTarget = { environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, + cwd: sourceControlCwd, }; const gitActionProgress = useGitActionProgress(gitActionProgressTarget); diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 637dab6768cf8..25fd4c2620c8e 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -14,7 +14,7 @@ import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; -import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { useSelectedThreadSourceControl } from "../../../state/use-selected-thread-worktree"; import { vcsEnvironment } from "../../../state/vcs"; type GitCommitSheetProps = StaticScreenProps<{ @@ -27,7 +27,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { const insets = useSafeAreaInsets(); const { height: windowHeight } = useWindowDimensions(); const { selectedThread } = useThreadSelection(); - const { selectedThreadCwd } = useSelectedThreadWorktree(); + const { sourceControlCwd: selectedThreadCwd } = useSelectedThreadSourceControl(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const { canWriteSourceControl, canChangeThreadBranch } = gitActions; diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index ae840b63ad271..db40de84516bb 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -14,6 +14,7 @@ import { ScreenContent } from "../../../components/ui/ScreenContent"; import { AppText as Text } from "../../../components/ui/AppText"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; +import { useSelectedThreadSourceControl } from "../../../state/use-selected-thread-worktree"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { SheetActionButton } from "./gitSheetComponents"; @@ -34,6 +35,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const { canWriteSourceControl, canChangeThreadBranch } = gitActions; + const { repoWorktree } = useSelectedThreadSourceControl(); const { selectedThreadProject } = useThreadSelection(); const terminology = sourceControlClients.get( selectedThreadProject?.repositoryIdentity?.provider, @@ -89,17 +91,20 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { return; } + // The new branch goes where the action runs, which may be a repository worktree. const branches = - gitState.selectedThreadBranches.length > 0 + repoWorktree === null && gitState.selectedThreadBranches.length > 0 ? gitState.selectedThreadBranches - : await gitActions.refreshSelectedThreadBranches(); + : await gitActions.refreshSelectedThreadBranches({ sourceControl: true }); if (branches === null) return; const newBranchName = resolveAutoFeatureBranchName( Arr.filterMap(branches, (branch) => branch.isRemote ? Result.failVoid : Result.succeed(branch.name), ), ); - const created = await gitActions.onCreateSelectedThreadBranch(newBranchName); + const created = await gitActions.onCreateSelectedThreadBranch(newBranchName, { + sourceControl: true, + }); if (created === null) return; await gitActions.onRunSelectedThreadGitAction({ action: confirmAction }); }, [ @@ -107,6 +112,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { confirmAction, gitActions, gitState.selectedThreadBranches, + repoWorktree, includesCommit, params, navigation, diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 43e541083688f..87c194a47d4b0 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -30,9 +30,13 @@ import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; -import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { + useSelectedThreadSourceControl, + useSelectedThreadWorktree, +} from "../../../state/use-selected-thread-worktree"; import { vcsEnvironment } from "../../../state/vcs"; import { menuItemIconName, statusSummary } from "./gitSheetComponents"; +import { RepoWorktreesGroup } from "./RepoWorktreesGroup"; import { LinkedPullRequestRow, linkedPullRequestKey, @@ -75,7 +79,9 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { replace: presentation === "sheet", context: selectedThread ?? undefined, }); - const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + const { selectedThreadWorktreePath } = useSelectedThreadWorktree(); + const sourceControl = useSelectedThreadSourceControl(); + const selectedThreadCwd = sourceControl.sourceControlCwd; const linkedPrLines = useLinkedPullRequestLines(selectedThread, selectedEnvironmentRuntime); const linkedPrActions = useLinkedPullRequestActions({ thread: selectedThread, @@ -272,7 +278,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { icon="text.bubble" label="Review changes" description="Inspect changes, uncommitted edits, and turn diffs" - disabled={busy || !isRepo} + disabled={busy || (!isRepo && sourceControl.repoWorktrees.length === 0)} onPress={() => { const params = { environmentId, threadId }; if (presentation === "sheet") { @@ -284,24 +290,27 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { } }} /> - - presentRoute(() => - navigation.navigate("GitBranches", { - environmentId: String(environmentId), - threadId: String(threadId), - }), - ) - } - /> + {/* The thread's own branch; a repository worktree keeps its branch. */} + {sourceControl.repoWorktree === null ? ( + + presentRoute(() => + navigation.navigate("GitBranches", { + environmentId: String(environmentId), + threadId: String(threadId), + }), + ) + } + /> + ) : null} {linkedPrActions.canLink ? ( ) : null} + + {currentWorktreePath ? ( diff --git a/apps/mobile/src/features/threads/git/RepoWorktreesGroup.tsx b/apps/mobile/src/features/threads/git/RepoWorktreesGroup.tsx new file mode 100644 index 0000000000000..9f7f95dcae185 --- /dev/null +++ b/apps/mobile/src/features/threads/git/RepoWorktreesGroup.tsx @@ -0,0 +1,82 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { MenuAction } from "@react-native-menu/menu"; +import type { EnvironmentId, ThreadId, ThreadRepoWorktree } from "@t3tools/contracts"; +import * as Clipboard from "expo-clipboard"; + +import { ListRow } from "../../../components/ui/ListRow"; +import { ListSection } from "../../../components/ui/ListSection"; +import { Menu } from "../../../components/ui/Menu"; +import { environmentThreadDetails, threadEnvironment } from "../../../state/threads"; +import { useAtomCommand } from "../../../state/use-atom-command"; + +/** + * Where this thread's work lives when its project holds several repositories, + * and which one the Git actions and changes above use. + */ +export function RepoWorktreesGroup(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly selected: ThreadRepoWorktree | null; + readonly workspaceLabel: string; + readonly onSelect: (worktreePath: string | null) => void; +}) { + const repoWorktrees = useAtomValue( + environmentThreadDetails.repoWorktreesAtom({ + environmentId: props.environmentId, + threadId: props.threadId, + }), + ); + const detach = useAtomCommand(threadEnvironment.detachRepoWorktree, { reportFailure: true }); + if (repoWorktrees.length === 0) return null; + + const menuActions = (entry: ThreadRepoWorktree): MenuAction[] => [ + props.selected?.worktreePath === entry.worktreePath + ? { id: "use-workspace", title: `Use ${props.workspaceLabel.toLowerCase()} for Git` } + : { id: "use-worktree", title: "Use for Git and changes" }, + { id: "copy-path", title: "Copy path" }, + { id: "detach", title: "Detach from thread", attributes: { destructive: true } }, + ]; + const onMenuAction = (entry: ThreadRepoWorktree, action: string) => { + if (action === "use-workspace") props.onSelect(null); + else if (action === "use-worktree") props.onSelect(entry.worktreePath); + else if (action === "copy-path") void Clipboard.setStringAsync(entry.worktreePath); + else if (action === "detach") { + void detach({ + environmentId: props.environmentId, + input: { threadId: props.threadId, worktreePath: entry.worktreePath }, + }); + } + }; + + return ( + + {repoWorktrees.map((entry) => ( + onMenuAction(entry, nativeEvent.event)} + > + {}} + /> + + ))} + + ); +} diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts index 548ae2d3ce6b0..d22ac1080a77d 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts @@ -24,6 +24,8 @@ const state = vi.hoisted(() => ({ statusRefreshes: 0, readRefs: vi.fn(), cachedRefs: [] as VcsListRefsResult["refs"], + repoWorktree: null as { worktreePath: string } | null, + createRefCwds: [] as string[], })); vi.mock("react", () => ({ @@ -48,6 +50,10 @@ vi.mock("./use-selected-thread-worktree", () => ({ selectedThreadCwd: "/repo", selectedThreadWorktreePath: null, }), + useSelectedThreadSourceControl: () => ({ + sourceControlCwd: state.repoWorktree?.worktreePath ?? "/repo", + repoWorktree: state.repoWorktree, + }), })); vi.mock("./queries", () => ({ useBranches: () => ({ data: { refs: state.cachedRefs }, refresh: () => {} }), @@ -93,7 +99,8 @@ vi.mock("./vcs", () => ({ state.afterGitAction?.(); return AsyncResult.success({ refName: state.branch }); }, - createRef: async ({ input }: { input: { refName: string } }) => { + createRef: async ({ input }: { input: { cwd: string; refName: string } }) => { + state.createRefCwds.push(input.cwd); state.branch = input.refName; state.afterGitAction?.(); return AsyncResult.success({ refName: state.branch }); @@ -140,6 +147,8 @@ describe("thread Git mutation permissions", () => { state.results = []; state.afterGitAction = undefined; state.statusRefreshes = 0; + state.repoWorktree = null; + state.createRefCwds = []; }); it.each([false, true])( @@ -160,6 +169,20 @@ describe("thread Git mutation permissions", () => { }, ); + it("creates a pending action's feature branch where the action runs", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + state.repoWorktree = { worktreePath: "/worktrees/alpha" }; + const actions = useSelectedThreadGitActions(); + await actions.onCreateSelectedThreadBranch("feature/task", { sourceControl: true }); + state.repoWorktree = null; + await useSelectedThreadGitActions().onCreateSelectedThreadBranch("feature/own", { + sourceControl: true, + }); + expect(state.createRefCwds).toEqual(["/worktrees/alpha", "/repo"]); + // Only the thread's own workspace branch is the thread's branch. + expect(state.metadataRequests.map((request) => request.branch)).toEqual(["feature/own"]); + }); + it("keeps ordinary commits and pushes available without task permission", async () => { const actions = useSelectedThreadGitActions(); await actions.onRunSelectedThreadGitAction({ action: "commit" }); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index 7ecc10d0b6d85..8943787aae871 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -34,7 +34,15 @@ import { import { useAtomCommand } from "./use-atom-command"; import { showGitActionResult } from "./use-vcs-action-state"; import { useThreadSelection } from "./use-thread-selection"; -import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; +import { + useSelectedThreadSourceControl, + useSelectedThreadWorktree, +} from "./use-selected-thread-worktree"; + +interface SourceControlTarget { + /** Where commits and pushes go, which may be a repository worktree, not the thread's workspace. */ + readonly sourceControl?: boolean; +} function refreshFailureMessage(cause: Cause.Cause): string { const error = Cause.squash(cause); @@ -73,10 +81,13 @@ export function useSelectedThreadGitActions() { ); const canChangeThreadBranch = canWriteSourceControl && canOperateThread; const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + // Commits, pushes, and pulls go to the chosen repository worktree; branch + // switching stays with the thread's own workspace. + const { sourceControlCwd, repoWorktree } = useSelectedThreadSourceControl(); const runStackedAction = useAtomCommand( vcsActionManager.runStackedAction({ environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, + cwd: sourceControlCwd, }), { reportFailure: false }, ); @@ -123,7 +134,7 @@ export function useSelectedThreadGitActions() { return null; } - const cwd = options?.cwd ?? selectedThreadCwd; + const cwd = options?.cwd ?? sourceControlCwd; if (!cwd) { return null; } @@ -155,8 +166,8 @@ export function useSelectedThreadGitActions() { useAtomMount( selectedThreadStatusRevalidation( - selectedThread && selectedThreadProject && selectedThreadCwd - ? { environmentId: selectedThread.environmentId, cwd: selectedThreadCwd } + selectedThread && selectedThreadProject && sourceControlCwd + ? { environmentId: selectedThread.environmentId, cwd: sourceControlCwd } : null, ), ); @@ -169,12 +180,18 @@ export function useSelectedThreadGitActions() { readonly project: EnvironmentProject; readonly cwd: string; }) => Promise>, - options?: { readonly managedExternally?: boolean; readonly changesThreadBranch?: boolean }, + options?: { + readonly managedExternally?: boolean; + readonly changesThreadBranch?: boolean; + /** Runs in the thread's own workspace even when a repository worktree is chosen. */ + readonly threadWorkspace?: boolean; + }, ): Promise => { + const cwd = options?.threadWorkspace === true ? selectedThreadCwd : sourceControlCwd; if ( !selectedThread || !selectedThreadProject || - !selectedThreadCwd || + !cwd || !readEnvironmentScope(selectedThread.environmentId, AuthSourceControlWriteScope) || (options?.changesThreadBranch === true && !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope)) @@ -184,14 +201,14 @@ export function useSelectedThreadGitActions() { const target = { environmentId: selectedThread.environmentId, - cwd: selectedThreadCwd, + cwd, }; setPendingConnectionError(null); const run = () => execute({ thread: selectedThread, project: selectedThreadProject, - cwd: selectedThreadCwd, + cwd, }); const result = options?.managedExternally === true @@ -207,11 +224,17 @@ export function useSelectedThreadGitActions() { return result.value; }; - const refreshSelectedThreadBranches = async (): Promise | null> => { - if (branchTarget.environmentId === null || branchTarget.cwd === null) return null; + const refreshSelectedThreadBranches = async ( + options?: SourceControlTarget, + ): Promise | null> => { + const cwd = + options?.sourceControl === true && repoWorktree !== null + ? sourceControlCwd + : branchTarget.cwd; + if (branchTarget.environmentId === null || cwd === null) return null; const result = await readRefs({ environmentId: branchTarget.environmentId, - input: { cwd: branchTarget.cwd, limit: 100, refresh: true }, + input: { cwd, limit: 100, refresh: true }, }); if (AsyncResult.isFailure(result)) { const error = Cause.squash(result.cause); @@ -267,11 +290,13 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, - { changesThreadBranch: true }, + { changesThreadBranch: true, threadWorkspace: true }, ); }; - const onCreateSelectedThreadBranch = async (branch: string) => { + const onCreateSelectedThreadBranch = async (branch: string, options?: SourceControlTarget) => { + // A repository worktree's new branch is not the thread's branch. + const threadWorkspace = options?.sourceControl !== true || repoWorktree === null; return runSelectedThreadGitMutation( "create_ref", "Creating branch", @@ -283,6 +308,10 @@ export function useSelectedThreadGitActions() { if (AsyncResult.isFailure(result)) { return result; } + if (!threadWorkspace) { + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); + return result; + } const syncResult = await syncSelectedThreadBranchState({ thread, cwd, @@ -293,7 +322,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, - { changesThreadBranch: true }, + threadWorkspace ? { changesThreadBranch: true, threadWorkspace: true } : undefined, ); }; @@ -327,7 +356,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, - { changesThreadBranch: true }, + { changesThreadBranch: true, threadWorkspace: true }, ); }; @@ -376,7 +405,12 @@ export function useSelectedThreadGitActions() { return result; } - if (result.value.branch.status === "created" && result.value.branch.name) { + // A repository worktree's new branch is not the thread's branch. + if ( + repoWorktree === null && + result.value.branch.status === "created" && + result.value.branch.name + ) { const syncResult = await syncSelectedThreadBranchState({ thread, cwd, @@ -399,7 +433,10 @@ export function useSelectedThreadGitActions() { }); return result; }, - { managedExternally: true, changesThreadBranch: input.featureBranch === true }, + { + managedExternally: true, + changesThreadBranch: input.featureBranch === true && repoWorktree === null, + }, ); }; diff --git a/apps/mobile/src/state/use-selected-thread-git-state.test.tsx b/apps/mobile/src/state/use-selected-thread-git-state.test.tsx index 73c3b22d02abe..ef5feb962bcd8 100644 --- a/apps/mobile/src/state/use-selected-thread-git-state.test.tsx +++ b/apps/mobile/src/state/use-selected-thread-git-state.test.tsx @@ -19,6 +19,7 @@ vi.mock("./use-thread-selection", () => ({ })); vi.mock("./use-selected-thread-worktree", () => ({ useSelectedThreadWorktree: () => ({ selectedThreadCwd: harness.cwd }), + useSelectedThreadSourceControl: () => ({ sourceControlCwd: harness.cwd, repoWorktree: null }), })); vi.mock("./use-vcs-action-state", () => ({ useVcsActionState: () => ({ currentLabel: null }), diff --git a/apps/mobile/src/state/use-selected-thread-git-state.ts b/apps/mobile/src/state/use-selected-thread-git-state.ts index e9a961b152fed..56fbbb5c8329f 100644 --- a/apps/mobile/src/state/use-selected-thread-git-state.ts +++ b/apps/mobile/src/state/use-selected-thread-git-state.ts @@ -7,15 +7,20 @@ import { useEnvironmentQuery } from "./query"; import { sourceControlEnvironment } from "./sourceControl"; import { useVcsActionState } from "./use-vcs-action-state"; import { useThreadSelection } from "./use-thread-selection"; -import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; +import { + useSelectedThreadSourceControl, + useSelectedThreadWorktree, +} from "./use-selected-thread-worktree"; export function useSelectedThreadGitState() { const { selectedThread } = useThreadSelection(); const { selectedThreadCwd } = useSelectedThreadWorktree(); + // Git actions follow the chosen repository worktree; branch controls stay on the thread. + const { sourceControlCwd } = useSelectedThreadSourceControl(); const selectedThreadGitTarget = { environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, + cwd: sourceControlCwd, }; const gitActionState = useVcsActionState(selectedThreadGitTarget); const sourceControlDiscovery = useEnvironmentQuery( diff --git a/apps/mobile/src/state/use-selected-thread-worktree.ts b/apps/mobile/src/state/use-selected-thread-worktree.ts index 74d45cc83f8c7..d7179497f7d3c 100644 --- a/apps/mobile/src/state/use-selected-thread-worktree.ts +++ b/apps/mobile/src/state/use-selected-thread-worktree.ts @@ -1,7 +1,18 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { resolveSelectedRepoWorktree } from "@t3tools/client-runtime/state/threads"; +import type { ThreadRepoWorktree } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { useEnvironmentQuery } from "./query"; +import { environmentThreadDetails } from "./threads"; import { useSelectedThreadWorktreePath } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; +import { vcsEnvironment } from "./vcs"; import { resolvePreferredThreadWorktreePath } from "../features/terminal/terminalLaunchContext"; +const NO_REPO_WORKTREES = Atom.make>([]); +const NO_SELECTION = Atom.make(undefined); + export function useSelectedThreadWorktree() { const { selectedThread, selectedThreadProject } = useThreadSelection(); const detailWorktreePath = useSelectedThreadWorktreePath(); @@ -16,3 +27,46 @@ export function useSelectedThreadWorktree() { selectedThreadCwd: selectedThreadWorktreePath ?? selectedThreadProject?.workspaceRoot ?? null, }; } + +/** + * Where the selected thread's Git actions and Changes work: a repository + * worktree the user chose, or the thread's own workspace. Files and terminals + * keep using the workspace. + */ +export function useSelectedThreadSourceControl() { + const { selectedThread } = useThreadSelection(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + const threadRef = + selectedThread === null + ? null + : { environmentId: selectedThread.environmentId, threadId: selectedThread.id }; + const repoWorktrees = useAtomValue( + threadRef === null ? NO_REPO_WORKTREES : environmentThreadDetails.repoWorktreesAtom(threadRef), + ); + const selectionAtom = + threadRef === null + ? NO_SELECTION + : environmentThreadDetails.repoWorktreeSelectionAtom(threadRef); + const selection = useAtomValue(selectionAtom); + const setSelection = useAtomSet(selectionAtom); + const workspaceStatus = useEnvironmentQuery( + threadRef !== null && selectedThreadCwd !== null && repoWorktrees.length > 0 + ? vcsEnvironment.status({ + environmentId: threadRef.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); + const repoWorktree = resolveSelectedRepoWorktree({ + repoWorktrees, + selection, + workspaceIsRepository: workspaceStatus.data?.isRepo ?? true, + }); + const select = (worktreePath: string | null) => setSelection(worktreePath); + return { + repoWorktrees, + repoWorktree, + select, + sourceControlCwd: repoWorktree?.worktreePath ?? selectedThreadCwd, + }; +} diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index aae4a4a38b662..81ff594ea8c60 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -65,6 +65,7 @@ function layerFor(input: { ), ), Layer.provideMerge(NodeCrypto.layer), + Layer.provideMerge(NodeServices.layer), ); } @@ -81,6 +82,7 @@ it.effect("excludes ready checkpoints from rolled-back runs", () => { ...projection.checkpoints[0]!, scopeId: firstScopeId, runId: firstRunId, + ordinalWithinScope: 1, appRunOrdinal: 1, ref: CheckpointRef.make("refs/t3/test/first"), }, @@ -190,6 +192,58 @@ it.effect.each([false, true])( }, ); +it.effect("keeps a repository worktree detached before the last turn in the full diff", () => { + const alpha = (ordinal: number) => ({ + repository: "alpha", + repositoryPath: "/folder/alpha", + worktreePath: "/worktrees/alpha/work", + branch: "work", + pathPrefix: "alpha", + ref: CheckpointRef.make(`refs/t3/test/alpha/${ordinal}`), + status: "ready" as const, + }); + const checkpoint = (ordinalWithinScope: number, runId: RunId | null) => ({ + scopeId: firstScopeId, + runId, + ordinalWithinScope, + appRunOrdinal: runId === null ? null : ordinalWithinScope, + status: "ready" as const, + ref: CheckpointRef.make(`refs/t3/test/ordinal/${ordinalWithinScope}`), + }); + const diffCheckpoints = vi.fn((input: CheckpointStore.DiffCheckpointsInput) => + Effect.succeed(`diff from ${input.cwd}\n`), + ); + const layer = layerFor({ + projection: Effect.succeed({ + runs: [ + { id: firstRunId, ordinal: 1, status: "completed" }, + { id: secondRunId, ordinal: 2, status: "completed" }, + ], + checkpointScopes: [{ id: firstScopeId, runId: secondRunId, kind: "root_run", cwd: "/repo" }], + // The thread detached alpha after turn 1, so turn 2 lists no repositories. + checkpoints: [ + { ...checkpoint(0, null), workspaceStatus: "ready", repositories: [alpha(0)] }, + { ...checkpoint(1, firstRunId), workspaceStatus: "ready", repositories: [alpha(1)] }, + checkpoint(2, secondRunId), + ], + } satisfies ProjectionCheckpointContext), + diffCheckpoints, + }); + + return Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const result = yield* query.getFullThreadDiff({ threadId, toTurnCount: 2 }); + + assert.strictEqual(result.diff, "diff from /repo\ndiff from /folder/alpha\n"); + assert.deepEqual( + diffCheckpoints.mock.calls + .filter(([input]) => input.pathPrefix !== undefined) + .map(([input]) => [input.fromCheckpointRef, input.toCheckpointRef]), + [["refs/t3/test/alpha/0", "refs/t3/test/alpha/1"]], + ); + }).pipe(Effect.provide(layer)); +}); + const layerVcsProcess = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); const layerStore = CheckpointStore.layer.pipe( Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(layerVcsProcess))), diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index 062405d533216..4356653157b47 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -1,19 +1,27 @@ import { + type CheckpointRef, OrchestrationGetTurnDiffResult, type OrchestrationGetFullThreadDiffInput, type OrchestrationGetFullThreadDiffResult, type OrchestrationGetTurnDiffInput, type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, + type OrchestrationV2CheckpointRepository, type ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { CHECKPOINT_DIFF_MAX_OUTPUT_BYTES } from "../vcs/VcsDriver.ts"; -import { checkpointRefForScopeOrdinal } from "../orchestration-v2/CheckpointService.ts"; +import { + checkpointRefForScopeOrdinal, + checkpointWorkspaceStatus, + resolveRepositorySnapshot, +} from "../orchestration-v2/CheckpointService.ts"; +import type { ProjectionCheckpointContext } from "../orchestration-v2/ProjectionStore.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import { CheckpointDiffResultInvalidError, @@ -64,6 +72,41 @@ export class CheckpointDiffQuery extends Context.Service< const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult); +/** + * Each repository worktree's last ready snapshot in the turns being compared, + * so a worktree the thread detached partway still shows its changes. + */ +function latestRepositoryEntries( + projection: ProjectionCheckpointContext, + to: ProjectionCheckpointContext["checkpoints"][number], + fromOrdinal: number, +) { + const latest = new Map< + string, + { readonly entry: OrchestrationV2CheckpointRepository; readonly ordinal: number } + >(); + for (const checkpoint of projection.checkpoints) { + if ( + checkpoint.scopeId !== to.scopeId || + checkpoint.status === "stale" || + checkpoint.ordinalWithinScope <= fromOrdinal || + checkpoint.ordinalWithinScope > to.ordinalWithinScope + ) { + continue; + } + for (const entry of checkpoint.repositories ?? []) { + const current = latest.get(entry.worktreePath); + if ( + entry.status === "ready" && + (current === undefined || current.ordinal < checkpoint.ordinalWithinScope) + ) { + latest.set(entry.worktreePath, { entry, ordinal: checkpoint.ordinalWithinScope }); + } + } + } + return [...latest.values()]; +} + function buildTurnDiffResult( input: { readonly threadId: ThreadId; @@ -85,6 +128,7 @@ export const make = Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; const checkpointStore = yield* CheckpointStore.CheckpointStore; const digest = yield* Digest.Digest; + const fileSystem = yield* FileSystem.FileSystem; const getTurnDiff: CheckpointDiffQuery["Service"]["getTurnDiff"] = Effect.fn("getTurnDiff")( function* (input) { @@ -179,6 +223,13 @@ export const make = Effect.gen(function* () { input.fromTurnCount === 0 ? projection.checkpointScopes.find((scope) => scope.kind === "root_run") : undefined; + const fromCheckpoint = + input.fromTurnCount === 0 + ? undefined + : (parentCheckpoint ?? + readyCheckpoints.find( + (checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount, + )); const fromCheckpointRef = input.fromTurnCount === 0 ? firstScope === undefined @@ -187,13 +238,15 @@ export const make = Effect.gen(function* () { scopeId: firstScope.id, ordinalWithinScope: 0, }).pipe(Effect.provideService(Digest.Digest, digest)) - : ( - parentCheckpoint ?? - readyCheckpoints.find( - (checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount, - ) - )?.ref; - if (fromCheckpointRef === undefined) { + : fromCheckpoint?.ref; + const repositoryEntries = latestRepositoryEntries( + projection, + toCheckpoint, + input.fromTurnCount, + ); + const hasRepositories = + toCheckpoint.repositories !== undefined || repositoryEntries.length > 0; + if (fromCheckpointRef === undefined && !hasRepositories) { return yield* new CheckpointRefUnavailableError({ operation, threadId: input.threadId, @@ -202,54 +255,123 @@ export const make = Effect.gen(function* () { }); } - const comparison = { - cwd: toScope.cwd, - fromCheckpointRef, - toCheckpointRef: toCheckpoint.ref, - fallbackFromToHead: false, - ignoreWhitespace, - }; - const authoredPaths = yield* checkpointStore - .listAuthoredPaths(comparison) - .pipe(Effect.orElseSucceed(() => null)); - const files = - authoredPaths === null - ? [] - : yield* checkpointStore.diffCheckpoints({ ...comparison, format: "numstat" }).pipe( - Effect.map(parseTurnDiffFilesFromNumstat), - Effect.orElseSucceed(() => []), - ); - const retainedFiles = files.filter((file) => !isGitImport(file, authoredPaths)); - // Select retained paths before generating a patch, so imported bulk cannot exhaust its output limit. - const pathBatches = - retainedFiles.length === files.length ? [undefined] : batchFilePaths(retainedFiles); - let diff = ""; - let remainingBytes = CHECKPOINT_DIFF_MAX_OUTPUT_BYTES; - for (const filePaths of pathBatches) { - const patch = yield* checkpointStore - .diffCheckpoints({ - ...comparison, - ...(filePaths === undefined ? {} : { filePaths }), - // One extra byte distinguishes an exact fit from a truncated patch. - maxOutputBytes: remainingBytes + 1, - }) - .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); - const patchBytes = Buffer.byteLength(patch); - diff += patch; - if (patchBytes > remainingBytes) { - // Reserve marker space only on overflow, which may trim an earlier batch. - // Streaming decode drops an incomplete final UTF-8 character at the byte boundary. - diff = - new TextDecoder().decode( - Buffer.from(diff).subarray( - 0, - CHECKPOINT_DIFF_MAX_OUTPUT_BYTES - Buffer.byteLength(DIFF_TRUNCATED_MARKER), - ), - { stream: true }, - ) + DIFF_TRUNCATED_MARKER; - break; + // Each part stops once it passes `budgetBytes`; the caller trims the total. + const diffPart = Effect.fnUntraced(function* ( + comparison: { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; + readonly fallbackFromToHead: boolean; + readonly ignoreWhitespace: boolean; + readonly pathPrefix?: string; + }, + budgetBytes: number, + ) { + const authoredPaths = yield* checkpointStore + .listAuthoredPaths(comparison) + .pipe(Effect.orElseSucceed(() => null)); + const files = + authoredPaths === null + ? [] + : yield* checkpointStore.diffCheckpoints({ ...comparison, format: "numstat" }).pipe( + Effect.map(parseTurnDiffFilesFromNumstat), + Effect.orElseSucceed(() => []), + ); + const retainedFiles = files.filter((file) => !isGitImport(file, authoredPaths)); + // Select retained paths before generating a patch, so imported bulk cannot exhaust its output limit. + const pathBatches = + retainedFiles.length === files.length ? [undefined] : batchFilePaths(retainedFiles); + let part = ""; + for (const filePaths of pathBatches) { + part += yield* checkpointStore + .diffCheckpoints({ + ...comparison, + ...(filePaths === undefined ? {} : { filePaths }), + // One extra byte distinguishes an exact fit from a truncated patch. + maxOutputBytes: budgetBytes - Buffer.byteLength(part) + 1, + }) + .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); + if (Buffer.byteLength(part) > budgetBytes) break; } - remainingBytes -= patchBytes; + return part; + }); + + const workspaceComparison = + fromCheckpointRef === undefined || + (hasRepositories && + (checkpointWorkspaceStatus(toCheckpoint) !== "ready" || + (fromCheckpoint !== undefined && + checkpointWorkspaceStatus(fromCheckpoint) !== "ready"))) + ? null + : { + cwd: toScope.cwd, + fromCheckpointRef, + toCheckpointRef: toCheckpoint.ref, + fallbackFromToHead: false, + ignoreWhitespace, + }; + let diff = ""; + const remainingBytes = () => CHECKPOINT_DIFF_MAX_OUTPUT_BYTES - Buffer.byteLength(diff); + if (workspaceComparison !== null) { + diff += hasRepositories + ? // Repositories still show when the workspace's thread-start ref is gone. + yield* diffPart(workspaceComparison, remainingBytes()).pipe( + Effect.orElseSucceed(() => ""), + ) + : yield* diffPart(workspaceComparison, remainingBytes()); + } + for (const { entry, ordinal } of repositoryEntries) { + if (remainingBytes() < 0) break; + const cwd = (yield* fileSystem + .exists(entry.worktreePath) + .pipe(Effect.orElseSucceed(() => false))) + ? entry.worktreePath + : entry.repositoryPath; + const from = yield* resolveRepositorySnapshot({ + history: { + scopeId: toCheckpoint.scopeId, + checkpoints: projection.checkpoints, + runs: projection.runs, + }, + worktreePath: entry.worktreePath, + cwd, + targetOrdinal: input.fromTurnCount, + beforeOrdinal: ordinal, + }).pipe( + Effect.provideService(Digest.Digest, digest), + Effect.provideService(CheckpointStore.CheckpointStore, checkpointStore), + ); + if (from._tag !== "found") continue; + diff += yield* diffPart( + { + cwd, + fromCheckpointRef: from.ref, + toCheckpointRef: entry.ref, + fallbackFromToHead: false, + ignoreWhitespace, + pathPrefix: entry.pathPrefix, + }, + remainingBytes(), + ).pipe( + Effect.catch((cause) => + Effect.logWarning("repository worktree diff failed", { + threadId: input.threadId, + cause: String(cause), + }).pipe(Effect.as("")), + ), + ); + } + if (remainingBytes() < 0) { + // Reserve marker space only on overflow, which may trim an earlier part. + // Streaming decode drops an incomplete final UTF-8 character at the byte boundary. + diff = + new TextDecoder().decode( + Buffer.from(diff).subarray( + 0, + CHECKPOINT_DIFF_MAX_OUTPUT_BYTES - Buffer.byteLength(DIFF_TRUNCATED_MARKER), + ), + { stream: true }, + ) + DIFF_TRUNCATED_MARKER; } const turnDiff = buildTurnDiffResult(input, diff); diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index e5d3006568a67..22274dbcdc4de 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -10,6 +10,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; export interface CaptureCheckpointInput { readonly cwd: string; readonly checkpointRef: CheckpointRef; + /** See `VcsCaptureCheckpointInput.baseline`. */ + readonly baseline?: boolean; } export interface RestoreCheckpointInput { @@ -29,6 +31,8 @@ export interface DiffCheckpointsInput { readonly maxOutputBytes?: number; /** Limits the diff to these exact paths. An empty list yields an empty diff. */ readonly filePaths?: ReadonlyArray; + /** Shown before every patch path, so patches from several repositories stay apart. */ + readonly pathPrefix?: string; } export interface ListAuthoredPathsInput { @@ -55,6 +59,11 @@ export class CheckpointStore extends Context.Service< input: Omit, ) => Effect.Effect; + /** False when the ref is missing or was not captured as a baseline. */ + readonly isBaselineCheckpoint: ( + input: Omit, + ) => Effect.Effect; + readonly restoreCheckpoint: ( input: RestoreCheckpointInput, ) => Effect.Effect; @@ -108,6 +117,13 @@ export const make = Effect.gen(function* () { return yield* checkpoints.hasCheckpointRef(input); }); + const isBaselineCheckpoint: CheckpointStore["Service"]["isBaselineCheckpoint"] = Effect.fn( + "isBaselineCheckpoint", + )(function* (input) { + const checkpoints = yield* resolveCheckpoints(input.cwd); + return yield* checkpoints.isBaselineCheckpoint(input); + }); + const restoreCheckpoint: CheckpointStore["Service"]["restoreCheckpoint"] = Effect.fn( "restoreCheckpoint", )(function* (input) { @@ -140,6 +156,7 @@ export const make = Effect.gen(function* () { isGitRepository, captureCheckpoint, hasCheckpointRef, + isBaselineCheckpoint, restoreCheckpoint, diffCheckpoints, listAuthoredPaths, diff --git a/apps/server/src/git/RepoWorktreeService.test.ts b/apps/server/src/git/RepoWorktreeService.test.ts new file mode 100644 index 0000000000000..dda8877e70dcf --- /dev/null +++ b/apps/server/src/git/RepoWorktreeService.test.ts @@ -0,0 +1,1067 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + CommandId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +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 IdAllocator from "@t3tools/provider-core/server/IdAllocator"; + +import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; +import * as ServerConfig from "../config.ts"; +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as CheckpointService from "../orchestration-v2/CheckpointService.ts"; +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as KnownWorkspaces from "../workspace/KnownWorkspaces.ts"; +import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; +import * as WorkspaceFileSystem from "../workspace/WorkspaceFileSystem.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as GitWorkflowService from "./GitWorkflowService.ts"; +import * as RepoWorktreeService from "./RepoWorktreeService.ts"; + +const projectId = ProjectId.make("project:repo-worktrees"); +const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.1-codex", +} as const; + +const adapter = { + instanceId: modelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("provider execution is disabled in repository worktree tests"), +} as ProviderAdapter.ProviderAdapterV2["Service"]; + +const layerServerConfig = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-repo-worktree-test-", +}).pipe(Layer.provide(NodeServices.layer)); + +const layerGitDriver = GitVcsDriver.layer.pipe( + Layer.provide(layerServerConfig), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), +); + +const layerCheckpoints = CheckpointService.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + CheckpointStore.layer.pipe( + Layer.provide( + VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(layerServerConfig), + ), + ), + ), + IdAllocator.layer, + ), + ), +); + +type CreateWorktree = GitWorkflowService.GitWorkflowService["Service"]["createWorktree"]; + +// GitWorkflowService adds project settings and caching on top of the driver; +// these tests exercise the real git operations underneath it. +const layerGitWorkflow = ( + createWorktree?: (git: GitVcsDriver.GitVcsDriver["Service"]) => CreateWorktree, +) => + Layer.unwrap( + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + return Layer.mock(GitWorkflowService.GitWorkflowService)({ + remoteExists: git.remoteExists, + fetchRemote: git.fetchRemote, + resolveRemoteTrackingCommit: git.resolveRemoteTrackingCommit, + pruneWorktrees: git.pruneWorktrees, + createWorktree: + createWorktree?.(git) ?? ((input, options) => git.createWorktree(input, options)), + removeWorktree: git.removeWorktree, + deleteLocalBranch: git.deleteLocalBranch, + invalidateStatus: () => Effect.void, + }); + }), + ); + +function makeLayer(input: { + readonly workspaceRoot: string; + readonly worktreesDirectory: string; + readonly createWorktree?: (git: GitVcsDriver.GitVcsDriver["Service"]) => CreateWorktree; +}) { + const layerDatabase = SqlitePersistence.layerMemory; + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "repo-worktrees" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ); + const layerSettings = ServerSettings.layerTest({ + repoWorktrees: true, + worktreesDirectory: input.worktreesDirectory, + newWorktreesStartFromOrigin: false, + }); + const layerThreads = ThreadManagement.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(layerSettings), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ); + const layerProjects = Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeedSome({ id: projectId, workspaceRoot: input.workspaceRoot } as never), + }); + return RepoWorktreeService.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + layerThreads, + layerSettings, + layerProjects, + layerGitWorkflow(input.createWorktree), + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + layerCheckpoints, + ), + ), + Layer.provideMerge(layerGitDriver), + Layer.provideMerge(layerServerConfig), + Layer.provideMerge(layerDatabase), + ); +} + +const git = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ + operation: "RepoWorktreeService.test", + cwd, + args, + timeoutMs: 30_000, + }); + return result.stdout.trim(); + }); + +const gitExitCode = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ + operation: "RepoWorktreeService.test", + cwd, + args, + allowNonZeroExit: true, + timeoutMs: 30_000, + }); + return result.exitCode; + }); + +const configureIdentity = (cwd: string) => + Effect.gen(function* () { + yield* git(cwd, ["config", "user.email", "test@example.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* git(cwd, ["config", "commit.gpgsign", "false"]); + }); + +const initRepo = (cwd: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(cwd, { recursive: true }); + yield* git(cwd, ["init", "--initial-branch=main"]); + yield* configureIdentity(cwd); + yield* fileSystem.writeFileString(path.join(cwd, "README.md"), `# ${path.basename(cwd)}\n`); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial"]); + }); + +const commitFile = (cwd: string, file: string, contents: string, message: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.writeFileString(path.join(cwd, file), contents); + yield* git(cwd, ["add", file]); + yield* git(cwd, ["commit", "-m", message]); + return yield* git(cwd, ["rev-parse", "HEAD"]); + }); + +/** A plain folder (not a repository) holding two repositories; `server` has a submodule. */ +const fixturePaths = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.realPath( + yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repo-worktrees-" }), + ); + return { + root, + folder: path.join(root, "folder"), + alpha: path.join(root, "folder", "alpha"), + server: path.join(root, "folder", "server"), + frameworkSource: path.join(root, "framework-source"), + worktrees: path.join(root, "worktrees"), + /** The configured worktree location, which may be a link to `worktrees`. */ + worktreesDirectory: path.join(root, "worktrees"), + }; +}); + +type Fixture = Effect.Success; + +/** + * `folder/beta` cloned from a remote that also has `develop`, so the clone has + * a local main and only origin/develop. + */ +const cloneWithRemoteOnlyBranch = (fixture: Fixture) => + Effect.gen(function* () { + const path = yield* Path.Path; + const seed = path.join(fixture.root, "beta-seed"); + const remote = path.join(fixture.root, "remotes", "beta.git"); + const beta = path.join(fixture.folder, "beta"); + yield* initRepo(seed); + yield* git(seed, ["switch", "-c", "develop"]); + const developCommit = yield* commitFile(seed, "develop.txt", "develop\n", "develop work"); + yield* git(seed, ["switch", "main"]); + yield* git(fixture.root, ["clone", "--quiet", "--bare", seed, remote]); + yield* git(fixture.root, ["clone", "--quiet", remote, beta]); + yield* configureIdentity(beta); + return { beta, developCommit }; + }); + +const buildRepositories = (fixture: Fixture) => + Effect.gen(function* () { + const path = yield* Path.Path; + yield* initRepo(fixture.alpha); + yield* initRepo(fixture.frameworkSource); + yield* initRepo(fixture.server); + yield* git(fixture.server, ["submodule", "add", fixture.frameworkSource, "framework"]); + yield* git(fixture.server, ["commit", "-m", "add framework"]); + yield* configureIdentity(path.join(fixture.server, "framework")); + }); + +let threadCounter = 0; +const createThreadIn = ( + workspace: { readonly branch: string; readonly worktreePath: string } | null, +) => + Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const threadId = ThreadId.make(`thread:repo-worktrees:${++threadCounter}`); + yield* threads.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create:${threadId}`), + threadId, + projectId, + title: workspace === null ? "Folder thread" : "Worktree thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: workspace?.branch ?? null, + worktreePath: workspace?.worktreePath ?? null, + createdBy: "user", + creationSource: "web", + }); + return threadId; + }); +const createThread = createThreadIn(null); + +const readThread = (threadId: ThreadId) => + Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + return (yield* threads.getThreadRecords(threadId, [])).thread; + }); + +const requestReason = (error: RepoWorktreeService.RepoWorktreeFailure) => + error._tag === "RepoWorktreeRequestError" ? error.reason : error._tag; + +/** Saves README.md as the Files editor does, through the real workspace authorization. */ +const saveReadme = (workspaceRoot: string, cwd: string) => + Effect.gen(function* () { + const files = yield* WorkspaceFileSystem.WorkspaceFileSystem; + yield* files.writeFile({ cwd, relativePath: "README.md", contents: "saved\n" }); + return "saved"; + }).pipe( + Effect.catchTags({ UnknownWorkspaceError: () => Effect.succeed("refused") }), + Effect.provide( + WorkspaceFileSystem.layer.pipe( + Layer.provide(KnownWorkspaces.layer), + Layer.provide( + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => + Effect.succeed([ + { + id: projectId, + title: "Folder", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ]), + }), + ), + Layer.provide(WorkspacePaths.layer), + Layer.provide( + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), + ), + ), + ), + ); + +const withFixture = ( + test: (fixture: Fixture) => Effect.Effect, + options: { + /** Configure the worktree location through a link, as /tmp is on macOS. */ + readonly linkedWorktreesDirectory?: boolean; + readonly createWorktree?: (git: GitVcsDriver.GitVcsDriver["Service"]) => CreateWorktree; + } = {}, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const fixture = yield* fixturePaths; + let worktreesDirectory = fixture.worktrees; + if (options.linkedWorktreesDirectory === true) { + worktreesDirectory = `${fixture.worktrees}-link`; + yield* fileSystem.makeDirectory(fixture.worktrees, { recursive: true }); + yield* fileSystem.symlink(fixture.worktrees, worktreesDirectory); + } + return yield* Effect.gen(function* () { + yield* buildRepositories(fixture); + return yield* test({ ...fixture, worktreesDirectory }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot: fixture.folder, + worktreesDirectory, + ...(options.createWorktree === undefined + ? {} + : { createWorktree: options.createWorktree }), + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); + +describe("RepoWorktreeService", () => { + it.effect("creates a worktree for a repository in the folder while the thread stays put", () => + withFixture((fixture) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const service = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread; + + const result = yield* service.ensure({ + threadId, + repository: "alpha", + branch: "feature/one", + baseRef: "main", + }); + + const expectedPath = path.join(fixture.worktrees, "alpha", "feature-one"); + assert.equal(result.status, "created"); + assert.isTrue(result.branchCreated); + assert.equal(result.repoWorktree.worktreePath, expectedPath); + assert.isTrue(yield* fileSystem.exists(path.join(expectedPath, "README.md"))); + assert.equal(yield* git(expectedPath, ["branch", "--show-current"]), "feature/one"); + assert.equal(yield* git(fixture.alpha, ["branch", "--show-current"]), "main"); + + const thread = yield* readThread(threadId); + assert.isNull(thread.worktreePath); + assert.isNull(thread.branch); + assert.deepEqual( + thread.repoWorktrees?.map((entry) => [ + entry.repository, + entry.repositoryPath, + entry.branch, + entry.baseRef, + entry.origin, + ]), + [["alpha", fixture.alpha, "feature/one", "main", "created"]], + ); + + // Mid-task, the same thread adds a second repository. + yield* service.ensure({ + threadId, + repository: path.join(fixture.server, "framework"), + branch: "feature/one", + baseRef: "main", + }); + assert.deepEqual( + (yield* readThread(threadId)).repoWorktrees?.map((entry) => entry.repository), + ["alpha", "server/framework"], + ); + }), + ), + ); + + it.effect("reuses the worktree that has the branch and keeps its edits", () => + withFixture(() => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const service = yield* RepoWorktreeService.RepoWorktreeService; + const first = yield* createThread; + const second = yield* createThread; + + const created = yield* service.ensure({ + threadId: first, + repository: "alpha", + branch: "feature/shared", + baseRef: "main", + }); + const worktreePath = created.repoWorktree.worktreePath; + yield* fileSystem.writeFileString(path.join(worktreePath, "README.md"), "edited\n"); + yield* fileSystem.writeFileString(path.join(worktreePath, "notes.txt"), "draft\n"); + // Over 256 KiB of status output; a rename and a name Git quotes count once each. + for (let index = 0; index < 1_100; index++) { + yield* fileSystem.writeFileString( + path.join(worktreePath, `${index}-${"x".repeat(240)}.txt`), + "", + ); + } + yield* commitFile(worktreePath, "old-name.txt", "old\n", "add old name"); + yield* git(worktreePath, ["mv", "old-name.txt", "new-name.txt"]); + yield* fileSystem.writeFileString(path.join(worktreePath, "line\nbreak.txt"), ""); + + const reused = yield* service.ensure({ + threadId: second, + repository: "alpha", + branch: "feature/shared", + }); + assert.equal(reused.status, "reused"); + assert.isFalse(reused.alreadyAttached); + assert.equal(reused.uncommittedFiles, 1_104); + assert.equal(reused.repoWorktree.worktreePath, worktreePath); + assert.equal(reused.repoWorktree.origin, "reused"); + assert.equal( + yield* fileSystem.readFileString(path.join(worktreePath, "README.md")), + "edited\n", + ); + assert.equal( + yield* fileSystem.readFileString(path.join(worktreePath, "notes.txt")), + "draft\n", + ); + + const again = yield* service.ensure({ + threadId: first, + repository: "alpha", + branch: "feature/shared", + }); + assert.equal(again.status, "reused"); + assert.isTrue(again.alreadyAttached); + assert.equal(again.repoWorktree.origin, "created"); + assert.equal((yield* readThread(first)).repoWorktrees?.length, 1); + }), + ), + ); + + it.effect("needs an explicit choice before touching an existing or source branch", () => + withFixture((fixture) => + Effect.gen(function* () { + const service = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread; + const path = yield* Path.Path; + yield* git(fixture.alpha, ["branch", "topic"]); + // A repository in the folder can itself be a linked worktree of a checkout elsewhere. + yield* git(fixture.alpha, [ + "worktree", + "add", + "--quiet", + "-b", + "linked-topic", + path.join(fixture.folder, "linked"), + ]); + + const attempts = [ + { branch: "topic", baseRef: "main" }, + { branch: "main", baseRef: "main" }, + { repository: "linked", branch: "linked-topic", useExistingBranch: true }, + { branch: "brand-new" }, + { branch: "missing", useExistingBranch: true }, + { branch: "bad..name", baseRef: "main" }, + ]; + const refusals = yield* Effect.forEach(attempts, (attempt) => + service + .ensure({ threadId, repository: "alpha", ...attempt }) + .pipe(Effect.flip, Effect.map(requestReason)), + ); + assert.deepEqual(refusals, [ + "branch_exists", + "branch_in_source_checkout", + "branch_in_source_checkout", + "base_ref_required", + "branch_missing", + "invalid_branch", + ]); + assert.deepEqual((yield* readThread(threadId)).repoWorktrees ?? [], []); + + const continued = yield* service.ensure({ + threadId, + repository: "alpha", + branch: "topic", + useExistingBranch: true, + }); + assert.equal(continued.status, "created"); + assert.isFalse(continued.branchCreated); + assert.equal( + yield* git(continued.repoWorktree.worktreePath, ["branch", "--show-current"]), + "topic", + ); + }), + ), + ); + + it.effect("refuses repositories outside the folder", () => + withFixture((fixture) => + Effect.gen(function* () { + const service = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread; + const reasons = yield* Effect.forEach( + [fixture.frameworkSource, "../framework-source", "missing-repo"], + (repository) => + service + .ensure({ threadId, repository, branch: "feature/x", baseRef: "main" }) + .pipe(Effect.flip, Effect.map(requestReason)), + ); + assert.deepEqual(reasons, ["outside_project", "outside_project", "not_a_repository"]); + }), + ), + ); + + it.effect("never overwrites an unrelated folder at the default location", () => + withFixture((fixture) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const service = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread; + const occupied = path.join(fixture.worktrees, "alpha", "feature-two"); + yield* fileSystem.makeDirectory(occupied, { recursive: true }); + yield* fileSystem.writeFileString(path.join(occupied, "keep.txt"), "unrelated\n"); + + const result = yield* service.ensure({ + threadId, + repository: "alpha", + branch: "feature/two", + baseRef: "main", + }); + + assert.equal(result.repoWorktree.worktreePath, `${occupied}-2`); + assert.equal( + yield* fileSystem.readFileString(path.join(occupied, "keep.txt")), + "unrelated\n", + ); + }), + ), + ); + + it.effect("pins a parent worktree's submodule to commits from the submodule's worktree", () => + withFixture((fixture) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const service = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread; + const sourceSubmodule = path.join(fixture.server, "framework"); + const sourceSubmoduleHead = yield* git(sourceSubmodule, ["rev-parse", "HEAD"]); + + const parent = (yield* service.ensure({ + threadId, + repository: "server", + branch: "feature/server", + baseRef: "main", + })).repoWorktree.worktreePath; + const framework = (yield* service.ensure({ + threadId, + repository: "server/framework", + branch: "feature/framework", + baseRef: "main", + })).repoWorktree.worktreePath; + assert.equal(framework, path.join(fixture.worktrees, "framework", "feature-framework")); + const parentSubmodule = path.join(parent, "framework"); + assert.isTrue(yield* fileSystem.exists(path.join(parentSubmodule, "README.md"))); + + const firstCommit = yield* commitFile(framework, "lib.txt", "v1\n", "framework v1"); + const first = yield* service.setSubmoduleCommit({ + threadId, + worktreePath: parent, + submodulePath: "framework", + sourceWorktreePath: framework, + }); + assert.deepEqual( + [first.commit, first.previousCommit, first.fetched, first.staged], + [firstCommit, sourceSubmoduleHead, true, true], + ); + assert.equal(yield* git(parentSubmodule, ["rev-parse", "HEAD"]), firstCommit); + assert.notEqual(yield* gitExitCode(parentSubmodule, ["symbolic-ref", "-q", "HEAD"]), 0); + assert.equal(yield* git(parent, ["diff", "--cached", "--name-only"]), "framework"); + assert.equal( + yield* git(parentSubmodule, ["remote", "get-url", "origin"]), + fixture.frameworkSource, + ); + + const secondCommit = yield* commitFile(framework, "lib.txt", "v2\n", "framework v2"); + const second = yield* service.setSubmoduleCommit({ + threadId, + worktreePath: parent, + submodulePath: "framework", + sourceWorktreePath: framework, + }); + assert.deepEqual([second.commit, second.previousCommit], [secondCommit, firstCommit]); + yield* git(parent, ["commit", "-m", "Pin framework"]); + assert.include(yield* git(parent, ["ls-tree", "HEAD", "framework"]), secondCommit); + + // The source checkouts never moved. + assert.equal(yield* git(sourceSubmodule, ["rev-parse", "HEAD"]), sourceSubmoduleHead); + assert.equal(yield* git(fixture.server, ["status", "--porcelain"]), ""); + assert.equal(yield* git(fixture.server, ["branch", "--show-current"]), "main"); + + // Edits in the parent's submodule checkout are never overwritten. + yield* commitFile(framework, "lib.txt", "v3\n", "framework v3"); + yield* fileSystem.writeFileString(path.join(parentSubmodule, "README.md"), "local edit\n"); + const refused = yield* service + .setSubmoduleCommit({ + threadId, + worktreePath: parent, + submodulePath: "framework", + sourceWorktreePath: framework, + }) + .pipe(Effect.flip, Effect.map(requestReason)); + assert.equal(refused, "submodule_changes"); + assert.equal( + yield* fileSystem.readFileString(path.join(parentSubmodule, "README.md")), + "local edit\n", + ); + assert.equal(yield* git(parentSubmodule, ["rev-parse", "HEAD"]), secondCommit); + + // Nor are ignored files there that the new commit tracks. + yield* git(parentSubmodule, ["checkout", "--", "README.md"]); + yield* fileSystem.writeFileString(path.join(parentSubmodule, ".gitignore"), "local.env\n"); + yield* fileSystem.writeFileString(path.join(parentSubmodule, "local.env"), "mine\n"); + yield* commitFile(framework, "local.env", "theirs\n", "track local.env"); + const ignoredRefusal = yield* service + .setSubmoduleCommit({ + threadId, + worktreePath: parent, + submodulePath: "framework", + sourceWorktreePath: framework, + }) + .pipe(Effect.flip, Effect.map(requestReason)); + assert.equal(ignoredRefusal, "GitCommandError"); + assert.equal( + yield* fileSystem.readFileString(path.join(parentSubmodule, "local.env")), + "mine\n", + ); + assert.equal(yield* git(parentSubmodule, ["rev-parse", "HEAD"]), secondCommit); + + const notAttached = yield* service + .setSubmoduleCommit({ + threadId, + worktreePath: fixture.server, + submodulePath: "framework", + sourceWorktreePath: framework, + }) + .pipe(Effect.flip, Effect.map(requestReason)); + assert.equal(notAttached, "not_attached"); + }), + ), + ); + + it.effect("detaches without touching files, also after the project turns it off", () => + withFixture(() => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const service = yield* RepoWorktreeService.RepoWorktreeService; + const settings = yield* ServerSettings.ServerSettingsService; + const threadId = yield* createThread; + const { worktreePath } = (yield* service.ensure({ + threadId, + repository: "alpha", + branch: "feature/detach", + baseRef: "main", + })).repoWorktree; + + yield* settings.updateSettings({ repoWorktrees: false }); + const disabled = yield* service + .ensure({ threadId, repository: "alpha", branch: "feature/other", baseRef: "main" }) + .pipe(Effect.flip); + assert.equal(disabled._tag, "RepoWorktreesDisabledError"); + + assert.deepEqual(yield* service.detach({ threadId, worktreePath }), { + detached: true, + worktreePath, + }); + assert.deepEqual((yield* readThread(threadId)).repoWorktrees, []); + assert.isTrue(yield* fileSystem.exists(worktreePath)); + assert.deepEqual(yield* service.detach({ threadId, worktreePath }), { + detached: false, + worktreePath, + }); + // A client's own detach command, sent again by a double tap or another device. + yield* (yield* ThreadManagement.ThreadManagementService).dispatch({ + type: "thread.repo-worktree.detach", + commandId: CommandId.make("detach:again"), + threadId, + worktreePath, + }); + }), + ), + ); + + // Given an unresolvable start point, `git worktree add -b feature/b + // develop` creates and checks out develop from origin/develop instead. + it.effect("starts a new branch from a base only origin has without Git guessing", () => + withFixture((fixture) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread; + const { beta, developCommit } = yield* cloneWithRemoteOnlyBranch(fixture); + const hasLocalDevelop = gitExitCode(beta, [ + "show-ref", + "--verify", + "--quiet", + "refs/heads/develop", + ]); + + const refused = yield* service + .ensure({ + threadId, + repository: "beta", + branch: "feature/b", + baseRef: "develop", + startFromOrigin: false, + }) + .pipe(Effect.flip); + assert.equal(requestReason(refused), "base_missing"); + assert.include(refused.message, "only origin has it"); + assert.isFalse(yield* fileSystem.exists(path.join(fixture.worktrees, "beta"))); + assert.notEqual(yield* hasLocalDevelop, 0); + assert.deepEqual((yield* readThread(threadId)).repoWorktrees ?? [], []); + + // Explicit remote base without a fetch, and a fetched one. + for (const request of [ + { branch: "feature/b", baseRef: "origin/develop", startFromOrigin: false }, + { branch: "feature/c", baseRef: "develop", startFromOrigin: true }, + ]) { + const created = yield* service.ensure({ threadId, repository: "beta", ...request }); + const worktreePath = created.repoWorktree.worktreePath; + assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), request.branch); + assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), developCommit); + assert.equal(created.repoWorktree.branch, request.branch); + } + assert.notEqual(yield* hasLocalDevelop, 0); + assert.equal(yield* git(beta, ["branch", "--show-current"]), "main"); + + // Continuing origin's branch itself takes an explicit choice, and tracks it. + const continueDevelop = { threadId, repository: "beta", branch: "develop" }; + const unconfirmed = yield* service.ensure(continueDevelop).pipe(Effect.flip); + assert.equal(requestReason(unconfirmed), "branch_exists"); + const continued = yield* service.ensure({ ...continueDevelop, useExistingBranch: true }); + const developPath = continued.repoWorktree.worktreePath; + assert.equal(yield* git(developPath, ["branch", "--show-current"]), "develop"); + assert.equal(yield* git(developPath, ["rev-parse", "HEAD"]), developCommit); + assert.equal( + yield* git(developPath, ["rev-parse", "--abbrev-ref", "develop@{upstream}"]), + "origin/develop", + ); + }), + ), + ); + + it.effect("never attaches a worktree on a branch other than the one requested", () => + withFixture( + (fixture) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread; + + const refused = yield* service + .ensure({ threadId, repository: "alpha", branch: "feature/b", baseRef: "main" }) + .pipe(Effect.flip); + assert.equal(requestReason(refused), "branch_mismatch"); + assert.include(refused.message, "'surprise' instead of 'feature/b'"); + assert.isFalse( + yield* fileSystem.exists(path.join(fixture.worktrees, "alpha", "feature-b")), + ); + assert.notEqual( + yield* gitExitCode(fixture.alpha, [ + "show-ref", + "--verify", + "--quiet", + "refs/heads/feature/b", + ]), + 0, + ); + assert.deepEqual((yield* readThread(threadId)).repoWorktrees ?? [], []); + }), + { + // A real checkout of another branch, standing in for Git choosing one. + createWorktree: (git) => (input, options) => + git.createWorktree({ ...input, newRefName: "surprise" }, options), + }, + ), + ); + + it.effect("relabels an attached worktree after its checkout switched branch", () => + withFixture(() => + Effect.gen(function* () { + const service = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread; + const { worktreePath } = (yield* service.ensure({ + threadId, + repository: "alpha", + branch: "feature/first", + baseRef: "main", + })).repoWorktree; + yield* git(worktreePath, ["switch", "--quiet", "-c", "feature/second"]); + + const reused = yield* service.ensure({ + threadId, + repository: "alpha", + branch: "feature/second", + }); + assert.deepEqual( + [reused.status, reused.alreadyAttached, reused.repoWorktree.branch], + ["reused", true, "feature/second"], + ); + assert.deepEqual( + (yield* readThread(threadId)).repoWorktrees?.map((entry) => [ + entry.worktreePath, + entry.branch, + ]), + [[worktreePath, "feature/second"]], + ); + }), + ), + ); + + it.effect("records a switched branch for every thread with the checkout, never reattaching", () => + withFixture(() => + Effect.gen(function* () { + const service = yield* RepoWorktreeService.RepoWorktreeService; + const threads = yield* ThreadManagement.ThreadManagementService; + const first = yield* createThread; + const second = yield* createThread; + const detached = yield* createThread; + const unrelated = yield* createThread; + const { repoWorktree } = yield* service.ensure({ + threadId: first, + repository: "alpha", + branch: "feature/shared", + baseRef: "main", + }); + for (const threadId of [second, detached]) { + yield* service.ensure({ threadId, repository: "alpha", branch: "feature/shared" }); + } + yield* service.detach({ threadId: detached, worktreePath: repoWorktree.worktreePath }); + // As a Git action does from the thread's Changes view. + yield* git(repoWorktree.worktreePath, ["switch", "--quiet", "-c", "feature/renamed"]); + + yield* service.recordBranch({ cwd: repoWorktree.worktreePath }); + for (const threadId of [first, second]) { + assert.deepEqual( + (yield* readThread(threadId)).repoWorktrees?.map((entry) => entry.branch), + ["feature/renamed"], + ); + } + assert.deepEqual((yield* readThread(detached)).repoWorktrees ?? [], []); + assert.deepEqual((yield* readThread(unrelated)).repoWorktrees ?? [], []); + + // A relabel read before that detach landed is a no-op and leaves it detached. + yield* threads.dispatch({ + type: "thread.repo-worktree.attach", + commandId: CommandId.make("relabel:after-detach"), + threadId: detached, + repoWorktree: { ...repoWorktree, branch: "feature/renamed" }, + relabelOnly: true, + }); + assert.deepEqual((yield* readThread(detached)).repoWorktrees ?? [], []); + }), + ), + ); + + it.effect("treats a linked worktree location and Git's real path as one checkout", () => + withFixture( + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const service = yield* RepoWorktreeService.RepoWorktreeService; + const first = yield* createThread; + const second = yield* createThread; + const request = { repository: "alpha", branch: "feature/shared", baseRef: "main" }; + + const created = yield* service.ensure({ threadId: first, ...request }); + const reused = yield* service.ensure({ threadId: second, ...request }); + assert.equal( + yield* fileSystem.realPath(created.repoWorktree.worktreePath), + reused.repoWorktree.worktreePath, + ); + assert.notEqual(created.repoWorktree.worktreePath, reused.repoWorktree.worktreePath); + + const again = yield* service.ensure({ threadId: first, ...request }); + assert.isTrue(again.alreadyAttached); + assert.equal((yield* readThread(first)).repoWorktrees?.length, 1); + assert.deepEqual( + yield* service.detach({ + threadId: first, + worktreePath: reused.repoWorktree.worktreePath, + }), + { detached: true, worktreePath: created.repoWorktree.worktreePath }, + ); + }), + { linkedWorktreesDirectory: true }, + ), + ); + + it.effect("keeps a worktree that a folder thread attached when its own thread deletes it", () => + withFixture( + (fixture) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* RepoWorktreeService.RepoWorktreeService; + // An ordinary thread's worktree, made through the linked location. + const worktreePath = path.join(fixture.worktreesDirectory, "alpha", "feature-login"); + yield* git(fixture.alpha, [ + "worktree", + "add", + "-b", + "feature/login", + worktreePath, + "main", + ]); + yield* fileSystem.writeFileString(path.join(worktreePath, "draft.txt"), "draft\n"); + yield* createThreadIn({ branch: "feature/login", worktreePath }); + const folderThread = yield* createThread; + const attached = yield* service.ensure({ + threadId: folderThread, + repository: "alpha", + branch: "feature/login", + }); + assert.equal(attached.status, "reused"); + assert.equal(attached.uncommittedFiles, 1); + + const refused = yield* service + .removeWorktree({ cwd: fixture.alpha, path: worktreePath, force: true }) + .pipe(Effect.flip); + assert.equal(refused._tag, "RepoWorktreeInUseError"); + assert.include(refused.message, '"Folder thread"'); + assert.equal( + yield* fileSystem.readFileString(path.join(worktreePath, "draft.txt")), + "draft\n", + ); + + yield* service.detach({ threadId: folderThread, worktreePath }); + yield* service.removeWorktree({ cwd: fixture.alpha, path: worktreePath, force: true }); + assert.isFalse(yield* fileSystem.exists(worktreePath)); + }), + { linkedWorktreesDirectory: true }, + ), + ); + + it.effect("lets Files save in attached worktrees outside both folders until detached", () => + withFixture((fixture) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* RepoWorktreeService.RepoWorktreeService; + const threads = yield* ThreadManagement.ThreadManagementService; + const outside = path.join(fixture.root, "outside"); + const alphaWorktree = path.join(outside, "alpha"); + const serverWorktree = path.join(outside, "server"); + const serverOther = path.join(outside, "server-other"); + yield* git(fixture.alpha, ["worktree", "add", "-b", "feature/external", alphaWorktree]); + yield* git(fixture.server, ["worktree", "add", "-b", "feature/external", serverWorktree]); + yield* git(fixture.server, ["worktree", "add", "-b", "feature/other", serverOther]); + yield* fileSystem.writeFileString(path.join(alphaWorktree, "draft.txt"), "draft\n"); + const unrelated = path.join(fixture.root, "unrelated"); + const unrelatedWorktree = path.join(fixture.root, "unrelated-feature"); + yield* initRepo(unrelated); + yield* git(unrelated, ["worktree", "add", "-b", "feature/x", unrelatedWorktree]); + const linked = path.join(fixture.folder, "linked"); + yield* fileSystem.symlink(unrelated, linked); + const plain = path.join(fixture.root, "plain"); + yield* fileSystem.makeDirectory(plain); + + // Bound by its own worktree path, which the folder project's Git list lacks. + yield* createThreadIn({ branch: "feature/external", worktreePath: serverWorktree }); + const threadId = yield* createThread; + const alpha = yield* service.ensure({ + threadId, + repository: "alpha", + branch: "feature/external", + }); + assert.equal(alpha.status, "reused"); + assert.equal(alpha.uncommittedFiles, 1); + assert.equal(alpha.repoWorktree.worktreePath, alphaWorktree); + const server = yield* service.ensure({ + threadId, + repository: "server", + branch: "feature/external", + }); + assert.equal(server.status, "reused"); + + assert.equal(yield* saveReadme(fixture.folder, alphaWorktree), "saved"); + assert.equal(yield* saveReadme(fixture.folder, serverWorktree), "saved"); + assert.equal( + yield* fileSystem.readFileString(path.join(alphaWorktree, "README.md")), + "saved\n", + ); + assert.equal( + yield* fileSystem.readFileString(path.join(alphaWorktree, "draft.txt")), + "draft\n", + ); + + // Records only the server writes, naming checkouts Git does not list for that repository. + for (const [repositoryPath, worktreePath] of [ + [fixture.alpha, serverOther], + [unrelated, unrelatedWorktree], + [linked, unrelatedWorktree], + [fixture.alpha, plain], + ] as const) { + const forged = yield* createThread; + yield* threads.dispatch({ + type: "thread.repo-worktree.attach", + commandId: CommandId.make(`attach:${forged}`), + threadId: forged, + repoWorktree: { + repository: "forged", + repositoryPath, + worktreePath, + branch: "forged", + baseRef: null, + origin: "reused", + attachedAt: "2026-01-01T00:00:00.000Z", + }, + }); + assert.equal(yield* saveReadme(fixture.folder, worktreePath), "refused"); + } + + yield* service.detach({ threadId, worktreePath: alphaWorktree }); + assert.equal(yield* saveReadme(fixture.folder, alphaWorktree), "refused"); + assert.equal(yield* saveReadme(fixture.folder, serverWorktree), "saved"); + yield* threads.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`delete:${threadId}`), + threadId, + }); + assert.equal(yield* saveReadme(fixture.folder, serverWorktree), "refused"); + }), + ), + ); +}); diff --git a/apps/server/src/git/RepoWorktreeService.ts b/apps/server/src/git/RepoWorktreeService.ts new file mode 100644 index 0000000000000..59a098d1999ad --- /dev/null +++ b/apps/server/src/git/RepoWorktreeService.ts @@ -0,0 +1,1134 @@ +import { + CommandId, + type GitCommandError, + type OrchestrationV2AppThread, + type ProjectId, + type ThreadId, + type ThreadRepoWorktree, + type VcsRemoveWorktreeInput, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +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 ServerConfig from "../config.ts"; +import * as CheckpointService from "../orchestration-v2/CheckpointService.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { withRepositoryLease } from "../workspace/workspaceLease.ts"; +import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; +import * as GitWorkflowService from "./GitWorkflowService.ts"; + +export class RepoWorktreesDisabledError extends Schema.TaggedError()( + "RepoWorktreesDisabledError", + { projectId: Schema.String }, +) { + override get message(): string { + return "Multi-Repository Project Folder is off for this project. Turn it on in the project's Worktrees settings first."; + } +} + +export class RepoWorktreeThreadUnavailableError extends Schema.TaggedError()( + "RepoWorktreeThreadUnavailableError", + { + threadId: Schema.String, + state: Schema.Literals(["missing", "archived"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.state === "archived" + ? `Thread ${this.threadId} is archived.` + : `Thread ${this.threadId} was not found.`; + } +} + +/** A request the repository's current state refuses; nothing was changed. */ +export class RepoWorktreeRequestError extends Schema.TaggedError()( + "RepoWorktreeRequestError", + { + reason: Schema.Literals([ + "outside_project", + "not_a_repository", + "invalid_branch", + "base_ref_required", + "base_missing", + "branch_exists", + "branch_missing", + "branch_in_source_checkout", + "branch_mismatch", + "location_invalid", + "path_unavailable", + "not_attached", + "not_a_submodule", + "submodule_changes", + "source_mismatch", + ]), + path: Schema.optional(Schema.String), + branch: Schema.optional(Schema.String), + baseRef: Schema.optional(Schema.String), + /** What Git checked out instead of the branch; absent for a detached HEAD. */ + actualBranch: Schema.optional(Schema.String), + remote: Schema.optional(Schema.Boolean), + }, +) { + override get message(): string { + const path = this.path ?? "the path"; + const branch = this.branch ?? "the branch"; + switch (this.reason) { + case "base_missing": + return this.remote === true + ? `Base '${this.baseRef}' is not a local branch or commit in ${path}; only origin has it.` + : `Base '${this.baseRef}' is not a branch, tag, or commit in ${path}.`; + case "branch_mismatch": + return `Git checked out ${this.actualBranch === undefined ? "a detached HEAD" : `'${this.actualBranch}'`} instead of '${branch}' at ${path}. That new worktree was removed and nothing was attached.`; + case "outside_project": + return `${path} is not a Git repository inside the project folder.`; + case "not_a_repository": + return `${path} is not a Git repository.`; + case "invalid_branch": + return `'${branch}' is not a valid branch name.`; + case "base_ref_required": + return `Branch '${branch}' does not exist in ${path} yet. Give the branch or ref it should start from.`; + case "branch_exists": + return `Branch '${branch}' already exists ${this.remote === true ? "on origin" : "locally"} in ${path} and has no worktree. Continue it explicitly, or choose a new branch name for new work.`; + case "branch_missing": + return `Branch '${branch}' does not exist in ${path}, locally or on origin.`; + case "branch_in_source_checkout": + return `Branch '${branch}' is checked out in the source checkout at ${path}. Choose another branch so the source checkout stays untouched.`; + case "location_invalid": + return "The worktree location must be an absolute folder on this machine, not a drive root. Change it in Settings, Storage."; + case "path_unavailable": + return `No free folder is left for a new worktree near ${path}.`; + case "not_attached": + return `${path} is not a repository worktree attached to this thread.`; + case "not_a_submodule": + return `${path} is not a submodule of that worktree.`; + case "submodule_changes": + return `The submodule at ${path} has uncommitted changes. Commit or move them first; they were left as they are.`; + case "source_mismatch": + return `The source worktree is not a checkout of the repository used by the submodule at ${path}.`; + } + } +} + +export class RepoWorktreeDispatchError extends Schema.TaggedError()( + "RepoWorktreeDispatchError", + { + threadId: Schema.String, + operation: Schema.Literals(["attach", "detach"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not ${this.operation} the repository worktree for thread ${this.threadId}.`; + } +} + +export class RepoWorktreeReadError extends Schema.TaggedError()( + "RepoWorktreeReadError", + { + operation: Schema.Literals(["settings", "project", "attachments"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not read the ${ + this.operation === "settings" + ? "server settings" + : this.operation === "project" + ? "project" + : "threads' repository worktrees" + }.`; + } +} + +/** A removal refused because a thread still works in the checkout; nothing was changed. */ +export class RepoWorktreeInUseError extends Schema.TaggedError()( + "RepoWorktreeInUseError", + { + path: Schema.String, + threadId: Schema.String, + threadTitle: Schema.String, + }, +) { + override get message(): string { + return `Thread "${this.threadTitle}" works in ${this.path} as a repository worktree, so it was kept. Detach it from that thread to delete it.`; + } +} + +export interface RepoWorktreeEnsureInput { + readonly threadId: ThreadId; + readonly repository: string; + readonly branch: string; + readonly baseRef?: string | undefined; + readonly startFromOrigin?: boolean | undefined; + readonly useExistingBranch?: boolean | undefined; +} + +export interface RepoWorktreeEnsureResult { + readonly status: "created" | "reused"; + readonly branchCreated: boolean; + readonly alreadyAttached: boolean; + readonly uncommittedFiles: number; + readonly startedFromOrigin: boolean; + readonly repoWorktree: ThreadRepoWorktree; +} + +export interface RepoWorktreeSetSubmoduleInput { + readonly threadId: ThreadId; + readonly worktreePath: string; + readonly submodulePath: string; + readonly sourceWorktreePath: string; + readonly commit?: string | undefined; + readonly stage?: boolean | undefined; +} + +export interface RepoWorktreeSetSubmoduleResult { + readonly worktreePath: string; + readonly submodulePath: string; + readonly commit: string; + readonly previousCommit: string | null; + readonly fetched: boolean; + readonly staged: boolean; +} + +export type RepoWorktreeFailure = + | RepoWorktreesDisabledError + | RepoWorktreeThreadUnavailableError + | RepoWorktreeRequestError + | RepoWorktreeDispatchError + | RepoWorktreeReadError + | GitCommandError; + +export class RepoWorktreeService extends Context.Service< + RepoWorktreeService, + { + /** Attaches a worktree of a repository in the project folder, creating it only when needed. */ + readonly ensure: ( + input: RepoWorktreeEnsureInput, + ) => Effect.Effect; + /** + * Relabels every thread that has the checkout at `cwd` attached with its + * current branch, after something switched it, so the branch shown and the + * one a rollback recreates a removed folder from stay current. + */ + readonly recordBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + /** Forgets an attached worktree. Works with the setting off; never touches files. */ + readonly detach: (input: { + readonly threadId: ThreadId; + readonly worktreePath: string; + }) => Effect.Effect< + { readonly detached: boolean; readonly worktreePath: string }, + RepoWorktreeFailure + >; + /** Points a parent worktree's submodule at a commit from another attached worktree. */ + readonly setSubmoduleCommit: ( + input: RepoWorktreeSetSubmoduleInput, + ) => Effect.Effect; + /** + * Removes a worktree unless a thread has it attached as a repository + * worktree. Forced removals are refused the same way, so user deletes and + * the undo of a canceled or failed setup or handoff all go through here. + */ + readonly removeWorktree: ( + input: VcsRemoveWorktreeInput, + ) => Effect.Effect; + } +>()("t3/git/RepoWorktreeService") {} + +interface WorktreeEntry { + readonly path: string; + readonly branchRef: string | null; + readonly prunable: boolean; +} + +/** `git worktree list --porcelain` records; the first is always the main worktree. */ +function parseWorktreeList(stdout: string): ReadonlyArray { + return stdout.split("\0\0").flatMap((record) => { + const fields = record.split("\0").filter((field) => field.length > 0); + const worktree = fields.find((field) => field.startsWith("worktree ")); + if (worktree === undefined) return []; + const branch = fields.find((field) => field.startsWith("branch ")); + return [ + { + path: worktree.slice("worktree ".length), + branchRef: branch === undefined ? null : branch.slice("branch ".length), + prunable: fields.some((field) => field === "prunable" || field.startsWith("prunable ")), + }, + ]; + }); +} + +const SETTLED_RUN_STATUSES: ReadonlySet = new Set([ + "completed", + "interrupted", + "failed", + "cancelled", + "rolled_back", +]); + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const projects = yield* ProjectService.ProjectService; + const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const checkpoints = yield* CheckpointService.CheckpointServiceV2; + const git = yield* GitVcsDriver.GitVcsDriver; + const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const random = yield* SecureRandom.SecureRandom; + const isWindows = yield* HostProcess.isWindows; + const repositoryLocks = yield* KeyedLock.make(); + + const pathKey = (target: string) => { + const resolved = path.resolve(target); + return isWindows ? resolved.toLowerCase() : resolved; + }; + const insideOrEqual = (root: string, target: string) => { + const relative = path.relative(pathKey(root), pathKey(target)); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); + }; + const realPathOr = (target: string) => + fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => path.resolve(target))); + const sameWorktree = (left: string, right: string) => pathKey(left) === pathKey(right); + // Git reports a checkout by its real path while settings may reach it + // through a link (/tmp and /private/tmp), so attachments compare this. + const pathIdentity = (target: string) => realPathOr(target).pipe(Effect.map(pathKey)); + + const run = (cwd: string, operation: string, args: ReadonlyArray) => + git.execute({ operation: `RepoWorktreeService.${operation}`, cwd, args, timeoutMs: 60_000 }); + const probe = (cwd: string, operation: string, args: ReadonlyArray) => + git + .execute({ + operation: `RepoWorktreeService.${operation}`, + cwd, + args, + allowNonZeroExit: true, + timeoutMs: 30_000, + }) + .pipe(Effect.map((result) => ({ ok: result.exitCode === 0, stdout: result.stdout.trim() }))); + + const loadThread = (threadId: ThreadId) => + threadManagement.getThreadRecords(threadId, []).pipe( + Effect.map((projection) => projection.thread), + Effect.mapError( + (cause) => new RepoWorktreeThreadUnavailableError({ threadId, state: "missing", cause }), + ), + Effect.filterOrFail( + (thread) => thread.deletedAt === null, + () => new RepoWorktreeThreadUnavailableError({ threadId, state: "missing" }), + ), + ); + + const loadProject = (projectId: ProjectId) => + projects.getById(projectId).pipe( + Effect.mapError((cause) => new RepoWorktreeReadError({ operation: "project", cause })), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new RepoWorktreeReadError({ operation: "project", cause: "Project not found." }), + ), + onSome: Effect.succeed, + }), + ), + ); + + const loadSettings = (projectId: ProjectId) => + settingsService.getSettings.pipe( + Effect.map((settings) => resolveProjectSettings(settings, projectId).settings), + Effect.mapError((cause) => new RepoWorktreeReadError({ operation: "settings", cause })), + ); + + /** The thread, its project, and resolved settings, for work that needs the setting on. */ + const loadEnabledContext = Effect.fnUntraced(function* (threadId: ThreadId) { + const thread = yield* loadThread(threadId); + if (thread.archivedAt !== null) { + return yield* new RepoWorktreeThreadUnavailableError({ threadId, state: "archived" }); + } + const settings = yield* loadSettings(thread.projectId); + if (!settings.repoWorktrees) { + return yield* new RepoWorktreesDisabledError({ projectId: thread.projectId }); + } + const project = yield* loadProject(thread.projectId); + return { thread, settings, project }; + }); + + const attachedWorktree = Effect.fnUntraced(function* ( + thread: OrchestrationV2AppThread, + worktreePath: string, + ) { + const target = yield* pathIdentity(worktreePath); + for (const entry of thread.repoWorktrees ?? []) { + if ((yield* pathIdentity(entry.worktreePath)) === target) return entry; + } + return undefined; + }); + + const requireAttached = (thread: OrchestrationV2AppThread, worktreePath: string) => + attachedWorktree(thread, worktreePath).pipe( + Effect.filterOrFail( + (attached): attached is ThreadRepoWorktree => attached !== undefined, + () => new RepoWorktreeRequestError({ reason: "not_attached", path: worktreePath }), + ), + ); + + // A name Git cannot resolve locally makes `worktree add -b` guess a remote + // branch and create that instead of the new branch, so the start point is + // always a commit resolved here, with no fetch. + const resolveLocalBase = Effect.fnUntraced(function* ( + cwd: string, + baseRef: string, + hasOrigin: boolean, + label: string, + ) { + const looksLikeOption = baseRef.startsWith("-"); + const resolved = looksLikeOption + ? { ok: false, stdout: "" } + : yield* probe(cwd, "resolveBaseRef", [ + "rev-parse", + "--verify", + "--quiet", + `${baseRef}^{commit}`, + ]); + if (resolved.ok && resolved.stdout !== "") return resolved.stdout; + const onOrigin = + hasOrigin && + !looksLikeOption && + (yield* probe(cwd, "remoteBaseExists", [ + "show-ref", + "--verify", + "--quiet", + `refs/remotes/origin/${baseRef}`, + ])).ok; + return yield* new RepoWorktreeRequestError({ + reason: "base_missing", + baseRef, + path: label, + remote: onOrigin, + }); + }); + + const checkedOutBranch = (worktreePath: string) => + probe(worktreePath, "verifyBranch", ["symbolic-ref", "--quiet", "HEAD"]).pipe( + Effect.map((head) => + head.ok && head.stdout.startsWith("refs/heads/") + ? head.stdout.slice("refs/heads/".length) + : undefined, + ), + ); + + const resolveRepository = Effect.fnUntraced(function* ( + workspaceRoot: string, + repository: string, + ) { + const root = yield* realPathOr(workspaceRoot); + const requested = path.isAbsolute(repository) + ? repository + : path.resolve(workspaceRoot, repository); + const exists = yield* fileSystem.exists(requested).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return yield* new RepoWorktreeRequestError({ reason: "not_a_repository", path: repository }); + } + const candidate = yield* realPathOr(requested); + if (!insideOrEqual(root, candidate)) { + return yield* new RepoWorktreeRequestError({ reason: "outside_project", path: repository }); + } + const toplevel = yield* probe(candidate, "resolveRepository", ["rev-parse", "--show-toplevel"]); + if (!toplevel.ok || toplevel.stdout === "") { + return yield* new RepoWorktreeRequestError({ reason: "not_a_repository", path: repository }); + } + const repositoryPath = yield* realPathOr(toplevel.stdout); + if (!insideOrEqual(root, repositoryPath)) { + return yield* new RepoWorktreeRequestError({ reason: "outside_project", path: repository }); + } + const commonDir = yield* run(repositoryPath, "resolveRepository", [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ]).pipe(Effect.flatMap((result) => realPathOr(result.stdout.trim()))); + const relative = path.relative(root, repositoryPath); + return { + repositoryPath, + commonDir, + label: relative === "" ? "." : relative.split(path.sep).join("/"), + }; + }); + + const listWorktrees = Effect.fnUntraced(function* (repositoryPath: string) { + const listed = yield* git.execute({ + operation: "RepoWorktreeService.listWorktrees", + cwd: repositoryPath, + args: ["worktree", "list", "--porcelain", "-z"], + allowNonZeroExit: true, + timeoutMs: 30_000, + }); + // Git before 2.36 has no -z for this command. + const stdout = + listed.exitCode === 0 + ? listed.stdout + : (yield* run(repositoryPath, "listWorktrees", [ + "worktree", + "list", + "--porcelain", + ])).stdout.replaceAll("\n", "\0"); + return parseWorktreeList(stdout); + }); + + // Without -z, porcelain v1 quotes paths with control characters and keeps a + // rename on one line, so each line is one changed file. Lines are counted as + // they stream, so the count stays exact however large the output is. + const countChangedFiles = Effect.fnUntraced(function* ( + cwd: string, + operation: string, + untrackedFiles: "normal" | "no", + timeoutMs: number, + ) { + let count = 0; + yield* git.execute({ + operation: `RepoWorktreeService.${operation}`, + cwd, + args: ["status", "--porcelain=v1", `--untracked-files=${untrackedFiles}`], + timeoutMs, + maxOutputBytes: 4_096, + appendTruncationMarker: true, + keepLineCallbacksAfterTruncation: true, + progress: { + onStdoutLine: () => + Effect.sync(() => { + count++; + }), + }, + }); + return count; + }); + + const allocateWorktreePath = Effect.fnUntraced(function* ( + worktreesDirectory: string, + repositoryPath: string, + branch: string, + ) { + const root = resolveWorktreesDirectory( + worktreesDirectory, + config.worktreesDir, + path, + yield* HostProcess.HomeDirectory, + ); + if (root === null) { + return yield* new RepoWorktreeRequestError({ reason: "location_invalid" }); + } + // Same layout as other managed worktrees. A folder already there may belong + // to another repository with the same name, so it is skipped, never reused. + const base = path.join(root, path.basename(repositoryPath), branch.replace(/\//g, "-")); + for (let attempt = 1; attempt <= 50; attempt++) { + const candidate = attempt === 1 ? base : `${base}-${attempt}`; + if (!(yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => true)))) { + return candidate; + } + } + return yield* new RepoWorktreeRequestError({ reason: "path_unavailable", path: base }); + }); + + /** + * Removes a checkout unless a thread has it attached. Callers hold the + * repository lease, which ensure attaches under, so the answer still holds + * when Git removes the folder. + */ + const removeUnattached = Effect.fnUntraced(function* (input: VcsRemoveWorktreeInput) { + const target = yield* pathIdentity(input.path); + const attachments = yield* projections + .getThreadRepoWorktreePaths() + .pipe( + Effect.mapError((cause) => new RepoWorktreeReadError({ operation: "attachments", cause })), + ); + for (const attachment of attachments) { + if ((yield* pathIdentity(attachment.worktreePath)) === target) { + return yield* new RepoWorktreeInUseError({ + path: input.path, + threadId: attachment.threadId, + threadTitle: attachment.title, + }); + } + } + yield* gitWorkflow.removeWorktree(input); + }); + + const dispatchAttach = Effect.fn("RepoWorktreeService.dispatchAttach")( + function* ( + threadId: ThreadId, + repoWorktree: ThreadRepoWorktree, + options: { readonly relabelOnly?: boolean } = {}, + ) { + const uuid = yield* random.uuidV4().pipe(Effect.orDie); + yield* threadManagement.dispatch({ + type: "thread.repo-worktree.attach", + commandId: CommandId.make(`repo-worktree:attach:${uuid}`), + threadId, + repoWorktree, + ...(options.relabelOnly === true ? { relabelOnly: true } : {}), + }); + }, + (effect, threadId) => + effect.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.fail( + new RepoWorktreeDispatchError({ + threadId, + operation: "attach", + cause: Cause.squash(cause), + }), + ), + ), + ), + ); + + // The snapshot the run's checkpoints compare against: the baseline of the + // run in progress, or of the next run when attached between turns. + const captureAttachBaseline = (threadId: ThreadId, repoWorktree: ThreadRepoWorktree) => + threadManagement.getThreadRecords(threadId, ["runs"]).pipe( + Effect.flatMap(({ runs }) => { + const open = runs + .filter((run) => !SETTLED_RUN_STATUSES.has(run.status)) + .map((run) => run.ordinal); + const ordinalWithinScope = + open.length > 0 + ? Math.max(0, Math.min(...open) - 1) + : Math.max(0, ...runs.map((run) => run.ordinal)); + return checkpoints.captureRepositoryBaseline({ + threadId, + repoWorktree, + ordinalWithinScope, + runs, + }); + }), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.logWarning("repository worktree baseline was not captured", { threadId }), + ), + ); + + const ensureLocked = Effect.fnUntraced(function* ( + input: RepoWorktreeEnsureInput, + context: Effect.Success>, + repository: Effect.Success>, + ) { + const { thread, settings } = context; + const cwd = repository.repositoryPath; + const branchRef = `refs/heads/${input.branch}`; + const valid = yield* probe(cwd, "checkBranchName", [ + "check-ref-format", + "--branch", + input.branch, + ]); + if (!valid.ok) { + return yield* new RepoWorktreeRequestError({ + reason: "invalid_branch", + branch: input.branch, + }); + } + const worktrees = yield* listWorktrees(cwd); + const [main, ...linked] = worktrees; + const existing = linked.find((entry) => entry.branchRef === branchRef && !entry.prunable); + // The repository in the folder may itself be a linked worktree of a checkout elsewhere. + if ( + main?.branchRef === branchRef || + (existing !== undefined && + (yield* pathIdentity(existing.path)) === (yield* pathIdentity(cwd))) + ) { + return yield* new RepoWorktreeRequestError({ + reason: "branch_in_source_checkout", + branch: input.branch, + path: cwd, + }); + } + const attachedAt = DateTime.formatIso(yield* DateTime.now); + + if (existing !== undefined) { + const worktreePath = path.resolve(existing.path); + const uncommittedFiles = yield* countChangedFiles( + worktreePath, + "countUncommittedFiles", + "normal", + 30_000, + ); + const alreadyAttached = yield* attachedWorktree(thread, worktreePath); + // An entry whose checkout was switched to this branch is relabeled, so + // the thread never lists a branch the checkout does not have. + const repoWorktree: ThreadRepoWorktree = + alreadyAttached === undefined + ? { + repository: repository.label, + repositoryPath: cwd, + worktreePath, + branch: input.branch, + baseRef: null, + origin: "reused", + attachedAt, + } + : { ...alreadyAttached, branch: input.branch }; + if (alreadyAttached?.branch !== input.branch) { + yield* dispatchAttach(thread.id, repoWorktree); + // Its uncommitted edits predate this thread, so they are the baseline. + yield* captureAttachBaseline(thread.id, repoWorktree); + } + return { + status: "reused", + branchCreated: false, + alreadyAttached: alreadyAttached !== undefined, + uncommittedFiles, + startedFromOrigin: false, + repoWorktree, + } satisfies RepoWorktreeEnsureResult; + } + + const localExists = (yield* probe(cwd, "branchExists", [ + "show-ref", + "--verify", + "--quiet", + branchRef, + ])).ok; + const hasOrigin = yield* gitWorkflow.remoteExists({ cwd, remoteName: "origin" }); + // The setting only applies where an origin exists; an explicit request + // without one fails in git instead of silently starting locally. + const startFromOrigin = + input.startFromOrigin ?? (settings.newWorktreesStartFromOrigin && hasOrigin); + if (startFromOrigin && !localExists) { + yield* gitWorkflow.fetchRemote({ cwd, remoteName: "origin" }); + } + const remoteExists = + !localExists && + hasOrigin && + (yield* probe(cwd, "remoteBranchExists", [ + "show-ref", + "--verify", + "--quiet", + `refs/remotes/origin/${input.branch}`, + ])).ok; + + let plan: { + readonly refName: string; + readonly newRefName?: string; + readonly baseRefName?: string; + readonly baseRef: string | null; + readonly startedFromOrigin: boolean; + }; + if (localExists || remoteExists) { + if (input.useExistingBranch !== true) { + return yield* new RepoWorktreeRequestError({ + reason: "branch_exists", + branch: input.branch, + path: repository.label, + remote: !localExists, + }); + } + plan = localExists + ? { refName: input.branch, baseRef: null, startedFromOrigin: false } + : { + // The full name sets origin's branch as upstream, which a commit would not. + refName: `refs/remotes/origin/${input.branch}`, + newRefName: input.branch, + baseRef: `origin/${input.branch}`, + startedFromOrigin: true, + }; + } else { + if (input.useExistingBranch === true) { + return yield* new RepoWorktreeRequestError({ + reason: "branch_missing", + branch: input.branch, + path: repository.label, + }); + } + if (input.baseRef === undefined) { + return yield* new RepoWorktreeRequestError({ + reason: "base_ref_required", + branch: input.branch, + path: repository.label, + }); + } + const startRef = startFromOrigin + ? (yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd, + refName: input.baseRef, + fallbackRemoteName: "origin", + })).commitSha + : yield* resolveLocalBase(cwd, input.baseRef, hasOrigin, repository.label); + plan = { + refName: startRef, + newRefName: input.branch, + baseRefName: input.baseRef, + baseRef: input.baseRef, + startedFromOrigin: startFromOrigin, + }; + } + + // A worktree folder deleted outside git still holds its branch; its record + // only refers to a missing folder, so dropping it loses nothing. + if (linked.some((entry) => entry.prunable && entry.branchRef === branchRef)) { + yield* gitWorkflow.pruneWorktrees({ cwd }); + } + const worktreePath = yield* allocateWorktreePath( + settings.worktreesDirectory, + cwd, + input.branch, + ); + + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const created = yield* restore( + gitWorkflow.createWorktree( + { + cwd, + refName: plan.refName, + ...(plan.newRefName === undefined ? {} : { newRefName: plan.newRefName }), + ...(plan.baseRefName === undefined ? {} : { baseRefName: plan.baseRefName }), + path: worktreePath, + }, + { submodules: settings.worktreeSubmodules }, + ), + ); + const repoWorktree: ThreadRepoWorktree = { + repository: repository.label, + repositoryPath: cwd, + worktreePath: path.resolve(created.worktree.path), + branch: input.branch, + baseRef: plan.baseRef, + origin: "created", + attachedAt, + }; + // Already under this repository's lease. An attach that committed + // despite reporting failure keeps the checkout and its branch. + const rollback = removeUnattached({ + cwd, + path: repoWorktree.worktreePath, + force: true, + }).pipe( + Effect.andThen( + plan.newRefName === undefined + ? Effect.void + : Effect.gen(function* () { + const created = yield* probe(cwd, "branchExists", [ + "show-ref", + "--verify", + "--quiet", + branchRef, + ]); + if (created.ok) { + yield* gitWorkflow.deleteLocalBranch({ + cwd, + refName: input.branch, + force: true, + }); + } + }), + ), + Effect.ignoreCause({ log: true }), + ); + // The result names a branch, so it must be the one Git checked out. + const actualBranch = yield* checkedOutBranch(repoWorktree.worktreePath).pipe( + Effect.onError(() => rollback), + ); + if (actualBranch !== input.branch) { + yield* rollback; + return yield* new RepoWorktreeRequestError({ + reason: "branch_mismatch", + branch: input.branch, + path: repoWorktree.worktreePath, + ...(actualBranch === undefined ? {} : { actualBranch }), + }); + } + // Interrupt-only causes keep the checkout: the attach may have + // committed, and deleting a worktree the thread lists is worse than + // leaving one behind. + yield* dispatchAttach(thread.id, repoWorktree).pipe( + Effect.onError((cause) => (Cause.hasInterruptsOnly(cause) ? Effect.void : rollback)), + ); + yield* restore(captureAttachBaseline(thread.id, repoWorktree)); + return { + status: "created", + branchCreated: plan.newRefName !== undefined, + alreadyAttached: false, + uncommittedFiles: 0, + startedFromOrigin: plan.startedFromOrigin, + repoWorktree, + } satisfies RepoWorktreeEnsureResult; + }), + ); + }); + + const ensure: RepoWorktreeService["Service"]["ensure"] = Effect.fn("RepoWorktreeService.ensure")( + function* (input) { + const context = yield* loadEnabledContext(input.threadId); + const repository = yield* resolveRepository(context.project.workspaceRoot, input.repository); + return yield* withRepositoryLease( + pathKey(repository.commonDir), + ensureLocked(input, context, repository), + ); + }, + ); + + const removeWorktree: RepoWorktreeService["Service"]["removeWorktree"] = Effect.fn( + "RepoWorktreeService.removeWorktree", + )(function* (input) { + const commonDir = yield* probe(input.cwd, "removeWorktree.commonDir", [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ]).pipe(Effect.orElseSucceed(() => ({ ok: false, stdout: "" }))); + const leaseKey = yield* pathIdentity( + commonDir.ok && commonDir.stdout !== "" ? commonDir.stdout : input.cwd, + ); + yield* withRepositoryLease(leaseKey, removeUnattached(input)); + }); + + const detach: RepoWorktreeService["Service"]["detach"] = Effect.fn("RepoWorktreeService.detach")( + function* (input) { + const thread = yield* loadThread(input.threadId); + const attached = yield* attachedWorktree(thread, input.worktreePath); + if (attached === undefined) return { detached: false, worktreePath: input.worktreePath }; + const uuid = yield* random.uuidV4().pipe(Effect.orDie); + yield* threadManagement + .dispatch({ + type: "thread.repo-worktree.detach", + commandId: CommandId.make(`repo-worktree:detach:${uuid}`), + threadId: input.threadId, + worktreePath: attached.worktreePath, + }) + .pipe( + Effect.mapError( + (cause) => + new RepoWorktreeDispatchError({ + threadId: input.threadId, + operation: "detach", + cause, + }), + ), + ); + return { detached: true, worktreePath: attached.worktreePath }; + }, + ); + + const recordBranch: RepoWorktreeService["Service"]["recordBranch"] = Effect.fn( + "RepoWorktreeService.recordBranch", + )(function* (input) { + const target = yield* pathIdentity(input.cwd); + const attachments = yield* projections + .getThreadRepoWorktreePaths() + .pipe( + Effect.mapError((cause) => new RepoWorktreeReadError({ operation: "attachments", cause })), + ); + const threadIds = new Set(); + for (const attachment of attachments) { + if ((yield* pathIdentity(attachment.worktreePath)) === target) { + threadIds.add(attachment.threadId); + } + } + if (threadIds.size === 0) return; + // A detached HEAD keeps the branch it was last on. + const branch = yield* checkedOutBranch(input.cwd); + if (branch === undefined) return; + for (const threadId of threadIds) { + const attached = yield* attachedWorktree(yield* loadThread(threadId), input.cwd); + if (attached === undefined || attached.branch === branch) continue; + // Refused when a detach landed first, which leaves nothing to relabel. + yield* dispatchAttach(threadId, { ...attached, branch }, { relabelOnly: true }).pipe( + Effect.catchTags({ + RepoWorktreeDispatchError: (error) => + Effect.logWarning("repository worktree branch was not relabeled", { + threadId, + cause: error.cause, + }), + }), + ); + } + }); + + const repositoryOriginUrl = (cwd: string) => + probe(cwd, "originUrl", ["config", "--get", "remote.origin.url"]).pipe( + Effect.map((result) => + result.ok && result.stdout !== "" ? result.stdout.replace(/(\.git)?\/*$/, "") : null, + ), + ); + + const setSubmoduleCommit: RepoWorktreeService["Service"]["setSubmoduleCommit"] = Effect.fn( + "RepoWorktreeService.setSubmoduleCommit", + )(function* (input) { + const { thread } = yield* loadEnabledContext(input.threadId); + const parent = yield* requireAttached(thread, input.worktreePath); + const source = yield* requireAttached(thread, input.sourceWorktreePath); + // One pin at a time per parent, so two calls cannot interleave checkouts. + return yield* repositoryLocks.withLock( + `submodules:${pathKey(parent.worktreePath)}`, + pinSubmodule(input, parent, source), + ); + }); + + const pinSubmodule = Effect.fnUntraced(function* ( + input: RepoWorktreeSetSubmoduleInput, + parent: ThreadRepoWorktree, + source: ThreadRepoWorktree, + ) { + const submodulePath = input.submodulePath.replace(/\\/g, "/").replace(/\/+$/, ""); + if ( + submodulePath === "" || + path.isAbsolute(submodulePath) || + submodulePath.split("/").some((segment) => segment === ".." || segment === ".") + ) { + return yield* new RepoWorktreeRequestError({ + reason: "not_a_submodule", + path: input.submodulePath, + }); + } + + // The index entry, not the checkout, is what the parent records. + const indexEntry = (yield* run(parent.worktreePath, "readGitlink", [ + "ls-files", + "--stage", + "-z", + "--", + submodulePath, + ])).stdout + .split("\0") + .map((record) => /^(\d+) ([0-9a-f]+) \d+\t(.*)$/.exec(record)) + .find((match) => match !== null && match[3] === submodulePath); + if (indexEntry == null || indexEntry[1] !== "160000") { + return yield* new RepoWorktreeRequestError({ + reason: "not_a_submodule", + path: submodulePath, + }); + } + const previousCommit = indexEntry[2] ?? null; + + const commit = (yield* run(source.worktreePath, "resolveCommit", [ + "rev-parse", + "--verify", + `${input.commit ?? "HEAD"}^{commit}`, + ])).stdout.trim(); + + const submoduleDir = path.join(parent.worktreePath, ...submodulePath.split("/")); + const isCheckout = Effect.gen(function* () { + const toplevel = yield* probe(submoduleDir, "submoduleToplevel", [ + "rev-parse", + "--show-toplevel", + ]); + return ( + toplevel.ok && + sameWorktree(yield* realPathOr(toplevel.stdout), yield* realPathOr(submoduleDir)) + ); + }).pipe(Effect.orElseSucceed(() => false)); + if (!(yield* isCheckout)) { + yield* run(parent.worktreePath, "initSubmodule", [ + "submodule", + "update", + "--init", + "--", + submodulePath, + ]); + } + + // Same history, or the same origin, before any objects move between them. + const related = + previousCommit !== null && + (yield* probe(source.worktreePath, "relatedHistory", [ + "cat-file", + "-e", + `${previousCommit}^{commit}`, + ])).ok; + if (!related) { + const [submoduleOrigin, sourceOrigin] = yield* Effect.all([ + repositoryOriginUrl(submoduleDir), + repositoryOriginUrl(source.worktreePath), + ]); + if (submoduleOrigin === null || submoduleOrigin !== sourceOrigin) { + return yield* new RepoWorktreeRequestError({ + reason: "source_mismatch", + path: submodulePath, + }); + } + } + + if ((yield* countChangedFiles(submoduleDir, "submoduleChanges", "no", 60_000)) > 0) { + return yield* new RepoWorktreeRequestError({ + reason: "submodule_changes", + path: submodulePath, + }); + } + + const present = (yield* probe(submoduleDir, "hasCommit", [ + "cat-file", + "-e", + `${commit}^{commit}`, + ])).ok; + if (!present) { + // A one-off fetch from the local repository: no remote is added and no + // URL changes, so the parent's submodule keeps pointing at its origin. + const sourceCommonDir = (yield* run(source.worktreePath, "sourceCommonDir", [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ])).stdout.trim(); + yield* run(submoduleDir, "fetchLocalCommit", [ + "fetch", + "--quiet", + "--no-tags", + "--no-write-fetch-head", + sourceCommonDir, + commit, + ]); + } + // The status check skips ignored files, so Git must refuse to overwrite them. + yield* run(submoduleDir, "checkoutCommit", [ + "checkout", + "--quiet", + "--no-overwrite-ignore", + "--detach", + commit, + ]); + const stage = input.stage ?? true; + if (stage) { + yield* run(parent.worktreePath, "stageGitlink", ["add", "--", submodulePath]); + } + yield* gitWorkflow.invalidateStatus(parent.worktreePath); + return { + worktreePath: parent.worktreePath, + submodulePath, + commit, + previousCommit, + fetched: !present, + staged: stage, + } satisfies RepoWorktreeSetSubmoduleResult; + }); + + return RepoWorktreeService.of({ + ensure, + detach, + recordBranch, + setSubmoduleCommit, + removeWorktree, + }); +}); + +export const layer = Layer.effect(RepoWorktreeService, make); diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 90cf3c7f8401a..a19390d8df3f8 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -23,6 +23,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as RepoWorktreeService from "../git/RepoWorktreeService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ServerConfig from "../config.ts"; import { @@ -114,6 +115,8 @@ interface HarnessOptions { readonly fetchWorktreeBaseFails?: boolean; readonly worktreeBaseMissing?: boolean; readonly removeWorktreeFails?: boolean; + /** Another thread attached the new checkout before the rollback. */ + readonly removalRefusedInUse?: boolean; readonly deleteLocalBranchFails?: boolean; readonly createWorktreeGate?: Effect.Effect; readonly existsFails?: boolean; @@ -331,6 +334,18 @@ const makeHarness = (options: HarnessOptions = {}) => { Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ refreshStatus, } satisfies Partial), + Layer.mock(RepoWorktreeService.RepoWorktreeService)({ + removeWorktree: (input) => + options.removalRefusedInUse + ? Effect.fail( + new RepoWorktreeService.RepoWorktreeInUseError({ + path: input.path, + threadId: "thread-folder", + threadTitle: "Folder thread", + }), + ) + : removeWorktree(input), + }), NodeServices.layer, NodeCrypto.layer, options.existsFails @@ -642,6 +657,16 @@ describe("t3_worktree_handoff", () => { }), ); + it.effect("keeps the checkout and branch when another thread attached them first", () => { + const harness = makeHarness({ dispatchFails: true, removalRefusedInUse: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/attached-race" })); + expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); + expect(harness.removeWorktree).not.toHaveBeenCalled(); + expect(harness.deleteLocalBranch).not.toHaveBeenCalled(); + }); + }); + it.effect("creates the worktree in the managed directory even when a call names a path", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 0ee45bb7f8bb3..9d5e37d9130e5 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -7,6 +7,12 @@ import { type WorktreeMcpContinuationStatus, type WorktreeMcpHandoffInput, type WorktreeMcpHandoffResult, + type WorktreeMcpRepoDetachInput, + type WorktreeMcpRepoDetachResult, + type WorktreeMcpRepoEnsureInput, + type WorktreeMcpRepoEnsureResult, + type WorktreeMcpRepoSetSubmoduleInput, + type WorktreeMcpRepoSetSubmoduleResult, type WorktreeMcpSetupScriptStatus, type WorktreeMcpStatusResult, type VcsListRefsInput, @@ -23,6 +29,7 @@ import * as Option from "effect/Option"; import type * as PlatformError from "effect/PlatformError"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as RepoWorktreeService from "../git/RepoWorktreeService.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; @@ -40,6 +47,18 @@ export class WorktreeMcpService extends Context.Service< readonly status: ( scope: McpInvocationScope, ) => Effect.Effect; + readonly ensureRepoWorktree: ( + scope: McpInvocationScope, + input: WorktreeMcpRepoEnsureInput, + ) => Effect.Effect; + readonly detachRepoWorktree: ( + scope: McpInvocationScope, + input: WorktreeMcpRepoDetachInput, + ) => Effect.Effect; + readonly setRepoWorktreeSubmodule: ( + scope: McpInvocationScope, + input: WorktreeMcpRepoSetSubmoduleInput, + ) => Effect.Effect; readonly listRefs: ( input: Omit & { readonly worktreePath: string | null; @@ -75,6 +94,7 @@ const make = Effect.gen(function* () { const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const setupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; // Serialize handoffs per thread to avoid creating two worktrees and orphaning one. const handoffThreadsInFlight = new Set(); @@ -267,8 +287,10 @@ const make = Effect.gen(function* () { }).pipe(Effect.as({ status: "failed", detail } as const)); }); + // The new branch exists from here, so a folder thread may attach its + // checkout before the rollback; the guard then keeps both for it. const removeCreatedWorktree = Effect.suspend(() => - gitWorkflow.removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }).pipe( + repoWorktrees.removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }).pipe( Effect.andThen( Effect.suspend(() => gitWorkflow.deleteLocalBranch({ @@ -433,19 +455,77 @@ const make = Effect.gen(function* () { const projection = yield* loadThread(scope); const project = yield* loadProject(scope, projection.thread.projectId); - const defaultStartFromOrigin = yield* readDefaultStartFromOrigin(project.id); + const settings = yield* serverSettings.getSettings.pipe( + Effect.map( + (current) => resolveProjectSettings(current, projection.thread.projectId).settings, + ), + asOperationFailed("Unable to read server settings"), + ); const result: WorktreeMcpStatusResult = { attached: projection.thread.worktreePath !== null, worktreePath: projection.thread.worktreePath, branch: projection.thread.branch, projectWorkspaceRoot: project.workspaceRoot, - defaultStartFromOrigin, + defaultStartFromOrigin: settings.newWorktreesStartFromOrigin, + repoWorktreesEnabled: settings.repoWorktrees, + repoWorktrees: projection.thread.repoWorktrees ?? [], }; return result; }, ); + const actAsThread = (callerScope: McpInvocationScope) => + requireCapability(callerScope).pipe(Effect.andThen(requireThreadScope(callerScope))); + + const ensureRepoWorktree: WorktreeMcpService["Service"]["ensureRepoWorktree"] = Effect.fn( + "WorktreeMcpService.ensureRepoWorktree", + )(function* (callerScope, input) { + const scope = yield* actAsThread(callerScope); + const result = yield* repoWorktrees + .ensure({ threadId: scope.thread.threadId, ...input }) + .pipe(Effect.mapError(repoWorktreeFailure)); + const { worktreePath, branch } = result.repoWorktree; + const note = + result.status === "reused" + ? `Reused the existing worktree for '${branch}' at ${worktreePath} as it is${ + result.uncommittedFiles > 0 + ? `, with ${result.uncommittedFiles} uncommitted ${result.uncommittedFiles === 1 ? "file" : "files"} kept` + : "" + }. Work there. If you meant to start new, independent work, detach it and choose a new branch name.` + : `Created ${worktreePath} on ${result.branchCreated ? "new" : "existing"} branch '${branch}'. Make every edit, build, and commit for this repository there. The thread stays in its folder.`; + return { ...result, note } satisfies WorktreeMcpRepoEnsureResult; + }); + + const detachRepoWorktree: WorktreeMcpService["Service"]["detachRepoWorktree"] = Effect.fn( + "WorktreeMcpService.detachRepoWorktree", + )(function* (callerScope, input) { + const scope = yield* actAsThread(callerScope); + const result = yield* repoWorktrees + .detach({ threadId: scope.thread.threadId, worktreePath: input.worktreePath }) + .pipe(Effect.mapError(repoWorktreeFailure)); + return { + ...result, + note: result.detached + ? "Detached. The folder, branch, and any edits are unchanged." + : "That worktree was not attached to this thread; nothing changed.", + } satisfies WorktreeMcpRepoDetachResult; + }); + + const setRepoWorktreeSubmodule: WorktreeMcpService["Service"]["setRepoWorktreeSubmodule"] = + Effect.fn("WorktreeMcpService.setRepoWorktreeSubmodule")(function* (callerScope, input) { + const scope = yield* actAsThread(callerScope); + const result = yield* repoWorktrees + .setSubmoduleCommit({ threadId: scope.thread.threadId, ...input }) + .pipe(Effect.mapError(repoWorktreeFailure)); + return { + ...result, + note: `${result.submodulePath} in ${result.worktreePath} is now at ${result.commit} (detached HEAD)${ + result.staged ? " and the gitlink is staged" : "" + }. Commit it in that worktree with ordinary git. Push the submodule branch before pushing the parent so others can fetch the commit.`, + } satisfies WorktreeMcpRepoSetSubmoduleResult; + }); + const listRefs: WorktreeMcpService["Service"]["listRefs"] = Effect.fn( "WorktreeMcpService.listRefs", )(function* ({ worktreePath, workspaceRoot, ...refs }) { @@ -457,9 +537,51 @@ const make = Effect.gen(function* () { return yield* gitWorkflow.listRefs({ ...refs, cwd }); }); - return WorktreeMcpService.of({ handoff, status, listRefs }); + return WorktreeMcpService.of({ + handoff, + status, + listRefs, + ensureRepoWorktree, + detachRepoWorktree, + setRepoWorktreeSubmodule, + }); }); +/** Domain errors carry their own wording; only tool parameters are added here. */ +function repoWorktreeFailure(error: RepoWorktreeService.RepoWorktreeFailure): WorktreeMcpFailure { + switch (error._tag) { + case "RepoWorktreesDisabledError": + return failure("not_enabled", error.message); + case "RepoWorktreeThreadUnavailableError": + return failure( + error.state === "missing" ? "thread_not_found" : "invalid_request", + error.message, + ); + case "RepoWorktreeRequestError": + return failure("invalid_request", `${error.message}${requestHint(error)}`); + case "RepoWorktreeDispatchError": + case "RepoWorktreeReadError": + return failure("operation_failed", error.message); + case "GitCommandError": + return failure("operation_failed", error.message); + } +} + +function requestHint(error: RepoWorktreeService.RepoWorktreeRequestError): string { + switch (error.reason) { + case "branch_exists": + return " To continue it, call again with useExistingBranch:true."; + case "base_ref_required": + return " Pass baseRef."; + case "base_missing": + return error.remote === true + ? ` Pass baseRef 'origin/${error.baseRef}' to start from origin's last fetched commit, or startFromOrigin:true to fetch it first.` + : ""; + default: + return ""; + } +} + export const layer: Layer.Layer< WorktreeMcpService, never, @@ -471,4 +593,5 @@ export const layer: Layer.Layer< | GitWorkflowService.GitWorkflowService | ProjectSetupScriptRunner.ProjectSetupScriptRunner | VcsStatusBroadcaster.VcsStatusBroadcaster + | RepoWorktreeService.RepoWorktreeService > = Layer.effect(WorktreeMcpService, make); diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index 2e78dd2a4f996..84bdaf832d66c 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -53,6 +53,27 @@ const handlers = { return yield* service.status(scope); }), ), + t3_repo_worktree_ensure: McpToolAccess.actsAsCaller((input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* WorktreeMcpService.WorktreeMcpService; + return yield* service.ensureRepoWorktree(scope, input); + }), + ), + t3_repo_worktree_detach: McpToolAccess.actsAsCaller((input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* WorktreeMcpService.WorktreeMcpService; + return yield* service.detachRepoWorktree(scope, input); + }), + ), + t3_repo_worktree_set_submodule: McpToolAccess.actsAsCaller((input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* WorktreeMcpService.WorktreeMcpService; + return yield* service.setRepoWorktreeSubmodule(scope, input); + }), + ), } satisfies McpToolAccess.Handlers; export const layer = McpToolAccess.toLayer(WorktreeToolkit, handlers); diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 18d6840d72291..2169aaa3e31fb 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -6,6 +6,12 @@ import { VcsListRefsResult, WorktreeMcpHandoffInput, WorktreeMcpHandoffResult, + WorktreeMcpRepoDetachInput, + WorktreeMcpRepoDetachResult, + WorktreeMcpRepoEnsureInput, + WorktreeMcpRepoEnsureResult, + WorktreeMcpRepoSetSubmoduleInput, + WorktreeMcpRepoSetSubmoduleResult, WorktreeMcpStatusResult, ThreadId, } from "@t3tools/contracts"; @@ -82,8 +88,56 @@ const WorktreeListTool = Tool.make("t3_worktree_list", { }) .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false); +const RepoWorktreeEnsureTool = Tool.make("t3_repo_worktree_ensure", { + description: + "For a project with Multi-Repository Project Folder on. Attaches a worktree of one Git repository in the project folder to this thread: reuses the worktree on this exact branch with its uncommitted edits as they are, or creates one. `repository` is a path relative to the folder, such as 'service/framework'. A new branch needs `baseRef`. Work in the returned `worktreePath`.", + parameters: WorktreeMcpRepoEnsureInput, + success: WorktreeMcpRepoEnsureResult, + failure: WorktreeToolFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Create or reuse a repository worktree") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +const RepoWorktreeDetachTool = Tool.make("t3_repo_worktree_detach", { + description: + "Detaches a repository worktree from this thread. Its folder, branch, and edits stay.", + parameters: WorktreeMcpRepoDetachInput, + success: WorktreeMcpRepoDetachResult, + failure: WorktreeToolFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Detach a repository worktree") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const RepoWorktreeSetSubmoduleTool = Tool.make("t3_repo_worktree_set_submodule", { + description: + "Points `submodulePath` in the parent repository worktree `worktreePath` at a commit (default HEAD) of `sourceWorktreePath`, the submodule's own worktree; both attached to this thread. Fetches it locally when missing (no push or remote change), checks it out detached, and stages the gitlink. Refuses a submodule with uncommitted changes.", + parameters: WorktreeMcpRepoSetSubmoduleInput, + success: WorktreeMcpRepoSetSubmoduleResult, + failure: WorktreeToolFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Pin a submodule to a worktree commit") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + export const WorktreeToolkit = Toolkit.make( WorktreeHandoffTool, WorktreeStatusTool, WorktreeListTool, + RepoWorktreeEnsureTool, + RepoWorktreeDetachTool, + RepoWorktreeSetSubmoduleTool, ); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 6e5fd3516e0b1..3696f8ac6364d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -334,7 +334,7 @@ describe("CodexAdapterV2 native protocol logging", () => { }); describe("CodexAdapterV2 rollback mapping", () => { - it.effect("derives native rollback count from durable provider turns", () => + it.effect("derives the native rollback boundary from durable provider turns", () => Effect.gen(function* () { const now = yield* DateTime.now; const providerThreadId = ProviderThreadId.make("provider-thread-codex-rollback"); @@ -387,7 +387,25 @@ describe("CodexAdapterV2 rollback mapping", () => { nativeTurnRef: null, }; - const numTurns = yield* CodexAdapterV2.resolveCodexRollbackTurnCount({ + const otherThreadTurn = { + ...providerTurn("provider-turn-other-thread", 6, "completed"), + providerThreadId: ProviderThreadId.make("provider-thread-other"), + }; + const steeredFirstTurn = { + ...providerTurn("provider-turn-steered-first", 7, "completed"), + nativeTurnRef: firstTurn.nativeTurnRef, + }; + const providerThreadTurns = [ + goalCommandTurn, + interruptedTurn, + runningTurn, + secondTurn, + firstTurn, + otherThreadTurn, + steeredFirstTurn, + ]; + + const boundary = yield* CodexAdapterV2.resolveCodexRollbackBoundary({ providerThread, target: { type: "provider_turn", @@ -395,10 +413,26 @@ describe("CodexAdapterV2 rollback mapping", () => { appRunOrdinal: 1, providerTurn: firstTurn, }, - providerThreadTurns: [goalCommandTurn, interruptedTurn, runningTurn, secondTurn, firstTurn], + providerThreadTurns, }); + assert.deepEqual([...boundary.keptTurnIds], ["native-provider-turn-first"]); + assert.deepEqual([...boundary.laterTurnIds].toSorted(), [ + "native-provider-turn-interrupted", + "native-provider-turn-running", + "native-provider-turn-second", + ]); - assert.equal(numTurns, 2); + const fromStart = yield* CodexAdapterV2.resolveCodexRollbackBoundary({ + providerThread, + target: { + type: "thread_start", + checkpointId: CheckpointId.make("start"), + appRunOrdinal: 0, + }, + providerThreadTurns, + }); + assert.equal(fromStart.keptTurnIds.size, 0); + assert.equal(fromStart.laterTurnIds.size, 4); }), ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 4e8fd1ac5f30b..b76e21358eba0 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1,6 +1,6 @@ import { pipe } from "effect/Function"; import * as DocumentText from "../../provider/DocumentText.ts"; -import { revertCodexThread } from "../../provider/CodexThreadRevert.ts"; +import { revertCodexThread, revertCodexThreadAfter } from "../../provider/CodexThreadRevert.ts"; import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; import { @@ -1080,36 +1080,44 @@ const readCodexThreadHistoryMetadata = Effect.fn("CodexAdapterV2.readThreadHisto }, ); -export const resolveCodexRollbackTurnCount = Effect.fn("CodexAdapterV2.resolveRollbackTurnCount")( +/** + * The native turns a rollback removes and keeps, by id. A count would cut + * from wherever native history is now, so a retry after a revert that already + * landed would remove kept turns too. + */ +export const resolveCodexRollbackBoundary = Effect.fn("CodexAdapterV2.resolveRollbackBoundary")( function* (input: ProviderAdapter.ProviderAdapterV2RollbackThreadInput) { - const providerTurns = input.providerThreadTurns; - switch (input.target.type) { - case "thread_start": - return providerTurns.filter(isTerminalProviderTurn).length; - case "provider_turn": { - if (input.target.providerTurn.providerThreadId !== input.providerThread.id) { - return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ - driver: CODEX_PROVIDER, - providerThreadId: input.providerThread.id, - cause: `Cannot roll back Codex thread ${input.providerThread.id} to provider turn ${input.target.providerTurn.id}: target turn belongs to provider thread ${input.target.providerTurn.providerThreadId}.`, - }); - } - - const rollbackTurnCount = countTerminalTurnsAfterBoundary( - providerTurns, - input.target.providerTurn.id, - ); - if (rollbackTurnCount === null) { - return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ - driver: CODEX_PROVIDER, - providerThreadId: input.providerThread.id, - cause: `Cannot roll back Codex thread ${input.providerThread.id} to provider turn ${input.target.providerTurn.id}: target turn was not found in durable provider turn history.`, - }); - } - - return rollbackTurnCount; + const providerTurns = providerTurnsForThread(input.providerThreadTurns, input.providerThread); + let keptOrdinal = Number.NEGATIVE_INFINITY; + if (input.target.type === "provider_turn") { + const targetTurn = input.target.providerTurn; + if (targetTurn.providerThreadId !== input.providerThread.id) { + return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: CODEX_PROVIDER, + providerThreadId: input.providerThread.id, + cause: `Cannot roll back Codex thread ${input.providerThread.id} to provider turn ${targetTurn.id}: target turn belongs to provider thread ${targetTurn.providerThreadId}.`, + }); } + const boundaryTurn = providerTurns.find((turn) => turn.id === targetTurn.id); + if (boundaryTurn === undefined) { + return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: CODEX_PROVIDER, + providerThreadId: input.providerThread.id, + cause: `Cannot roll back Codex thread ${input.providerThread.id} to provider turn ${targetTurn.id}: target turn was not found in durable provider turn history.`, + }); + } + keptOrdinal = boundaryTurn.ordinal; + } + const keptTurnIds = new Set(); + const laterTurnIds = new Set(); + for (const turn of providerTurns) { + const nativeId = turn.nativeTurnRef?.nativeId; + if (nativeId == null) continue; + (turn.ordinal <= keptOrdinal ? keptTurnIds : laterTurnIds).add(nativeId); } + // A later turn that continued a kept native turn must not remove it. + for (const nativeId of keptTurnIds) laterTurnIds.delete(nativeId); + return { laterTurnIds, keptTurnIds }; }, ); @@ -8372,12 +8380,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( rollbackThread: Effect.fn("CodexAdapterV2.rollbackThread")( function* (threadInput) { const threadId = yield* getNativeThreadId(threadInput.providerThread); - const numTurns = yield* resolveCodexRollbackTurnCount(threadInput); + const boundary = yield* resolveCodexRollbackBoundary(threadInput); const nativeConversationHeadRef = threadInput.target.type === "provider_turn" ? threadInput.target.providerTurn.nativeTurnRef : null; - if (numTurns === 0) { + if (boundary.laterTurnIds.size === 0) { return { providerThread: { ...threadInput.providerThread, @@ -8412,7 +8420,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); } const response = yield* ensureInitialized.pipe( - Effect.andThen(revertCodexThread(client, threadId, numTurns)), + Effect.andThen(revertCodexThreadAfter(client, threadId, boundary)), ); turnTokenUsageByThread.delete(threadId); return { diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index 828d438646cdf..6360d53f00997 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -1,4 +1,5 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CheckpointId, @@ -24,6 +25,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import { VcsProcessTimeoutError } from "@t3tools/contracts"; import * as CheckpointService from "./CheckpointService.ts"; import * as CheckpointCaptureService from "./CheckpointCaptureService.ts"; @@ -289,6 +291,8 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => { }), ), }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + NodeServices.layer, ), ), ) @@ -646,6 +650,8 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => { diffCheckpoints: () => Effect.succeed(""), listAuthoredPaths: () => Effect.succeed(null), }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + NodeServices.layer, ), ), ), @@ -1089,6 +1095,8 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => { captureCheckpoint: gitUnavailable, hasCheckpointRef: gitUnavailable, }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + NodeServices.layer, ), ), ), diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts index 43cdae25d4bcd..98dd86de648b0 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts @@ -103,6 +103,41 @@ export const layer: Layer.Layer< const baselineOrdinalWithinScope = Math.max(0, run.ordinal - 1); const isPreserved = (ordinalWithinScope: number) => preservedCheckpointOrdinals.includes(ordinalWithinScope); + const { + thread: { repoWorktrees }, + runs, + } = yield* projections.getThreadRecords(input.threadId, ["runs"]); + // Read only when worktrees are snapshot, so each keeps its earlier path prefix. + const earlier = + repoWorktrees === undefined || repoWorktrees.length === 0 || sideChat + ? undefined + : (yield* projections.getThreadRecords(input.threadId, ["checkpoints"])).checkpoints; + const checkpoint = yield* checkpoints.capture({ + scope, + runId: run.id, + nodeId: rootNode.id, + ordinalWithinScope: run.ordinal, + appRunOrdinal: run.ordinal, + capturedAt, + // A side chat shares its parent's checkouts, so it snapshots none of them. + ...(repoWorktrees === undefined || sideChat ? {} : { repoWorktrees }), + ...(earlier === undefined ? {} : { checkpoints: earlier }), + runs, + forceMissing: sideChat, + }); + const hasRepositories = (checkpoint.repositories?.length ?? 0) > 0; + const backfilled = hasRepositories + ? yield* projections.getThreadRecords(input.threadId, ["checkpoints", "runs"]).pipe( + Effect.flatMap((history) => + checkpoints.backfillRepositories({ + scope, + checkpoint, + checkpoints: history.checkpoints, + runs: history.runs, + }), + ), + ) + : []; // The baselines this capture materializes are attributed to the run materializing them. The // thread's scope may already be a later run's: one queued meanwhile, or sent after Stop. const materializeBaseline = (ordinalWithinScope: number) => @@ -111,22 +146,17 @@ export const layer: Layer.Layer< ordinalWithinScope, nodeId: rootNode.id, capturedAt: run.requestedAt, + runs, forceMissing: sideChat, }); const threadStartCheckpoint = - baselineOrdinalWithinScope === 0 || isPreserved(0) ? null : yield* materializeBaseline(0); - const baselineCheckpoint = isPreserved(baselineOrdinalWithinScope) - ? null - : yield* materializeBaseline(baselineOrdinalWithinScope); - const checkpoint = yield* checkpoints.capture({ - scope, - runId: run.id, - nodeId: rootNode.id, - ordinalWithinScope: run.ordinal, - appRunOrdinal: run.ordinal, - capturedAt, - forceMissing: sideChat, - }); + hasRepositories || baselineOrdinalWithinScope === 0 || isPreserved(0) + ? null + : yield* materializeBaseline(0); + const baselineCheckpoint = + hasRepositories || isPreserved(baselineOrdinalWithinScope) + ? null + : yield* materializeBaseline(baselineOrdinalWithinScope); // Omit delegatedCompletion to preserve cohort writes made after capture loaded the run. const { delegatedCompletion: _delegatedCompletion, ...runWithoutDelegatedCompletion } = run; const commandId = CommandId.make(`command:effect:checkpoint.capture:${run.id}`); @@ -137,6 +167,19 @@ export const layer: Layer.Layer< acceptedAt: capturedAt, effects: [], events: [ + ...(yield* Effect.forEach(backfilled, (earlier) => + Effect.map(ids.allocate.event({ threadId: input.threadId, commandId }), (id) => ({ + id, + type: "checkpoint.captured" as const, + threadId: input.threadId, + ...(earlier.runId === null ? {} : { runId: earlier.runId }), + nodeId: earlier.nodeId, + driver: providerThread.driver, + providerInstanceId: run.providerInstanceId, + occurredAt: capturedAt, + payload: earlier, + })), + )), ...(threadStartCheckpoint === null ? [] : [ diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts index 145de122f387a..45170d2c5bd3c 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts @@ -9,6 +9,7 @@ import { ProviderThreadId, ThreadId, } from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -17,7 +18,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; -import { isCheckpointRestoreIsolated } from "./CheckpointRestoreSafety.ts"; +import { isCheckpointRestoreIsolated, planCheckpointRestore } from "./CheckpointRestoreSafety.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { EventSinkV2 } from "./EventSink.ts"; @@ -34,6 +35,8 @@ it.effect.each([ "sibling", "shared-provider", "conversation", + "repository-worktree", + ...(symlinksSupported ? (["aliased-repository-worktree"] as const) : []), ] as const)("preserves overlapping workspace files, owner=%s", (owner) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -46,6 +49,15 @@ it.effect.each([ yield* fs.makeDirectory(sibling); const otherFile = path.join(nested, "other-thread.txt"); yield* fs.writeFileString(otherFile, "other thread's uncommitted work"); + const alias = path.join(parent, "alias"); + if (owner === "aliased-repository-worktree") yield* fs.symlink(cwd, alias); + // A folder thread reused this thread's own checkout as a repository worktree. + const repositoryAttachment = + owner === "repository-worktree" + ? cwd + : owner === "aliased-repository-worktree" + ? alias + : null; const threadId = ThreadId.make("restore-current"); const otherId = ThreadId.make("restore-other"); const projectId = ProjectId.make("restore-project"); @@ -119,6 +131,12 @@ it.effect.each([ checkpoints: [], checkpointScopes: [], }), + getThreadRepoWorktreePaths: () => + Effect.succeed( + repositoryAttachment === null + ? [] + : [{ threadId: otherId, title: "Folder", worktreePath: repositoryAttachment }], + ), getThreadProviderContext: () => Effect.succeed({ thread: otherThread as never, @@ -196,6 +214,7 @@ it.effect("lets rollback recreate a worktree storage cleanup removed", () => path, projections: { getShellSnapshot: () => Effect.succeed({ threads: [], archivedThreads: [] }), + getThreadRepoWorktreePaths: () => Effect.succeed([]), } as never, projects: {} as never, }, @@ -204,6 +223,55 @@ it.effect("lets rollback recreate a worktree storage cleanup removed", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +// Turn 1 saved the service worktree but Git failed on the thread's own worktree. +it.effect("refuses file restore when the thread worktree's snapshot failed", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-v2-restore-error-" }); + const worktreePath = path.join(parent, "worktree"); + const scopeId = CheckpointScopeId.make("restore-error-scope"); + const repository = { + repository: "service", + repositoryPath: path.join(parent, "service"), + worktreePath: path.join(parent, "service-work"), + branch: "work", + pathPrefix: "service", + ref: "refs/t3/test/service", + status: "ready", + }; + const checkpoint = (ordinal: number, workspaceStatus: "ready" | "error") => + ({ + id: CheckpointId.make(`restore-error-checkpoint-${ordinal}`), + scopeId, + status: "ready", + workspaceStatus, + ordinalWithinScope: ordinal, + repositories: [repository], + files: [{ path: "service/index.ts", kind: "modified", additions: 1, deletions: 1 }], + }) as never; + const target = checkpoint(1, "error"); + const plan = yield* planCheckpointRestore( + { + thread: { id: ThreadId.make("restore-error"), worktreePath, repoWorktrees: [] }, + scope: { id: scopeId, cwd: worktreePath }, + checkpoint: target, + checkpoints: [target, checkpoint(2, "ready")], + }, + { + fileSystem, + path, + projections: { + getShellSnapshot: () => Effect.succeed({ threads: [], archivedThreads: [] }), + getThreadRepoWorktreePaths: () => Effect.succeed([]), + } as never, + projects: {} as never, + }, + ); + assert.equal(plan._tag, "workspace-unavailable"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect.each([ { sideChat: "answering questions", running: false, toolItems: 0, isolated: true }, { sideChat: "running", running: true, toolItems: 0, isolated: false }, @@ -245,6 +313,7 @@ it.effect.each([ }), getCheckpointContext: () => Effect.succeed({ checkpointScopes: [] }), getThreadProviderContext: () => Effect.succeed({ providerSessions: [] }), + getThreadRepoWorktreePaths: () => Effect.succeed([]), } as never, projects: {} as never, }, diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts index b7dc08018a896..c01cc3a533f0a 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts @@ -1,106 +1,304 @@ -import type { OrchestrationV2AppThread, OrchestrationV2CheckpointScope } from "@t3tools/contracts"; +import type { + OrchestrationV2AppThread, + OrchestrationV2Checkpoint, + OrchestrationV2CheckpointScope, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import type * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import type * as Path from "effect/Path"; +import { + type CheckpointRepositoryIdentity, + checkpointWorkspaceFiles, + checkpointWorkspaceStatus, +} from "./CheckpointService.ts"; import type { ProjectionStoreV2 } from "./ProjectionStore.ts"; import type * as ProjectStore from "./ProjectStore.ts"; export const SHARED_WORKSPACE_RESTORE_MESSAGE = "File restore requires an isolated worktree. This workspace may contain changes from another thread or a side chat. Rewind the conversation without restoring files instead, or delete side chats you no longer need."; +export const sharedRepositoryRestoreMessage = ( + repository: Pick, +) => + `Another thread also works in the ${repository.repository} worktree at ${repository.worktreePath}, so files can't be restored. Rewind the conversation without restoring files instead.`; + +export const WORKSPACE_SNAPSHOT_FAILED_MESSAGE = + "The thread's worktree couldn't be saved at that point, so its files can't be restored. Rewind the conversation without restoring files instead."; + +interface RestoreSafetyDependencies { + readonly fileSystem: FileSystem.FileSystem; + readonly projections: ProjectionStoreV2["Service"]; + readonly projects: ProjectStore.ProjectStoreV2["Service"]; + readonly path: Path.Path; +} + +/** + * Real paths every other thread may write in: its workspace, its checkpoint + * scopes, live sessions, and its repository worktrees. Null when a thread's + * project is gone, so its workspace is unknown. + */ +const otherThreadPaths = Effect.fn("orchestrationV2.otherThreadPaths")(function* ( + thread: Pick, + dependencies: RestoreSafetyDependencies, +) { + const { fileSystem, projections, projects } = dependencies; + const candidates = new Set(); + const shell = yield* projections.getShellSnapshot(); + for (const otherThread of [...shell.threads, ...shell.archivedThreads]) { + if (otherThread.id === thread.id || otherThread.deletedAt !== null) continue; + // An idle side chat that only answered questions never touched the shared worktree. + if ( + otherThread.lineage.sideChat === true && + otherThread.activeRunId === null && + otherThread.worktreePath === thread.worktreePath + ) { + const { turnItems } = yield* projections.getThreadRecords(otherThread.id, ["turnItems"], { + turnItemTypes: ["command_execution", "file_change", "dynamic_tool"], + }); + if (turnItems.length === 0) continue; + } + const other = yield* projections.getCheckpointContext(otherThread.id); + const providerContext = yield* projections.getThreadProviderContext(otherThread.id); + for (const candidate of [ + otherThread.worktreePath, + ...other.checkpointScopes.map((scope) => scope.cwd), + // Errored sessions can still stream. Shared sessions retain another thread's opening cwd; + // turn worktrees and project roots are already covered here. + ...providerContext.providerSessions + .filter( + (session) => + session.status !== "stopped" && + !session.capabilities.sessions.supportsMultipleProviderThreadsPerSession, + ) + .map((session) => session.cwd), + ]) { + if (candidate !== null) candidates.add(candidate); + } + if (otherThread.worktreePath === null) { + const project = yield* projects.get(otherThread.projectId, { includeDeleted: true }); + if (Option.isNone(project)) return null; + candidates.add(project.value.workspaceRoot); + } + } + // A folder thread can attach another thread's own checkout as a repository worktree. + for (const attachment of yield* projections.getThreadRepoWorktreePaths()) { + if (attachment.threadId !== thread.id) candidates.add(attachment.worktreePath); + } + const resolved: Array = []; + for (const candidate of candidates) { + const real = yield* fileSystem.realPath(candidate).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + (_error) => Effect.succeed(null), + ), + ); + if (real !== null) resolved.push(real); + } + return resolved; +}); + +const restoreIsolation = Effect.fn("orchestrationV2.restoreIsolation")(function* ( + thread: Pick, + targets: { + readonly workspace: Pick | null; + readonly repositories: ReadonlyArray; + }, + dependencies: RestoreSafetyDependencies, +) { + const { fileSystem, path } = dependencies; + const contains = (parent: string, child: string) => { + const relative = path.relative(parent, child); + return ( + relative === "" || + (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)) + ); + }; + // Storage cleanup may have removed a worktree; rollback recreates it. + const resolve = (target: string) => + fileSystem.realPath(target).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + (_error) => Effect.succeed(path.resolve(target)), + ), + ); + // A checkpoint snapshots the whole checkout, so only the thread's own worktree is restorable. + let workspaceCwd: string | null = null; + let workspaceIsolated = targets.workspace === null ? null : thread.worktreePath != null; + if (targets.workspace !== null && thread.worktreePath != null) { + workspaceCwd = yield* resolve(targets.workspace.cwd); + workspaceIsolated = workspaceCwd === (yield* resolve(thread.worktreePath)); + } + const repositoryCwds = yield* Effect.forEach(targets.repositories, (repository) => + resolve(repository.worktreePath), + ); + if (workspaceIsolated !== true && repositoryCwds.length === 0) { + return { workspace: workspaceIsolated, sharedRepository: null }; + } + const others = yield* otherThreadPaths(thread, dependencies); + const shared = (cwd: string) => + others === null || others.some((other) => contains(cwd, other) || contains(other, cwd)); + if (workspaceIsolated === true && workspaceCwd !== null && shared(workspaceCwd)) { + workspaceIsolated = false; + } + const sharedIndex = repositoryCwds.findIndex(shared); + return { + workspace: workspaceIsolated, + sharedRepository: sharedIndex < 0 ? null : targets.repositories[sharedIndex]!, + }; +}); + // Check isolation at admission and rollback; checkpoints restore whole checkouts, which may become shared. export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpointRestoreIsolated")( function* ( thread: Pick, scope: Pick, - dependencies: { - readonly fileSystem: FileSystem.FileSystem; - readonly projections: ProjectionStoreV2["Service"]; - readonly projects: ProjectStore.ProjectStoreV2["Service"]; - readonly path: Path.Path; - }, + dependencies: RestoreSafetyDependencies, ) { - const { fileSystem, projections, projects, path } = dependencies; - const contains = (parent: string, child: string) => { - const relative = path.relative(parent, child); - return ( - relative === "" || - (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)) - ); - }; - const worktreePath = thread.worktreePath; - let shared = worktreePath == null; - if (!shared && worktreePath !== null) { - // Storage cleanup may have removed the worktree; rollback recreates it. - const resolve = (target: string) => - fileSystem.realPath(target).pipe( - Effect.catchIf( - (error) => error.reason._tag === "NotFound", - (_error) => Effect.succeed(path.resolve(target)), - ), - ); - const cwd = yield* resolve(scope.cwd); - const worktreeCwd = yield* resolve(worktreePath); - shared = cwd !== worktreeCwd; - if (!shared) { - const shell = yield* projections.getShellSnapshot(); - const checkedPaths = new Set(); - for (const otherThread of [...shell.threads, ...shell.archivedThreads]) { - if (otherThread.id === thread.id || otherThread.deletedAt !== null) continue; - // An idle side chat that only answered questions never touched the shared worktree. - if ( - otherThread.lineage.sideChat === true && - otherThread.activeRunId === null && - otherThread.worktreePath === worktreePath - ) { - const { turnItems } = yield* projections.getThreadRecords( - otherThread.id, - ["turnItems"], - { - turnItemTypes: ["command_execution", "file_change", "dynamic_tool"], - }, - ); - if (turnItems.length === 0) continue; - } - const other = yield* projections.getCheckpointContext(otherThread.id); - const providerContext = yield* projections.getThreadProviderContext(otherThread.id); - const paths = [ - otherThread.worktreePath, - ...other.checkpointScopes.map((candidate) => candidate.cwd), - // Errored sessions can still stream. Shared sessions retain another thread's opening cwd; - // turn worktrees and project roots are already covered here. - ...providerContext.providerSessions - .filter( - (session) => - session.status !== "stopped" && - !session.capabilities.sessions.supportsMultipleProviderThreadsPerSession, - ) - .map((session) => session.cwd), - ].filter((value): value is string => value !== null); - if (otherThread.worktreePath === null) { - const project = yield* projects.get(otherThread.projectId, { includeDeleted: true }); - if (Option.isNone(project)) return false; - paths.push(project.value.workspaceRoot); - } - for (const candidate of paths) { - if (checkedPaths.has(candidate)) continue; - checkedPaths.add(candidate); - const otherCwd = yield* fileSystem.realPath(candidate).pipe( - Effect.catchIf( - (error) => error.reason._tag === "NotFound", - (_error) => Effect.succeed(null), - ), - ); - if (otherCwd !== null && (contains(cwd, otherCwd) || contains(otherCwd, cwd))) { - shared = true; - break; - } - } - if (shared) break; - } - } - } - return !shared; + const isolation = yield* restoreIsolation( + thread, + { workspace: scope, repositories: [] }, + dependencies, + ); + return isolation.workspace === true; }, ); + +/** + * Repository worktrees whose files a rollback to `targetOrdinal` must + * restore: the ones later checkpoints captured, detached since or not, and + * the ones attached now. The current attachment's identity wins. + */ +export function participatingRepositories(input: { + readonly thread: Pick; + readonly scope: Pick; + readonly checkpoints: ReadonlyArray< + Pick + >; + readonly targetOrdinal: number; +}): ReadonlyArray { + const byWorktree = new Map(); + const later = input.checkpoints + .filter( + (checkpoint) => + checkpoint.scopeId === input.scope.id && + checkpoint.status !== "stale" && + checkpoint.ordinalWithinScope > input.targetOrdinal, + ) + .toSorted((left, right) => left.ordinalWithinScope - right.ordinalWithinScope); + for (const checkpoint of later) { + for (const entry of checkpoint.repositories ?? []) { + byWorktree.set(entry.worktreePath, { + repository: entry.repository, + repositoryPath: entry.repositoryPath, + worktreePath: entry.worktreePath, + branch: entry.branch, + }); + } + } + for (const attached of input.thread.repoWorktrees ?? []) { + // The workspace snapshot already covers a checkout the thread works in itself. + if (attached.worktreePath === input.scope.cwd) continue; + byWorktree.set(attached.worktreePath, { + repository: attached.repository, + repositoryPath: attached.repositoryPath, + worktreePath: attached.worktreePath, + branch: attached.branch, + }); + } + return [...byWorktree.values()]; +} + +export type CheckpointRestorePlan = + | { + readonly _tag: "restore"; + readonly workspace: boolean; + /** The thread's own checkout, when it also attached it as a repository worktree. */ + readonly workspaceRepository: CheckpointRepositoryIdentity | null; + readonly repositories: ReadonlyArray; + } + | { readonly _tag: "shared-workspace" } + | { readonly _tag: "workspace-unavailable" } + | { readonly _tag: "shared-repository"; readonly repository: CheckpointRepositoryIdentity }; + +/** + * What a file restore to `checkpoint` may change. The workspace is restored + * only when it is the thread's own worktree; a shared one, such as the project + * folder, blocks the restore only when later turns changed files in it. + */ +export const planCheckpointRestore = Effect.fn("orchestrationV2.planCheckpointRestore")(function* ( + input: { + readonly thread: Pick; + readonly scope: Pick; + readonly checkpoint: OrchestrationV2Checkpoint; + readonly checkpoints: ReadonlyArray< + Pick< + OrchestrationV2Checkpoint, + "scopeId" | "status" | "ordinalWithinScope" | "repositories" | "files" + > + >; + }, + dependencies: RestoreSafetyDependencies, +) { + const targetOrdinal = input.checkpoint.ordinalWithinScope; + // The workspace snapshot covers a checkout the thread attached under another spelling. + const realPathOr = (target: string) => + dependencies.fileSystem + .realPath(target) + .pipe(Effect.orElseSucceed(() => dependencies.path.resolve(target))); + const workspaceCwd = yield* realPathOr(input.scope.cwd); + const workspaceStatus = checkpointWorkspaceStatus(input.checkpoint); + // Unlike a missing snapshot, a failed one of the thread's own worktree can't be skipped: + // restoring only its repositories would leave later turns' edits in the worktree. + if ( + workspaceStatus === "error" && + input.thread.worktreePath != null && + (yield* realPathOr(input.thread.worktreePath)) === workspaceCwd + ) { + return { _tag: "workspace-unavailable" } satisfies CheckpointRestorePlan; + } + const repositories = yield* Effect.filter( + participatingRepositories({ ...input, targetOrdinal }), + (repository) => + realPathOr(repository.worktreePath).pipe(Effect.map((real) => real !== workspaceCwd)), + ); + const workspaceReady = workspaceStatus === "ready"; + const isolation = yield* restoreIsolation( + input.thread, + { workspace: workspaceReady ? input.scope : null, repositories }, + dependencies, + ); + if (workspaceReady && isolation.workspace !== true) { + const workspaceChangedLater = input.checkpoints.some( + (checkpoint) => + checkpoint.scopeId === input.scope.id && + checkpoint.status !== "stale" && + checkpoint.ordinalWithinScope > targetOrdinal && + checkpointWorkspaceFiles(checkpoint).length > 0, + ); + if (repositories.length === 0 || workspaceChangedLater) { + return { _tag: "shared-workspace" } satisfies CheckpointRestorePlan; + } + } + if (isolation.sharedRepository !== null) { + return { + _tag: "shared-repository", + repository: isolation.sharedRepository, + } satisfies CheckpointRestorePlan; + } + const workspace = workspaceReady && isolation.workspace === true; + let workspaceRepository: CheckpointRepositoryIdentity | null = null; + for (const attached of workspace ? (input.thread.repoWorktrees ?? []) : []) { + if ((yield* realPathOr(attached.worktreePath)) === workspaceCwd) { + const { repository, repositoryPath, worktreePath, branch } = attached; + workspaceRepository = { repository, repositoryPath, worktreePath, branch }; + break; + } + } + return { + _tag: "restore", + workspace, + workspaceRepository, + repositories, + } satisfies CheckpointRestorePlan; +}); diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index e5ef81ad46b54..8f45cc31beaa0 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -32,9 +32,9 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; -import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; +import { withRepositoryLease, withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { ProviderWorkspaceMissingError } from "../provider/Errors.ts"; -import { resolveCodexRollbackTurnCount } from "./Adapters/CodexAdapterV2.ts"; +import { resolveCodexRollbackBoundary } from "./Adapters/CodexAdapterV2.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; @@ -104,6 +104,8 @@ it.effect.each(["initial", "waiting"])( Layer.mock(ProjectionStore.ProjectionStoreV2)({ getThread, getCheckpointRollbackContext, + getCheckpointContext: () => + Effect.succeed({ runs: [], checkpointScopes: [], checkpoints: [] }), getShellSnapshot: () => Effect.succeed({ schemaVersion: 1, @@ -178,9 +180,12 @@ it.effect.each([ { order: "cleanup-first", restoreFiles: false, leavesFolder: true }, { order: "cleanup-first", restoreFiles: true, leavesFolder: true, shared: true }, { order: "cleanup-first", restoreFiles: true, leavesFolder: true, createFails: true }, + { order: "cleanup-first", restoreFiles: true, leavesFolder: true, repositoryRefused: true }, + { order: "cleanup-first", restoreFiles: true, repositoryRefused: true }, ])("locks rollback with %s", (row) => Effect.gen(function* () { - const { order, restoreFiles, unmanaged, leavesFolder, shared, createFails } = row; + const { order, restoreFiles, unmanaged, leavesFolder, shared, createFails, repositoryRefused } = + row; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspaceRoot = yield* fileSystem.realPath( @@ -212,6 +217,22 @@ it.effect.each([ branch, activeProviderThreadId: providerThreadId, modelSelection: { instanceId: providerInstanceId, model: "test-model" }, + // Its snapshot is unavailable, so the restore is refused. + ...(repositoryRefused + ? { + repoWorktrees: [ + { + repository: "service", + repositoryPath: path.join(workspaceRoot, "service"), + worktreePath: path.join(worktreesDir, "service", "work"), + branch: "work", + baseRef: null, + origin: "created", + attachedAt: "2026-01-01T00:00:00.000Z", + }, + ], + } + : {}), }, providerThread, providerSession: undefined, @@ -266,6 +287,9 @@ it.effect.each([ ); calls.push("files"); }), + repositoryLeaseKey: (target) => Effect.succeed(target.worktreePath), + resolveRepositorySnapshot: () => + Effect.succeed({ _tag: "unavailable", ordinal: 0 } as const), }), Layer.mock(EventSink.EventSinkV2)({ write: () => @@ -278,6 +302,9 @@ it.effect.each([ Layer.mock(ProjectionStore.ProjectionStoreV2)({ getThread: () => Effect.succeed(projection.thread), getCheckpointRollbackContext: () => Effect.succeed(projection), + getCheckpointContext: () => + Effect.succeed({ runs: [], checkpointScopes: [], checkpoints: [] }), + getThreadRepoWorktreePaths: () => Effect.succeed([]), getShellSnapshot: () => Effect.succeed({ schemaVersion: 1, @@ -295,7 +322,6 @@ it.effect.each([ : [], archivedThreads: [], } as never), - getCheckpointContext: () => Effect.succeed({ checkpointScopes: [] } as never), getThreadProviderContext: () => Effect.succeed({ providerSessions: [] } as never), }), Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ @@ -355,13 +381,17 @@ it.effect.each([ if (order === "rollback-first") assert.isFalse(cleanupEntered); yield* Deferred.succeed(release, undefined); if (cleanup !== undefined) yield* Fiber.join(cleanup); - if (unmanaged || shared) { + if (unmanaged || shared || repositoryRefused) { const error = yield* Fiber.join(rollback).pipe(Effect.flip); assert.equal(error._tag, "CheckpointRollbackExecutionError"); assert.deepEqual(calls, ["cleanup"]); - if (shared) { - assert.equal(error.reason, "shared-workspace"); - assert.isTrue(yield* fileSystem.exists(path.join(worktreePath, "unsaved.txt"))); + if (shared || repositoryRefused) { + assert.equal(error.reason, shared ? "shared-workspace" : "repository-unavailable"); + // A removed folder is not recreated either: pruning its record would drop Git data. + assert.equal( + yield* fileSystem.exists(path.join(worktreePath, "unsaved.txt")), + leavesFolder === true, + ); } return; } @@ -394,6 +424,135 @@ it.effect.each([ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +// Beta was attached while the rollback waited for alpha's lease, after it chose its leases. +it.effect("takes the lease of a repository attached while it waited before checking it", () => { + const threadId = ThreadId.make("thread:rollback-late-attach"); + const providerThreadId = ProviderThreadId.make("provider-thread:rollback-late-attach"); + const providerInstanceId = ProviderInstanceId.make("provider_rollback_late_attach"); + const checkpointId = CheckpointId.make("checkpoint:rollback-late-attach"); + const scopeId = CheckpointScopeId.make("checkpoint-scope:rollback-late-attach"); + const repository = (name: string) => ({ + repository: name, + repositoryPath: `/nonexistent/folder/${name}`, + worktreePath: `/nonexistent/worktrees/${name}`, + branch: "work", + baseRef: null, + origin: "created" as const, + attachedAt: "2026-01-01T00:00:00.000Z", + }); + const thread = { + id: threadId, + worktreePath: null, + branch: null, + activeProviderThreadId: providerThreadId, + modelSelection: { instanceId: providerInstanceId, model: "test-model" }, + repoWorktrees: [repository("alpha")], + }; + const scope = { id: scopeId, kind: "root_run", cwd: "/nonexistent/folder" }; + const askedForBetaLease = Deferred.makeUnsafe(); + const checkedBeta = Deferred.makeUnsafe(); + const betaHeld = Deferred.makeUnsafe(); + const releaseBeta = Deferred.makeUnsafe(); + let betaHeldElsewhere = false; + const checkedOutsideLease: Array = []; + const testLayer = layerCheckpointRollbackService.pipe( + Layer.provide( + Layer.mergeAll( + ThreadCommandExecutor.layer, + Layer.mock(CheckpointService.CheckpointServiceV2)({ + repositoryLeaseKey: (target) => + target.worktreePath === repository("beta").worktreePath + ? Deferred.succeed(askedForBetaLease, undefined).pipe(Effect.as("beta")) + : Effect.succeed(target.worktreePath), + resolveRepositorySnapshot: () => + Effect.succeed({ + _tag: "found", + ordinal: 0, + ref: CheckpointRef.make("refs/t3/test/late-attach"), + } as const), + inspectRepositoryRestore: (input) => + Effect.gen(function* () { + if (input.repository.repository !== "beta") return { recreate: false }; + if (betaHeldElsewhere) checkedOutsideLease.push("beta"); + yield* Deferred.succeed(checkedBeta, undefined); + return { recreate: false }; + }), + restoreRepository: () => Effect.void, + }), + Layer.mock(EventSink.EventSinkV2)({ write: () => Effect.succeed([]) }), + IdAllocator.layer, + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThread: () => Effect.succeed(thread as never), + getCheckpointRollbackContext: () => + Effect.succeed({ + thread: { ...thread, repoWorktrees: [repository("alpha"), repository("beta")] }, + providerThread: { + id: providerThreadId, + providerSessionId: ProviderSessionId.make("provider-session:late-attach"), + providerInstanceId, + }, + providerSession: undefined, + checkpoint: { + id: checkpointId, + scopeId, + status: "ready", + workspaceStatus: "missing", + appRunOrdinal: 0, + ordinalWithinScope: 0, + }, + scope, + runsToRollback: [], + targetAttempt: undefined, + rolledBackAttemptIds: [], + staleCheckpoints: [], + rootNodes: [], + providerTurns: [], + } as unknown as ProjectionStore.ProjectionCheckpointRollbackContext), + getCheckpointContext: () => + Effect.succeed({ runs: [], checkpointScopes: [scope], checkpoints: [] } as never), + getThreadRepoWorktreePaths: () => Effect.succeed([]), + getShellSnapshot: () => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 0, + threads: [], + archivedThreads: [], + }), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + open: () => Effect.succeed({} as never), + }), + Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: () => Effect.succeed({} as never) }), + ), + ), + ); + + return Effect.gen(function* () { + const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2; + const holder = yield* withRepositoryLease( + "beta", + Effect.gen(function* () { + betaHeldElsewhere = true; + yield* Deferred.succeed(betaHeld, undefined); + yield* Deferred.await(releaseBeta); + betaHeldElsewhere = false; + }), + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(betaHeld); + const rollback = yield* service + .execute({ threadId, providerThreadId, checkpointId, scopeId }) + .pipe(Effect.forkChild({ startImmediately: true })); + // Only one happens while beta's lease is held elsewhere: asking for it, or checking beta. + yield* Effect.raceFirst(Deferred.await(askedForBetaLease), Deferred.await(checkedBeta)); + yield* Deferred.succeed(releaseBeta, undefined); + yield* Fiber.join(holder); + yield* Fiber.join(rollback); + + assert.isTrue(yield* Deferred.isDone(checkedBeta)); + assert.deepEqual(checkedOutsideLease, []); + }).pipe(Effect.provide(testLayer)); +}); + const seedRollbackHistory = Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; @@ -645,7 +804,9 @@ const seedRollbackHistory = Effect.gen(function* () { Effect.succeed({ rollbackThread: (input: ProviderAdapter.ProviderAdapterV2RollbackThreadInput) => Effect.gen(function* () { - rollbackCounts.push(yield* resolveCodexRollbackTurnCount(input)); + rollbackCounts.push( + (yield* resolveCodexRollbackBoundary(input)).laterTurnIds.size, + ); return { providerThread: input.providerThread }; }), } as never), diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 16cbe8b101e28..efba8c95d70db 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -1,7 +1,9 @@ import { CheckpointId, CheckpointScopeId, + type CommandId, latestProviderTurnForAttempt, + type OrchestrationV2AppThread, type OrchestrationV2DomainEvent, ProviderThreadId, ThreadId, @@ -21,12 +23,21 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import type * as ServerConfig from "../config.ts"; import type * as ServerSettings from "../serverSettings.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; -import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; +import { withRepositoryLeases, withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { isCheckpointRestoreIsolated, + participatingRepositories, + planCheckpointRestore, SHARED_WORKSPACE_RESTORE_MESSAGE, + sharedRepositoryRestoreMessage, + WORKSPACE_SNAPSHOT_FAILED_MESSAGE, } from "./CheckpointRestoreSafety.ts"; -import { CheckpointRestoreError, CheckpointServiceV2 } from "./CheckpointService.ts"; +import { + CheckpointRepositoryUnavailableError, + type CheckpointRepositoryIdentity, + CheckpointRestoreError, + CheckpointServiceV2, +} from "./CheckpointService.ts"; import { EventSinkV2 } from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProjectionStoreV2 } from "./ProjectionStore.ts"; @@ -49,14 +60,37 @@ export class CheckpointRollbackExecutionError extends Schema.TaggedError + this.conversationRewound === true + ? `The conversation was rewound, but restoring files stopped at ${stoppedAt}.` + : `Restoring files stopped at ${stoppedAt}.`; + const tryAgain = + this.conversationRewound === true + ? "then try again to finish restoring files." + : "then roll back again."; switch (this.reason) { case "rollback-target-invalid": return `Rollback target ${this.checkpointId} for provider thread ${this.providerThreadId} on thread ${this.threadId} is incomplete or invalid.`; @@ -66,6 +100,28 @@ export class CheckpointRollbackExecutionError extends Schema.TaggedError = new Set([ + "shared-workspace", + "workspace-unavailable", + "shared-repository", + "repository-unavailable", + "workspace-restore-failed", + "repository-restore-failed", +]); + +/** A rollback stopped over the files it would restore, with a message for the user. */ +const isFileRestoreRefusal = (error: CheckpointRollbackExecutionError) => + FILE_RESTORE_REFUSALS.has(error.reason); + /** - * The message clients see when a rollback fails. Only a held Git index lock - * gets its own explanation; other reasons carry internal ids or raw Git - * output, so they fall back to the generic message. + * The message clients see when a rollback fails. A file restore refusal names + * what blocked it and a held Git index lock gets its own explanation; other + * reasons carry internal ids or raw Git output, so they get the generic message. */ export function rollbackFailureClientMessage(error: CheckpointRollbackExecutionError): string { if (isRuntimePolicyAboveCeilingError(error.cause)) return error.cause.message; const restoreCause = isCheckpointRestoreError(error.cause) ? error.cause.cause : undefined; - return isVcsProcessExitError(restoreCause) && restoreCause.detail === GIT_INDEX_LOCKED_DETAIL - ? GIT_INDEX_LOCKED_DETAIL - : ROLLBACK_FAILED_MESSAGE; + const indexLocked = + isVcsProcessExitError(restoreCause) && restoreCause.detail === GIT_INDEX_LOCKED_DETAIL; + if (isFileRestoreRefusal(error)) { + return indexLocked ? `${error.message} ${GIT_INDEX_LOCKED_DETAIL}` : error.message; + } + return indexLocked ? GIT_INDEX_LOCKED_DETAIL : ROLLBACK_FAILED_MESSAGE; } export interface CheckpointRollbackServiceV2Shape { @@ -97,6 +169,8 @@ export interface CheckpointRollbackServiceV2Shape { readonly checkpointId: CheckpointId; readonly scopeId: CheckpointScopeId; readonly restoreFiles?: boolean; + /** The rollback command, so a failure after the conversation rewound is recorded with it. */ + readonly requestId?: CommandId; }) => Effect.Effect; } @@ -137,13 +211,60 @@ export const layer: Layer.Layer< const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const isManagedWorktreePath = yield* makeIsManagedWorktreePath; - const execute = Effect.fn("orchestrationV2.checkpointRollback.execute")(function* (input: { - readonly threadId: ThreadId; - readonly providerThreadId: ProviderThreadId; - readonly checkpointId: CheckpointId; - readonly scopeId: CheckpointScopeId; - readonly restoreFiles?: boolean; + const prepareRepositoryRestore = Effect.fn( + "CheckpointRollbackService.prepareRepositoryRestore", + )(function* (input: { + readonly repository: CheckpointRepositoryIdentity; + readonly history: Parameters< + CheckpointServiceV2["Service"]["resolveRepositorySnapshot"] + >[0]["history"]; + readonly targetOrdinal: number; }) { + const snapshot = yield* checkpoints.resolveRepositorySnapshot(input); + if (snapshot._tag !== "found") { + return yield* new CheckpointRepositoryUnavailableError({ + repository: input.repository.repository, + worktreePath: input.repository.worktreePath, + reason: "checkpoint-missing", + }); + } + const { recreate } = yield* checkpoints.inspectRepositoryRestore({ + repository: input.repository, + ref: snapshot.ref, + }); + return { repository: input.repository, ref: snapshot.ref, recreate }; + }); + + // Attaching and removing a repository's worktrees take its lease, so the + // checks hold until the files are restored. Every repository the scope ever + // recorded counts, which covers any target in it. + const repositoryLeaseKeys = Effect.fn("CheckpointRollbackService.repositoryLeaseKeys")( + function* (input: { + readonly thread: OrchestrationV2AppThread; + readonly scopeId: CheckpointScopeId; + readonly restoreFiles?: boolean; + }) { + if (input.restoreFiles === false) return []; + const history = yield* projections.getCheckpointContext(input.thread.id); + const scope = history.checkpointScopes.find((candidate) => candidate.id === input.scopeId); + if (scope === undefined) return []; + const keys = yield* Effect.forEach( + participatingRepositories({ + thread: input.thread, + scope, + checkpoints: history.checkpoints, + targetOrdinal: -1, + }), + (repository) => checkpoints.repositoryLeaseKey(repository), + ); + return keys.filter((key): key is string => key !== null); + }, + ); + + const execute = Effect.fn("orchestrationV2.checkpointRollback.execute")(function* ( + input: Parameters[0], + heldLeaseKeys: ReadonlySet, + ) { const context = yield* projections.getCheckpointRollbackContext(input.threadId, input); const { providerThread, checkpoint, scope, targetAttempt, runsToRollback, staleCheckpoints } = context; @@ -175,6 +296,16 @@ export const layer: Layer.Layer< checkpointId: input.checkpointId, }); } + // Metadata only, without files or run payloads; the later ready checkpoints carry files. + const history = yield* projections.getCheckpointContext(input.threadId); + const laterReady = new Map( + staleCheckpoints.map((candidate) => [candidate.ordinalWithinScope, candidate]), + ); + const planCheckpoints = history.checkpoints.map((candidate) => + candidate.scopeId === scope.id + ? (laterReady.get(candidate.ordinalWithinScope) ?? { ...candidate, files: [] }) + : { ...candidate, files: [] }, + ); const targetOrdinal = checkpoint.appRunOrdinal ?? 0; const rollbackTarget: ProviderAdapter.ProviderAdapterV2RollbackTarget = @@ -185,6 +316,16 @@ export const layer: Layer.Layer< appRunOrdinal: 0, } : yield* Effect.gen(function* () { + const targetRun = history.runs.find((run) => run.ordinal === targetOrdinal); + // Its checkpoint stays ready while an earlier rollback finishes restoring files. + if (targetRun?.status === "rolled_back") { + return yield* new CheckpointRollbackExecutionError({ + reason: "rollback-target-invalid", + threadId: input.threadId, + providerThreadId: input.providerThreadId, + checkpointId: input.checkpointId, + }); + } const targetTurn = latestProviderTurnForAttempt(context.providerTurns, targetAttempt?.id) ?? context.providerTurns.find((turn) => turn.id === targetAttempt?.providerTurnId); @@ -225,79 +366,151 @@ export const layer: Layer.Layer< projects, path, }); + const refusal = { + threadId: input.threadId, + providerThreadId: input.providerThreadId, + checkpointId: input.checkpointId, + }; const sharedWorkspace = () => new CheckpointRollbackExecutionError({ + ...refusal, reason: "shared-workspace", - threadId: input.threadId, - providerThreadId: input.providerThreadId, - checkpointId: input.checkpointId, }); + const plan = restoreFiles + ? yield* planCheckpointRestore( + { + thread: context.thread, + scope, + checkpoint, + checkpoints: planCheckpoints, + }, + { fileSystem, projections, projects, path }, + ) + : null; + if (plan?._tag === "shared-workspace") return yield* sharedWorkspace(); + if (plan?._tag === "workspace-unavailable") { + return yield* new CheckpointRollbackExecutionError({ + ...refusal, + reason: "workspace-unavailable", + }); + } + if (plan?._tag === "shared-repository") { + return yield* new CheckpointRollbackExecutionError({ + ...refusal, + reason: "shared-repository", + repository: plan.repository.repository, + worktreePath: plan.repository.worktreePath, + }); + } + // A worktree attached while this waited for its leases needs its own lease first. + if (plan !== null) { + const neededKeys = yield* Effect.forEach(plan.repositories, (repository) => + checkpoints.repositoryLeaseKey(repository), + ); + const missingLeaseKeys = neededKeys.filter( + (key): key is string => key !== null && !heldLeaseKeys.has(key), + ); + if (missingLeaseKeys.length > 0) return { missingLeaseKeys }; + } + const refuseUnavailable = Effect.catchTags({ + CheckpointRepositoryUnavailableError: (cause: CheckpointRepositoryUnavailableError) => + Effect.fail( + new CheckpointRollbackExecutionError({ + ...refusal, + reason: "repository-unavailable", + repository: cause.repository, + worktreePath: cause.worktreePath, + repositoryProblem: cause.reason, + ...(cause.submodulePath === undefined ? {} : { submodulePath: cause.submodulePath }), + }), + ), + }); const { worktreePath, branch } = context.thread; + // A failed removal can leave the folder without its checkout; only a file restore replaces it. + const leftover = + worktreePath != null && + branch != null && + restoreFiles && + (yield* fileSystem.exists(worktreePath)) && + !(yield* fileSystem.exists(path.join(worktreePath, ".git"))); // Recreated only where the server would have made it, as turn start does. - if (worktreePath != null && branch != null) { - // A failed removal can leave the folder without its checkout; only a file restore replaces it. - const leftover = - restoreFiles && - (yield* fileSystem.exists(worktreePath)) && - !(yield* fileSystem.exists(path.join(worktreePath, ".git"))); - if ( - (leftover || !(yield* fileSystem.exists(worktreePath))) && - (yield* isManagedWorktreePath(worktreePath)) - ) { - const project = yield* projects.get(context.thread.projectId); - if (Option.isSome(project)) { - const recreate = gitWorkflow.pruneWorktrees({ cwd: project.value.workspaceRoot }).pipe( - Effect.andThen( - gitWorkflow.createWorktree({ - cwd: project.value.workspaceRoot, - refName: branch, - path: worktreePath, - }), + const recreate = + worktreePath != null && + branch != null && + (leftover || !(yield* fileSystem.exists(worktreePath))) && + (yield* isManagedWorktreePath(worktreePath)) + ? Option.map(yield* projects.get(context.thread.projectId), (project) => + gitWorkflow.pruneWorktrees({ cwd: project.workspaceRoot }).pipe( + Effect.andThen( + gitWorkflow.createWorktree({ + cwd: project.workspaceRoot, + refName: branch, + path: worktreePath, + }), + ), ), + ) + : Option.none(); + // Every repository is checked before the provider or any file changes. + const repositoryTargets = + plan === null + ? [] + : yield* Effect.forEach(plan.repositories, (repository) => + prepareRepositoryRestore({ + repository, + history: { + scopeId: scope.id, + checkpoints: history.checkpoints, + runs: history.runs, + }, + targetOrdinal: checkpoint.ordinalWithinScope, + }).pipe(refuseUnavailable), ); - if (leftover) { - if (!(yield* isolated)) return yield* sharedWorkspace(); - // Set aside, not removed: a checkout that fails puts the folder and its files back. - yield* Effect.acquireUseRelease( - Effect.gen(function* () { - const aside = yield* fileSystem.makeTempDirectory({ - directory: path.dirname(worktreePath), - prefix: ".t3-replaced-", - }); - yield* fileSystem.rename(worktreePath, path.join(aside, "folder")); - return aside; - }), - () => recreate, - (aside, exit) => - (Exit.isSuccess(exit) - ? Effect.void - : fileSystem - .remove(worktreePath, { recursive: true, force: true }) - .pipe( - Effect.andThen( - fileSystem.rename(path.join(aside, "folder"), worktreePath), - ), - ) - ).pipe( - Effect.andThen(fileSystem.remove(aside, { recursive: true, force: true })), - Effect.catchCause((cause) => - Effect.logWarning("Edit from here left a worktree folder set aside", { - worktreePath, - setAside: path.join(aside, "folder"), - cause, - }), - ), - ), - ); - } else { - yield* recreate; - } - } - } + // Last, since pruning the removed folder's record drops its submodules' Git data; the + // thread's own checkout is then checked as recreated, submodules included. + if (!leftover && Option.isSome(recreate)) yield* recreate.value; + // A folder that lost its checkout fails here when the thread attached it: its submodules may + // hold unsaved work. + const workspaceRepository = plan?.workspaceRepository ?? null; + if (workspaceRepository !== null) { + yield* checkpoints + .inspectRepositoryRestore({ repository: workspaceRepository, ref: checkpoint.ref }) + .pipe(refuseUnavailable); } - if (restoreFiles && !(yield* isolated)) return yield* sharedWorkspace(); + // After every refusal above: replacing a folder that lost its checkout deletes its files. + if (leftover && Option.isSome(recreate) && worktreePath != null) { + if (!(yield* isolated)) return yield* sharedWorkspace(); + // Set aside, not removed: a checkout that fails puts the folder and its files back. + yield* Effect.acquireUseRelease( + Effect.gen(function* () { + const aside = yield* fileSystem.makeTempDirectory({ + directory: path.dirname(worktreePath), + prefix: ".t3-replaced-", + }); + yield* fileSystem.rename(worktreePath, path.join(aside, "folder")); + return aside; + }), + () => recreate.value, + (aside, exit) => + (Exit.isSuccess(exit) + ? Effect.void + : fileSystem + .remove(worktreePath, { recursive: true, force: true }) + .pipe(Effect.andThen(fileSystem.rename(path.join(aside, "folder"), worktreePath))) + ).pipe( + Effect.andThen(fileSystem.remove(aside, { recursive: true, force: true })), + Effect.catchCause((cause) => + Effect.logWarning("Edit from here left a worktree folder set aside", { + worktreePath, + setAside: path.join(aside, "folder"), + cause, + }), + ), + ), + ); + } const modelSelection = context.thread.modelSelection; const resolvedRuntimePolicy = yield* runtimePolicy.resolve({ @@ -337,94 +550,224 @@ export const layer: Layer.Layer< target: rollbackTarget, providerThreadTurns, }); - if (input.restoreFiles !== false) yield* checkpoints.restore({ scope, checkpoint }); - if (staleCheckpoints.length > 0) { - yield* checkpoints.deleteStaleRefs({ scope, checkpoints: staleCheckpoints }); - } - - // Lock against full-row thread writes that could restore the old native session after rollback. - yield* threadCommands.withLock( - input.threadId, - Effect.gen(function* () { - const now = yield* DateTime.now; - const makeEvent = (event: Omit) => - Effect.map( - ids.allocate.event({ threadId: event.threadId }), - (id) => - ({ - ...event, - id, - }) as Event, + // An earlier attempt that rewound the conversation, then stopped + // restoring files, left its runs rolled back with ready checkpoints. + const rolledBackRunIds = new Set( + history.runs.filter((run) => run.status === "rolled_back").map((run) => run.id), + ); + const conversationRewound = + runsToRollback.length > 0 || + staleCheckpoints.some( + (candidate) => candidate.runId !== null && rolledBackRunIds.has(candidate.runId), + ); + const restoreFilesAndCleanUp = Effect.gen(function* () { + if (plan?._tag === "restore" && plan.workspace) { + yield* checkpoints + .restore({ + scope, + checkpoint, + ...(workspaceRepository === null ? {} : { alignSubmodules: true }), + }) + .pipe( + Effect.mapError( + (cause) => + new CheckpointRollbackExecutionError({ + ...refusal, + reason: "workspace-restore-failed", + worktreePath: scope.cwd, + conversationRewound, + cause, + }), + ), ); - const events: Array = []; - events.push( - yield* makeEvent({ - type: "provider-thread.updated", - threadId: input.threadId, - driver: providerThread.driver, - providerInstanceId: providerThread.providerInstanceId, - occurredAt: now, - payload: { - ...snapshot.providerThread, - lastRunOrdinal: targetOrdinal === 0 ? null : targetOrdinal, - updatedAt: now, - }, - }), + } + // In order, stopping at the first failure: the worktrees not reached + // keep their files, and the error names the ones already restored. + const restoredRepositories: Array = []; + for (const target of repositoryTargets) { + yield* Effect.gen(function* () { + if (target.recreate && target.repository.branch !== null) { + yield* gitWorkflow.pruneWorktrees({ cwd: target.repository.repositoryPath }); + yield* gitWorkflow.createWorktree({ + cwd: target.repository.repositoryPath, + refName: target.repository.branch, + path: target.repository.worktreePath, + }); + } + yield* checkpoints.restoreRepository({ + repository: target.repository, + ref: target.ref, + }); + }).pipe( + Effect.mapError( + (cause) => + new CheckpointRollbackExecutionError({ + ...refusal, + reason: "repository-restore-failed", + repository: target.repository.repository, + worktreePath: target.repository.worktreePath, + restoredRepositories: [...restoredRepositories], + conversationRewound, + cause, + }), + ), + ); + restoredRepositories.push(target.repository.repository); + } + if (staleCheckpoints.length > 0) { + const staleOrdinals = new Set( + staleCheckpoints.map((candidate) => candidate.ordinalWithinScope), ); - for (const staleCheckpoint of staleCheckpoints) { + yield* checkpoints.deleteStaleRefs({ + scope, + checkpoints: staleCheckpoints, + keep: history.checkpoints.filter( + (candidate) => + candidate.scopeId === scope.id && + candidate.status !== "stale" && + !staleOrdinals.has(candidate.ordinalWithinScope), + ), + }); + } + }); + + const makeEvent = (event: Omit) => + Effect.map( + ids.allocate.event({ threadId: event.threadId }), + (id) => + ({ + ...event, + id, + }) as Event, + ); + // Lock against full-row thread writes that could restore the old native session after rollback. + // A commit before the files are restored records only the conversation and the failure, + // with checkpoints still ready, so a retry restores the same files and rewinds nothing more. + const commit = (outcome: { readonly complete: boolean; readonly failure?: string }) => + threadCommands.withLock( + input.threadId, + Effect.gen(function* () { + const now = yield* DateTime.now; + const events: Array = []; events.push( yield* makeEvent({ - type: "checkpoint.captured", + type: "provider-thread.updated", threadId: input.threadId, - ...(staleCheckpoint.runId === null ? {} : { runId: staleCheckpoint.runId }), - nodeId: staleCheckpoint.nodeId, + driver: providerThread.driver, providerInstanceId: providerThread.providerInstanceId, occurredAt: now, - payload: { ...staleCheckpoint, status: "stale" }, - }), - ); - } - for (const run of runsToRollback) { - const rootNode = context.rootNodes.find((candidate) => candidate.id === run.rootNodeId); - events.push( - yield* makeEvent({ - type: "run.updated", - threadId: input.threadId, - runId: run.id, - ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), - providerInstanceId: run.providerInstanceId, - occurredAt: now, - payload: { ...run, status: "rolled_back", completedAt: now }, + payload: { + ...snapshot.providerThread, + lastRunOrdinal: targetOrdinal === 0 ? null : targetOrdinal, + updatedAt: now, + }, }), ); - if (rootNode !== undefined) { + for (const staleCheckpoint of outcome.complete ? staleCheckpoints : []) { events.push( yield* makeEvent({ - type: "node.updated", + type: "checkpoint.captured", + threadId: input.threadId, + ...(staleCheckpoint.runId === null ? {} : { runId: staleCheckpoint.runId }), + nodeId: staleCheckpoint.nodeId, + providerInstanceId: providerThread.providerInstanceId, + occurredAt: now, + payload: { ...staleCheckpoint, status: "stale" }, + }), + ); + } + for (const run of runsToRollback) { + const rootNode = context.rootNodes.find( + (candidate) => candidate.id === run.rootNodeId, + ); + events.push( + yield* makeEvent({ + type: "run.updated", threadId: input.threadId, runId: run.id, - nodeId: rootNode.id, + ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), providerInstanceId: run.providerInstanceId, occurredAt: now, - payload: { ...rootNode, status: "rolled_back", completedAt: now }, + payload: { ...run, status: "rolled_back", completedAt: now }, }), ); + if (rootNode !== undefined) { + events.push( + yield* makeEvent({ + type: "node.updated", + threadId: input.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...rootNode, status: "rolled_back", completedAt: now }, + }), + ); + } } - } - yield* eventSink.write({ events }); - }), + // A retry that finishes clears the failure an earlier attempt recorded. + const { requestId } = input; + const thread = + requestId === undefined ? undefined : yield* projections.getThread(input.threadId); + let rollbackFailure: OrchestrationV2AppThread["rollbackFailure"]; + if (thread !== undefined && requestId !== undefined) { + if (outcome.complete) { + if (thread.rollbackFailure?.requestId === requestId) rollbackFailure = null; + } else if (outcome.failure !== undefined && thread.rollbackRequestId === requestId) { + rollbackFailure = { requestId, message: outcome.failure }; + } + } + if (thread !== undefined && rollbackFailure !== undefined) { + events.push( + yield* makeEvent({ + type: "thread.metadata-updated", + threadId: input.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: { ...thread, rollbackFailure, updatedAt: now }, + }), + ); + } + yield* eventSink.write({ events }); + }), + ); + + yield* restoreFilesAndCleanUp.pipe( + Effect.tapError((error) => + runsToRollback.length === 0 + ? Effect.void + : commit({ + complete: false, + ...(isCheckpointRollbackExecutionError(error) && isFileRestoreRefusal(error) + ? { failure: rollbackFailureClientMessage(error) } + : {}), + }), + ), ); + yield* commit({ complete: true }); + return undefined; }); return CheckpointRollbackServiceV2.of({ - // The inner execute owns the rollback span; this wrapper selects the workspace lease. + // The inner execute owns the rollback span; this wrapper selects the leases. execute: Effect.fnUntraced( function* (input) { // Storage cleanup removes worktrees under the same lease. const thread = yield* projections.getThread(input.threadId); + const locked = Effect.gen(function* () { + const keys = [...(yield* repositoryLeaseKeys({ ...input, thread }))]; + for (;;) { + const held = new Set(keys); + const result = yield* keys.length === 0 + ? execute(input, held) + : withRepositoryLeases(keys, execute(input, held)); + if (result === undefined) return; + keys.push(...result.missingLeaseKeys); + } + }); yield* thread.worktreePath === null - ? execute(input) - : withWorkspaceLease(path.resolve(thread.worktreePath), execute(input)); + ? locked + : withWorkspaceLease(path.resolve(thread.worktreePath), locked); }, (effect, input) => effect.pipe( diff --git a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts index 33a7490a0245a..bcb753283dd2e 100644 --- a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts @@ -1,4 +1,5 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -227,6 +228,7 @@ it.effect("resolves the thread baseline after a second root run replaces scope o return Effect.succeed("two-run diff"); }, }), + NodeServices.layer, ), ), ); diff --git a/apps/server/src/orchestration-v2/CheckpointService.test.ts b/apps/server/src/orchestration-v2/CheckpointService.test.ts index 62fa68c4c53af..939b2527b6f27 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.test.ts @@ -1,12 +1,15 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { + CheckpointRef, CheckpointScopeId, NodeId, ProviderThreadId, RunId, ThreadId, type OrchestrationV2CheckpointScope, + type ThreadRepoWorktree, VcsProcessExitError, VcsProcessTimeoutError, VcsUnsupportedOperationError, @@ -16,13 +19,115 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +const serviceWorktree: ThreadRepoWorktree = { + repository: "service", + repositoryPath: "/folder/service", + worktreePath: "/worktrees/service/feature", + branch: "feature", + baseRef: null, + origin: "created", + attachedAt: "2026-07-28T00:00:00.000Z", +}; + +it.effect("gives each worktree its own path prefix, also when folder names match", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const worktree = (repository: string, worktreePath: string) => ({ + repository, + repositoryPath: `/folder/${repository}`, + worktreePath, + }); + const prefixes = CheckpointService.repositoryPathPrefixes(path, [ + worktree("alpha", "/worktrees/one/shared"), + worktree("alpha", "/worktrees/two/shared"), + worktree("alpha", "/worktrees/shared-2"), + worktree("beta", "/worktrees/beta/work"), + ]); + assert.deepEqual( + [...prefixes.values()], + ["alpha@shared", "alpha@shared-2", "alpha@shared-2-2", "beta"], + ); + // Captured before prefixes were kept: both took `alpha`, and the first keeps it. + const kept = CheckpointService.repositoryPathPrefixes( + path, + [worktree("alpha", "/worktrees/two/shared"), worktree("alpha", "/worktrees/one/shared")], + [ + { ...worktree("alpha", "/worktrees/one/shared"), pathPrefix: "alpha" }, + { ...worktree("alpha", "/worktrees/two/shared"), pathPrefix: "alpha" }, + ], + ); + assert.deepEqual([...kept.values()], ["alpha@shared", "alpha"]); + }).pipe(Effect.provide(NodeServices.layer)), +); + +// Turn 2 had no worktrees; turn 3 first attached beta, and its baseline sits +// at ordinal 2. Turn 2's checkpoint names that baseline. +it.effect.each([ + { stored: "marked backfilled", expected: { _tag: "found", ordinal: 2 } }, + { stored: "from before backfills were marked", expected: { _tag: "unavailable", ordinal: 1 } }, +] as const)("rewinds beta to turn 1 past a borrowed entry $stored", ({ stored, expected }) => { + const scopeId = CheckpointScopeId.make("checkpoint-scope:borrowed"); + const worktreePath = "/worktrees/beta/t3-thread"; + const entry = (ordinal: number) => ({ + repository: "beta", + repositoryPath: "/folder/beta", + worktreePath, + branch: "t3/thread", + pathPrefix: "beta", + ref: CheckpointRef.make(`refs/t3/beta/ordinal/${ordinal}`), + status: "ready" as const, + }); + const history: CheckpointService.RepositoryCheckpointHistory = { + scopeId, + checkpoints: [ + { scopeId, ordinalWithinScope: 1, status: "ready", repositories: [] }, + { + scopeId, + ordinalWithinScope: 2, + status: "ready", + repositories: [ + stored === "marked backfilled" ? { ...entry(2), backfilled: true } : entry(2), + ], + }, + { scopeId, ordinalWithinScope: 3, status: "ready", repositories: [entry(3)] }, + ], + runs: [{ ordinal: 1 }, { ordinal: 2 }, { ordinal: 3 }], + }; + return Effect.gen(function* () { + const snapshot = yield* CheckpointService.resolveRepositorySnapshot({ + history, + worktreePath, + cwd: worktreePath, + targetOrdinal: 1, + }); + assert.deepEqual( + snapshot._tag === "none" ? snapshot : { _tag: snapshot._tag, ordinal: snapshot.ordinal }, + expected, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(CheckpointStore.CheckpointStore)({ + // Only beta's attach baseline, at ordinal 2, exists. + hasCheckpointRef: ({ checkpointRef }) => + Effect.succeed(checkpointRef.endsWith("/ordinal/2")), + }), + NodeCrypto.layer, + ), + ), + ); +}); + it.effect( "skips unsupported baseline and turn checkpoints without swallowing capture failures", () => @@ -62,9 +167,11 @@ it.effect( hasCheckpointRef: () => Effect.succeed(false), captureCheckpoint: () => Effect.fail(captureError), }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), ), ), Layer.provideMerge(NodeCrypto.layer), + Layer.provideMerge(NodeServices.layer), ); yield* Effect.gen(function* () { const checkpoints = yield* CheckpointService.CheckpointServiceV2; @@ -122,6 +229,17 @@ it.effect( const refs = yield* Ref.make>(new Set()); const snapshots = yield* Ref.make(0); const firstSnapshotStarted = yield* Deferred.make(); + // Baselines with repository worktrees resolve real paths first, so the clock moves + // only once their snapshots have started. + const startedSnapshots = yield* Ref.make | undefined>(undefined); + const watchSnapshots = Effect.gen(function* () { + const started = yield* Queue.unbounded(); + yield* Ref.set(startedSnapshots, started); + return (count: number) => + Effect.forEach(Array.from({ length: count }), () => Queue.take(started), { + discard: true, + }); + }); const layerTest = CheckpointService.layer.pipe( Layer.provide( Layer.mergeAll( @@ -130,17 +248,21 @@ it.effect( isGitRepository: () => Effect.succeed(true), hasCheckpointRef: ({ checkpointRef }) => Ref.get(refs).pipe(Effect.map((current) => current.has(checkpointRef))), - captureCheckpoint: ({ checkpointRef }) => + captureCheckpoint: ({ cwd, checkpointRef }) => Effect.gen(function* () { yield* Ref.update(snapshots, (count) => count + 1); yield* Deferred.succeed(firstSnapshotStarted, undefined); + const started = yield* Ref.get(startedSnapshots); + if (started !== undefined) yield* Queue.offer(started, cwd); yield* Effect.sleep("10 seconds"); yield* Ref.update(refs, (current) => new Set([...current, checkpointRef])); }), }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), ), ), Layer.provideMerge(NodeCrypto.layer), + Layer.provideMerge(NodeServices.layer), ); yield* Effect.gen(function* () { @@ -171,6 +293,54 @@ it.effect( assert.strictEqual((yield* Fiber.join(slowBaseline))._tag, "Failure"); assert.strictEqual(yield* Ref.get(snapshots), 2); assert.strictEqual((yield* Ref.get(refs)).size, 1); + + // A repository worktree's baseline has the same budget and records the timeout. + const repositoryBaseline = yield* checkpoints + .captureRepositoryBaseline({ + threadId: scope.threadId, + repoWorktree: serviceWorktree, + ordinalWithinScope: 0, + }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("5 seconds"); + assert.strictEqual(yield* Fiber.join(repositoryBaseline), "error"); + + // Two slow repository snapshots and the workspace's share one budget. + const started = yield* Ref.get(snapshots); + const awaitSideBySide = yield* watchSnapshots; + const repositoriesAndWorkspace = yield* checkpoints + .captureBaseline({ + scope, + ordinalWithinScope: 3, + repoWorktrees: [ + serviceWorktree, + { + ...serviceWorktree, + repository: "client", + worktreePath: "/worktrees/client/feature", + }, + ], + }) + .pipe(Effect.result, Effect.forkChild); + yield* awaitSideBySide(3); + yield* TestClock.adjust("5 seconds"); + assert.strictEqual((yield* Fiber.join(repositoriesAndWorkspace))._tag, "Failure"); + assert.strictEqual(yield* Ref.get(snapshots), started + 3); + + // The workspace snapshot alone covers a checkout the thread attached itself. + const beforeOwn = yield* Ref.get(snapshots); + const awaitOwnCheckout = yield* watchSnapshots; + const ownCheckout = yield* checkpoints + .captureBaseline({ + scope, + ordinalWithinScope: 4, + repoWorktrees: [{ ...serviceWorktree, worktreePath: scope.cwd }], + }) + .pipe(Effect.result, Effect.forkChild); + yield* awaitOwnCheckout(1); + yield* TestClock.adjust("5 seconds"); + assert.strictEqual((yield* Fiber.join(ownCheckout))._tag, "Failure"); + assert.strictEqual(yield* Ref.get(snapshots), beforeOwn + 1); }).pipe(Effect.provide(layerTest)); }), ); @@ -206,9 +376,11 @@ it.effect("records a repository probe failure as an error checkpoint, not a miss }), ), }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), ), ), Layer.provideMerge(NodeCrypto.layer), + Layer.provideMerge(NodeServices.layer), ); const statuses = yield* Effect.gen(function* () { @@ -226,10 +398,87 @@ it.effect("records a repository probe failure as an error checkpoint, not a miss nodeId: scope.nodeId!, appRunOrdinal: 1, capturedAt: scope.createdAt, + repoWorktrees: [serviceWorktree], + }); + const repositoryBaseline = yield* checkpoints.captureRepositoryBaseline({ + threadId: scope.threadId, + repoWorktree: serviceWorktree, + ordinalWithinScope: 0, }); - return { baseline: baseline.status, turn: turn.status }; + return { + baseline: baseline.status, + turn: turn.status, + repositoryTurn: turn.repositories?.map((entry) => entry.status), + repositoryBaseline, + }; }).pipe(Effect.provide(layerTest)); - assert.deepEqual(statuses, { baseline: "error", turn: "error" }); + assert.deepEqual(statuses, { + baseline: "error", + turn: "error", + repositoryTurn: ["error"], + repositoryBaseline: "error", + }); + }), +); + +it.effect("finishes repository baselines when the workspace baseline fails", () => + Effect.gen(function* () { + const scope: OrchestrationV2CheckpointScope = { + id: CheckpointScopeId.make("checkpoint-scope:workspace-failure"), + threadId: ThreadId.make("thread:workspace-failure"), + runId: RunId.make("run:workspace-failure:1"), + nodeId: NodeId.make("node:workspace-failure:1"), + parentScopeId: null, + providerThreadId: ProviderThreadId.make("provider-thread:workspace-failure"), + kind: "root_run", + ordinalWithinParent: 0, + advancesAppRunCount: true, + cwd: "/repo/broken-config", + createdAt: DateTime.makeUnsafe("2026-07-28T00:00:00.000Z"), + }; + const captured = yield* Ref.make>([]); + const repositoryStarted = yield* Deferred.make(); + const layerTest = CheckpointService.layer.pipe( + Layer.provide( + Layer.mergeAll( + IdAllocator.layer, + Layer.mock(CheckpointStore.CheckpointStore)({ + isGitRepository: (cwd) => + cwd === scope.cwd + ? Effect.fail( + new VcsProcessExitError({ + operation: "GitVcsDriver.detectRepository", + command: "git", + cwd, + exitCode: 128, + detail: "fatal: bad config line 1 in file .git/config", + }), + ) + : Effect.succeed(true), + hasCheckpointRef: () => Effect.succeed(false), + captureCheckpoint: ({ cwd }) => + Deferred.succeed(repositoryStarted, undefined).pipe( + Effect.andThen(Effect.sleep("1 second")), + Effect.andThen(Ref.update(captured, (paths) => [...paths, cwd])), + ), + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + ), + ), + Layer.provideMerge(NodeCrypto.layer), + Layer.provideMerge(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const checkpoints = yield* CheckpointService.CheckpointServiceV2; + const baseline = yield* checkpoints + .captureBaseline({ scope, ordinalWithinScope: 1, repoWorktrees: [serviceWorktree] }) + .pipe(Effect.result, Effect.forkChild); + yield* Deferred.await(repositoryStarted); + yield* TestClock.adjust("1 second"); + assert.strictEqual((yield* Fiber.join(baseline))._tag, "Failure"); + assert.deepEqual(yield* Ref.get(captured), [serviceWorktree.worktreePath]); + }).pipe(Effect.provide(layerTest)); }), ); diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index c37b206f35a87..dbf252a43ea4e 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -4,23 +4,32 @@ import { CheckpointScopeId, NodeId, OrchestrationV2Checkpoint, + type OrchestrationV2CheckpointRepository, OrchestrationV2CheckpointScope, + type OrchestrationV2CheckpointStatus, + type OrchestrationV2Run, ProviderThreadId, RunId, ThreadId, + type ThreadRepoWorktree, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as Hex from "effect/encoding/Hex"; +import * as FileSystem from "effect/FileSystem"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { isGitImport, parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { CHECKPOINT_DIFF_MAX_OUTPUT_BYTES } from "../vcs/VcsDriver.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; const CHECKPOINT_REFS_PREFIX = "refs/t3/orchestration-v2/checkpoints"; @@ -94,16 +103,107 @@ export class CheckpointDeleteStaleRefsError extends Schema.TaggedError()( + "CheckpointRepositoryUnavailableError", + { + repository: Schema.String, + worktreePath: Schema.String, + reason: Schema.Literals([ + "checkpoint-missing", + "worktree-unrelated", + "source-missing", + "branch-missing", + "branch-in-use", + "submodule-changes", + "submodule-commit-missing", + "submodule-untracked-files", + ]), + submodulePath: Schema.optional(Schema.String), + }, +) { + override get message(): string { + const at = `${this.repository} at ${this.worktreePath}`; + switch (this.reason) { + case "checkpoint-missing": + return `No checkpoint of ${at} is left for this turn.`; + case "worktree-unrelated": + return `${this.worktreePath} is no longer a worktree of ${this.repository}, so it was left as it is.`; + case "source-missing": + return `The worktree of ${at} is gone, and so is the checkout it came from, so it can't be recreated.`; + case "branch-missing": + return `The worktree of ${at} is gone, and its branch no longer exists, so it can't be recreated.`; + case "branch-in-use": + return `The worktree of ${at} is gone, and another worktree has its branch checked out, so it can't be recreated.`; + case "submodule-changes": + return `The submodule ${this.submodulePath ?? ""} in ${at} has uncommitted changes. Commit or move them first.`; + case "submodule-commit-missing": + return `The submodule ${this.submodulePath ?? ""} in ${at} no longer has the commit this turn used.`; + case "submodule-untracked-files": + return `The submodule ${this.submodulePath ?? ""} in ${at} has untracked or ignored files that the commit this turn used would replace. Move them first.`; + } + } +} + +export class CheckpointRepositoryRestoreError extends Schema.TaggedError()( + "CheckpointRepositoryRestoreError", + { + repository: Schema.String, + worktreePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to restore the files of ${this.repository} at ${this.worktreePath}.`; + } +} + export const CheckpointServiceV2Error = Schema.Union([ CheckpointRootScopePrepareError, CheckpointBaselineCaptureError, CheckpointCaptureError, CheckpointRestoreError, CheckpointDeleteStaleRefsError, + CheckpointRepositoryUnavailableError, + CheckpointRepositoryRestoreError, ]); export type CheckpointServiceV2Error = typeof CheckpointServiceV2Error.Type; const isCheckpointRestoreError = Schema.is(CheckpointRestoreError); +const isCheckpointRepositoryUnavailableError = Schema.is(CheckpointRepositoryUnavailableError); + +/** Which repository worktree a snapshot belongs to. */ +export type CheckpointRepositoryIdentity = Pick< + OrchestrationV2CheckpointRepository, + "repository" | "repositoryPath" | "worktreePath" | "branch" +>; + +/** What the history walk needs from a thread's checkpoints and runs. */ +export interface RepositoryCheckpointHistory { + readonly scopeId: CheckpointScopeId; + readonly checkpoints: ReadonlyArray< + Pick + >; + readonly runs: ReadonlyArray>; +} + +/** + * The thread's runs. A snapshot left where a rolled-back run ended is reused + * as a baseline only if it was captured as one since. + */ +export type CheckpointRunHistory = ReadonlyArray>; + +export type RepositorySnapshot = + | { readonly _tag: "found"; readonly ordinal: number; readonly ref: CheckpointRef } + /** A snapshot this ordinal should have is missing or failed to capture. */ + | { readonly _tag: "unavailable"; readonly ordinal: number } + /** The thread has no snapshot of the worktree at or after the target. */ + | { readonly _tag: "none" }; + +export interface RepositoryRestorePlan { + /** The worktree folder is gone and gets recreated from its branch before the restore. */ + readonly recreate: boolean; +} export interface CheckpointServiceV2Shape { readonly prepareRootRunScope: (input: { @@ -114,16 +214,30 @@ export interface CheckpointServiceV2Shape { readonly cwd: string; readonly createdAt: DateTime.Utc; }) => Effect.Effect; + /** Repository worktrees are captured first and never fail the call. */ readonly captureBaseline: (input: { readonly scope: OrchestrationV2CheckpointScope; readonly ordinalWithinScope: number; + readonly repoWorktrees?: ReadonlyArray; + readonly runs?: CheckpointRunHistory; }) => Effect.Effect; + /** + * Snapshots a worktree the thread is attaching, before the agent edits it, + * so the turn's diff and rollback start from what was already there. + */ + readonly captureRepositoryBaseline: (input: { + readonly threadId: ThreadId; + readonly repoWorktree: ThreadRepoWorktree; + readonly ordinalWithinScope: number; + readonly runs?: CheckpointRunHistory; + }) => Effect.Effect; readonly materializeBaselineCheckpoint: (input: { readonly scope: OrchestrationV2CheckpointScope; readonly ordinalWithinScope: number; /** The run materializing the baseline: its root node, and when it was requested. */ readonly nodeId: NodeId; readonly capturedAt: DateTime.Utc; + readonly runs?: CheckpointRunHistory; /** Records a missing checkpoint without touching git, for side chats sharing a parent's worktree. */ readonly forceMissing?: boolean; }) => Effect.Effect; @@ -134,15 +248,55 @@ export interface CheckpointServiceV2Shape { readonly ordinalWithinScope: number; readonly appRunOrdinal: number | null; readonly capturedAt: DateTime.Utc; + readonly repoWorktrees?: ReadonlyArray; + /** Decides whether the previous ordinal's snapshot can be counted against. */ + readonly runs?: CheckpointRunHistory; + /** The thread's checkpoints so far; their path prefixes stay their worktrees'. */ + readonly checkpoints?: ReadonlyArray< + Pick + >; readonly forceMissing?: boolean; }) => Effect.Effect; + /** + * Gives earlier checkpoints that have no usable snapshot (a folder that is + * not a Git repository, say) the repository snapshots a rollback to them + * would restore, so those turns can be rewound too. + */ + readonly backfillRepositories: (input: { + readonly scope: OrchestrationV2CheckpointScope; + readonly checkpoint: OrchestrationV2Checkpoint; + readonly checkpoints: ReadonlyArray; + readonly runs: RepositoryCheckpointHistory["runs"]; + }) => Effect.Effect, CheckpointServiceV2Error>; + readonly resolveRepositorySnapshot: (input: { + readonly history: RepositoryCheckpointHistory; + readonly repository: CheckpointRepositoryIdentity; + readonly targetOrdinal: number; + }) => Effect.Effect; + /** Checks a repository restore before anything changes. */ + readonly inspectRepositoryRestore: (input: { + readonly repository: CheckpointRepositoryIdentity; + readonly ref: CheckpointRef; + }) => Effect.Effect; + readonly restoreRepository: (input: { + readonly repository: CheckpointRepositoryIdentity; + readonly ref: CheckpointRef; + }) => Effect.Effect; + /** The key of the repository lease that attaching and removing its worktrees take. */ + readonly repositoryLeaseKey: ( + repository: Pick, + ) => Effect.Effect; readonly restore: (input: { readonly scope: OrchestrationV2CheckpointScope; readonly checkpoint: OrchestrationV2Checkpoint; + /** Also move submodule checkouts, as a repository worktree restore does. */ + readonly alignSubmodules?: boolean; }) => Effect.Effect; + /** Repository refs that a `keep` checkpoint still names survive. */ readonly deleteStaleRefs: (input: { readonly scope: OrchestrationV2CheckpointScope; readonly checkpoints: ReadonlyArray; + readonly keep?: ReadonlyArray>; }) => Effect.Effect; } @@ -151,19 +305,197 @@ export class CheckpointServiceV2 extends Context.Service< CheckpointServiceV2Shape >()("t3/orchestration-v2/CheckpointService/CheckpointServiceV2") {} +const checkpointRefForKey = Effect.fn("checkpointRefForKey")(function* ( + key: string, + ordinalWithinScope: number, +) { + const sha256 = yield* Digest.Digest; + const digest = yield* sha256 + .digest(Digest.sha256, new TextEncoder().encode(key)) + .pipe(Effect.orDie); + const scopeKey = Hex.encode(digest).slice(0, 32); + return CheckpointRef.make( + `${CHECKPOINT_REFS_PREFIX}/${Base64Url.encode(scopeKey)}/ordinal/${ordinalWithinScope}`, + ); +}); + export const checkpointRefForScopeOrdinal = Effect.fn("checkpointRefForScopeOrdinal")( function* (input: { readonly scopeId: CheckpointScopeId; readonly ordinalWithinScope: number }) { - const crypto = yield* Digest.Digest; - const digest = yield* crypto - .digest(Digest.sha256, new TextEncoder().encode(input.scopeId)) - .pipe(Effect.orDie); - const scopeKey = Hex.encode(digest).slice(0, 32); - return CheckpointRef.make( - `${CHECKPOINT_REFS_PREFIX}/${Base64Url.encode(scopeKey)}/ordinal/${input.ordinalWithinScope}`, - ); + return yield* checkpointRefForKey(input.scopeId, input.ordinalWithinScope); }, ); +/** + * Lives in the worktree's repository. Two worktrees of one repository share + * its refs, so the worktree path is part of the key. + */ +const checkpointRefForRepository = Effect.fn("checkpointRefForRepository")(function* (input: { + readonly scopeId: CheckpointScopeId; + readonly worktreePath: string; + readonly ordinalWithinScope: number; +}) { + return yield* checkpointRefForKey( + `${input.scopeId}\u0000repository\u0000${input.worktreePath}`, + input.ordinalWithinScope, + ); +}); + +/** The status of the scope's own workspace snapshot. */ +export const checkpointWorkspaceStatus = ( + checkpoint: Pick, +): OrchestrationV2CheckpointStatus => checkpoint.workspaceStatus ?? checkpoint.status; + +function aggregateCheckpointStatus( + workspaceStatus: OrchestrationV2CheckpointStatus, + repositories: ReadonlyArray>, +): OrchestrationV2CheckpointStatus { + const statuses = [workspaceStatus, ...repositories.map((repository) => repository.status)]; + if (statuses.includes("ready")) return "ready"; + return statuses.includes("error") ? "error" : "missing"; +} + +/** Paths the checkpoint lists for its own workspace, without the repositories' paths. */ +export function checkpointWorkspaceFiles( + checkpoint: Pick, +): OrchestrationV2Checkpoint["files"] { + const prefixes = (checkpoint.repositories ?? []).map((repository) => `${repository.pathPrefix}/`); + return prefixes.length === 0 + ? checkpoint.files + : checkpoint.files.filter((file) => !prefixes.some((prefix) => file.path.startsWith(prefix))); +} + +/** + * Names each worktree's paths after its repository; two worktrees of one repository, ever in + * the scope, or of the folder itself, add the folder name, and a number if those match too. + * An earlier checkpoint's prefix stays its worktree's, so diffs across turns name each apart. + */ +export function repositoryPathPrefixes( + path: Path.Path, + repositories: ReadonlyArray< + Pick + >, + earlier: ReadonlyArray< + Pick< + OrchestrationV2CheckpointRepository, + "repository" | "repositoryPath" | "worktreePath" | "pathPrefix" + > + > = [], +): ReadonlyMap { + const nameOf = (entry: Pick) => + entry.repository === "." + ? path.basename(entry.repositoryPath) || "repository" + : entry.repository; + // The first worktree to use a prefix owns it. + const owners = new Map(); + const kept = new Map(); + for (const entry of earlier) { + if (!owners.has(entry.pathPrefix)) owners.set(entry.pathPrefix, entry.worktreePath); + kept.set(entry.worktreePath, entry.pathPrefix); + } + const worktreesByName = new Map>(); + for (const entry of [...earlier, ...repositories]) { + const worktrees = worktreesByName.get(nameOf(entry)) ?? new Set(); + worktreesByName.set(nameOf(entry), worktrees.add(entry.worktreePath)); + } + const used = new Set(owners.keys()); + return new Map( + repositories.map((entry) => { + const previous = kept.get(entry.worktreePath); + if (previous !== undefined && owners.get(previous) === entry.worktreePath) { + return [entry.worktreePath, previous] as const; + } + const name = nameOf(entry); + const base = + entry.repository === "." || (worktreesByName.get(name)?.size ?? 0) > 1 + ? `${name}@${path.basename(entry.worktreePath)}` + : name; + let prefix = base; + for (let suffix = 2; used.has(prefix); suffix += 1) prefix = `${base}-${suffix}`; + used.add(prefix); + return [entry.worktreePath, prefix] as const; + }), + ); +} + +/** + * The snapshot of a repository worktree that rewinding to `targetOrdinal` restores: the one the + * target checkpoint lists, or else the earliest one taken after it, from when the thread first + * attached the worktree, even if that run was rolled back. A snapshot a later checkpoint relied on + * is never passed over for a newer one. + */ +export const resolveRepositorySnapshot = Effect.fn("resolveRepositorySnapshot")(function* (input: { + readonly history: RepositoryCheckpointHistory; + readonly worktreePath: string; + /** A checkout of the repository; its refs live in the common Git directory. */ + readonly cwd: string; + readonly targetOrdinal: number; + /** Only snapshots before this ordinal count. */ + readonly beforeOrdinal?: number; +}) { + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const entryAt = (ordinal: number, options?: { readonly includeStale: boolean }) => + input.history.checkpoints + .find( + (checkpoint) => + checkpoint.scopeId === input.history.scopeId && + checkpoint.ordinalWithinScope === ordinal && + (options?.includeStale === true || checkpoint.status !== "stale"), + ) + ?.repositories?.find((repository) => repository.worktreePath === input.worktreePath); + const target = entryAt(input.targetOrdinal); + if (target !== undefined) { + return target.status === "ready" + ? ({ _tag: "found", ordinal: input.targetOrdinal, ref: target.ref } as const) + : ({ _tag: "unavailable", ordinal: input.targetOrdinal } as const); + } + const before = input.beforeOrdinal ?? Number.POSITIVE_INFINITY; + const existing = (ordinal: number) => + checkpointRefForRepository({ + scopeId: input.history.scopeId, + worktreePath: input.worktreePath, + ordinalWithinScope: ordinal, + }).pipe( + Effect.flatMap((ref) => + checkpointStore.hasCheckpointRef({ cwd: input.cwd, checkpointRef: ref }).pipe( + Effect.orElseSucceed(() => false), + Effect.map((exists) => (exists ? ref : null)), + ), + ), + ); + // Rolled-back runs count: a rewind deletes the snapshots its runs took at + // their end, not the one taken when a run first attached a worktree, and a + // retry that finishes restoring files must find the same ones as the + // attempt before it. + const laterRuns = [ + ...new Set( + input.history.runs + .filter((run) => run.ordinal > input.targetOrdinal) + .map((run) => run.ordinal), + ), + ].toSorted((left, right) => left - right); + // A run's baseline sits one ordinal below it. + for (const runOrdinal of laterRuns) { + const ordinal = runOrdinal - 1; + if (ordinal >= before) break; + const ref = yield* existing(ordinal); + if (ref !== null) return { _tag: "found", ordinal, ref } as const; + // A rolled-back run's stale checkpoint still records that it captured the + // worktree. One a later turn backfilled records no such thing. + const entry = entryAt(runOrdinal, { includeStale: true }); + if (entry !== undefined && entry.backfilled !== true) { + return { _tag: "unavailable", ordinal } as const; + } + } + // Attached between turns: the baseline sits at the latest run's ordinal, + // which the loop above never reaches. + const latest = Math.max(input.targetOrdinal, ...input.history.runs.map((run) => run.ordinal)); + if (latest < before) { + const ref = yield* existing(latest); + if (ref !== null) return { _tag: "found", ordinal: latest, ref } as const; + } + return { _tag: "none" } as const; +}); + function checkpointIdForScopeOrdinal( idAllocator: IdAllocator.IdAllocatorV2["Service"], input: { @@ -234,24 +566,63 @@ function makeCheckpoint(input: { }; } +function withRepositories( + checkpoint: OrchestrationV2Checkpoint, + repositories: ReadonlyArray, + repositoryFiles: OrchestrationV2Checkpoint["files"], +): OrchestrationV2Checkpoint { + if (repositories.length === 0) return checkpoint; + const workspaceStatus = checkpointWorkspaceStatus(checkpoint); + return { + ...checkpoint, + status: aggregateCheckpointStatus(workspaceStatus, repositories), + files: [...checkpointWorkspaceFiles(checkpoint), ...repositoryFiles], + workspaceStatus, + repositories, + }; +} + +/** `git config -z --get-regexp` records: the key, a newline, then the value. */ +function parseConfigValues(stdout: string): ReadonlyArray { + return stdout + .split("\0") + .filter((record) => record.length > 0) + .flatMap((record) => { + const newline = record.indexOf("\n"); + return newline < 0 ? [] : [record.slice(newline + 1)]; + }); +} + export const layer: Layer.Layer< CheckpointServiceV2, never, - CheckpointStore.CheckpointStore | Digest.Digest | IdAllocator.IdAllocatorV2 + | CheckpointStore.CheckpointStore + | Digest.Digest + | IdAllocator.IdAllocatorV2 + | GitVcsDriver.GitVcsDriver + | FileSystem.FileSystem + | Path.Path > = Layer.effect( CheckpointServiceV2, Effect.gen(function* () { const checkpointStore = yield* CheckpointStore.CheckpointStore; - const crypto = yield* Digest.Digest; + const digest = yield* Digest.Digest; + const git = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const isWindows = yield* HostProcess.isWindows; + const provideDigest = Effect.provideService(Digest.Digest, digest); const checkpointRefFor = (input: Parameters[0]) => - checkpointRefForScopeOrdinal(input).pipe(Effect.provideService(Digest.Digest, crypto)); + checkpointRefForScopeOrdinal(input).pipe(provideDigest); + const repositoryRefFor = (input: Parameters[0]) => + checkpointRefForRepository(input).pipe(provideDigest); const idAllocator = yield* IdAllocator.IdAllocatorV2; const workspaceLocks = yield* KeyedLock.make(); const withWorkspaceLock = (cwd: string, effect: Effect.Effect) => workspaceLocks.withLock(cwd, effect); // A failed probe is an operational error, not proof that the workspace has no repository. - const probeRepository = (scope: OrchestrationV2CheckpointScope) => + const probeRepository = (scope: Pick) => checkpointStore.isGitRepository(scope.cwd).pipe( Effect.map((isRepository) => isRepository ? ("repository" as const) : ("missing" as const), @@ -263,50 +634,267 @@ export const layer: Layer.Layer< }).pipe(Effect.as("error" as const)), ), ); + const exists = (target: string) => + fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false)); + const realPathOr = (target: string) => + fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => path.resolve(target))); + const pathIdentity = (target: string) => + realPathOr(target).pipe( + Effect.map((resolved) => (isWindows ? resolved.toLowerCase() : resolved)), + ); + const probe = ( + cwd: string, + operation: string, + args: ReadonlyArray, + maxOutputBytes = 256 * 1024, + ) => + git + .execute({ + operation: `CheckpointService.${operation}`, + cwd, + args, + allowNonZeroExit: true, + timeoutMs: 30_000, + maxOutputBytes, + }) + .pipe( + Effect.map((result) => ({ ok: result.exitCode === 0, stdout: result.stdout })), + Effect.orElseSucceed(() => ({ ok: false, stdout: "" })), + ); + const run = (cwd: string, operation: string, args: ReadonlyArray) => + git.execute({ operation: `CheckpointService.${operation}`, cwd, args, timeoutMs: 60_000 }); - const captureBaseline: CheckpointServiceV2Shape["captureBaseline"] = (input) => + const changedFiles = (input: { + readonly scopeId: CheckpointScopeId; + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; + }) => { + const refs = { + cwd: input.cwd, + fromCheckpointRef: input.fromCheckpointRef, + toCheckpointRef: input.toCheckpointRef, + }; + // A pull or rebase can change thousands of files the turn did not write. + return Effect.all([ + checkpointStore.diffCheckpoints({ + ...refs, + fallbackFromToHead: false, + ignoreWhitespace: false, + format: "numstat", + }), + checkpointStore.listAuthoredPaths(refs).pipe( + Effect.catch((cause) => + Effect.logWarning("orchestration V2 checkpoint authored paths failed", { + scopeId: input.scopeId, + checkpointRef: input.toCheckpointRef, + cause: String(cause), + }).pipe(Effect.as(null)), + ), + ), + ]).pipe( + Effect.map(([diff, authoredPaths]) => + parseTurnDiffFilesFromNumstat(diff) + .filter((file) => !isGitImport(file, authoredPaths)) + .map((file) => ({ + path: file.path, + kind: "modified", + additions: file.additions, + deletions: file.deletions, + })), + ), + Effect.catch((cause) => + Effect.logWarning("orchestration V2 checkpoint diff summary failed", { + scopeId: input.scopeId, + checkpointRef: input.toCheckpointRef, + cause: String(cause), + }).pipe(Effect.as([])), + ), + ); + }; + + const refExists = (cwd: string, checkpointRef: CheckpointRef) => + checkpointStore.hasCheckpointRef({ cwd, checkpointRef }).pipe( + Effect.catch((cause) => + Effect.logWarning("orchestration V2 checkpoint ref lookup failed", { + checkpointRef, + cause: String(cause), + }).pipe(Effect.as(false)), + ), + ); + + // A rewind deletes the snapshots its runs took at their end, but Git can + // refuse, as when another process holds the ref's lock. One left where a + // rolled-back run ended still holds that run's files. Only a baseline + // captured there since matches what the next run starts from. + const isStaleSnapshot = (input: { + readonly cwd: string; + readonly checkpointRef: CheckpointRef; + readonly ordinalWithinScope: number; + readonly runs: CheckpointRunHistory | undefined; + }) => + input.runs?.some( + (run) => run.ordinal === input.ordinalWithinScope && run.status === "rolled_back", + ) === true + ? checkpointStore + .isBaselineCheckpoint({ cwd: input.cwd, checkpointRef: input.checkpointRef }) + .pipe( + Effect.map((baseline) => !baseline), + Effect.catch((cause) => + Effect.logWarning("orchestration V2 checkpoint baseline lookup failed", { + checkpointRef: input.checkpointRef, + cause: String(cause), + }).pipe(Effect.as(true)), + ), + ) + : Effect.succeed(false); + + const captureRepositorySnapshotIfMissing = (input: { + readonly scopeId: CheckpointScopeId; + readonly repoWorktree: ThreadRepoWorktree; + readonly ordinalWithinScope: number; + readonly runs: CheckpointRunHistory | undefined; + }): Effect.Effect => withWorkspaceLock( - input.scope.cwd, + input.repoWorktree.worktreePath, Effect.gen(function* () { - if (!(yield* checkpointStore.isGitRepository(input.scope.cwd))) { - return; - } - - const checkpointRef = yield* checkpointRefFor({ - scopeId: input.scope.id, + const cwd = input.repoWorktree.worktreePath; + const repository = yield* probeRepository({ id: input.scopeId, cwd }); + if (repository !== "repository") return repository; + const checkpointRef = yield* repositoryRefFor({ + scopeId: input.scopeId, + worktreePath: cwd, ordinalWithinScope: input.ordinalWithinScope, }); - const exists = yield* checkpointStore.hasCheckpointRef({ - cwd: input.scope.cwd, - checkpointRef, - }); - if (exists) { - return; + if ( + (yield* refExists(cwd, checkpointRef)) && + !(yield* isStaleSnapshot({ ...input, cwd, checkpointRef })) + ) { + return "ready"; } - yield* checkpointStore - .captureCheckpoint({ - cwd: input.scope.cwd, - checkpointRef, - }) - .pipe( - Effect.timeout(BASELINE_CAPTURE_BUDGET), - Effect.catchTags({ - VcsUnsupportedOperationError: () => Effect.void, - }), - ); + .captureCheckpoint({ cwd, checkpointRef, baseline: true }) + .pipe(Effect.timeout(BASELINE_CAPTURE_BUDGET)); + return "ready" as const; }), ).pipe( - Effect.mapError( - (cause) => - new CheckpointBaselineCaptureError({ - scopeId: input.scope.id, - ordinalWithinScope: input.ordinalWithinScope, - cause, - }), + Effect.catch((cause) => + Effect.logWarning("orchestration V2 repository baseline capture failed", { + scopeId: input.scopeId, + ordinalWithinScope: input.ordinalWithinScope, + cause: String(cause), + }).pipe(Effect.as("error" as const)), ), ); + // Snapshots run side by side so the whole baseline, not each checkout, gets the budget. + const captureBaseline: CheckpointServiceV2Shape["captureBaseline"] = Effect.fn( + "CheckpointService.captureBaseline", + )( + function* (input) { + const workspaceKey = yield* pathIdentity(input.scope.cwd); + // As in capture, the workspace snapshot covers a checkout the thread attached itself. + const repoWorktrees = yield* Effect.filter(input.repoWorktrees ?? [], (repoWorktree) => + pathIdentity(repoWorktree.worktreePath).pipe(Effect.map((key) => key !== workspaceKey)), + ); + const [, workspace] = yield* Effect.all( + [ + Effect.forEach( + repoWorktrees, + (repoWorktree) => + captureRepositorySnapshotIfMissing({ + scopeId: input.scope.id, + repoWorktree, + ordinalWithinScope: input.ordinalWithinScope, + runs: input.runs, + }), + { discard: true, concurrency: "unbounded" }, + ), + // Held as a result, so a workspace failure lets repository snapshots finish. + Effect.exit( + withWorkspaceLock( + input.scope.cwd, + Effect.gen(function* () { + if (!(yield* checkpointStore.isGitRepository(input.scope.cwd))) { + return; + } + + const checkpointRef = yield* checkpointRefFor({ + scopeId: input.scope.id, + ordinalWithinScope: input.ordinalWithinScope, + }); + const exists = yield* checkpointStore.hasCheckpointRef({ + cwd: input.scope.cwd, + checkpointRef, + }); + if ( + exists && + !(yield* isStaleSnapshot({ + cwd: input.scope.cwd, + checkpointRef, + ordinalWithinScope: input.ordinalWithinScope, + runs: input.runs, + })) + ) { + return; + } + + yield* checkpointStore + .captureCheckpoint({ + cwd: input.scope.cwd, + checkpointRef, + baseline: true, + }) + .pipe( + Effect.timeout(BASELINE_CAPTURE_BUDGET), + Effect.catchTags({ + VcsUnsupportedOperationError: () => Effect.void, + }), + ); + }), + ), + ), + ], + { concurrency: "unbounded" }, + ); + return yield* workspace; + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new CheckpointBaselineCaptureError({ + scopeId: input.scope.id, + ordinalWithinScope: input.ordinalWithinScope, + cause, + }), + ), + ), + ); + + const captureRepositoryBaseline: CheckpointServiceV2Shape["captureRepositoryBaseline"] = ( + input, + ) => + idAllocator.allocate + .checkpointScope({ threadId: input.threadId, name: ROOT_CHECKPOINT_SCOPE_NAME }) + .pipe( + Effect.flatMap((scopeId) => + captureRepositorySnapshotIfMissing({ + scopeId, + repoWorktree: input.repoWorktree, + ordinalWithinScope: input.ordinalWithinScope, + runs: input.runs, + }), + ), + Effect.catchTags({ + IdAllocatorV2AllocationError: (cause) => + Effect.logWarning("orchestration V2 repository baseline scope was not allocated", { + threadId: input.threadId, + cause: String(cause), + }).pipe(Effect.as("error" as const)), + }), + ); + const materializeBaselineCheckpoint: CheckpointServiceV2Shape["materializeBaselineCheckpoint"] = (input) => withWorkspaceLock( @@ -340,6 +928,14 @@ export const layer: Layer.Layer< ), ) : repository; + const stale = + status === "ready" && + (yield* isStaleSnapshot({ + cwd: input.scope.cwd, + checkpointRef, + ordinalWithinScope: input.ordinalWithinScope, + runs: input.runs, + })); return makeCheckpoint({ id: checkpointId, scope: input.scope, @@ -349,7 +945,7 @@ export const layer: Layer.Layer< ordinalWithinScope: input.ordinalWithinScope, appRunOrdinal: null, ref: checkpointRef, - status, + status: stale ? "error" : status, files: [], capturedAt: input.capturedAt, }); @@ -364,7 +960,7 @@ export const layer: Layer.Layer< ), ); - const capture: CheckpointServiceV2Shape["capture"] = (input) => + const captureWorkspace = (input: Parameters[0]) => withWorkspaceLock( input.scope.cwd, Effect.gen(function* () { @@ -455,49 +1051,21 @@ export const layer: Layer.Layer< }).pipe(Effect.as(false)), ), ); - const refs = { - cwd: input.scope.cwd, - fromCheckpointRef: previousCheckpointRef, - toCheckpointRef: checkpointRef, - }; - // A pull or rebase can change thousands of files the turn did not write. - const files = previousExists - ? yield* Effect.all([ - checkpointStore.diffCheckpoints({ - ...refs, - fallbackFromToHead: false, - ignoreWhitespace: false, - format: "numstat", - }), - checkpointStore.listAuthoredPaths(refs).pipe( - Effect.catch((cause) => - Effect.logWarning("orchestration V2 checkpoint authored paths failed", { - scopeId: input.scope.id, - checkpointRef, - cause: String(cause), - }).pipe(Effect.as(null)), - ), - ), - ]).pipe( - Effect.map(([diff, authoredPaths]) => - parseTurnDiffFilesFromNumstat(diff) - .filter((file) => !isGitImport(file, authoredPaths)) - .map((file) => ({ - path: file.path, - kind: "modified", - additions: file.additions, - deletions: file.deletions, - })), - ), - Effect.catch((cause) => - Effect.logWarning("orchestration V2 checkpoint diff summary failed", { - scopeId: input.scope.id, - checkpointRef, - cause: String(cause), - }).pipe(Effect.as([])), - ), - ) - : []; + const files = + previousExists && + !(yield* isStaleSnapshot({ + cwd: input.scope.cwd, + checkpointRef: previousCheckpointRef, + ordinalWithinScope: input.ordinalWithinScope - 1, + runs: input.runs, + })) + ? yield* changedFiles({ + scopeId: input.scope.id, + cwd: input.scope.cwd, + fromCheckpointRef: previousCheckpointRef, + toCheckpointRef: checkpointRef, + }) + : []; return makeCheckpoint({ id: checkpointId, @@ -513,28 +1081,550 @@ export const layer: Layer.Layer< capturedAt: input.capturedAt, }); }), + ); + + const captureRepository = (input: { + readonly scopeId: CheckpointScopeId; + readonly repoWorktree: ThreadRepoWorktree; + readonly pathPrefix: string; + readonly ordinalWithinScope: number; + readonly runs: CheckpointRunHistory | undefined; + }) => + withWorkspaceLock( + input.repoWorktree.worktreePath, + Effect.gen(function* () { + const cwd = input.repoWorktree.worktreePath; + const checkpointRef = yield* repositoryRefFor({ + scopeId: input.scopeId, + worktreePath: cwd, + ordinalWithinScope: input.ordinalWithinScope, + }); + const entry = ( + status: OrchestrationV2CheckpointStatus, + ): OrchestrationV2CheckpointRepository => ({ + repository: input.repoWorktree.repository, + repositoryPath: input.repoWorktree.repositoryPath, + worktreePath: cwd, + branch: input.repoWorktree.branch, + pathPrefix: input.pathPrefix, + ref: checkpointRef, + status, + }); + const repository = yield* probeRepository({ id: input.scopeId, cwd }); + if (repository !== "repository") { + return { entry: entry(repository), files: [] }; + } + const captured = yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef }).pipe( + Effect.as(true), + Effect.catch((cause) => + Effect.logWarning("orchestration V2 repository checkpoint capture failed", { + scopeId: input.scopeId, + checkpointRef, + cause: String(cause), + }).pipe(Effect.as(false)), + ), + ); + if (!captured) return { entry: entry("error"), files: [] }; + const previousCheckpointRef = yield* repositoryRefFor({ + scopeId: input.scopeId, + worktreePath: cwd, + ordinalWithinScope: Math.max(0, input.ordinalWithinScope - 1), + }); + const files = + input.ordinalWithinScope > 0 && + (yield* refExists(cwd, previousCheckpointRef)) && + !(yield* isStaleSnapshot({ + cwd, + checkpointRef: previousCheckpointRef, + ordinalWithinScope: input.ordinalWithinScope - 1, + runs: input.runs, + })) + ? yield* changedFiles({ + scopeId: input.scopeId, + cwd, + fromCheckpointRef: previousCheckpointRef, + toCheckpointRef: checkpointRef, + }) + : []; + return { + entry: entry("ready"), + files: files.map((file) => ({ ...file, path: `${input.pathPrefix}/${file.path}` })), + }; + }), + ); + + const capture: CheckpointServiceV2Shape["capture"] = Effect.fn("CheckpointService.capture")( + function* (input) { + const workspace = yield* captureWorkspace(input); + const workspaceKey = yield* pathIdentity(input.scope.cwd); + const repoWorktrees: Array = []; + for (const repoWorktree of input.repoWorktrees ?? []) { + // A thread can attach its own checkout; the workspace snapshot covers it. + if ((yield* pathIdentity(repoWorktree.worktreePath)) !== workspaceKey) { + repoWorktrees.push(repoWorktree); + } + } + const prefixes = repositoryPathPrefixes( + path, + repoWorktrees, + (input.checkpoints ?? []).flatMap((checkpoint) => + checkpoint.scopeId === input.scope.id ? (checkpoint.repositories ?? []) : [], + ), + ); + const captured = yield* Effect.forEach(repoWorktrees, (repoWorktree) => + captureRepository({ + scopeId: input.scope.id, + repoWorktree, + pathPrefix: prefixes.get(repoWorktree.worktreePath) ?? repoWorktree.repository, + ordinalWithinScope: input.ordinalWithinScope, + runs: input.runs, + }), + ); + return withRepositories( + workspace, + captured.map(({ entry }) => entry), + captured.flatMap(({ files }) => files), + ); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new CheckpointCaptureError({ + scopeId: input.scope.id, + cause, + }), + ), + ), + ); + + const resolveSnapshot: CheckpointServiceV2Shape["resolveRepositorySnapshot"] = Effect.fn( + "CheckpointService.resolveRepositorySnapshot", + )(function* (input) { + const cwd = (yield* exists(input.repository.worktreePath)) + ? input.repository.worktreePath + : input.repository.repositoryPath; + return yield* resolveRepositorySnapshot({ + history: input.history, + worktreePath: input.repository.worktreePath, + cwd, + targetOrdinal: input.targetOrdinal, + }).pipe( + provideDigest, + Effect.provideService(CheckpointStore.CheckpointStore, checkpointStore), + ); + }); + + const backfillRepositories: CheckpointServiceV2Shape["backfillRepositories"] = Effect.fn( + "CheckpointService.backfillRepositories", + )( + function* (input) { + const entries = input.checkpoint.repositories ?? []; + const latestOrdinal = input.checkpoint.ordinalWithinScope; + if (entries.length === 0) return []; + const scopeCheckpoints = [ + ...input.checkpoints.filter( + (candidate) => + candidate.scopeId === input.scope.id && candidate.id !== input.checkpoint.id, + ), + input.checkpoint, + ]; + const history: RepositoryCheckpointHistory = { + scopeId: input.scope.id, + checkpoints: scopeCheckpoints, + runs: input.runs, + }; + const ordinals = new Set( + [0, latestOrdinal - 1].filter((ordinal) => ordinal >= 0 && ordinal < latestOrdinal), + ); + for (const candidate of scopeCheckpoints) { + if (candidate.ordinalWithinScope < latestOrdinal && candidate.status === "missing") { + ordinals.add(candidate.ordinalWithinScope); + } + } + const workspaceRepository = yield* probeRepository(input.scope); + const upgraded: Array = []; + for (const ordinal of [...ordinals].toSorted((left, right) => left - right)) { + const existing = scopeCheckpoints.find( + (candidate) => candidate.ordinalWithinScope === ordinal, + ); + if ( + existing !== undefined && + (existing.status === "ready" || + existing.status === "stale" || + existing.repositories !== undefined) + ) { + continue; + } + const repositories: Array = []; + for (const entry of entries) { + const cwd = (yield* exists(entry.worktreePath)) + ? entry.worktreePath + : entry.repositoryPath; + const snapshot = yield* resolveRepositorySnapshot({ + history, + worktreePath: entry.worktreePath, + cwd, + targetOrdinal: ordinal, + beforeOrdinal: latestOrdinal, + }).pipe( + provideDigest, + Effect.provideService(CheckpointStore.CheckpointStore, checkpointStore), + ); + if (snapshot._tag === "found") { + repositories.push({ ...entry, ref: snapshot.ref, status: "ready", backfilled: true }); + } else if (snapshot._tag === "unavailable") { + repositories.push({ + ...entry, + ref: yield* repositoryRefFor({ + scopeId: input.scope.id, + worktreePath: entry.worktreePath, + ordinalWithinScope: snapshot.ordinal, + }), + status: "error", + backfilled: true, + }); + } + } + if (existing !== undefined && repositories.length === 0) continue; + const workspaceRef = + existing?.ref ?? + (yield* checkpointRefFor({ scopeId: input.scope.id, ordinalWithinScope: ordinal })); + const workspaceReady = + workspaceRepository === "repository" && + (yield* refExists(input.scope.cwd, workspaceRef)); + const base = + existing ?? + makeCheckpoint({ + id: yield* checkpointIdForScopeOrdinal(idAllocator, { + scopeId: input.scope.id, + ordinalWithinScope: ordinal, + }), + scope: input.scope, + runId: null, + nodeId: input.scope.nodeId, + parentCheckpointId: null, + ordinalWithinScope: ordinal, + appRunOrdinal: null, + ref: workspaceRef, + status: "missing", + files: [], + capturedAt: input.scope.createdAt, + }); + upgraded.push( + withRepositories( + { + ...base, + status: workspaceReady + ? "ready" + : base.status === "error" || workspaceRepository === "error" + ? "error" + : "missing", + }, + repositories, + [], + ), + ); + } + return upgraded; + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => new CheckpointCaptureError({ scopeId: input.scope.id, cause }), + ), + ), + ); + + const submoduleAlignment = Effect.fnUntraced(function* ( + worktreePath: string, + ref: CheckpointRef, + ) { + const listed = yield* probe(worktreePath, "listSubmodules", [ + "config", + "--blob", + `${ref}:.gitmodules`, + "-z", + "--get-regexp", + "^submodule\\..*\\.path$", + ]); + if (!listed.ok) return []; + const worktreeKey = yield* pathIdentity(worktreePath); + const changes: Array<{ + readonly path: string; + readonly dir: string; + readonly commit: string; + }> = []; + for (const submodulePath of parseConfigValues(listed.stdout)) { + const segments = submodulePath.split("/").filter((segment) => segment.length > 0); + if (segments.length === 0 || segments.some((segment) => segment === "..")) continue; + const dir = path.join(worktreePath, ...segments); + const toplevel = yield* probe(dir, "submoduleToplevel", ["rev-parse", "--show-toplevel"]); + // Not checked out, so nothing there to move. + if ( + !toplevel.ok || + (yield* pathIdentity(toplevel.stdout.trim())) !== (yield* pathIdentity(dir)) || + (yield* pathIdentity(dir)) === worktreeKey + ) { + continue; + } + const recorded = /^160000 commit ([0-9a-f]+)\t/.exec( + (yield* probe(worktreePath, "submoduleGitlink", [ + "ls-tree", + "-z", + `${ref}^{tree}`, + "--", + submodulePath, + ])).stdout, + ); + if (recorded === null) continue; + const head = (yield* probe(dir, "submoduleHead", ["rev-parse", "HEAD"])).stdout.trim(); + if (head !== recorded[1]) { + changes.push({ path: submodulePath, dir, commit: recorded[1]! }); + } + } + return changes; + }); + + const submoduleProblem = Effect.fnUntraced(function* (change: { + readonly dir: string; + readonly commit: string; + }) { + const status = yield* probe(change.dir, "submoduleChanges", [ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=no", + ]); + if (!status.ok || status.stdout.length > 0) return "submodule-changes" as const; + const present = yield* probe(change.dir, "submoduleCommit", [ + "cat-file", + "-e", + `${change.commit}^{commit}`, + ]); + if (!present.ok) return "submodule-commit-missing" as const; + // Checkout would replace an ignored file at a path the commit adds, or + // refuse an untracked one after other files were restored, so both refuse here. + const changed = yield* probe( + change.dir, + "submoduleAddedFiles", + ["diff-tree", "-r", "-z", "--name-status", "--diff-filter=AD", "HEAD", change.commit], + CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + ); + if (!changed.ok) return "submodule-changes" as const; + const fields = changed.stdout.split("\0"); + const removed = new Set(); + const added: Array = []; + for (let index = 0; index + 1 < fields.length; index += 2) { + if (fields[index] === "D") removed.add(fields[index + 1]!); + else added.push(fields[index + 1]!); + } + for (const file of added) { + const segments = file.split("/"); + for (let depth = 1; depth <= segments.length; depth += 1) { + const relative = segments.slice(0, depth).join("/"); + const found = yield* fileSystem + .lstat(path.join(change.dir, relative)) + .pipe(Effect.orElseSucceed(() => null)); + if (found === null) break; + // A file HEAD tracks where the commit has a folder is Git's to replace. + if (depth === segments.length || (found.type !== "Directory" && !removed.has(relative))) { + return "submodule-untracked-files" as const; + } + } + } + return null; + }); + + // The restored tree records each submodule's commit; its checkout follows, as pinning + // left it. All are checked before any file changes, and none moves unless all can. + const prepareSubmoduleAlignment = Effect.fnUntraced(function* ( + cwd: string, + ref: CheckpointRef, + ) { + const changes = yield* submoduleAlignment(cwd, ref); + for (const change of changes) { + const problem = yield* submoduleProblem(change); + if (problem !== null) return { problem, path: change.path } as const; + } + return { + problem: null, + move: Effect.forEach( + changes, + (change) => + run(change.dir, "alignSubmodule", [ + "checkout", + "--quiet", + "--no-overwrite-ignore", + "--detach", + change.commit, + ]), + { discard: true }, + ), + } as const; + }); + + const commonDirOf = (cwd: string) => + probe(cwd, "commonDir", ["rev-parse", "--path-format=absolute", "--git-common-dir"]).pipe( + Effect.flatMap((result) => + result.ok && result.stdout.trim() !== "" + ? pathIdentity(result.stdout.trim()) + : Effect.succeed(null), + ), + ); + + const inspectRepositoryRestore: CheckpointServiceV2Shape["inspectRepositoryRestore"] = + Effect.fn("CheckpointService.inspectRepositoryRestore")(function* (input) { + const { repository } = input; + const unavailable = ( + reason: CheckpointRepositoryUnavailableError["reason"], + submodulePath?: string, + ) => + new CheckpointRepositoryUnavailableError({ + repository: repository.repository, + worktreePath: repository.worktreePath, + reason, + ...(submodulePath === undefined ? {} : { submodulePath }), + }); + const sourceCommonDir = (yield* exists(repository.repositoryPath)) + ? yield* commonDirOf(repository.repositoryPath) + : null; + + if (!(yield* exists(repository.worktreePath))) { + if (sourceCommonDir === null) return yield* unavailable("source-missing"); + if (repository.branch === null) return yield* unavailable("branch-missing"); + const branchRef = `refs/heads/${repository.branch}`; + const branch = yield* probe(repository.repositoryPath, "branchExists", [ + "show-ref", + "--verify", + "--quiet", + branchRef, + ]); + if (!branch.ok) return yield* unavailable("branch-missing"); + const worktrees = yield* probe(repository.repositoryPath, "listWorktrees", [ + "worktree", + "list", + "--porcelain", + ]); + // A record whose folder is gone is pruned before the worktree is recreated. + for (const record of worktrees.stdout.split("\n\n")) { + const lines = record.split("\n"); + if ( + lines.includes(`branch ${branchRef}`) && + !lines.some((line) => line === "prunable" || line.startsWith("prunable ")) + ) { + return yield* unavailable("branch-in-use"); + } + } + if (!(yield* refExists(repository.repositoryPath, input.ref))) { + return yield* unavailable("checkpoint-missing"); + } + return { recreate: true }; + } + + const located = yield* probe(repository.worktreePath, "locateWorktree", [ + "rev-parse", + "--path-format=absolute", + "--show-toplevel", + "--git-dir", + "--git-common-dir", + ]); + const [toplevel = "", gitDir = "", commonDir = ""] = located.stdout.trim().split("\n"); + // Only a linked worktree of the same repository, never its source checkout. + if ( + !located.ok || + (yield* pathIdentity(toplevel)) !== (yield* pathIdentity(repository.worktreePath)) || + (yield* pathIdentity(gitDir)) === (yield* pathIdentity(commonDir)) || + (sourceCommonDir !== null && (yield* pathIdentity(commonDir)) !== sourceCommonDir) || + (yield* pathIdentity(repository.worktreePath)) === + (yield* pathIdentity(repository.repositoryPath)) + ) { + return yield* unavailable("worktree-unrelated"); + } + if (!(yield* refExists(repository.worktreePath, input.ref))) { + return yield* unavailable("checkpoint-missing"); + } + for (const change of yield* submoduleAlignment(repository.worktreePath, input.ref)) { + const problem = yield* submoduleProblem(change); + if (problem !== null) return yield* unavailable(problem, change.path); + } + return { recreate: false }; + }); + + const restoreRepository: CheckpointServiceV2Shape["restoreRepository"] = (input) => + withWorkspaceLock( + input.repository.worktreePath, + Effect.gen(function* () { + const cwd = input.repository.worktreePath; + const submodules = yield* prepareSubmoduleAlignment(cwd, input.ref); + if (submodules.problem !== null) { + return yield* new CheckpointRepositoryUnavailableError({ + repository: input.repository.repository, + worktreePath: cwd, + reason: submodules.problem, + submodulePath: submodules.path, + }); + } + const restored = yield* checkpointStore.restoreCheckpoint({ + cwd, + checkpointRef: input.ref, + fallbackToHead: false, + }); + if (!restored) { + return yield* new CheckpointRepositoryUnavailableError({ + repository: input.repository.repository, + worktreePath: cwd, + reason: "checkpoint-missing", + }); + } + yield* submodules.move; + }), ).pipe( - Effect.mapError( - (cause) => - new CheckpointCaptureError({ - scopeId: input.scope.id, - cause, - }), + Effect.mapError((cause) => + isCheckpointRepositoryUnavailableError(cause) + ? cause + : new CheckpointRepositoryRestoreError({ + repository: input.repository.repository, + worktreePath: input.repository.worktreePath, + cause, + }), ), ); + const repositoryLeaseKey: CheckpointServiceV2Shape["repositoryLeaseKey"] = Effect.fn( + "CheckpointService.repositoryLeaseKey", + )(function* (repository) { + for (const cwd of [repository.repositoryPath, repository.worktreePath]) { + if (!(yield* exists(cwd))) continue; + const commonDir = yield* commonDirOf(cwd); + if (commonDir !== null) return commonDir; + } + return null; + }); + const restore: CheckpointServiceV2Shape["restore"] = (input) => withWorkspaceLock( input.scope.cwd, Effect.gen(function* () { - if (input.checkpoint.status !== "ready") { + if (checkpointWorkspaceStatus(input.checkpoint) !== "ready") { return yield* new CheckpointRestoreError({ scopeId: input.scope.id, checkpointId: input.checkpoint.id, - cause: `Checkpoint status is ${input.checkpoint.status}.`, + cause: `Checkpoint status is ${checkpointWorkspaceStatus(input.checkpoint)}.`, }); } + const submodules = + input.alignSubmodules === true + ? yield* prepareSubmoduleAlignment(input.scope.cwd, input.checkpoint.ref) + : null; + if (submodules !== null && submodules.problem !== null) { + return yield* new CheckpointRestoreError({ + scopeId: input.scope.id, + checkpointId: input.checkpoint.id, + cause: `Submodule ${submodules.path} was not moved: ${submodules.problem}.`, + }); + } const restored = yield* checkpointStore.restoreCheckpoint({ cwd: input.scope.cwd, checkpointRef: input.checkpoint.ref, @@ -547,6 +1637,7 @@ export const layer: Layer.Layer< cause: "Checkpoint ref is unavailable.", }); } + if (submodules !== null) yield* submodules.move; }), ).pipe( Effect.mapError((cause) => @@ -560,23 +1651,98 @@ export const layer: Layer.Layer< ), ); - const deleteStaleRefs: CheckpointServiceV2Shape["deleteStaleRefs"] = (input) => - withWorkspaceLock( - input.scope.cwd, - checkpointStore.deleteCheckpointRefs({ - cwd: input.scope.cwd, - checkpointRefs: input.checkpoints.map((checkpoint) => checkpoint.ref), - }), - ).pipe( - Effect.mapError( - (cause) => - new CheckpointDeleteStaleRefsError({ - scopeId: input.scope.id, - checkpointIds: input.checkpoints.map((checkpoint) => checkpoint.id), - cause, + const deleteStaleRepositoryRefs = Effect.fn("CheckpointService.deleteStaleRepositoryRefs")( + function* (input: { + readonly checkpoints: ReadonlyArray; + readonly keep: ReadonlyArray>; + }) { + // Backfilled checkpoints share a baseline ref with later ones. Entries + // stored before backfills were marked rely on this. + const kept = new Set( + input.keep.flatMap( + (checkpoint) => + checkpoint.repositories?.map((entry) => `${entry.worktreePath}\0${entry.ref}`) ?? [], + ), + ); + const byWorktree = new Map< + string, + { readonly entry: OrchestrationV2CheckpointRepository; readonly refs: Set } + >(); + for (const checkpoint of input.checkpoints) { + for (const entry of checkpoint.repositories ?? []) { + // It names the snapshot taken when the worktree was attached, which + // an earlier rewind still restores. + if (entry.backfilled === true) continue; + if (kept.has(`${entry.worktreePath}\0${entry.ref}`)) continue; + const group = byWorktree.get(entry.worktreePath) ?? { entry, refs: new Set() }; + group.refs.add(entry.ref); + byWorktree.set(entry.worktreePath, group); + } + } + for (const { entry, refs } of byWorktree.values()) { + const cwd = (yield* exists(entry.worktreePath)) + ? entry.worktreePath + : (yield* exists(entry.repositoryPath)) + ? entry.repositoryPath + : null; + if (cwd === null) continue; + yield* withWorkspaceLock( + entry.worktreePath, + checkpointStore.deleteCheckpointRefs({ cwd, checkpointRefs: [...refs] }), + ).pipe( + Effect.catch((cause) => + Effect.logWarning("orchestration V2 stale repository refs were kept", { + worktreePath: entry.worktreePath, + cause: String(cause), + }), + ), + ); + } + }, + ); + + // Cleanup only: a ref Git refuses to delete is never reused as a baseline + // (see `isStaleSnapshot`), so the rewind still completes without it. + const deleteStaleRefs: CheckpointServiceV2Shape["deleteStaleRefs"] = Effect.fn( + "CheckpointService.deleteStaleRefs", + )( + function* (input) { + const workspaceRefs = input.checkpoints + .filter((checkpoint) => checkpointWorkspaceStatus(checkpoint) !== "missing") + .map((checkpoint) => checkpoint.ref); + if (workspaceRefs.length > 0) { + yield* withWorkspaceLock( + input.scope.cwd, + checkpointStore.deleteCheckpointRefs({ + cwd: input.scope.cwd, + checkpointRefs: workspaceRefs, }), + ).pipe( + Effect.catch((cause) => + Effect.logWarning("orchestration V2 stale checkpoint refs were kept", { + scopeId: input.scope.id, + cause: String(cause), + }), + ), + ); + } + yield* deleteStaleRepositoryRefs({ + checkpoints: input.checkpoints, + keep: input.keep ?? [], + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new CheckpointDeleteStaleRefsError({ + scopeId: input.scope.id, + checkpointIds: input.checkpoints.map((checkpoint) => checkpoint.id), + cause, + }), + ), ), - ); + ); return CheckpointServiceV2.of({ prepareRootRunScope: (input) => @@ -591,8 +1757,14 @@ export const layer: Layer.Layer< ), ), captureBaseline, + captureRepositoryBaseline, materializeBaselineCheckpoint, capture, + backfillRepositories, + resolveRepositorySnapshot: resolveSnapshot, + inspectRepositoryRestore, + restoreRepository, + repositoryLeaseKey, restore, deleteStaleRefs, } satisfies CheckpointServiceV2Shape); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 9dd5079805745..faac3fdb78b79 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -478,6 +478,7 @@ export const layerExecutor: Layer.Layer< providerThreadId: effect.request.providerThreadId, checkpointId: effect.request.checkpointId, scopeId: effect.request.scopeId, + requestId: effect.commandId, ...(effect.request.restoreFiles === undefined ? {} : { restoreFiles: effect.request.restoreFiles }), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index c186c40f9e73a..72ac6dfeb8ed0 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -38,6 +38,7 @@ import { type OrchestrationV2ServerCommand, type ThreadPullRequestLink, type ThreadPullRequestWatch, + type ThreadRepoWorktree, type OrchestrationV2AppThread, type OrchestrationV2ContextHandoff, type OrchestrationV2ContextSourcePoint, @@ -103,8 +104,10 @@ import * as Stream from "effect/Stream"; import * as ProjectStore from "./ProjectStore.ts"; import { - isCheckpointRestoreIsolated, + planCheckpointRestore, SHARED_WORKSPACE_RESTORE_MESSAGE, + sharedRepositoryRestoreMessage, + WORKSPACE_SNAPSHOT_FAILED_MESSAGE, } from "./CheckpointRestoreSafety.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { @@ -632,6 +635,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.pull-request.watch": case "thread.pull-request-watch.sync": case "thread.pull-request.sync": + case "thread.repo-worktree.attach": + case "thread.repo-worktree.detach": case "thread.title.regeneration.complete": case "thread.project.adopt-folder": case "thread.runtime-mode.set": @@ -3728,6 +3733,69 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); + // Reuses thread.metadata-updated, whose payload is the whole thread, so + // clients that predate repoWorktrees keep decoding the stream. + const dispatchRepoWorktreeMutation = Effect.fn("orchestrationV2.dispatch.repoWorktreeMutation")( + function* ( + command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "thread.repo-worktree.attach" | "thread.repo-worktree.detach" } + >, + events: Ref.Ref>, + ) { + const thread = yield* projectionStore + .getThread(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (thread.deletedAt !== null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is deleted.`, + }); + } + const current = thread.repoWorktrees ?? []; + let repoWorktrees: ReadonlyArray; + if (command.type === "thread.repo-worktree.attach") { + if (thread.archivedAt !== null && command.relabelOnly !== true) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is archived.`, + }); + } + const listed = current.find( + (entry) => entry.worktreePath === command.repoWorktree.worktreePath, + ); + // A relabel read before a detach must not attach the worktree again. + if (listed === undefined && command.relabelOnly === true) return; + if (listed?.branch === command.repoWorktree.branch) return; + // A listed path is relabeled in place after its checkout switched branch. + repoWorktrees = + listed === undefined + ? [...current, command.repoWorktree] + : current.map((entry) => (entry === listed ? command.repoWorktree : entry)); + } else { + repoWorktrees = current.filter((entry) => entry.worktreePath !== command.worktreePath); + if (repoWorktrees.length === current.length) return; + } + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: { ...thread, repoWorktrees, updatedAt: now }, + }); + }, + ); + // Deleted after the share change commits: the files of waiting messages it dismissed or dropped. const releaseGuestAttachments = ( command: { readonly commandId: CommandId; readonly threadId: ThreadId }, @@ -13307,12 +13375,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } if (command.restoreFiles !== false) { - const isolated = yield* isCheckpointRestoreIsolated(projection.thread, targetScope, { - projects, - path, - fileSystem, - projections: projectionStore, - }).pipe( + const plan = yield* planCheckpointRestore( + { + thread: projection.thread, + scope: targetScope, + checkpoint: targetCheckpoint, + checkpoints: projection.checkpoints, + }, + { projects, path, fileSystem, projections: projectionStore }, + ).pipe( Effect.mapError( (cause) => new OrchestratorDispatchError({ @@ -13322,11 +13393,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - if (!isolated) + if (plan._tag !== "restore") return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: SHARED_WORKSPACE_RESTORE_MESSAGE, + cause: + plan._tag === "shared-workspace" + ? SHARED_WORKSPACE_RESTORE_MESSAGE + : plan._tag === "workspace-unavailable" + ? WORKSPACE_SNAPSHOT_FAILED_MESSAGE + : sharedRepositoryRestoreMessage(plan.repository), }); } @@ -14447,6 +14523,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.pull-request-watch.sync": yield* dispatchPullRequestWatchSync(command, events, effects); break; + case "thread.repo-worktree.attach": + case "thread.repo-worktree.detach": + yield* dispatchRepoWorktreeMutation(command, events); + break; case "thread.guest-share.open": case "thread.guest-share.redeem": case "thread.guest-share.close": @@ -14806,6 +14886,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.type === "subagent.stop" || // Closing a share again, or after it ended, is not an error. command.type === "thread.guest-share.close" || + // Attaching or detaching again, or after another client did, is not an error. + command.type === "thread.repo-worktree.attach" || + command.type === "thread.repo-worktree.detach" || (command.type === "thread.delete" && command.onlyIfUnused === true), (_planned) => new OrchestratorDispatchError({ diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 22d8cfe25ddbb..4e1b4f80900ef 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -52,7 +52,9 @@ import { idleCompactionOf, OrchestrationV2AppThreadJson as OrchestrationV2AppThreadJsonSchema, OrchestrationV2CheckpointJson as OrchestrationV2CheckpointJsonSchema, + OrchestrationV2CheckpointRepository, OrchestrationV2CheckpointScopeJson as OrchestrationV2CheckpointScopeJsonSchema, + OrchestrationV2CheckpointStatus, OrchestrationV2ContextHandoffJson as OrchestrationV2ContextHandoffJsonSchema, OrchestrationV2ContextTransferJson as OrchestrationV2ContextTransferJsonSchema, OrchestrationV2ConversationMessageJson as OrchestrationV2ConversationMessageJsonSchema, @@ -257,12 +259,43 @@ const ProjectionCheckpointContext = Schema.Struct({ appRunOrdinal, status, ref, + // SQLite yields NULL for an absent field and the array as JSON text. + workspaceStatus: Schema.NullOr(OrchestrationV2CheckpointStatus), + repositories: Schema.NullOr( + Schema.fromJsonString(Schema.Array(OrchestrationV2CheckpointRepository)), + ), }), ), ), }); -export type ProjectionCheckpointContext = typeof ProjectionCheckpointContext.Type; -const decodeCheckpointContext = Schema.decodeUnknownEffect(ProjectionCheckpointContext); +type ProjectionCheckpointContextRows = typeof ProjectionCheckpointContext.Type; +export type ProjectionCheckpointContext = Omit & { + readonly checkpoints: ReadonlyArray< + Pick< + OrchestrationV2Checkpoint, + | "scopeId" + | "runId" + | "ordinalWithinScope" + | "appRunOrdinal" + | "status" + | "ref" + | "workspaceStatus" + | "repositories" + > + >; +}; +const decodeCheckpointContextRows = Schema.decodeUnknownEffect(ProjectionCheckpointContext); +const decodeCheckpointContext = (rows: unknown) => + decodeCheckpointContextRows(rows).pipe( + Effect.map((context): ProjectionCheckpointContext => ({ + ...context, + checkpoints: context.checkpoints.map(({ workspaceStatus, repositories, ...checkpoint }) => ({ + ...checkpoint, + ...(workspaceStatus === null ? {} : { workspaceStatus }), + ...(repositories === null ? {} : { repositories }), + })), + })), + ); export interface ProjectionCheckpointCaptureContext { readonly run: OrchestrationV2Run | undefined; @@ -615,6 +648,15 @@ export interface ProjectionStoreV2Shape { ReadonlyArray<{ readonly threadId: ThreadId; readonly worktreePath: string }>, ProjectionStoreV2Error >; + /** Repository worktrees attached to threads that are not deleted, archived ones included. */ + readonly getThreadRepoWorktreePaths: () => Effect.Effect< + ReadonlyArray<{ + readonly threadId: ThreadId; + readonly title: string; + readonly worktreePath: string; + }>, + ProjectionStoreV2Error + >; /** * Delegated tasks in a linked environment with work left for this one: no * result yet, or cancelled or stopped here without that stop reaching there, @@ -5801,7 +5843,9 @@ export const layer: Layer.Layer = SELECT scope_id AS "scopeId", run_id AS "runId", ordinal_within_scope AS "ordinalWithinScope", app_run_ordinal AS "appRunOrdinal", status, - json_extract(payload_json, '$.ref') AS ref + json_extract(payload_json, '$.ref') AS ref, + json_extract(payload_json, '$.workspaceStatus') AS "workspaceStatus", + json_extract(payload_json, '$.repositories') AS repositories FROM orchestration_v2_projection_checkpoints WHERE thread_id = ${threadId} ORDER BY scope_id ASC, ordinal_within_scope ASC @@ -8045,6 +8089,25 @@ export const layer: Layer.Layer = Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })), ); + const getThreadRepoWorktreePaths: ProjectionStoreV2Shape["getThreadRepoWorktreePaths"] = () => + sql<{ readonly thread_id: string; readonly title: string; readonly worktree_path: string }>` + SELECT t.thread_id, + json_extract(t.payload_json, '$.title') AS title, + json_extract(r.value, '$.worktreePath') AS worktree_path + FROM orchestration_v2_projection_threads t, json_each(t.payload_json, '$.repoWorktrees') r + WHERE t.deleted_at IS NULL + AND json_extract(r.value, '$.worktreePath') IS NOT NULL + `.pipe( + Effect.map((rows) => + rows.map((row) => ({ + threadId: ThreadId.make(row.thread_id), + title: row.title, + worktreePath: row.worktree_path, + })), + ), + Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })), + ); + const shellThreadStateFromRow = /* Runs once per shell row; the enclosing snapshot or thread read owns the span. */ Effect.fnUntraced(function* (input: { @@ -8794,6 +8857,7 @@ export const layer: Layer.Layer = getThreadTitles: readOnReader(getThreadTitles), getThreadLinkMetadata: readOnReader(getThreadLinkMetadata), getThreadWorktreePaths: readOnReader(getThreadWorktreePaths), + getThreadRepoWorktreePaths: readOnReader(getThreadRepoWorktreePaths), getOpenRemoteDelegatedTasks: getOpenRemoteDelegatedTasks.pipe(NodeSqliteClient.readOnly), getThreadProjection: readOnReader(getThreadProjection), getTurnStartContext: readOnReader(getTurnStartContext), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 34ff5a7631105..e7a7b6949a694 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -227,6 +227,7 @@ it.effect( getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"), getThreadsWithPullRequests: () => Effect.die("unused getThreadsWithPullRequests"), getThreadWorktreePaths: () => Effect.die("unused getThreadWorktreePaths"), + getThreadRepoWorktreePaths: () => Effect.die("unused getThreadRepoWorktreePaths"), getThreadProjection: () => Effect.die("control effects must not load transcript"), getTurnStartContext: () => Effect.die("unused"), getTurnStartHistory: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 2de9fcbc70d9d..229f08d6e6726 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1,5 +1,7 @@ import { modelSelectionsEqual } from "@t3tools/shared/model"; import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { CommandId, latestProviderTurnForAttempt, @@ -63,6 +65,7 @@ import { import * as RunExecutionService from "./RunExecutionService.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; +import { repoWorktreeNote } from "./RepoWorktreeNote.ts"; import { isRestartNoteContinuation, pendingRestartCancelledBackgroundWork, @@ -187,6 +190,9 @@ export const layer: Layer.Layer< const runtimePolicy = yield* RuntimePolicy.RuntimePolicyV2; const turnItemPositions = yield* TurnItemPositionStore.TurnItemPositionStoreV2; const isManagedWorktreePath = yield* makeIsManagedWorktreePath; + const serverSettings = yield* ServerSettings.ServerSettingsService; + // Optional so harnesses without MCP start turns unchanged. + const mcpSessions = yield* Effect.serviceOption(McpProviderSessions.McpProviderSessions); const resolveRecreateBase = Effect.fn("ProviderTurnStartService.resolveRecreateBase")( function* (cwd: string) { @@ -902,6 +908,22 @@ export const layer: Layer.Layer< restartCancelledWork.length === 0 ? "" : restartCancelledBackgroundWorkNote(restartCancelledWork); + const hasT3Mcp = + Option.isSome(mcpSessions) && + (yield* mcpSessions.value.read(projection.thread.id)) !== undefined; + const repoNote = repoWorktreeNote({ + userText, + hasT3Mcp, + settings: hasT3Mcp + ? yield* serverSettings.getSettings.pipe( + Effect.map( + (current) => resolveProjectSettings(current, projection.thread.projectId).settings, + ), + Effect.orElseSucceed(() => null), + ) + : null, + repoWorktrees: projection.thread.repoWorktrees ?? [], + }); const tokenCap = yield* handoffTokenCapConfig.pipe( Effect.orElseSucceed(() => DEFAULT_HANDOFF_TOKEN_CAP), ); @@ -1047,7 +1069,9 @@ export const layer: Layer.Layer< return handoffBudget({ tokenCap, modelContextWindow, - userText: restartNote === "" ? userText : `${restartNote}\n\n${userText}`, + userText: [restartNote, repoNote, userText] + .filter((part) => part !== "") + .join("\n\n"), attachments: message.attachments, providerThread: budgetProviderThread, nativeContextEstimate: @@ -1753,7 +1777,7 @@ export const layer: Layer.Layer< sideChatHandoff === undefined || compact || goalCommand ? "" : sideChatHandoff.summaryText; - const context = [delivery.context, sideChatContext, restartNote] + const context = [delivery.context, sideChatContext, restartNote, compact ? "" : repoNote] .filter((part) => part !== "") .join("\n\n"); const { restartContinuationOfRunId: _resumedRunId, ...promptedInput } = turnInput; @@ -1821,6 +1845,7 @@ export const layer: Layer.Layer< retryHandoff.length === 0 && sideChatHandoff === undefined && restartNote === "" && + repoNote === "" && !noteContinuation ? session : makeDeliverySession(session, startWithHandoffs); @@ -1830,6 +1855,7 @@ export const layer: Layer.Layer< providerSessionId, session: deliverySession, run: runningRun, + runs: projection.runs, rootNode: runningRootNode, checkpointScope, providerThread: runningProviderThread, diff --git a/apps/server/src/orchestration-v2/RepoWorktreeCheckpoints.integration.test.ts b/apps/server/src/orchestration-v2/RepoWorktreeCheckpoints.integration.test.ts new file mode 100644 index 0000000000000..4a9513cec71b9 --- /dev/null +++ b/apps/server/src/orchestration-v2/RepoWorktreeCheckpoints.integration.test.ts @@ -0,0 +1,2100 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + type OrchestrationV2DomainEvent, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + ThreadId, +} from "@t3tools/contracts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import type * as CodexClient from "effect-codex-app-server/client"; +import * as CodexErrors from "effect-codex-app-server/errors"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as CheckpointDiffQuery from "../checkpointing/CheckpointDiffQuery.ts"; +import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as RepoWorktreeService from "../git/RepoWorktreeService.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { revertCodexThreadAfter } from "../provider/CodexThreadRevert.ts"; +import { + CodexProviderCapabilitiesV2, + resolveCodexRollbackBoundary, +} from "./Adapters/CodexAdapterV2.ts"; +import { SHARED_WORKSPACE_RESTORE_MESSAGE } from "./CheckpointRestoreSafety.ts"; +import * as CheckpointService from "./CheckpointService.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; +import * as EffectWorker from "./EffectWorker.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as RuntimePolicy from "./RuntimePolicy.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; +import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; + +const driver = ProviderDriverKind.make("codex"); +const agentInstanceId = ProviderInstanceId.make("codex"); +const noRollbackInstanceId = ProviderInstanceId.make("codex-no-rollback"); +const folderProjectId = ProjectId.make("project:repo-checkpoints:folder"); +const alphaProjectId = ProjectId.make("project:repo-checkpoints:alpha"); +const serverProjectId = ProjectId.make("project:repo-checkpoints:server"); + +interface ActiveTurn { + readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; + readonly providerTurnId: ProviderTurnId; + readonly events: Queue.Queue; +} + +interface Agent { + readonly started: Queue.Queue; + readonly rollbacks: Ref.Ref; + /** The provider's own turn history, oldest first, which a rewind really truncates. */ + readonly nativeTurns: Ref.Ref>; + /** Drops the reply to the next revert after it lands, as a lost connection would. */ + readonly loseNextRevertReply: Ref.Ref; +} + +/** Answers Codex's history requests from `agent.nativeTurns`. */ +const codexHistory = (agent: Agent) => ({ + request: (( + method: string, + params: { + readonly threadId: string; + readonly cursor?: string | null; + readonly limit?: number; + readonly beforeTurnId?: string; + }, + ) => + Effect.gen(function* () { + const turns = yield* Ref.get(agent.nativeTurns); + if (method === "thread/turns/list") { + const newestFirst = turns.toReversed(); + const start = Number(params.cursor ?? 0); + const end = start + (params.limit ?? 100); + return { + data: newestFirst.slice(start, end).map((id) => ({ id, items: [], status: "completed" })), + nextCursor: end < newestFirst.length ? String(end) : null, + }; + } + if (method === "thread/revert") { + const index = turns.indexOf(params.beforeTurnId ?? ""); + const reply = (message: string) => + CodexErrors.CodexAppServerRequestError.internalError(message, undefined, { + method: "thread/revert", + operation: "decode-payload", + }); + if (index < 0) return yield* reply(`Unknown turn ${params.beforeTurnId}.`); + yield* Ref.set(agent.nativeTurns, turns.slice(0, index)); + if (yield* Ref.getAndSet(agent.loseNextRevertReply, false)) { + return yield* reply("The connection closed before Codex replied."); + } + } + return { thread: { id: params.threadId } }; + })) as unknown as CodexClient.CodexAppServerClient["Service"]["request"], +}); + +/** Starts a turn and hands it to the test, which plays the agent and then finishes it. */ +function makeAgentAdapter( + agent: Agent, + instanceId: ProviderInstanceId, + capabilities: OrchestrationV2ProviderCapabilities, +): ProviderAdapter.ProviderAdapterV2["Service"] { + return { + instanceId, + driver, + getCapabilities: () => Effect.succeed(capabilities), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: (sessionInput) => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const now = yield* DateTime.now; + const providerSession: OrchestrationV2ProviderSession = { + id: sessionInput.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd: sessionInput.runtimePolicy.cwd ?? "/unused", + model: sessionInput.modelSelection.model, + capabilities, + createdAt: now, + updatedAt: now, + lastError: null, + }; + return { + instanceId, + driver, + providerSessionId: sessionInput.providerSessionId, + providerSession, + events: Stream.fromQueue(events), + ensureThread: (threadInput) => + Effect.gen(function* () { + const createdAt = yield* DateTime.now; + return { + id: ProviderThreadId.make(`provider-thread:${threadInput.threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: sessionInput.providerSessionId, + appThreadId: threadInput.threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: `native-thread:${threadInput.threadId}`, + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt, + updatedAt: createdAt, + } satisfies OrchestrationV2ProviderThread; + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turnInput) => + Effect.gen(function* () { + const providerTurnId = ProviderTurnId.make(`provider-turn:${turnInput.attemptId}`); + const occurredAt = yield* DateTime.now; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: providerTurnId, + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: { + driver, + nativeId: `native:${providerTurnId}`, + strength: "strong", + }, + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt: occurredAt, + completedAt: null, + }, + }); + yield* Ref.update(agent.nativeTurns, (turns) => [ + ...turns, + `native:${providerTurnId}`, + ]); + yield* Queue.offer(agent.started, { input: turnInput, providerTurnId, events }); + }), + steerTurn: () => Effect.void, + interruptTurn: () => Effect.void, + respondToRuntimeRequest: () => Effect.void, + readThreadSnapshot: () => Effect.die("unused readThreadSnapshot"), + // Codex's own rewind, against history that a revert really changes. + rollbackThread: (rollbackInput) => + Effect.gen(function* () { + yield* Ref.update(agent.rollbacks, (count) => count + 1); + const boundary = yield* resolveCodexRollbackBoundary(rollbackInput); + if (boundary.laterTurnIds.size > 0) { + yield* revertCodexThreadAfter(codexHistory(agent), "native", boundary).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver, + providerThreadId: rollbackInput.providerThread.id, + cause, + }), + ), + ); + } + return { + providerThread: rollbackInput.providerThread, + providerTurns: [], + messages: [], + runtimeRequests: [], + }; + }), + forkThread: () => Effect.die("unused forkThread"), + }; + }), + }; +} + +const finishTurn = (active: ActiveTurn) => + Effect.gen(function* () { + const occurredAt = yield* DateTime.now; + yield* Queue.offer(active.events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: active.providerTurnId, + providerThreadId: active.input.providerThread.id, + nodeId: active.input.rootNodeId, + runAttemptId: active.input.attemptId, + nativeTurnRef: { driver, nativeId: `native:${active.providerTurnId}`, strength: "strong" }, + ordinal: active.input.providerTurnOrdinal, + status: "completed", + startedAt: occurredAt, + completedAt: occurredAt, + }, + }); + yield* Queue.offer(active.events, { + type: "turn.terminal", + driver, + providerThreadId: active.input.providerThread.id, + providerTurnId: active.providerTurnId, + runOrdinal: active.input.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + }); + +const layerServerConfig = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-repo-checkpoints-config-", +}).pipe(Layer.provide(NodeServices.layer)); +const layerGitDriver = GitVcsDriver.layer.pipe( + Layer.provide(layerServerConfig), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), +); + +const git = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* gitDriver.execute({ + operation: "RepoWorktreeCheckpoints.test", + cwd, + args, + timeoutMs: 30_000, + }); + return result.stdout.trim(); + }); + +const configureIdentity = (cwd: string) => + Effect.gen(function* () { + yield* git(cwd, ["config", "user.email", "test@example.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* git(cwd, ["config", "commit.gpgsign", "false"]); + }); + +const initRepo = (cwd: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(cwd, { recursive: true }); + yield* git(cwd, ["init", "--initial-branch=main"]); + yield* configureIdentity(cwd); + // The same file in every repository, so diff paths must name the repository. + yield* fileSystem.writeFileString(path.join(cwd, "README.md"), `# ${path.basename(cwd)}\n`); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial"]); + }); + +const read = (file: string) => + Effect.gen(function* () { + return yield* (yield* FileSystem.FileSystem).readFileString(file); + }); +const write = (file: string, contents: string) => + Effect.gen(function* () { + yield* (yield* FileSystem.FileSystem).writeFileString(file, contents); + }); +const exists = (file: string) => + Effect.gen(function* () { + return yield* (yield* FileSystem.FileSystem).exists(file); + }); + +interface Fixture { + readonly root: string; + readonly folder: string; + readonly alpha: string; + readonly beta: string; + readonly server: string; + readonly frameworkSource: string; + readonly worktrees: string; +} + +/** A plain folder (not a repository) holding repositories; `server` has a submodule. */ +const makeFixture = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.realPath( + yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repo-checkpoints-" }), + ); + const fixture: Fixture = { + root, + folder: path.join(root, "folder"), + alpha: path.join(root, "folder", "alpha"), + beta: path.join(root, "folder", "beta"), + server: path.join(root, "folder", "server"), + frameworkSource: path.join(root, "framework-source"), + worktrees: path.join(root, "worktrees"), + }; + yield* initRepo(fixture.alpha); + yield* initRepo(fixture.beta); + yield* initRepo(fixture.frameworkSource); + yield* initRepo(fixture.server); + yield* git(fixture.server, ["submodule", "add", fixture.frameworkSource, "framework"]); + yield* git(fixture.server, ["commit", "-m", "add framework"]); + yield* configureIdentity(path.join(fixture.server, "framework")); + return fixture; +}); + +const layerGitWorkflow = Layer.unwrap( + Effect.gen(function* () { + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + return Layer.mock(GitWorkflowService.GitWorkflowService)({ + remoteExists: gitDriver.remoteExists, + fetchRemote: gitDriver.fetchRemote, + resolveRemoteTrackingCommit: gitDriver.resolveRemoteTrackingCommit, + pruneWorktrees: gitDriver.pruneWorktrees, + createWorktree: (input, options) => gitDriver.createWorktree(input, options), + removeWorktree: gitDriver.removeWorktree, + deleteLocalBranch: gitDriver.deleteLocalBranch, + invalidateStatus: () => Effect.void, + }); + }), +); + +function makeLayer(fixture: Fixture, agent: Agent) { + const layerDatabase = SqlitePersistence.layerMemory; + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "repo-worktree-checkpoints" }, + ProviderAdapterRegistry.layerFromAdapters([ + makeAgentAdapter(agent, agentInstanceId, CodexProviderCapabilitiesV2), + makeAgentAdapter(agent, noRollbackInstanceId, { + ...CodexProviderCapabilitiesV2, + threads: { ...CodexProviderCapabilitiesV2.threads, canRollbackThread: false }, + }), + ]), + { + databaseLayer: layerDatabase, + runEffectWorker: false, + // As in production: a thread without its own worktree works in the project folder. + runtimePolicy: Layer.succeed(RuntimePolicy.RuntimePolicyV2, { + resolve: ({ thread }) => + Effect.succeed({ + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + cwd: + thread.worktreePath ?? + (thread.projectId === alphaProjectId ? fixture.alpha : fixture.folder), + }), + }), + rollbackGitWorkflow: layerGitWorkflow.pipe(Layer.provide(layerGitDriver), Layer.orDie), + worktreesDirectory: fixture.worktrees, + }, + ); + const layerSettings = ServerSettings.layerTest({ + repoWorktrees: true, + worktreesDirectory: fixture.worktrees, + newWorktreesStartFromOrigin: false, + }); + const layerThreads = ThreadManagement.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(layerSettings), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ); + const layerCheckpointStore = CheckpointStore.layer.pipe( + Layer.provide( + VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(layerServerConfig), + ), + ), + ); + return Layer.mergeAll(RepoWorktreeService.layer, CheckpointDiffQuery.layer).pipe( + Layer.provideMerge( + Layer.mergeAll( + layerThreads, + layerSettings, + Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeedSome({ id: folderProjectId, workspaceRoot: fixture.folder } as never), + }), + layerGitWorkflow, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + ProjectStore.layer.pipe(Layer.provide(layerDatabase)), + CheckpointService.layer.pipe(Layer.provide(IdAllocator.layer)), + layerOrchestrator, + ), + ), + Layer.provideMerge(layerCheckpointStore), + Layer.provideMerge(layerGitDriver), + Layer.provideMerge(layerServerConfig), + ); +} + +let sequence = 0; +const createProject = (projectId: ProjectId, workspaceRoot: string) => + Effect.gen(function* () { + const projects = yield* ProjectStore.ProjectStoreV2; + sequence += 1; + yield* projects.apply({ + sequence, + eventId: EventId.make(`event:project:${projectId}`), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: "2026-11-04T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.created", + payload: { + projectId, + title: projectId, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-11-04T00:00:00.000Z", + updatedAt: "2026-11-04T00:00:00.000Z", + }, + }); + }); + +const createThread = (input: { + readonly threadId: string; + readonly projectId?: ProjectId; + readonly worktreePath?: string; + readonly branch?: string; + readonly instanceId?: ProviderInstanceId; +}) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make(input.threadId); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create:${threadId}`), + threadId, + projectId: input.projectId ?? folderProjectId, + title: input.threadId, + modelSelection: { instanceId: input.instanceId ?? agentInstanceId, model: "gpt-5.1-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: input.branch ?? null, + worktreePath: input.worktreePath ?? null, + createdBy: "user", + creationSource: "web", + }); + return threadId; + }); + +const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + return yield* orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + }); + +let messageCounter = 0; +/** One turn: the agent's `work` runs while the turn is live, then the checkpoint is captured. */ +const runTurn = (agent: Agent, threadId: ThreadId, work: Effect.Effect) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + messageCounter += 1; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`message:${threadId}:${messageCounter}`), + threadId, + messageId: MessageId.make(`message:${threadId}:${messageCounter}`), + text: `turn ${messageCounter}`, + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + const active = yield* Queue.take(agent.started); + yield* work; + const waiting = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === active.input.runId && + event.payload.status === "waiting", + ); + yield* finishTurn(active); + yield* Fiber.join(waiting); + yield* worker.drain(); + return active.input.runOrdinal; + }); + +const projectionOf = (threadId: ThreadId) => + Effect.gen(function* () { + return yield* (yield* Orchestrator.OrchestratorV2).getThreadProjection(threadId); + }); + +const rollbackCommand = (threadId: ThreadId, turnCount: number, restoreFiles: boolean) => + Effect.gen(function* () { + const projection = yield* projectionOf(threadId); + const checkpoint = projection.checkpoints.findLast((candidate) => + turnCount === 0 + ? candidate.ordinalWithinScope === 0 && candidate.appRunOrdinal === null + : candidate.appRunOrdinal === turnCount, + ); + if (checkpoint === undefined) return yield* Effect.die(`no checkpoint for turn ${turnCount}`); + messageCounter += 1; + return { + type: "checkpoint.rollback" as const, + commandId: CommandId.make(`rollback:${threadId}:${messageCounter}`), + threadId, + scopeId: checkpoint.scopeId, + checkpointId: checkpoint.id, + restoreFiles, + }; + }); + +const rollback = (threadId: ThreadId, turnCount: number, restoreFiles = true) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + yield* orchestrator.dispatch(yield* rollbackCommand(threadId, turnCount, restoreFiles)); + yield* worker.drain(); + return yield* projectionOf(threadId); + }); + +const withHarness = ( + test: (input: { readonly fixture: Fixture; readonly agent: Agent }) => Effect.Effect, +) => + Effect.gen(function* () { + const fixture = yield* makeFixture.pipe(Effect.provide(layerGitDriver)); + const agent: Agent = { + started: yield* Queue.unbounded(), + rollbacks: yield* Ref.make(0), + nativeTurns: yield* Ref.make>([]), + loseNextRevertReply: yield* Ref.make(false), + }; + return yield* Effect.gen(function* () { + yield* createProject(folderProjectId, fixture.folder); + return yield* test({ fixture, agent }); + }).pipe(Effect.provide(makeLayer(fixture, agent))); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); + +describe("repository worktree checkpoints", () => { + it.effect( + "captures, diffs, and restores two repositories of a folder that is not a Git repository", + () => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const diffs = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const threadId = yield* createThread({ threadId: "thread:folder" }); + + // Someone already works in a beta worktree; its edits predate the thread. + const betaWorktree = path.join(fixture.worktrees, "beta", "shared"); + yield* git(fixture.beta, ["worktree", "add", "-b", "shared", betaWorktree]); + yield* write(path.join(betaWorktree, "README.md"), "# beta\nearlier edit\n"); + yield* write(path.join(betaWorktree, "notes.txt"), "earlier untracked\n"); + + let alphaWorktree = ""; + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + const alpha = yield* repoWorktrees.ensure({ + threadId, + repository: "alpha", + branch: "t3/work", + baseRef: "main", + }); + alphaWorktree = alpha.repoWorktree.worktreePath; + yield* write(path.join(alphaWorktree, "README.md"), "# alpha\nturn 1\n"); + yield* write(path.join(alphaWorktree, "new.txt"), "untracked in turn 1\n"); + // Found partway through the turn. + const beta = yield* repoWorktrees.ensure({ + threadId, + repository: "beta", + branch: "shared", + }); + assert.equal(beta.status, "reused"); + assert.equal(beta.uncommittedFiles, 2); + yield* write(path.join(betaWorktree, "README.md"), "# beta\nearlier edit\nturn 1\n"); + }), + ); + + const afterFirst = yield* projectionOf(threadId); + assert.deepEqual( + afterFirst.checkpointScopes.map((scope) => scope.cwd), + [fixture.folder], + ); + const first = afterFirst.checkpoints.find( + (checkpoint) => checkpoint.appRunOrdinal === 1, + )!; + assert.equal(first.status, "ready"); + assert.equal(first.workspaceStatus, "missing"); + assert.deepEqual( + first.repositories?.map((entry) => [entry.pathPrefix, entry.status]), + [ + ["alpha", "ready"], + ["beta", "ready"], + ], + ); + assert.sameMembers( + first.files.map((file) => file.path), + ["alpha/README.md", "alpha/new.txt", "beta/README.md"], + ); + // The thread start is a rollback target too, from the baselines taken on attach. + const threadStart = afterFirst.checkpoints.find( + (checkpoint) => checkpoint.ordinalWithinScope === 0, + )!; + assert.equal(threadStart.status, "ready"); + + const firstDiff = yield* diffs.getFullThreadDiff({ threadId, toTurnCount: 1 }); + assert.include(firstDiff.diff, "diff --git a/alpha/README.md b/alpha/README.md"); + assert.include(firstDiff.diff, "diff --git a/beta/README.md b/beta/README.md"); + assert.include(firstDiff.diff, "+turn 1"); + // Edits that predate the thread are its baseline, not its work. + assert.notInclude(firstDiff.diff, "+earlier edit"); + assert.notInclude(firstDiff.diff, "notes.txt"); + + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + yield* git(alphaWorktree, ["add", "."]); + yield* git(alphaWorktree, ["commit", "-m", "turn 1 work"]); + yield* write(path.join(alphaWorktree, "README.md"), "# alpha\nturn 2\n"); + }), + ); + const secondDiff = yield* diffs.getTurnDiff({ + threadId, + fromTurnCount: 1, + toTurnCount: 2, + }); + assert.include(secondDiff.diff, "diff --git a/alpha/README.md b/alpha/README.md"); + assert.include(secondDiff.diff, "+turn 2"); + assert.notInclude(secondDiff.diff, "beta/"); + + // Rewind turn 2: alpha gets turn 1's files back; its commit stays. + const rewound = yield* rollback(threadId, 1); + assert.equal(yield* read(path.join(alphaWorktree, "README.md")), "# alpha\nturn 1\n"); + assert.equal(yield* git(alphaWorktree, ["log", "-1", "--format=%s"]), "turn 1 work"); + assert.equal(rewound.runs.find((run) => run.ordinal === 2)?.status, "rolled_back"); + assert.equal( + rewound.checkpoints.find((checkpoint) => checkpoint.appRunOrdinal === 2)?.status, + "stale", + ); + + // A turn after the rewind diffs against the restored files. + yield* runTurn( + agent, + threadId, + write(path.join(alphaWorktree, "README.md"), "# alpha\nturn 1\nturn 3\n"), + ); + const thirdDiff = yield* diffs.getTurnDiff({ + threadId, + fromTurnCount: 2, + toTurnCount: 3, + }); + assert.include(thirdDiff.diff, "+turn 3"); + assert.notInclude(thirdDiff.diff, "-turn 2"); + + // Back to the start: both worktrees as the thread found them. + yield* rollback(threadId, 0); + assert.equal(yield* read(path.join(alphaWorktree, "README.md")), "# alpha\n"); + assert.isFalse(yield* exists(path.join(alphaWorktree, "new.txt"))); + assert.equal(yield* read(path.join(betaWorktree, "README.md")), "# beta\nearlier edit\n"); + assert.equal(yield* read(path.join(betaWorktree, "notes.txt")), "earlier untracked\n"); + assert.isTrue(yield* exists(betaWorktree)); + // The source checkouts never change. + assert.equal(yield* read(path.join(fixture.alpha, "README.md")), "# alpha\n"); + assert.equal(yield* git(fixture.alpha, ["branch", "--show-current"]), "main"); + assert.equal(yield* git(fixture.beta, ["status", "--porcelain"]), ""); + assert.equal(yield* agent.rollbacks.pipe(Ref.get), 2); + }), + ), + ); + + it.effect( + "restores a parent worktree's gitlink and submodule checkout with its framework worktree", + () => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread({ threadId: "thread:framework" }); + const sourceFrameworkHead = yield* git(path.join(fixture.server, "framework"), [ + "rev-parse", + "HEAD", + ]); + const sourceServerHead = yield* git(fixture.server, ["rev-parse", "HEAD"]); + const sourceOrigin = yield* git(path.join(fixture.server, "framework"), [ + "config", + "--get", + "remote.origin.url", + ]); + let parent = ""; + let framework = ""; + const commitFramework = (contents: string) => + Effect.gen(function* () { + yield* write(path.join(framework, "README.md"), contents); + yield* git(framework, ["commit", "-am", contents.trim()]); + const pinned = yield* repoWorktrees.setSubmoduleCommit({ + threadId, + worktreePath: parent, + submodulePath: "framework", + sourceWorktreePath: framework, + }); + yield* git(parent, ["commit", "-m", `pin ${contents.trim()}`]); + return pinned.commit; + }); + + let firstPin = ""; + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + framework = (yield* repoWorktrees.ensure({ + threadId, + repository: "server/framework", + branch: "t3/framework", + baseRef: "main", + })).repoWorktree.worktreePath; + parent = (yield* repoWorktrees.ensure({ + threadId, + repository: "server", + branch: "t3/framework", + baseRef: "main", + })).repoWorktree.worktreePath; + yield* configureIdentity(framework); + yield* configureIdentity(parent); + yield* write(path.join(framework, "local.env"), "tracked in turn 1\n"); + yield* git(framework, ["add", "local.env"]); + firstPin = yield* commitFramework("framework one\n"); + }), + ); + let secondPin = ""; + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + yield* git(framework, ["rm", "--quiet", "local.env"]); + yield* write(path.join(framework, ".gitignore"), "local.env\n"); + yield* git(framework, ["add", ".gitignore"]); + secondPin = yield* commitFramework("framework two\n"); + }), + ); + const embedded = path.join(parent, "framework"); + assert.equal(yield* git(embedded, ["rev-parse", "HEAD"]), secondPin); + + // Turn 1's commit tracks the file this ignored one sits at, so nothing moves. + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const localFile = path.join(embedded, "local.env"); + yield* write(localFile, "my settings\n"); + yield* rollback(threadId, 1); + for (let attempt = 0; attempt < 5; attempt += 1) { + yield* TestClock.adjust(Duration.seconds(31)); + yield* worker.drain(); + } + const refused = yield* projectionOf(threadId); + assert.include( + refused.thread.rollbackFailure?.message ?? "", + "The submodule framework in server at", + ); + assert.include(refused.thread.rollbackFailure?.message ?? "", "untracked or ignored"); + assert.equal(refused.runs.find((run) => run.ordinal === 2)?.status, "completed"); + assert.equal(yield* agent.rollbacks.pipe(Ref.get), 0); + assert.equal(yield* read(localFile), "my settings\n"); + assert.equal(yield* git(embedded, ["rev-parse", "HEAD"]), secondPin); + assert.equal(yield* read(path.join(framework, "README.md")), "framework two\n"); + yield* (yield* FileSystem.FileSystem).remove(localFile); + + yield* rollback(threadId, 1); + assert.equal(yield* read(path.join(framework, "README.md")), "framework one\n"); + assert.equal(yield* git(embedded, ["rev-parse", "HEAD"]), firstPin); + assert.equal( + yield* git(parent, ["ls-files", "--stage", "--", "framework"]).pipe( + Effect.map((line) => line.split(" ")[1]), + ), + secondPin, + "the index keeps HEAD's gitlink, as files restore without touching history", + ); + assert.equal( + yield* git(parent, ["ls-tree", "HEAD~1", "framework"]).pipe( + Effect.map((line) => line.split(/\s+/)[2]), + ), + firstPin, + ); + // The source checkouts and their origins are untouched. + assert.equal( + yield* git(path.join(fixture.server, "framework"), ["rev-parse", "HEAD"]), + sourceFrameworkHead, + ); + assert.equal(yield* git(fixture.server, ["rev-parse", "HEAD"]), sourceServerHead); + assert.equal( + yield* git(path.join(fixture.server, "framework"), [ + "config", + "--get", + "remote.origin.url", + ]), + sourceOrigin, + ); + }), + ), + ); + + it.effect("moves the submodule of a thread's own checkout back with its files", () => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + yield* createProject(serverProjectId, fixture.server); + const own = path.join(fixture.worktrees, "server", "own"); + yield* git(fixture.server, ["worktree", "add", "-b", "t3/own", own]); + yield* git(own, ["submodule", "update", "--init", "--quiet"]); + yield* configureIdentity(own); + const threadId = yield* createThread({ + threadId: "thread:own-checkout", + projectId: serverProjectId, + worktreePath: own, + branch: "t3/own", + }); + let framework = ""; + const pin = (contents: string) => + Effect.gen(function* () { + yield* write(path.join(framework, "README.md"), contents); + yield* git(framework, ["commit", "-am", contents.trim()]); + return (yield* repoWorktrees.setSubmoduleCommit({ + threadId, + worktreePath: own, + submodulePath: "framework", + sourceWorktreePath: framework, + })).commit; + }); + + let firstPin = ""; + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + // Pinning into its own checkout needs it attached. + const attached = yield* repoWorktrees.ensure({ + threadId, + repository: "server", + branch: "t3/own", + }); + assert.equal(attached.repoWorktree.worktreePath, own); + framework = (yield* repoWorktrees.ensure({ + threadId, + repository: "server/framework", + branch: "t3/framework", + baseRef: "main", + })).repoWorktree.worktreePath; + yield* configureIdentity(framework); + firstPin = yield* pin("framework one\n"); + }), + ); + yield* runTurn(agent, threadId, Effect.asVoid(pin("framework two\n"))); + + const rewound = yield* rollback(threadId, 1); + assert.isNull(rewound.thread.rollbackFailure ?? null); + assert.equal(yield* git(path.join(own, "framework"), ["rev-parse", "HEAD"]), firstPin); + }), + ), + ); + + it.effect.each(["removed", "without its checkout"] as const)( + "handles a thread's own attached checkout whose folder is %s", + (state) => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + yield* createProject(serverProjectId, fixture.server); + const own = path.join(fixture.worktrees, "server", "own-attached"); + yield* git(fixture.server, ["worktree", "add", "-b", "t3/own-attached", own]); + const threadId = yield* createThread({ + threadId: "thread:own-attached", + projectId: serverProjectId, + worktreePath: own, + branch: "t3/own-attached", + }); + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + yield* repoWorktrees.ensure({ + threadId, + repository: "server", + branch: "t3/own-attached", + }); + yield* write(path.join(own, "notes.txt"), "turn 1\n"); + }), + ); + yield* runTurn(agent, threadId, write(path.join(own, "notes.txt"), "turn 2\n")); + + if (state === "removed") { + yield* fileSystem.remove(own, { recursive: true }); + const rewound = yield* rollback(threadId, 1); + assert.isNull(rewound.thread.rollbackFailure ?? null); + assert.equal(yield* git(own, ["branch", "--show-current"]), "t3/own-attached"); + assert.equal(yield* read(path.join(own, "notes.txt")), "turn 1\n"); + return; + } + // A failed `git worktree remove` can leave the folder without its checkout. Its + // submodules may hold work no checkpoint has, so it is not replaced. + yield* fileSystem.remove(path.join(own, ".git")); + yield* rollback(threadId, 1); + for (let attempt = 0; attempt < 5; attempt += 1) { + yield* TestClock.adjust(Duration.seconds(31)); + yield* worker.drain(); + } + const refused = yield* projectionOf(threadId); + assert.include( + refused.thread.rollbackFailure?.message ?? "", + "is no longer a worktree of server", + ); + assert.equal(yield* agent.rollbacks.pipe(Ref.get), 0); + assert.equal(yield* read(path.join(own, "notes.txt")), "turn 2\n"); + }), + ), + ); + + it.effect("restores a worktree first attached after the target, and one detached since", () => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const settings = yield* ServerSettings.ServerSettingsService; + const threadId = yield* createThread({ threadId: "thread:late" }); + let alpha = ""; + let beta = ""; + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + alpha = (yield* repoWorktrees.ensure({ + threadId, + repository: "alpha", + branch: "t3/late", + baseRef: "main", + })).repoWorktree.worktreePath; + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 1\n"); + }), + ); + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + beta = (yield* repoWorktrees.ensure({ + threadId, + repository: "beta", + branch: "t3/late", + baseRef: "main", + })).repoWorktree.worktreePath; + yield* write(path.join(beta, "README.md"), "# beta\nturn 2\n"); + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 2\n"); + }), + ); + // The worktree is let go and the feature turned off; the history still restores it. + yield* repoWorktrees.detach({ threadId, worktreePath: beta }); + yield* settings.updateSettings({ repoWorktrees: false }); + + const rewound = yield* rollback(threadId, 1); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nturn 1\n"); + assert.equal(yield* read(path.join(beta, "README.md")), "# beta\n"); + assert.isNull(rewound.thread.rollbackFailure ?? null); + assert.equal(yield* read(path.join(fixture.beta, "README.md")), "# beta\n"); + }), + ), + ); + + it.effect("names a detached worktree and a later one of its repository apart", () => + withHarness(({ agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const diffs = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const threadId = yield* createThread({ threadId: "thread:history-prefix" }); + const work = (branch: string, contents: string) => + Effect.gen(function* () { + const worktree = (yield* repoWorktrees.ensure({ + threadId, + repository: "alpha", + branch, + baseRef: "main", + })).repoWorktree.worktreePath; + yield* write(path.join(worktree, "README.md"), contents); + return worktree; + }); + let first = ""; + yield* runTurn( + agent, + threadId, + Effect.map(work("t3/history-one", "# alpha\nfirst worktree\n"), (worktree) => { + first = worktree; + }), + ); + yield* repoWorktrees.detach({ threadId, worktreePath: first }); + let second = ""; + yield* runTurn( + agent, + threadId, + Effect.map(work("t3/history-two", "# alpha\nsecond worktree\n"), (worktree) => { + second = worktree; + }), + ); + + const prefixes = (yield* projectionOf(threadId)).checkpoints + .filter((checkpoint) => checkpoint.appRunOrdinal !== null) + .flatMap((checkpoint) => checkpoint.repositories ?? []) + .map((entry) => [entry.worktreePath, entry.pathPrefix]); + const secondPrefix = `alpha@${path.basename(second)}`; + assert.deepEqual(prefixes, [ + [first, "alpha"], + [second, secondPrefix], + ]); + const full = yield* diffs.getFullThreadDiff({ threadId, toTurnCount: 2 }); + assert.include(full.diff, "diff --git a/alpha/README.md b/alpha/README.md"); + assert.include( + full.diff, + `diff --git a/${secondPrefix}/README.md b/${secondPrefix}/README.md`, + ); + assert.include(full.diff, "+first worktree"); + assert.include(full.diff, "+second worktree"); + }), + ), + ); + + it.effect("refuses file restore of a checkout another thread works in, both ways", () => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const diffs = yield* CheckpointDiffQuery.CheckpointDiffQuery; + yield* createProject(alphaProjectId, fixture.alpha); + // An ordinary thread's own worktree, reached through a link. + const worktree = path.join(fixture.worktrees, "alpha", "ordinary"); + yield* git(fixture.alpha, ["worktree", "add", "-b", "t3/ordinary", worktree]); + const alias = path.join(fixture.root, "alias"); + yield* fileSystem.symlink(fixture.worktrees, alias); + const ordinary = yield* createThread({ + threadId: "thread:ordinary", + projectId: alphaProjectId, + worktreePath: path.join(alias, "alpha", "ordinary"), + branch: "t3/ordinary", + }); + yield* runTurn( + agent, + ordinary, + write(path.join(worktree, "README.md"), "# alpha\nordinary\n"), + ); + + const folder = yield* createThread({ threadId: "thread:folder-reuse" }); + yield* runTurn( + agent, + folder, + Effect.gen(function* () { + const reused = yield* repoWorktrees.ensure({ + threadId: folder, + repository: "alpha", + branch: "t3/ordinary", + }); + assert.equal(reused.repoWorktree.worktreePath, worktree); + yield* write(path.join(worktree, "README.md"), "# alpha\nordinary\nfolder\n"); + }), + ); + const otherFolder = yield* createThread({ threadId: "thread:folder-second" }); + yield* repoWorktrees.ensure({ + threadId: otherFolder, + repository: "alpha", + branch: "t3/ordinary", + }); + + const before = yield* read(path.join(worktree, "README.md")); + for (const threadId of [ordinary, folder]) { + const error = yield* orchestrator + .dispatch(yield* rollbackCommand(threadId, 0, true)) + .pipe(Effect.flip); + assert.equal(error._tag, "OrchestratorDispatchError"); + assert.include( + error._tag === "OrchestratorDispatchError" ? String(error.cause) : "", + threadId === ordinary + ? SHARED_WORKSPACE_RESTORE_MESSAGE + : "Another thread also works in the alpha worktree", + ); + } + assert.equal(yield* read(path.join(worktree, "README.md")), before); + assert.equal(yield* agent.rollbacks.pipe(Ref.get), 0); + + // Reading stays open: diffs, and rewinding the conversation alone. + const folderDiff = yield* diffs.getFullThreadDiff({ threadId: folder, toTurnCount: 1 }); + assert.include(folderDiff.diff, "diff --git a/alpha/README.md b/alpha/README.md"); + yield* rollback(folder, 0, false); + assert.equal(yield* read(path.join(worktree, "README.md")), before); + assert.equal(yield* agent.rollbacks.pipe(Ref.get), 1); + }), + ), + ); + + it.effect("changes nothing when one repository cannot be restored", () => + withHarness(({ agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* createThread({ threadId: "thread:failure" }); + let alpha = ""; + let beta = ""; + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + alpha = (yield* repoWorktrees.ensure({ + threadId, + repository: "alpha", + branch: "t3/failure", + baseRef: "main", + })).repoWorktree.worktreePath; + beta = (yield* repoWorktrees.ensure({ + threadId, + repository: "beta", + branch: "t3/failure", + baseRef: "main", + })).repoWorktree.worktreePath; + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 1\n"); + yield* write(path.join(beta, "README.md"), "# beta\nturn 1\n"); + }), + ); + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 2\n"); + yield* write(path.join(beta, "README.md"), "# beta\nturn 2\n"); + }), + ); + // Beta's snapshot for turn 1 is gone. + const projection = yield* projectionOf(threadId); + const betaRef = projection.checkpoints + .find((checkpoint) => checkpoint.appRunOrdinal === 1) + ?.repositories?.find((entry) => entry.repository === "beta")?.ref; + yield* git(beta, ["update-ref", "-d", betaRef!]); + + yield* rollback(threadId, 1); + for (let attempt = 0; attempt < 5; attempt += 1) { + yield* TestClock.adjust(Duration.seconds(31)); + yield* worker.drain(); + } + const after = yield* projectionOf(threadId); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nturn 2\n"); + assert.equal(yield* read(path.join(beta, "README.md")), "# beta\nturn 2\n"); + assert.equal(after.runs.find((run) => run.ordinal === 2)?.status, "completed"); + assert.equal(yield* agent.rollbacks.pipe(Ref.get), 0); + assert.include(after.thread.rollbackFailure?.message ?? "", "No checkpoint of beta"); + assert.include(after.thread.rollbackFailure?.message ?? "", "Nothing was changed"); + }), + ), + ); + + // Permissions do not stop root or Windows from writing. + const permissionsBlockWrites = + HostProcess.Platform.defaultValue() !== "win32" && HostProcess.UserId.defaultValue() !== 0; + const failsPartway = (finish: "rollback" | "outbox") => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const diffs = yield* CheckpointDiffQuery.CheckpointDiffQuery; + yield* initRepo(path.join(fixture.folder, "gamma")); + const threadId = yield* createThread({ threadId: "thread:partial" }); + const ensure = (repository: string) => + Effect.map( + repoWorktrees.ensure({ threadId, repository, branch: "t3/partial", baseRef: "main" }), + (result) => result.repoWorktree.worktreePath, + ); + let alpha = ""; + let beta = ""; + let gamma = ""; + yield* runTurn(agent, threadId, Effect.void); + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + alpha = yield* ensure("alpha"); + beta = yield* ensure("beta"); + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 2\n"); + yield* write(path.join(beta, "README.md"), "# beta\nturn 2\n"); + }), + ); + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 3\n"); + yield* git(alpha, ["commit", "-am", "turn 3 work"]); + yield* write(path.join(beta, "README.md"), "# beta\nturn 3\n"); + // First used in the turn being rewound, so its baseline is the target. + gamma = yield* ensure("gamma"); + yield* write(path.join(gamma, "README.md"), "# gamma\nturn 3\n"); + }), + ); + const committed = yield* git(alpha, ["rev-parse", "HEAD"]); + const nativeTurns = yield* Ref.get(agent.nativeTurns); + assert.equal(nativeTurns.length, 3); + const retained = nativeTurns.slice(0, 2); + + // Git can read beta but not replace its files. + yield* fileSystem.chmod(beta, 0o555); + yield* Effect.addFinalizer(() => fileSystem.chmod(beta, 0o755).pipe(Effect.ignore)); + + const partial = yield* rollback(threadId, 2); + // Codex and the thread both went back to turn 2, in one step. + assert.deepEqual(yield* Ref.get(agent.nativeTurns), retained); + assert.deepEqual( + partial.runs.map((run) => run.status), + ["completed", "completed", "rolled_back"], + ); + const message = partial.thread.rollbackFailure?.message ?? ""; + assert.include( + message, + "The conversation was rewound, but restoring files stopped at the beta worktree", + ); + assert.include(message, "Already restored: alpha."); + // Not finished: turn 3's checkpoint still holds what the retry restores. + assert.equal( + partial.checkpoints.find((checkpoint) => checkpoint.appRunOrdinal === 3)?.status, + "ready", + ); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nturn 2\n"); + assert.equal(yield* git(alpha, ["rev-parse", "HEAD"]), committed); + assert.equal(yield* read(path.join(beta, "README.md")), "# beta\nturn 3\n"); + + if (finish === "outbox") { + // Fixed before the outbox's next attempt, which finishes the files. + yield* fileSystem.chmod(beta, 0o755); + yield* TestClock.adjust(Duration.seconds(31)); + yield* worker.drain(); + } else { + // The outbox's remaining attempts find nothing left to rewind. + for (let attempt = 0; attempt < 4; attempt += 1) { + yield* TestClock.adjust(Duration.seconds(31)); + yield* worker.drain(); + } + assert.equal(yield* Ref.get(agent.rollbacks), 1); + assert.deepEqual(yield* Ref.get(agent.nativeTurns), retained); + assert.equal((yield* projectionOf(threadId)).thread.rollbackFailure?.message, message); + + // The rewound turn's checkpoint is not a target while its files wait. + const refused = yield* orchestrator + .dispatch(yield* rollbackCommand(threadId, 3, true)) + .pipe(Effect.flip); + assert.include( + refused._tag === "OrchestratorDispatchError" ? String(refused.cause) : "", + "its provider turn is unavailable", + ); + + // Fixed, the same rollback finishes the files. + yield* fileSystem.chmod(beta, 0o755); + yield* rollback(threadId, 2); + } + // Codex was asked once, and kept its first two turns. + const finished = yield* projectionOf(threadId); + assert.equal(yield* Ref.get(agent.rollbacks), 1); + assert.deepEqual(yield* Ref.get(agent.nativeTurns), retained); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nturn 2\n"); + assert.equal(yield* read(path.join(beta, "README.md")), "# beta\nturn 2\n"); + assert.equal(yield* read(path.join(gamma, "README.md")), "# gamma\n"); + assert.equal( + finished.checkpoints.find((checkpoint) => checkpoint.appRunOrdinal === 3)?.status, + "stale", + ); + assert.isNull(finished.thread.rollbackFailure ?? null); + + // The thread goes on from turn 2. + yield* runTurn( + agent, + threadId, + write(path.join(alpha, "README.md"), "# alpha\nturn 2\nturn 4\n"), + ); + const continued = yield* Ref.get(agent.nativeTurns); + assert.deepEqual(continued.slice(0, 2), retained); + assert.equal(continued.length, 3); + const fourth = yield* diffs.getTurnDiff({ threadId, fromTurnCount: 3, toTurnCount: 4 }); + assert.include(fourth.diff, "+turn 4"); + assert.notInclude(fourth.diff, "beta/"); + assert.notInclude(fourth.diff, "gamma/"); + }), + ); + it.effect.skipIf(!permissionsBlockWrites)( + "keeps the retained turns when restoring files fails partway, then finishes on a later rollback", + () => failsPartway("rollback"), + ); + it.effect.skipIf(!permissionsBlockWrites)( + "finishes restoring files on the outbox's next attempt once the worktree is fixed", + () => failsPartway("outbox"), + ); + + /** + * Turn 1 attaches nothing, turn 2 attaches alpha, and turn 3 first attaches + * beta, a reused worktree with edits of its own. Rewinding to turn 2 then + * sends beta back to how the thread found it. + */ + const attachedInRewoundTurn = (fixture: Fixture, agent: Agent, name: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread({ threadId: `thread:${name}` }); + const beta = path.join(fixture.worktrees, "beta", "shared"); + yield* git(fixture.beta, ["worktree", "add", "-b", "shared", beta]); + yield* write(path.join(beta, "README.md"), "# beta\nearlier edit\n"); + yield* write(path.join(beta, "notes.txt"), "earlier untracked\n"); + let alpha = ""; + yield* runTurn(agent, threadId, Effect.void); + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + alpha = (yield* repoWorktrees.ensure({ + threadId, + repository: "alpha", + branch: `t3/${name}`, + baseRef: "main", + })).repoWorktree.worktreePath; + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 2\n"); + }), + ); + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + const reused = yield* repoWorktrees.ensure({ + threadId, + repository: "beta", + branch: "shared", + }); + assert.equal(reused.status, "reused"); + yield* write(path.join(beta, "README.md"), "# beta\nearlier edit\nturn 3\n"); + yield* write(path.join(beta, "draft.txt"), "turn 3\n"); + }), + ); + const turnThreeRef = (yield* projectionOf(threadId)).checkpoints + .find((checkpoint) => checkpoint.appRunOrdinal === 3) + ?.repositories?.find((entry) => entry.repository === "beta")?.ref; + assert.isDefined(turnThreeRef); + const turnThree = { + ref: turnThreeRef!, + commit: yield* git(beta, ["rev-parse", turnThreeRef!]), + }; + + const rewound = yield* rollback(threadId, 2); + assert.isNull(rewound.thread.rollbackFailure ?? null); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nturn 2\n"); + yield* expectBetaBaseline(beta); + return { threadId, alpha, beta, turnThree }; + }); + const expectBetaBaseline = (beta: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + assert.equal(yield* read(path.join(beta, "README.md")), "# beta\nearlier edit\n"); + assert.equal(yield* read(path.join(beta, "notes.txt")), "earlier untracked\n"); + assert.isFalse(yield* exists(path.join(beta, "draft.txt"))); + assert.equal(yield* git(beta, ["status", "--porcelain"]), "M README.md\n?? notes.txt"); + }); + + it.effect("restores a worktree first attached in a rewound turn on each earlier rewind", () => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const diffs = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const { threadId, alpha, beta } = yield* attachedInRewoundTurn( + fixture, + agent, + "rewound-attach", + ); + + const first = yield* rollback(threadId, 1); + assert.isNull(first.thread.rollbackFailure ?? null); + assert.equal(first.runs.find((run) => run.ordinal === 2)?.status, "rolled_back"); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\n"); + yield* expectBetaBaseline(beta); + + // A turn after the gap diffs beta against that baseline, and rewinding it finds it again. + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 4\n"); + yield* write(path.join(beta, "README.md"), "# beta\nearlier edit\nturn 4\n"); + yield* write(path.join(beta, "draft.txt"), "turn 4\n"); + }), + ); + const fourth = yield* diffs.getTurnDiff({ threadId, fromTurnCount: 1, toTurnCount: 4 }); + assert.include(fourth.diff, "diff --git a/beta/README.md b/beta/README.md"); + assert.include(fourth.diff, "+turn 4"); + assert.notInclude(fourth.diff, "+earlier edit"); + assert.notInclude(fourth.diff, "notes.txt"); + const second = yield* rollback(threadId, 1); + assert.isNull(second.thread.rollbackFailure ?? null); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\n"); + yield* expectBetaBaseline(beta); + + const start = yield* rollback(threadId, 0); + assert.isNull(start.thread.rollbackFailure ?? null); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\n"); + yield* expectBetaBaseline(beta); + assert.deepEqual(yield* Ref.get(agent.nativeTurns), []); + assert.equal(yield* Ref.get(agent.rollbacks), 4); + // The source checkouts never change. + assert.equal(yield* read(path.join(fixture.beta, "README.md")), "# beta\n"); + assert.equal(yield* git(fixture.beta, ["status", "--porcelain"]), ""); + assert.equal(yield* git(fixture.alpha, ["branch", "--show-current"]), "main"); + }), + ), + ); + + it.effect( + "refuses an earlier rewind whose first-attach baseline is gone, not using a newer snapshot", + () => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const { threadId, alpha, beta, turnThree } = yield* attachedInRewoundTurn( + fixture, + agent, + "rewound-attach-missing", + ); + // The baseline is gone, while turn 3's edited snapshot outlived its rewind. + const refs = (yield* git(beta, [ + "for-each-ref", + "--format=%(refname)", + "refs/t3/", + ])).split("\n"); + assert.equal(refs.length, 1); + yield* git(beta, ["update-ref", "-d", refs[0]!]); + yield* git(beta, ["update-ref", turnThree.ref, turnThree.commit]); + + yield* rollback(threadId, 1); + for (let attempt = 0; attempt < 5; attempt += 1) { + yield* TestClock.adjust(Duration.seconds(31)); + yield* worker.drain(); + } + const after = yield* projectionOf(threadId); + assert.include(after.thread.rollbackFailure?.message ?? "", "No checkpoint of beta"); + assert.include(after.thread.rollbackFailure?.message ?? "", "Nothing was changed"); + assert.equal(after.runs.find((run) => run.ordinal === 2)?.status, "completed"); + assert.equal(yield* Ref.get(agent.rollbacks), 1); + assert.equal((yield* Ref.get(agent.nativeTurns)).length, 2); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nturn 2\n"); + yield* expectBetaBaseline(beta); + }), + ), + ); + + it.effect.skipIf(!permissionsBlockWrites)( + "retries an earlier rewind's file restore from the same first-attach baseline", + () => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const { threadId, alpha, beta } = yield* attachedInRewoundTurn( + fixture, + agent, + "rewound-attach-retry", + ); + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 4\n"); + yield* write(path.join(beta, "README.md"), "# beta\nearlier edit\nturn 4\n"); + yield* write(path.join(beta, "draft.txt"), "turn 4\n"); + }), + ); + const retained = (yield* Ref.get(agent.nativeTurns)).slice(0, 1); + + // Git can read beta but not replace its files. + yield* fileSystem.chmod(beta, 0o555); + yield* Effect.addFinalizer(() => fileSystem.chmod(beta, 0o755).pipe(Effect.ignore)); + const partial = yield* rollback(threadId, 1); + assert.include( + partial.thread.rollbackFailure?.message ?? "", + "The conversation was rewound, but restoring files stopped at the beta worktree", + ); + assert.include(partial.thread.rollbackFailure?.message ?? "", "Already restored: alpha."); + assert.deepEqual(yield* Ref.get(agent.nativeTurns), retained); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\n"); + assert.equal(yield* read(path.join(beta, "README.md")), "# beta\nearlier edit\nturn 4\n"); + + // Fixed before the outbox's next attempt, which finishes the files. + yield* fileSystem.chmod(beta, 0o755); + yield* TestClock.adjust(Duration.seconds(31)); + yield* worker.drain(); + const finished = yield* projectionOf(threadId); + assert.isNull(finished.thread.rollbackFailure ?? null); + assert.equal( + finished.checkpoints.find((checkpoint) => checkpoint.appRunOrdinal === 4)?.status, + "stale", + ); + assert.equal(yield* Ref.get(agent.rollbacks), 2); + assert.deepEqual(yield* Ref.get(agent.nativeTurns), retained); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\n"); + yield* expectBetaBaseline(beta); + }), + ), + ); + + /** + * Turn 1 attaches alpha, which is detached before turn 2, so turn 2 has no + * worktrees. Turn 3 attaches alpha again and beta for the first time. Turn + * 2's checkpoint borrows their attach snapshots, which is no sign that turn + * 2 captured either one. + */ + const detachedAllThenAttached = (agent: Agent, name: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread({ threadId: `thread:${name}` }); + const ensure = (repository: string) => + repoWorktrees + .ensure({ threadId, repository, branch: `t3/${name}`, baseRef: "main" }) + .pipe(Effect.map((result) => result.repoWorktree.worktreePath)); + let alpha = ""; + let beta = ""; + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + alpha = yield* ensure("alpha"); + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 1\n"); + }), + ); + yield* repoWorktrees.detach({ threadId, worktreePath: alpha }); + assert.deepEqual((yield* projectionOf(threadId)).thread.repoWorktrees, []); + yield* runTurn(agent, threadId, Effect.void); + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + assert.equal(yield* ensure("alpha"), alpha); + beta = yield* ensure("beta"); + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 1\nturn 3\n"); + yield* write(path.join(beta, "README.md"), "# beta\nturn 3\n"); + }), + ); + const borrowed = (yield* projectionOf(threadId)).checkpoints + .find((checkpoint) => checkpoint.appRunOrdinal === 2) + ?.repositories?.find((entry) => entry.repository === "beta"); + assert.isDefined(borrowed); + return { threadId, alpha, beta, borrowed: borrowed! }; + }); + + it.effect("restores an earlier turn after every worktree was detached and others attached", () => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const diffs = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const { threadId, alpha, beta, borrowed } = yield* detachedAllThenAttached( + agent, + "detach-all", + ); + + const first = yield* rollback(threadId, 1); + assert.isNull(first.thread.rollbackFailure ?? null); + assert.deepEqual( + first.runs.map((run) => run.status), + ["completed", "rolled_back", "rolled_back"], + ); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nturn 1\n"); + assert.equal(yield* read(path.join(beta, "README.md")), "# beta\n"); + assert.equal((yield* Ref.get(agent.nativeTurns)).length, 1); + + // Beta's attach snapshot outlives that rewind: a later turn diffs + // against it, and rewinding to the start restores it again. + yield* runTurn(agent, threadId, write(path.join(beta, "README.md"), "# beta\nturn 4\n")); + const fourth = yield* diffs.getTurnDiff({ threadId, fromTurnCount: 1, toTurnCount: 4 }); + assert.include(fourth.diff, "+turn 4"); + assert.notInclude(fourth.diff, "turn 3"); + const start = yield* rollback(threadId, 0); + assert.isNull(start.thread.rollbackFailure ?? null); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\n"); + assert.equal(yield* read(path.join(beta, "README.md")), "# beta\n"); + assert.deepEqual(yield* Ref.get(agent.nativeTurns), []); + assert.equal(yield* Ref.get(agent.rollbacks), 2); + assert.isTrue(borrowed.backfilled); + for (const source of [fixture.alpha, fixture.beta]) { + assert.equal(yield* git(source, ["status", "--porcelain"]), ""); + assert.equal(yield* git(source, ["branch", "--show-current"]), "main"); + } + }), + ), + ); + + it.effect.skipIf(!permissionsBlockWrites)( + "finishes that rewind's file restore on a retry from the same attach snapshot", + () => + withHarness(({ agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const { threadId, alpha, beta } = yield* detachedAllThenAttached( + agent, + "detach-all-retry", + ); + // Git can read beta but not replace its files. + yield* fileSystem.chmod(beta, 0o555); + yield* Effect.addFinalizer(() => fileSystem.chmod(beta, 0o755).pipe(Effect.ignore)); + const partial = yield* rollback(threadId, 1); + assert.include( + partial.thread.rollbackFailure?.message ?? "", + "restoring files stopped at the beta worktree", + ); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nturn 1\n"); + assert.equal(yield* read(path.join(beta, "README.md")), "# beta\nturn 3\n"); + + yield* fileSystem.chmod(beta, 0o755); + yield* TestClock.adjust(Duration.seconds(31)); + yield* worker.drain(); + const finished = yield* projectionOf(threadId); + assert.isNull(finished.thread.rollbackFailure ?? null); + assert.equal(yield* read(path.join(beta, "README.md")), "# beta\n"); + // The provider already rewound; finishing the files does not ask it again. + assert.equal(yield* Ref.get(agent.rollbacks), 1); + assert.equal((yield* Ref.get(agent.nativeTurns)).length, 1); + }), + ), + ); + + it.effect("refuses that rewind once beta's attach snapshot is gone, not using a newer one", () => + withHarness(({ agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const { threadId, alpha, beta, borrowed } = yield* detachedAllThenAttached( + agent, + "detach-all-missing", + ); + // Turn 3's own snapshot of beta, with its edit, is still there. + yield* git(beta, ["update-ref", "-d", borrowed.ref]); + + yield* rollback(threadId, 1); + for (let attempt = 0; attempt < 5; attempt += 1) { + yield* TestClock.adjust(Duration.seconds(31)); + yield* worker.drain(); + } + const after = yield* projectionOf(threadId); + assert.include(after.thread.rollbackFailure?.message ?? "", "No checkpoint of beta"); + assert.include(after.thread.rollbackFailure?.message ?? "", "Nothing was changed"); + assert.deepEqual( + after.runs.map((run) => run.status), + ["completed", "completed", "completed"], + ); + assert.equal(yield* Ref.get(agent.rollbacks), 0); + assert.equal((yield* Ref.get(agent.nativeTurns)).length, 3); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nturn 1\nturn 3\n"); + assert.equal(yield* read(path.join(beta, "README.md")), "# beta\nturn 3\n"); + }), + ), + ); + + /** + * Turn 1 leaves alpha's README with a kept line, and turn 2 adds one that + * rewinding to turn 1 removes. With `lock`, a lock file on turn 2's + * snapshot ref, as another Git process leaves, makes Git refuse to delete + * that snapshot, so it still holds turn 2's edit where turn 3's baseline goes. + */ + const rewindLeavingStaleSnapshot = ( + fixture: Fixture, + agent: Agent, + input: { readonly name: string; readonly ordinary: boolean; readonly lock: boolean }, + ) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const branch = `t3/${input.name}`; + let cwd = path.join(fixture.worktrees, "alpha", input.name); + let threadId: ThreadId; + if (input.ordinary) { + yield* createProject(alphaProjectId, fixture.alpha); + yield* git(fixture.alpha, ["worktree", "add", "-b", branch, cwd]); + threadId = yield* createThread({ + threadId: `thread:${input.name}`, + projectId: alphaProjectId, + worktreePath: cwd, + branch, + }); + } else { + threadId = yield* createThread({ threadId: `thread:${input.name}` }); + } + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + if (!input.ordinary) { + cwd = (yield* repoWorktrees.ensure({ + threadId, + repository: "alpha", + branch, + baseRef: "main", + })).repoWorktree.worktreePath; + } + yield* write(path.join(cwd, "README.md"), "# alpha\nkept\n"); + }), + ); + yield* runTurn( + agent, + threadId, + write(path.join(cwd, "README.md"), "# alpha\nkept\nturn 2\n"), + ); + const second = (yield* projectionOf(threadId)).checkpoints.find( + (checkpoint) => checkpoint.appRunOrdinal === 2, + ); + const ref = input.ordinary ? second!.ref : second!.repositories![0]!.ref; + const lockPath = yield* git(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-path", + `${ref}.lock`, + ]); + if (input.lock) { + yield* write(lockPath, "held by another Git process\n"); + yield* Effect.addFinalizer(() => fileSystem.remove(lockPath).pipe(Effect.ignore)); + } + + const rewound = yield* rollback(threadId, 1); + assert.isNull(rewound.thread.rollbackFailure ?? null); + assert.equal( + rewound.checkpoints.find((checkpoint) => checkpoint.appRunOrdinal === 2)?.status, + "stale", + ); + assert.equal(yield* read(path.join(cwd, "README.md")), "# alpha\nkept\n"); + assert.equal( + yield* git(cwd, ["for-each-ref", "--format=%(refname)", ref]), + input.lock ? ref : "", + ); + return { threadId, cwd, ref, lockPath, prefix: input.ordinary ? "" : "alpha/" }; + }); + const expectSourceUnchanged = (fixture: Fixture) => + Effect.gen(function* () { + const path = yield* Path.Path; + assert.equal(yield* read(path.join(fixture.alpha, "README.md")), "# alpha\n"); + assert.equal(yield* git(fixture.alpha, ["status", "--porcelain"]), ""); + assert.equal(yield* git(fixture.alpha, ["branch", "--show-current"]), "main"); + }); + const turnCounts = (threadId: ThreadId, turn: number) => + projectionOf(threadId).pipe( + Effect.map((projection) => + projection.checkpoints + .find((checkpoint) => checkpoint.appRunOrdinal === turn) + ?.files.map((file) => [file.path, file.additions, file.deletions]), + ), + ); + + it.effect.each([ + { worktree: "repository", lock: "released" }, + { worktree: "repository", lock: "none" }, + { worktree: "ordinary", lock: "released" }, + { worktree: "ordinary", lock: "none" }, + ] as const)( + "baselines the turn after a $worktree worktree rewind on its restored files, lock $lock", + ({ worktree, lock }) => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const diffs = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const { threadId, cwd, ref, lockPath, prefix } = yield* rewindLeavingStaleSnapshot( + fixture, + agent, + { + name: `stale-${worktree}-${lock}`, + ordinary: worktree === "ordinary", + lock: lock !== "none", + }, + ); + // Another Git process lets go before the next turn. + if (lock === "released") yield* fileSystem.remove(lockPath); + + let baseline = ""; + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + baseline = yield* git(cwd, ["show", `${ref}:README.md`]); + yield* write(path.join(cwd, "README.md"), "# alpha\nkept\nturn 3\n"); + }), + ); + assert.equal(baseline, "# alpha\nkept"); + assert.deepEqual(yield* turnCounts(threadId, 3), [[`${prefix}README.md`, 1, 0]]); + const diff = yield* diffs.getTurnDiff({ threadId, fromTurnCount: 1, toTurnCount: 3 }); + assert.include(diff.diff, "+turn 3"); + assert.notInclude(diff.diff, "turn 2"); + + const again = yield* rollback(threadId, 1); + assert.isNull(again.thread.rollbackFailure ?? null); + assert.equal(yield* read(path.join(cwd, "README.md")), "# alpha\nkept\n"); + yield* expectSourceUnchanged(fixture); + }), + ), + ); + + it.effect.each(["repository", "ordinary"] as const)( + "leaves a %s worktree turn uncounted, not miscounted, while its stale baseline stays locked", + (worktree) => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const diffs = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const { threadId, cwd, ref, lockPath, prefix } = yield* rewindLeavingStaleSnapshot( + fixture, + agent, + { name: `locked-${worktree}`, ordinary: worktree === "ordinary", lock: true }, + ); + + // Capture retries a locked ref after a short real delay before it gives up. + yield* TestClock.withLive( + runTurn(agent, threadId, write(path.join(cwd, "README.md"), "# alpha\nkept\nturn 3\n")), + ); + // Git could not replace the stale snapshot, and nothing calls it a baseline. + assert.equal(yield* git(cwd, ["show", `${ref}:README.md`]), "# alpha\nkept\nturn 2"); + assert.deepEqual(yield* turnCounts(threadId, 3), []); + const third = yield* projectionOf(threadId); + assert.notInclude( + third.checkpoints + .filter((checkpoint) => checkpoint.ordinalWithinScope === 2) + .map((checkpoint) => checkpoint.status), + "ready", + ); + const diff = yield* diffs.getTurnDiff({ threadId, fromTurnCount: 1, toTurnCount: 3 }); + assert.include(diff.diff, "+turn 3"); + assert.notInclude(diff.diff, "turn 2"); + + // Once Git lets go, the next turn counts against turn 3's own snapshot. + yield* fileSystem.remove(lockPath); + yield* runTurn( + agent, + threadId, + write(path.join(cwd, "README.md"), "# alpha\nkept\nturn 3\nturn 4\n"), + ); + assert.deepEqual(yield* turnCounts(threadId, 4), [[`${prefix}README.md`, 1, 0]]); + const again = yield* rollback(threadId, 1); + assert.isNull(again.thread.rollbackFailure ?? null); + assert.equal(yield* read(path.join(cwd, "README.md")), "# alpha\nkept\n"); + yield* expectSourceUnchanged(fixture); + }), + ), + ); + + it.effect("recovers a rewind whose reply was lost without removing earlier turns", () => + withHarness(({ agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* createThread({ threadId: "thread:lost-reply" }); + // History the provider thread started with, as a fork's does. + yield* Ref.set(agent.nativeTurns, ["native:inherited"]); + let alpha = ""; + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + alpha = (yield* repoWorktrees.ensure({ + threadId, + repository: "alpha", + branch: "t3/lost-reply", + baseRef: "main", + })).repoWorktree.worktreePath; + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 1\n"); + }), + ); + yield* runTurn(agent, threadId, write(path.join(alpha, "README.md"), "# alpha\nturn 2\n")); + + yield* Ref.set(agent.loseNextRevertReply, true); + const lost = yield* rollback(threadId, 0); + // Codex rewound, but without its reply nothing was recorded or restored. + assert.deepEqual(yield* Ref.get(agent.nativeTurns), ["native:inherited"]); + assert.deepEqual( + lost.runs.map((run) => run.status), + ["completed", "completed"], + ); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nturn 2\n"); + + yield* TestClock.adjust(Duration.seconds(31)); + yield* worker.drain(); + const recovered = yield* projectionOf(threadId); + assert.equal(yield* Ref.get(agent.rollbacks), 2); + assert.deepEqual(yield* Ref.get(agent.nativeTurns), ["native:inherited"]); + assert.deepEqual( + recovered.runs.map((run) => run.status), + ["rolled_back", "rolled_back"], + ); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\n"); + }), + ), + ); + + it.effect.each(["removed", "replaced"] as const)( + "handles a repository worktree whose folder is %s", + (state) => + withHarness(({ fixture, agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* createThread({ threadId: `thread:${state}` }); + let alpha = ""; + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + alpha = (yield* repoWorktrees.ensure({ + threadId, + repository: "alpha", + branch: "t3/missing", + baseRef: "main", + })).repoWorktree.worktreePath; + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 1\n"); + }), + ); + yield* runTurn( + agent, + threadId, + write(path.join(alpha, "README.md"), "# alpha\nturn 2\n"), + ); + yield* fileSystem.remove(alpha, { recursive: true }); + if (state === "replaced") { + yield* fileSystem.makeDirectory(alpha); + yield* write(path.join(alpha, "unrelated.txt"), "someone else's folder\n"); + } + + const after = yield* rollback(threadId, 1); + if (state === "removed") { + // Recreated from its branch, like a thread's own removed worktree. + assert.equal(yield* git(alpha, ["branch", "--show-current"]), "t3/missing"); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nturn 1\n"); + assert.equal(after.runs.find((run) => run.ordinal === 2)?.status, "rolled_back"); + return; + } + for (let attempt = 0; attempt < 5; attempt += 1) { + yield* TestClock.adjust(Duration.seconds(31)); + yield* worker.drain(); + } + const refused = yield* projectionOf(threadId); + assert.equal(yield* read(path.join(alpha, "unrelated.txt")), "someone else's folder\n"); + assert.isFalse(yield* exists(path.join(alpha, "README.md"))); + assert.include( + refused.thread.rollbackFailure?.message ?? "", + "is no longer a worktree of alpha", + ); + assert.equal(yield* agent.rollbacks.pipe(Ref.get), 0); + assert.equal(yield* read(path.join(fixture.alpha, "README.md")), "# alpha\n"); + }), + ), + ); + + it.effect("recreates a removed worktree on the branch a Git action switched it to", () => + withHarness(({ agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread({ threadId: "thread:switched" }); + let alpha = ""; + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + alpha = (yield* repoWorktrees.ensure({ + threadId, + repository: "alpha", + branch: "t3/before", + baseRef: "main", + })).repoWorktree.worktreePath; + yield* write(path.join(alpha, "README.md"), "# alpha\nturn 1\n"); + }), + ); + yield* runTurn(agent, threadId, write(path.join(alpha, "README.md"), "# alpha\nturn 2\n")); + // As "Commit on new branch" does from the thread's Changes view. + yield* git(alpha, ["switch", "--quiet", "-c", "t3/after"]); + yield* repoWorktrees.recordBranch({ cwd: alpha }); + yield* fileSystem.remove(alpha, { recursive: true }); + + yield* rollback(threadId, 1); + assert.equal(yield* git(alpha, ["branch", "--show-current"]), "t3/after"); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nturn 1\n"); + }), + ), + ); + + it.effect("rejects rollback for a provider that cannot rewind before any file changes", () => + withHarness(({ agent }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; + const threadId = yield* createThread({ + threadId: "thread:no-rollback", + instanceId: noRollbackInstanceId, + }); + let alpha = ""; + yield* runTurn( + agent, + threadId, + Effect.gen(function* () { + alpha = (yield* repoWorktrees.ensure({ + threadId, + repository: "alpha", + branch: "t3/no-rollback", + baseRef: "main", + })).repoWorktree.worktreePath; + yield* write(path.join(alpha, "README.md"), "# alpha\nkept\n"); + }), + ); + const error = yield* orchestrator + .dispatch(yield* rollbackCommand(threadId, 0, true)) + .pipe(Effect.flip); + assert.equal(error._tag, "OrchestratorDispatchError"); + assert.include( + error._tag === "OrchestratorDispatchError" ? String(error.cause) : "", + "rollback", + ); + assert.equal(yield* read(path.join(alpha, "README.md")), "# alpha\nkept\n"); + assert.equal(yield* agent.rollbacks.pipe(Ref.get), 0); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration-v2/RepoWorktreeNote.test.ts b/apps/server/src/orchestration-v2/RepoWorktreeNote.test.ts new file mode 100644 index 0000000000000..54c7dc427eabb --- /dev/null +++ b/apps/server/src/orchestration-v2/RepoWorktreeNote.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { ThreadRepoWorktree } from "@t3tools/contracts"; + +import { repoWorktreeNote } from "./RepoWorktreeNote.ts"; + +const enabled = { repoWorktrees: true, repoWorktreeInstructions: "" }; + +const worktree = (index: number): ThreadRepoWorktree => ({ + repository: `repo-${index}`, + repositoryPath: `/folder/repo-${index}`, + worktreePath: `/worktrees/repo-${index}/feature`, + branch: "feature", + baseRef: "main", + origin: "created", + attachedAt: "2026-11-04T00:00:00.000Z", +}); + +const note = (overrides: Partial[0]> = {}) => + repoWorktreeNote({ + userText: "Fix the login bug", + hasT3Mcp: true, + settings: enabled, + repoWorktrees: [], + ...overrides, + }); + +describe("repoWorktreeNote", () => { + it("adds nothing unless the project opted in and the session can call T3 tools", () => { + expect(note({ settings: { repoWorktrees: false, repoWorktreeInstructions: "x" } })).toBe(""); + expect(note({ settings: null })).toBe(""); + expect(note({ hasT3Mcp: false })).toBe(""); + }); + + it("keeps slash commands first in the prompt", () => { + expect(note({ userText: " /compact" })).toBe(""); + }); + + // The setting can turn on partway through a conversation, so no turn may + // count on an earlier one having carried the rules. + it("carries every rule the agent needs on any turn it is sent", () => { + const rules = note(); + expect(rules).toContain("never edit, commit, or switch branches there"); + expect(rules).toContain("t3_repo_worktree_ensure"); + expect(rules).toContain("never stash, reset, or clean"); + expect(rules).toContain("Use a new branch name for new, independent work"); + expect(rules).toContain("`useExistingBranch:true` only to continue an existing branch"); + expect(rules).toContain("t3_repo_worktree_set_submodule"); + expect(rules).toContain("checkpoints these worktrees with every turn"); + }); + + it("repeats project guidance and where the work is on every turn", () => { + const later = note({ + settings: { + repoWorktrees: true, + repoWorktreeInstructions: "Base framework branches on team-develop.", + }, + repoWorktrees: [worktree(1)], + }); + expect(later).toContain("Project guidance:\nBase framework branches on team-develop."); + expect(later).toContain("- repo-1 on feature: /worktrees/repo-1/feature"); + }); + + it("caps the listed worktrees and points at the full list", () => { + const many = note({ repoWorktrees: Array.from({ length: 25 }, (_, index) => worktree(index)) }); + expect(many).toContain("- repo-19 on feature"); + expect(many).not.toContain("- repo-20 on feature"); + expect(many).toContain("- and 5 more (see t3_worktree_status)"); + }); +}); diff --git a/apps/server/src/orchestration-v2/RepoWorktreeNote.ts b/apps/server/src/orchestration-v2/RepoWorktreeNote.ts new file mode 100644 index 0000000000000..9bbb93b5b181b --- /dev/null +++ b/apps/server/src/orchestration-v2/RepoWorktreeNote.ts @@ -0,0 +1,51 @@ +import type { ServerSettings, ThreadRepoWorktree } from "@t3tools/contracts"; + +const MAX_LISTED_WORKTREES = 20; + +const RULES = [ + "Repository worktrees are on: this thread stays in the project folder, which holds several Git repositories. Leave the source checkouts in the folder as they are: never edit, commit, or switch branches there.", + "- Before changing a repository, call `t3_repo_worktree_ensure` with its path relative to the folder, an exact branch, and a `baseRef` for a new branch. Do every edit, build, test, and commit for that repository in the returned `worktreePath`. Call it again whenever the work reaches another repository.", + "- A worktree that already has the branch is reused as it is. Keep its uncommitted edits: never stash, reset, or clean them. Use a new branch name for new, independent work, and pass `useExistingBranch:true` only to continue an existing branch on purpose.", + "- To change a submodule, ensure a worktree for the submodule's path too (for example `service/framework`) and commit there. Then call `t3_repo_worktree_set_submodule` to point the parent worktree's submodule at that commit and stage the gitlink, and commit the parent with ordinary git. Repeat after each new submodule commit.", + "- T3 checkpoints these worktrees with every turn, from the moment `t3_repo_worktree_ensure` returns, so the user can review and roll back your changes in them. `t3_worktree_status` lists them; `t3_repo_worktree_detach` forgets one without deleting anything.", +].join("\n"); + +/** + * Sent with every turn: the setting can turn on mid-conversation, and compaction or a resumed + * session can drop an earlier copy. Empty when the feature is off, when the session has no T3 + * MCP server to call, and for slash commands, which must stay first in the prompt. + */ +export function repoWorktreeNote(input: { + readonly userText: string; + readonly hasT3Mcp: boolean; + readonly settings: Pick | null; + readonly repoWorktrees: ReadonlyArray; +}): string { + if ( + !input.hasT3Mcp || + input.settings === null || + !input.settings.repoWorktrees || + input.userText.trimStart().startsWith("/") + ) { + return ""; + } + const instructions = input.settings.repoWorktreeInstructions; + const listed = input.repoWorktrees.slice(0, MAX_LISTED_WORKTREES); + const omitted = input.repoWorktrees.length - listed.length; + const sections = [ + RULES, + ...(instructions === "" ? [] : [`Project guidance:\n${instructions}`]), + ...(listed.length === 0 + ? [] + : [ + [ + "Attached repository worktrees:", + ...listed.map( + (entry) => `- ${entry.repository} on ${entry.branch}: ${entry.worktreePath}`, + ), + ...(omitted > 0 ? [`- and ${omitted} more (see t3_worktree_status)`] : []), + ].join("\n"), + ]), + ]; + return `\n${sections.join("\n\n")}\n`; +} diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 612a130e019aa..4c29020af8d22 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -570,6 +570,8 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly providerSessionId: ProviderSessionId; readonly session: ProviderAdapter.ProviderAdapterV2SessionRuntime; readonly run: OrchestrationV2Run; + /** The thread's runs, for the checkpoint baseline this run is compared to. */ + readonly runs?: CheckpointService.CheckpointRunHistory; readonly rootNode: OrchestrationV2ExecutionNode; readonly checkpointScope: OrchestrationV2CheckpointScope; readonly providerThread: OrchestrationV2ProviderThread; @@ -969,6 +971,10 @@ export const layer: Layer.Layer< : checkpointService.captureBaseline({ scope: input.checkpointScope, ordinalWithinScope: Math.max(0, input.run.ordinal - 1), + ...(input.appThread.repoWorktrees === undefined + ? {} + : { repoWorktrees: input.appThread.repoWorktrees }), + ...(input.runs === undefined ? {} : { runs: input.runs }), }) ).pipe( Effect.catchCauseIf( diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index 6da6833ac7936..7b06d240a3e05 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts @@ -87,6 +87,22 @@ const make = Effect.gen(function* () { }), ); } + // Repository worktrees the turn captured show their new status too. One + // that is gone must not fail the turn. + for (const repository of projection.checkpoints.find( + (checkpoint) => checkpoint.runId === input.runId, + )?.repositories ?? []) { + yield* observer + .refresh({ cwd: repository.worktreePath, threadId: input.threadId, runId: input.runId }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("failed to refresh a repository worktree after run completion", { + threadId: input.threadId, + cause: String(cause), + }), + ), + ); + } }); return RunFinalizationService.of({ finalize }); }); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index a0a438bf97301..b960790768b9f 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -43,7 +43,9 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import * as RepoWorktrees from "../git/RepoWorktreeService.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; @@ -56,6 +58,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import { ClaudeProviderCapabilitiesV2 } from "./Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as CheckpointService from "./CheckpointService.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -118,7 +121,19 @@ const claudeAdapter = { openSession: () => Effect.die("provider execution is disabled in launch tests"), } as ProviderAdapter.ProviderAdapterV2["Service"]; -interface HarnessOptions { +type HarnessSharedServices = + | ThreadManagement.ThreadManagementService + | ProjectionStore.ProjectionStoreV2 + | ProjectService.ProjectService + | ServerSettings.ServerSettingsService + | GitWorkflow.GitWorkflowService; + +interface HarnessOptions { + readonly workspaceRoots?: { readonly project: string; readonly other: string }; + /** Defaults to a guard that forwards every removal to the git mock. */ + readonly repoWorktrees?: ( + shared: Layer.Layer, + ) => Layer.Layer; readonly closeTerminal?: TerminalManager.TerminalManager["Service"]["close"]; readonly adapters?: ReadonlyArray; readonly managedFolders?: Layer.Layer; @@ -135,7 +150,12 @@ interface HarnessOptions { readonly git?: Partial; } -function makeHarness(options: HarnessOptions = {}) { +function makeHarness(options: HarnessOptions = {}) { + const roots = options.workspaceRoots; + const harnessProject = + roots === undefined ? project : { ...project, workspaceRoot: roots.project }; + const harnessOtherProject = + roots === undefined ? otherProject : { ...otherProject, workspaceRoot: roots.other }; const layerDatabase = SqlitePersistence.layerMemory; const layerRegistry = ProviderAdapterRegistry.layerFromAdapters(options.adapters ?? [adapter]); const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( @@ -192,12 +212,12 @@ function makeHarness(options: HarnessOptions = {}) { getById: (id) => Effect.succeed( id === projectId - ? Option.some(project) + ? Option.some(harnessProject) : id === otherProjectId - ? Option.some(otherProject) + ? Option.some(harnessOtherProject) : Option.none(), ), - getByWorkspaceRoot: () => Effect.succeedSome(project), + getByWorkspaceRoot: () => Effect.succeedSome(harnessProject), workspaceScanDirectory: () => Effect.die("unused"), snapshot: Effect.die("unused"), getShell: () => Effect.die("unused"), @@ -236,6 +256,17 @@ function makeHarness(options: HarnessOptions = {}) { folderForThread: () => Effect.succeedNone, }), ); + const layerRepoWorktrees = + options.repoWorktrees?.( + Layer.mergeAll( + layerExternalServices, + layerThreadManagement, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + ).pipe(Layer.orDie), + ) ?? + Layer.mock(RepoWorktrees.RepoWorktreeService)({ + removeWorktree: (input) => removeWorktree(input), + }); const layerLaunch = ThreadLaunch.layer.pipe( Layer.provide( Layer.mergeAll( @@ -243,6 +274,7 @@ function makeHarness(options: HarnessOptions = {}) { layerThreadManagement, layerReceipts, layerOrchestrator, + layerRepoWorktrees, IdAllocator.layer, ), ), @@ -283,6 +315,7 @@ function makeHarness(options: HarnessOptions = {}) { return { layer: Layer.mergeAll( layerLaunch, + layerRepoWorktrees, layerRegistry, layerThreadManagement, layerTitleRegeneration, @@ -2319,56 +2352,152 @@ it.effect( }), ); -it.effect("stops an in-flight branch rename when tracked setup is cancelled", () => - Effect.gen(function* () { - const setupEntered = yield* Deferred.make(); - const branchNameStarted = yield* Deferred.make(); - const allowBranchName = yield* Deferred.make(); - const branchNameInterrupted = yield* Deferred.make(); - const renameAttempted = yield* Deferred.make(); - const harness = makeHarness({ - renameBranch: (input) => - Deferred.succeed(renameAttempted, undefined).pipe(Effect.as({ branch: input.newBranch })), - runSetup: () => Deferred.succeed(setupEntered, undefined).pipe(Effect.andThen(Effect.never)), - generateBranchName: () => - Deferred.succeed(branchNameStarted, undefined).pipe( - Effect.andThen(Deferred.await(allowBranchName)), - Effect.as({ branch: "generated-branch" }), - Effect.onInterrupt(() => Deferred.succeed(branchNameInterrupted, undefined)), - ), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; - const threads = yield* ThreadManagement.ThreadManagementService; - const launched = yield* launches.launch( - launchInput({ - command: "launch:cancel-renaming", - thread: "thread:cancel-renaming", - message: "Start", - workspace: { type: "worktree", baseRef: "main" }, - }), - ); - yield* Deferred.await(setupEntered); - yield* Deferred.await(branchNameStarted); - assert.isTrue(yield* tracker.cancel(launched.threadId)); - yield* Deferred.succeed(allowBranchName, undefined); - yield* Effect.raceFirst( - Deferred.await(branchNameInterrupted), - Deferred.await(renameAttempted), - ); - assert.isTrue( - yield* Deferred.isDone(branchNameInterrupted), - "the cancelled setup's branch rename kept running", - ); - assert.equal(harness.renameBranch.mock.calls.length, 0); - assert.isNull((yield* threads.getThreadProjection(launched.threadId)).thread.worktreePath); - assert.equal( - harness.deleteLocalBranch.mock.calls[0]?.[0].refName, - harness.createWorktree.mock.calls[0]?.[0].newRefName, +it.effect.each(["attached", "unattached"] as const)( + "cancelling setup removes its new worktree only when no folder thread attached it: %s", + (attachment) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver.GitVcsDriver; + const root = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "launch-cancel-shared-" }), ); - }).pipe(Effect.provide(harness.layer)); - }), + // Projects and the worktree are reached through a link, while Git + // reports the reused checkout by its real path. + const real = `${root}/real`; + const alias = `${root}/alias`; + yield* fs.makeDirectory(`${real}/folder/alpha`, { recursive: true }); + yield* fs.symlink(real, alias); + const repo = `${alias}/folder/alpha`; + const command = (cwd: string, args: ReadonlyArray) => + git + .execute({ operation: "launch-test", cwd, args }) + .pipe(Effect.map((result) => result.stdout.trim())); + yield* command(repo, ["init", "-b", "main"]); + yield* command(repo, [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "commit", + "--allow-empty", + "-m", + "initial", + ]); + const branch = "feature/cancel-shared"; + const worktreePath = `${alias}/worktrees/alpha/feature-cancel-shared`; + const ordinaryThreadId = ThreadId.make("thread:cancel-shared"); + const setupEntered = yield* Deferred.make(); + const harness = makeHarness({ + workspaceRoots: { project: repo, other: `${alias}/folder` }, + serverSettings: { + repoWorktrees: true, + newWorktreesStartFromOrigin: false, + worktreesDirectory: `${alias}/worktrees`, + }, + createWorktree: (input, options) => + git.createWorktree({ ...input, path: worktreePath }, options), + git: { removeWorktree: git.removeWorktree }, + // The folder thread's launch runs setup too; only the worktree thread's is held. + runSetup: (input) => + input.threadId === ordinaryThreadId + ? Deferred.succeed(setupEntered, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.succeed({ status: "no-script" as const }), + repoWorktrees: (shared) => + RepoWorktrees.layer.pipe( + Layer.provide( + Layer.mergeAll( + shared, + NodeCrypto.layer, + Layer.mock(CheckpointService.CheckpointServiceV2)({ + captureRepositoryBaseline: () => Effect.succeed("ready" as const), + }), + ), + ), + ), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const repoWorktrees = yield* RepoWorktrees.RepoWorktreeService; + const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; + const folder = yield* launches.launch({ + ...launchInput({ command: "launch:cancel-shared:folder", thread: "thread:folder" }), + projectId: otherProjectId, + }); + const launched = yield* launches.launch( + launchInput({ + command: "launch:cancel-shared", + thread: ordinaryThreadId, + message: "Start", + workspace: { type: "worktree", baseRef: "main", branch, startFromOrigin: false }, + }), + ); + yield* Deferred.await(setupEntered); + assert.equal((yield* tracker.get(launched.threadId))?.phase, "running"); + assert.equal( + (yield* threads.getThreadProjection(launched.threadId)).thread.worktreePath, + worktreePath, + ); + let draftPath: string | null = null; + if (attachment === "attached") { + const ensured = yield* repoWorktrees.ensure({ + threadId: folder.threadId, + repository: "alpha", + branch, + }); + assert.equal(ensured.status, "reused"); + assert.equal( + ensured.repoWorktree.worktreePath, + `${real}/worktrees/alpha/feature-cancel-shared`, + ); + draftPath = `${ensured.repoWorktree.worktreePath}/draft.txt`; + yield* fs.writeFileString(draftPath, "must survive cancel\n"); + } + + assert.isTrue(yield* tracker.cancel(launched.threadId)); + + assert.equal((yield* tracker.get(launched.threadId))?.phase, "cancelled"); + const cancelled = yield* threads.getThreadProjection(launched.threadId); + assert.equal(cancelled.runs[0]?.status, "failed"); + assert.isNull(cancelled.thread.worktreePath); + assert.isNull(cancelled.thread.branch); + const listed = yield* command(repo, ["worktree", "list", "--porcelain"]); + // A checkout kept for the folder thread keeps its branch too. + assert.deepEqual( + harness.deleteLocalBranch.mock.calls.map(([call]) => call.refName), + attachment === "attached" ? [] : [branch], + ); + const folderThread = (yield* threads.getThreadProjection(folder.threadId)).thread; + if (draftPath === null) { + assert.isFalse(yield* fs.exists(worktreePath)); + assert.notInclude(listed, "feature-cancel-shared"); + assert.isEmpty(folderThread.repoWorktrees ?? []); + return; + } + assert.equal(yield* fs.readFileString(draftPath), "must survive cancel\n"); + assert.include(listed, `branch refs/heads/${branch}`); + assert.equal(yield* command(worktreePath, ["status", "--porcelain"]), "?? draft.txt"); + assert.deepEqual( + folderThread.repoWorktrees?.map((entry) => [entry.worktreePath, entry.branch]), + [[`${real}/worktrees/alpha/feature-cancel-shared`, branch]], + ); + // The folder thread can still work in it: ensure reports it attached as it is. + const again = yield* repoWorktrees.ensure({ + threadId: folder.threadId, + repository: "alpha", + branch, + }); + assert.isTrue(again.alreadyAttached); + assert.equal(again.uncommittedFiles, 1); + }).pipe(Effect.provide(harness.layer)); + }).pipe( + Effect.provide( + GitVcsDriver.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "launch-git-" })), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); it.effect("starts the agent of a new worktree thread only after its branch is renamed", () => diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index c65754dece315..1717b7016e9cf 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -41,6 +41,7 @@ import { } from "@t3tools/shared/git"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import * as RepoWorktreeService from "../git/RepoWorktreeService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; @@ -242,6 +243,7 @@ const make = Effect.gen(function* () { const cloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker; const terminals = yield* TerminalManager.TerminalManager; const git = yield* GitWorkflow.GitWorkflowService; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; const setupScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -820,10 +822,12 @@ const make = Effect.gen(function* () { const removedPath = createdWorktreePath; const removedBranch = createdBranch; // Forget worktrees only after removal succeeds; failed removals must not reuse partially written checkouts. - yield* git + // A folder thread may attach the new branch's checkout during setup; the guard then + // keeps the checkout and its branch for that thread, and this thread still lets it go. + yield* repoWorktrees .removeWorktree({ cwd: project.workspaceRoot, path: removedPath, force: true }) .pipe( - Effect.andThen( + Effect.andThen(() => removedBranch === null ? Effect.void : git @@ -834,6 +838,15 @@ const make = Effect.gen(function* () { }) .pipe(Effect.ignoreCause({ log: true })), ), + Effect.catchTags({ + RepoWorktreeInUseError: (inUse) => + Effect.logInfo("Kept an abandoned thread worktree another thread works in", { + commandId: input.commandId, + threadId, + path: removedPath, + attachedThreadId: inUse.threadId, + }), + }), Effect.andThen( threads .dispatch({ diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index d83f0603d7f7c..4e7b27c5469f4 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -4,6 +4,7 @@ import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as Layer from "effect/Layer"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as RepoWorktreeService from "../git/RepoWorktreeService.ts"; import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts"; import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts"; @@ -117,8 +118,10 @@ const layerProviderEventIngestorProvided = ProviderEventIngestor.layer.pipe( ), ); +// One instance serializes checkpoint Git work per checkout for every caller, +// repository worktree attachment included. const layerCheckpointServiceProvided = CheckpointService.layer.pipe( - Layer.provide(IdAllocator.layer), + Layer.provide(Layer.merge(IdAllocator.layer, GitVcsDriver.layer)), ); const layerContextHandoffServiceProvided = ContextHandoffService.layer.pipe( Layer.provide(IdAllocator.layer), @@ -276,6 +279,18 @@ export const layerProjectSetupScriptRunner = ProjectSetupScriptRunner.layer.pipe const layerManagedProjectFoldersProvided = ManagedProjectFolders.layer.pipe( Layer.provide(layerProjectService), ); +// One instance for launches, WebSocket, and MCP, so per-worktree submodule +// pins stay serialized across every caller. +const layerRepoWorktreeProvided = RepoWorktreeService.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerProjectService, + layerThreadManagementProvided, + ProjectionStore.layer, + layerCheckpointServiceProvided, + ), + ), +); const layerThreadLaunchProvided = ThreadLaunchService.layer.pipe( Layer.provide( Layer.mergeAll( @@ -285,6 +300,7 @@ const layerThreadLaunchProvided = ThreadLaunchService.layer.pipe( layerThreadManagementProvided, layerCommandReceiptStoreProvided, layerEventSink, + layerRepoWorktreeProvided, IdAllocator.layer, ), ), @@ -390,6 +406,7 @@ export const layerProduction = Layer.mergeAll( layer.pipe(Layer.provide(layerProjectService)), layerProjectService, layerManagedProjectFoldersProvided, + layerRepoWorktreeProvided, layerThreadLaunchProvided, layerScheduledTaskProvided, layerScratchThreadPromotionProvided, diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index b413c0aefa259..033bc59310234 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -160,6 +160,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray( readonly wrapProjectionStore?: ( store: ProjectionStore.ProjectionStoreV2Shape, ) => ProjectionStore.ProjectionStoreV2Shape; + /** Replaces the default policy, whose cwd is only the thread's worktree. */ + readonly runtimePolicy?: Layer.Layer; + /** Git for rollback to recreate removed worktrees with; a no-op by default. */ + readonly rollbackGitWorkflow?: Layer.Layer; + /** The worktree location setting, which decides what rollback may recreate. */ + readonly worktreesDirectory?: string; readonly managedPolicy?: ManagedPolicyDocument; } = {}, ): Layer.Layer< @@ -304,9 +311,9 @@ export function layerWithRegistry( ).pipe(Layer.provide(NodeServices.layer)); const layerRuntimeBase = scenario.runtimePolicyOverride === undefined - ? RuntimePolicy.layer + ? (options.runtimePolicy ?? RuntimePolicy.layer) : RuntimePolicy.layerWithOverride(scenario.runtimePolicyOverride).pipe( - Layer.provide(RuntimePolicy.layer), + Layer.provide(options.runtimePolicy ?? RuntimePolicy.layer), ); const layerDatabase = options.databaseLayer ?? SqlitePersistence.layerMemory; // Share one continuation queue across adapters, orchestrator, and worker through layer memoization. @@ -320,6 +327,9 @@ export function layerWithRegistry( ServerSettings.layerTest( { responseStreamingMode: "turn", + ...(options.worktreesDirectory === undefined + ? {} + : { worktreesDirectory: options.worktreesDirectory }), ...(options.continueThreadsAfterServerUpdate === undefined ? {} : { continueThreadsAfterServerUpdate: options.continueThreadsAfterServerUpdate }), @@ -383,7 +393,16 @@ export function layerWithRegistry( Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); const layerCheckpointServiceProvided = CheckpointService.layer.pipe( - Layer.provide(Layer.mergeAll(layerCheckpointStore, IdAllocator.layer)), + Layer.provide( + Layer.mergeAll( + layerCheckpointStore, + IdAllocator.layer, + GitVcsDriver.layer.pipe( + Layer.provide(layerServerConfig), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), ); const layerContextHandoffServiceProvided = ContextHandoffService.layer.pipe( Layer.provide(IdAllocator.layer), @@ -460,6 +479,7 @@ export function layerWithRegistry( const layerCheckpointRollbackServiceProvided = CheckpointRollbackService.layer.pipe( Layer.provide( Layer.mergeAll( + options.rollbackGitWorkflow ?? Layer.empty, layerCheckpointServiceProvided, layerEventSinkProvided, IdAllocator.layer, diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 8cdd52d202f12..af58b570bc1c9 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -13,6 +13,7 @@ import { ServerConfig } from "../config.ts"; import { DeviceService } from "../device/DeviceService.ts"; import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { RepoWorktreeService } from "../git/RepoWorktreeService.ts"; import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import { PreviewAutomationBroker } from "../mcp/PreviewAutomationBroker.ts"; import { @@ -68,6 +69,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(ProviderAdapterRegistryV2)({}), Layer.mock(ProviderRegistry)({}), Layer.mock(PullRequestReviewGuideService)({}), + Layer.mock(RepoWorktreeService)({}), Layer.mock(RemoteDelegation)({}), Layer.mock(ScheduledTaskService)({}), Layer.mock(SecretRequests)({}), diff --git a/apps/server/src/provider/CodexThreadRevert.test.ts b/apps/server/src/provider/CodexThreadRevert.test.ts index e48caf094027c..db8df8a5404d2 100644 --- a/apps/server/src/provider/CodexThreadRevert.test.ts +++ b/apps/server/src/provider/CodexThreadRevert.test.ts @@ -3,7 +3,7 @@ import * as CodexClient from "effect-codex-app-server/client"; import * as CodexReplay from "effect-codex-app-server/replay"; import * as Effect from "effect/Effect"; -import { revertCodexThread } from "./CodexThreadRevert.ts"; +import { revertCodexThread, revertCodexThreadAfter } from "./CodexThreadRevert.ts"; const page = ( id: number, @@ -90,3 +90,85 @@ it.effect("rejects repeated cursors instead of looping or reverting incomplete h Effect.scoped, ), ); + +const settles = (id: number, method: "thread/revert" | "thread/read", params: object) => [ + { type: "expect_outbound" as const, frame: { id, method, params } }, + { + type: "emit_inbound" as const, + frame: { id, error: { code: -32603, message: `${method} ${JSON.stringify(params)}` } }, + }, +]; + +const revertAfter = ( + scenario: string, + boundary: { readonly later: string[]; readonly kept: string[] }, + entries: ReadonlyArray, +) => + Effect.gen(function* () { + const client = yield* CodexClient.CodexAppServerClient; + const error = yield* revertCodexThreadAfter(client, "thread", { + laterTurnIds: new Set(boundary.later), + keptTurnIds: new Set(boundary.kept), + }).pipe(Effect.flip); + return error.message; + }).pipe( + Effect.provide( + CodexReplay.layerReplay({ + provider: "codex", + protocol: "codex.app-server", + version: "test", + scenario, + entries, + }), + ), + Effect.scoped, + ); + +it.effect("reverts before the oldest later turn it finds", () => + Effect.gen(function* () { + const message = yield* revertAfter( + "revert-after", + { later: ["t3", "t4"], kept: ["t1", "t2"] }, + [ + ...page(1, null, 2, ["t4", "t3"], "older"), + ...settles(2, "thread/revert", { threadId: "thread", beforeTurnId: "t3" }), + ], + ); + assert.equal(message, 'thread/revert {"threadId":"thread","beforeTurnId":"t3"}'); + }), +); + +it.effect("removes nothing once the later turns are gone", () => + Effect.gen(function* () { + const message = yield* revertAfter( + "revert-after-repeat", + { later: ["t3", "t4"], kept: ["t1", "t2"] }, + [ + ...page(1, null, 2, ["t2", "t1"], "older"), + ...settles(2, "thread/read", { threadId: "thread", includeTurns: false }), + ], + ); + assert.equal(message, 'thread/read {"threadId":"thread","includeTurns":false}'); + }), +); + +it.effect("never cuts past a kept turn", () => + Effect.gen(function* () { + const message = yield* revertAfter("revert-after-kept", { later: ["t3", "t4"], kept: ["t2"] }, [ + ...page(1, null, 2, ["t4", "t2"], "older"), + ...settles(2, "thread/revert", { threadId: "thread", beforeTurnId: "t4" }), + ]); + assert.equal(message, 'thread/revert {"threadId":"thread","beforeTurnId":"t4"}'); + }), +); + +it.effect("pages through turns it doesn't know before removing nothing", () => + Effect.gen(function* () { + const message = yield* revertAfter("revert-after-inherited", { later: ["own"], kept: [] }, [ + ...page(1, null, 1, ["inherited-2"], "more"), + ...page(2, "more", 100, ["inherited-1"], null), + ...settles(3, "thread/read", { threadId: "thread", includeTurns: false }), + ]); + assert.equal(message, 'thread/read {"threadId":"thread","includeTurns":false}'); + }), +); diff --git a/apps/server/src/provider/CodexThreadRevert.ts b/apps/server/src/provider/CodexThreadRevert.ts index f2933dfe3d753..8921d1ee20e36 100644 --- a/apps/server/src/provider/CodexThreadRevert.ts +++ b/apps/server/src/provider/CodexThreadRevert.ts @@ -39,3 +39,53 @@ export const revertCodexThread = Effect.fn("revertCodexThread")(function* ( ? yield* client.request("thread/read", { threadId, includeTurns: false }) : yield* client.request("thread/revert", { threadId, beforeTurnId }); }); + +/** + * Reverts the thread before the oldest of `laterTurnIds` that it still has, + * never looking past the newest of `keptTurnIds`. It reads the history each + * time, so repeating it after a revert that already happened, or whose + * response was lost, removes nothing instead of earlier turns. + */ +export const revertCodexThreadAfter = Effect.fn("revertCodexThreadAfter")(function* ( + client: Pick, + threadId: string, + boundary: { + readonly laterTurnIds: ReadonlySet; + readonly keptTurnIds: ReadonlySet; + }, +) { + let unseen = boundary.laterTurnIds.size; + let beforeTurnId: string | undefined; + let cursor: string | null = null; + const visited = new Set(); + scan: while (unseen > 0) { + if (visited.has(cursor)) { + return yield* CodexErrors.CodexAppServerRequestError.internalError( + "Thread history pagination repeated a cursor.", + undefined, + { method: "thread/turns/list", operation: "decode-payload" }, + ); + } + visited.add(cursor); + const page: CodexSchema.V2ThreadTurnsListResponse = yield* client.request("thread/turns/list", { + threadId, + cursor, + // The later turns, newest first, usually fill the first page; on a + // repeat it starts with a kept turn instead. + limit: cursor === null ? Math.min(unseen, 100) : 100, + sortDirection: "desc", + itemsView: "summary", + }); + for (const turn of page.data) { + if (boundary.keptTurnIds.has(turn.id)) break scan; + if (!boundary.laterTurnIds.has(turn.id)) continue; + beforeTurnId = turn.id; + if (--unseen === 0) break scan; + } + cursor = page.nextCursor ?? null; + if (cursor === null) break; + } + return beforeTurnId === undefined + ? yield* client.request("thread/read", { threadId, includeTurns: false }) + : yield* client.request("thread/revert", { threadId, beforeTurnId }); +}); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index e166af3f78340..acc09b22a33ce 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5597, "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48126 + "mcpToolListJsonBytes": 49933 } diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index e7220718f15a4..42ad960ddbdd5 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -350,6 +350,8 @@ const cleanupFixture = ( let threads = [shell({ branch: "feature", worktreePath: worktree })]; let deletedThreads: OrchestrationV2ThreadShell[] = []; let storedWorktreePaths: string[] = [worktree]; + let repoWorktreeRows: Array<{ threadId: string; worktreePath: string }> = []; + let attachBeforeRemoval: Array<{ threadId: string; worktreePath: string }> | null = null; let projectRoots = [repo]; let editBeforeRemoval = false; let openSessionCwd: string | null = null; @@ -376,6 +378,9 @@ const cleanupFixture = ( (input.args.includes("remove") || input.args.includes("--porcelain")) ) yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); + // The repository lease is the last step before removal. + if (attachBeforeRemoval !== null && input.args.includes("--git-common-dir")) + repoWorktreeRows = attachBeforeRemoval; return yield* git.execute(input); }), statusDetailsLocal: (...args) => { @@ -425,6 +430,8 @@ const cleanupFixture = ( ), getThreadShell: (threadId: ThreadId) => Effect.sync(() => threads.find((thread) => thread.id === threadId) ?? null), + getThreadRepoWorktreePaths: () => + Effect.sync(() => repoWorktreeRows.map((row) => ({ ...row, title: "Folder thread" }))), } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), Effect.provideService(ProviderSessionManager.ProviderSessionManagerV2, { isLive: () => @@ -539,6 +546,12 @@ const cleanupFixture = ( setStoredWorktreePaths: (paths: string[]) => { storedWorktreePaths = paths; }, + setRepoWorktrees: (rows: Array<{ threadId: string; worktreePath: string }>) => { + repoWorktreeRows = rows; + }, + attachBeforeRemoval: (rows: Array<{ threadId: string; worktreePath: string }>) => { + attachBeforeRemoval = rows; + }, setProjectRoots: (roots: string[]) => { projectRoots = roots; }, @@ -2401,6 +2414,97 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live( + "keeps a thread's worktree while another thread works in it as a repository worktree", + () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + fixture.setRepoWorktrees([{ threadId: "thread-2", worktreePath: fixture.worktree }]); + expect((yield* fixture.service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Another thread works in it as a repository worktree", + }); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + + fixture.setRepoWorktrees([]); + expect((yield* fixture.service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + }), + ), + ); + // Git records a reused checkout by its real path, while the thread that made + // it may use the configured spelling (/tmp for /private/tmp). + it.live("keeps a worktree attached under another spelling of its path", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + const { fs } = fixture; + const link = `${yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-link-" })}/worktrees`; + yield* fs.symlink(fixture.config.worktreesDir, link); + const linked = `${link}/feature`; + const real = yield* fs.realPath(fixture.worktree); + for (const [threadPath, attachedPath] of [ + [linked, real], + [fixture.worktree, linked], + ] as const) { + fixture.setThreads([shell({ branch: "feature", worktreePath: threadPath })]); + fixture.setRepoWorktrees([{ threadId: "thread-2", worktreePath: attachedPath }]); + expect((yield* fixture.service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Another thread works in it as a repository worktree", + }); + } + expect(yield* fs.exists(fixture.worktree)).toBe(true); + }), + ), + ); + it.live("keeps a worktree attached right before its removal, without cleaning it", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + fixture.setPolicy("tracked-changes"); + yield* fixture.fs.writeFileString(`${fixture.worktree}/notes.txt`, "untracked\n"); + fixture.attachBeforeRemoval([{ threadId: "thread-2", worktreePath: fixture.worktree }]); + expect((yield* fixture.service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Another thread works in it as a repository worktree", + }); + expect(yield* fixture.fs.readFileString(`${fixture.worktree}/notes.txt`)).toBe( + "untracked\n", + ); + }), + ), + ); + it.live("lists attached repository worktrees as kept, not as unused folders", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + fixture.setThreads([]); + const folder = `${fixture.config.worktreesDir}/other/feature`; + yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "repo-work", folder]); + yield* fixture.fs.utimes(folder, 1, 1); + fixture.setRepoWorktrees([{ threadId: "thread-9", worktreePath: folder }]); + expect((yield* fixture.service.runNow).entries).toEqual([ + expect.objectContaining({ + path: folder, + outcome: "kept", + reason: + "Repository worktree attached to a thread; cleanup keeps it until it is detached", + }), + ]); + + fixture.setRepoWorktrees([]); + // Unused folders are reported under the canonical managed root. + expect((yield* fixture.service.runNow).entries).toEqual([ + expect.objectContaining({ + path: yield* fixture.fs.realPath(folder), + reason: "No thread uses this worktree", + }), + ]); + expect(yield* fixture.fs.exists(`${folder}/tracked.txt`)).toBe(true); + }), + ), + ); it.live("does not measure a worktree kept for an open provider session", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 673c121612c9a..e566bf4e488e6 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -50,7 +50,7 @@ import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirect import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import { isNonRepositoryGitStderr } from "./vcs/GitVcsDriverCore.ts"; import { removeLinkedWorktree } from "./vcs/worktreeRemoval.ts"; -import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; +import { withRepositoryLease, withWorkspaceLease } from "./workspace/workspaceLease.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { isNetworkPath } from "@t3tools/shared/path"; @@ -424,6 +424,32 @@ const make = Effect.gen(function* () { Effect.orElseSucceed(() => null), ); + // A repository worktree is recorded with the path Git reported, which can be + // another spelling (/private/tmp for /tmp) of a thread's own worktree path. + const pathIdentity = (target: string) => + fs.realPath(target).pipe( + Effect.orElseSucceed(() => path.resolve(target)), + Effect.map(pathKey), + ); + + // Repository worktrees of threads that still exist, archived ones included, + // keyed by path identity. Cleanup never removes them, and a thread's own + // worktree that another thread also works in counts as shared. + const readRepoWorktreePaths = Effect.fn("StorageCleanup.readRepoWorktreePaths")(function* () { + const rows = yield* projections.getThreadRepoWorktreePaths().pipe(NodeSqliteClient.readOnly); + const paths = new Map }>(); + for (const row of rows) { + const key = yield* pathIdentity(row.worktreePath); + const entry = paths.get(key) ?? { + path: path.resolve(row.worktreePath), + threadIds: new Set(), + }; + entry.threadIds.add(row.threadId); + paths.set(key, entry); + } + return paths; + }); + // Large snapshots take seconds; use the reader worker instead of blocking the event loop. const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () { const snapshot = yield* projections.getShellSnapshot().pipe(NodeSqliteClient.readOnly); @@ -451,6 +477,7 @@ const make = Effect.gen(function* () { const reportOrphanWorktrees = Effect.fn("StorageCleanup.reportOrphanWorktrees")(function* ( roots: ReadonlyArray, projects: ReadonlyArray<{ readonly workspaceRoot: string }>, + repoWorktreePaths: ReadonlyArray, entries: StorageCleanupReportEntry[], ) { const rows = yield* sql<{ worktreePath: string }>` @@ -459,8 +486,8 @@ const make = Effect.gen(function* () { WHERE json_extract(payload_json, '$.worktreePath') IS NOT NULL `.pipe(NodeSqliteClient.readOnly); const referenced = new Set(); - for (const row of rows) { - const resolved = path.resolve(row.worktreePath); + for (const worktreePath of [...rows.map((row) => row.worktreePath), ...repoWorktreePaths]) { + const resolved = path.resolve(worktreePath); referenced.add(pathKey(resolved)); referenced.add( pathKey(yield* fs.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved))), @@ -963,6 +990,7 @@ const make = Effect.gen(function* () { ), )).filter((thread) => thread.worktreePath !== null && thread.branch !== null); const snapshot = yield* readThreads(); + const repoWorktreePaths = yield* readRepoWorktreePaths(); const threadsById = new Map(snapshot.threads.map((thread) => [thread.id, thread])); const refreshedDefaultRefs = new Map>(); const defaultBranches = new Map(); @@ -1040,6 +1068,8 @@ const make = Effect.gen(function* () { if (deleted && !settings.worktreeOnDelete) return keep("no rules apply"); if (members.length > 1 && worktreeOwner(members) === undefined) return keep(`shared by ${members.length} threads`); + if (repoWorktreePaths.has(yield* pathIdentity(worktreePath))) + return keep("another thread works in it as a repository worktree"); if (project === undefined) return keep("project is unavailable"); if ( thread.branch === null || @@ -1206,6 +1236,8 @@ const make = Effect.gen(function* () { includeArchived: true, })).filter((entry) => path.resolve(entry.worktreePath) === worktreePath); if (hasTerminal(worktreePath)) return waitForStop("open terminal"); + if ((yield* readRepoWorktreePaths()).has(yield* pathIdentity(worktreePath))) + return "another thread works in it as a repository worktree"; if (deleted) { if ( !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) @@ -1317,24 +1349,55 @@ const make = Effect.gen(function* () { ) ) return keep("settings changed since check"); - // Clean only untracked files. Ordinary worktrees retain Git's final tracked-edit check. - if (settings.worktreeKeepWhen === "tracked-changes") - yield* cleanupGit(worktreePath, ["clean", "-ffdx"], "git clean"); - const submodules = yield* submoduleRemoval(worktreePath, settings); - if (submodules.reason !== null) return keep(submodules.reason); - if (submodules.force) { - const changes = yield* localChangesReason(worktreePath, settings); - if (changes !== null) return keep(changes); - } - yield* removeLinkedWorktree({ - operation: "StorageCleanup.cleanupGit", + const commonDir = yield* git.execute({ + operation: "StorageCleanup.repositoryLease", cwd: project.workspaceRoot, - worktreePath, - // Git requires force for submodules; recursive checks above cannot make removal atomic. - force: submodules.force, - execute: git.execute, - run: (args) => cleanupGit(project.workspaceRoot, [...args], "git worktree remove"), + args: ["rev-parse", "--path-format=absolute", "--git-common-dir"], + allowNonZeroExit: true, }); + const leaseKey = yield* pathIdentity( + commonDir.exitCode === 0 && commonDir.stdout.trim() !== "" + ? commonDir.stdout.trim() + : project.workspaceRoot, + ); + // Attaching takes this lease too, so the last attachment check holds + // through the clean and the removal. + const removed = yield* withRepositoryLease( + leaseKey, + Effect.gen(function* () { + if ((yield* readRepoWorktreePaths()).has(yield* pathIdentity(worktreePath))) { + keep("another thread works in it as a repository worktree"); + return false; + } + // Clean only untracked files. Ordinary worktrees retain Git's final tracked-edit check. + if (settings.worktreeKeepWhen === "tracked-changes") + yield* cleanupGit(worktreePath, ["clean", "-ffdx"], "git clean"); + const submodules = yield* submoduleRemoval(worktreePath, settings); + if (submodules.reason !== null) { + keep(submodules.reason); + return false; + } + if (submodules.force) { + const changes = yield* localChangesReason(worktreePath, settings); + if (changes !== null) { + keep(changes); + return false; + } + } + yield* removeLinkedWorktree({ + operation: "StorageCleanup.cleanupGit", + cwd: project.workspaceRoot, + worktreePath, + // Git requires force for submodules; recursive checks above cannot make removal atomic. + force: submodules.force, + execute: git.execute, + run: (args) => + cleanupGit(project.workspaceRoot, [...args], "git worktree remove"), + }); + return true; + }), + ); + if (!removed) return; entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes }); yield* gitManager.invalidateStatus(project.workspaceRoot); // Preserve branch/path so ProviderTurnStartService can recreate the checkout on resume. @@ -1358,9 +1421,34 @@ const make = Effect.gen(function* () { ), ); } + const candidatePaths = new Set( + yield* Effect.forEach(candidates, (thread) => pathIdentity(thread.worktreePath!)), + ); + for (const [key, attached] of repoWorktreePaths) { + if ( + candidatePaths.has(key) || + !(yield* fs.exists(attached.path).pipe(Effect.orElseSucceed(() => false))) + ) + continue; + const owner = snapshot.threads.find((thread) => attached.threadIds.has(thread.id)); + entries.push({ + kind: "worktree", + outcome: "kept", + reason: "Repository worktree attached to a thread; cleanup keeps it until it is detached", + path: attached.path, + projectPath: + snapshot.projects.find((project) => project.id === owner?.projectId)?.workspaceRoot ?? + null, + threadId: owner?.id ?? null, + threadTitle: owner?.title ?? null, + bytes: null, + files: null, + }); + } yield* reportOrphanWorktrees( roots, yield* projectStore.list({ includeDeleted: true }), + [...repoWorktreePaths.values()].map((attached) => attached.path), entries, ); return { waiting, kept }; diff --git a/apps/server/src/testUtils/gitConfig.setup.ts b/apps/server/src/testUtils/gitConfig.setup.ts index f2ad4a83e2d54..9e3c374a0ad14 100644 --- a/apps/server/src/testUtils/gitConfig.setup.ts +++ b/apps/server/src/testUtils/gitConfig.setup.ts @@ -1,4 +1,5 @@ // Override inherited autocrlf, signing and init-branch settings through env so every git child gets stable fixtures. +// Fixtures add submodules from local paths, which Git otherwise refuses to clone. const entries: ReadonlyArray = [ ["core.autocrlf", "false"], ["core.filemode", "false"], @@ -6,6 +7,7 @@ const entries: ReadonlyArray = [ ["commit.gpgsign", "false"], ["tag.gpgsign", "false"], ["init.defaultBranch", "main"], + ["protocol.file.allow", "always"], ]; const existing = Number(process.env.GIT_CONFIG_COUNT ?? "0"); diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 89580e0937af7..cca2ba1872a2a 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1232,6 +1232,40 @@ it.effect("restores empty checkpoints without changing paths outside the workspa }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect("checkpoint ref deletion reports a ref Git keeps locked and deletes the rest", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-delete-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const lockedRef = CheckpointRef.make("refs/t3/checkpoints/locked"); + const missingRef = CheckpointRef.make("refs/t3/checkpoints/missing"); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: lockedRef }); + const lockPath = (yield* git([ + "rev-parse", + "--path-format=absolute", + "--git-path", + `${lockedRef}.lock`, + ])).stdout.trim(); + yield* fileSystem.writeFileString(lockPath, "held by another Git process\n"); + + const error = yield* driver.checkpoints + .deleteCheckpointRefs({ cwd, checkpointRefs: [lockedRef, checkpointRef, missingRef] }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "VcsProcessExitError"); + assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); + assert.isTrue(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef: lockedRef })); + + yield* fileSystem.remove(lockPath); + yield* driver.checkpoints.deleteCheckpointRefs({ + cwd, + checkpointRefs: [lockedRef, missingRef], + }); + assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef: lockedRef })); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect("restores a checkpoint before the first commit without staging its files", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index ff0eac7c00093..ca148c5f20789 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -467,6 +467,8 @@ const FLAGGED_INDEX_ENTRIES_MAX_BYTES = 1024 * 1024; const CHECKPOINT_RECOVERY_MAX_CANDIDATES = 64; const CHECKPOINT_RECOVERY_TIMEOUT = "5 seconds"; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; +/** Opens the message of a checkpoint commit captured as a baseline. */ +const BASELINE_CHECKPOINT_SUBJECT = "t3 checkpoint baseline"; const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ "-c", "core.fsmonitor=false", @@ -1331,7 +1333,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); } - const message = `t3 checkpoint ref=${input.checkpointRef}`; + const message = `${input.baseline === true ? BASELINE_CHECKPOINT_SUBJECT : "t3 checkpoint"} ref=${input.checkpointRef}`; // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints. const commitTreeResult = yield* execute({ operation, @@ -1370,6 +1372,23 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( Effect.map((commit) => commit !== null), ), + isBaselineCheckpoint: Effect.fn("GitVcsDriver.checkpoints.isBaselineCheckpoint")( + function* (input) { + const commit = yield* execute({ + operation: "GitVcsDriver.checkpoints.isBaselineCheckpoint", + cwd: input.cwd, + args: ["cat-file", "commit", `${input.checkpointRef}^{commit}`], + allowNonZeroExit: true, + }); + const messageStart = commit.stdout.indexOf("\n\n"); + return ( + commit.exitCode === 0 && + messageStart >= 0 && + commit.stdout.startsWith(`${BASELINE_CHECKPOINT_SUBJECT} `, messageStart + 2) + ); + }, + ), + restoreCheckpoint: Effect.fn("GitVcsDriver.checkpoints.restoreCheckpoint")(function* (input) { const operation = "GitVcsDriver.checkpoints.restoreCheckpoint"; @@ -1539,7 +1558,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "--no-ext-diff", "--no-textconv", "--submodule=short", - ...PATCH_RENDER_PREFIX_ARGS, + ...(input.pathPrefix === undefined + ? PATCH_RENDER_PREFIX_ARGS + : [`--src-prefix=a/${input.pathPrefix}/`, `--dst-prefix=b/${input.pathPrefix}/`]), ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${fromRevision}^{commit}`, `${input.toCheckpointRef}^{commit}`, @@ -1626,17 +1647,17 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")( function* (input) { - yield* Effect.forEach( - input.checkpointRefs, - (checkpointRef) => - execute({ - operation: "GitVcsDriver.checkpoints.deleteCheckpointRefs", - cwd: input.cwd, - args: ["update-ref", "-d", checkpointRef], - allowNonZeroExit: true, - }), - { discard: true }, + // Deleting a missing ref succeeds. A ref Git keeps locked does not + // stop the rest, and its failure is reported after them. + const [, failures] = yield* Effect.partition(input.checkpointRefs, (checkpointRef) => + execute({ + operation: "GitVcsDriver.checkpoints.deleteCheckpointRefs", + cwd: input.cwd, + args: ["update-ref", "-d", checkpointRef], + }), ); + const [failure] = failures; + if (failure !== undefined) return yield* failure; }, ), }; diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index b382922c5ab27..bb3703b49fcaa 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -14,6 +14,11 @@ import * as VcsProcess from "./VcsProcess.ts"; export interface VcsCaptureCheckpointInput { readonly cwd: string; readonly checkpointRef: CheckpointRef; + /** + * Taken before a turn changes files. A rewind can leave the snapshot a + * rolled-back turn ended with under the same ref; this tells them apart. + */ + readonly baseline?: boolean; } export interface VcsRestoreCheckpointInput { @@ -35,6 +40,8 @@ export interface VcsDiffCheckpointsInput { readonly maxOutputBytes?: number; /** An empty list yields an empty diff. */ readonly filePaths?: ReadonlyArray; + /** Shown before every patch path, so patches from several repositories stay apart. */ + readonly pathPrefix?: string; } export interface VcsListAuthoredPathsInput { @@ -53,6 +60,10 @@ export interface VcsCheckpointOps { readonly hasCheckpointRef: ( input: Omit, ) => Effect.Effect; + /** False when the ref is missing or was not captured as a baseline. */ + readonly isBaselineCheckpoint: ( + input: Omit, + ) => Effect.Effect; readonly restoreCheckpoint: ( input: VcsRestoreCheckpointInput, ) => Effect.Effect; diff --git a/apps/server/src/workspace/KnownWorkspaces.ts b/apps/server/src/workspace/KnownWorkspaces.ts index f442e4cedbb61..6654bc9752e21 100644 --- a/apps/server/src/workspace/KnownWorkspaces.ts +++ b/apps/server/src/workspace/KnownWorkspaces.ts @@ -1,4 +1,5 @@ -// Client-supplied worktrees count only under managed folders or when Git lists them for the project. +// Client-supplied worktrees count only under managed folders or when Git lists them for the +// project, or for a repository in the project's folder that a thread attached. import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Context from "effect/Context"; @@ -101,34 +102,66 @@ export const make = Effect.gen(function* () { NodeSqliteClient.readOnly, Effect.orElseSucceed(() => []), ); - const projectWorktrees = new Map>(); - const isProjectWorktree = Effect.fnUntraced(function* ( - projectId: string, - worktreePath: string, - ) { - const project = projects.find((candidate) => candidate.id === projectId); - if (project === undefined) return false; - let listed = projectWorktrees.get(projectId); + // Keyed by repository path: a folder project holds several repositories. + const listedWorktrees = new Map>(); + const isListedWorktree = Effect.fnUntraced(function* (repository: string, worktree: string) { + let listed = listedWorktrees.get(repository); if (listed === undefined) { - listed = yield* git.listWorktreePaths(project.workspaceRoot).pipe( + listed = yield* git.listWorktreePaths(repository).pipe( Effect.flatMap((paths) => Effect.forEach(paths, resolveRoot)), Effect.map((paths) => paths.filter((listedPath) => listedPath !== null)), Effect.orElseSucceed((): ReadonlyArray => []), ); - projectWorktrees.set(projectId, listed); + listedWorktrees.set(repository, listed); } - const real = yield* resolveRoot(worktreePath); - return real !== null && listed.includes(real); + return listed.includes(worktree); }); + const projectRoot = (projectId: string) => { + const project = projects.find((candidate) => candidate.id === projectId); + return project === undefined ? Effect.succeed(null) : resolveRoot(project.workspaceRoot); + }; for (const { worktreePath, projectId } of threads) { // Relative paths use server cwd; managed paths still need resolution to reject symlinks elsewhere. if (!path.isAbsolute(worktreePath)) continue; - if (!within(yield* resolveRoot(worktreePath))) continue; + const worktree = yield* resolveRoot(worktreePath); + if (worktree === null || !within(worktree)) continue; const lexical = path.resolve(worktreePath); if (managedDirectories.some((directory) => isWithinRoot(path, lexical, directory))) { return true; } - if (yield* isProjectWorktree(projectId, worktreePath)) return true; + const project = projects.find((candidate) => candidate.id === projectId); + if (project !== undefined && (yield* isListedWorktree(project.workspaceRoot, worktree))) { + return true; + } + } + + const attachments = yield* sql<{ + readonly repositoryPath: string; + readonly worktreePath: string; + readonly projectId: string; + }>` + SELECT DISTINCT + json_extract(r.value, '$.repositoryPath') AS "repositoryPath", + json_extract(r.value, '$.worktreePath') AS "worktreePath", + t.project_id AS "projectId" + FROM orchestration_v2_projection_threads t, json_each(t.payload_json, '$.repoWorktrees') r + WHERE t.deleted_at IS NULL + AND json_extract(r.value, '$.repositoryPath') IS NOT NULL + AND json_extract(r.value, '$.worktreePath') IS NOT NULL + `.pipe( + NodeSqliteClient.readOnly, + Effect.orElseSucceed(() => []), + ); + // The record alone grants nothing: its repository must sit in the project's + // folder, and Git must list the worktree for that repository. + for (const { repositoryPath, worktreePath, projectId } of attachments) { + if (!path.isAbsolute(repositoryPath) || !path.isAbsolute(worktreePath)) continue; + const worktree = yield* resolveRoot(worktreePath); + if (worktree === null || !within(worktree)) continue; + const root = yield* projectRoot(projectId); + const repository = yield* resolveRoot(repositoryPath); + if (root === null || repository === null || !isWithinRoot(path, repository, root)) continue; + if (yield* isListedWorktree(repository, worktree)) return true; } return false; }, diff --git a/apps/server/src/workspace/workspaceLease.ts b/apps/server/src/workspace/workspaceLease.ts index 8ad6e41b859c8..b300f00998090 100644 --- a/apps/server/src/workspace/workspaceLease.ts +++ b/apps/server/src/workspace/workspaceLease.ts @@ -20,3 +20,26 @@ export const withWorkspaceLease = ( ), ); }); + +/** + * Serializes attaching a repository's worktrees to threads with removing them, + * so no checkout gets attached between a removal's last check and the removal. + * Keyed by the canonical common Git directory, which every checkout of the + * repository and every alias of its paths share. + */ +export const withRepositoryLease = ( + commonDirKey: string, + effect: Effect.Effect, +): Effect.Effect => withWorkspaceLease(`git-common-dir:${commonDirKey}`, effect); + +/** + * Holds several repository leases. They are taken in one sorted order, so two + * holders of overlapping sets cannot each wait on the other. + */ +export const withRepositoryLeases = ( + commonDirKeys: ReadonlyArray, + effect: Effect.Effect, +): Effect.Effect => + [...new Set(commonDirKeys)] + .toSorted() + .reduceRight((inner, key) => withRepositoryLease(key, inner), effect); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 492b874cc451e..749a6a63bec08 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -98,6 +98,7 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; +import * as RepoWorktreeService from "./git/RepoWorktreeService.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; @@ -838,6 +839,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(PullRequestService.PullRequestService)({}), Layer.mock(PullRequestSyncReactor.PullRequestSyncReactor)({}), Layer.mock(RelayClient.RelayClient)({}), + Layer.mock(RepoWorktreeService.RepoWorktreeService)({}), Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({}), Layer.mock(ResourceTelemetry.ResourceTelemetry)({}), Layer.mock(ReviewService.ReviewService)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4cfceb4ed8001..b768ed7c7477c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -42,6 +42,7 @@ import { type FileManagerRevealKind, type OrchestrationV2Command, type GitActionProgressEvent, + GitCommandError, type GitManagerServiceError, type MessageId, type AcpRegistryDeleteSessionInput, @@ -212,6 +213,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as RepoWorktreeService from "./git/RepoWorktreeService.ts"; import { refreshPushedPullRequests } from "./git/refreshPushedPullRequests.ts"; import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; @@ -1509,6 +1511,7 @@ export const layerWsRpc = ( const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; const directEndpoints = yield* DirectEndpoints.DirectEndpoints; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -1886,6 +1889,9 @@ export const layerWsRpc = ( vcsStatusBroadcaster .refreshStatus(cwd) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + // The checkout's branch already changed, so a stale label must not fail the action. + const recordBranch = (cwd: string) => + repoWorktrees.recordBranch({ cwd }).pipe(Effect.ignoreCause({ log: true })); const getOrchestrationV2ArchivedShellSnapshot = Effect.gen(function* () { const { threads, projects, snapshotSequence } = yield* loadShellSnapshotParts({ @@ -3133,6 +3139,9 @@ export const layerWsRpc = ( ), ), ), + Effect.andThen( + result.branch.status === "created" ? recordBranch(input.cwd) : Effect.void, + ), Effect.andThen(refreshGitStatus(input.cwd)), Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), ), @@ -3148,11 +3157,40 @@ export const layerWsRpc = ( [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))), + repoWorktrees.removeWorktree(input).pipe( + Effect.catchTags({ + RepoWorktreeInUseError: (error) => + Effect.fail( + new GitCommandError({ + operation: "RepoWorktreeService.removeWorktree", + command: "git worktree remove", + cwd: input.cwd, + detail: error.message, + }), + ), + RepoWorktreeReadError: (error) => + Effect.fail( + new GitCommandError({ + operation: "RepoWorktreeService.removeWorktree", + command: "git worktree remove", + cwd: input.cwd, + detail: error.message, + cause: error, + }), + ), + }), + Effect.tap(() => refreshGitStatus(input.cwd)), + ), [WS_METHODS.vcsCreateRef]: (input) => - gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + gitWorkflow.createRef(input).pipe( + Effect.tap(() => (input.switchRef === true ? recordBranch(input.cwd) : Effect.void)), + Effect.tap(() => refreshGitStatus(input.cwd)), + ), [WS_METHODS.vcsSwitchRef]: (input) => - gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + gitWorkflow.switchRef(input).pipe( + Effect.tap(() => recordBranch(input.cwd)), + Effect.tap(() => refreshGitStatus(input.cwd)), + ), [WS_METHODS.vcsInit]: (input) => vcsProvisioning.initRepository(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.reviewGetBranchCommits]: (input) => review.getBranchCommits(input), @@ -3480,6 +3518,7 @@ export const layer = Layer.unwrap( const npmRelease = yield* NpmRelease.NpmRelease; const pullRequests = yield* PullRequestService.PullRequestService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; + const repoWorktrees = yield* RepoWorktreeService.RepoWorktreeService; const peerLinks = yield* PeerLinks.PeerLinks; const peerLinkRequests = yield* PeerLinkRequests.PeerLinkRequests; const sql = yield* SqlClient.SqlClient; @@ -3569,6 +3608,7 @@ export const layer = Layer.unwrap( reviewGuides, ), ), + Layer.provide(Layer.succeed(RepoWorktreeService.RepoWorktreeService, repoWorktrees)), Layer.provide(Layer.succeed(PeerLinks.PeerLinks, peerLinks)), Layer.provide(Layer.succeed(PeerLinkRequests.PeerLinkRequests, peerLinkRequests)), Layer.provide( diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8223e03540405..f252dd8297638 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -8,6 +8,7 @@ import { withMessageContext, deriveAvailableRevertTurnCounts, deriveThreadPendingBackgroundTasks, + fileRestoreRetryApplies, } from "./ChatView.logic"; import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; @@ -959,6 +960,75 @@ describe("waitForRevertedMessage", () => { } }); + // The conversation is back at turn 1; its later checkpoints stay ready until files are restored. + const rewoundTo1 = ( + projection: ReturnType, + checkpointStatus: "ready" | "stale", + rollbackFailure: { requestId: CommandId; message: string } | null = null, + ) => + ({ + ...projection, + thread: { ...projection.thread, rollbackRequestId: requestId, rollbackFailure }, + providerThreads: projection.providerThreads.map((thread) => ({ + ...thread, + lastRunOrdinal: 1, + })), + runs: projection.runs.map((run) => + run.ordinal > 1 ? { ...run, status: "rolled_back" as const } : run, + ), + checkpoints: [2, 3, 4].map((ordinal) => ({ + appRunOrdinal: ordinal, + status: checkpointStatus, + })), + }) as unknown as ReturnType; + + it("waits for the files before reporting a rewind as finished", async () => { + vi.useFakeTimers(); + const { state, projection } = projectionAtom(); + let outcome: string | null | undefined; + const waiting = waitForRevertedMessage(threadRef, 1, requestId, async () => {}).then( + (failure) => { + outcome = failure; + }, + ); + + try { + appAtomRegistry.set(state, { data: Option.some(rewoundTo1(projection, "ready")) }); + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toBeUndefined(); + + appAtomRegistry.set(state, { data: Option.some(rewoundTo1(projection, "stale")) }); + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toBeNull(); + await waiting; + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it("resolves with the failure when the conversation rewound but files did not", async () => { + const { state, projection } = projectionAtom(); + const waiting = waitForRevertedMessage(threadRef, 1, requestId, async () => {}); + await Promise.resolve(); + appAtomRegistry.set(state, { + data: Option.some( + rewoundTo1(projection, "ready", { requestId, message: "Restoring files stopped." }), + ), + }); + + await expect(waiting).resolves.toBe("Restoring files stopped."); + }); + + it("offers that file restore again only until another turn runs", () => { + const rewound = [ + { ordinal: 1, status: "completed" as const }, + { ordinal: 2, status: "rolled_back" as const }, + ]; + expect(fileRestoreRetryApplies(rewound, 1)).toBe(true); + expect(fileRestoreRetryApplies([...rewound, { ordinal: 3, status: "running" }], 1)).toBe(false); + }); + it("rejects a stale edit already rewound by another client during attachment preparation", async () => { vi.useFakeTimers(); const { state, projection } = projectionAtom(); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b1d44d95441c5..dc5573a3541d6 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,8 @@ -import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts"; +import { + type OrchestrationV2ProviderTurn, + type OrchestrationV2Run, + type RunAttemptId, +} from "@t3tools/contracts"; import { deriveLatestThreadRun, deriveThreadRuntime, @@ -215,13 +219,15 @@ export function shouldOpenProactiveTurnDiff(input: { } export function resolveProactiveTurnDiffAction(input: { - checkpoint: Pick | undefined; + checkpoint: Pick | undefined; isGitRepo: boolean | undefined; }): "defer" | "ignore" | "open" { if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; - if (input.isGitRepo === undefined) return "defer"; + // Repository worktrees have diffs even when the folder is not a Git repository. + const hasRepositories = (input.checkpoint.repositories?.length ?? 0) > 0; + if (input.isGitRepo === undefined && !hasRepositories) return "defer"; if ( - !input.isGitRepo || + (!input.isGitRepo && !hasRepositories) || input.checkpoint.status !== "ready" || input.checkpoint.files.length === 0 ) { @@ -881,13 +887,29 @@ export function getStartedThreadModelChangeBlockReason(input: { }; } +/** + * Whether trying a stopped file restore again still restores only its files. A turn that ran + * since, from any client, would be rewound by it. + */ +export function fileRestoreRetryApplies( + runs: ReadonlyArray>, + turnCount: number, +): boolean { + return runs.every((run) => run.ordinal <= turnCount || run.status === "rolled_back"); +} + +/** + * Resolves once the conversation is rewound to before the message: with + * `null` when the files were restored too, or with the failure when restoring + * them stopped. Rejects when nothing was rewound. + */ export async function waitForRevertedMessage( threadRef: ScopedThreadRef, turnCount: number, requestId: CommandId, revert: () => Promise, timeoutMs = 120_000, -): Promise { +): Promise { const threadAtom = environmentThreadDetails.stateAtom(threadRef); const readProjection = () => Option.getOrNull(appAtomRegistry.get(threadAtom).data); const initial = readProjection(); @@ -911,29 +933,41 @@ export async function waitForRevertedMessage( if (witness === undefined) { throw new Error("The thread has changed. Select a message to rewind again."); } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let settled = false; let accepted = false; let unsubscribe = () => {}; let timeout: ReturnType | undefined; - const finish = (error?: unknown) => { + const finish = (outcome: { readonly error: unknown } | { readonly failure: string | null }) => { if (settled) return; settled = true; if (timeout !== undefined) globalThis.clearTimeout(timeout); unsubscribe(); - if (error !== undefined) reject(error); - else resolve(); + if ("error" in outcome) reject(outcome.error); + else resolve(outcome.failure); }; const inspect = () => { const thread = readProjection(); if (!thread) return; + const rewound = thread.runs.some( + (run) => run.id === witness.id && run.status === "rolled_back", + ); const failure = thread.thread.rollbackFailure; if (failure?.requestId === requestId) { - finish(new Error(failure.message)); + finish(rewound ? { failure: failure.message } : { error: new Error(failure.message) }); return; } + // Later checkpoints turn stale once their files are back; until then a failure may follow. + const filesPending = thread.checkpoints.some( + (checkpoint) => + checkpoint.status === "ready" && + checkpoint.appRunOrdinal !== null && + checkpoint.appRunOrdinal > turnCount, + ); if ( accepted && + rewound && + !filesPending && thread.providerThreads.some( (providerThread) => (providerThread.id === providerThreadId || @@ -944,21 +978,23 @@ export async function waitForRevertedMessage( providerThread.providerInstanceId === initialProviderThread?.providerInstanceId && !initialProviderThreadIds.has(providerThread.id))) && providerThread.lastRunOrdinal === (turnCount === 0 ? null : turnCount), - ) && - thread.runs.some((run) => run.id === witness.id && run.status === "rolled_back") + ) ) - finish(); + finish({ failure: null }); }; unsubscribe = appAtomRegistry.subscribe(threadAtom, inspect); timeout = globalThis.setTimeout(() => { - finish(new Error("Timed out waiting for the thread to rewind.")); + finish({ error: new Error("Timed out waiting for the thread to rewind.") }); }, timeoutMs); Promise.resolve() .then(revert) - .then(() => { - accepted = true; - inspect(); - }, finish); + .then( + () => { + accepted = true; + inspect(); + }, + (error: unknown) => finish({ error }), + ); }); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fdbb91d9073ff..10ac312a7d189 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1730,6 +1730,9 @@ export default function ChatView(props: ChatViewProps) { } }, [environmentId, gitStatusCwd, liveIsGitRepo]); const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; + // A folder that is not a repository still shows the changes of its repository worktrees. + const changesAvailable = + isServerThread && (isGitRepo || (serverProjection?.thread.repoWorktrees?.length ?? 0) > 0); // Provider-native subagents cannot take messages; approvals and questions go to the top-level parent. const showProviderSubagentBar = isProviderSubagent; const composerMounted = !showProviderSubagentBar; @@ -2381,7 +2384,7 @@ export default function ChatView(props: ChatViewProps) { threadPanelPresentation, isServerThread, embedded, - isGitRepo, + changesAvailable, browserAvailable, canOperatePreview, diffOpen, @@ -2824,26 +2827,32 @@ export default function ChatView(props: ChatViewProps) { : null; const openSideChatStable = useStableCallback(() => void sideChat.open()); const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open(prompt)); - const { onConfirmRevertStable, onForkFromRunStable, onRevertTimelineTurnStable } = - useTimelineRewind({ - environmentId, - routeThreadRef, - routeThreadKey, - currentRouteThreadKeyRef, - serverProjection, - activeThread, - timelineMessages, - phase, - isSendBusy, - isRevertingCheckpoint, - supportsConversationRollback, - activeEnvironmentUnavailable, - activeEnvironmentUnavailableLabel, - promptRef, - composerRef, - composerDraftTarget, - setThreadError, - }); + const { + onConfirmRevertStable, + onForkFromRunStable, + onRevertTimelineTurnStable, + fileRestoreRetryMessage, + onRetryFileRestoreStable, + clearFileRestoreRetryStable, + } = useTimelineRewind({ + environmentId, + routeThreadRef, + routeThreadKey, + currentRouteThreadKeyRef, + serverProjection, + activeThread, + timelineMessages, + phase, + isSendBusy, + isRevertingCheckpoint, + supportsConversationRollback, + activeEnvironmentUnavailable, + activeEnvironmentUnavailableLabel, + promptRef, + composerRef, + composerDraftTarget, + setThreadError, + }); const onCompactContext = () => { if (compactDisabled) return; void sendStandaloneCommand("/compact", "Failed to compact context."); @@ -3391,6 +3400,9 @@ export default function ChatView(props: ChatViewProps) { threadErrorBannerKey={threadErrorBannerKey} setThreadError={setThreadError} setThreadErrorBannerDismissTick={setThreadErrorBannerDismissTick} + fileRestoreRetryMessage={fileRestoreRetryMessage} + onRetryFileRestore={onRetryFileRestoreStable} + onClearFileRestoreRetry={clearFileRestoreRetryStable} />
(summary.repositories?.length ?? 0) > 0, + ); const orderedTurnDiffSummaries = turnDiffSummaries.toSorted((left, right) => { if (left.checkpointTurnCount !== right.checkpointTurnCount) { return right.checkpointTurnCount - left.checkpointTurnCount; @@ -346,7 +362,9 @@ export default function DiffPanel({ ? `turn:${selectedTurn.runId}` : selectedCommitSha !== null ? `commit:${selectedCommitSha}` - : selectedGitScope; + : selectedRepoWorktree === null + ? selectedGitScope + : `${selectedGitScope}:${selectedRepoWorktree.worktreePath}`; const collapseScopeKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` : null; @@ -367,7 +385,7 @@ export default function DiffPanel({ ignoreWhitespace: diffIgnoreWhitespace, cacheScope: selectedTurn ? `turn:${selectedTurn.runId}` : null, }, - { enabled: isGitRepo && selectedTurn !== undefined }, + { enabled: (isGitRepo || hasRepositoryTurns) && selectedTurn !== undefined }, ); const branchDiffPreviewInput = canReadFiles && isGitSelection && activeThread && activeCwd @@ -448,11 +466,16 @@ export default function DiffPanel({ : selectedTurn?.runId === latestTurn?.runId ? "Latest turn" : `Turn ${selectedCheckpointTurnCount ?? "?"}`; + // Review comments carry this title, so it names the repository their paths belong to. const reviewSectionTitle = toolCallSelection ? "Tool call" : selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` - : selectedGitScopeLabel; + : `${selectedGitScopeLabel}${ + selectedRepoWorktree === null + ? "" + : ` in ${selectedRepoWorktree.repository} · ${selectedRepoWorktree.branch}` + }`; const headerCommitSha = selectedRunId === null ? selectedCommitSha : null; const selectedCommitUrl = headerCommitSha !== null @@ -741,12 +764,38 @@ export default function DiffPanel({ } }, [collapseScopeKey, selectedFilePath, selectedFileRevealRequestId, revealDiffFile]); + // A turn diff names repository files by their prefix; a repository's + // Changes are relative to its worktree. Either way the file opens there. + const repositoryFileTarget = (filePath: string) => { + if (selectedTurn) { + const target = resolveCheckpointFilePath(selectedTurn.repositories, filePath); + return target === null + ? null + : { filePath: target.relativePath, workspaceRoot: target.worktreePath }; + } + return selectedRepoWorktree === null + ? null + : { filePath, workspaceRoot: selectedRepoWorktree.worktreePath }; + }; const openDiffFile = (filePath: string) => { + const repositoryFile = repositoryFileTarget(filePath); + if (repositoryFile !== null) { + const absolutePath = resolvePathLinkTarget( + repositoryFile.filePath, + repositoryFile.workspaceRoot, + ); + if (routeThreadRef) { + useRightPanelStore.getState().openFile(routeThreadRef, absolutePath); + } else { + void openInPreferredEditor(absolutePath); + } + return; + } openDiffFilePrimaryAction({ threadRef: routeThreadRef, filePath, - activeCwd, - repositoryRoot: activeRepositoryRoot, + activeCwd: workspaceCwd, + repositoryRoot: workspaceRepositoryRoot, openInEditor: (targetPath) => { void (async () => { const result = await openInPreferredEditor(targetPath); @@ -1046,7 +1095,7 @@ export default function DiffPanel({ > } - className="min-w-9 max-w-48 shrink-0" + className="min-w-9 max-w-48 shrink-0 @max-[20rem]/diff-refs:shrink" aria-label={`Change comparison target. Currently ${comparisonBaseRef}`} > {comparisonBaseRef} @@ -1299,13 +1348,28 @@ export default function DiffPanel({ return ( + {/* Its own row: beside the scope and comparison, a normal-width panel has no room left. */} + {selectedRunId === null && !toolCallSelection && repoTarget.repoWorktrees.length > 0 ? ( +
+ +
+ ) : null} {!activeThread ? (
Select a thread to inspect turn diffs.
- ) : !isGitRepo && !toolCallSelection ? ( + ) : !isGitRepo && !toolCallSelection && (selectedRunId === null || !hasRepositoryTurns) ? (
- Turn diffs are unavailable because this project is not a git repository. + {selectedRunId === null && repoTarget.repoWorktrees.length > 0 + ? "This folder is not a Git repository. Choose one of its repository worktrees above." + : "Turn diffs are unavailable because this project is not a git repository."}
) : selectedRunId !== null && orderedTurnDiffSummaries.length === 0 ? (
@@ -1433,13 +1497,20 @@ export default function DiffPanel({ const filePath = title?.textContent?.trim(); if (!filePath) return; event.preventDefault(); + const repositoryFile = repositoryFileTarget(filePath); onFileContextMenu( - { - environmentId: activeThread?.environmentId ?? null, - filePath, - workspaceRoot: activeCwd, - repositoryRoot: activeRepositoryRoot, - }, + repositoryFile === null + ? { + environmentId: activeThread?.environmentId ?? null, + filePath, + workspaceRoot: workspaceCwd, + repositoryRoot: workspaceRepositoryRoot, + } + : { + environmentId: activeThread?.environmentId ?? null, + ...repositoryFile, + repositoryRoot: undefined, + }, event, ); }} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index bb89514b2f0a1..46de990dfe0cb 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -136,6 +136,8 @@ interface GitActionsControlProps { showChanges?: boolean; onOpenChanges?: () => void; workspaceMutationId?: string | null; + /** False when `gitCwd` is a repository worktree, whose branch is not the thread's. */ + syncThreadBranch?: boolean; } interface PendingDefaultBranchAction { @@ -957,6 +959,7 @@ export default function GitActionsControl({ showChanges = true, onOpenChanges, workspaceMutationId = null, + syncThreadBranch = true, }: GitActionsControlProps) { const panelAnchorRef = useRef(null); const updateThreadMetadata = useAtomCommand( @@ -1007,7 +1010,7 @@ export default function GitActionsControl({ inlineSuccess?.scopeKey === successScopeKey && !inlineSuccessExpired ? inlineSuccess : null; const persistThreadBranchSync = (branch: string | null, manualSelection = false) => { - if (!activeThreadRef) { + if (!activeThreadRef || !syncThreadBranch) { return; } diff --git a/apps/web/src/components/RepoWorktreeTargetMenu.tsx b/apps/web/src/components/RepoWorktreeTargetMenu.tsx new file mode 100644 index 0000000000000..19b073bd84502 --- /dev/null +++ b/apps/web/src/components/RepoWorktreeTargetMenu.tsx @@ -0,0 +1,58 @@ +import type { ThreadRepoWorktree } from "@t3tools/contracts"; +import { ChevronDownIcon, FolderGit2Icon } from "lucide-react"; + +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "./ui/menu"; + +const WORKSPACE_VALUE = "__thread_workspace__"; + +/** Picks the repository a thread's Changes and Git actions work in. */ +export function RepoWorktreeTargetMenu(props: { + readonly repoWorktrees: ReadonlyArray; + readonly selected: ThreadRepoWorktree | null; + readonly workspaceLabel: string; + readonly onSelect: (worktreePath: string | null) => void; +}) { + if (props.repoWorktrees.length === 0) return null; + const label = + props.selected === null + ? props.workspaceLabel + : `${props.selected.repository} · ${props.selected.branch}`; + return ( + + } + className="min-w-0 max-w-56" + aria-label={`Repository: ${label}`} + > + + {label} + + + + props.onSelect(value === WORKSPACE_VALUE ? null : value)} + > + + {props.workspaceLabel} + + {props.repoWorktrees.map((entry) => ( + + + {entry.repository} + {entry.branch} + + + ))} + + + + ); +} diff --git a/apps/web/src/components/chat/ChatBannerLane.tsx b/apps/web/src/components/chat/ChatBannerLane.tsx index 8d8a6cb01b839..d6b3c5dbf7716 100644 --- a/apps/web/src/components/chat/ChatBannerLane.tsx +++ b/apps/web/src/components/chat/ChatBannerLane.tsx @@ -26,6 +26,10 @@ export function ChatBannerLane(props: { threadErrorBannerKey: string | null; setThreadError: (threadId: ThreadId | null, error: string | null) => void; setThreadErrorBannerDismissTick: Dispatch>; + /** Offered while the shown error is the one a file restore stopped with. */ + fileRestoreRetryMessage: string | null; + onRetryFileRestore: () => void; + onClearFileRestoreRetry: () => void; }) { const { providerStatus, @@ -39,6 +43,9 @@ export function ChatBannerLane(props: { threadErrorBannerKey, setThreadError, setThreadErrorBannerDismissTick, + fileRestoreRetryMessage, + onRetryFileRestore, + onClearFileRestoreRetry, } = props; return (
@@ -52,8 +59,14 @@ export function ChatBannerLane(props: { error={threadError} errorClass={threadErrorClass} errorCode={threadErrorCode} + action={ + fileRestoreRetryMessage !== null && fileRestoreRetryMessage === threadError + ? { label: "Try again", onClick: onRetryFileRestore } + : null + } onDismiss={() => { setThreadError(threadId, null); + onClearFileRestoreRetry(); dismissThreadErrorBannerForSession(threadErrorBannerKey); setThreadErrorBannerDismissTick((tick) => tick + 1); }} diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 4942080dbb2d2..5657019dea9a6 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -12,9 +12,11 @@ import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import type { ComposerThreadTarget, DraftId } from "../../composerDraftStore"; import { useT3ProjectFileScripts } from "../../hooks/useT3ProjectFileScripts"; +import { useThreadRepoWorktreeTarget } from "../../hooks/useThreadRepoWorktreeTarget"; import { type EnvMode, type EnvironmentOption } from "../BranchToolbar.logic"; import { BranchToolbar } from "../BranchToolbar"; import BaseGitActionsControl from "../GitActionsControl"; +import { RepoWorktreeTargetMenu } from "../RepoWorktreeTargetMenu"; import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, @@ -25,6 +27,7 @@ import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; +import { ThreadRepoWorktreesSection } from "./ThreadRepoWorktreesSection"; import { ThreadServersPanel } from "./ThreadServersPanel"; import { ThreadDetailsContext, @@ -110,6 +113,12 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { serverConfig !== undefined && project !== null && !isScratchProject(project, serverConfig.scratchWorkspaceRoot); + const repoTarget = useThreadRepoWorktreeTarget({ + threadRef: props.draftId ? null : activeThreadRef, + workspaceCwd: props.gitCwd, + }); + // A repository worktree's branch is not the thread's, so the thread branch selector hides. + const offersBranchSelector = offersGit && props.isGitRepo && repoTarget.repoWorktree === null; const branchToolbarProps = { showGitControls: props.isGitRepo, environmentId: props.environmentId, @@ -169,13 +178,14 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { environment: density === "full" && visible.environment, openIn: density !== "essential" && visible.openIn && props.showOpenInPicker, scripts: visible.scripts && props.activeProjectScripts !== undefined, - branch: offersGit && props.isGitRepo && visible.branch, + branch: offersBranchSelector && visible.branch, // Without a branch selector, linked pull requests still need somewhere to show. - pullRequests: !(offersGit && props.isGitRepo) && visible.branch, + pullRequests: !offersBranchSelector && visible.branch, git: offersGit && props.activeProjectName !== undefined && - (visible.gitActions || (visible.changes && props.isGitRepo)), + (visible.gitActions || + (visible.changes && (props.isGitRepo || repoTarget.repoWorktree !== null))), }; return ( <> @@ -238,6 +248,16 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { separated={density === "full"} >
+ {show.git ? ( + + ) : null} {show.branch ? ( ) : null} @@ -253,7 +273,8 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { compact={density !== "full"} showActions={visible.gitActions} showChanges={visible.changes} - gitCwd={props.gitCwd} + gitCwd={repoTarget.repoWorktree?.worktreePath ?? props.gitCwd} + syncThreadBranch={repoTarget.repoWorktree === null} workspaceMutationId={props.workspaceMutationId} activeThreadRef={activeThreadRef} {...(props.draftId ? { draftId: props.draftId } : {})} @@ -264,6 +285,17 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { ) : null} + {!props.draftId ? ( + { + repoTarget.select(worktreePath); + props.onOpenChanges?.(); + }} + /> + ) : null} + {density === "full" && !props.draftId && visible.servers ? ( ) : null} diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index 7c8f4b7fee78c..ef09487a9f364 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -74,11 +74,14 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ onDismiss, errorClass, errorCode, + action, }: { error: string | null; errorClass?: OrchestrationV2ProviderFailureClass | null; errorCode?: string | null; onDismiss?: () => void; + /** Lets the user act on the error, such as trying the failed step again. */ + action?: { readonly label: string; readonly onClick: () => void } | null; }) { if (!error) return null; const workspaceLimit = errorCode === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE; @@ -108,7 +111,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ )} - {(chatGptLimit || onDismiss) && ( + {(chatGptLimit || onDismiss || action) && ( {workspaceLimit ? ( + ) : null} {onDismiss ? (
) : null} - {relativePath && !attachment && !isHostFile && !canWriteFiles && !fileAccess.isPending ? ( + {relativePath && + !attachment && + !readOnlyHostFile && + !canWriteFiles && + !fileAccess.isPending ? (
Read-only connection. Unsaved edits are kept until write access returns.
@@ -1613,7 +1643,7 @@ export default function FilePreviewPanel({ text={file.data.contents} delimiter={tableDelimiter} /> - ) : file.data.truncated || isHostFile || !canWriteFiles ? ( + ) : file.data.truncated || readOnlyHostFile || !canWriteFiles ? ( , +): { readonly worktreePath: string; readonly relativePath: string } | null { + if (!isHostPath(path)) return null; + for (const { worktreePath } of repoWorktrees) { + const relativePath = workspaceRelativeFilePath(path, worktreePath); + if (relativePath !== null && relativePath !== "." && !isHostPath(relativePath)) { + return { worktreePath, relativePath }; + } + } + return null; +} + export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx|markdown|mdown|mkd)$/i.test(path); diff --git a/apps/web/src/components/settings/RepoWorktreeSettings.tsx b/apps/web/src/components/settings/RepoWorktreeSettings.tsx new file mode 100644 index 0000000000000..6b9755bfff523 --- /dev/null +++ b/apps/web/src/components/settings/RepoWorktreeSettings.tsx @@ -0,0 +1,98 @@ +import { useRef } from "react"; + +import { Switch } from "../ui/switch"; +import { Textarea } from "../ui/textarea"; +import { SettingResetButton, SettingsRow } from "./settingsLayout"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { searchableSetting } from "./settingsSearch"; +import { + useScopedSettings, + useScopedSettingsMixed, + useUpdateScopedSettings, +} from "./useScopedSettings"; + +export function RepoWorktreeSettings() { + const settings = useScopedSettings(); + const { scope, targets } = useSettingsScope(); + const scopeKey = targets.map((target) => `${target.environmentId}:${target.projectId}`).join(","); + const updateSettings = useUpdateScopedSettings(); + const instructionsEdited = useRef(false); + const enabledMixed = useScopedSettingsMixed(["repoWorktrees"]); + const instructionsMixed = useScopedSettingsMixed(["repoWorktreeInstructions"]); + const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; + + return ( + <> + updateSettings({ repoWorktrees: false })} + /> + ) : null + } + control={ + updateSettings({ repoWorktrees: enabled })} + /> + } + /> + {enabledMixed || settings.repoWorktrees ? ( + updateSettings({ repoWorktreeInstructions: "" })} + /> + ) : null + } + > +
+