From b3763f6d93965b3b31ff3769cf603f4e049008fc Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:48:23 +0200 Subject: [PATCH 1/8] feat(worktrees): manage lifecycle on orchestrator v2 Rebased onto the current t3code/codex-turn-mapping base. The base's interim inline worktree repair in ProviderTurnStartService is replaced by the revival service this change introduces. Revival only creates a missing directory. An existing worktree is left as is, whatever ref it has checked out and wherever it lives. Only per-thread provider sessions restart after a revival; a session shared across threads stays open. Turn starts skip the mutation permit when the directory exists, and the reaper holds it per worktree instead of per batch. Turn start no longer serializes on a per-session lock. Restarts only apply to per-thread sessions, whose ids are allocated per thread, so the lock protected nothing while serializing every Codex start behind handoff delivery and baseline capture. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/auth/RpcAuthorization.ts | 3 + .../src/environment/ServerEnvironment.ts | 1 + .../server/src/git/GitWorkflowService.test.ts | 3 + apps/server/src/git/GitWorkflowService.ts | 27 +- .../AcpRegistryOrchestratorV2.live.test.ts | 4 +- .../CursorOrchestratorV2.live.test.ts | 4 +- .../DelegatedCompletionDelivery.test.ts | 4 +- .../GrokOrchestratorV2.live.test.ts | 4 +- .../ProviderTurnStartService.test.ts | 306 ++++++- .../ProviderTurnStartService.testkit.ts | 16 - .../ProviderTurnStartService.ts | 109 +-- .../src/orchestration-v2/runtimeLayer.test.ts | 5 + .../testkit/ProviderReplayHarness.ts | 5 +- apps/server/src/server.ts | 39 + apps/server/src/serverRuntimeStartup.ts | 9 +- apps/server/src/vcs/GitVcsDriver.ts | 42 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 29 + apps/server/src/vcs/GitVcsDriverCore.ts | 55 +- .../src/vcs/GitVcsDriverWorktree.test.ts | 100 +++ apps/server/src/vcs/GitWorktree.test.ts | 40 + apps/server/src/vcs/GitWorktree.ts | 54 ++ apps/server/src/vcs/VcsDriver.ts | 2 + .../src/vcs/VcsProvisioningService.test.ts | 1 + .../src/vcs/WorktreeDeletionCleanup.test.ts | 259 ++++++ .../server/src/vcs/WorktreeDeletionCleanup.ts | 151 ++++ apps/server/src/vcs/WorktreeLifecycle.test.ts | 48 ++ apps/server/src/vcs/WorktreeLifecycle.ts | 39 + apps/server/src/vcs/WorktreeReaper.test.ts | 120 +++ apps/server/src/vcs/WorktreeReaper.ts | 97 +++ .../src/vcs/WorktreeRevivalService.test.ts | 389 +++++++++ .../src/vcs/WorktreeRevivalService.testkit.ts | 12 + apps/server/src/vcs/WorktreeRevivalService.ts | 511 ++++++++++++ apps/server/src/vcs/WorktreeService.test.ts | 540 ++++++++++++ apps/server/src/vcs/WorktreeService.ts | 779 ++++++++++++++++++ apps/server/src/ws.ts | 16 + .../settings/SourceControlSettings.tsx | 736 ++++++++++++++++- .../settings/settingsSearch.test.ts | 1 + .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/hooks/useThreadActions.ts | 22 +- apps/web/src/state/entities.ts | 8 + apps/web/src/state/worktrees.ts | 5 + docs/user/source-control.md | 18 + packages/client-runtime/package.json | 4 + packages/client-runtime/src/rpc/client.ts | 1 + packages/client-runtime/src/state/vcs.ts | 10 +- .../src/state/vcsCommandScheduler.ts | 8 + .../client-runtime/src/state/worktrees.ts | 32 + packages/contracts/src/environment.ts | 4 + packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 34 + packages/contracts/src/settings.ts | 28 + packages/contracts/src/vcs.ts | 8 + packages/contracts/src/worktrees.test.ts | 45 + packages/contracts/src/worktrees.ts | 227 +++++ 54 files changed, 4866 insertions(+), 154 deletions(-) delete mode 100644 apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts create mode 100644 apps/server/src/vcs/GitVcsDriverWorktree.test.ts create mode 100644 apps/server/src/vcs/GitWorktree.test.ts create mode 100644 apps/server/src/vcs/GitWorktree.ts create mode 100644 apps/server/src/vcs/WorktreeDeletionCleanup.test.ts create mode 100644 apps/server/src/vcs/WorktreeDeletionCleanup.ts create mode 100644 apps/server/src/vcs/WorktreeLifecycle.test.ts create mode 100644 apps/server/src/vcs/WorktreeLifecycle.ts create mode 100644 apps/server/src/vcs/WorktreeReaper.test.ts create mode 100644 apps/server/src/vcs/WorktreeReaper.ts create mode 100644 apps/server/src/vcs/WorktreeRevivalService.test.ts create mode 100644 apps/server/src/vcs/WorktreeRevivalService.testkit.ts create mode 100644 apps/server/src/vcs/WorktreeRevivalService.ts create mode 100644 apps/server/src/vcs/WorktreeService.test.ts create mode 100644 apps/server/src/vcs/WorktreeService.ts create mode 100644 apps/web/src/state/worktrees.ts create mode 100644 packages/client-runtime/src/state/worktrees.ts create mode 100644 packages/contracts/src/worktrees.test.ts create mode 100644 packages/contracts/src/worktrees.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 086fa6b30c46..00a3acd4bb6f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -156,6 +156,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope, [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, + [WS_METHODS.vcsListWorktrees]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeWorktreeInventory]: AuthOrchestrationReadScope, + [WS_METHODS.vcsPruneWorktrees]: AuthOrchestrationOperateScope, [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope, [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope, [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 91ab19b55e01..a3d0c0107d01 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -245,6 +245,7 @@ export const make = Effect.gen(function* () { serverResolvedCommandContext: true, environmentIcon: true, projectCloneTracking: true, + worktreeManagement: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), // V2 restart recovery uses the environment-owned opt-in. The old // per-update request flag is not wired into the V2 update RPC path. diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 5e7545eafdf8..30b79883889d 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -10,6 +10,7 @@ import * as GitManager from "./GitManager.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as WorktreeLifecycle from "../vcs/WorktreeLifecycle.ts"; function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; @@ -22,6 +23,7 @@ function makeLayer(input: { ), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), Layer.provide(Layer.mock(GitManager.GitManager)({})), + Layer.provide(WorktreeLifecycle.layer), ); } @@ -131,6 +133,7 @@ describe("GitWorkflowService", () => { status, }), ), + Layer.provide(WorktreeLifecycle.layer), ); return Effect.gen(function* () { diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 912e0c1f4620..83b5317f8c75 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -31,6 +31,7 @@ import { import * as GitManager from "./GitManager.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as WorktreeLifecycle from "../vcs/WorktreeLifecycle.ts"; export class GitWorkflowService extends Context.Service< GitWorkflowService, @@ -159,6 +160,7 @@ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; const gitManager = yield* GitManager.GitManager; + const worktreeLifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( operation: string, @@ -335,10 +337,13 @@ export const make = Effect.gen(function* () { "GitWorkflowService.resolvePullRequest", gitManager.resolvePullRequest, ), - preparePullRequestThread: routeGitManager( - "GitWorkflowService.preparePullRequestThread", - gitManager.preparePullRequestThread, - ), + preparePullRequestThread: (input) => + worktreeLifecycle.withMutationPermit( + ensureGit("GitWorkflowService.preparePullRequestThread", input.cwd).pipe( + Effect.andThen(gitManager.preparePullRequestThread(input)), + Effect.tap(() => worktreeLifecycle.markInventoryChanged), + ), + ), listRefs: (input) => detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe( Effect.flatMap((isGitRepository) => @@ -346,8 +351,11 @@ export const make = Effect.gen(function* () { ), ), createWorktree: (input, options) => - ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( - Effect.andThen(git.createWorktree(input, options)), + worktreeLifecycle.withMutationPermit( + ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( + Effect.andThen(git.createWorktree(input, options)), + Effect.tap(() => worktreeLifecycle.markInventoryChanged), + ), ), listLocalBranchNames: (cwd) => ensureGitCommand("GitWorkflowService.listLocalBranchNames", cwd).pipe( @@ -370,8 +378,11 @@ export const make = Effect.gen(function* () { Effect.andThen(git.resolveRemoteTrackingCommit(input)), ), removeWorktree: (input) => - ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( - Effect.andThen(git.removeWorktree(input)), + worktreeLifecycle.withMutationPermit( + ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( + Effect.andThen(git.removeWorktree(input)), + Effect.tap(() => worktreeLifecycle.markInventoryChanged), + ), ), pruneWorktrees: (input) => ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe( diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index 22aa8a0dc042..009b83ff89e4 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -38,8 +38,8 @@ import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { layerNoop as worktreeRevivalTestLayer } from "../vcs/WorktreeRevivalService.testkit.ts"; import { OrchestratorV2 } from "./Orchestrator.ts"; -import { worktreeRepairDependenciesTestLayer } from "./ProviderTurnStartService.testkit.ts"; import { OrchestrationV2LayerLive } from "./runtimeLayer.ts"; import { layer as mcpSessionRegistryTestLayer } from "../mcp/McpSessionRegistry.testkit.ts"; @@ -128,6 +128,7 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe ); const liveLayer = OrchestrationV2LayerLive.pipe( + Layer.provide(worktreeRevivalTestLayer), Layer.provide(mcpSessionRegistryTestLayer), Layer.provide(SqlitePersistenceMemory), Layer.provide(checkpointStoreLayer), @@ -136,7 +137,6 @@ const liveLayer = OrchestrationV2LayerLive.pipe( Layer.provide(providerInstanceRegistryLayer), Layer.provide(ResetCreditCoordinator.layer), Layer.provide(backgroundPolicyLayer), - Layer.provide(worktreeRepairDependenciesTestLayer), Layer.provide(PlatformTestLayer), ); diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index bdeafa018e25..3597cbef88da 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -36,8 +36,8 @@ import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { layerNoop as worktreeRevivalTestLayer } from "../vcs/WorktreeRevivalService.testkit.ts"; import { OrchestratorV2 } from "./Orchestrator.ts"; -import { worktreeRepairDependenciesTestLayer } from "./ProviderTurnStartService.testkit.ts"; import { runDaemonWithOptions as runEffectWorkerDaemonWithOptions } from "./EffectWorker.ts"; import { OrchestrationV2LayerLive } from "./runtimeLayer.ts"; import { layer as mcpSessionRegistryTestLayer } from "../mcp/McpSessionRegistry.testkit.ts"; @@ -103,7 +103,7 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe ); const liveLayer = OrchestrationV2LayerLive.pipe( - Layer.provide(worktreeRepairDependenciesTestLayer), + Layer.provide(worktreeRevivalTestLayer), Layer.provide(mcpSessionRegistryTestLayer), Layer.provide(SqlitePersistenceMemory), Layer.provide(checkpointStoreLayer), diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 74e0864369e5..cb2380664286 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -30,6 +30,7 @@ import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceR import { ServerSettingsService } from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { layerNoop as worktreeRevivalTestLayer } from "../vcs/WorktreeRevivalService.testkit.ts"; import { WorkspacePaths } from "../workspace/WorkspacePaths.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { EventSinkV2 } from "./EventSink.ts"; @@ -40,7 +41,6 @@ import { OrchestrationV2LayerLive, ProjectServiceLayerLive, } from "./runtimeLayer.ts"; -import { worktreeRepairDependenciesTestLayer } from "./ProviderTurnStartService.testkit.ts"; const PlatformTestLayer = Layer.merge( NodeServices.layer, @@ -105,7 +105,7 @@ const TestLayer = Layer.mergeAll(OrchestrationV2LayerLive, OrchestrationV2EventS normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot), }), ), - Layer.provide(worktreeRepairDependenciesTestLayer), + Layer.provide(worktreeRevivalTestLayer), Layer.provide( Layer.succeed(ProjectEnrichmentService, { peek: () => diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index ae9b4fd05db2..c533d3e127b0 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -36,8 +36,8 @@ import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { layerNoop as worktreeRevivalTestLayer } from "../vcs/WorktreeRevivalService.testkit.ts"; import { OrchestratorV2 } from "./Orchestrator.ts"; -import { worktreeRepairDependenciesTestLayer } from "./ProviderTurnStartService.testkit.ts"; import { OrchestrationV2LayerLive } from "./runtimeLayer.ts"; import { layer as mcpSessionRegistryTestLayer } from "../mcp/McpSessionRegistry.testkit.ts"; import { GROK_MODEL_SELECTION } from "./testkit/fixtures/shared.ts"; @@ -102,7 +102,7 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe ); const liveLayer = OrchestrationV2LayerLive.pipe( - Layer.provide(worktreeRepairDependenciesTestLayer), + Layer.provide(worktreeRevivalTestLayer), Layer.provide(mcpSessionRegistryTestLayer), Layer.provide(SqlitePersistenceMemory), Layer.provide(checkpointStoreLayer), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index b79e983e1311..5f5be7b6fb8b 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -13,19 +13,20 @@ import { RunId, ThreadId, ProjectId, + WorktreeMutationError, + type OrchestrationV2Run, type OrchestrationV2ThreadProjection, OrchestrationV2DomainEvent, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as GitWorkflow from "../git/GitWorkflowService.ts"; -import * as ProjectService from "../project/ProjectService.ts"; import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import * as EventSink from "./EventSink.ts"; @@ -36,6 +37,8 @@ import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnStart from "./ProviderTurnStartService.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; +import * as WorktreeRevivalService from "../vcs/WorktreeRevivalService.ts"; +import type { ProviderAdapterV2SessionRuntime } from "./ProviderAdapter.ts"; const isDomainEvent = Schema.is(OrchestrationV2DomainEvent); @@ -55,12 +58,7 @@ it("does not commit running state when inherited background routing cannot be re "checkpoint_scope_provider_turn_start_projection_failure", ); const projection = { - thread: { - id: threadId, - projectId: ProjectId.make("project_provider_turn_start_projection_failure"), - branch: "feature/restore", - worktreePath: "/tmp/missing-provider-turn-start-worktree", - }, + thread: { id: threadId, branch: null, worktreePath: null }, runs: [ { id: runId, @@ -86,22 +84,12 @@ it("does not commit running state when inherited background routing cannot be re Effect.succeed({ committed: true, storedEvents: [] } as never), ); const startRootRun = vi.fn(() => Effect.void); - const pruneWorktrees = vi.fn(() => Effect.void); - const createWorktree = vi.fn(() => Effect.succeed({} as never)); const layer = ProviderTurnStart.layer.pipe( Layer.provide( Layer.mergeAll( Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, - Layer.succeed(FileSystem.FileSystem, { exists: () => Effect.succeed(false) } as never), - Layer.mock(GitWorkflow.GitWorkflowService)({ pruneWorktrees, createWorktree }), - Layer.mock(ProjectService.ProjectService)({ - getById: () => - Effect.succeed( - Option.some({ workspaceRoot: "/tmp/provider-turn-start-project" } as never), - ), - }), Layer.mock(ProjectionStore.ProjectionStoreV2)({ getTurnStartContext: () => { projectionReadCount += 1; @@ -128,6 +116,7 @@ it("does not commit running state when inherited background routing cannot be re Layer.mock(ProviderAuthService)({ tryHandlePromptCommand: () => Effect.succeed(false) }), Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }), Layer.mock(RuntimePolicy.RuntimePolicyV2)({}), + Layer.mock(WorktreeRevivalService.WorktreeRevivalService)({}), ), ), ); @@ -139,12 +128,6 @@ it("does not commit running state when inherited background routing cannot be re expect(error._tag).toBe("ProviderTurnStartError"); expect(projectionReadCount).toBe(2); - expect(pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-turn-start-project" }); - expect(createWorktree).toHaveBeenCalledWith({ - cwd: "/tmp/provider-turn-start-project", - refName: "feature/restore", - path: "/tmp/missing-provider-turn-start-worktree", - }); expect(writeIfRunCurrent).not.toHaveBeenCalled(); expect(startRootRun).not.toHaveBeenCalled(); }).pipe(Effect.provide(layer), Effect.runPromise); @@ -455,9 +438,7 @@ function makeLocalCommandHarness(input: { }), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, - FileSystem.layerNoop({}), - Layer.mock(GitWorkflow.GitWorkflowService)({}), - Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(WorktreeRevivalService.WorktreeRevivalService)({}), Layer.mock(ProjectionStore.ProjectionStoreV2)({ getTurnStartContext: () => Effect.succeed({ @@ -800,3 +781,272 @@ for (const previousMessages of [[], ["/compact", " /COMPACT "]]) { }), ); } + +/** + * A worktree thread whose run is starting. `setGeneration` and `setRunStatus` + * let one layer instance observe several starts, since the per-runtime + * worktree generation lives in that instance. + */ +function makeWorktreeTurnStartFixture(input: { + readonly revival: "revived" | "unchanged" | "failed"; + readonly revivalGate?: Effect.Effect; + /** Whether `get` reports the session as already open. */ + readonly liveSession?: boolean; + /** Whether the adapter shares one session across provider threads. */ + readonly sharedSession?: boolean; +}) { + const threadId = ThreadId.make(`thread_worktree_turn_start_${input.revival}`); + const runId = RunId.make(`run_worktree_turn_start_${input.revival}`); + const attemptId = RunAttemptId.make(`attempt_worktree_turn_start_${input.revival}`); + const rootNodeId = NodeId.make(`node_worktree_turn_start_${input.revival}`); + const providerThreadId = ProviderThreadId.make( + `provider_thread_worktree_turn_start_${input.revival}`, + ); + const providerSessionId = ProviderSessionId.make( + `provider_session_worktree_turn_start_${input.revival}`, + ); + const messageId = MessageId.make(`message_worktree_turn_start_${input.revival}`); + const checkpointScopeId = CheckpointScopeId.make( + `checkpoint_scope_worktree_turn_start_${input.revival}`, + ); + const providerInstanceId = ProviderInstanceId.make(`provider_instance_${input.revival}`); + const order: string[] = []; + const providerThread = { + id: providerThreadId, + providerSessionId, + providerInstanceId, + nativeThreadRef: null, + handoffIds: [], + forkedFrom: null, + appThreadId: threadId, + }; + const projection = { + thread: { + id: threadId, + projectId: ProjectId.make(`project_worktree_turn_start_${input.revival}`), + branch: "feature/revival", + worktreePath: "/tmp/t3-worktrees/feature-revival", + }, + runs: [ + { + id: runId, + status: "starting", + rootNodeId, + activeAttemptId: attemptId, + providerThreadId, + userMessageId: messageId, + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "test-model" }, + ordinal: 1, + }, + ], + nodes: [{ id: rootNodeId, checkpointScopeId }], + attempts: [{ id: attemptId, providerTurnId: null }], + providerThreads: [providerThread], + providerSessions: [{ id: providerSessionId }], + providerTurns: [], + messages: [ + { + id: messageId, + text: "continue", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + ], + checkpointScopes: [{ id: checkpointScopeId }], + contextHandoffs: [], + contextTransfers: [], + turnItems: [], + subagents: [], + } as unknown as OrchestrationV2ThreadProjection; + let runStatus: OrchestrationV2Run["status"] = "starting"; + let generation = 0; + const currentProjection = (): OrchestrationV2ThreadProjection => ({ + ...projection, + runs: projection.runs.map((candidate) => + candidate.id === runId ? { ...candidate, status: runStatus } : candidate, + ), + }); + const session = { + driver: "claudeAgent", + instanceId: providerInstanceId, + providerSessionId, + providerSession: { + id: providerSessionId, + capabilities: { + sessions: { supportsMultipleProviderThreadsPerSession: input.sharedSession === true }, + }, + }, + ensureThread: () => Effect.succeed(providerThread), + resumeThread: () => Effect.succeed(providerThread), + forkThread: () => Effect.succeed(providerThread), + } as unknown as ProviderAdapterV2SessionRuntime; + const open = vi.fn(() => + Effect.sync(() => { + order.push("open"); + return session; + }), + ); + const close = vi.fn(() => + Effect.sync(() => { + order.push("close"); + }), + ); + const get = vi.fn(() => + Effect.succeed( + input.liveSession === true + ? Option.some(session) + : Option.none(), + ), + ); + const reviveForThread = vi.fn(() => + Effect.sync(() => { + order.push("revive"); + }).pipe( + Effect.andThen(input.revivalGate ?? Effect.void), + Effect.andThen( + input.revival === "failed" + ? Effect.fail( + new WorktreeMutationError({ + operation: "revive", + stage: "missing_branch", + branch: "feature/revival", + }), + ) + : Effect.sync(() => ({ revived: input.revival === "revived", generation })), + ), + ), + ); + const startRootRun = vi.fn(() => + Effect.sync(() => { + order.push("start-root-run"); + }), + ); + const layer = ProviderTurnStart.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), + Layer.mock(EventSink.EventSinkV2)({ + writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] } as never), + }), + IdAllocator.layer, + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getTurnStartContext: () => + Effect.sync(() => ({ ...currentProjection(), hasConversation: true })), + getRuntimeRecoveryProjection: () => Effect.sync(currentProjection), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open, close, get }), + Layer.mock(ProviderAuthService)({}), + Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }), + Layer.mock(RuntimePolicy.RuntimePolicyV2)({ + resolve: () => + Effect.succeed({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/tmp/t3-worktrees/feature-revival", + }), + }), + Layer.mock(WorktreeRevivalService.WorktreeRevivalService)({ reviveForThread }), + ), + ), + ); + return { + layer, + order, + open, + close, + startRootRun, + start: Effect.flatMap(ProviderTurnStart.ProviderTurnStartServiceV2, (service) => + service.start({ threadId, runId }), + ), + setRunStatus: (status: OrchestrationV2Run["status"]) => { + runStatus = status; + }, + setGeneration: (next: number) => { + generation = next; + }, + }; +} + +effectIt.effect("restarts a per-thread provider session after reviving its worktree", () => + Effect.gen(function* () { + const fixture = makeWorktreeTurnStartFixture({ revival: "revived", liveSession: true }); + + yield* fixture.start.pipe(Effect.provide(fixture.layer)); + + expect(fixture.order).toEqual(["revive", "close", "open", "start-root-run"]); + }), +); + +effectIt.effect("keeps a shared provider session open after reviving a worktree", () => + Effect.gen(function* () { + const fixture = makeWorktreeTurnStartFixture({ + revival: "revived", + liveSession: true, + sharedSession: true, + }); + + yield* fixture.start.pipe(Effect.provide(fixture.layer)); + + expect(fixture.order).toEqual(["revive", "open", "start-root-run"]); + expect(fixture.close).not.toHaveBeenCalled(); + }), +); + +effectIt.effect("restarts a live session after another thread recreated its worktree", () => + Effect.gen(function* () { + const fixture = makeWorktreeTurnStartFixture({ revival: "unchanged", liveSession: true }); + + yield* Effect.gen(function* () { + yield* fixture.start; + fixture.setGeneration(1); + yield* fixture.start; + }).pipe(Effect.provide(fixture.layer)); + + expect(fixture.order).toEqual([ + "revive", + "open", + "start-root-run", + "revive", + "close", + "open", + "start-root-run", + ]); + }), +); + +effectIt.effect("starts the provider turn when worktree revival fails", () => + Effect.gen(function* () { + const fixture = makeWorktreeTurnStartFixture({ revival: "failed", liveSession: true }); + + yield* fixture.start.pipe(Effect.provide(fixture.layer)); + + expect(fixture.order).toEqual(["revive", "open", "start-root-run"]); + expect(fixture.close).not.toHaveBeenCalled(); + }), +); + +effectIt.effect("does not open a provider session for a run superseded during revival", () => + Effect.gen(function* () { + const revivalStarted = yield* Deferred.make(); + const releaseRevival = yield* Deferred.make(); + const fixture = makeWorktreeTurnStartFixture({ + revival: "revived", + liveSession: true, + revivalGate: Deferred.succeed(revivalStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRevival)), + ), + }); + + const start = yield* fixture.start.pipe(Effect.provide(fixture.layer), Effect.forkChild); + yield* Deferred.await(revivalStarted); + fixture.setRunStatus("cancelled"); + yield* Deferred.succeed(releaseRevival, undefined); + yield* Fiber.join(start); + + expect(fixture.order).toEqual(["revive"]); + expect(fixture.close).not.toHaveBeenCalled(); + expect(fixture.open).not.toHaveBeenCalled(); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts deleted file mode 100644 index 6517d05b4b85..000000000000 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.testkit.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as GitWorkflow from "../git/GitWorkflowService.ts"; -import * as ProjectService from "../project/ProjectService.ts"; - -export const worktreeRepairDependenciesTestLayer = Layer.merge( - Layer.mock(GitWorkflow.GitWorkflowService)({ - pruneWorktrees: () => Effect.void, - createWorktree: () => Effect.succeed({} as never), - }), - Layer.mock(ProjectService.ProjectService)({ - getById: () => Effect.succeed(Option.none()), - }), -); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index cfe847e4ccc3..bf10d3e2a827 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -15,13 +15,10 @@ import * as Context from "effect/Context"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { GitWorkflowService } from "../git/GitWorkflowService.ts"; -import { ProjectService } from "../project/ProjectService.ts"; import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts"; import { EventSinkV2 } from "./EventSink.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; @@ -56,6 +53,7 @@ import { pendingRestartCancelledBackgroundWork, restartCancelledBackgroundWorkNote, } from "./RestartBackgroundNote.ts"; +import { WorktreeRevivalService } from "../vcs/WorktreeRevivalService.ts"; export class ProviderTurnStartError extends Schema.TaggedError()( "ProviderTurnStartError", @@ -91,28 +89,31 @@ export const layer: Layer.Layer< | EventSinkV2 | ContextHandoffService.ContextHandoffServiceV2 | IdAllocatorV2 - | FileSystem.FileSystem - | GitWorkflowService - | ProjectService | ProviderAuthService | ProjectionStoreV2 | ProviderSessionManagerV2 | RunExecutionServiceV2 | RuntimePolicyV2 + | WorktreeRevivalService > = Layer.effect( ProviderTurnStartServiceV2, Effect.gen(function* () { const eventSink = yield* EventSinkV2; const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2; const idAllocator = yield* IdAllocatorV2; - const fileSystem = yield* FileSystem.FileSystem; - const gitWorkflow = yield* GitWorkflowService; - const projects = yield* ProjectService; const providerAuth = yield* ProviderAuthService; const projectionStore = yield* ProjectionStoreV2; const providerSessions = yield* ProviderSessionManagerV2; const runExecution = yield* RunExecutionServiceV2; const runtimePolicy = yield* RuntimePolicyV2; + const worktreeRevival = yield* WorktreeRevivalService; + // The worktree generation each live provider runtime was opened against. A + // per-thread runtime whose worktree was recreated since then still holds the + // deleted directory as its cwd and must restart before its next turn. + const worktreeGenerationBySession = new WeakMap< + ProviderAdapterV2SessionRuntime, + { readonly path: string; readonly generation: number } + >(); // These callbacks outlive startup while a run drains background work. Build // them outside start's scope so they cannot retain its full thread history. @@ -456,43 +457,6 @@ export const layer: Layer.Layer< return; } } - const { worktreePath, branch } = projection.thread; - if (worktreePath !== null && branch !== null) { - const exists = yield* fileSystem - .exists(worktreePath) - .pipe(Effect.orElseSucceed(() => true)); - if (!exists) { - const project = yield* projects.getById(projection.thread.projectId).pipe( - Effect.map(Option.getOrUndefined), - Effect.orElseSucceed(() => undefined), - ); - if (project !== undefined) { - yield* Effect.logWarning("provider turn start recreating missing worktree", { - threadId: projection.thread.id, - worktreePath, - branch, - }); - yield* gitWorkflow.pruneWorktrees({ cwd: project.workspaceRoot }).pipe( - Effect.andThen( - gitWorkflow.createWorktree({ - cwd: project.workspaceRoot, - refName: branch, - path: worktreePath, - }), - ), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("provider turn start failed to recreate worktree", { - threadId: projection.thread.id, - worktreePath, - cause: Cause.pretty(cause), - }), - ), - ); - } - } - } const selectInheritedBackgroundItems = ( current: ProjectionRuntimeRecoveryState, ): ReturnType => @@ -521,6 +485,56 @@ export const layer: Layer.Layer< thread: projection.thread, modelSelection: run.modelSelection, }); + let observedWorktreeGeneration: + | { readonly path: string; readonly generation: number } + | undefined; + if (projection.thread.worktreePath !== null && projection.thread.branch !== null) { + const worktreePath = projection.thread.worktreePath; + // A failed revival must not fail this effect: that only leaves the run + // in "starting". Proceeding lets the provider report the missing + // directory as a visible turn error the user can act on. + const revival = yield* worktreeRevival + .reviveForThread({ + threadId: projection.thread.id, + projectId: projection.thread.projectId, + worktreePath, + branch: projection.thread.branch, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("provider turn start could not revive the worktree", { + threadId: projection.thread.id, + worktreePath, + error, + }).pipe(Effect.as(undefined)), + ), + ); + // Revival can recreate the worktree and run project setup, so the run + // may have been cancelled or superseded while it ran. + if (!(yield* isCurrentAttemptInStatus("starting"))) return; + if (revival !== undefined) { + observedWorktreeGeneration = { path: worktreePath, generation: revival.generation }; + const liveSession = yield* providerSessions.get(providerSessionId); + const previousWorktreeGeneration = Option.isSome(liveSession) + ? worktreeGenerationBySession.get(liveSession.value) + : undefined; + // Only a per-thread provider process holds the worktree as its cwd. + // A session shared across threads (Codex) passes cwd per native + // thread, and closing it would fail every sibling's running turn. + const holdsWorktreeAsCwd = + Option.isSome(liveSession) && + !liveSession.value.providerSession.capabilities.sessions + .supportsMultipleProviderThreadsPerSession; + const worktreeChanged = + revival.revived || + (previousWorktreeGeneration !== undefined && + (previousWorktreeGeneration.path !== worktreePath || + previousWorktreeGeneration.generation !== revival.generation)); + if (holdsWorktreeAsCwd && worktreeChanged) { + yield* providerSessions.close(providerSessionId); + } + } + } const existingSessionProjection = projection.providerSessions.find( (candidate) => candidate.id === providerSessionId, ); @@ -587,6 +601,9 @@ export const layer: Layer.Layer< return; } const session = sessionResult.success; + if (observedWorktreeGeneration !== undefined) { + worktreeGenerationBySession.set(session, observedWorktreeGeneration); + } // Only the provider's own thread load fails the run on the last attempt; // store, id and handoff failures around it keep their typed errors. const loadFromProvider = ( diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 7a4d8145de94..1f7077b1cee7 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -49,6 +49,7 @@ import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import { WorkspacePaths } from "../workspace/WorkspacePaths.ts"; +import { layerNoop as worktreeRevivalTestLayer } from "../vcs/WorktreeRevivalService.testkit.ts"; import { LegacyV1ThreadImporter, LegacyV1ThreadImportError, @@ -219,6 +220,7 @@ const TestLayer = Layer.mergeAll( effectOutboxLayer, threadCommandExecutorLayer, ).pipe( + Layer.provide(worktreeRevivalTestLayer), Layer.provide(mcpSessionRegistryTestLayer), Layer.provide(SqlitePersistenceMemory), Layer.provide(CheckpointStoreTestLayer), @@ -231,6 +233,7 @@ const TestLayer = Layer.mergeAll( ); const LegacyImportTestLayer = OrchestrationV2LayerLive.pipe( + Layer.provide(worktreeRevivalTestLayer), Layer.provide(mcpSessionRegistryTestLayer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provide(CheckpointStoreTestLayer), @@ -248,6 +251,7 @@ const ProjectDeletionTestLayer = Layer.mergeAll( OrchestrationV2EventSinkLayerLive, threadCommandExecutorLayer, ).pipe( + Layer.provide(worktreeRevivalTestLayer), Layer.provide( Layer.mock(ProjectEnrichmentService)({ peek: () => @@ -387,6 +391,7 @@ const SharedApplicationDataPlaneTestLayer = Layer.mergeAll( OrchestrationV2EventSinkLayerLive, OrchestrationEventInfrastructureLayerLive, ).pipe( + Layer.provide(worktreeRevivalTestLayer), Layer.provide( Layer.mock(WorkspacePaths)({ normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index eb2fc5e373f5..393ab9d7a364 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -19,6 +19,7 @@ import { ThreadManagementService } from "../ThreadManagementService.ts"; import { layer as mcpSessionRegistryTestLayer } from "../../mcp/McpSessionRegistry.testkit.ts"; import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { layerNoop as worktreeRevivalTestLayer } from "../../vcs/WorktreeRevivalService.testkit.ts"; import { layer as checkpointCaptureServiceLayer } from "../CheckpointCaptureService.ts"; import { layer as checkpointServiceLayer } from "../CheckpointService.ts"; import { layer as checkpointRollbackServiceLayer } from "../CheckpointRollbackService.ts"; @@ -49,7 +50,6 @@ import { layerWithOptions as providerSessionManagerLayerWithOptions } from "../P import { layer as providerSwitchServiceLayer } from "../ProviderSwitchService.ts"; import { layer as providerTurnControlServiceLayer } from "../ProviderTurnControlService.ts"; import { layer as providerTurnStartServiceLayer } from "../ProviderTurnStartService.ts"; -import { worktreeRepairDependenciesTestLayer } from "../ProviderTurnStartService.testkit.ts"; import { layer as runExecutionServiceLayer } from "../RunExecutionService.ts"; import { layer as runFinalizationServiceLayer } from "../RunFinalizationService.ts"; import { ThreadTitleRegenerationService } from "../ThreadTitleRegenerationService.ts"; @@ -374,6 +374,7 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( Layer.mock(ProviderAuthService)({ tryHandlePromptCommand: () => Effect.succeed(false) }), runExecutionServiceProvided, runtimeLayer, + worktreeRevivalTestLayer, ), ), ); @@ -465,7 +466,7 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( effectWorkerProvided, eventSinkProvided, continuationWorkerProvided, - ).pipe(Layer.provide(worktreeRepairDependenciesTestLayer), Layer.provide(NodeServices.layer)); + ).pipe(Layer.provide(NodeServices.layer)); // Build the daemon from the exact worker instance exposed alongside the // orchestrator. Keeping this acquisition in the replay layer makes the diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d3afebcc3611..b0058482778c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -100,6 +100,11 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; +import * as WorktreeDeletionCleanup from "./vcs/WorktreeDeletionCleanup.ts"; +import * as WorktreeLifecycle from "./vcs/WorktreeLifecycle.ts"; +import * as WorktreeReaper from "./vcs/WorktreeReaper.ts"; +import * as WorktreeRevival from "./vcs/WorktreeRevivalService.ts"; +import * as WorktreeService from "./vcs/WorktreeService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; @@ -151,6 +156,7 @@ import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; import { OrchestrationEventInfrastructureLayerLive, + OrchestrationV2EventSinkLayerLive, OrchestrationV2ProductionLayerLive, ProjectServiceLayerLive, ProjectSetupScriptRunnerLayerLive, @@ -343,9 +349,12 @@ const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitVcsDriver.layer), ); +const WorktreeLifecycleLayerLive = WorktreeLifecycle.layer; + const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( Layer.provideMerge(VcsDriverRegistryLayerLive), Layer.provideMerge(GitLayerLive), + Layer.provideMerge(WorktreeLifecycleLayerLive), ); const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.layer.pipe( @@ -442,6 +451,13 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll( ), ); +const WorktreeRevivalLayerLive = WorktreeRevival.layer.pipe( + Layer.provideMerge(ProjectServiceLayerLive), + Layer.provideMerge(ProjectSetupScriptRunnerLayerLive), + Layer.provideMerge(GitLayerLive), + Layer.provideMerge(WorktreeLifecycleLayerLive), +); + const OrchestrationV2RuntimeLayerLive = OrchestrationV2ProductionLayerLive.pipe( Layer.provide(ProviderEventIngestor.analyticsLive), Layer.provide(CheckpointStoreLayerLive), @@ -454,6 +470,7 @@ const OrchestrationV2RuntimeLayerLive = OrchestrationV2ProductionLayerLive.pipe( Layer.provide(ProjectServiceLayerLive), ), ), + Layer.provideMerge(WorktreeRevivalLayerLive), ); const OrchestrationApplicationLayerLive = CheckpointDiffQuery.layer.pipe( @@ -461,6 +478,25 @@ const OrchestrationApplicationLayerLive = CheckpointDiffQuery.layer.pipe( Layer.provideMerge(OrchestrationV2RuntimeLayerLive), ); +const WorktreeManagementLayerLive = WorktreeService.layer.pipe( + Layer.provideMerge(OrchestrationApplicationLayerLive), + Layer.provideMerge(WorktreeLifecycleLayerLive), + Layer.provideMerge(GitLayerLive), + Layer.provideMerge(VcsLayerLive), +); + +const WorktreeReaperLayerLive = WorktreeReaper.layer.pipe( + Layer.provideMerge(WorktreeManagementLayerLive), + Layer.provideMerge(ServerSettingsLayerLive), +); + +const WorktreeDeletionCleanupLayerLive = WorktreeDeletionCleanup.layer.pipe( + Layer.provideMerge(OrchestrationV2EventSinkLayerLive), + Layer.provideMerge(OrchestrationApplicationLayerLive), + Layer.provideMerge(WorktreeManagementLayerLive), + Layer.provideMerge(ServerSettingsLayerLive), +); + // Automatic thread settlement (#8600): a server-owned sweep evaluates // inactivity and merged pull requests, then settles through the orchestrator // so every client sees the same shelf. @@ -536,6 +572,9 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll( Layer.provideMerge(Layer.mergeAll(SourceControlProviderRegistryLayerLive, GitHubCli.layer)), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), + Layer.provideMerge(WorktreeManagementLayerLive), + Layer.provideMerge(WorktreeDeletionCleanupLayerLive), + Layer.provideMerge(WorktreeReaperLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive, DeviceLayerLive)), Layer.provideMerge(PersistenceLayerLive), // Both read a user-owned file out of the state directory and stream changes diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index e3f92058e70c..5480288869d7 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -42,6 +42,8 @@ import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as WorktreeDeletionCleanup from "./vcs/WorktreeDeletionCleanup.ts"; +import * as WorktreeReaper from "./vcs/WorktreeReaper.ts"; import { forkParked, forkParkedFiber } from "./serverActivation.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -414,6 +416,8 @@ const make = (options?: StartupOptions) => const legacyV1ThreadImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const providerRuntimeRecovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const worktreeDeletionCleanup = yield* WorktreeDeletionCleanup.WorktreeDeletionCleanup; + const worktreeReaper = yield* WorktreeReaper.WorktreeReaper; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -515,7 +519,10 @@ const make = (options?: StartupOptions) => runWorker: EffectWorker.runDaemon, startRelay: agentAwarenessRelay.start(), workerFiberRef: effectWorkerFiber, - }), + }).pipe( + Effect.andThen(worktreeDeletionCleanup.start()), + Effect.andThen(forkParked(worktreeReaper.run)), + ), ), autoBootstrap: (serverConfig.autoBootstrapProjectFromCwd ? runStartupPhase( diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index d7e511afa8c9..cd1b0bc4a099 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -13,6 +13,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, VcsProcessExitError, + VcsProcessOutputLimitError, type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsCreateRefInput, @@ -26,6 +27,7 @@ import { type VcsInitInput, type VcsListRefsInput, type VcsListRefsResult, + type VcsWorkspace, type VcsPullResult, type VcsRemoveWorktreeInput, type VcsStatusInput, @@ -38,8 +40,11 @@ import { splitNullSeparatedGitStdoutPaths, } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; +import { parseGitWorktreeListPorcelain } from "./GitWorktree.ts"; import * as VcsProcess from "./VcsProcess.ts"; +const WORKTREE_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; + export interface ExecuteGitInput { readonly operation: string; readonly cwd: string; @@ -310,6 +315,9 @@ export class GitVcsDriver extends Context.Service< cwd: string, options?: GitRemoteStatusOptions, ) => Effect.Effect; + readonly listWorkspaces: ( + cwd: string, + ) => Effect.Effect, GitCommandError>; readonly prepareCommitContext: ( cwd: string, filePaths?: readonly string[], @@ -632,6 +640,39 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), ); + const listWorkspaces: VcsDriver.VcsDriver["Service"]["listWorkspaces"] = Effect.fn( + "GitVcsDriver.listWorkspaces", + )(function* (cwd) { + const args = ["worktree", "list", "--porcelain", "-z"] as const; + const result = yield* gitCommand(vcsProcess, "GitVcsDriver.listWorkspaces", cwd, args, { + timeoutMs: 30_000, + maxOutputBytes: WORKTREE_LIST_MAX_OUTPUT_BYTES, + }); + if (result.exitCode !== 0) { + return yield* new VcsProcessExitError({ + operation: "GitVcsDriver.listWorkspaces", + command: "git worktree list", + cwd, + exitCode: result.exitCode, + detail: "git worktree list failed", + stderrLength: result.stderr.length, + }); + } + if (result.stdoutTruncated) { + return yield* new VcsProcessOutputLimitError({ + operation: "GitVcsDriver.listWorkspaces", + command: "git", + cwd, + // gitCommand prepends `-C ` to the invocation. + argumentCount: args.length + 2, + stream: "stdout", + maxBytes: WORKTREE_LIST_MAX_OUTPUT_BYTES, + observedBytes: result.stdout.length, + }); + } + return parseGitWorktreeListPorcelain(result.stdout) satisfies ReadonlyArray; + }); + const listRemotes: VcsDriver.VcsDriver["Service"]["listRemotes"] = Effect.fn("listRemotes")( function* (cwd) { const result = yield* gitCommand( @@ -1234,6 +1275,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( detectRepository, isInsideWorkTree, listWorkspaceFiles, + listWorkspaces, listRemotes, filterIgnoredPaths, initRepository, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7850c4a125dd..361e450adb58 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -339,6 +339,35 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () }).pipe(Effect.provide(layer)); }); +it.effect("rejects truncated core worktree listings", () => { + const outputLimit = 16 * 1024 * 1024; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + makeSuccessfulHandle( + `worktree /repo\0HEAD deadbeef\0branch refs/heads/main\0\0${"x".repeat(outputLimit)}`, + ), + ), + ); + const nodeServicesLayer = Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const layer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(nodeServicesLayer), + ); + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const error = yield* driver.listWorkspaces("/repo").pipe(Effect.flip); + + assert.instanceOf(error, GitCommandError); + assert.equal(error.operation, "GitVcsDriver.listWorkspaces"); + assert.equal(error.cwd, "/repo"); + assert.include(error.detail, "output exceeded"); + }).pipe(Effect.provide(layer)); +}); + it.effect("invalidates origin remote cache when a driver mutation adds origin", () => Effect.gen(function* () { const driver = yield* GitVcsDriver.GitVcsDriver; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3068e3640872..40b7598a53a6 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -41,6 +41,7 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import { ServerConfig } from "../config.ts"; +import { parseGitWorktreeBranchPaths, parseGitWorktreeListPorcelain } from "./GitWorktree.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const gitProcesses = Semaphore.makeUnsafe(8); @@ -62,6 +63,7 @@ const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; // prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would // otherwise leak into the patch and leave every parsed file unnamed. export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; +const WORKTREE_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -271,37 +273,6 @@ function paginateBranches(input: { }; } -function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { - const worktreePaths = new Map(); - let currentPath: string | null = null; - let currentBranch: string | null = null; - let currentPrunable = false; - - const flush = () => { - if (currentPath !== null && currentBranch !== null && !currentPrunable) { - worktreePaths.set(currentBranch, currentPath); - } - currentPath = null; - currentBranch = null; - currentPrunable = false; - }; - - for (const field of stdout.split("\0")) { - if (field === "") { - flush(); - } else if (field.startsWith("worktree ")) { - currentPath = field.slice("worktree ".length); - } else if (field.startsWith("branch refs/heads/")) { - currentBranch = field.slice("branch refs/heads/".length); - } else if (field === "prunable" || field.startsWith("prunable ")) { - currentPrunable = true; - } - } - flush(); - - return worktreePaths; -} - function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { const parts = input.split("\0"); if (parts.length === 0) return []; @@ -1031,6 +1002,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, }); + const listWorkspaces: GitVcsDriver.GitVcsDriver["Service"]["listWorkspaces"] = Effect.fn( + "GitVcsDriver.listWorkspaces", + )(function* (cwd) { + const args = ["worktree", "list", "--porcelain", "-z"] as const; + const result = yield* executeGit("GitVcsDriver.listWorkspaces", cwd, args, { + timeoutMs: 30_000, + maxOutputBytes: WORKTREE_LIST_MAX_OUTPUT_BYTES, + }); + if (result.stdoutTruncated) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.listWorkspaces", cwd, args }), + detail: `Git worktree output exceeded ${WORKTREE_LIST_MAX_OUTPUT_BYTES} bytes.`, + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); + } + return parseGitWorktreeListPorcelain(result.stdout); + }); + const runGit = ( operation: string, cwd: string, @@ -2834,7 +2824,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : null; const parsedWorktreeEntries = worktreeListResult.exitCode === 0 - ? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map( + ? [...parseGitWorktreeBranchPaths(worktreeListResult.stdout)].map( ([branchName, worktreePath]) => [branchName, path.normalize(path.resolve(worktreePath))] as const, ) @@ -3697,6 +3687,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusDetails, statusDetailsLocal, statusDetailsRemote, + listWorkspaces, prepareCommitContext, commit: (cwd, subject, body, options) => withListRefsInvalidation(cwd, commit(cwd, subject, body, options)), diff --git a/apps/server/src/vcs/GitVcsDriverWorktree.test.ts b/apps/server/src/vcs/GitVcsDriverWorktree.test.ts new file mode 100644 index 000000000000..c6b0cf0b234b --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriverWorktree.test.ts @@ -0,0 +1,100 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { VcsProcessOutputLimitError } from "@t3tools/contracts"; +import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as VcsProcess from "./VcsProcess.ts"; + +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-worktree-driver-test-", +}); +const serverConfigLiveLayer = serverConfigLayer.pipe(Layer.provide(NodeServices.layer)); +const testLayer = GitVcsDriver.layer.pipe( + Layer.provide(serverConfigLiveLayer), + Layer.provideMerge(NodeServices.layer), +); + +it.effect("lists registered Git workspaces including the primary checkout", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-git-worktree-repo-" }); + const worktreePath = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-git-worktree-root-" }), + "feature", + ); + + const run = (args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriverWorktreeTest.git", + cwd: repositoryRoot, + args, + }); + + yield* run(["init", "-b", "main"]); + yield* run(["config", "user.email", "test@example.com"]); + yield* run(["config", "user.name", "T3 Test"]); + yield* fs.writeFileString(path.join(repositoryRoot, "README.md"), "hello\n"); + yield* run(["add", "README.md"]); + yield* run(["commit", "-m", "initial"]); + yield* driver.createWorktree({ + cwd: repositoryRoot, + refName: "main", + newRefName: "feature/worktree", + path: worktreePath, + }); + + const workspaces = yield* driver.listWorkspaces(repositoryRoot); + assert.deepInclude(workspaces, { + path: repositoryRoot, + refName: "main", + prunable: false, + }); + assert.deepInclude(workspaces, { + path: worktreePath, + refName: "feature/worktree", + prunable: false, + }); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("rejects truncated worktree listings instead of parsing partial records", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const error = yield* driver.listWorkspaces("/repo").pipe(Effect.flip); + + assert.instanceOf(error, VcsProcessOutputLimitError); + assert.equal(error.operation, "GitVcsDriver.listWorkspaces"); + assert.equal(error.command, "git"); + assert.equal(error.cwd, "/repo"); + assert.equal(error.stream, "stdout"); + assert.equal(error.maxBytes, 16 * 1024 * 1024); + assert.equal( + error.observedBytes, + "worktree /repo\0HEAD deadbeef\0branch refs/heads/main\0\0worktree /partial".length, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "worktree /repo\0HEAD deadbeef\0branch refs/heads/main\0\0worktree /partial", + stderr: "", + stdoutTruncated: true, + stderrTruncated: false, + }), + }), + ), + ), + ), +); diff --git a/apps/server/src/vcs/GitWorktree.test.ts b/apps/server/src/vcs/GitWorktree.test.ts new file mode 100644 index 000000000000..bb45f9ae18ad --- /dev/null +++ b/apps/server/src/vcs/GitWorktree.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseGitWorktreeBranchPaths, parseGitWorktreeListPorcelain } from "./GitWorktree.ts"; + +describe("Git worktree porcelain parsing", () => { + it("parses NUL-terminated porcelain and filters detached or prunable branch paths", () => { + const stdout = [ + "worktree /repo/main", + "HEAD 1111111", + "branch refs/heads/main", + "", + "worktree /repo/line\nbreak", + "HEAD 2222222", + "branch refs/heads/feature/demo", + "", + "worktree /repo/detached", + "HEAD 3333333", + "detached", + "", + "worktree /repo/stale", + "HEAD 4444444", + "branch refs/heads/stale", + "prunable gitdir file points to non-existent location", + "", + ].join("\0"); + + expect(parseGitWorktreeListPorcelain(stdout)).toEqual([ + { path: "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/repo/main", refName: "main", prunable: false }, + { path: "/repo/line\nbreak", refName: "feature/demo", prunable: false }, + { path: "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/repo/detached", refName: null, prunable: false }, + { path: "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/repo/stale", refName: "stale", prunable: true }, + ]); + expect(parseGitWorktreeBranchPaths(stdout)).toEqual( + new Map([ + ["main", "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/repo/main"], + ["feature/demo", "/repo/line\nbreak"], + ]), + ); + }); +}); diff --git a/apps/server/src/vcs/GitWorktree.ts b/apps/server/src/vcs/GitWorktree.ts new file mode 100644 index 000000000000..31a3258f630d --- /dev/null +++ b/apps/server/src/vcs/GitWorktree.ts @@ -0,0 +1,54 @@ +import type { VcsWorkspace } from "@t3tools/contracts"; + +/** + * Parse `git worktree list --porcelain -z`. Fields are NUL-terminated and + * records end with an extra NUL, so paths may contain newlines. `-z` needs + * Git 2.36, which listRefs already requires. + */ +export function parseGitWorktreeListPorcelain(stdout: string): VcsWorkspace[] { + const entries: VcsWorkspace[] = []; + let currentPath: string | null = null; + let currentRefName: string | null = null; + let currentPrunable = false; + + const flush = () => { + if (currentPath !== null) { + entries.push({ + path: currentPath, + refName: currentRefName, + prunable: currentPrunable, + }); + } + currentPath = null; + currentRefName = null; + currentPrunable = false; + }; + + for (const field of stdout.split("\0")) { + if (field === "") { + flush(); + } else if (field.startsWith("worktree ")) { + // Be tolerant of malformed output containing a new record without the + // expected empty separator. + if (currentPath !== null) flush(); + currentPath = field.slice("worktree ".length); + } else if (field.startsWith("branch refs/heads/")) { + currentRefName = field.slice("branch refs/heads/".length); + } else if (field === "prunable" || field.startsWith("prunable ")) { + currentPrunable = true; + } + } + flush(); + + return entries; +} + +export function parseGitWorktreeBranchPaths(stdout: string): ReadonlyMap { + const worktreePaths = new Map(); + for (const entry of parseGitWorktreeListPorcelain(stdout)) { + if (entry.refName !== null && !entry.prunable) { + worktreePaths.set(entry.refName, entry.path); + } + } + return worktreePaths; +} diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index a8680c9a54be..ab1868435d8d 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -7,6 +7,7 @@ import type { VcsInitInput, VcsListRemotesResult, VcsListWorkspaceFilesResult, + VcsWorkspace, ReviewDiffPreviewInput, ReviewDiffPreviewResult, VcsRepositoryIdentity, @@ -68,6 +69,7 @@ export class VcsDriver extends Context.Service< readonly listWorkspaceFiles: ( cwd: string, ) => Effect.Effect; + readonly listWorkspaces: (cwd: string) => Effect.Effect, VcsError>; readonly listRemotes: (cwd: string) => Effect.Effect; readonly filterIgnoredPaths: ( cwd: string, diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts index 0a28f9c9b2ce..70e9d14be2ef 100644 --- a/apps/server/src/vcs/VcsProvisioningService.test.ts +++ b/apps/server/src/vcs/VcsProvisioningService.test.ts @@ -41,6 +41,7 @@ function makeDriver(calls: string[]): VcsDriver.VcsDriver["Service"] { expiresAt: Option.none(), }, }), + listWorkspaces: () => Effect.succeed([]), listRemotes: () => Effect.succeed({ remotes: [], diff --git a/apps/server/src/vcs/WorktreeDeletionCleanup.test.ts b/apps/server/src/vcs/WorktreeDeletionCleanup.test.ts new file mode 100644 index 000000000000..23565c4154ef --- /dev/null +++ b/apps/server/src/vcs/WorktreeDeletionCleanup.test.ts @@ -0,0 +1,259 @@ +import { assert, it } from "@effect/vitest"; +import type { OrchestrationV2DomainEvent, OrchestrationV2StoredEvent } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as EventSink from "../orchestration-v2/EventSink.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as WorktreeDeletionCleanup from "./WorktreeDeletionCleanup.ts"; +import * as WorktreeService from "./WorktreeService.ts"; + +const threadId = ThreadId.make("thread:worktree-deletion-cleanup"); + +const deletionEvent = (worktreePath: string | null) => + ({ + type: "thread.deleted", + threadId, + payload: { worktreePath }, + }) as unknown as OrchestrationV2DomainEvent; + +const storedDeletionEvent = (sequence: number, worktreePath: string | null) => + ({ + sequence, + commandId: null, + event: deletionEvent(worktreePath), + }) satisfies OrchestrationV2StoredEvent; + +interface TestEventSource { + readonly latestSequence: Effect.Effect; + readonly streamStoredEventsFrom: ( + afterSequence: number, + ) => Stream.Stream; +} + +const makeLayer = ( + pruneOrphanedWorktree: WorktreeService.WorktreeService["Service"]["pruneOrphanedWorktree"], + options: { + readonly deleteOrphanedImmediately: boolean; + readonly eventSource?: TestEventSource; + }, +) => { + const worktrees = Layer.succeed( + WorktreeService.WorktreeService, + WorktreeService.WorktreeService.of({ + listWorktrees: () => Effect.succeed({ worktrees: [] }), + pruneWorktrees: () => Effect.succeed({ removed: [], skipped: [] }), + pruneOrphanedWorktree, + }), + ); + const eventSource = options.eventSource ?? { + latestSequence: Effect.succeed(0), + streamStoredEventsFrom: () => Stream.empty, + }; + return WorktreeDeletionCleanup.layer.pipe( + Layer.provide(worktrees), + Layer.provide( + Layer.mock(EventSink.EventSinkV2)({ + latestSequence: () => eventSource.latestSequence, + stream: (input) => eventSource.streamStoredEventsFrom(input?.afterSequence ?? 0), + }), + ), + Layer.provide( + ServerSettings.layerTest({ + worktrees: { + autoPruneAfterDays: null, + deleteOrphanedImmediately: options.deleteOrphanedImmediately, + }, + }), + ), + ); +}; + +it.effect("prunes only non-null worktree paths while immediate orphan cleanup is enabled", () => + Effect.gen(function* () { + const pruned: string[] = []; + const prune = (path: string) => + Effect.sync(() => { + pruned.push(path); + return true; + }); + + yield* Effect.gen(function* () { + const cleanup = yield* WorktreeDeletionCleanup.WorktreeDeletionCleanup; + yield* cleanup.enqueue({ threadId, worktreePath: "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/worktrees/disabled" }); + yield* cleanup.drain; + }).pipe( + Effect.provide( + makeLayer(prune, { + deleteOrphanedImmediately: false, + }), + ), + ); + + yield* Effect.gen(function* () { + const cleanup = yield* WorktreeDeletionCleanup.WorktreeDeletionCleanup; + yield* cleanup.enqueue({ threadId, worktreePath: null }); + yield* cleanup.enqueue({ threadId, worktreePath: "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/worktrees/orphan" }); + yield* cleanup.drain; + }).pipe( + Effect.provide( + makeLayer(prune, { + deleteOrphanedImmediately: true, + }), + ), + ); + + assert.deepEqual(pruned, ["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/worktrees/orphan"]); + }), +); + +it.effect("preserves deletion cleanup interruption", () => + Effect.gen(function* () { + const exit = yield* Effect.interrupt.pipe( + WorktreeDeletionCleanup.__testing.recoverDeletionFailure({ + threadId, + worktreePath: "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/worktrees/interrupted", + }), + Effect.exit, + ); + + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.isTrue(Cause.hasInterruptsOnly(exit.cause)); + } + }), +); + +it.effect("resumes stored deletion events from the last consumed sequence after a failure", () => + Effect.gen(function* () { + const firstAttemptFailed = yield* Deferred.make(); + const recoveredPath = yield* Deferred.make(); + const attempts = yield* Ref.make(0); + const requestedAfterSequences = yield* Ref.make>([]); + const storedEvents = yield* Ref.make>([ + storedDeletionEvent(1, "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/worktrees/before-failure"), + ]); + const eventSource: TestEventSource = { + latestSequence: Effect.succeed(0), + streamStoredEventsFrom: (afterSequence) => + Stream.unwrap( + Effect.gen(function* () { + yield* Ref.update(requestedAfterSequences, (current) => [...current, afterSequence]); + const attempt = yield* Ref.getAndUpdate(attempts, (count) => count + 1); + const available = (yield* Ref.get(storedEvents)).filter( + (stored) => stored.sequence > afterSequence, + ); + return attempt === 0 + ? Stream.concat( + Stream.fromIterable(available), + Stream.fromEffect(Deferred.succeed(firstAttemptFailed, undefined)).pipe( + Stream.drain, + Stream.concat( + Stream.fail( + new EventSink.EventSinkStreamError({ + afterSequence, + cause: "simulated subscription failure", + }), + ), + ), + ), + ) + : Stream.fromIterable(available); + }), + ), + }; + const pruned: string[] = []; + const prune = (path: string) => + Effect.sync(() => pruned.push(path)).pipe( + Effect.andThen( + path === "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/worktrees/recovered" + ? Deferred.succeed(recoveredPath, path) + : Effect.succeed(false), + ), + Effect.as(true), + ); + const layer = makeLayer(prune, { + deleteOrphanedImmediately: true, + eventSource, + }); + + yield* Effect.gen(function* () { + const cleanup = yield* WorktreeDeletionCleanup.WorktreeDeletionCleanup; + yield* cleanup.start(); + yield* cleanup.start(); + yield* Deferred.await(firstAttemptFailed); + yield* Ref.set(storedEvents, [ + storedDeletionEvent(1, "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/worktrees/before-failure"), + storedDeletionEvent(2, "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/worktrees/recovered"), + ]); + yield* TestClock.adjust("1 second"); + assert.equal(yield* Deferred.await(recoveredPath), "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/worktrees/recovered"); + yield* cleanup.drain; + assert.equal(yield* Ref.get(attempts), 2); + assert.deepEqual(yield* Ref.get(requestedAfterSequences), [0, 1]); + assert.deepEqual(pruned, ["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/worktrees/before-failure", "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/worktrees/recovered"]); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("retries the initial sequence lookup instead of replaying history", () => + Effect.gen(function* () { + const sequenceAttempts = yield* Ref.make(0); + const requestedAfterSequences = yield* Ref.make>([]); + const observedPath = yield* Deferred.make(); + const pruned: string[] = []; + const prune = (path: string) => + Effect.sync(() => pruned.push(path)).pipe( + Effect.andThen(Deferred.succeed(observedPath, path)), + Effect.as(true), + ); + const eventSource: TestEventSource = { + latestSequence: Ref.getAndUpdate(sequenceAttempts, (count) => count + 1).pipe( + Effect.flatMap((attempt) => + attempt === 0 + ? Effect.fail( + new EventSink.EventSinkStreamError({ + afterSequence: 0, + cause: "simulated lookup failure", + }), + ) + : Effect.succeed(2), + ), + ), + streamStoredEventsFrom: (afterSequence) => + Stream.unwrap( + Ref.update(requestedAfterSequences, (current) => [...current, afterSequence]).pipe( + Effect.as( + Stream.fromIterable([ + storedDeletionEvent(1, "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/worktrees/stale"), + storedDeletionEvent(2, "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/worktrees/stale-2"), + storedDeletionEvent(3, "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/worktrees/fresh"), + ]).pipe(Stream.filter((stored) => stored.sequence > afterSequence)), + ), + ), + ), + }; + const layer = makeLayer(prune, { + deleteOrphanedImmediately: true, + eventSource, + }); + + yield* Effect.gen(function* () { + const cleanup = yield* WorktreeDeletionCleanup.WorktreeDeletionCleanup; + yield* cleanup.start(); + yield* TestClock.adjust("1 second"); + assert.equal(yield* Deferred.await(observedPath), "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/worktrees/fresh"); + yield* cleanup.drain; + assert.equal(yield* Ref.get(sequenceAttempts), 2); + assert.deepEqual(yield* Ref.get(requestedAfterSequences), [2]); + assert.deepEqual(pruned, ["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/worktrees/fresh"]); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/vcs/WorktreeDeletionCleanup.ts b/apps/server/src/vcs/WorktreeDeletionCleanup.ts new file mode 100644 index 000000000000..1aac5249a797 --- /dev/null +++ b/apps/server/src/vcs/WorktreeDeletionCleanup.ts @@ -0,0 +1,151 @@ +import type { OrchestrationV2DomainEvent, ThreadId } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import { EventSinkV2 } from "../orchestration-v2/EventSink.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { WorktreeService } from "./WorktreeService.ts"; + +export interface ThreadDeletionCleanupRequest { + readonly threadId: ThreadId; + readonly worktreePath: string | null; +} + +const recoverDeletionFailure = + (request: ThreadDeletionCleanupRequest) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("worktree.deletion-cleanup.failed", { + threadId: request.threadId, + worktreePath: request.worktreePath, + cause, + }), + ), + ); + +function threadDeletionCleanupRequest( + event: OrchestrationV2DomainEvent, +): ThreadDeletionCleanupRequest | null { + if (event.type !== "thread.deleted") return null; + return { + threadId: event.threadId, + worktreePath: event.payload.worktreePath, + }; +} + +export class WorktreeDeletionCleanup extends Context.Service< + WorktreeDeletionCleanup, + { + readonly start: () => Effect.Effect; + readonly enqueue: (request: ThreadDeletionCleanupRequest) => Effect.Effect; + readonly drain: Effect.Effect; + } +>()("t3/vcs/WorktreeDeletionCleanup") {} + +const make = Effect.gen(function* () { + const events = yield* EventSinkV2; + const settings = yield* ServerSettingsService; + const worktrees = yield* WorktreeService; + const started = yield* Ref.make(false); + const lastSequence = yield* Ref.make(null); + + const processDeletion = Effect.fn("WorktreeDeletionCleanup.processDeletion")(function* ( + request: ThreadDeletionCleanupRequest, + ) { + if (request.worktreePath === null) return; + + const policy = (yield* settings.getSettings).worktrees; + if (!policy.deleteOrphanedImmediately) return; + + const removed = yield* worktrees.pruneOrphanedWorktree(request.worktreePath); + if (removed) { + yield* Effect.logInfo("worktree.deletion-cleanup.removed", { + threadId: request.threadId, + worktreePath: request.worktreePath, + }); + } + }); + + const worker = yield* makeDrainableWorker((request: ThreadDeletionCleanupRequest) => + processDeletion(request).pipe(recoverDeletionFailure(request)), + ); + + const start: WorktreeDeletionCleanup["Service"]["start"] = Effect.fn( + "WorktreeDeletionCleanup.start", + )(function* () { + const shouldStart = yield* Ref.modify(started, (isStarted) => [!isStarted, true]); + if (!shouldStart) return; + + const storedEvents = Stream.unwrap( + Ref.get(lastSequence).pipe( + Effect.flatMap((current) => + current === null + ? events.latestSequence().pipe( + // Re-fail instead of defaulting to 0: a zero high-water mark would + // replay every historical thread.deleted event against the current + // policy. The stream-level retry below re-runs this lookup. + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("worktree.deletion-cleanup.initial-sequence-failed", { + cause, + }).pipe(Effect.andThen(Effect.failCause(cause))), + ), + Effect.tap((sequence) => Ref.set(lastSequence, sequence)), + ) + : Effect.succeed(current), + ), + Effect.map((afterSequence) => events.stream({ afterSequence })), + ), + ); + + yield* storedEvents.pipe( + Stream.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Stream.fromEffect( + Effect.logWarning("worktree.deletion-cleanup.stream-failed", { + cause, + }).pipe(Effect.andThen(Effect.failCause(cause))), + ), + ), + Stream.retry( + Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + ), + ), + Stream.runForEach((stored) => { + const request = threadDeletionCleanupRequest(stored.event); + const enqueue = request === null ? Effect.void : worker.enqueue(request); + return enqueue.pipe(Effect.andThen(Ref.set(lastSequence, stored.sequence))); + }), + Effect.forkScoped, + ); + }); + + return WorktreeDeletionCleanup.of({ + start, + enqueue: worker.enqueue, + drain: worker.drain, + }); +}); + +export const layer = Layer.effect(WorktreeDeletionCleanup, make); + +/** Exposed for tests. */ +export const __testing = { + recoverDeletionFailure, +}; diff --git a/apps/server/src/vcs/WorktreeLifecycle.test.ts b/apps/server/src/vcs/WorktreeLifecycle.test.ts new file mode 100644 index 000000000000..861628512152 --- /dev/null +++ b/apps/server/src/vcs/WorktreeLifecycle.test.ts @@ -0,0 +1,48 @@ +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import { layer, WorktreeLifecycle } from "./WorktreeLifecycle.ts"; + +it.effect("serializes worktree mutations and publishes inventory revisions", () => + Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* WorktreeLifecycle; + const firstEntered = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const secondQueued = yield* Deferred.make(); + const secondEntered = yield* Deferred.make(); + + const initialChange = yield* Stream.runHead(lifecycle.changes); + assert.deepEqual(Option.getOrThrow(initialChange), { revision: 0 }); + + const first = yield* lifecycle + .withMutationPermit( + Deferred.succeed(firstEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirst)), + ), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(firstEntered); + + const second = yield* Deferred.succeed(secondQueued, undefined).pipe( + Effect.andThen(lifecycle.withMutationPermit(Deferred.succeed(secondEntered, undefined))), + Effect.forkChild, + ); + yield* Deferred.await(secondQueued); + assert.isTrue(Option.isNone(yield* Deferred.poll(secondEntered))); + + yield* Deferred.succeed(releaseFirst, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + assert.isTrue(Option.isSome(yield* Deferred.poll(secondEntered))); + + yield* lifecycle.markInventoryChanged; + const changed = yield* Stream.runHead(lifecycle.changes); + assert.deepEqual(Option.getOrThrow(changed), { revision: 1 }); + }), + ).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/vcs/WorktreeLifecycle.ts b/apps/server/src/vcs/WorktreeLifecycle.ts new file mode 100644 index 000000000000..6bcdf4e800b4 --- /dev/null +++ b/apps/server/src/vcs/WorktreeLifecycle.ts @@ -0,0 +1,39 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type { WorktreeInventoryChange } from "@t3tools/contracts"; + +export class WorktreeLifecycle extends Context.Service< + WorktreeLifecycle, + { + readonly withMutationPermit: ( + effect: Effect.Effect, + ) => Effect.Effect; + readonly markInventoryChanged: Effect.Effect; + readonly changes: Stream.Stream; + } +>()("t3/vcs/WorktreeLifecycle") {} + +export const make = Effect.gen(function* () { + const mutationSemaphore = yield* Semaphore.make(1); + const revision = yield* SubscriptionRef.make(0); + + const withMutationPermit: WorktreeLifecycle["Service"]["withMutationPermit"] = (effect) => + mutationSemaphore.withPermit(effect); + const markInventoryChanged = SubscriptionRef.update(revision, (current) => current + 1); + const changes = SubscriptionRef.changes(revision).pipe( + Stream.map((currentRevision) => ({ revision: currentRevision })), + ); + + return WorktreeLifecycle.of({ + withMutationPermit, + markInventoryChanged, + changes, + }); +}); + +export const layer = Layer.effect(WorktreeLifecycle, make); diff --git a/apps/server/src/vcs/WorktreeReaper.test.ts b/apps/server/src/vcs/WorktreeReaper.test.ts new file mode 100644 index 000000000000..275cd721a32f --- /dev/null +++ b/apps/server/src/vcs/WorktreeReaper.test.ts @@ -0,0 +1,120 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProjectId, type WorktreeInfo } from "@t3tools/contracts"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as WorktreeReaper from "./WorktreeReaper.ts"; +import * as WorktreeService from "./WorktreeService.ts"; + +const makeWorktree = (overrides: Partial = {}): WorktreeInfo => ({ + projectId: ProjectId.make("reaper-project"), + projectTitle: "Reaper project", + workspaceRoot: "/repo", + projects: [], + path: "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/worktrees/feature", + branch: "feature/reaper", + threads: [], + orphaned: false, + dirty: false, + dirtyFileCount: 0, + hasUpstream: false, + upstreamGone: false, + aheadOfUpstreamCount: null, + behindUpstreamCount: null, + aheadOfDefaultCount: 0, + lastActivityAt: "2020-01-01T00:00:00.000Z", + safeToPrune: true, + pruneBlockers: [], + ...overrides, +}); + +const makeLayer = ( + inventory: ReadonlyArray, + prune: (paths: ReadonlyArray) => void, + settings: Parameters[0], + observed?: { readonly listCalls: { value: number } }, +) => { + const worktreeLayer = Layer.succeed( + WorktreeService.WorktreeService, + WorktreeService.WorktreeService.of({ + listWorktrees: () => + Effect.sync(() => { + if (observed !== undefined) observed.listCalls.value += 1; + return { worktrees: [...inventory] }; + }), + pruneWorktrees: (input) => + Effect.sync(() => prune(input.paths)).pipe( + Effect.as({ + removed: [], + skipped: [], + }), + ), + pruneOrphanedWorktree: () => Effect.succeed(false), + }), + ); + return WorktreeReaper.layerWith({ initialDelayMs: 0, sweepIntervalMs: 60_000 }).pipe( + Layer.provide(worktreeLayer), + Layer.provide(ServerSettings.layerTest(settings)), + ); +}; + +it.effect("reaps only inactive worktrees already proven safe", () => + Effect.gen(function* () { + const pruned: ReadonlyArray[] = []; + const observed = { listCalls: { value: 0 } }; + const layer = makeLayer( + [ + makeWorktree({ path: "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/worktrees/old", lastActivityAt: "1960-01-01T00:00:00.000Z" }), + makeWorktree({ + path: "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/worktrees/dirty", + lastActivityAt: "1960-01-01T00:00:00.000Z", + safeToPrune: false, + pruneBlockers: ["dirty"], + }), + makeWorktree({ path: "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/worktrees/recent", lastActivityAt: "2999-01-01T00:00:00.000Z" }), + ], + (paths) => pruned.push(paths), + { worktrees: { autoPruneAfterDays: 14, deleteOrphanedImmediately: false } }, + observed, + ); + + yield* Effect.gen(function* () { + const reaper = yield* WorktreeReaper.WorktreeReaper; + yield* reaper.sweep; + }).pipe(Effect.provide(layer)); + + assert.equal(observed.listCalls.value, 1); + assert.deepEqual(pruned, [["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/worktrees/old"]]); + }), +); + +it.effect("can sweep safe orphans immediately and stays inert when disabled", () => + Effect.gen(function* () { + const pruned: ReadonlyArray[] = []; + const layer = makeLayer( + [makeWorktree({ path: "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/worktrees/orphan", orphaned: true, lastActivityAt: null })], + (paths) => pruned.push(paths), + { worktrees: { autoPruneAfterDays: null, deleteOrphanedImmediately: true } }, + ); + + yield* Effect.gen(function* () { + const reaper = yield* WorktreeReaper.WorktreeReaper; + yield* reaper.sweep; + }).pipe(Effect.provide(layer)); + assert.deepEqual(pruned, [["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/worktrees/orphan"]]); + + const disabledPruned: ReadonlyArray[] = []; + const disabledLayer = makeLayer( + [makeWorktree({ path: "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/worktrees/disabled" })], + (paths) => disabledPruned.push(paths), + { worktrees: { autoPruneAfterDays: null, deleteOrphanedImmediately: false } }, + ); + yield* Effect.gen(function* () { + const reaper = yield* WorktreeReaper.WorktreeReaper; + yield* reaper.sweep; + }).pipe(Effect.provide(disabledLayer)); + assert.deepEqual(disabledPruned, []); + }), +); diff --git a/apps/server/src/vcs/WorktreeReaper.ts b/apps/server/src/vcs/WorktreeReaper.ts new file mode 100644 index 000000000000..b040cb42cb2d --- /dev/null +++ b/apps/server/src/vcs/WorktreeReaper.ts @@ -0,0 +1,97 @@ +/** + * Periodically removes managed worktrees that the inventory proves are safe + * and inactive according to the server policy. + * + * The reaper never removes a worktree directly. Every candidate is handed to + * WorktreeService.pruneWorktrees, which recomputes safety immediately before + * invoking non-forced Git removal. + */ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; + +import { ServerSettingsService } from "../serverSettings.ts"; +import { WorktreeService } from "./WorktreeService.ts"; + +const DEFAULT_SWEEP_INTERVAL_MS = 60 * 60 * 1000; +const DEFAULT_INITIAL_DELAY_MS = 2 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface WorktreeReaperOptions { + readonly sweepIntervalMs?: number; + readonly initialDelayMs?: number; +} + +export class WorktreeReaper extends Context.Service< + WorktreeReaper, + { + /** The sweep loop. Never returns; the runtime forks it behind the + activation boundary so a runtime that never activates never prunes. */ + readonly run: Effect.Effect; + readonly sweep: Effect.Effect; + } +>()("t3/vcs/WorktreeReaper") {} + +const make = (options?: WorktreeReaperOptions) => + Effect.gen(function* () { + const worktrees = yield* WorktreeService; + const settings = yield* ServerSettingsService; + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + const initialDelayMs = Math.max(0, options?.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS); + + const runSweep = Effect.gen(function* () { + const policy = (yield* settings.getSettings).worktrees; + if (policy.autoPruneAfterDays === null && !policy.deleteOrphanedImmediately) { + return; + } + + const { worktrees: inventory } = yield* worktrees.listWorktrees({}); + const now = yield* Clock.currentTimeMillis; + const cutoffMs = + policy.autoPruneAfterDays === null ? null : now - policy.autoPruneAfterDays * DAY_MS; + const candidates = inventory.filter((worktree) => { + if (!worktree.safeToPrune) return false; + if (policy.deleteOrphanedImmediately && worktree.orphaned) return true; + if (cutoffMs === null || worktree.lastActivityAt === null) return false; + const lastActivityMs = Date.parse(worktree.lastActivityAt); + return !Number.isNaN(lastActivityMs) && lastActivityMs < cutoffMs; + }); + if (candidates.length === 0) return; + + const result = yield* worktrees.pruneWorktrees({ + paths: candidates.map((worktree) => worktree.path), + }); + yield* Effect.logInfo("worktree.reaper.sweep-complete", { + candidateCount: candidates.length, + removedCount: result.removed.length, + skippedCount: result.skipped.length, + autoPruneAfterDays: policy.autoPruneAfterDays, + deleteOrphanedImmediately: policy.deleteOrphanedImmediately, + }); + }); + + // Interrupts must propagate so scope close and shutdown stop the loop. + const sweep = runSweep.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning("worktree.reaper.sweep-failed", { cause }), + ), + ); + + const run = Effect.sleep(Duration.millis(initialDelayMs)).pipe( + Effect.andThen(sweep.pipe(Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))))), + Effect.asVoid, + ); + + return WorktreeReaper.of({ run, sweep }); + }); + +export const layer = Layer.effect(WorktreeReaper, make()); + +export const layerWith = (options: WorktreeReaperOptions) => + Layer.effect(WorktreeReaper, make(options)); diff --git a/apps/server/src/vcs/WorktreeRevivalService.test.ts b/apps/server/src/vcs/WorktreeRevivalService.test.ts new file mode 100644 index 000000000000..e73be07c949a --- /dev/null +++ b/apps/server/src/vcs/WorktreeRevivalService.test.ts @@ -0,0 +1,389 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +import { ProjectId, ThreadId, type Project } from "@t3tools/contracts"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as WorktreeLifecycle from "./WorktreeLifecycle.ts"; +import * as WorktreeRevivalService from "./WorktreeRevivalService.ts"; + +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-worktree-revival-test-", +}); +const serverConfigLiveLayer = serverConfigLayer.pipe(Layer.provide(NodeServices.layer)); +const gitLayer = GitVcsDriver.layer.pipe( + Layer.provide(serverConfigLiveLayer), + Layer.provideMerge(NodeServices.layer), +); + +const projectId = ProjectId.make("project-worktree-revival"); +const threadId = ThreadId.make("thread-worktree-revival"); + +const makeProject = (workspaceRoot: string): Project => ({ + id: projectId, + title: "Revival project", + workspaceRoot, + defaultModelSelection: null, + scripts: [ + { + id: "setup", + name: "Setup", + command: "vp i", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +const initializeRepository = Effect.fn("WorktreeRevivalServiceTest.initializeRepository")( + function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-worktree-revival-repo-", + }); + + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.init", + cwd: repositoryRoot, + args: ["init", "-b", "main"], + }); + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.userEmail", + cwd: repositoryRoot, + args: ["config", "user.email", "test@example.com"], + }); + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.userName", + cwd: repositoryRoot, + args: ["config", "user.name", "T3 Test"], + }); + yield* fs.writeFileString(path.join(repositoryRoot, "README.md"), "hello\n"); + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.add", + cwd: repositoryRoot, + args: ["add", "README.md"], + }); + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.commit", + cwd: repositoryRoot, + args: ["commit", "-m", "initial"], + }); + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.branch", + cwd: repositoryRoot, + args: ["branch", "feature/revival"], + }); + + return repositoryRoot; + }, +); + +const makeRevivalLayer = ( + project: Project, + runForThread: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"], +) => + WorktreeRevivalService.layer.pipe( + Layer.provide( + Layer.mergeAll( + serverConfigLiveLayer, + NodeServices.layer, + gitLayer, + WorktreeLifecycle.layer, + Layer.mock(ProjectService.ProjectService)({ + getById: (requestedProjectId) => + Effect.succeed( + requestedProjectId === project.id ? Option.some(project) : Option.none(), + ), + snapshot: Effect.succeed({ + projects: [project], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + }), + Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { runForThread }), + ), + ), + ); + +it.effect( + "rejects a missing destination whose existing symlink ancestor escapes the managed root", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const outsideRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-worktree-revival-outside-", + }); + const symlinkPath = path.join(config.worktreesDir, "outside-link"); + const requestedPath = path.join(symlinkPath, "revived"); + const escapedPath = path.join(outsideRoot, "revived"); + yield* fs.symlink(outsideRoot, symlinkPath); + + const error = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + return yield* revival.reviveWorktree({ + workspaceRoot: repositoryRoot, + worktreePath: requestedPath, + branch: "feature/revival", + }); + }).pipe( + Effect.provide( + makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.succeed({ status: "no-script" }), + ), + ), + Effect.flip, + ); + + assert.equal(error._tag, "WorktreeMutationError"); + assert.match(error.message, /outside the managed worktrees directory/); + assert.isFalse(yield* fs.exists(escapedPath)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("revives worktrees whose first path segment starts with two dots", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "..cache", "revived"); + + const result = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + return yield* revival.reviveWorktree({ + workspaceRoot: repositoryRoot, + worktreePath, + branch: "feature/revival", + }); + }).pipe( + Effect.provide( + makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.succeed({ status: "no-script" }), + ), + ), + ); + + assert.isTrue(result.revived); + assert.isTrue(yield* fs.exists(worktreePath)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +const addWorktree = Effect.fn("WorktreeRevivalServiceTest.addWorktree")(function* ( + repositoryRoot: string, + worktreePath: string, +) { + const git = yield* GitVcsDriver.GitVcsDriver; + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.worktreeAdd", + cwd: repositoryRoot, + args: ["worktree", "add", worktreePath, "feature/revival"], + }); +}); + +it.effect("leaves an existing worktree alone when its HEAD is detached", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const git = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "detached"); + yield* addWorktree(repositoryRoot, worktreePath); + yield* git.execute({ + operation: "WorktreeRevivalServiceTest.detach", + cwd: worktreePath, + args: ["checkout", "--detach"], + }); + + const result = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + return yield* revival.reviveForThread({ + threadId, + projectId, + worktreePath, + branch: "feature/revival", + }); + }).pipe( + Effect.provide( + makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.succeed({ status: "no-script" }), + ), + ), + ); + + assert.deepEqual(result, { revived: false, generation: 0 }); + const head = yield* git.execute({ + operation: "WorktreeRevivalServiceTest.symbolicRef", + cwd: worktreePath, + args: ["symbolic-ref", "-q", "HEAD"], + allowNonZeroExit: true, + }); + assert.notEqual(head.exitCode, 0, "HEAD must stay detached"); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("leaves an existing checkout outside the managed root alone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repositoryRoot = yield* initializeRepository(); + const outsideRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-worktree-revival-outside-", + }); + const worktreePath = path.join(outsideRoot, "checkout"); + yield* addWorktree(repositoryRoot, worktreePath); + + const result = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + return yield* revival.reviveForThread({ + threadId, + projectId, + worktreePath, + branch: "feature/revival", + }); + }).pipe( + Effect.provide( + makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.succeed({ status: "no-script" }), + ), + ), + ); + + assert.deepEqual(result, { revived: false, generation: 0 }); + assert.isTrue(yield* fs.exists(path.join(worktreePath, "README.md"))); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("finishes inventory publication when interrupted after Git creates a worktree", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "interruption", "revived"); + const project = makeProject(repositoryRoot); + const markEntered = yield* Deferred.make(); + const releaseMark = yield* Deferred.make(); + const inventoryChanges = yield* Ref.make(0); + const lifecycle = yield* WorktreeLifecycle.make; + const instrumentedLifecycle = WorktreeLifecycle.WorktreeLifecycle.of({ + ...lifecycle, + markInventoryChanged: Deferred.succeed(markEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseMark)), + Effect.andThen(Ref.update(inventoryChanges, (count) => count + 1)), + Effect.andThen(lifecycle.markInventoryChanged), + ), + }); + const projectLayer = Layer.mock(ProjectService.ProjectService)({ + snapshot: Effect.succeed({ + projects: [project], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + }); + const layer = WorktreeRevivalService.layer.pipe( + Layer.provide( + Layer.mergeAll( + serverConfigLiveLayer, + NodeServices.layer, + Layer.succeed(GitVcsDriver.GitVcsDriver, driver), + Layer.succeed(WorktreeLifecycle.WorktreeLifecycle, instrumentedLifecycle), + projectLayer, + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), + ), + ), + ); + + yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const revivalFiber = yield* revival + .reviveWorktree({ + workspaceRoot: repositoryRoot, + worktreePath, + branch: "feature/revival", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(markEntered); + const interruptionFiber = yield* Fiber.interrupt(revivalFiber).pipe(Effect.forkChild); + yield* Effect.yieldNow; + + assert.equal(yield* Ref.get(inventoryChanges), 0); + yield* Deferred.succeed(releaseMark, undefined); + yield* Fiber.join(interruptionFiber); + + assert.equal(yield* Ref.get(inventoryChanges), 1); + assert.isTrue(yield* fs.exists(worktreePath)); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("retries setup after a recreated worktree's first setup attempt fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "failure"); + const setupFailure = new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ + threadId, + projectId, + projectCwd: repositoryRoot, + worktreePath, + operation: "openTerminal", + cause: "simulated setup failure", + }); + let setupAttempts = 0; + const layer = makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.suspend(() => { + setupAttempts += 1; + return setupAttempts === 1 + ? Effect.fail(setupFailure) + : Effect.succeed({ status: "no-script" as const }); + }), + ); + + const { error, retry } = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const error = yield* revival + .reviveForThread({ + threadId, + projectId, + worktreePath, + branch: "feature/revival", + }) + .pipe(Effect.flip); + const retry = yield* revival.reviveForThread({ + threadId, + projectId, + worktreePath, + branch: "feature/revival", + }); + return { error, retry }; + }).pipe(Effect.provide(layer)); + + assert.equal(error._tag, "WorktreeMutationError"); + assert.equal(error.message, "Failed to run the project setup script after revival."); + assert.strictEqual(error.cause, setupFailure); + assert.isFalse(retry.revived); + assert.equal(retry.generation, 1); + assert.equal(setupAttempts, 2); + assert.isTrue(yield* fs.exists(worktreePath)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); diff --git a/apps/server/src/vcs/WorktreeRevivalService.testkit.ts b/apps/server/src/vcs/WorktreeRevivalService.testkit.ts new file mode 100644 index 000000000000..eec32f1665c9 --- /dev/null +++ b/apps/server/src/vcs/WorktreeRevivalService.testkit.ts @@ -0,0 +1,12 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { WorktreeRevivalService } from "./WorktreeRevivalService.ts"; + +export const layerNoop = Layer.succeed( + WorktreeRevivalService, + WorktreeRevivalService.of({ + reviveWorktree: () => Effect.succeed({ revived: false }), + reviveForThread: () => Effect.succeed({ revived: false, generation: 0 }), + }), +); diff --git a/apps/server/src/vcs/WorktreeRevivalService.ts b/apps/server/src/vcs/WorktreeRevivalService.ts new file mode 100644 index 000000000000..7c68d9c2f229 --- /dev/null +++ b/apps/server/src/vcs/WorktreeRevivalService.ts @@ -0,0 +1,511 @@ +import * as Context from "effect/Context"; +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 Ref from "effect/Ref"; + +import { + type ProjectId, + type ThreadId, + type WorktreeMutationErrorStage, + WorktreeMutationError, +} from "@t3tools/contracts"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as WorktreeLifecycle from "./WorktreeLifecycle.ts"; + +const PROJECT_SCAN_CONCURRENCY = 4; + +function isPathInside( + root: string, + candidate: string, + path: { + readonly relative: (from: string, to: string) => string; + readonly isAbsolute: (value: string) => boolean; + readonly sep: string; + }, +): boolean { + const relative = path.relative(root, candidate); + return ( + relative.length > 0 && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +function mutationError( + stage: WorktreeMutationErrorStage, + cause?: unknown, + context: { + readonly path?: string; + readonly conflictingPath?: string; + readonly workspaceRoot?: string; + readonly branch?: string; + readonly projectId?: ProjectId; + } = {}, +): WorktreeMutationError { + return new WorktreeMutationError({ + operation: "revive", + stage, + ...(context.path === undefined ? {} : { path: context.path }), + ...(context.conflictingPath === undefined ? {} : { conflictingPath: context.conflictingPath }), + ...(context.workspaceRoot === undefined ? {} : { workspaceRoot: context.workspaceRoot }), + ...(context.branch === undefined ? {} : { branch: context.branch }), + ...(context.projectId === undefined ? {} : { projectId: context.projectId }), + ...(cause === undefined ? {} : { cause }), + }); +} + +export interface WorktreeRevivalForThreadInput { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly worktreePath: string; + readonly branch: string; +} + +interface WorktreeRevivalInput { + readonly workspaceRoot: string; + readonly worktreePath: string; + readonly branch: string; +} + +interface WorktreeRevivalResult { + readonly revived: boolean; +} + +export class WorktreeRevivalService extends Context.Service< + WorktreeRevivalService, + { + readonly reviveWorktree: ( + input: WorktreeRevivalInput, + ) => Effect.Effect; + readonly reviveForThread: ( + input: WorktreeRevivalForThreadInput, + ) => Effect.Effect< + WorktreeRevivalResult & { readonly generation: number }, + WorktreeMutationError + >; + } +>()("t3/vcs/WorktreeRevivalService") {} + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const lifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; + const projectsService = yield* ProjectService.ProjectService; + const setupScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const generationByWorktreePath = yield* Ref.make(new Map()); + const setupGenerationByProjectWorktree = yield* Ref.make(new Map()); + + const setupKey = (projectId: ProjectId, worktreePath: string) => `${projectId}\0${worktreePath}`; + const currentGeneration = (worktreePath: string) => + Ref.get(generationByWorktreePath).pipe( + Effect.map((generations) => generations.get(worktreePath) ?? 0), + ); + const advanceGeneration = (worktreePath: string) => + Ref.modify(generationByWorktreePath, (generations) => { + const generation = (generations.get(worktreePath) ?? 0) + 1; + const next = new Map(generations); + next.set(worktreePath, generation); + return [generation, next] as const; + }); + + // Canonicalize through symlinks so configured roots, Git metadata, and V2 + // thread paths compare equal on hosts such as macOS (/var vs /private/var). + const canonicalizePath = (value: string) => + fs.realPath(value).pipe(Effect.orElseSucceed(() => path.resolve(value))); + const managedWorktreesRoot = yield* canonicalizePath(config.worktreesDir); + + const resolveEffectiveDestination = Effect.fn( + "WorktreeRevivalService.resolveEffectiveDestination", + )(function* ( + value: string, + context: { + readonly workspaceRoot: string; + readonly branch: string; + }, + ) { + const unresolvedSegments: string[] = []; + let existingAncestor = path.resolve(value); + + while ( + !(yield* fs.exists(existingAncestor).pipe( + Effect.mapError((cause) => + mutationError("inspect_target_path", cause, { + path: value, + ...context, + }), + ), + )) + ) { + const parent = path.dirname(existingAncestor); + if (parent === existingAncestor) { + return yield* mutationError("resolve_target_path", undefined, { + path: value, + ...context, + }); + } + unresolvedSegments.unshift(path.basename(existingAncestor)); + existingAncestor = parent; + } + + const canonicalAncestor = yield* fs.realPath(existingAncestor).pipe( + Effect.mapError((cause) => + mutationError("resolve_target_path", cause, { + path: value, + ...context, + }), + ), + ); + return path.resolve(canonicalAncestor, ...unresolvedSegments); + }); + + const resolveManagedWorkspaceRoot = Effect.fn( + "WorktreeRevivalService.resolveManagedWorkspaceRoot", + )(function* (input: WorktreeRevivalInput) { + const requestedWorkspaceRoot = yield* canonicalizePath(input.workspaceRoot); + const projectSnapshot = yield* projectsService.snapshot.pipe( + Effect.mapError((cause) => + mutationError("load_projects", cause, { + workspaceRoot: requestedWorkspaceRoot, + branch: input.branch, + }), + ), + ); + const projectRoots = yield* Effect.forEach( + projectSnapshot.projects, + (project) => canonicalizePath(project.workspaceRoot), + { concurrency: PROJECT_SCAN_CONCURRENCY }, + ); + if (!projectRoots.includes(requestedWorkspaceRoot)) { + return yield* mutationError("unmanaged_workspace", undefined, { + workspaceRoot: requestedWorkspaceRoot, + branch: input.branch, + }); + } + return requestedWorkspaceRoot; + }); + + const listCanonicalWorkspaces = Effect.fn("WorktreeRevivalService.listCanonicalWorkspaces")( + function* (workspaceRoot: string, branch: string) { + const entries = yield* git.listWorkspaces(workspaceRoot).pipe( + Effect.mapError((cause) => + mutationError("inspect_registrations", cause, { + workspaceRoot, + branch, + }), + ), + ); + return yield* Effect.forEach(entries, (entry) => + canonicalizePath(entry.path).pipe(Effect.map((path) => ({ ...entry, path }))), + ); + }, + ); + + const validateBranchExists = Effect.fn("WorktreeRevivalService.validateBranchExists")(function* ( + workspaceRoot: string, + branch: string, + ) { + const branchFormat = yield* git + .execute({ + operation: "WorktreeRevivalService.validateBranch", + cwd: workspaceRoot, + args: ["check-ref-format", "--branch", branch], + env: { LC_ALL: "C" }, + allowNonZeroExit: true, + timeoutMs: 5_000, + }) + .pipe( + Effect.mapError((cause) => + mutationError("validate_branch", cause, { + workspaceRoot, + branch, + }), + ), + ); + if (branchFormat.exitCode !== 0) { + return yield* mutationError("invalid_branch", undefined, { workspaceRoot, branch }); + } + + const branchExists = yield* git + .execute({ + operation: "WorktreeRevivalService.branchExists", + cwd: workspaceRoot, + args: ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + env: { LC_ALL: "C" }, + allowNonZeroExit: true, + timeoutMs: 5_000, + }) + .pipe( + Effect.mapError((cause) => + mutationError("check_branch", cause, { + workspaceRoot, + branch, + }), + ), + ); + if (branchExists.exitCode !== 0) { + return yield* mutationError("missing_branch", undefined, { workspaceRoot, branch }); + } + }); + + const reviveWorktreeUnlocked = Effect.fn("WorktreeRevivalService.reviveWorktree")(function* ( + input: WorktreeRevivalInput, + ) { + const workspaceRoot = yield* resolveManagedWorkspaceRoot(input); + const worktreePath = yield* resolveEffectiveDestination(input.worktreePath, { + workspaceRoot, + branch: input.branch, + }); + const exists = yield* fs.exists(worktreePath).pipe( + Effect.mapError((cause) => + mutationError("inspect_target_path", cause, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }), + ), + ); + // An existing directory is left alone wherever it lives and whatever it + // has checked out. A detached HEAD after a stopped rebase or a switched + // branch is working state the thread runs in, not damage to repair. The + // checks below only guard creating a worktree that went missing. + if (exists) { + return { + revived: false, + generation: yield* currentGeneration(worktreePath), + worktreePath, + }; + } + if (!isPathInside(managedWorktreesRoot, worktreePath, path)) { + return yield* mutationError("outside_managed_root", undefined, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }); + } + + let registrations = yield* listCanonicalWorkspaces(workspaceRoot, input.branch); + let targetRegistration = registrations.find((entry) => entry.path === worktreePath); + + if (targetRegistration !== undefined && targetRegistration.refName !== input.branch) { + return yield* mutationError("registered_different_ref", undefined, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }); + } + + const branchRegisteredElsewhere = registrations.find( + (entry) => entry.refName === input.branch && entry.path !== worktreePath && !entry.prunable, + ); + if (branchRegisteredElsewhere !== undefined) { + return yield* mutationError("branch_in_use", undefined, { + path: worktreePath, + conflictingPath: branchRegisteredElsewhere.path, + workspaceRoot, + branch: input.branch, + }); + } + + // A deleted directory can leave a stale registration in Git's metadata. + // Prune only stale registrations, then inspect the registration table + // again before creating anything at the requested path. + const needsGitPrune = + targetRegistration !== undefined || registrations.some((entry) => entry.prunable); + if (needsGitPrune) { + yield* git + .execute({ + operation: "WorktreeRevivalService.prune", + cwd: workspaceRoot, + args: ["worktree", "prune"], + env: { LC_ALL: "C" }, + timeoutMs: 15_000, + }) + .pipe( + Effect.mapError((cause) => + mutationError("prune_metadata", cause, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }), + ), + ); + registrations = yield* listCanonicalWorkspaces(workspaceRoot, input.branch); + targetRegistration = registrations.find((entry) => entry.path === worktreePath); + if (targetRegistration !== undefined) { + return yield* mutationError("stale_registration_remaining", undefined, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }); + } + const branchStillRegisteredElsewhere = registrations.find( + (entry) => entry.refName === input.branch && entry.path !== worktreePath && !entry.prunable, + ); + if (branchStillRegisteredElsewhere !== undefined) { + return yield* mutationError("branch_in_use", undefined, { + path: worktreePath, + conflictingPath: branchStillRegisteredElsewhere.path, + workspaceRoot, + branch: input.branch, + }); + } + } + + const existsAfterPrune = yield* fs.exists(worktreePath).pipe( + Effect.mapError((cause) => + mutationError("inspect_target_path", cause, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }), + ), + ); + if (existsAfterPrune) { + return yield* mutationError("target_appeared", undefined, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }); + } + + yield* validateBranchExists(workspaceRoot, input.branch); + const generation = yield* Effect.uninterruptibleMask((restore) => + restore( + git.createWorktree({ cwd: workspaceRoot, refName: input.branch, path: worktreePath }).pipe( + Effect.mapError((cause) => + mutationError("create_worktree", cause, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }), + ), + ), + ).pipe( + Effect.andThen(advanceGeneration(worktreePath)), + Effect.tap(() => lifecycle.markInventoryChanged), + ), + ); + + const finalExists = yield* fs.exists(worktreePath).pipe( + Effect.mapError((cause) => + mutationError("verify_worktree", cause, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }), + ), + ); + const finalRegistrations = yield* listCanonicalWorkspaces(workspaceRoot, input.branch); + const finalRegistration = finalRegistrations.find((entry) => entry.path === worktreePath); + if (!finalExists || finalRegistration?.refName !== input.branch || finalRegistration.prunable) { + return yield* mutationError("worktree_verification_failed", undefined, { + path: worktreePath, + workspaceRoot, + branch: input.branch, + }); + } + + yield* Effect.logInfo("worktree.revived", { + worktreePath, + branch: input.branch, + }); + return { revived: true, generation, worktreePath }; + }); + + const reviveWorktree = (input: WorktreeRevivalInput) => + lifecycle + .withMutationPermit(reviveWorktreeUnlocked(input)) + .pipe(Effect.map(({ revived }) => ({ revived }))); + + const reviveForThreadUnlocked = Effect.fn("WorktreeRevivalService.reviveForThread")(function* ( + input: WorktreeRevivalForThreadInput, + ) { + const project = yield* projectsService.getById(input.projectId).pipe( + Effect.mapError((cause) => + mutationError("load_project", cause, { + path: input.worktreePath, + branch: input.branch, + projectId: input.projectId, + }), + ), + ); + if (Option.isNone(project)) { + return yield* mutationError("project_not_found", undefined, { + path: input.worktreePath, + branch: input.branch, + projectId: input.projectId, + }); + } + const revival = yield* reviveWorktreeUnlocked({ + workspaceRoot: project.value.workspaceRoot, + worktreePath: input.worktreePath, + branch: input.branch, + }); + const setupGenerationKey = setupKey(input.projectId, revival.worktreePath); + const setupGeneration = (yield* Ref.get(setupGenerationByProjectWorktree)).get( + setupGenerationKey, + ); + if (revival.generation > 0 && setupGeneration !== revival.generation) { + yield* setupScripts + .runForThread({ + threadId: input.threadId, + projectId: input.projectId, + projectCwd: project.value.workspaceRoot, + worktreePath: revival.worktreePath, + project: { + id: project.value.id, + workspaceRoot: project.value.workspaceRoot, + scripts: project.value.scripts, + }, + }) + .pipe( + Effect.mapError((cause) => + mutationError("run_setup", cause, { + path: input.worktreePath, + workspaceRoot: project.value.workspaceRoot, + branch: input.branch, + }), + ), + ); + yield* Ref.update(setupGenerationByProjectWorktree, (generations) => { + const next = new Map(generations); + next.set(setupGenerationKey, revival.generation); + return next; + }); + } + return { revived: revival.revived, generation: revival.generation }; + }); + // Every turn start on a worktree thread passes through here, so an existing + // directory is answered without the mutation permit: a reaper sweep or a + // sibling revival must not hold up turns that need no Git mutation. Only a + // missing directory, or a pending setup retry, takes the locked path. + const reviveForThread: WorktreeRevivalService["Service"]["reviveForThread"] = (input) => + Effect.gen(function* () { + const exists = yield* fs.exists(input.worktreePath).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + const worktreePath = yield* canonicalizePath(input.worktreePath); + const generation = yield* currentGeneration(worktreePath); + const setupGeneration = (yield* Ref.get(setupGenerationByProjectWorktree)).get( + setupKey(input.projectId, worktreePath), + ); + if (generation === 0 || setupGeneration === generation) { + return { revived: false, generation }; + } + } + return yield* lifecycle.withMutationPermit(reviveForThreadUnlocked(input)); + }); + + return WorktreeRevivalService.of({ reviveWorktree, reviveForThread }); +}); + +export const layer = Layer.effect(WorktreeRevivalService, make); diff --git a/apps/server/src/vcs/WorktreeService.test.ts b/apps/server/src/vcs/WorktreeService.test.ts new file mode 100644 index 000000000000..bb95ae574b2b --- /dev/null +++ b/apps/server/src/vcs/WorktreeService.test.ts @@ -0,0 +1,540 @@ +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"; +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 { + OrchestrationV2ThreadShellSnapshot, + ProjectId, + ThreadId, + type OrchestrationV2ThreadShell, + type Project, +} from "@t3tools/contracts"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as WorktreeLifecycle from "./WorktreeLifecycle.ts"; +import * as WorktreeRevivalService from "./WorktreeRevivalService.ts"; +import { layer as worktreeServiceLayer, WorktreeService } from "./WorktreeService.ts"; + +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-worktree-service-v2-test-", +}); +const serverConfigLiveLayer = serverConfigLayer.pipe(Layer.provide(NodeServices.layer)); +const gitLayer = GitVcsDriver.layer.pipe( + Layer.provide(serverConfigLiveLayer), + Layer.provideMerge(NodeServices.layer), +); +const worktreeLifecycleLayer = WorktreeLifecycle.layer; + +const projectA = ProjectId.make("project-worktree-v2-a"); +const projectB = ProjectId.make("project-worktree-v2-b"); + +const makeProject = (id: ProjectId, workspaceRoot: string): Project => + ({ + id, + title: id === projectA ? "Project A" : "Project B", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }) as Project; + +const makeShell = (input: { + readonly id: ThreadId; + readonly projectId: ProjectId; + readonly worktreePath: string; + readonly settledOverride: "settled" | "active" | null; + readonly updatedAt: DateTime.Utc; +}): OrchestrationV2ThreadShell => + ({ + id: input.id, + projectId: input.projectId, + title: `${input.projectId} thread`, + branch: "feature/shared", + worktreePath: input.worktreePath, + updatedAt: input.updatedAt, + archivedAt: null, + settledOverride: input.settledOverride, + deletedAt: null, + }) as OrchestrationV2ThreadShell; + +const makeTestLayer = ( + projects: () => ReadonlyArray, + shells: () => OrchestrationV2ThreadShell[], + gitDriver?: GitVcsDriver.GitVcsDriver["Service"], +) => { + const selectedGitLayer = + gitDriver === undefined ? gitLayer : Layer.succeed(GitVcsDriver.GitVcsDriver, gitDriver); + const projectLayer = Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.none()), + snapshot: Effect.sync(() => ({ + projects: [...projects()], + updatedAt: "2026-01-01T00:00:00.000Z", + })), + }); + const revivalLayer = WorktreeRevivalService.layer.pipe( + Layer.provide( + Layer.mergeAll( + selectedGitLayer, + serverConfigLiveLayer, + NodeServices.layer, + projectLayer, + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread: () => Effect.succeed({ status: "no-script" }), + }), + worktreeLifecycleLayer, + ), + ), + ); + return worktreeServiceLayer.pipe( + Layer.provide( + Layer.mergeAll( + selectedGitLayer, + serverConfigLiveLayer, + NodeServices.layer, + projectLayer, + revivalLayer, + worktreeLifecycleLayer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getShellSnapshot: () => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 1, + threads: [...shells()], + archivedThreads: [], + } as OrchestrationV2ThreadShellSnapshot), + }), + ), + ), + ); +}; + +it.effect("rechecks unpublished commits immediately before pruning a worktree", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const driver = yield* GitVcsDriver.GitVcsDriver; + + const repositoryRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-worktree-v2-prune-race-repo-", + }); + yield* driver.execute({ + operation: "WorktreeServicePruneRaceTest.init", + cwd: repositoryRoot, + args: ["init", "-b", "main"], + }); + yield* driver.execute({ + operation: "WorktreeServicePruneRaceTest.userEmail", + cwd: repositoryRoot, + args: ["config", "user.email", "test@example.com"], + }); + yield* driver.execute({ + operation: "WorktreeServicePruneRaceTest.userName", + cwd: repositoryRoot, + args: ["config", "user.name", "T3 Test"], + }); + yield* fs.writeFileString(path.join(repositoryRoot, "README.md"), "hello\n"); + yield* driver.execute({ + operation: "WorktreeServicePruneRaceTest.add", + cwd: repositoryRoot, + args: ["add", "README.md"], + }); + yield* driver.execute({ + operation: "WorktreeServicePruneRaceTest.commit", + cwd: repositoryRoot, + args: ["commit", "-m", "initial"], + }); + + const worktreePath = path.join(config.worktreesDir, "orphan", "prune-race"); + yield* driver.createWorktree({ + cwd: repositoryRoot, + refName: "main", + newRefName: "feature/prune-race", + path: worktreePath, + }); + yield* driver.execute({ + operation: "WorktreeServicePruneRaceTest.addOrigin", + cwd: repositoryRoot, + args: ["remote", "add", "origin", repositoryRoot], + }); + yield* driver.execute({ + operation: "WorktreeServicePruneRaceTest.createUpstream", + cwd: repositoryRoot, + args: [ + "update-ref", + "refs/remotes/origin/feature/prune-race", + "refs/heads/feature/prune-race", + ], + }); + yield* driver.execute({ + operation: "WorktreeServicePruneRaceTest.trackUpstream", + cwd: repositoryRoot, + args: ["branch", "--set-upstream-to=origin/feature/prune-race", "feature/prune-race"], + }); + + let worktreeStatusChecks = 0; + const racingDriver = GitVcsDriver.GitVcsDriver.of({ + ...driver, + statusDetailsLocal: (cwd) => { + if (cwd !== worktreePath) return driver.statusDetailsLocal(cwd); + worktreeStatusChecks += 1; + if (worktreeStatusChecks !== 1) return driver.statusDetailsLocal(cwd); + return Effect.gen(function* () { + yield* driver.execute({ + operation: "WorktreeServicePruneRaceTest.lateCommit", + cwd: worktreePath, + args: ["commit", "--allow-empty", "-m", "late unpublished change"], + }); + return yield* driver.statusDetailsLocal(cwd); + }); + }, + }); + const layer = makeTestLayer( + () => [makeProject(projectA, repositoryRoot)], + () => [], + racingDriver, + ); + + const result = yield* Effect.gen(function* () { + const service = yield* WorktreeService; + return yield* service.pruneWorktrees({ paths: [worktreePath] }); + }).pipe(Effect.provide(layer)); + + assert.equal(worktreeStatusChecks, 1); + assert.deepEqual(result.removed, []); + assert.deepEqual(result.skipped, [{ path: worktreePath, reason: "unpushed" }]); + assert.isTrue(yield* fs.exists(worktreePath)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("rechecks thread linkage immediately before pruning a worktree", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const driver = yield* GitVcsDriver.GitVcsDriver; + + const repositoryRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-worktree-v2-prune-link-race-repo-", + }); + yield* driver.execute({ + operation: "WorktreeServiceLinkRaceTest.init", + cwd: repositoryRoot, + args: ["init", "-b", "main"], + }); + yield* driver.execute({ + operation: "WorktreeServiceLinkRaceTest.userEmail", + cwd: repositoryRoot, + args: ["config", "user.email", "test@example.com"], + }); + yield* driver.execute({ + operation: "WorktreeServiceLinkRaceTest.userName", + cwd: repositoryRoot, + args: ["config", "user.name", "T3 Test"], + }); + yield* fs.writeFileString(path.join(repositoryRoot, "README.md"), "hello\n"); + yield* driver.execute({ + operation: "WorktreeServiceLinkRaceTest.add", + cwd: repositoryRoot, + args: ["add", "README.md"], + }); + yield* driver.execute({ + operation: "WorktreeServiceLinkRaceTest.commit", + cwd: repositoryRoot, + args: ["commit", "-m", "initial"], + }); + + const worktreePath = path.join(config.worktreesDir, "orphan", "link-race"); + yield* driver.createWorktree({ + cwd: repositoryRoot, + refName: "main", + newRefName: "feature/link-race", + path: worktreePath, + }); + + // First snapshot (inventory) sees no threads; the recheck taken right + // before removal sees a thread that linked to the worktree in between. + let snapshotCalls = 0; + const shells = () => { + snapshotCalls += 1; + if (snapshotCalls === 1) return []; + return [ + makeShell({ + id: ThreadId.make("thread-late-link"), + projectId: projectA, + worktreePath, + settledOverride: "active", + updatedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), + }), + ]; + }; + const layer = makeTestLayer(() => [makeProject(projectA, repositoryRoot)], shells, driver); + + const result = yield* Effect.gen(function* () { + const service = yield* WorktreeService; + return yield* service.pruneWorktrees({ paths: [worktreePath] }); + }).pipe(Effect.provide(layer)); + + assert.isAtLeast(snapshotCalls, 2); + assert.deepEqual(result.removed, []); + assert.deepEqual(result.skipped, [{ path: worktreePath, reason: "active_thread" }]); + assert.isTrue(yield* fs.exists(worktreePath)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("combines thread safety across nested projects in the same repository", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const driver = yield* GitVcsDriver.GitVcsDriver; + + const repositoryRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-worktree-v2-nested-repo-", + }); + yield* driver.execute({ + operation: "WorktreeServiceNestedTest.init", + cwd: repositoryRoot, + args: ["init", "-b", "main"], + }); + yield* driver.execute({ + operation: "WorktreeServiceNestedTest.userEmail", + cwd: repositoryRoot, + args: ["config", "user.email", "test@example.com"], + }); + yield* driver.execute({ + operation: "WorktreeServiceNestedTest.userName", + cwd: repositoryRoot, + args: ["config", "user.name", "T3 Test"], + }); + yield* fs.writeFileString(path.join(repositoryRoot, "README.md"), "hello\n"); + yield* driver.execute({ + operation: "WorktreeServiceNestedTest.add", + cwd: repositoryRoot, + args: ["add", "README.md"], + }); + yield* driver.execute({ + operation: "WorktreeServiceNestedTest.commit", + cwd: repositoryRoot, + args: ["commit", "-m", "initial"], + }); + + const nestedProjectRoot = path.join(repositoryRoot, "packages", "nested"); + yield* fs.makeDirectory(nestedProjectRoot, { recursive: true }); + const worktreePath = path.join(config.worktreesDir, "nested", "active"); + yield* driver.createWorktree({ + cwd: repositoryRoot, + refName: "main", + newRefName: "feature/nested-active", + path: worktreePath, + }); + + const activeThread = makeShell({ + id: ThreadId.make("thread-worktree-v2-nested-active"), + projectId: projectA, + worktreePath, + settledOverride: "active", + updatedAt: DateTime.makeUnsafe("2026-01-05T00:00:00.000Z"), + }); + const layer = makeTestLayer( + () => [makeProject(projectA, nestedProjectRoot), makeProject(projectB, repositoryRoot)], + () => [activeThread], + ); + + const inventory = yield* Effect.gen(function* () { + const service = yield* WorktreeService; + return yield* service.listWorktrees({}); + }).pipe(Effect.provide(layer)); + + assert.equal(inventory.worktrees.length, 1); + const worktree = inventory.worktrees[0]; + assert.isDefined(worktree); + assert.deepEqual( + worktree?.projects.map((project) => project.workspaceRoot), + [nestedProjectRoot, repositoryRoot], + ); + assert.deepEqual( + worktree?.threads.map((thread) => thread.threadId), + [activeThread.id], + ); + assert.isFalse(worktree?.safeToPrune); + assert.deepEqual(worktree?.pruneBlockers, ["active_thread"]); + + const filteredInventory = yield* Effect.gen(function* () { + const service = yield* WorktreeService; + return yield* service.listWorktrees({ projectId: projectB }); + }).pipe(Effect.provide(layer)); + assert.equal(filteredInventory.worktrees.length, 1); + assert.deepEqual( + filteredInventory.worktrees[0]?.threads.map((thread) => thread.threadId), + [activeThread.id], + ); + assert.isFalse(filteredInventory.worktrees[0]?.safeToPrune); + + const pruneResult = yield* Effect.gen(function* () { + const service = yield* WorktreeService; + return yield* service.pruneWorktrees({ paths: [worktreePath] }); + }).pipe(Effect.provide(layer)); + assert.deepEqual(pruneResult.removed, []); + assert.deepEqual(pruneResult.skipped, [{ path: worktreePath, reason: "active_thread" }]); + assert.isTrue(yield* fs.exists(worktreePath)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("fails inventory when a repository worktree listing fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-worktree-v2-invalid-repo-", + }); + yield* driver.execute({ + operation: "WorktreeServiceListingFailureTest.init", + cwd: repositoryRoot, + args: ["init", "-b", "main"], + }); + const workspaceRoot = yield* fs.realPath(repositoryRoot); + // The repository resolves, but listing its worktrees blows up. + const failingDriver = GitVcsDriver.GitVcsDriver.of({ + ...driver, + listWorkspaces: () => driver.listWorkspaces(path.join(repositoryRoot, "missing")), + }); + const layer = makeTestLayer( + () => [makeProject(projectA, repositoryRoot)], + () => [], + failingDriver, + ); + + const error = yield* Effect.gen(function* () { + const service = yield* WorktreeService; + return yield* service.listWorktrees({}); + }).pipe(Effect.provide(layer), Effect.flip); + + assert.equal(error._tag, "WorktreeInventoryError"); + if (error._tag !== "WorktreeInventoryError") { + return assert.fail(`Expected WorktreeInventoryError, received ${error._tag}`); + } + assert.equal(error.stage, "inspect_repository"); + assert.equal(error.workspaceRoot, workspaceRoot); + assert.equal(error.message, "Failed to inspect a repository for the worktree inventory."); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("treats a detached worktree as safe only once its commit is on the default ref", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const driver = yield* GitVcsDriver.GitVcsDriver; + + const repositoryRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-worktree-v2-detached-repo-", + }); + const run = (operation: string, cwd: string, args: ReadonlyArray) => + driver.execute({ operation: `WorktreeServiceDetachedTest.${operation}`, cwd, args }); + yield* run("init", repositoryRoot, ["init", "-b", "main"]); + yield* run("userEmail", repositoryRoot, ["config", "user.email", "test@example.com"]); + yield* run("userName", repositoryRoot, ["config", "user.name", "T3 Test"]); + yield* fs.writeFileString(path.join(repositoryRoot, "README.md"), "hello\n"); + yield* run("add", repositoryRoot, ["add", "README.md"]); + yield* run("commit", repositoryRoot, ["commit", "-m", "initial"]); + + const mergedPath = path.join(config.worktreesDir, "detached", "merged"); + const aheadPath = path.join(config.worktreesDir, "detached", "ahead"); + yield* run("addMerged", repositoryRoot, ["worktree", "add", "--detach", mergedPath, "main"]); + yield* run("addAhead", repositoryRoot, ["worktree", "add", "--detach", aheadPath, "main"]); + yield* run("aheadCommit", aheadPath, ["commit", "--allow-empty", "-m", "detached work"]); + + const mergedRealPath = yield* fs.realPath(mergedPath); + + const aheadRealPath = yield* fs.realPath(aheadPath); + + const layer = makeTestLayer( + () => [makeProject(projectA, repositoryRoot)], + () => [], + ); + const { inventory, pruned } = yield* Effect.gen(function* () { + const service = yield* WorktreeService; + const inventory = yield* service.listWorktrees({}); + const pruned = yield* service.pruneWorktrees({ paths: [mergedPath, aheadPath] }); + return { inventory, pruned }; + }).pipe(Effect.provide(layer)); + + const byPath = new Map(inventory.worktrees.map((worktree) => [worktree.path, worktree])); + const merged = byPath.get(mergedRealPath); + const ahead = byPath.get(aheadRealPath); + assert.isDefined(merged); + assert.isDefined(ahead); + assert.isNull(merged.branch); + assert.isTrue(merged.safeToPrune); + assert.deepEqual(merged.pruneBlockers, []); + assert.equal(ahead.aheadOfDefaultCount, 1); + assert.deepEqual(ahead.pruneBlockers, ["unpushed"]); + + assert.deepEqual( + pruned.removed.map((entry) => entry.path), + [mergedRealPath], + ); + assert.deepEqual( + pruned.skipped.map((entry) => ({ path: entry.path, reason: entry.reason })), + [{ path: aheadRealPath, reason: "unpushed" }], + ); + assert.isFalse(yield* fs.exists(mergedPath)); + assert.isTrue(yield* fs.exists(aheadPath)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("skips a project outside any Git repository instead of failing the inventory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const driver = yield* GitVcsDriver.GitVcsDriver; + + const repositoryRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-worktree-v2-mixed-repo-", + }); + const plainRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-worktree-v2-plain-" }); + const run = (operation: string, cwd: string, args: ReadonlyArray) => + driver.execute({ operation: `WorktreeServiceMixedTest.${operation}`, cwd, args }); + yield* run("init", repositoryRoot, ["init", "-b", "main"]); + yield* run("userEmail", repositoryRoot, ["config", "user.email", "test@example.com"]); + yield* run("userName", repositoryRoot, ["config", "user.name", "T3 Test"]); + yield* fs.writeFileString(path.join(repositoryRoot, "README.md"), "hello\n"); + yield* run("add", repositoryRoot, ["add", "README.md"]); + yield* run("commit", repositoryRoot, ["commit", "-m", "initial"]); + const worktreePath = path.join(config.worktreesDir, "mixed", "feature"); + yield* driver.createWorktree({ + cwd: repositoryRoot, + refName: "main", + newRefName: "feature/mixed", + path: worktreePath, + }); + + const layer = makeTestLayer( + () => [makeProject(projectA, repositoryRoot), makeProject(projectB, plainRoot)], + () => [], + ); + const inventory = yield* Effect.gen(function* () { + const service = yield* WorktreeService; + return yield* service.listWorktrees({}); + }).pipe(Effect.provide(layer)); + + assert.deepEqual( + inventory.worktrees.map((worktree) => worktree.branch), + ["feature/mixed"], + ); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); diff --git a/apps/server/src/vcs/WorktreeService.ts b/apps/server/src/vcs/WorktreeService.ts new file mode 100644 index 000000000000..be68666bb92b --- /dev/null +++ b/apps/server/src/vcs/WorktreeService.ts @@ -0,0 +1,779 @@ +import * as Context from "effect/Context"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { + type GitCommandError, + type OrchestrationV2ThreadShell, + type ProjectId, + type VcsListWorktreesInput, + type VcsListWorktreesResult, + type VcsPruneWorktreesInput, + type VcsPruneWorktreesResult, + type WorktreeInfo, + type WorktreeInventoryErrorStage, + type WorktreePruneBlocker, + type WorktreePruneSkipReason, + type WorktreeProjectRef, + type WorktreeThreadRef, + type WorktreeThreadStatus, + WorktreeInventoryError, + WorktreeMutationError, +} from "@t3tools/contracts"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as WorktreeLifecycle from "./WorktreeLifecycle.ts"; + +const WORKTREE_STATUS_CONCURRENCY = 8; +const PROJECT_SCAN_CONCURRENCY = 4; + +interface ProjectGroup { + readonly canonicalWorkspaceRoot: string; + readonly projects: ReadonlyArray; +} + +interface ProjectReference { + readonly id: ProjectId; + readonly title: string; + readonly workspaceRoot: string; +} + +interface BranchSyncInfo { + readonly upstream: string | null; + readonly upstreamGone: boolean; + readonly aheadOfUpstreamCount: number | null; + readonly behindUpstreamCount: number | null; +} + +function parseBranchSyncInfo(stdout: string): Map { + const result = new Map(); + for (const line of stdout.split("\n")) { + if (line.length === 0) continue; + const [name, upstream = "", track = ""] = line.split("\t"); + if (!name) continue; + if (upstream.length === 0) { + result.set(name, { + upstream: null, + upstreamGone: false, + aheadOfUpstreamCount: null, + behindUpstreamCount: null, + }); + continue; + } + if (track.trim() === "gone") { + result.set(name, { + upstream, + upstreamGone: true, + aheadOfUpstreamCount: null, + behindUpstreamCount: null, + }); + continue; + } + const aheadMatch = /(?:^|, )ahead (\d+)/.exec(track); + const behindMatch = /(?:^|, )behind (\d+)/.exec(track); + result.set(name, { + upstream, + upstreamGone: false, + aheadOfUpstreamCount: aheadMatch ? Number(aheadMatch[1]) : 0, + behindUpstreamCount: behindMatch ? Number(behindMatch[1]) : 0, + }); + } + return result; +} + +function threadStatus(thread: OrchestrationV2ThreadShell): WorktreeThreadStatus { + if (thread.deletedAt !== null) return "deleted"; + if (thread.archivedAt !== null) return "archived"; + if (thread.settledOverride === "settled") return "settled"; + return "active"; +} + +function latestIsoTimestamp(values: ReadonlyArray): string | null { + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const value of values) { + const ms = Date.parse(value); + if (!Number.isNaN(ms) && ms > latestMs) { + latestMs = ms; + latest = value; + } + } + return latest; +} + +function isPathInside( + root: string, + candidate: string, + path: { + readonly relative: (from: string, to: string) => string; + readonly isAbsolute: (value: string) => boolean; + readonly sep: string; + }, +): boolean { + const relative = path.relative(root, candidate); + return ( + relative.length > 0 && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +function inventoryError( + stage: WorktreeInventoryErrorStage, + cause: unknown, + context: { readonly projectId?: ProjectId; readonly workspaceRoot?: string } = {}, +): WorktreeInventoryError { + return new WorktreeInventoryError({ + stage, + ...(context.projectId === undefined ? {} : { projectId: context.projectId }), + ...(context.workspaceRoot === undefined ? {} : { workspaceRoot: context.workspaceRoot }), + cause, + }); +} + +export class WorktreeService extends Context.Service< + WorktreeService, + { + readonly listWorktrees: ( + input: VcsListWorktreesInput, + ) => Effect.Effect; + readonly pruneWorktrees: ( + input: VcsPruneWorktreesInput, + ) => Effect.Effect; + readonly pruneOrphanedWorktree: ( + worktreePath: string, + ) => Effect.Effect; + } +>()("t3/vcs/WorktreeService") {} + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitVcsDriver.GitVcsDriver; + const projectsService = yield* ProjectService.ProjectService; + const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const lifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; + + // Canonicalize through symlinks so configured roots, Git metadata, and V2 + // thread paths compare equal on hosts such as macOS (/var vs /private/var). + const canonicalizePath = (value: string) => + fs.realPath(value).pipe(Effect.orElseSucceed(() => path.resolve(value))); + const managedWorktreesRoot = yield* canonicalizePath(config.worktreesDir); + + const executeLenient = (operation: string, cwd: string, args: ReadonlyArray) => + git + .execute({ + operation, + cwd, + args, + env: { LC_ALL: "C" }, + allowNonZeroExit: true, + timeoutMs: 15_000, + }) + .pipe(Effect.map((result) => (result.exitCode === 0 ? result.stdout : null))); + + const resolveDefaultRef = Effect.fn("WorktreeService.resolveDefaultRef")(function* (cwd: string) { + const originHead = yield* executeLenient("WorktreeService.resolveDefaultRef", cwd, [ + "symbolic-ref", + "refs/remotes/origin/HEAD", + ]); + if (originHead !== null && originHead.trim().length > 0) { + return originHead.trim(); + } + for (const candidate of ["refs/heads/main", "refs/heads/master"]) { + const result = yield* git.execute({ + operation: "WorktreeService.resolveDefaultRef.localFallback", + cwd, + args: ["show-ref", "--verify", "--quiet", candidate], + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + if (result.exitCode === 0) return candidate; + } + return null; + }); + + /** Commits on `revision` that the default ref does not have. A branch passes + its ref from the repository root; a detached worktree passes HEAD from its + own path. */ + const countAheadOfDefault = Effect.fn("WorktreeService.countAheadOfDefault")(function* ( + cwd: string, + revision: string, + defaultRef: string, + ) { + const stdout = yield* executeLenient("WorktreeService.countAheadOfDefault", cwd, [ + "rev-list", + "--count", + revision, + "--not", + defaultRef, + ]); + if (stdout === null) return null; + const count = Number(stdout.trim()); + return Number.isInteger(count) && count >= 0 ? count : null; + }); + + const freshPruneBlocker = Effect.fn("WorktreeService.freshPruneBlocker")(function* ( + worktree: WorktreeInfo, + ): Effect.fn.Return { + const status = yield* git.statusDetailsLocal(worktree.path); + if (!status.isRepo || status.branch !== worktree.branch) { + return "status_unavailable"; + } + if (status.hasWorkingTreeChanges) { + return "dirty"; + } + if (status.branch === null) { + // Detached HEAD: safe once its commit is already on the default ref. + const defaultRef = yield* resolveDefaultRef(worktree.workspaceRoot); + if (defaultRef === null) return "status_unavailable"; + const aheadOfDefaultCount = yield* countAheadOfDefault(worktree.path, "HEAD", defaultRef); + if (aheadOfDefaultCount === null) return "status_unavailable"; + return aheadOfDefaultCount > 0 ? "unpushed" : null; + } + + const branchSyncStdout = yield* executeLenient( + "WorktreeService.pruneWorktrees.branchSync", + worktree.workspaceRoot, + [ + "for-each-ref", + `refs/heads/${status.branch}`, + "--format=%(refname:lstrip=2)%09%(upstream:short)%09%(upstream:track,nobracket)", + ], + ); + if (branchSyncStdout === null) { + return "status_unavailable"; + } + + const sync = parseBranchSyncInfo(branchSyncStdout).get(status.branch); + if (sync === undefined) { + return "status_unavailable"; + } + if (sync.upstream !== null && !sync.upstreamGone) { + if (sync.aheadOfUpstreamCount === null) { + return "status_unavailable"; + } + return sync.aheadOfUpstreamCount > 0 ? "unpushed" : null; + } + + const defaultRef = yield* resolveDefaultRef(worktree.workspaceRoot); + if (defaultRef === null) { + return "status_unavailable"; + } + const aheadOfDefaultCount = yield* countAheadOfDefault( + worktree.workspaceRoot, + `refs/heads/${status.branch}`, + defaultRef, + ); + if (aheadOfDefaultCount === null) { + return "status_unavailable"; + } + return aheadOfDefaultCount > 0 ? "unpushed" : null; + }); + + /** Working-tree change count from one `git status` call; null when status + cannot be read. The inventory does not need the diffs and remote lookups + that statusDetails also performs, and they dominate its cost. */ + const readWorkingTreeChanges = Effect.fn("WorktreeService.readWorkingTreeChanges")( + function* (worktreePath: string) { + const stdout = yield* executeLenient("WorktreeService.readWorkingTreeChanges", worktreePath, [ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=normal", + ]); + if (stdout === null) return { dirty: null, dirtyFileCount: null }; + const tokens = stdout.split("\0"); + let count = 0; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === undefined || token.length === 0) continue; + count += 1; + // Renames and copies carry the original path as the following token. + if (token[0] === "R" || token[0] === "C" || token[1] === "R" || token[1] === "C") { + index += 1; + } + } + return { dirty: count > 0, dirtyFileCount: count }; + }, + // Interrupts propagate; anything else degrades this row to unknown status. + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.gen(function* () { + yield* Effect.logWarning("worktrees.inventory.status-failed", { cause }); + return { dirty: null, dirtyFileCount: null }; + }), + ), + ); + + const worktreeMtime = (worktreePath: string) => + fs.stat(worktreePath).pipe( + Effect.map((info) => + Option.match(info.mtime, { + onNone: () => null, + onSome: (mtime) => mtime.toISOString(), + }), + ), + Effect.orElseSucceed(() => null), + ); + + const listGroup = Effect.fn("WorktreeService.listGroup")(function* ( + group: ProjectGroup, + shells: ReadonlyArray, + ) { + const primaryProject = group.projects[0]; + if (primaryProject === undefined) return [] as WorktreeInfo[]; + + // The workspace listing, branch sync counters, and default ref only need + // the group root, so they run together instead of as three spawn rounds. + const [entries, branchSyncStdout, defaultRef] = yield* Effect.all( + [ + git.listWorkspaces(group.canonicalWorkspaceRoot).pipe( + Effect.flatMap((rawEntries) => + Effect.forEach(rawEntries, (entry) => + canonicalizePath(entry.path).pipe( + Effect.map((canonicalPath) => ({ ...entry, path: canonicalPath })), + ), + ), + ), + Effect.map((rawEntries) => + rawEntries.filter((entry) => isPathInside(managedWorktreesRoot, entry.path, path)), + ), + ), + executeLenient("WorktreeService.listGroup.branchSync", group.canonicalWorkspaceRoot, [ + "for-each-ref", + "refs/heads", + "--format=%(refname:lstrip=2)%09%(upstream:short)%09%(upstream:track,nobracket)", + ]), + resolveDefaultRef(group.canonicalWorkspaceRoot), + ], + { concurrency: 3 }, + ); + if (entries.length === 0) return [] as WorktreeInfo[]; + + const projectIds = new Set(group.projects.map((project) => project.id)); + const threadsByPath = new Map(); + for (const thread of shells) { + if (!projectIds.has(thread.projectId) || thread.worktreePath === null) continue; + const resolvedPath = yield* canonicalizePath(thread.worktreePath); + const bucket = threadsByPath.get(resolvedPath); + if (bucket === undefined) { + threadsByPath.set(resolvedPath, [thread]); + } else { + bucket.push(thread); + } + } + + const branchSyncUnavailable = branchSyncStdout === null; + const branchSync = parseBranchSyncInfo(branchSyncStdout ?? ""); + + return yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const linkedThreads = threadsByPath.get(entry.path) ?? []; + const threadRefs: WorktreeThreadRef[] = linkedThreads.map((thread) => ({ + threadId: thread.id, + title: thread.title, + status: threadStatus(thread), + })); + const primaryThreadActivity = latestIsoTimestamp( + linkedThreads + .filter((thread) => thread.deletedAt === null) + .map((thread) => DateTime.formatIso(thread.updatedAt)), + ); + const lastActivityAt = + primaryThreadActivity ?? + latestIsoTimestamp( + linkedThreads.map((thread) => DateTime.formatIso(thread.updatedAt)), + ) ?? + (yield* worktreeMtime(entry.path)); + + const sync = entry.refName === null ? undefined : branchSync.get(entry.refName); + const upstreamUnavailable = + branchSyncUnavailable || (entry.refName !== null && sync === undefined); + const hasUpstream = upstreamUnavailable + ? null + : sync?.upstream !== null && sync !== undefined; + const upstreamGone = sync?.upstreamGone ?? false; + const aheadOfUpstreamCount = sync?.aheadOfUpstreamCount ?? null; + const behindUpstreamCount = sync?.behindUpstreamCount ?? null; + + const [status, aheadOfDefaultCount] = yield* Effect.all( + [ + readWorkingTreeChanges(entry.path), + defaultRef === null + ? Effect.succeed(null) + : entry.refName === null + ? countAheadOfDefault(entry.path, "HEAD", defaultRef) + : countAheadOfDefault( + group.canonicalWorkspaceRoot, + `refs/heads/${entry.refName}`, + defaultRef, + ), + ], + { concurrency: 2 }, + ); + + const blockers = new Set(); + if (threadRefs.some((thread) => thread.status === "active")) { + blockers.add("active_thread"); + } + if (status.dirty === true) { + blockers.add("dirty"); + } else if (status.dirty === null) { + blockers.add("status_unavailable"); + } + if (entry.refName !== null && upstreamUnavailable) { + blockers.add("status_unavailable"); + } else if ( + entry.refName !== null && + sync?.upstream !== null && + sync !== undefined && + !sync.upstreamGone + ) { + if (aheadOfUpstreamCount === null) { + blockers.add("status_unavailable"); + } else if (aheadOfUpstreamCount > 0) { + blockers.add("unpushed"); + } + } else if (aheadOfDefaultCount === null) { + // A branch with no usable upstream, or a detached HEAD, is only + // safe once its commits can be compared with the default ref. + blockers.add("status_unavailable"); + } else if (aheadOfDefaultCount > 0) { + blockers.add("unpushed"); + } + + const projects: WorktreeProjectRef[] = group.projects.map((project) => ({ + projectId: project.id, + projectTitle: project.title, + workspaceRoot: project.workspaceRoot, + })); + + return { + projectId: primaryProject.id, + projectTitle: primaryProject.title, + workspaceRoot: primaryProject.workspaceRoot, + projects, + path: entry.path, + branch: entry.refName, + threads: threadRefs, + // A worktree that never had a thread is not an orphan: it may be a + // launch still binding its thread, or a checkout the user made. + orphaned: + threadRefs.length > 0 && threadRefs.every((thread) => thread.status === "deleted"), + dirty: status.dirty, + dirtyFileCount: status.dirtyFileCount, + hasUpstream, + upstreamGone, + aheadOfUpstreamCount, + behindUpstreamCount, + aheadOfDefaultCount, + lastActivityAt, + safeToPrune: blockers.size === 0, + pruneBlockers: [...blockers], + } satisfies WorktreeInfo; + }), + { concurrency: WORKTREE_STATUS_CONCURRENCY }, + ); + }); + + const listWorktrees: WorktreeService["Service"]["listWorktrees"] = Effect.fn( + "WorktreeService.listWorktrees", + )(function* (input) { + const projectSnapshot = yield* projectsService.snapshot.pipe( + Effect.mapError((cause) => inventoryError("load_projects", cause)), + ); + const shellSnapshot = yield* threadManagement + .getShellSnapshot() + .pipe(Effect.mapError((cause) => inventoryError("load_threads", cause))); + + const normalizedProjects = yield* Effect.forEach( + projectSnapshot.projects, + (project) => + Effect.gen(function* () { + const canonicalWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); + // A project outside any Git repository, or whose directory is gone, + // has no worktrees to list; skip it rather than fail the inventory + // for every other project and stall the reaper. + const commonDir = yield* executeLenient( + "WorktreeService.listWorktrees.repositoryKey", + canonicalWorkspaceRoot, + ["rev-parse", "--git-common-dir"], + ).pipe( + Effect.catch((cause) => + Effect.logWarning("worktrees.inventory.repository-probe-failed", { + projectId: project.id, + workspaceRoot: canonicalWorkspaceRoot, + cause, + }).pipe(Effect.as(null)), + ), + ); + if (commonDir === null) return null; + const repositoryKey = + commonDir.trim().length === 0 + ? canonicalWorkspaceRoot + : yield* canonicalizePath(path.resolve(canonicalWorkspaceRoot, commonDir.trim())); + return { + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + canonicalWorkspaceRoot, + repositoryKey, + }; + }), + { concurrency: PROJECT_SCAN_CONCURRENCY }, + ).pipe(Effect.map((projects) => projects.filter((project) => project !== null))); + const selectedRepositoryKeys = + input.projectId === undefined + ? null + : new Set( + normalizedProjects + .filter((project) => project.id === input.projectId) + .map((project) => project.repositoryKey), + ); + + const groups = new Map(); + for (const project of normalizedProjects) { + if (selectedRepositoryKeys !== null && !selectedRepositoryKeys.has(project.repositoryKey)) { + continue; + } + const existing = groups.get(project.repositoryKey); + if (existing === undefined) { + groups.set(project.repositoryKey, { + canonicalWorkspaceRoot: project.canonicalWorkspaceRoot, + projects: [project], + }); + } else { + groups.set(project.repositoryKey, { + ...existing, + projects: [...existing.projects, project], + }); + } + } + + const records = yield* Effect.forEach( + [...groups.values()], + (group) => + listGroup(group, [...shellSnapshot.threads, ...shellSnapshot.archivedThreads]).pipe( + Effect.mapError((cause) => + inventoryError("inspect_repository", cause, { + workspaceRoot: group.canonicalWorkspaceRoot, + }), + ), + ), + { concurrency: PROJECT_SCAN_CONCURRENCY }, + ); + const worktrees = records.flat().toSorted((a, b) => { + const aMs = a.lastActivityAt === null ? 0 : Date.parse(a.lastActivityAt); + const bMs = b.lastActivityAt === null ? 0 : Date.parse(b.lastActivityAt); + return aMs - bMs; + }); + return { worktrees }; + }); + + // Fresh Git and thread rechecks plus the removal run under the mutation + // permit per worktree, so a sweep over many stale worktrees never holds + // every turn start behind the whole batch. + const removeIfStillSafe = Effect.fn("WorktreeService.removeIfStillSafe")(function* ( + worktree: WorktreeInfo, + options?: { readonly requireOrphaned?: boolean }, + ) { + const freshBlocker = yield* freshPruneBlocker(worktree).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.gen(function* () { + yield* Effect.logWarning("worktrees.prune.status-failed", { + worktreePath: worktree.path, + cause, + }); + return "status_unavailable" as const; + }), + ), + ); + if (freshBlocker !== null) { + return { outcome: "skipped" as const, reason: freshBlocker }; + } + + // Threads can link to a worktree without taking the mutation permit + // (branch carry-over, unsettle), so linkage is rechecked on a fresh + // shell snapshot immediately before removal, like Git state above. + const freshThreadCheck = yield* threadManagement.getShellSnapshot().pipe( + Effect.flatMap((snapshot) => + Effect.gen(function* () { + const references: WorktreeThreadStatus[] = []; + for (const thread of [...snapshot.threads, ...snapshot.archivedThreads]) { + if (thread.worktreePath === null) continue; + const status = threadStatus(thread); + if (status === "deleted") continue; + const resolvedPath = yield* canonicalizePath(thread.worktreePath); + if (resolvedPath === worktree.path) references.push(status); + } + return { ok: true as const, references }; + }), + ), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning("worktrees.prune.thread-recheck-failed", { + worktreePath: worktree.path, + cause, + }).pipe(Effect.as({ ok: false as const, references: [] as WorktreeThreadStatus[] })), + ), + ); + if (!freshThreadCheck.ok) { + return { outcome: "skipped" as const, reason: "status_unavailable" as const }; + } + if (options?.requireOrphaned === true && freshThreadCheck.references.length > 0) { + return { outcome: "ignored" as const }; + } + if (freshThreadCheck.references.includes("active")) { + return { outcome: "skipped" as const, reason: "active_thread" as const }; + } + + const removal = yield* git + .removeWorktree({ cwd: worktree.workspaceRoot, path: worktree.path }) + .pipe( + Effect.match({ + onFailure: (cause) => ({ ok: false as const, cause }), + onSuccess: () => ({ ok: true as const }), + }), + ); + if (!removal.ok) { + return { + outcome: "skipped" as const, + reason: "remove_failed" as const, + detail: removal.cause.message, + }; + } + return { outcome: "removed" as const }; + }); + + const pruneWorktreesInternal = Effect.fn("WorktreeService.pruneWorktrees")(function* ( + input: VcsPruneWorktreesInput, + options?: { readonly requireOrphaned?: boolean }, + ) { + // Re-derive the inventory so safety reflects the current state, never a + // stale client view. Git's non-forced remove is a second safety boundary. + const { worktrees } = yield* listWorktrees({}).pipe( + Effect.mapError( + (cause) => + new WorktreeMutationError({ + operation: "prune", + stage: "revalidate_inventory", + cause, + }), + ), + ); + const byPath = new Map(worktrees.map((worktree) => [worktree.path, worktree])); + const removed: Array<{ path: string; workspaceRoot: string }> = []; + const skipped: Array<{ + path: string; + reason: WorktreePruneSkipReason; + detail?: string; + }> = []; + const touchedRoots = new Set(); + const processedPaths = new Set(); + + for (const requestedPath of input.paths) { + const canonicalPath = yield* canonicalizePath(requestedPath); + if (processedPaths.has(canonicalPath)) continue; + processedPaths.add(canonicalPath); + + const worktree = byPath.get(canonicalPath); + if (worktree === undefined) { + skipped.push({ path: requestedPath, reason: "unknown_worktree" }); + continue; + } + // Thread deletion cleanup is allowed to remove only an orphan. This is + // deliberately checked on the fresh inventory above so a worktree that + // became shared after the deletion event cannot be removed by a stale + // observation. + if (options?.requireOrphaned === true && !worktree.orphaned) { + continue; + } + if (!worktree.safeToPrune) { + skipped.push({ + path: worktree.path, + reason: worktree.pruneBlockers[0] ?? "status_unavailable", + }); + continue; + } + + const result = yield* lifecycle.withMutationPermit(removeIfStillSafe(worktree, options)); + if (result.outcome === "ignored") continue; + if (result.outcome === "skipped") { + skipped.push({ + path: worktree.path, + reason: result.reason, + ...(result.detail === undefined ? {} : { detail: result.detail }), + }); + continue; + } + removed.push({ path: worktree.path, workspaceRoot: worktree.workspaceRoot }); + touchedRoots.add(worktree.workspaceRoot); + } + + // Do not remove branches or checkpoint refs. This only clears stale Git + // worktree registrations left by an already completed removal. + yield* Effect.forEach( + [...touchedRoots], + (workspaceRoot) => + git + .execute({ + operation: "WorktreeService.pruneWorktrees.gitPrune", + cwd: workspaceRoot, + args: ["worktree", "prune"], + env: { LC_ALL: "C" }, + timeoutMs: 15_000, + }) + .pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("worktrees.prune.git-prune-failed", { + workspaceRoot, + cause, + }), + ), + ), + { concurrency: 1 }, + ); + + if (removed.length > 0) { + yield* lifecycle.markInventoryChanged; + yield* Effect.logInfo("worktrees.pruned", { + removedCount: removed.length, + skippedCount: skipped.length, + removedPaths: removed.map((entry) => entry.path), + }); + } + + return { removed, skipped }; + }); + + const pruneWorktrees = (input: VcsPruneWorktreesInput) => pruneWorktreesInternal(input); + const pruneOrphanedWorktree = (worktreePath: string) => + pruneWorktreesInternal({ paths: [worktreePath] }, { requireOrphaned: true }).pipe( + Effect.map((result) => result.removed.length > 0), + ); + return WorktreeService.of({ + listWorktrees, + pruneWorktrees, + pruneOrphanedWorktree, + }); +}); + +export const layer = Layer.effect(WorktreeService, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ad8261e68710..9a4115e1eb63 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -194,6 +194,8 @@ import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as WorktreeLifecycle from "./vcs/WorktreeLifecycle.ts"; +import * as WorktreeService from "./vcs/WorktreeService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import { refreshPushedPullRequests } from "./git/refreshPushedPullRequests.ts"; import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; @@ -1141,6 +1143,8 @@ const makeWsRpcLayer = ( const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const worktreeLifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; + const worktrees = yield* WorktreeService.WorktreeService; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -3245,6 +3249,18 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { "rpc.aggregate": "vcs", }), + [WS_METHODS.vcsListWorktrees]: (input) => + observeRpcEffect(WS_METHODS.vcsListWorktrees, worktrees.listWorktrees(input), { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.subscribeWorktreeInventory]: () => + observeRpcStream(WS_METHODS.subscribeWorktreeInventory, worktreeLifecycle.changes, { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.vcsPruneWorktrees]: (input) => + observeRpcEffect(WS_METHODS.vcsPruneWorktrees, worktrees.pruneWorktrees(input), { + "rpc.aggregate": "vcs", + }), [WS_METHODS.vcsCreateWorktree]: (input) => observeRpcEffect( WS_METHODS.vcsCreateWorktree, diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 9fba0edc7d44..bc1c78875444 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,29 +1,57 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; -import { ChevronDownIcon } from "lucide-react"; +import { + ArrowUpRightIcon, + ChevronDownIcon, + Clock3Icon, + GitBranchIcon, + RefreshCwIcon, +} from "lucide-react"; +import { Link } from "@tanstack/react-router"; import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; -import { useEffect, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useAtomValue } from "@effect/atom-react"; import type { BackgroundActivitySettings, + EnvironmentId, SourceControlProviderKind, SourceControlDiscoveryResult, SourceControlProviderAuth, SourceControlProviderDiscoveryItem, VcsDriverKind, VcsDiscoveryItem, + WorktreeInfo, } from "@t3tools/contracts"; import { getBackgroundActivityBaseProfile, getBackgroundActivityPresetSettings, resolveServerBackgroundActivitySettings, } from "@t3tools/shared/backgroundActivitySettings"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { useSettingsScope } from "./SettingsScopeContext"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { cn } from "../../lib/utils"; +import { useAtomCommand } from "../../state/use-atom-command"; + +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { useProject } from "../../state/entities"; +import { environmentServerConfigsAtom } from "../../state/server"; import { useEnvironmentQuery } from "../../state/query"; import { sourceControlEnvironment } from "../../state/sourceControl"; +import { worktreeEnvironment } from "../../state/worktrees"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; @@ -44,6 +72,7 @@ import { NumberFieldInput, } from "../ui/number-field"; import { Switch } from "../ui/switch"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { @@ -56,6 +85,7 @@ import { JujutsuIcon, type Icon, } from "../Icons"; +import { ProjectFavicon } from "../ProjectFavicon"; import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; @@ -63,8 +93,10 @@ import { PolicyTooltip, SettingResetButton, SettingsPageContainer, + SettingsRow, SettingsSearchTarget, SettingsSection, + useRelativeTimeTick, useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -506,6 +538,705 @@ function EmptySourceControlDiscovery({ ); } +type WorktreeManagementViewProps = { + readonly environmentId: EnvironmentId; + readonly worktrees: ReadonlyArray; + readonly onPrune: (worktree: WorktreeInfo) => void; + readonly pendingPath: string | null; + readonly inventoryError: string | null; + readonly isPending: boolean; + readonly pruneAfterDays: number | null; + readonly deleteOrphanedImmediately: boolean; + readonly canResetPruneAfterDays: boolean; + readonly canResetDeleteOrphaned: boolean; + readonly onPruneAfterDaysChange: (days: number | null) => void; + readonly onDeleteOrphanedImmediatelyChange: (checked: boolean) => void; + readonly onResetPruneAfterDays: () => void; + readonly onResetDeleteOrphaned: () => void; +}; + +function WorktreeRetentionSelect({ + pruneAfterDays, + onChange, + className = "w-full sm:w-36", +}: { + readonly pruneAfterDays: number | null; + readonly onChange: (days: number | null) => void; + readonly className?: string; +}) { + const value = pruneAfterDays === null ? "never" : String(pruneAfterDays); + return ( + + ); +} + +type WorktreeRowProps = { + readonly environmentId: EnvironmentId; + readonly worktree: WorktreeInfo; + readonly onPrune: (worktree: WorktreeInfo) => void; + readonly pendingPath: string | null; +}; + +/** Every blocker in short form: "4 changed, 2 unpushed". */ +function worktreeBlockerSummary(worktree: WorktreeInfo): string { + return worktree.pruneBlockers + .map((blocker) => { + switch (blocker) { + case "active_thread": { + const active = worktree.threads.filter((thread) => thread.status === "active").length; + return active > 1 ? `${active} active` : "In use"; + } + case "dirty": + return worktree.dirtyFileCount ? `${worktree.dirtyFileCount} changed` : "Local changes"; + case "unpushed": { + // Without an upstream (detached HEAD, or a branch that was never + // pushed) the count is against the default branch instead. + const count = worktree.aheadOfUpstreamCount ?? worktree.aheadOfDefaultCount; + const label = worktree.hasUpstream ? "unpushed" : "unmerged"; + return count ? `${count} ${label}` : "Unpushed"; + } + case "status_unavailable": + return "Status unavailable"; + } + }) + .join(", "); +} + +/** Only an active thread reads as neutral; local work and unknown status warn. */ +function worktreeBlockerIsWarning(worktree: WorktreeInfo): boolean { + return worktree.pruneBlockers.some((blocker) => blocker !== "active_thread"); +} + +function formatCompactRelativeTime(iso: string): string { + const minutes = Math.max(0, Math.floor((Date.now() - Date.parse(iso)) / 60_000)); + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + return months < 12 ? `${months}mo ago` : `${Math.floor(days / 365)}y ago`; +} + +function worktreeSyncSummary(worktree: WorktreeInfo): string | null { + if (worktree.upstreamGone) return "upstream gone"; + const parts = [ + worktree.aheadOfUpstreamCount ? `${worktree.aheadOfUpstreamCount} ahead` : null, + worktree.behindUpstreamCount ? `${worktree.behindUpstreamCount} behind` : null, + ].filter((part) => part !== null); + return parts.length > 0 ? parts.join(", ") : null; +} + +/** Thread shown for a worktree: active first, then settled, then whatever is left. */ +function primaryLinkedThread(worktree: WorktreeInfo) { + const linked = worktree.threads.filter((thread) => thread.status !== "deleted"); + const thread = + linked.find((candidate) => candidate.status === "active") ?? + linked.find((candidate) => candidate.status === "settled") ?? + linked[0] ?? + null; + return { thread, otherCount: thread === null ? 0 : linked.length - 1 }; +} + +function WorktreeThreadLabel({ + environmentId, + worktree, +}: { + readonly environmentId: EnvironmentId; + readonly worktree: WorktreeInfo; +}) { + const { thread, otherCount } = primaryLinkedThread(worktree); + if (thread === null) return No linked threads; + const title = thread.title || "Untitled thread"; + return ( + + {thread.status === "archived" ? ( + Archived: {title} + ) : ( + + + {title} + + )} + {otherCount > 0 ? ( + +{otherCount} + ) : null} + + ); +} + +/** thread · used · sync, all on one line. */ +function WorktreeMetadata({ + environmentId, + worktree, +}: { + readonly environmentId: EnvironmentId; + readonly worktree: WorktreeInfo; +}) { + const sync = worktreeSyncSummary(worktree); + return ( + + + {worktree.lastActivityAt ? ( + + · {formatCompactRelativeTime(worktree.lastActivityAt)} + + ) : null} + {sync ? ( + · {sync} + ) : null} + + ); +} + +/** House inline error strip. */ +function WorktreeErrorStrip({ error }: { readonly error: string | null }) { + if (error === null) return null; + return ( + + Couldn't load worktrees. + {error} + + ); +} + +/** House pending strip: live label, no skeletons. */ +function WorktreePendingStrip({ label }: { readonly label: string }) { + return ( +
+ {label} +
+ ); +} + +/** Settings-list empty medallion (matches Scheduled Tasks). */ +function WorktreeEmptyMedallion() { + return ( +
+
+ +
+
+

No worktrees yet

+

+ Worktrees created for threads show up here. +

+
+
+ ); +} + +/** Right-edge cell with a stable shape: Remove action, blocker summary, or in-flight label. */ +function WorktreeStateCell({ worktree, onPrune, pendingPath }: WorktreeRowProps) { + if (pendingPath === worktree.path) { + return ( + + Removing... + + ); + } + if (worktree.safeToPrune) { + return ( + + ); + } + return ( + + {worktreeBlockerSummary(worktree)} + + ); +} + +/** Cleanup policy as canonical settings rows. */ +function WorktreePolicyRows(props: WorktreeManagementViewProps) { + return ( + <> + + ) : undefined + } + control={ + + } + /> + + ) : undefined + } + control={ + props.onDeleteOrphanedImmediatelyChange(Boolean(checked))} + aria-label="Remove a worktree when its last linked thread is deleted" + /> + } + /> + + ); +} + +function WorktreeRow({ environmentId, worktree, onPrune, pendingPath }: WorktreeRowProps) { + return ( +
+ + + {worktree.branch ?? "Detached HEAD"} + + + · + + + + +
+ ); +} + +type WorktreeProjectGroup = { + readonly projectId: WorktreeInfo["projectId"]; + readonly projectTitle: string; + readonly projectTitles: ReadonlyArray; + readonly workspaceRoot: string; + readonly worktrees: ReadonlyArray; +}; + +function groupWorktreesByProject( + worktrees: ReadonlyArray, +): ReadonlyArray { + const groups = new Map< + WorktreeInfo["projectId"], + Omit & { worktrees: WorktreeInfo[] } + >(); + for (const worktree of worktrees) { + const existing = groups.get(worktree.projectId); + if (existing) { + existing.worktrees.push(worktree); + } else { + groups.set(worktree.projectId, { + projectId: worktree.projectId, + projectTitle: worktree.projectTitle, + projectTitles: worktree.projects.map((project) => project.projectTitle), + workspaceRoot: worktree.workspaceRoot, + worktrees: [worktree], + }); + } + } + return [...groups.values()].toSorted( + (a, b) => + a.projectTitle.localeCompare(b.projectTitle) || + a.workspaceRoot.localeCompare(b.workspaceRoot), + ); +} + +/** Project heading; the workspace path lives in the title tooltip. */ +function WorktreeGroupHeading({ + environmentId, + group, +}: { + readonly environmentId: EnvironmentId; + readonly group: WorktreeProjectGroup; +}) { + const otherProjectCount = group.projectTitles.length - 1; + const project = useProject({ environmentId, projectId: group.projectId }); + return ( +
+ {project ? : null} + + {group.projectTitle} + } + /> + + {group.workspaceRoot} + + + {otherProjectCount > 0 ? ( + + +{otherProjectCount} project{otherProjectCount === 1 ? "" : "s"} + + ) : null} +
+ ); +} + +function WorktreeLedgerView(props: WorktreeManagementViewProps) { + useRelativeTimeTick(30_000); + const groups = groupWorktreesByProject(props.worktrees); + return ( +
+ + + {props.isPending ? ( + + ) : groups.length === 0 ? ( + + ) : ( + groups.map((group) => ( +
+ +
+ {[...group.worktrees] + .sort((a, b) => Number(b.safeToPrune) - Number(a.safeToPrune)) + .map((worktree) => ( + + ))} +
+
+ )) + )} +
+ ); +} + +function WorktreePruneConfirmation({ + open, + worktree, + onOpenChange, + onOpenChangeComplete, + onConfirm, +}: { + readonly open: boolean; + readonly worktree: WorktreeInfo | null; + readonly onOpenChange: (open: boolean) => void; + readonly onOpenChangeComplete: (open: boolean) => void; + readonly onConfirm: () => void; +}) { + const branch = worktree?.branch ?? "Detached HEAD"; + + return ( + + + + Remove {branch}? + + {worktree?.branch === null + ? "This removes the worktree checkout. Its commit stays in the repository." + : "This removes the worktree checkout. The local branch and checkpoint refs will be kept."} + + + + }>Cancel + + + + + ); +} + +type WorktreeEnvironmentTarget = { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPrimary: boolean; +}; + +/** Worktree inventory and cleanup policy for one environment. Mounted per + environment, so state can never leak across servers. */ +function WorktreeEnvironmentGroup({ + target, + showLabel, + refreshToken, + onPendingChange, +}: { + readonly target: WorktreeEnvironmentTarget; + readonly showLabel: boolean; + readonly refreshToken: number; + readonly onPendingChange: (environmentId: EnvironmentId, pending: boolean) => void; +}) { + const environmentId = target.environmentId; + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const inventory = useEnvironmentQuery(worktreeEnvironment.list({ environmentId, input: {} })); + const inventoryChanges = useEnvironmentQuery( + worktreeEnvironment.changes({ environmentId, input: {} }), + ); + const pruneWorktrees = useAtomCommand(worktreeEnvironment.prune, { + label: "prune worktrees", + }); + const [pendingPath, setPendingPath] = useState(null); + const [pruneCandidate, setPruneCandidate] = useState(null); + const [pruneDialogOpen, setPruneDialogOpen] = useState(false); + // Paths the server confirmed removed but the inventory has not re-read yet. + // Hides them right away; the next inventory read clears the set, so a + // worktree revived at the same path shows up again with that read. + const [removedPaths, setRemovedPaths] = useState>(() => new Set()); + const inventoryWorktrees = inventory.data?.worktrees ?? []; + const worktrees = + removedPaths.size === 0 + ? inventoryWorktrees + : inventoryWorktrees.filter((worktree) => !removedPaths.has(worktree.path)); + const observedInventoryRevision = useRef(null); + + useEffect(() => { + const revision = inventoryChanges.data?.revision; + if (revision === undefined || observedInventoryRevision.current === revision) return; + // The first revision describes the state the initial query already read; + // only later bumps mean the inventory changed underneath us. + const isFirstObservation = observedInventoryRevision.current === null; + observedInventoryRevision.current = revision; + if (!isFirstObservation) inventory.refresh(); + }, [inventory.refresh, inventoryChanges.data?.revision]); + + useEffect(() => { + if (refreshToken === 0) return; + inventory.refresh(); + }, [inventory.refresh, refreshToken]); + + useEffect(() => { + if (inventory.data === null) return; + setRemovedPaths((current) => (current.size === 0 ? current : new Set())); + }, [inventory.data]); + + useEffect(() => { + onPendingChange(environmentId, inventory.isPending); + }, [environmentId, inventory.isPending, onPendingChange]); + + useEffect( + () => () => { + onPendingChange(environmentId, false); + }, + [environmentId, onPendingChange], + ); + + const handlePrune = (worktree: WorktreeInfo) => { + if (!worktree.safeToPrune || pendingPath !== null) return; + setPruneCandidate(worktree); + setPruneDialogOpen(true); + }; + + const handleConfirmPrune = () => { + if (pruneCandidate === null || pendingPath !== null) return; + const worktree = pruneCandidate; + setPendingPath(worktree.path); + setPruneDialogOpen(false); + void pruneWorktrees({ environmentId, input: { paths: [worktree.path] } }) + .then((result) => { + if (result._tag !== "Success" || result.value.removed.length === 0) return; + const removed = new Set(result.value.removed.map((entry) => entry.path)); + setRemovedPaths((current) => new Set([...current, ...removed])); + }) + .finally(() => { + setPendingPath(null); + inventory.refresh(); + }); + }; + + const pruneAfterDays = settings.worktrees.autoPruneAfterDays; + const deleteOrphanedImmediately = settings.worktrees.deleteOrphanedImmediately; + const defaults = DEFAULT_UNIFIED_SETTINGS.worktrees; + const isInitialInventoryPending = inventory.isPending && inventory.data === null; + const managementProps: WorktreeManagementViewProps = { + environmentId, + worktrees, + onPrune: handlePrune, + pendingPath, + inventoryError: inventory.error ?? null, + isPending: isInitialInventoryPending, + pruneAfterDays, + deleteOrphanedImmediately, + canResetPruneAfterDays: pruneAfterDays !== defaults.autoPruneAfterDays, + canResetDeleteOrphaned: deleteOrphanedImmediately !== defaults.deleteOrphanedImmediately, + onPruneAfterDaysChange: (days) => updateSettings({ worktrees: { autoPruneAfterDays: days } }), + onDeleteOrphanedImmediatelyChange: (checked) => + updateSettings({ worktrees: { deleteOrphanedImmediately: checked } }), + onResetPruneAfterDays: () => + updateSettings({ worktrees: { autoPruneAfterDays: defaults.autoPruneAfterDays } }), + onResetDeleteOrphaned: () => + updateSettings({ + worktrees: { deleteOrphanedImmediately: defaults.deleteOrphanedImmediately }, + }), + }; + + return ( +
+ {showLabel ? ( +
+

{target.label}

+ {target.isPrimary ? ( + primary + ) : null} +
+ ) : null} + + { + if (!open) setPruneCandidate(null); + }} + onConfirm={handleConfirmPrune} + /> +
+ ); +} + +function WorktreeManagementSection() { + const { environments } = useEnvironments(); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const [refreshToken, setRefreshToken] = useState(0); + const [pendingEnvironmentIds, setPendingEnvironmentIds] = useState>( + () => new Set(), + ); + const handlePendingChange = useCallback((environmentId: EnvironmentId, pending: boolean) => { + setPendingEnvironmentIds((current) => { + const alreadyPending = current.has(environmentId); + if (alreadyPending === pending) return current; + const next = new Set(current); + if (pending) next.add(environmentId); + else next.delete(environmentId); + return next; + }); + }, []); + const isPending = pendingEnvironmentIds.size > 0; + const targets: WorktreeEnvironmentTarget[] = environments + .filter( + (environment) => + environment.connection.phase === "connected" && + serverConfigs.get(environment.environmentId)?.environment.capabilities + .worktreeManagement === true, + ) + .map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + isPrimary: environment.environmentId === primaryEnvironmentId, + })) + .sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary) || a.label.localeCompare(b.label)); + + return ( + } + headerAction={ + + setRefreshToken((token) => token + 1)} + disabled={isPending} + aria-busy={isPending} + aria-label={ + isPending ? "Refreshing worktree inventory" : "Refresh worktree inventory" + } + > + {isPending ? ( + + ) : ( + + )} + + } + /> + + {isPending ? "Refreshing worktree inventory" : "Refresh worktree inventory"} + + + } + > + {targets.length === 0 ? ( +

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

+ ) : ( +
+ {targets.map((target) => ( + 1} + refreshToken={refreshToken} + onPendingChange={handlePendingChange} + /> + ))} +
+ )} +
+ ); +} + export function SourceControlSettingsPanel() { const { scope, environment, connectedEnvironments } = useSettingsScope(); // Discovery scans one machine's tools, so it shows the representative @@ -617,6 +1348,7 @@ export function SourceControlSettingsPanel() { /> )} + ); diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 76f26b2eeab6..57e44fb2e057 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -70,6 +70,7 @@ describe("searchSettings", () => { localeLowerCase.mockRestore(); } expect(searchSettings("xyzzy")).toEqual([]); + expect(searchSettings("worktrees")[0]?.id).toBe("worktrees"); }); it("keeps catalog order for multiple title matches", () => { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 0e86928e96d4..30597704e8d2 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -834,6 +834,11 @@ export const SETTINGS_SEARCH_ITEMS = [ "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint", ], }, + { + id: "worktrees", + title: "Worktrees", + to: "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/settings/source-control", + }, { id: "remote-environments", title: "Environments", diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 1ca620c5ccc7..9b70e1d1a229 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -34,6 +34,7 @@ import { readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, readEnvironmentSupportsVisitedTracking, + readEnvironmentSupportsWorktreeManagement, readEnvironmentThreadRefs, readProject, readThreadShell, @@ -404,10 +405,15 @@ export function useThreadActions() { const shell = readThreadShell(ref); return shell === null ? [] : [shell]; }); - const threadProject = readProject({ - environmentId: threadRef.environmentId, - projectId: thread.projectId, - }); + const needsLegacyWorktreeCleanup = !readEnvironmentSupportsWorktreeManagement( + threadRef.environmentId, + ); + const threadProject = needsLegacyWorktreeCleanup + ? readProject({ + environmentId: threadRef.environmentId, + projectId: thread.projectId, + }) + : null; const deletedIds = opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0 ? new Set( @@ -421,10 +427,9 @@ export function useThreadActions() { deletedIds && deletedIds.size > 0 ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) : threads; - const orphanedWorktreePath = getOrphanedWorktreePathForThread( - survivingThreads, - threadRef.threadId, - ); + const orphanedWorktreePath = needsLegacyWorktreeCleanup + ? getOrphanedWorktreePathForThread(survivingThreads, threadRef.threadId) + : null; const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; @@ -560,6 +565,7 @@ export function useThreadActions() { // The thread was deleted. Cleanup has its own toast; returning its // failure would make callers incorrectly report a thread deletion error. } + return deleteResult; }, [ diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index a21e5e5c4c4b..3f94f44dd5fe 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -265,6 +265,14 @@ export function readEnvironmentSupportsVisitedTracking(environmentId: Environmen ); } +/** Whether the server owns worktree inventory, pruning, and orphan cleanup. */ +export function readEnvironmentSupportsWorktreeManagement(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .worktreeManagement === true + ); +} + export function readEnvironmentThreadRefs( environmentId: EnvironmentId, ): ReadonlyArray { diff --git a/apps/web/src/state/worktrees.ts b/apps/web/src/state/worktrees.ts new file mode 100644 index 000000000000..3b1e0acb275a --- /dev/null +++ b/apps/web/src/state/worktrees.ts @@ -0,0 +1,5 @@ +import { createWorktreeEnvironmentAtoms } from "@t3tools/client-runtime/state/worktrees"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const worktreeEnvironment = createWorktreeEnvironmentAtoms(connectionAtomRuntime); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 306632d9ca64..b4fa2e62c0fe 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -141,6 +141,24 @@ server, but the host's own site will not show them, and the count reads **viewed The **Code** tab is a web and desktop surface. The mobile app reports a pull request's status but does not show its diff, so marks are made and read on web and desktop. +## Manage thread worktrees + +Open **Settings → Source Control → Worktrees** to see the Git worktrees managed by T3 Code. +Worktrees are grouped by project. Each row shows its branch, a linked thread, recent activity, and +whether local work protects it from cleanup. Select an available thread title to return to that +conversation. + +T3 Code only performs automatic cleanup when a worktree has no active thread, no local changes, and +no unpushed commits. Removing a safe worktree keeps its local branch and T3 checkpoint history. If a +thread needs that worktree again, T3 Code recreates it from the retained branch before starting the +next turn. + +**Auto-remove after** sets how long an unused safe worktree is kept. Enable **Remove with last +thread** to remove a safe worktree as soon as its last linked thread is deleted instead of waiting +out that period. Protected worktrees remain listed for you to inspect; T3 Code never automatically +removes dirty or unpushed worktrees. A detached worktree counts as unpushed until its commit is on +the default branch. + ## Troubleshooting - **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 7fd112ec655b..0bfb17429988 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -358,6 +358,10 @@ "./device/duo-control": { "types": "./src/device/duoControl.ts", "default": "./src/device/duoControl.ts" + }, + "./state/worktrees": { + "types": "./src/state/worktrees.ts", + "default": "./src/state/worktrees.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 7c5e4b6a23de..b8401e17572e 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -61,6 +61,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.subscribeWorktreeSetup | typeof WS_METHODS.subscribeProjectClones + | typeof WS_METHODS.subscribeWorktreeInventory | typeof WS_METHODS.terminalAttach; export type EnvironmentStreamCommandRpcTag = diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index bedcb751216f..846090949e34 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -26,7 +26,11 @@ import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { request, subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; import { followStreamInEnvironment } from "./runtime.ts"; -import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; +import { + vcsCommandConcurrency, + vcsCommandScheduler, + worktreeCommandConcurrency, +} from "./vcsCommandScheduler.ts"; import { invalidateCachedVcsRefs, vcsRefsCacheStateAtom, @@ -312,7 +316,7 @@ export function createVcsEnvironmentAtoms( label: "environment-data:vcs:create-worktree", tag: WS_METHODS.vcsCreateWorktree, scheduler: vcsCommandScheduler, - concurrency: vcsCommandConcurrency, + concurrency: worktreeCommandConcurrency, onSettled: invalidateRefs, }), // Live stages of a bootstrap worktree setup. Null until the server begins @@ -331,7 +335,7 @@ export function createVcsEnvironmentAtoms( label: "environment-data:vcs:remove-worktree", tag: WS_METHODS.vcsRemoveWorktree, scheduler: vcsCommandScheduler, - concurrency: vcsCommandConcurrency, + concurrency: worktreeCommandConcurrency, onSettled: invalidateRefs, }), createRef: createEnvironmentRpcCommand(runtime, { diff --git a/packages/client-runtime/src/state/vcsCommandScheduler.ts b/packages/client-runtime/src/state/vcsCommandScheduler.ts index a11b157bb2d8..b69186a74ac9 100644 --- a/packages/client-runtime/src/state/vcsCommandScheduler.ts +++ b/packages/client-runtime/src/state/vcsCommandScheduler.ts @@ -11,3 +11,11 @@ export const vcsCommandConcurrency: AtomCommandConcurrency<{ mode: "serial", key: ({ environmentId, input }) => JSON.stringify([environmentId, input.cwd]), }; + +export const worktreeCommandConcurrency: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentId; + readonly input: unknown; +}> = { + mode: "serial", + key: ({ environmentId }) => environmentId, +}; diff --git a/packages/client-runtime/src/state/worktrees.ts b/packages/client-runtime/src/state/worktrees.ts new file mode 100644 index 000000000000..266cc4f8b682 --- /dev/null +++ b/packages/client-runtime/src/state/worktrees.ts @@ -0,0 +1,32 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; +import { vcsCommandScheduler, worktreeCommandConcurrency } from "./vcsCommandScheduler.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createWorktreeEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + list: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:worktrees", + tag: WS_METHODS.vcsListWorktrees, + staleTimeMs: 15_000, + }), + changes: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:vcs:worktrees:changes", + tag: WS_METHODS.subscribeWorktreeInventory, + }), + prune: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:worktrees:prune", + tag: WS_METHODS.vcsPruneWorktrees, + scheduler: vcsCommandScheduler, + concurrency: worktreeCommandConcurrency, + }), + }; +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a649cfe38de0..93b024539afa 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -163,6 +163,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean), threadPullRequests: Schema.optionalKey(Schema.Boolean), pullRequestStackActions: Schema.optionalKey(Schema.Boolean), + /** Server exposes the worktree.list/changes/prune APIs and runs worktree + lifecycle cleanup. Same version-skew contract as threadSettlement: + clients hide worktree management for environments that lack this. */ + worktreeManagement: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8690bb1b2390..6b61c2789cc1 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -29,6 +29,7 @@ export * from "./server.ts"; export * from "./settings.ts"; export * from "./git.ts"; export * from "./vcs.ts"; +export * from "./worktrees.ts"; export * from "./sourceControl.ts"; export * from "./projectClone.ts"; export * from "./pullRequest.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 55b96b87c55e..6184014e94db 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -123,6 +123,15 @@ import { VcsStatusResult, VcsStatusStreamEvent, } from "./git.ts"; +import { + VcsListWorktreesInput, + VcsListWorktreesResult, + WorktreeInventoryChange, + WorktreeInventoryError, + VcsPruneWorktreesInput, + VcsPruneWorktreesResult, + WorktreeMutationError, +} from "./worktrees.ts"; import { ReviewDiffFileContentsInput, ReviewDiffFileContentsResult, @@ -378,6 +387,9 @@ export const WS_METHODS = { vcsPull: "vcs.pull", vcsRefreshStatus: "vcs.refreshStatus", vcsListRefs: "vcs.listRefs", + vcsListWorktrees: "vcs.listWorktrees", + subscribeWorktreeInventory: "vcs.subscribeWorktreeInventory", + vcsPruneWorktrees: "vcs.pruneWorktrees", vcsCreateWorktree: "vcs.createWorktree", vcsRemoveWorktree: "vcs.removeWorktree", vcsCreateRef: "vcs.createRef", @@ -1263,6 +1275,25 @@ const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +const WsVcsListWorktreesRpc = Rpc.make(WS_METHODS.vcsListWorktrees, { + payload: VcsListWorktreesInput, + success: VcsListWorktreesResult, + error: Schema.Union([WorktreeInventoryError, EnvironmentAuthorizationError]), +}); + +const WsSubscribeWorktreeInventoryRpc = Rpc.make(WS_METHODS.subscribeWorktreeInventory, { + payload: Schema.Struct({}), + success: WorktreeInventoryChange, + error: EnvironmentAuthorizationError, + stream: true, +}); + +const WsVcsPruneWorktreesRpc = Rpc.make(WS_METHODS.vcsPruneWorktrees, { + payload: VcsPruneWorktreesInput, + success: VcsPruneWorktreesResult, + error: Schema.Union([WorktreeMutationError, EnvironmentAuthorizationError]), +}); + const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { payload: VcsCreateWorktreeInput, success: VcsCreateWorktreeResult, @@ -1779,6 +1810,9 @@ export const WsRpcGroup = RpcGroup.make( WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, WsVcsListRefsRpc, + WsVcsListWorktreesRpc, + WsSubscribeWorktreeInventoryRpc, + WsVcsPruneWorktreesRpc, WsVcsCreateWorktreeRpc, WsVcsRemoveWorktreeRpc, WsVcsCreateRefRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 764e6d560a73..3ef6763d06d7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1024,6 +1024,27 @@ export interface BranchNamingOptions { instructions: string; } +const MIN_WORKTREE_AUTO_PRUNE_AFTER_DAYS = 1; +const MAX_WORKTREE_AUTO_PRUNE_AFTER_DAYS = 365; +export const WorktreeAutoPruneAfterDays = Schema.Int.check( + Schema.isBetween({ + minimum: MIN_WORKTREE_AUTO_PRUNE_AFTER_DAYS, + maximum: MAX_WORKTREE_AUTO_PRUNE_AFTER_DAYS, + }), +); +export type WorktreeAutoPruneAfterDays = typeof WorktreeAutoPruneAfterDays.Type; +const DEFAULT_WORKTREE_AUTO_PRUNE_AFTER_DAYS: WorktreeAutoPruneAfterDays = 14; + +export const WorktreeSettings = Schema.Struct({ + /** Remove safe-to-prune worktrees after this many inactive days; null disables it. */ + autoPruneAfterDays: Schema.NullOr(WorktreeAutoPruneAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_WORKTREE_AUTO_PRUNE_AFTER_DAYS)), + ), + /** Allow the reaper to remove safe orphaned worktrees on its next sweep. */ + deleteOrphanedImmediately: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), +}); +export type WorktreeSettings = typeof WorktreeSettings.Type; + export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); export const DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL = Duration.minutes(5); @@ -1398,6 +1419,7 @@ export const ServerSettings = Schema.Struct({ usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + worktrees: WorktreeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -1667,6 +1689,12 @@ export const ServerSettingsPatch = Schema.Struct({ apiToken: Schema.optionalKey(TrimmedString), }), ), + worktrees: Schema.optionalKey( + Schema.Struct({ + autoPruneAfterDays: Schema.optionalKey(Schema.NullOr(WorktreeAutoPruneAfterDays)), + deleteOrphanedImmediately: Schema.optionalKey(Schema.Boolean), + }), + ), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index dec0e46b6eef..26b07ea8303f 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -44,6 +44,14 @@ export const VcsListWorkspaceFilesResult = Schema.Struct({ }); export type VcsListWorkspaceFilesResult = typeof VcsListWorkspaceFilesResult.Type; +/** A VCS-managed checkout reported by the provider's local metadata. */ +export const VcsWorkspace = Schema.Struct({ + path: TrimmedNonEmptyString, + refName: Schema.NullOr(TrimmedNonEmptyString), + prunable: Schema.Boolean, +}); +export type VcsWorkspace = typeof VcsWorkspace.Type; + export const VcsRemote = Schema.Struct({ name: TrimmedNonEmptyString, url: TrimmedNonEmptyString, diff --git a/packages/contracts/src/worktrees.test.ts b/packages/contracts/src/worktrees.test.ts new file mode 100644 index 000000000000..e59b57d3e2db --- /dev/null +++ b/packages/contracts/src/worktrees.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { + VcsPruneWorktreesInput, + WorktreeInventoryError, + WorktreeMutationError, +} from "./worktrees.ts"; + +const decodePruneWorktreesInput = Schema.decodeUnknownSync(VcsPruneWorktreesInput); + +describe("worktree filesystem paths", () => { + it("preserves leading and trailing whitespace", () => { + const worktreePath = " /tmp/worktree "; + + const prune = decodePruneWorktreesInput({ + paths: [worktreePath], + }); + + expect(prune.paths).toEqual([worktreePath]); + }); +}); + +describe("worktree errors", () => { + it("derives messages from structural stages", () => { + const inventoryError = new WorktreeInventoryError({ + stage: "inspect_repository", + workspaceRoot: "/tmp/repository", + cause: new Error("git inspection failed"), + }); + const mutationError = new WorktreeMutationError({ + operation: "revive", + stage: "branch_in_use", + branch: "feature/worktrees", + conflictingPath: "/tmp/existing", + }); + + expect(inventoryError.message).toBe( + "Failed to inspect a repository for the worktree inventory.", + ); + expect(mutationError.message).toBe( + "Cannot revive branch 'feature/worktrees': it is already checked out at '/tmp/existing'.", + ); + }); +}); diff --git a/packages/contracts/src/worktrees.ts b/packages/contracts/src/worktrees.ts new file mode 100644 index 000000000000..ca2dc8246527 --- /dev/null +++ b/packages/contracts/src/worktrees.ts @@ -0,0 +1,227 @@ +import * as Schema from "effect/Schema"; +import { + IsoDateTime, + NonNegativeInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; + +export const WorktreeThreadStatus = Schema.Literals(["active", "settled", "archived", "deleted"]); +export type WorktreeThreadStatus = typeof WorktreeThreadStatus.Type; + +export const WorktreeThreadRef = Schema.Struct({ + threadId: ThreadId, + title: Schema.String, + status: WorktreeThreadStatus, +}); +export type WorktreeThreadRef = typeof WorktreeThreadRef.Type; + +export const WorktreeProjectRef = Schema.Struct({ + projectId: ProjectId, + projectTitle: Schema.String, + workspaceRoot: Schema.NonEmptyString, +}); +export type WorktreeProjectRef = typeof WorktreeProjectRef.Type; + +/** Reasons a managed worktree must not be pruned automatically. */ +export const WorktreePruneBlocker = Schema.Literals([ + "active_thread", + "dirty", + "unpushed", + "status_unavailable", +]); +export type WorktreePruneBlocker = typeof WorktreePruneBlocker.Type; + +export const WorktreeInfo = Schema.Struct({ + /** Primary project retained for simple callers; `projects` preserves all references. */ + projectId: ProjectId, + projectTitle: Schema.String, + workspaceRoot: Schema.NonEmptyString, + projects: Schema.Array(WorktreeProjectRef), + path: Schema.NonEmptyString, + branch: Schema.NullOr(TrimmedNonEmptyString), + threads: Schema.Array(WorktreeThreadRef), + /** No non-deleted V2 shell references this worktree. */ + orphaned: Schema.Boolean, + /** null when the working-tree status could not be read. */ + dirty: Schema.NullOr(Schema.Boolean), + /** Number of changed/untracked files; null when status could not be read. */ + dirtyFileCount: Schema.NullOr(NonNegativeInt), + hasUpstream: Schema.NullOr(Schema.Boolean), + /** Upstream is configured but its remote ref no longer exists. */ + upstreamGone: Schema.Boolean, + aheadOfUpstreamCount: Schema.NullOr(NonNegativeInt), + behindUpstreamCount: Schema.NullOr(NonNegativeInt), + aheadOfDefaultCount: Schema.NullOr(NonNegativeInt), + /** Latest linked-thread activity; falls back to directory mtime for orphans. */ + lastActivityAt: Schema.NullOr(IsoDateTime), + safeToPrune: Schema.Boolean, + pruneBlockers: Schema.Array(WorktreePruneBlocker), +}); +export type WorktreeInfo = typeof WorktreeInfo.Type; + +export const WorktreePruneSkipReason = Schema.Literals([ + "active_thread", + "dirty", + "unpushed", + "status_unavailable", + "unknown_worktree", + "remove_failed", +]); +export type WorktreePruneSkipReason = typeof WorktreePruneSkipReason.Type; + +export const VcsListWorktreesInput = Schema.Struct({ + projectId: Schema.optional(ProjectId), +}); +export type VcsListWorktreesInput = typeof VcsListWorktreesInput.Type; + +export const VcsPruneWorktreesInput = Schema.Struct({ + paths: Schema.Array(Schema.NonEmptyString).check(Schema.isMinLength(1)), +}); +export type VcsPruneWorktreesInput = typeof VcsPruneWorktreesInput.Type; + +export const VcsListWorktreesResult = Schema.Struct({ + worktrees: Schema.Array(WorktreeInfo), +}); +export type VcsListWorktreesResult = typeof VcsListWorktreesResult.Type; + +/** Monotonic for one server runtime; subscribers use it only as an invalidation signal. */ +export const WorktreeInventoryChange = Schema.Struct({ + revision: NonNegativeInt, +}); +export type WorktreeInventoryChange = typeof WorktreeInventoryChange.Type; + +export const VcsPruneWorktreesResult = Schema.Struct({ + removed: Schema.Array( + Schema.Struct({ + path: Schema.NonEmptyString, + workspaceRoot: Schema.NonEmptyString, + }), + ), + skipped: Schema.Array( + Schema.Struct({ + path: Schema.NonEmptyString, + reason: WorktreePruneSkipReason, + detail: Schema.optional(Schema.String), + }), + ), +}); +export type VcsPruneWorktreesResult = typeof VcsPruneWorktreesResult.Type; + +export const WorktreeInventoryErrorStage = Schema.Literals([ + "load_projects", + "load_threads", + "inspect_repository", +]); +export type WorktreeInventoryErrorStage = typeof WorktreeInventoryErrorStage.Type; + +export class WorktreeInventoryError extends Schema.TaggedError()( + "WorktreeInventoryError", + { + stage: WorktreeInventoryErrorStage, + projectId: Schema.optional(ProjectId), + workspaceRoot: Schema.optional(Schema.NonEmptyString), + cause: Schema.Defect(), + }, +) { + override get message(): string { + switch (this.stage) { + case "load_projects": + return "Failed to load projects for the worktree inventory."; + case "load_threads": + return "Failed to load V2 thread shells for the worktree inventory."; + case "inspect_repository": + return "Failed to inspect a repository for the worktree inventory."; + } + } +} + +export const WorktreeMutationErrorStage = Schema.Literals([ + "revalidate_inventory", + "inspect_target_path", + "resolve_target_path", + "load_projects", + "unmanaged_workspace", + "inspect_registrations", + "validate_branch", + "invalid_branch", + "check_branch", + "missing_branch", + "outside_managed_root", + "registered_different_ref", + "branch_in_use", + "prune_metadata", + "stale_registration_remaining", + "target_appeared", + "create_worktree", + "verify_worktree", + "worktree_verification_failed", + "load_project", + "project_not_found", + "run_setup", +]); +export type WorktreeMutationErrorStage = typeof WorktreeMutationErrorStage.Type; + +export class WorktreeMutationError extends Schema.TaggedError()( + "WorktreeMutationError", + { + operation: Schema.Literals(["prune", "revive"]), + stage: WorktreeMutationErrorStage, + path: Schema.optional(Schema.NonEmptyString), + conflictingPath: Schema.optional(Schema.NonEmptyString), + workspaceRoot: Schema.optional(Schema.NonEmptyString), + branch: Schema.optional(TrimmedNonEmptyString), + projectId: Schema.optional(ProjectId), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.stage) { + case "revalidate_inventory": + return "Failed to revalidate worktrees before pruning."; + case "inspect_target_path": + return "Failed to inspect the target worktree path."; + case "resolve_target_path": + return "Failed to resolve the target worktree path."; + case "load_projects": + return "Failed to load projects before reviving the worktree."; + case "unmanaged_workspace": + return `Cannot revive a worktree for unmanaged workspace '${this.workspaceRoot ?? "unknown"}'.`; + case "inspect_registrations": + return "Failed to inspect Git worktree registrations."; + case "validate_branch": + return "Failed to validate the worktree branch."; + case "invalid_branch": + return `Cannot revive the worktree: '${this.branch ?? "unknown"}' is not a valid local branch name.`; + case "check_branch": + return "Failed to check whether the worktree branch exists."; + case "missing_branch": + return `Cannot recreate the worktree: branch '${this.branch ?? "unknown"}' no longer exists.`; + case "outside_managed_root": + return `Cannot revive a worktree outside the managed worktrees directory: '${this.path ?? "unknown"}'.`; + case "registered_different_ref": + return `Cannot revive '${this.path ?? "unknown"}': Git already registers that path for a different ref.`; + case "branch_in_use": + return `Cannot revive branch '${this.branch ?? "unknown"}': it is already checked out at '${this.conflictingPath ?? "unknown"}'.`; + case "prune_metadata": + return "Failed to clear stale Git worktree metadata."; + case "stale_registration_remaining": + return `Cannot revive '${this.path ?? "unknown"}': Git still has a worktree registration at that path.`; + case "target_appeared": + return `Cannot revive '${this.path ?? "unknown"}': the target directory appeared before creation.`; + case "create_worktree": + return "Failed to create the revived Git worktree."; + case "verify_worktree": + return "Failed to verify the revived worktree directory."; + case "worktree_verification_failed": + return `The worktree was created but could not be verified at '${this.path ?? "unknown"}'.`; + case "load_project": + return "Failed to load the project before reviving the worktree."; + case "project_not_found": + return `Cannot revive a worktree for project '${this.projectId ?? "unknown"}': the project was not found.`; + case "run_setup": + return "Failed to run the project setup script after revival."; + } + } +} From cc14215c6bc1968043e35f4bb3f453a7f3b6c1eb Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:28:05 +0200 Subject: [PATCH 2/8] fix(worktrees): wait for required setup and recheck cleanup policy A recreated worktree started the provider turn as soon as its setup script was written to the terminal, even when the script is marked to finish first. Revival now observes the script's exit: a required script is awaited outside the mutation permit, concurrent turn starts share one run per worktree generation, and the run lives in the service scope so a cancelled turn start cannot strand the next one. A failed revival settles the run with the reason instead of starting the provider in a missing or half set up worktree. The reaper read its policy once per sweep, so turning cleanup off or lengthening retention while it ran still removed worktrees. Prune now takes a recheck that runs under the permit right before each removal, and the reaper passes its policy through it. A registration whose directory was deleted stayed in the inventory with an unreadable status that nothing could ever remove. It is left out now; Git's own gc, the next removal, and revival prune it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ProviderTurnStartService.test.ts | 28 ++- .../ProviderTurnStartService.ts | 88 +++++---- apps/server/src/vcs/WorktreeReaper.test.ts | 75 +++++-- apps/server/src/vcs/WorktreeReaper.ts | 39 ++-- .../src/vcs/WorktreeRevivalService.test.ts | 110 +++++++++++ apps/server/src/vcs/WorktreeRevivalService.ts | 186 +++++++++++++----- apps/server/src/vcs/WorktreeService.test.ts | 113 +++++++++++ apps/server/src/vcs/WorktreeService.ts | 32 ++- docs/user/source-control.md | 3 +- 9 files changed, 550 insertions(+), 124 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 5f5be7b6fb8b..610d38eaa2d4 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -923,12 +923,17 @@ function makeWorktreeTurnStartFixture(input: { order.push("start-root-run"); }), ); + const events: Array = []; const layer = ProviderTurnStart.layer.pipe( Layer.provide( Layer.mergeAll( Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), Layer.mock(EventSink.EventSinkV2)({ - writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] } as never), + writeIfRunCurrent: ({ events: written }) => + Effect.sync(() => { + events.push(...written); + return { committed: true, storedEvents: [] } as never; + }), }), IdAllocator.layer, Layer.mock(ProjectionStore.ProjectionStoreV2)({ @@ -954,6 +959,7 @@ function makeWorktreeTurnStartFixture(input: { return { layer, order, + events, open, close, startRootRun, @@ -1016,14 +1022,28 @@ effectIt.effect("restarts a live session after another thread recreated its work }), ); -effectIt.effect("starts the provider turn when worktree revival fails", () => +effectIt.effect("fails the run with the reason when its worktree cannot be restored", () => Effect.gen(function* () { const fixture = makeWorktreeTurnStartFixture({ revival: "failed", liveSession: true }); yield* fixture.start.pipe(Effect.provide(fixture.layer)); - expect(fixture.order).toEqual(["revive", "open", "start-root-run"]); - expect(fixture.close).not.toHaveBeenCalled(); + expect(fixture.order).toEqual(["revive"]); + expect(fixture.events).toMatchObject([ + { + type: "turn-item.updated", + payload: { + type: "error", + title: "Worktree could not be restored", + failure: { + message: "Cannot recreate the worktree: branch 'feature/revival' no longer exists.", + }, + }, + }, + { type: "run.updated", payload: { status: "failed" } }, + { type: "run-attempt.updated", payload: { status: "failed" } }, + { type: "node.updated", payload: { status: "failed" } }, + ]); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index bf10d3e2a827..c1f8726101af 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -12,7 +12,6 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; -import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -490,49 +489,60 @@ export const layer: Layer.Layer< | undefined; if (projection.thread.worktreePath !== null && projection.thread.branch !== null) { const worktreePath = projection.thread.worktreePath; - // A failed revival must not fail this effect: that only leaves the run - // in "starting". Proceeding lets the provider report the missing - // directory as a visible turn error the user can act on. - const revival = yield* worktreeRevival - .reviveForThread({ + const revivalResult = yield* Effect.result( + worktreeRevival.reviveForThread({ threadId: projection.thread.id, projectId: projection.thread.projectId, worktreePath, branch: projection.thread.branch, - }) - .pipe( - Effect.catch((error) => - Effect.logWarning("provider turn start could not revive the worktree", { - threadId: projection.thread.id, - worktreePath, - error, - }).pipe(Effect.as(undefined)), - ), - ); - // Revival can recreate the worktree and run project setup, so the run - // may have been cancelled or superseded while it ran. + }), + ); + if (revivalResult._tag === "Failure") { + // A missing worktree that cannot be recreated, or a required setup + // script that failed, leaves nothing for the provider to run in. + // Settle the run with the reason; the next message tries again. + const revivalError = revivalResult.failure; + yield* settleRunBeforeStart({ + signal: "worktree-revival-failure", + status: "failed", + now: yield* DateTime.now, + providerInstanceId: run.providerInstanceId, + itemProviderThreadId: providerThread.id, + item: { + type: "error", + title: "Worktree could not be restored", + failure: makeProviderFailure({ + cause: revivalError, + message: revivalError.message, + class: "validation_error", + }), + }, + }); + return; + } + const revival = revivalResult.success; + // Revival can recreate the worktree and wait for project setup, so the + // run may have been cancelled or superseded meanwhile. if (!(yield* isCurrentAttemptInStatus("starting"))) return; - if (revival !== undefined) { - observedWorktreeGeneration = { path: worktreePath, generation: revival.generation }; - const liveSession = yield* providerSessions.get(providerSessionId); - const previousWorktreeGeneration = Option.isSome(liveSession) - ? worktreeGenerationBySession.get(liveSession.value) - : undefined; - // Only a per-thread provider process holds the worktree as its cwd. - // A session shared across threads (Codex) passes cwd per native - // thread, and closing it would fail every sibling's running turn. - const holdsWorktreeAsCwd = - Option.isSome(liveSession) && - !liveSession.value.providerSession.capabilities.sessions - .supportsMultipleProviderThreadsPerSession; - const worktreeChanged = - revival.revived || - (previousWorktreeGeneration !== undefined && - (previousWorktreeGeneration.path !== worktreePath || - previousWorktreeGeneration.generation !== revival.generation)); - if (holdsWorktreeAsCwd && worktreeChanged) { - yield* providerSessions.close(providerSessionId); - } + observedWorktreeGeneration = { path: worktreePath, generation: revival.generation }; + const liveSession = yield* providerSessions.get(providerSessionId); + const previousWorktreeGeneration = Option.isSome(liveSession) + ? worktreeGenerationBySession.get(liveSession.value) + : undefined; + // Only a per-thread provider process holds the worktree as its cwd. + // A session shared across threads (Codex) passes cwd per native + // thread, and closing it would fail every sibling's running turn. + const holdsWorktreeAsCwd = + Option.isSome(liveSession) && + !liveSession.value.providerSession.capabilities.sessions + .supportsMultipleProviderThreadsPerSession; + const worktreeChanged = + revival.revived || + (previousWorktreeGeneration !== undefined && + (previousWorktreeGeneration.path !== worktreePath || + previousWorktreeGeneration.generation !== revival.generation)); + if (holdsWorktreeAsCwd && worktreeChanged) { + yield* providerSessions.close(providerSessionId); } } const existingSessionProjection = projection.providerSessions.find( diff --git a/apps/server/src/vcs/WorktreeReaper.test.ts b/apps/server/src/vcs/WorktreeReaper.test.ts index 275cd721a32f..54b797af9c89 100644 --- a/apps/server/src/vcs/WorktreeReaper.test.ts +++ b/apps/server/src/vcs/WorktreeReaper.test.ts @@ -30,28 +30,47 @@ const makeWorktree = (overrides: Partial = {}): WorktreeInfo => ({ ...overrides, }); +/** + * The worktree service is stubbed: prune records the paths whose removal the + * reaper still wants, and `duringList` runs while the inventory is read, with + * the same settings service the reaper sees. + */ const makeLayer = ( inventory: ReadonlyArray, prune: (paths: ReadonlyArray) => void, settings: Parameters[0], observed?: { readonly listCalls: { value: number } }, + duringList: Effect.Effect = Effect.void, ) => { - const worktreeLayer = Layer.succeed( + const worktreeLayer = Layer.effect( WorktreeService.WorktreeService, - WorktreeService.WorktreeService.of({ - listWorktrees: () => - Effect.sync(() => { - if (observed !== undefined) observed.listCalls.value += 1; - return { worktrees: [...inventory] }; - }), - pruneWorktrees: (input) => - Effect.sync(() => prune(input.paths)).pipe( - Effect.as({ - removed: [], - skipped: [], - }), - ), - pruneOrphanedWorktree: () => Effect.succeed(false), + Effect.gen(function* () { + const context = yield* Effect.context(); + return WorktreeService.WorktreeService.of({ + listWorktrees: () => + duringList.pipe( + Effect.provide(context), + Effect.andThen( + Effect.sync(() => { + if (observed !== undefined) observed.listCalls.value += 1; + return { worktrees: [...inventory] }; + }), + ), + ), + pruneWorktrees: (input, options) => + Effect.filter(input.paths, (path) => { + const worktree = inventory.find((candidate) => candidate.path === path); + return worktree === undefined || options?.stillWanted === undefined + ? Effect.succeed(true) + : options.stillWanted(worktree); + }).pipe( + Effect.map((paths) => { + prune(paths); + return { removed: [], skipped: [] }; + }), + ), + pruneOrphanedWorktree: () => Effect.succeed(false), + }); }), ); return WorktreeReaper.layerWith({ initialDelayMs: 0, sweepIntervalMs: 60_000 }).pipe( @@ -118,3 +137,29 @@ it.effect("can sweep safe orphans immediately and stays inert when disabled", () assert.deepEqual(disabledPruned, []); }), ); + +it.effect("keeps worktrees when cleanup is turned off during a sweep", () => + Effect.gen(function* () { + const pruned: ReadonlyArray[] = []; + const layer = makeLayer( + [makeWorktree({ path: "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/worktrees/old", lastActivityAt: "1960-01-01T00:00:00.000Z" })], + (paths) => pruned.push(paths), + { worktrees: { autoPruneAfterDays: 14, deleteOrphanedImmediately: false } }, + undefined, + ServerSettings.ServerSettingsService.pipe( + Effect.flatMap((settings) => + settings.updateSettings({ worktrees: { autoPruneAfterDays: null } }), + ), + Effect.orDie, + Effect.asVoid, + ), + ); + + yield* Effect.gen(function* () { + const reaper = yield* WorktreeReaper.WorktreeReaper; + yield* reaper.sweep; + }).pipe(Effect.provide(layer)); + + assert.deepEqual(pruned.flat(), []); + }), +); diff --git a/apps/server/src/vcs/WorktreeReaper.ts b/apps/server/src/vcs/WorktreeReaper.ts index b040cb42cb2d..0f52900cb6b6 100644 --- a/apps/server/src/vcs/WorktreeReaper.ts +++ b/apps/server/src/vcs/WorktreeReaper.ts @@ -14,6 +14,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; +import type { WorktreeInfo, WorktreeSettings } from "@t3tools/contracts"; + import { ServerSettingsService } from "../serverSettings.ts"; import { WorktreeService } from "./WorktreeService.ts"; @@ -36,6 +38,16 @@ export class WorktreeReaper extends Context.Service< } >()("t3/vcs/WorktreeReaper") {} +/** Whether the retention policy selects a safe worktree for removal now. */ +function isExpired(policy: WorktreeSettings, nowMs: number, worktree: WorktreeInfo): boolean { + if (policy.deleteOrphanedImmediately && worktree.orphaned) return true; + if (policy.autoPruneAfterDays === null || worktree.lastActivityAt === null) return false; + const lastActivityMs = Date.parse(worktree.lastActivityAt); + return ( + !Number.isNaN(lastActivityMs) && lastActivityMs < nowMs - policy.autoPruneAfterDays * DAY_MS + ); +} + const make = (options?: WorktreeReaperOptions) => Effect.gen(function* () { const worktrees = yield* WorktreeService; @@ -51,20 +63,23 @@ const make = (options?: WorktreeReaperOptions) => const { worktrees: inventory } = yield* worktrees.listWorktrees({}); const now = yield* Clock.currentTimeMillis; - const cutoffMs = - policy.autoPruneAfterDays === null ? null : now - policy.autoPruneAfterDays * DAY_MS; - const candidates = inventory.filter((worktree) => { - if (!worktree.safeToPrune) return false; - if (policy.deleteOrphanedImmediately && worktree.orphaned) return true; - if (cutoffMs === null || worktree.lastActivityAt === null) return false; - const lastActivityMs = Date.parse(worktree.lastActivityAt); - return !Number.isNaN(lastActivityMs) && lastActivityMs < cutoffMs; - }); + const candidates = inventory.filter( + (worktree) => worktree.safeToPrune && isExpired(policy, now, worktree), + ); if (candidates.length === 0) return; - const result = yield* worktrees.pruneWorktrees({ - paths: candidates.map((worktree) => worktree.path), - }); + // Reading the inventory and removing worktrees both take time. The + // policy is read again right before each removal, so turning cleanup off + // or lengthening retention during a sweep is honoured. + const stillWanted = (worktree: WorktreeInfo) => + Effect.all([settings.getSettings, Clock.currentTimeMillis]).pipe( + Effect.map(([current, nowMs]) => isExpired(current.worktrees, nowMs, worktree)), + Effect.orElseSucceed(() => false), + ); + const result = yield* worktrees.pruneWorktrees( + { paths: candidates.map((worktree) => worktree.path) }, + { stillWanted }, + ); yield* Effect.logInfo("worktree.reaper.sweep-complete", { candidateCount: candidates.length, removedCount: result.removed.length, diff --git a/apps/server/src/vcs/WorktreeRevivalService.test.ts b/apps/server/src/vcs/WorktreeRevivalService.test.ts index e73be07c949a..0fb1c280561a 100644 --- a/apps/server/src/vcs/WorktreeRevivalService.test.ts +++ b/apps/server/src/vcs/WorktreeRevivalService.test.ts @@ -387,3 +387,113 @@ it.effect("retries setup after a recreated worktree's first setup attempt fails" assert.isTrue(yield* fs.exists(worktreePath)); }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), ); + +/** A setup script that has started and settles when `completion` does. */ +const startedSetup = ( + worktreePath: string, + async: boolean, + completion: Effect.Effect, +) => + ({ + status: "started", + scriptId: "setup", + scriptName: "Setup", + scriptCommand: "vp i", + terminalId: "setup-setup", + cwd: worktreePath, + async, + completion, + }) as const; + +it.effect( + "keeps a required setup running once when the turn start that began it is cancelled", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "required"); + const setupStarted = yield* Deferred.make(); + const setupFinished = + yield* Deferred.make(); + let setupRuns = 0; + const layer = makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.sync(() => { + setupRuns += 1; + }).pipe( + Effect.andThen(Deferred.succeed(setupStarted, undefined)), + Effect.as(startedSetup(worktreePath, false, Deferred.await(setupFinished))), + ), + ); + const input = { threadId, projectId, worktreePath, branch: "feature/revival" }; + + const result = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const cancelled = yield* revival.reviveForThread(input).pipe(Effect.forkChild); + yield* Deferred.await(setupStarted); + yield* Fiber.interrupt(cancelled); + const next = yield* revival.reviveForThread(input).pipe(Effect.forkChild); + yield* Deferred.succeed(setupFinished, { exitCode: 0, durationMs: 1 }); + return yield* Fiber.join(next); + }).pipe(Effect.provide(layer)); + + assert.deepEqual(result, { revived: false, generation: 1 }); + assert.equal(setupRuns, 1); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("fails a revival whose required setup exits non-zero and reruns it next time", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "exit-code"); + const exitCodes = [1, 0]; + const layer = makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.sync(() => + startedSetup( + worktreePath, + false, + Effect.succeed({ exitCode: exitCodes.shift() ?? 0, durationMs: 1 }), + ), + ), + ); + const input = { threadId, projectId, worktreePath, branch: "feature/revival" }; + + const { error, retry } = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + const error = yield* revival.reviveForThread(input).pipe(Effect.flip); + const retry = yield* revival.reviveForThread(input); + return { error, retry }; + }).pipe(Effect.provide(layer)); + + assert.equal(error.stage, "run_setup"); + assert.equal(error.cause, "Setup script exited with 1."); + assert.deepEqual(retry, { revived: false, generation: 1 }); + assert.deepEqual(exitCodes, []); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("does not wait for a setup script that lets the agent start alongside it", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const repositoryRoot = yield* initializeRepository(); + const worktreePath = path.join(config.worktreesDir, "setup", "async"); + const layer = makeRevivalLayer(makeProject(repositoryRoot), () => + Effect.succeed(startedSetup(worktreePath, true, Effect.never)), + ); + + const result = yield* Effect.gen(function* () { + const revival = yield* WorktreeRevivalService.WorktreeRevivalService; + return yield* revival.reviveForThread({ + threadId, + projectId, + worktreePath, + branch: "feature/revival", + }); + }).pipe(Effect.provide(layer)); + + assert.deepEqual(result, { revived: true, generation: 1 }); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); diff --git a/apps/server/src/vcs/WorktreeRevivalService.ts b/apps/server/src/vcs/WorktreeRevivalService.ts index 7c68d9c2f229..03590206a174 100644 --- a/apps/server/src/vcs/WorktreeRevivalService.ts +++ b/apps/server/src/vcs/WorktreeRevivalService.ts @@ -1,5 +1,7 @@ import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -103,7 +105,19 @@ const make = Effect.gen(function* () { const projectsService = yield* ProjectService.ProjectService; const setupScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const generationByWorktreePath = yield* Ref.make(new Map()); - const setupGenerationByProjectWorktree = yield* Ref.make(new Map()); + // Project setup for a recreated worktree: one run per project, worktree, and + // generation, shared by every turn start that needs it. + const setupRuns = yield* Ref.make( + new Map< + string, + { + readonly generation: number; + readonly outcome: Deferred.Deferred; + } + >(), + ); + // Setup runs are forked here so they outlive the turn start that began them. + const serviceScope = yield* Effect.scope; const setupKey = (projectId: ProjectId, worktreePath: string) => `${projectId}\0${worktreePath}`; const currentGeneration = (worktreePath: string) => @@ -427,7 +441,7 @@ const make = Effect.gen(function* () { .withMutationPermit(reviveWorktreeUnlocked(input)) .pipe(Effect.map(({ revived }) => ({ revived }))); - const reviveForThreadUnlocked = Effect.fn("WorktreeRevivalService.reviveForThread")(function* ( + const loadProject = Effect.fn("WorktreeRevivalService.loadProject")(function* ( input: WorktreeRevivalForThreadInput, ) { const project = yield* projectsService.getById(input.projectId).pipe( @@ -446,63 +460,137 @@ const make = Effect.gen(function* () { projectId: input.projectId, }); } - const revival = yield* reviveWorktreeUnlocked({ - workspaceRoot: project.value.workspaceRoot, - worktreePath: input.worktreePath, - branch: input.branch, - }); - const setupGenerationKey = setupKey(input.projectId, revival.worktreePath); - const setupGeneration = (yield* Ref.get(setupGenerationByProjectWorktree)).get( - setupGenerationKey, - ); - if (revival.generation > 0 && setupGeneration !== revival.generation) { - yield* setupScripts - .runForThread({ - threadId: input.threadId, - projectId: input.projectId, - projectCwd: project.value.workspaceRoot, - worktreePath: revival.worktreePath, - project: { - id: project.value.id, - workspaceRoot: project.value.workspaceRoot, - scripts: project.value.scripts, - }, - }) - .pipe( - Effect.mapError((cause) => - mutationError("run_setup", cause, { - path: input.worktreePath, - workspaceRoot: project.value.workspaceRoot, - branch: input.branch, - }), - ), - ); - yield* Ref.update(setupGenerationByProjectWorktree, (generations) => { - const next = new Map(generations); - next.set(setupGenerationKey, revival.generation); - return next; + return project.value; + }); + + /** + * Runs the project's setup script in a recreated worktree and resolves + * `outcome` once the agent may start. As at thread launch, an async script + * only has to start, while a script marked `async: false` has to exit 0. + */ + const runSetup = Effect.fn("WorktreeRevivalService.runSetup")(function* ( + input: WorktreeRevivalForThreadInput, + worktreePath: string, + outcome: Deferred.Deferred, + ) { + const project = yield* loadProject(input); + const setupFailed = (cause: unknown) => + mutationError("run_setup", cause, { + path: input.worktreePath, + workspaceRoot: project.workspaceRoot, + branch: input.branch, }); + const setup = yield* setupScripts + .runForThread({ + threadId: input.threadId, + projectId: input.projectId, + projectCwd: project.workspaceRoot, + worktreePath, + project: { + id: project.id, + workspaceRoot: project.workspaceRoot, + scripts: project.scripts, + }, + // Reports the script's exit code back so a required script can be awaited. + observeCompletion: {}, + }) + .pipe(Effect.mapError(setupFailed)); + if (setup.status !== "started" || setup.completion === undefined) return; + if (setup.async) yield* Deferred.succeed(outcome, undefined); + // Awaiting completion also releases the script's terminal subscription. + const completion = yield* setup.completion; + if (!setup.async && completion.exitCode !== 0) { + return yield* setupFailed( + `Setup script exited with ${completion.exitCode ?? "no exit code"}.`, + ); + } + }); + + /** + * Waits until project setup in a recreated worktree lets the agent start. + * Concurrent turn starts share one run per worktree generation. The run is + * forked into the service scope because a cancelled turn start must not + * stop observing the script, or the next turn would wait on it forever. A + * failed run is forgotten so the next turn tries setup again. + */ + const ensureSetup = Effect.fn("WorktreeRevivalService.ensureSetup")(function* ( + input: WorktreeRevivalForThreadInput, + worktreePath: string, + generation: number, + ) { + const key = setupKey(input.projectId, worktreePath); + const fresh = yield* Deferred.make(); + const outcome = yield* Ref.modify(setupRuns, (runs) => { + const current = runs.get(key); + if (current !== undefined && current.generation === generation) { + return [current.outcome, runs] as const; + } + return [fresh, new Map(runs).set(key, { generation, outcome: fresh })] as const; + }); + if (outcome === fresh) { + yield* runSetup(input, worktreePath, fresh).pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + if (Exit.isFailure(exit)) { + yield* Ref.update(setupRuns, (runs) => { + if (runs.get(key)?.outcome !== fresh) return runs; + const next = new Map(runs); + next.delete(key); + return next; + }); + } + yield* Deferred.done(fresh, exit); + }), + ), + Effect.forkIn(serviceScope), + ); } - return { revived: revival.revived, generation: revival.generation }; + yield* Deferred.await(outcome); }); - // Every turn start on a worktree thread passes through here, so an existing - // directory is answered without the mutation permit: a reaper sweep or a - // sibling revival must not hold up turns that need no Git mutation. Only a - // missing directory, or a pending setup retry, takes the locked path. + + /** + * Makes sure a thread's worktree exists before its turn starts. Every turn + * start on a worktree thread passes through here, so an existing directory + * is used as is and without the mutation permit: a reaper sweep or a sibling + * revival must not hold up turns that need no Git mutation. A missing one is + * recreated from its branch under the permit. Setup for a recreated worktree + * runs outside the permit, since a slow script must not block every other + * worktree mutation. + */ const reviveForThread: WorktreeRevivalService["Service"]["reviveForThread"] = (input) => Effect.gen(function* () { const exists = yield* fs.exists(input.worktreePath).pipe(Effect.orElseSucceed(() => false)); + let revival: { + readonly revived: boolean; + readonly generation: number; + readonly worktreePath: string; + }; if (exists) { const worktreePath = yield* canonicalizePath(input.worktreePath); - const generation = yield* currentGeneration(worktreePath); - const setupGeneration = (yield* Ref.get(setupGenerationByProjectWorktree)).get( - setupKey(input.projectId, worktreePath), + revival = { + revived: false, + generation: yield* currentGeneration(worktreePath), + worktreePath, + }; + } else { + revival = yield* lifecycle.withMutationPermit( + loadProject(input).pipe( + Effect.flatMap((project) => + reviveWorktreeUnlocked({ + workspaceRoot: project.workspaceRoot, + worktreePath: input.worktreePath, + branch: input.branch, + }), + ), + ), ); - if (generation === 0 || setupGeneration === generation) { - return { revived: false, generation }; - } } - return yield* lifecycle.withMutationPermit(reviveForThreadUnlocked(input)); + // Generation 0 means this server never recreated the worktree, so its + // setup belonged to the thread launch that created it. + if (revival.generation > 0) { + yield* ensureSetup(input, revival.worktreePath, revival.generation); + } + return { revived: revival.revived, generation: revival.generation }; }); return WorktreeRevivalService.of({ reviveWorktree, reviveForThread }); diff --git a/apps/server/src/vcs/WorktreeService.test.ts b/apps/server/src/vcs/WorktreeService.test.ts index bb95ae574b2b..0d244b596b3e 100644 --- a/apps/server/src/vcs/WorktreeService.test.ts +++ b/apps/server/src/vcs/WorktreeService.test.ts @@ -538,3 +538,116 @@ it.effect("skips a project outside any Git repository instead of failing the inv ); }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), ); + +const initRepository = Effect.fn("WorktreeServiceTest.initRepository")(function* (prefix: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* fs.makeTempDirectoryScoped({ prefix }); + for (const args of [ + ["init", "-b", "main"], + ["config", "user.email", "test@example.com"], + ["config", "user.name", "T3 Test"], + ]) { + yield* driver.execute({ operation: "WorktreeServiceTest.init", cwd: repositoryRoot, args }); + } + yield* fs.writeFileString(path.join(repositoryRoot, "README.md"), "hello\n"); + yield* driver.execute({ + operation: "WorktreeServiceTest.add", + cwd: repositoryRoot, + args: ["add", "README.md"], + }); + yield* driver.execute({ + operation: "WorktreeServiceTest.commit", + cwd: repositoryRoot, + args: ["commit", "-m", "initial"], + }); + return repositoryRoot; +}); + +it.effect("leaves a worktree whose directory is gone out of the inventory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initRepository("t3-worktree-v2-stale-repo-"); + const keptPath = path.join(config.worktreesDir, "stale", "kept"); + const deletedPath = path.join(config.worktreesDir, "stale", "deleted"); + for (const [worktreePath, branch] of [ + [keptPath, "feature/kept"], + [deletedPath, "feature/deleted"], + ] as const) { + yield* driver.createWorktree({ + cwd: repositoryRoot, + refName: "main", + newRefName: branch, + path: worktreePath, + }); + } + yield* fs.remove(deletedPath, { recursive: true }); + + const { worktrees } = yield* Effect.gen(function* () { + const service = yield* WorktreeService; + return yield* service.listWorktrees({}); + }).pipe( + Effect.provide( + makeTestLayer( + () => [makeProject(projectA, repositoryRoot)], + () => [], + ), + ), + ); + + assert.deepEqual( + worktrees.map((worktree) => worktree.path), + [yield* fs.realPath(keptPath)], + ); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); + +it.effect("keeps a safe worktree whose removal the caller no longer wants", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const driver = yield* GitVcsDriver.GitVcsDriver; + const repositoryRoot = yield* initRepository("t3-worktree-v2-unwanted-repo-"); + const worktreePath = path.join(config.worktreesDir, "unwanted", "feature"); + yield* driver.createWorktree({ + cwd: repositoryRoot, + refName: "main", + newRefName: "feature/unwanted", + path: worktreePath, + }); + const canonicalPath = yield* fs.realPath(worktreePath); + + const { unwanted, wanted } = yield* Effect.gen(function* () { + const service = yield* WorktreeService; + const unwanted = yield* service.pruneWorktrees( + { paths: [worktreePath] }, + { stillWanted: () => Effect.succeed(false) }, + ); + const stillThere = yield* fs.exists(worktreePath); + const wanted = yield* service.pruneWorktrees( + { paths: [worktreePath] }, + { stillWanted: () => Effect.succeed(true) }, + ); + return { unwanted: { ...unwanted, stillThere }, wanted }; + }).pipe( + Effect.provide( + makeTestLayer( + () => [makeProject(projectA, repositoryRoot)], + () => [], + ), + ), + ); + + assert.deepEqual(unwanted, { removed: [], skipped: [], stillThere: true }); + assert.deepEqual( + wanted.removed.map((entry) => entry.path), + [canonicalPath], + ); + assert.isFalse(yield* fs.exists(worktreePath)); + }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), +); diff --git a/apps/server/src/vcs/WorktreeService.ts b/apps/server/src/vcs/WorktreeService.ts index be68666bb92b..b31b947ed0e4 100644 --- a/apps/server/src/vcs/WorktreeService.ts +++ b/apps/server/src/vcs/WorktreeService.ts @@ -140,6 +140,18 @@ function inventoryError( }); } +/** Pruning on behalf of automatic cleanup rather than a user's request. */ +export interface WorktreePruneOptions { + /** + * Checked against the revalidated worktree under the mutation permit, right + * before its removal. The reaper passes its retention policy so a policy + * change during a sweep is honoured. + */ + readonly stillWanted?: (worktree: WorktreeInfo) => Effect.Effect; +} + +type InternalPruneOptions = WorktreePruneOptions & { readonly requireOrphaned?: boolean }; + export class WorktreeService extends Context.Service< WorktreeService, { @@ -148,6 +160,7 @@ export class WorktreeService extends Context.Service< ) => Effect.Effect; readonly pruneWorktrees: ( input: VcsPruneWorktreesInput, + options?: WorktreePruneOptions, ) => Effect.Effect; readonly pruneOrphanedWorktree: ( worktreePath: string, @@ -347,8 +360,15 @@ const make = Effect.gen(function* () { ), ), ), + // A registration whose directory is gone has nothing on disk to + // manage, and its status can never be read, so as a row it could + // never be removed. Git's own gc and the next removal in that + // repository prune it, and revival prunes it before recreating the + // path. Effect.map((rawEntries) => - rawEntries.filter((entry) => isPathInside(managedWorktreesRoot, entry.path, path)), + rawEntries.filter( + (entry) => !entry.prunable && isPathInside(managedWorktreesRoot, entry.path, path), + ), ), ), executeLenient("WorktreeService.listGroup.branchSync", group.canonicalWorkspaceRoot, [ @@ -589,7 +609,7 @@ const make = Effect.gen(function* () { // every turn start behind the whole batch. const removeIfStillSafe = Effect.fn("WorktreeService.removeIfStillSafe")(function* ( worktree: WorktreeInfo, - options?: { readonly requireOrphaned?: boolean }, + options?: InternalPruneOptions, ) { const freshBlocker = yield* freshPruneBlocker(worktree).pipe( Effect.catchCause((cause) => @@ -643,6 +663,9 @@ const make = Effect.gen(function* () { if (freshThreadCheck.references.includes("active")) { return { outcome: "skipped" as const, reason: "active_thread" as const }; } + if (options?.stillWanted !== undefined && !(yield* options.stillWanted(worktree))) { + return { outcome: "ignored" as const }; + } const removal = yield* git .removeWorktree({ cwd: worktree.workspaceRoot, path: worktree.path }) @@ -664,7 +687,7 @@ const make = Effect.gen(function* () { const pruneWorktreesInternal = Effect.fn("WorktreeService.pruneWorktrees")(function* ( input: VcsPruneWorktreesInput, - options?: { readonly requireOrphaned?: boolean }, + options?: InternalPruneOptions, ) { // Re-derive the inventory so safety reflects the current state, never a // stale client view. Git's non-forced remove is a second safety boundary. @@ -764,7 +787,8 @@ const make = Effect.gen(function* () { return { removed, skipped }; }); - const pruneWorktrees = (input: VcsPruneWorktreesInput) => pruneWorktreesInternal(input); + const pruneWorktrees = (input: VcsPruneWorktreesInput, options?: WorktreePruneOptions) => + pruneWorktreesInternal(input, options); const pruneOrphanedWorktree = (worktreePath: string) => pruneWorktreesInternal({ paths: [worktreePath] }, { requireOrphaned: true }).pipe( Effect.map((result) => result.removed.length > 0), diff --git a/docs/user/source-control.md b/docs/user/source-control.md index b4fa2e62c0fe..1a6e63545a18 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -151,7 +151,8 @@ conversation. T3 Code only performs automatic cleanup when a worktree has no active thread, no local changes, and no unpushed commits. Removing a safe worktree keeps its local branch and T3 checkpoint history. If a thread needs that worktree again, T3 Code recreates it from the retained branch before starting the -next turn. +next turn and runs the project's setup script there again. If the worktree cannot be recreated, for +example because its branch was deleted, the turn stops and shows why. **Auto-remove after** sets how long an unused safe worktree is kept. Enable **Remove with last thread** to remove a safe worktree as soon as its last linked thread is deleted instead of waiting From 1e4ff9b8f49c4cc120e6afb6fa56d2fe3881bad2 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:38:08 +0200 Subject: [PATCH 3/8] test(server): provide worktree revival to the turn-start memory fixture The memory fixture builds the turn-start layer by hand in an untyped module, so it still supplied the base's interim repair dependencies and not the revival service the ported turn start now requires. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../testkit/ProviderTurnStartMemory.fixture.mjs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs index 36d5ce1adb1b..7e0cdfd37e50 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs +++ b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs @@ -7,13 +7,9 @@ const mode = process.argv[3]; const withHandoff = mode.startsWith("handoff"); const require = NodeModule.createRequire(root + "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/apps/server/package.json"); const load = (name) => import(NodeURL.pathToFileURL(require.resolve("effect/" + name))); -const [Effect, Layer, FileSystem] = await Promise.all([ - load("Effect"), - load("Layer"), - load("FileSystem"), -]); +const [Effect, Layer] = await Promise.all([load("Effect"), load("Layer")]); const app = (file) => import(NodeURL.pathToFileURL(root + "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/apps/server/src/" + file + ".ts")); -const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project, Auth] = +const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Revival, Auth] = await Promise.all([ app("orchestration-v2/ProviderTurnStartService"), app("orchestration-v2/ProjectionStore"), @@ -23,8 +19,7 @@ const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project app("orchestration-v2/IdAllocator"), app("orchestration-v2/EventSink"), app("orchestration-v2/ContextHandoffService"), - app("git/GitWorkflowService"), - app("project/ProjectService"), + app("vcs/WorktreeRevivalService"), app("provider/Services/ProviderAuthService"), ]); let current; @@ -45,9 +40,7 @@ const session = { const dependencies = Layer.mergeAll( Layer.mock(Handoff.ContextHandoffServiceV2)({}), Id.layer, - FileSystem.layerNoop({}), - Layer.mock(Git.GitWorkflowService)({}), - Layer.mock(Project.ProjectService)({}), + Layer.mock(Revival.WorktreeRevivalService)({}), Layer.mock(Auth.ProviderAuthService)({}), Layer.mock(Projection.ProjectionStoreV2)({ getThreadProjection: () => From 51ecc7106245d4c4e9d1b27ea667f5ab85cb9e5a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:38:13 +0200 Subject: [PATCH 4/8] fix(web): reach the worktree inventory from any settings selection The Worktrees search result inherited Source Control's environment-defaults scope, so choosing it with a project selected showed an unavailable notice, although the section lists every connected environment whatever the selection. A search item can now opt out of its page's scope with null. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/settings/settingsSearch.test.ts | 7 +++++++ apps/web/src/components/settings/settingsSearch.ts | 7 +++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 57e44fb2e057..b465b6b5f15a 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -405,6 +405,13 @@ describe("settings search targets", () => { expect(isSettingsSearchScopeAvailable(model.scope, "project")).toBe(true); }); + it("reaches the worktree inventory from every selection", () => { + const worktrees = getSettingsSearchTargetScope("worktrees")!; + for (const kind of ["all", "environment", "project", "checkout"] as const) { + expect(isSettingsSearchScopeAvailable(worktrees.scope, kind)).toBe(true); + } + }); + it("reaches source control discovery and git fetch interval from the default scope", () => { for (const id of ["source-control", "git-fetch-interval"]) { const item = getSettingsSearchTargetScope(id)!; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 30597704e8d2..00d9fea2cffb 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -45,7 +45,8 @@ export interface SettingsSearchItem { readonly targetId?: string; /** Descriptions, option labels, and aliases people may remember instead of the title. */ readonly searchTerms?: ReadonlyArray; - readonly scope?: SettingsSearchScope; + /** Defaults to its page's scope; `null` marks a row that renders at every selection. */ + readonly scope?: SettingsSearchScope | null; // Its row only renders in the desktop app, so a browser result would land on // an anchor that isn't there. readonly desktopOnly?: boolean; @@ -838,6 +839,8 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "worktrees", title: "Worktrees", to: "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/settings/source-control", + // Lists every connected environment, whatever the selection. + scope: null, }, { id: "remote-environments", @@ -897,7 +900,7 @@ export function getSettingsSearchTargetScope(targetId: string) { return item ? { title: item.title, - scope: item.scope ?? SETTINGS_CATEGORY_SCOPES[item.to], + scope: item.scope === undefined ? SETTINGS_CATEGORY_SCOPES[item.to] : item.scope, ...(item.requiresThreadAutoSettlement ? { requiresThreadAutoSettlement: true } : {}), } : null; From f2cfafb8bdbd0324f10494c7818f482282f8d32d Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:20:56 +0200 Subject: [PATCH 5/8] fix(server): build worktree management inside the current v2 runtime The base's orchestration services now read projects from a store that the runtime provides after them. Worktree management was merged in below that store, so the server layer no longer typechecked; it now sits right after the orchestration services it depends on. The project settings upgrade test provides the no-op revival service in place of the removed inline repair. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ProjectSettingsUpgrade.integration.test.ts | 2 ++ apps/server/src/server.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts index 127d8867aa8f..6e8fe6794f5a 100644 --- a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts +++ b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts @@ -24,6 +24,7 @@ import { ServerSettingsService } from "../serverSettings.ts"; import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { layerNoop as worktreeRevivalTestLayer } from "../vcs/WorktreeRevivalService.testkit.ts"; import { WorkspacePaths } from "../workspace/WorkspacePaths.ts"; import { LegacyV1ThreadImporter } from "./legacy/LegacyV1ThreadImporter.ts"; import { OrchestrationV2LayerLive, ProjectServiceLayerLive } from "./runtimeLayer.ts"; @@ -166,6 +167,7 @@ const makeRuntimeLayer = (dbPath: string) => { pruneWorktrees: () => Effect.void, }), ), + Layer.provide(worktreeRevivalTestLayer), Layer.provide(platform), ); }; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b0058482778c..61cb12d1f7e9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -565,6 +565,8 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll( ).pipe( // Core Services Layer.provideMerge(OrchestrationApplicationLayerLive), + // Its orchestration services read projects from the store provided below. + Layer.provideMerge(WorktreeManagementLayerLive), Layer.provideMerge(OrchestrationEventInfrastructureLayerLive), Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), Layer.provideMerge(ServerSettingsLayerLive), @@ -572,7 +574,6 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll( Layer.provideMerge(Layer.mergeAll(SourceControlProviderRegistryLayerLive, GitHubCli.layer)), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), - Layer.provideMerge(WorktreeManagementLayerLive), Layer.provideMerge(WorktreeDeletionCleanupLayerLive), Layer.provideMerge(WorktreeReaperLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive, DeviceLayerLive)), From 48e8c155f40dcedfd59eb41aef6235230e9f98fe Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:21:12 +0200 Subject: [PATCH 6/8] refactor(worktrees): leave automatic cleanup to Storage settings Settings > Storage (#11598) now removes inactive worktrees and worktrees whose last thread was deleted, per machine and per project, off by default. This branch still ran its own reaper and deletion cleanup with a second retention setting that defaulted to 14 days, and it hid the desktop "delete the worktree too?" prompt that Storage cleanup keeps when its deletion rule is off. Drop the reaper, the deletion cleanup, the worktrees retention settings and their rows, the orphaned flag only they read, and the prompt change. The inventory, manual removal, and revival before a turn stay. Storage cleanup now removes under the same mutation permit and bumps the inventory revision, so its removals serialize with revival and show up in the Worktrees list. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/server.ts | 17 -- apps/server/src/serverRuntimeStartup.ts | 9 +- apps/server/src/storageCleanup.ts | 14 +- .../src/vcs/WorktreeDeletionCleanup.test.ts | 259 ------------------ .../server/src/vcs/WorktreeDeletionCleanup.ts | 151 ---------- apps/server/src/vcs/WorktreeReaper.test.ts | 165 ----------- apps/server/src/vcs/WorktreeReaper.ts | 112 -------- apps/server/src/vcs/WorktreeRevivalService.ts | 2 +- apps/server/src/vcs/WorktreeService.test.ts | 32 +-- apps/server/src/vcs/WorktreeService.ts | 57 +--- .../settings/SourceControlSettings.tsx | 117 +------- apps/web/src/hooks/useThreadActions.ts | 22 +- apps/web/src/state/entities.ts | 8 - docs/user/source-control.md | 25 +- packages/contracts/src/environment.ts | 6 +- packages/contracts/src/settings.ts | 28 -- packages/contracts/src/worktrees.ts | 6 +- 17 files changed, 61 insertions(+), 969 deletions(-) delete mode 100644 apps/server/src/vcs/WorktreeDeletionCleanup.test.ts delete mode 100644 apps/server/src/vcs/WorktreeDeletionCleanup.ts delete mode 100644 apps/server/src/vcs/WorktreeReaper.test.ts delete mode 100644 apps/server/src/vcs/WorktreeReaper.ts diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 61cb12d1f7e9..e65f6ce88682 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -100,9 +100,7 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; -import * as WorktreeDeletionCleanup from "./vcs/WorktreeDeletionCleanup.ts"; import * as WorktreeLifecycle from "./vcs/WorktreeLifecycle.ts"; -import * as WorktreeReaper from "./vcs/WorktreeReaper.ts"; import * as WorktreeRevival from "./vcs/WorktreeRevivalService.ts"; import * as WorktreeService from "./vcs/WorktreeService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; @@ -156,7 +154,6 @@ import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; import { OrchestrationEventInfrastructureLayerLive, - OrchestrationV2EventSinkLayerLive, OrchestrationV2ProductionLayerLive, ProjectServiceLayerLive, ProjectSetupScriptRunnerLayerLive, @@ -485,18 +482,6 @@ const WorktreeManagementLayerLive = WorktreeService.layer.pipe( Layer.provideMerge(VcsLayerLive), ); -const WorktreeReaperLayerLive = WorktreeReaper.layer.pipe( - Layer.provideMerge(WorktreeManagementLayerLive), - Layer.provideMerge(ServerSettingsLayerLive), -); - -const WorktreeDeletionCleanupLayerLive = WorktreeDeletionCleanup.layer.pipe( - Layer.provideMerge(OrchestrationV2EventSinkLayerLive), - Layer.provideMerge(OrchestrationApplicationLayerLive), - Layer.provideMerge(WorktreeManagementLayerLive), - Layer.provideMerge(ServerSettingsLayerLive), -); - // Automatic thread settlement (#8600): a server-owned sweep evaluates // inactivity and merged pull requests, then settles through the orchestrator // so every client sees the same shelf. @@ -574,8 +559,6 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll( Layer.provideMerge(Layer.mergeAll(SourceControlProviderRegistryLayerLive, GitHubCli.layer)), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), - Layer.provideMerge(WorktreeDeletionCleanupLayerLive), - Layer.provideMerge(WorktreeReaperLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive, DeviceLayerLive)), Layer.provideMerge(PersistenceLayerLive), // Both read a user-owned file out of the state directory and stream changes diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 5480288869d7..e3f92058e70c 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -42,8 +42,6 @@ import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; -import * as WorktreeDeletionCleanup from "./vcs/WorktreeDeletionCleanup.ts"; -import * as WorktreeReaper from "./vcs/WorktreeReaper.ts"; import { forkParked, forkParkedFiber } from "./serverActivation.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -416,8 +414,6 @@ const make = (options?: StartupOptions) => const legacyV1ThreadImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const providerRuntimeRecovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; - const worktreeDeletionCleanup = yield* WorktreeDeletionCleanup.WorktreeDeletionCleanup; - const worktreeReaper = yield* WorktreeReaper.WorktreeReaper; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -519,10 +515,7 @@ const make = (options?: StartupOptions) => runWorker: EffectWorker.runDaemon, startRelay: agentAwarenessRelay.start(), workerFiberRef: effectWorkerFiber, - }).pipe( - Effect.andThen(worktreeDeletionCleanup.start()), - Effect.andThen(forkParked(worktreeReaper.run)), - ), + }), ), autoBootstrap: (serverConfig.autoBootstrapProjectFromCwd ? runStartupPhase( diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index e4a5bf34d0fa..662a76889e6a 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -36,6 +36,7 @@ import { forkParked } from "./serverActivation.ts"; import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as WorktreeLifecycle from "./vcs/WorktreeLifecycle.ts"; import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; const decodeCleanupThread = Schema.decodeUnknownEffect( @@ -114,6 +115,7 @@ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const git = yield* GitVcsDriver.GitVcsDriver; const gitManager = yield* GitManager.GitManager; + const worktreeLifecycle = yield* WorktreeLifecycle.WorktreeLifecycle; const terminals = yield* TerminalManager.TerminalManager; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -369,10 +371,16 @@ export const make = Effect.gen(function* () { ) ) return; - yield* git.removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false }); + // Serialized with every other worktree mutation, and shown in the + // Worktrees inventory without a manual refresh. + yield* worktreeLifecycle.withMutationPermit( + git + .removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false }) + .pipe(Effect.tap(() => worktreeLifecycle.markInventoryChanged)), + ); yield* gitManager.invalidateStatus(project.workspaceRoot); - // Preserve branch and path: ProviderCommandReactor recreates the checkout - // from that branch when the thread is resumed. + // Preserve branch and path: WorktreeRevivalService recreates the + // checkout from that branch before the thread's next turn. yield* Effect.logInfo("storage cleanup removed worktree", { threadId: thread.id }); }).pipe( (effect) => withWorkspaceLease(worktreePath, effect), diff --git a/apps/server/src/vcs/WorktreeDeletionCleanup.test.ts b/apps/server/src/vcs/WorktreeDeletionCleanup.test.ts deleted file mode 100644 index 23565c4154ef..000000000000 --- a/apps/server/src/vcs/WorktreeDeletionCleanup.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import type { OrchestrationV2DomainEvent, OrchestrationV2StoredEvent } from "@t3tools/contracts"; -import { ThreadId } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; - -import * as EventSink from "../orchestration-v2/EventSink.ts"; -import * as ServerSettings from "../serverSettings.ts"; -import * as WorktreeDeletionCleanup from "./WorktreeDeletionCleanup.ts"; -import * as WorktreeService from "./WorktreeService.ts"; - -const threadId = ThreadId.make("thread:worktree-deletion-cleanup"); - -const deletionEvent = (worktreePath: string | null) => - ({ - type: "thread.deleted", - threadId, - payload: { worktreePath }, - }) as unknown as OrchestrationV2DomainEvent; - -const storedDeletionEvent = (sequence: number, worktreePath: string | null) => - ({ - sequence, - commandId: null, - event: deletionEvent(worktreePath), - }) satisfies OrchestrationV2StoredEvent; - -interface TestEventSource { - readonly latestSequence: Effect.Effect; - readonly streamStoredEventsFrom: ( - afterSequence: number, - ) => Stream.Stream; -} - -const makeLayer = ( - pruneOrphanedWorktree: WorktreeService.WorktreeService["Service"]["pruneOrphanedWorktree"], - options: { - readonly deleteOrphanedImmediately: boolean; - readonly eventSource?: TestEventSource; - }, -) => { - const worktrees = Layer.succeed( - WorktreeService.WorktreeService, - WorktreeService.WorktreeService.of({ - listWorktrees: () => Effect.succeed({ worktrees: [] }), - pruneWorktrees: () => Effect.succeed({ removed: [], skipped: [] }), - pruneOrphanedWorktree, - }), - ); - const eventSource = options.eventSource ?? { - latestSequence: Effect.succeed(0), - streamStoredEventsFrom: () => Stream.empty, - }; - return WorktreeDeletionCleanup.layer.pipe( - Layer.provide(worktrees), - Layer.provide( - Layer.mock(EventSink.EventSinkV2)({ - latestSequence: () => eventSource.latestSequence, - stream: (input) => eventSource.streamStoredEventsFrom(input?.afterSequence ?? 0), - }), - ), - Layer.provide( - ServerSettings.layerTest({ - worktrees: { - autoPruneAfterDays: null, - deleteOrphanedImmediately: options.deleteOrphanedImmediately, - }, - }), - ), - ); -}; - -it.effect("prunes only non-null worktree paths while immediate orphan cleanup is enabled", () => - Effect.gen(function* () { - const pruned: string[] = []; - const prune = (path: string) => - Effect.sync(() => { - pruned.push(path); - return true; - }); - - yield* Effect.gen(function* () { - const cleanup = yield* WorktreeDeletionCleanup.WorktreeDeletionCleanup; - yield* cleanup.enqueue({ threadId, worktreePath: "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/worktrees/disabled" }); - yield* cleanup.drain; - }).pipe( - Effect.provide( - makeLayer(prune, { - deleteOrphanedImmediately: false, - }), - ), - ); - - yield* Effect.gen(function* () { - const cleanup = yield* WorktreeDeletionCleanup.WorktreeDeletionCleanup; - yield* cleanup.enqueue({ threadId, worktreePath: null }); - yield* cleanup.enqueue({ threadId, worktreePath: "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/worktrees/orphan" }); - yield* cleanup.drain; - }).pipe( - Effect.provide( - makeLayer(prune, { - deleteOrphanedImmediately: true, - }), - ), - ); - - assert.deepEqual(pruned, ["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/worktrees/orphan"]); - }), -); - -it.effect("preserves deletion cleanup interruption", () => - Effect.gen(function* () { - const exit = yield* Effect.interrupt.pipe( - WorktreeDeletionCleanup.__testing.recoverDeletionFailure({ - threadId, - worktreePath: "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/worktrees/interrupted", - }), - Effect.exit, - ); - - assert.isTrue(Exit.isFailure(exit)); - if (Exit.isFailure(exit)) { - assert.isTrue(Cause.hasInterruptsOnly(exit.cause)); - } - }), -); - -it.effect("resumes stored deletion events from the last consumed sequence after a failure", () => - Effect.gen(function* () { - const firstAttemptFailed = yield* Deferred.make(); - const recoveredPath = yield* Deferred.make(); - const attempts = yield* Ref.make(0); - const requestedAfterSequences = yield* Ref.make>([]); - const storedEvents = yield* Ref.make>([ - storedDeletionEvent(1, "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/worktrees/before-failure"), - ]); - const eventSource: TestEventSource = { - latestSequence: Effect.succeed(0), - streamStoredEventsFrom: (afterSequence) => - Stream.unwrap( - Effect.gen(function* () { - yield* Ref.update(requestedAfterSequences, (current) => [...current, afterSequence]); - const attempt = yield* Ref.getAndUpdate(attempts, (count) => count + 1); - const available = (yield* Ref.get(storedEvents)).filter( - (stored) => stored.sequence > afterSequence, - ); - return attempt === 0 - ? Stream.concat( - Stream.fromIterable(available), - Stream.fromEffect(Deferred.succeed(firstAttemptFailed, undefined)).pipe( - Stream.drain, - Stream.concat( - Stream.fail( - new EventSink.EventSinkStreamError({ - afterSequence, - cause: "simulated subscription failure", - }), - ), - ), - ), - ) - : Stream.fromIterable(available); - }), - ), - }; - const pruned: string[] = []; - const prune = (path: string) => - Effect.sync(() => pruned.push(path)).pipe( - Effect.andThen( - path === "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/worktrees/recovered" - ? Deferred.succeed(recoveredPath, path) - : Effect.succeed(false), - ), - Effect.as(true), - ); - const layer = makeLayer(prune, { - deleteOrphanedImmediately: true, - eventSource, - }); - - yield* Effect.gen(function* () { - const cleanup = yield* WorktreeDeletionCleanup.WorktreeDeletionCleanup; - yield* cleanup.start(); - yield* cleanup.start(); - yield* Deferred.await(firstAttemptFailed); - yield* Ref.set(storedEvents, [ - storedDeletionEvent(1, "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/worktrees/before-failure"), - storedDeletionEvent(2, "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/worktrees/recovered"), - ]); - yield* TestClock.adjust("1 second"); - assert.equal(yield* Deferred.await(recoveredPath), "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/worktrees/recovered"); - yield* cleanup.drain; - assert.equal(yield* Ref.get(attempts), 2); - assert.deepEqual(yield* Ref.get(requestedAfterSequences), [0, 1]); - assert.deepEqual(pruned, ["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/worktrees/before-failure", "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/worktrees/recovered"]); - }).pipe(Effect.provide(layer)); - }), -); - -it.effect("retries the initial sequence lookup instead of replaying history", () => - Effect.gen(function* () { - const sequenceAttempts = yield* Ref.make(0); - const requestedAfterSequences = yield* Ref.make>([]); - const observedPath = yield* Deferred.make(); - const pruned: string[] = []; - const prune = (path: string) => - Effect.sync(() => pruned.push(path)).pipe( - Effect.andThen(Deferred.succeed(observedPath, path)), - Effect.as(true), - ); - const eventSource: TestEventSource = { - latestSequence: Ref.getAndUpdate(sequenceAttempts, (count) => count + 1).pipe( - Effect.flatMap((attempt) => - attempt === 0 - ? Effect.fail( - new EventSink.EventSinkStreamError({ - afterSequence: 0, - cause: "simulated lookup failure", - }), - ) - : Effect.succeed(2), - ), - ), - streamStoredEventsFrom: (afterSequence) => - Stream.unwrap( - Ref.update(requestedAfterSequences, (current) => [...current, afterSequence]).pipe( - Effect.as( - Stream.fromIterable([ - storedDeletionEvent(1, "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/worktrees/stale"), - storedDeletionEvent(2, "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/worktrees/stale-2"), - storedDeletionEvent(3, "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/worktrees/fresh"), - ]).pipe(Stream.filter((stored) => stored.sequence > afterSequence)), - ), - ), - ), - }; - const layer = makeLayer(prune, { - deleteOrphanedImmediately: true, - eventSource, - }); - - yield* Effect.gen(function* () { - const cleanup = yield* WorktreeDeletionCleanup.WorktreeDeletionCleanup; - yield* cleanup.start(); - yield* TestClock.adjust("1 second"); - assert.equal(yield* Deferred.await(observedPath), "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/worktrees/fresh"); - yield* cleanup.drain; - assert.equal(yield* Ref.get(sequenceAttempts), 2); - assert.deepEqual(yield* Ref.get(requestedAfterSequences), [2]); - assert.deepEqual(pruned, ["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/worktrees/fresh"]); - }).pipe(Effect.provide(layer)); - }), -); diff --git a/apps/server/src/vcs/WorktreeDeletionCleanup.ts b/apps/server/src/vcs/WorktreeDeletionCleanup.ts deleted file mode 100644 index 1aac5249a797..000000000000 --- a/apps/server/src/vcs/WorktreeDeletionCleanup.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { OrchestrationV2DomainEvent, ThreadId } from "@t3tools/contracts"; -import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; -import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import * as Schedule from "effect/Schedule"; -import type * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; - -import { EventSinkV2 } from "../orchestration-v2/EventSink.ts"; -import { ServerSettingsService } from "../serverSettings.ts"; -import { WorktreeService } from "./WorktreeService.ts"; - -export interface ThreadDeletionCleanupRequest { - readonly threadId: ThreadId; - readonly worktreePath: string | null; -} - -const recoverDeletionFailure = - (request: ThreadDeletionCleanupRequest) => - (effect: Effect.Effect): Effect.Effect => - effect.pipe( - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (cause) => - Effect.logWarning("worktree.deletion-cleanup.failed", { - threadId: request.threadId, - worktreePath: request.worktreePath, - cause, - }), - ), - ); - -function threadDeletionCleanupRequest( - event: OrchestrationV2DomainEvent, -): ThreadDeletionCleanupRequest | null { - if (event.type !== "thread.deleted") return null; - return { - threadId: event.threadId, - worktreePath: event.payload.worktreePath, - }; -} - -export class WorktreeDeletionCleanup extends Context.Service< - WorktreeDeletionCleanup, - { - readonly start: () => Effect.Effect; - readonly enqueue: (request: ThreadDeletionCleanupRequest) => Effect.Effect; - readonly drain: Effect.Effect; - } ->()("t3/vcs/WorktreeDeletionCleanup") {} - -const make = Effect.gen(function* () { - const events = yield* EventSinkV2; - const settings = yield* ServerSettingsService; - const worktrees = yield* WorktreeService; - const started = yield* Ref.make(false); - const lastSequence = yield* Ref.make(null); - - const processDeletion = Effect.fn("WorktreeDeletionCleanup.processDeletion")(function* ( - request: ThreadDeletionCleanupRequest, - ) { - if (request.worktreePath === null) return; - - const policy = (yield* settings.getSettings).worktrees; - if (!policy.deleteOrphanedImmediately) return; - - const removed = yield* worktrees.pruneOrphanedWorktree(request.worktreePath); - if (removed) { - yield* Effect.logInfo("worktree.deletion-cleanup.removed", { - threadId: request.threadId, - worktreePath: request.worktreePath, - }); - } - }); - - const worker = yield* makeDrainableWorker((request: ThreadDeletionCleanupRequest) => - processDeletion(request).pipe(recoverDeletionFailure(request)), - ); - - const start: WorktreeDeletionCleanup["Service"]["start"] = Effect.fn( - "WorktreeDeletionCleanup.start", - )(function* () { - const shouldStart = yield* Ref.modify(started, (isStarted) => [!isStarted, true]); - if (!shouldStart) return; - - const storedEvents = Stream.unwrap( - Ref.get(lastSequence).pipe( - Effect.flatMap((current) => - current === null - ? events.latestSequence().pipe( - // Re-fail instead of defaulting to 0: a zero high-water mark would - // replay every historical thread.deleted event against the current - // policy. The stream-level retry below re-runs this lookup. - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (cause) => - Effect.logWarning("worktree.deletion-cleanup.initial-sequence-failed", { - cause, - }).pipe(Effect.andThen(Effect.failCause(cause))), - ), - Effect.tap((sequence) => Ref.set(lastSequence, sequence)), - ) - : Effect.succeed(current), - ), - Effect.map((afterSequence) => events.stream({ afterSequence })), - ), - ); - - yield* storedEvents.pipe( - Stream.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (cause) => - Stream.fromEffect( - Effect.logWarning("worktree.deletion-cleanup.stream-failed", { - cause, - }).pipe(Effect.andThen(Effect.failCause(cause))), - ), - ), - Stream.retry( - Schedule.exponential("1 second").pipe( - Schedule.modifyDelay(({ duration }) => - Effect.succeed(Duration.min(duration, Duration.seconds(30))), - ), - ), - ), - Stream.runForEach((stored) => { - const request = threadDeletionCleanupRequest(stored.event); - const enqueue = request === null ? Effect.void : worker.enqueue(request); - return enqueue.pipe(Effect.andThen(Ref.set(lastSequence, stored.sequence))); - }), - Effect.forkScoped, - ); - }); - - return WorktreeDeletionCleanup.of({ - start, - enqueue: worker.enqueue, - drain: worker.drain, - }); -}); - -export const layer = Layer.effect(WorktreeDeletionCleanup, make); - -/** Exposed for tests. */ -export const __testing = { - recoverDeletionFailure, -}; diff --git a/apps/server/src/vcs/WorktreeReaper.test.ts b/apps/server/src/vcs/WorktreeReaper.test.ts deleted file mode 100644 index 54b797af9c89..000000000000 --- a/apps/server/src/vcs/WorktreeReaper.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { ProjectId, type WorktreeInfo } from "@t3tools/contracts"; - -import * as ServerSettings from "../serverSettings.ts"; -import * as WorktreeReaper from "./WorktreeReaper.ts"; -import * as WorktreeService from "./WorktreeService.ts"; - -const makeWorktree = (overrides: Partial = {}): WorktreeInfo => ({ - projectId: ProjectId.make("reaper-project"), - projectTitle: "Reaper project", - workspaceRoot: "/repo", - projects: [], - path: "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/worktrees/feature", - branch: "feature/reaper", - threads: [], - orphaned: false, - dirty: false, - dirtyFileCount: 0, - hasUpstream: false, - upstreamGone: false, - aheadOfUpstreamCount: null, - behindUpstreamCount: null, - aheadOfDefaultCount: 0, - lastActivityAt: "2020-01-01T00:00:00.000Z", - safeToPrune: true, - pruneBlockers: [], - ...overrides, -}); - -/** - * The worktree service is stubbed: prune records the paths whose removal the - * reaper still wants, and `duringList` runs while the inventory is read, with - * the same settings service the reaper sees. - */ -const makeLayer = ( - inventory: ReadonlyArray, - prune: (paths: ReadonlyArray) => void, - settings: Parameters[0], - observed?: { readonly listCalls: { value: number } }, - duringList: Effect.Effect = Effect.void, -) => { - const worktreeLayer = Layer.effect( - WorktreeService.WorktreeService, - Effect.gen(function* () { - const context = yield* Effect.context(); - return WorktreeService.WorktreeService.of({ - listWorktrees: () => - duringList.pipe( - Effect.provide(context), - Effect.andThen( - Effect.sync(() => { - if (observed !== undefined) observed.listCalls.value += 1; - return { worktrees: [...inventory] }; - }), - ), - ), - pruneWorktrees: (input, options) => - Effect.filter(input.paths, (path) => { - const worktree = inventory.find((candidate) => candidate.path === path); - return worktree === undefined || options?.stillWanted === undefined - ? Effect.succeed(true) - : options.stillWanted(worktree); - }).pipe( - Effect.map((paths) => { - prune(paths); - return { removed: [], skipped: [] }; - }), - ), - pruneOrphanedWorktree: () => Effect.succeed(false), - }); - }), - ); - return WorktreeReaper.layerWith({ initialDelayMs: 0, sweepIntervalMs: 60_000 }).pipe( - Layer.provide(worktreeLayer), - Layer.provide(ServerSettings.layerTest(settings)), - ); -}; - -it.effect("reaps only inactive worktrees already proven safe", () => - Effect.gen(function* () { - const pruned: ReadonlyArray[] = []; - const observed = { listCalls: { value: 0 } }; - const layer = makeLayer( - [ - makeWorktree({ path: "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/worktrees/old", lastActivityAt: "1960-01-01T00:00:00.000Z" }), - makeWorktree({ - path: "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/worktrees/dirty", - lastActivityAt: "1960-01-01T00:00:00.000Z", - safeToPrune: false, - pruneBlockers: ["dirty"], - }), - makeWorktree({ path: "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/worktrees/recent", lastActivityAt: "2999-01-01T00:00:00.000Z" }), - ], - (paths) => pruned.push(paths), - { worktrees: { autoPruneAfterDays: 14, deleteOrphanedImmediately: false } }, - observed, - ); - - yield* Effect.gen(function* () { - const reaper = yield* WorktreeReaper.WorktreeReaper; - yield* reaper.sweep; - }).pipe(Effect.provide(layer)); - - assert.equal(observed.listCalls.value, 1); - assert.deepEqual(pruned, [["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/worktrees/old"]]); - }), -); - -it.effect("can sweep safe orphans immediately and stays inert when disabled", () => - Effect.gen(function* () { - const pruned: ReadonlyArray[] = []; - const layer = makeLayer( - [makeWorktree({ path: "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/worktrees/orphan", orphaned: true, lastActivityAt: null })], - (paths) => pruned.push(paths), - { worktrees: { autoPruneAfterDays: null, deleteOrphanedImmediately: true } }, - ); - - yield* Effect.gen(function* () { - const reaper = yield* WorktreeReaper.WorktreeReaper; - yield* reaper.sweep; - }).pipe(Effect.provide(layer)); - assert.deepEqual(pruned, [["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/worktrees/orphan"]]); - - const disabledPruned: ReadonlyArray[] = []; - const disabledLayer = makeLayer( - [makeWorktree({ path: "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/worktrees/disabled" })], - (paths) => disabledPruned.push(paths), - { worktrees: { autoPruneAfterDays: null, deleteOrphanedImmediately: false } }, - ); - yield* Effect.gen(function* () { - const reaper = yield* WorktreeReaper.WorktreeReaper; - yield* reaper.sweep; - }).pipe(Effect.provide(disabledLayer)); - assert.deepEqual(disabledPruned, []); - }), -); - -it.effect("keeps worktrees when cleanup is turned off during a sweep", () => - Effect.gen(function* () { - const pruned: ReadonlyArray[] = []; - const layer = makeLayer( - [makeWorktree({ path: "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/worktrees/old", lastActivityAt: "1960-01-01T00:00:00.000Z" })], - (paths) => pruned.push(paths), - { worktrees: { autoPruneAfterDays: 14, deleteOrphanedImmediately: false } }, - undefined, - ServerSettings.ServerSettingsService.pipe( - Effect.flatMap((settings) => - settings.updateSettings({ worktrees: { autoPruneAfterDays: null } }), - ), - Effect.orDie, - Effect.asVoid, - ), - ); - - yield* Effect.gen(function* () { - const reaper = yield* WorktreeReaper.WorktreeReaper; - yield* reaper.sweep; - }).pipe(Effect.provide(layer)); - - assert.deepEqual(pruned.flat(), []); - }), -); diff --git a/apps/server/src/vcs/WorktreeReaper.ts b/apps/server/src/vcs/WorktreeReaper.ts deleted file mode 100644 index 0f52900cb6b6..000000000000 --- a/apps/server/src/vcs/WorktreeReaper.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Periodically removes managed worktrees that the inventory proves are safe - * and inactive according to the server policy. - * - * The reaper never removes a worktree directly. Every candidate is handed to - * WorktreeService.pruneWorktrees, which recomputes safety immediately before - * invoking non-forced Git removal. - */ -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schedule from "effect/Schedule"; - -import type { WorktreeInfo, WorktreeSettings } from "@t3tools/contracts"; - -import { ServerSettingsService } from "../serverSettings.ts"; -import { WorktreeService } from "./WorktreeService.ts"; - -const DEFAULT_SWEEP_INTERVAL_MS = 60 * 60 * 1000; -const DEFAULT_INITIAL_DELAY_MS = 2 * 60 * 1000; -const DAY_MS = 24 * 60 * 60 * 1000; - -export interface WorktreeReaperOptions { - readonly sweepIntervalMs?: number; - readonly initialDelayMs?: number; -} - -export class WorktreeReaper extends Context.Service< - WorktreeReaper, - { - /** The sweep loop. Never returns; the runtime forks it behind the - activation boundary so a runtime that never activates never prunes. */ - readonly run: Effect.Effect; - readonly sweep: Effect.Effect; - } ->()("t3/vcs/WorktreeReaper") {} - -/** Whether the retention policy selects a safe worktree for removal now. */ -function isExpired(policy: WorktreeSettings, nowMs: number, worktree: WorktreeInfo): boolean { - if (policy.deleteOrphanedImmediately && worktree.orphaned) return true; - if (policy.autoPruneAfterDays === null || worktree.lastActivityAt === null) return false; - const lastActivityMs = Date.parse(worktree.lastActivityAt); - return ( - !Number.isNaN(lastActivityMs) && lastActivityMs < nowMs - policy.autoPruneAfterDays * DAY_MS - ); -} - -const make = (options?: WorktreeReaperOptions) => - Effect.gen(function* () { - const worktrees = yield* WorktreeService; - const settings = yield* ServerSettingsService; - const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); - const initialDelayMs = Math.max(0, options?.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS); - - const runSweep = Effect.gen(function* () { - const policy = (yield* settings.getSettings).worktrees; - if (policy.autoPruneAfterDays === null && !policy.deleteOrphanedImmediately) { - return; - } - - const { worktrees: inventory } = yield* worktrees.listWorktrees({}); - const now = yield* Clock.currentTimeMillis; - const candidates = inventory.filter( - (worktree) => worktree.safeToPrune && isExpired(policy, now, worktree), - ); - if (candidates.length === 0) return; - - // Reading the inventory and removing worktrees both take time. The - // policy is read again right before each removal, so turning cleanup off - // or lengthening retention during a sweep is honoured. - const stillWanted = (worktree: WorktreeInfo) => - Effect.all([settings.getSettings, Clock.currentTimeMillis]).pipe( - Effect.map(([current, nowMs]) => isExpired(current.worktrees, nowMs, worktree)), - Effect.orElseSucceed(() => false), - ); - const result = yield* worktrees.pruneWorktrees( - { paths: candidates.map((worktree) => worktree.path) }, - { stillWanted }, - ); - yield* Effect.logInfo("worktree.reaper.sweep-complete", { - candidateCount: candidates.length, - removedCount: result.removed.length, - skippedCount: result.skipped.length, - autoPruneAfterDays: policy.autoPruneAfterDays, - deleteOrphanedImmediately: policy.deleteOrphanedImmediately, - }); - }); - - // Interrupts must propagate so scope close and shutdown stop the loop. - const sweep = runSweep.pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.interrupt - : Effect.logWarning("worktree.reaper.sweep-failed", { cause }), - ), - ); - - const run = Effect.sleep(Duration.millis(initialDelayMs)).pipe( - Effect.andThen(sweep.pipe(Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))))), - Effect.asVoid, - ); - - return WorktreeReaper.of({ run, sweep }); - }); - -export const layer = Layer.effect(WorktreeReaper, make()); - -export const layerWith = (options: WorktreeReaperOptions) => - Layer.effect(WorktreeReaper, make(options)); diff --git a/apps/server/src/vcs/WorktreeRevivalService.ts b/apps/server/src/vcs/WorktreeRevivalService.ts index 03590206a174..7fe285ce244b 100644 --- a/apps/server/src/vcs/WorktreeRevivalService.ts +++ b/apps/server/src/vcs/WorktreeRevivalService.ts @@ -551,7 +551,7 @@ const make = Effect.gen(function* () { /** * Makes sure a thread's worktree exists before its turn starts. Every turn * start on a worktree thread passes through here, so an existing directory - * is used as is and without the mutation permit: a reaper sweep or a sibling + * is used as is and without the mutation permit: a cleanup or a sibling * revival must not hold up turns that need no Git mutation. A missing one is * recreated from its branch under the permit. Setup for a recreated worktree * runs outside the permit, since a slow script must not block every other diff --git a/apps/server/src/vcs/WorktreeService.test.ts b/apps/server/src/vcs/WorktreeService.test.ts index 0d244b596b3e..58946a2e0563 100644 --- a/apps/server/src/vcs/WorktreeService.test.ts +++ b/apps/server/src/vcs/WorktreeService.test.ts @@ -606,34 +606,25 @@ it.effect("leaves a worktree whose directory is gone out of the inventory", () = }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), ); -it.effect("keeps a safe worktree whose removal the caller no longer wants", () => +it.effect("removes a safe worktree and keeps its branch", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig.ServerConfig; const driver = yield* GitVcsDriver.GitVcsDriver; - const repositoryRoot = yield* initRepository("t3-worktree-v2-unwanted-repo-"); - const worktreePath = path.join(config.worktreesDir, "unwanted", "feature"); + const repositoryRoot = yield* initRepository("t3-worktree-v2-remove-repo-"); + const worktreePath = path.join(config.worktreesDir, "remove", "feature"); yield* driver.createWorktree({ cwd: repositoryRoot, refName: "main", - newRefName: "feature/unwanted", + newRefName: "feature/remove", path: worktreePath, }); const canonicalPath = yield* fs.realPath(worktreePath); - const { unwanted, wanted } = yield* Effect.gen(function* () { + const result = yield* Effect.gen(function* () { const service = yield* WorktreeService; - const unwanted = yield* service.pruneWorktrees( - { paths: [worktreePath] }, - { stillWanted: () => Effect.succeed(false) }, - ); - const stillThere = yield* fs.exists(worktreePath); - const wanted = yield* service.pruneWorktrees( - { paths: [worktreePath] }, - { stillWanted: () => Effect.succeed(true) }, - ); - return { unwanted: { ...unwanted, stillThere }, wanted }; + return yield* service.pruneWorktrees({ paths: [worktreePath] }); }).pipe( Effect.provide( makeTestLayer( @@ -643,11 +634,18 @@ it.effect("keeps a safe worktree whose removal the caller no longer wants", () = ), ); - assert.deepEqual(unwanted, { removed: [], skipped: [], stillThere: true }); assert.deepEqual( - wanted.removed.map((entry) => entry.path), + result.removed.map((entry) => entry.path), [canonicalPath], ); + assert.deepEqual(result.skipped, []); assert.isFalse(yield* fs.exists(worktreePath)); + const branch = yield* driver.execute({ + operation: "WorktreeServiceTest.branchKept", + cwd: repositoryRoot, + args: ["show-ref", "--verify", "--quiet", "refs/heads/feature/remove"], + allowNonZeroExit: true, + }); + assert.equal(branch.exitCode, 0); }).pipe(Effect.provide(Layer.mergeAll(serverConfigLiveLayer, NodeServices.layer, gitLayer))), ); diff --git a/apps/server/src/vcs/WorktreeService.ts b/apps/server/src/vcs/WorktreeService.ts index b31b947ed0e4..df62843b353d 100644 --- a/apps/server/src/vcs/WorktreeService.ts +++ b/apps/server/src/vcs/WorktreeService.ts @@ -140,18 +140,6 @@ function inventoryError( }); } -/** Pruning on behalf of automatic cleanup rather than a user's request. */ -export interface WorktreePruneOptions { - /** - * Checked against the revalidated worktree under the mutation permit, right - * before its removal. The reaper passes its retention policy so a policy - * change during a sweep is honoured. - */ - readonly stillWanted?: (worktree: WorktreeInfo) => Effect.Effect; -} - -type InternalPruneOptions = WorktreePruneOptions & { readonly requireOrphaned?: boolean }; - export class WorktreeService extends Context.Service< WorktreeService, { @@ -160,11 +148,7 @@ export class WorktreeService extends Context.Service< ) => Effect.Effect; readonly pruneWorktrees: ( input: VcsPruneWorktreesInput, - options?: WorktreePruneOptions, ) => Effect.Effect; - readonly pruneOrphanedWorktree: ( - worktreePath: string, - ) => Effect.Effect; } >()("t3/vcs/WorktreeService") {} @@ -490,10 +474,6 @@ const make = Effect.gen(function* () { path: entry.path, branch: entry.refName, threads: threadRefs, - // A worktree that never had a thread is not an orphan: it may be a - // launch still binding its thread, or a checkout the user made. - orphaned: - threadRefs.length > 0 && threadRefs.every((thread) => thread.status === "deleted"), dirty: status.dirty, dirtyFileCount: status.dirtyFileCount, hasUpstream, @@ -527,7 +507,7 @@ const make = Effect.gen(function* () { const canonicalWorkspaceRoot = yield* canonicalizePath(project.workspaceRoot); // A project outside any Git repository, or whose directory is gone, // has no worktrees to list; skip it rather than fail the inventory - // for every other project and stall the reaper. + // for every other project. const commonDir = yield* executeLenient( "WorktreeService.listWorktrees.repositoryKey", canonicalWorkspaceRoot, @@ -605,11 +585,10 @@ const make = Effect.gen(function* () { }); // Fresh Git and thread rechecks plus the removal run under the mutation - // permit per worktree, so a sweep over many stale worktrees never holds - // every turn start behind the whole batch. + // permit per worktree, so a request for many worktrees never holds every + // turn start behind the whole batch. const removeIfStillSafe = Effect.fn("WorktreeService.removeIfStillSafe")(function* ( worktree: WorktreeInfo, - options?: InternalPruneOptions, ) { const freshBlocker = yield* freshPruneBlocker(worktree).pipe( Effect.catchCause((cause) => @@ -657,15 +636,9 @@ const make = Effect.gen(function* () { if (!freshThreadCheck.ok) { return { outcome: "skipped" as const, reason: "status_unavailable" as const }; } - if (options?.requireOrphaned === true && freshThreadCheck.references.length > 0) { - return { outcome: "ignored" as const }; - } if (freshThreadCheck.references.includes("active")) { return { outcome: "skipped" as const, reason: "active_thread" as const }; } - if (options?.stillWanted !== undefined && !(yield* options.stillWanted(worktree))) { - return { outcome: "ignored" as const }; - } const removal = yield* git .removeWorktree({ cwd: worktree.workspaceRoot, path: worktree.path }) @@ -685,10 +658,9 @@ const make = Effect.gen(function* () { return { outcome: "removed" as const }; }); - const pruneWorktreesInternal = Effect.fn("WorktreeService.pruneWorktrees")(function* ( - input: VcsPruneWorktreesInput, - options?: InternalPruneOptions, - ) { + const pruneWorktrees: WorktreeService["Service"]["pruneWorktrees"] = Effect.fn( + "WorktreeService.pruneWorktrees", + )(function* (input) { // Re-derive the inventory so safety reflects the current state, never a // stale client view. Git's non-forced remove is a second safety boundary. const { worktrees } = yield* listWorktrees({}).pipe( @@ -721,13 +693,6 @@ const make = Effect.gen(function* () { skipped.push({ path: requestedPath, reason: "unknown_worktree" }); continue; } - // Thread deletion cleanup is allowed to remove only an orphan. This is - // deliberately checked on the fresh inventory above so a worktree that - // became shared after the deletion event cannot be removed by a stale - // observation. - if (options?.requireOrphaned === true && !worktree.orphaned) { - continue; - } if (!worktree.safeToPrune) { skipped.push({ path: worktree.path, @@ -736,8 +701,7 @@ const make = Effect.gen(function* () { continue; } - const result = yield* lifecycle.withMutationPermit(removeIfStillSafe(worktree, options)); - if (result.outcome === "ignored") continue; + const result = yield* lifecycle.withMutationPermit(removeIfStillSafe(worktree)); if (result.outcome === "skipped") { skipped.push({ path: worktree.path, @@ -787,16 +751,9 @@ const make = Effect.gen(function* () { return { removed, skipped }; }); - const pruneWorktrees = (input: VcsPruneWorktreesInput, options?: WorktreePruneOptions) => - pruneWorktreesInternal(input, options); - const pruneOrphanedWorktree = (worktreePath: string) => - pruneWorktreesInternal({ paths: [worktreePath] }, { requireOrphaned: true }).pipe( - Effect.map((result) => result.removed.length > 0), - ); return WorktreeService.of({ listWorktrees, pruneWorktrees, - pruneOrphanedWorktree, }); }); diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index bc1c78875444..3ea18e2ee302 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -27,15 +27,12 @@ import { getBackgroundActivityPresetSettings, resolveServerBackgroundActivitySettings, } from "@t3tools/shared/backgroundActivitySettings"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { useSettingsScope } from "./SettingsScopeContext"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { cn } from "../../lib/utils"; import { useAtomCommand } from "../../state/use-atom-command"; - -import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; import { useProject } from "../../state/entities"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -72,7 +69,6 @@ import { NumberFieldInput, } from "../ui/number-field"; import { Switch } from "../ui/switch"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { @@ -93,7 +89,6 @@ import { PolicyTooltip, SettingResetButton, SettingsPageContainer, - SettingsRow, SettingsSearchTarget, SettingsSection, useRelativeTimeTick, @@ -545,52 +540,8 @@ type WorktreeManagementViewProps = { readonly pendingPath: string | null; readonly inventoryError: string | null; readonly isPending: boolean; - readonly pruneAfterDays: number | null; - readonly deleteOrphanedImmediately: boolean; - readonly canResetPruneAfterDays: boolean; - readonly canResetDeleteOrphaned: boolean; - readonly onPruneAfterDaysChange: (days: number | null) => void; - readonly onDeleteOrphanedImmediatelyChange: (checked: boolean) => void; - readonly onResetPruneAfterDays: () => void; - readonly onResetDeleteOrphaned: () => void; }; -function WorktreeRetentionSelect({ - pruneAfterDays, - onChange, - className = "w-full sm:w-36", -}: { - readonly pruneAfterDays: number | null; - readonly onChange: (days: number | null) => void; - readonly className?: string; -}) { - const value = pruneAfterDays === null ? "never" : String(pruneAfterDays); - return ( - - ); -} - type WorktreeRowProps = { readonly environmentId: EnvironmentId; readonly worktree: WorktreeInfo; @@ -792,51 +743,6 @@ function WorktreeStateCell({ worktree, onPrune, pendingPath }: WorktreeRowProps) ); } -/** Cleanup policy as canonical settings rows. */ -function WorktreePolicyRows(props: WorktreeManagementViewProps) { - return ( - <> - - ) : undefined - } - control={ - - } - /> - - ) : undefined - } - control={ - props.onDeleteOrphanedImmediatelyChange(Boolean(checked))} - aria-label="Remove a worktree when its last linked thread is deleted" - /> - } - /> - - ); -} - function WorktreeRow({ environmentId, worktree, onPrune, pendingPath }: WorktreeRowProps) { return (
@@ -932,7 +838,6 @@ function WorktreeLedgerView(props: WorktreeManagementViewProps) { const groups = groupWorktreesByProject(props.worktrees); return (
- {props.isPending ? ( @@ -1009,8 +914,8 @@ type WorktreeEnvironmentTarget = { readonly isPrimary: boolean; }; -/** Worktree inventory and cleanup policy for one environment. Mounted per - environment, so state can never leak across servers. */ +/** Worktree inventory for one environment. Mounted per environment, so state + can never leak across servers. */ function WorktreeEnvironmentGroup({ target, showLabel, @@ -1023,8 +928,6 @@ function WorktreeEnvironmentGroup({ readonly onPendingChange: (environmentId: EnvironmentId, pending: boolean) => void; }) { const environmentId = target.environmentId; - const settings = useEnvironmentSettings(environmentId); - const updateSettings = useUpdateEnvironmentSettings(environmentId); const inventory = useEnvironmentQuery(worktreeEnvironment.list({ environmentId, input: {} })); const inventoryChanges = useEnvironmentQuery( worktreeEnvironment.changes({ environmentId, input: {} }), @@ -1100,9 +1003,6 @@ function WorktreeEnvironmentGroup({ }); }; - const pruneAfterDays = settings.worktrees.autoPruneAfterDays; - const deleteOrphanedImmediately = settings.worktrees.deleteOrphanedImmediately; - const defaults = DEFAULT_UNIFIED_SETTINGS.worktrees; const isInitialInventoryPending = inventory.isPending && inventory.data === null; const managementProps: WorktreeManagementViewProps = { environmentId, @@ -1111,19 +1011,6 @@ function WorktreeEnvironmentGroup({ pendingPath, inventoryError: inventory.error ?? null, isPending: isInitialInventoryPending, - pruneAfterDays, - deleteOrphanedImmediately, - canResetPruneAfterDays: pruneAfterDays !== defaults.autoPruneAfterDays, - canResetDeleteOrphaned: deleteOrphanedImmediately !== defaults.deleteOrphanedImmediately, - onPruneAfterDaysChange: (days) => updateSettings({ worktrees: { autoPruneAfterDays: days } }), - onDeleteOrphanedImmediatelyChange: (checked) => - updateSettings({ worktrees: { deleteOrphanedImmediately: checked } }), - onResetPruneAfterDays: () => - updateSettings({ worktrees: { autoPruneAfterDays: defaults.autoPruneAfterDays } }), - onResetDeleteOrphaned: () => - updateSettings({ - worktrees: { deleteOrphanedImmediately: defaults.deleteOrphanedImmediately }, - }), }; return ( diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 9b70e1d1a229..1ca620c5ccc7 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -34,7 +34,6 @@ import { readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, readEnvironmentSupportsVisitedTracking, - readEnvironmentSupportsWorktreeManagement, readEnvironmentThreadRefs, readProject, readThreadShell, @@ -405,15 +404,10 @@ export function useThreadActions() { const shell = readThreadShell(ref); return shell === null ? [] : [shell]; }); - const needsLegacyWorktreeCleanup = !readEnvironmentSupportsWorktreeManagement( - threadRef.environmentId, - ); - const threadProject = needsLegacyWorktreeCleanup - ? readProject({ - environmentId: threadRef.environmentId, - projectId: thread.projectId, - }) - : null; + const threadProject = readProject({ + environmentId: threadRef.environmentId, + projectId: thread.projectId, + }); const deletedIds = opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0 ? new Set( @@ -427,9 +421,10 @@ export function useThreadActions() { deletedIds && deletedIds.size > 0 ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) : threads; - const orphanedWorktreePath = needsLegacyWorktreeCleanup - ? getOrphanedWorktreePathForThread(survivingThreads, threadRef.threadId) - : null; + const orphanedWorktreePath = getOrphanedWorktreePathForThread( + survivingThreads, + threadRef.threadId, + ); const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; @@ -565,7 +560,6 @@ export function useThreadActions() { // The thread was deleted. Cleanup has its own toast; returning its // failure would make callers incorrectly report a thread deletion error. } - return deleteResult; }, [ diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 3f94f44dd5fe..a21e5e5c4c4b 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -265,14 +265,6 @@ export function readEnvironmentSupportsVisitedTracking(environmentId: Environmen ); } -/** Whether the server owns worktree inventory, pruning, and orphan cleanup. */ -export function readEnvironmentSupportsWorktreeManagement(environmentId: EnvironmentId): boolean { - return ( - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .worktreeManagement === true - ); -} - export function readEnvironmentThreadRefs( environmentId: EnvironmentId, ): ReadonlyArray { diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 1a6e63545a18..dcc718a3f4ef 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -143,22 +143,19 @@ does not show its diff, so marks are made and read on web and desktop. ## Manage thread worktrees -Open **Settings → Source Control → Worktrees** to see the Git worktrees managed by T3 Code. -Worktrees are grouped by project. Each row shows its branch, a linked thread, recent activity, and -whether local work protects it from cleanup. Select an available thread title to return to that +Open **Settings → Source Control → Worktrees** to see the Git worktrees managed by T3 Code on each +connected machine, grouped by project. Each row shows its branch, a linked thread, recent activity, +and either **Remove** or what protects it. Select an available thread title to return to that conversation. -T3 Code only performs automatic cleanup when a worktree has no active thread, no local changes, and -no unpushed commits. Removing a safe worktree keeps its local branch and T3 checkpoint history. If a -thread needs that worktree again, T3 Code recreates it from the retained branch before starting the -next turn and runs the project's setup script there again. If the worktree cannot be recreated, for -example because its branch was deleted, the turn stops and shows why. - -**Auto-remove after** sets how long an unused safe worktree is kept. Enable **Remove with last -thread** to remove a safe worktree as soon as its last linked thread is deleted instead of waiting -out that period. Protected worktrees remain listed for you to inspect; T3 Code never automatically -removes dirty or unpushed worktrees. A detached worktree counts as unpushed until its commit is on -the default branch. +A worktree can be removed only when it has no active thread, no local changes, and no unpushed +commits. A detached worktree counts as unpushed until its commit is on the default branch. Removing +a worktree keeps its local branch and T3 checkpoint history. To remove worktrees automatically, see +[Storage cleanup](./project-settings.md#storage-cleanup). + +If a thread needs a removed worktree again, T3 Code recreates it from the retained branch before +starting the next turn and runs the project's setup script there again. If the worktree cannot be +recreated, for example because its branch was deleted, the turn stops and shows why. ## Troubleshooting diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 93b024539afa..4c88c1e1c51d 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -163,9 +163,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean), threadPullRequests: Schema.optionalKey(Schema.Boolean), pullRequestStackActions: Schema.optionalKey(Schema.Boolean), - /** Server exposes the worktree.list/changes/prune APIs and runs worktree - lifecycle cleanup. Same version-skew contract as threadSettlement: - clients hide worktree management for environments that lack this. */ + /** Server exposes the worktree inventory, change stream, and removal RPCs. + Same version-skew contract as threadSettlement: clients hide worktree + management for environments that lack this. */ worktreeManagement: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3ef6763d06d7..764e6d560a73 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1024,27 +1024,6 @@ export interface BranchNamingOptions { instructions: string; } -const MIN_WORKTREE_AUTO_PRUNE_AFTER_DAYS = 1; -const MAX_WORKTREE_AUTO_PRUNE_AFTER_DAYS = 365; -export const WorktreeAutoPruneAfterDays = Schema.Int.check( - Schema.isBetween({ - minimum: MIN_WORKTREE_AUTO_PRUNE_AFTER_DAYS, - maximum: MAX_WORKTREE_AUTO_PRUNE_AFTER_DAYS, - }), -); -export type WorktreeAutoPruneAfterDays = typeof WorktreeAutoPruneAfterDays.Type; -const DEFAULT_WORKTREE_AUTO_PRUNE_AFTER_DAYS: WorktreeAutoPruneAfterDays = 14; - -export const WorktreeSettings = Schema.Struct({ - /** Remove safe-to-prune worktrees after this many inactive days; null disables it. */ - autoPruneAfterDays: Schema.NullOr(WorktreeAutoPruneAfterDays).pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_WORKTREE_AUTO_PRUNE_AFTER_DAYS)), - ), - /** Allow the reaper to remove safe orphaned worktrees on its next sweep. */ - deleteOrphanedImmediately: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), -}); -export type WorktreeSettings = typeof WorktreeSettings.Type; - export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); export const DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL = Duration.minutes(5); @@ -1419,7 +1398,6 @@ export const ServerSettings = Schema.Struct({ usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), - worktrees: WorktreeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -1689,12 +1667,6 @@ export const ServerSettingsPatch = Schema.Struct({ apiToken: Schema.optionalKey(TrimmedString), }), ), - worktrees: Schema.optionalKey( - Schema.Struct({ - autoPruneAfterDays: Schema.optionalKey(Schema.NullOr(WorktreeAutoPruneAfterDays)), - deleteOrphanedImmediately: Schema.optionalKey(Schema.Boolean), - }), - ), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), diff --git a/packages/contracts/src/worktrees.ts b/packages/contracts/src/worktrees.ts index ca2dc8246527..bf4d837412b9 100644 --- a/packages/contracts/src/worktrees.ts +++ b/packages/contracts/src/worktrees.ts @@ -24,7 +24,7 @@ export const WorktreeProjectRef = Schema.Struct({ }); export type WorktreeProjectRef = typeof WorktreeProjectRef.Type; -/** Reasons a managed worktree must not be pruned automatically. */ +/** Reasons a managed worktree cannot be removed from the inventory. */ export const WorktreePruneBlocker = Schema.Literals([ "active_thread", "dirty", @@ -42,8 +42,6 @@ export const WorktreeInfo = Schema.Struct({ path: Schema.NonEmptyString, branch: Schema.NullOr(TrimmedNonEmptyString), threads: Schema.Array(WorktreeThreadRef), - /** No non-deleted V2 shell references this worktree. */ - orphaned: Schema.Boolean, /** null when the working-tree status could not be read. */ dirty: Schema.NullOr(Schema.Boolean), /** Number of changed/untracked files; null when status could not be read. */ @@ -54,7 +52,7 @@ export const WorktreeInfo = Schema.Struct({ aheadOfUpstreamCount: Schema.NullOr(NonNegativeInt), behindUpstreamCount: Schema.NullOr(NonNegativeInt), aheadOfDefaultCount: Schema.NullOr(NonNegativeInt), - /** Latest linked-thread activity; falls back to directory mtime for orphans. */ + /** Latest linked-thread activity; falls back to directory mtime without a live thread. */ lastActivityAt: Schema.NullOr(IsoDateTime), safeToPrune: Schema.Boolean, pruneBlockers: Schema.Array(WorktreePruneBlocker), From a221822e61bff9d13261b8bd3d0bca1a5da115fc Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:28:05 +0200 Subject: [PATCH 7/8] perf(server): create worktrees without waiting on the mutation permit Thread launches, PR threads, and the MCP handoff created worktrees under the server-wide mutation permit, so parallel launches (one worktree per model) ran their checkout and submodule init one after another, and a slow checkout held up removals and turn starts that needed a revival. Creating a worktree only adds a new path, and git locks it against prune while it initializes, so creation now just bumps the inventory revision. Removals and revival still hold the permit. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/git/GitWorkflowService.ts | 18 ++++++++---------- apps/server/src/vcs/WorktreeLifecycle.ts | 7 +++++++ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 83b5317f8c75..ad6cd79e9e85 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -337,12 +337,12 @@ export const make = Effect.gen(function* () { "GitWorkflowService.resolvePullRequest", gitManager.resolvePullRequest, ), + // Creating a worktree only adds a new path, so it skips the mutation + // permit: parallel launches keep checking out (and fetching) side by side. preparePullRequestThread: (input) => - worktreeLifecycle.withMutationPermit( - ensureGit("GitWorkflowService.preparePullRequestThread", input.cwd).pipe( - Effect.andThen(gitManager.preparePullRequestThread(input)), - Effect.tap(() => worktreeLifecycle.markInventoryChanged), - ), + ensureGit("GitWorkflowService.preparePullRequestThread", input.cwd).pipe( + Effect.andThen(gitManager.preparePullRequestThread(input)), + Effect.tap(() => worktreeLifecycle.markInventoryChanged), ), listRefs: (input) => detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe( @@ -351,11 +351,9 @@ export const make = Effect.gen(function* () { ), ), createWorktree: (input, options) => - worktreeLifecycle.withMutationPermit( - ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( - Effect.andThen(git.createWorktree(input, options)), - Effect.tap(() => worktreeLifecycle.markInventoryChanged), - ), + ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( + Effect.andThen(git.createWorktree(input, options)), + Effect.tap(() => worktreeLifecycle.markInventoryChanged), ), listLocalBranchNames: (cwd) => ensureGitCommand("GitWorkflowService.listLocalBranchNames", cwd).pipe( diff --git a/apps/server/src/vcs/WorktreeLifecycle.ts b/apps/server/src/vcs/WorktreeLifecycle.ts index 6bcdf4e800b4..60a36a81ff57 100644 --- a/apps/server/src/vcs/WorktreeLifecycle.ts +++ b/apps/server/src/vcs/WorktreeLifecycle.ts @@ -7,6 +7,13 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import type { WorktreeInventoryChange } from "@t3tools/contracts"; +/** + * Server-wide coordination for existing worktrees. Removals and revival hold + * the mutation permit, so a removal's final safety check or a revival's + * missing-path check never interleaves with another removal or revival. + * Creating a new worktree only adds a path and skips it. Every change bumps + * the inventory revision that settings clients subscribe to. + */ export class WorktreeLifecycle extends Context.Service< WorktreeLifecycle, { From 86035311f5a6e44c868b0100f977f813fad57fe1 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:40:04 +0200 Subject: [PATCH 8/8] fix(web): refresh a worktree inventory read before its latest change The inventory list and its change stream are separate requests, and the Worktrees section took the stream's first revision as already read. A change landing between the list read and the subscription, or while the section was closed and the list still cached, stayed hidden until another change or a manual refresh. The list now reports the revision it was read at, and the section refetches once whenever the stream shows a different one. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/vcs/WorktreeLifecycle.ts | 2 ++ apps/server/src/vcs/WorktreeService.test.ts | 12 ++++++++++-- apps/server/src/vcs/WorktreeService.ts | 4 +++- .../settings/SourceControlSettings.tsx | 18 ++++++++++-------- packages/contracts/src/worktrees.ts | 3 +++ 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/apps/server/src/vcs/WorktreeLifecycle.ts b/apps/server/src/vcs/WorktreeLifecycle.ts index 60a36a81ff57..ecfb63a721a0 100644 --- a/apps/server/src/vcs/WorktreeLifecycle.ts +++ b/apps/server/src/vcs/WorktreeLifecycle.ts @@ -21,6 +21,7 @@ export class WorktreeLifecycle extends Context.Service< effect: Effect.Effect, ) => Effect.Effect; readonly markInventoryChanged: Effect.Effect; + readonly revision: Effect.Effect; readonly changes: Stream.Stream; } >()("t3/vcs/WorktreeLifecycle") {} @@ -39,6 +40,7 @@ export const make = Effect.gen(function* () { return WorktreeLifecycle.of({ withMutationPermit, markInventoryChanged, + revision: SubscriptionRef.get(revision), changes, }); }); diff --git a/apps/server/src/vcs/WorktreeService.test.ts b/apps/server/src/vcs/WorktreeService.test.ts index 58946a2e0563..2fc44d4f6b63 100644 --- a/apps/server/src/vcs/WorktreeService.test.ts +++ b/apps/server/src/vcs/WorktreeService.test.ts @@ -622,9 +622,12 @@ it.effect("removes a safe worktree and keeps its branch", () => }); const canonicalPath = yield* fs.realPath(worktreePath); - const result = yield* Effect.gen(function* () { + const { before, result, after } = yield* Effect.gen(function* () { const service = yield* WorktreeService; - return yield* service.pruneWorktrees({ paths: [worktreePath] }); + const before = yield* service.listWorktrees({}); + const result = yield* service.pruneWorktrees({ paths: [worktreePath] }); + const after = yield* service.listWorktrees({}); + return { before, result, after }; }).pipe( Effect.provide( makeTestLayer( @@ -639,6 +642,11 @@ it.effect("removes a safe worktree and keeps its branch", () => [canonicalPath], ); assert.deepEqual(result.skipped, []); + // The removal bumps the revision a later list reports, so clients can tell + // an earlier list is stale. + assert.equal(before.worktrees.length, 1); + assert.deepEqual(after.worktrees, []); + assert.isAbove(after.revision, before.revision); assert.isFalse(yield* fs.exists(worktreePath)); const branch = yield* driver.execute({ operation: "WorktreeServiceTest.branchKept", diff --git a/apps/server/src/vcs/WorktreeService.ts b/apps/server/src/vcs/WorktreeService.ts index df62843b353d..24103b9acc8b 100644 --- a/apps/server/src/vcs/WorktreeService.ts +++ b/apps/server/src/vcs/WorktreeService.ts @@ -493,6 +493,8 @@ const make = Effect.gen(function* () { const listWorktrees: WorktreeService["Service"]["listWorktrees"] = Effect.fn( "WorktreeService.listWorktrees", )(function* (input) { + // Read first, so a change that lands while listing reports a newer revision. + const revision = yield* lifecycle.revision; const projectSnapshot = yield* projectsService.snapshot.pipe( Effect.mapError((cause) => inventoryError("load_projects", cause)), ); @@ -581,7 +583,7 @@ const make = Effect.gen(function* () { const bMs = b.lastActivityAt === null ? 0 : Date.parse(b.lastActivityAt); return aMs - bMs; }); - return { worktrees }; + return { worktrees, revision }; }); // Fresh Git and thread rechecks plus the removal run under the mutation diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 3ea18e2ee302..e33a63fb3281 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -947,17 +947,19 @@ function WorktreeEnvironmentGroup({ removedPaths.size === 0 ? inventoryWorktrees : inventoryWorktrees.filter((worktree) => !removedPaths.has(worktree.path)); - const observedInventoryRevision = useRef(null); + const refreshedForRevision = useRef(null); + const listedRevision = inventory.data?.revision; useEffect(() => { const revision = inventoryChanges.data?.revision; - if (revision === undefined || observedInventoryRevision.current === revision) return; - // The first revision describes the state the initial query already read; - // only later bumps mean the inventory changed underneath us. - const isFirstObservation = observedInventoryRevision.current === null; - observedInventoryRevision.current = revision; - if (!isFirstObservation) inventory.refresh(); - }, [inventory.refresh, inventoryChanges.data?.revision]); + if (revision === undefined || listedRevision === undefined) return; + // The list and the change stream are separate requests, so compare the + // revision the list was read at. Differs, not newer: a restarted server + // starts over at 0. One read per revision, so a failing read cannot loop. + if (revision === listedRevision || refreshedForRevision.current === revision) return; + refreshedForRevision.current = revision; + inventory.refresh(); + }, [inventory.refresh, inventoryChanges.data?.revision, listedRevision]); useEffect(() => { if (refreshToken === 0) return; diff --git a/packages/contracts/src/worktrees.ts b/packages/contracts/src/worktrees.ts index bf4d837412b9..da6f4f8728e6 100644 --- a/packages/contracts/src/worktrees.ts +++ b/packages/contracts/src/worktrees.ts @@ -81,6 +81,9 @@ export type VcsPruneWorktreesInput = typeof VcsPruneWorktreesInput.Type; export const VcsListWorktreesResult = Schema.Struct({ worktrees: Schema.Array(WorktreeInfo), + /** Inventory revision read before listing. A newer change revision means + this list may already be stale. */ + revision: NonNegativeInt, }); export type VcsListWorktreesResult = typeof VcsListWorktreesResult.Type;