diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 1e6490bdd46a5f6024146cd41872cec05de8969e..a778023571fe9e8b7baab8a04f2bf711b1c9d8a0 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -118,6 +118,13 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(descriptor.capabilities.serverSelfUpdate).toBe( mode === "web" ? undefined : "desktop-managed", ); + expect(descriptor.capabilities).toMatchObject({ + settledWorktreeCleanup: true, + storageCleanupRun: true, + projectWorktreeCleanup: true, + worktreesDirectory: true, + threadAutoSettlementScope: true, + }); } }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 27141c636fe0c88e8f982fd130bec4e8588d9cd7..8e635f9814b3d314a81ee717860336116e2906d5 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -231,6 +231,7 @@ export const make = Effect.gen(function* () { storageCleanupRun: true, otlpEndpointCheck: true, projectWorktreeCleanup: true, + settledWorktreeCleanup: true, worktreesDirectory: true, threadAutoSettlementScope: true, threadRestartContinuation: true, diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts index f86e1b7402d92f8b31a0e6435fd209de946a5ac0..65613079f550e28004f9f295dc07e666d993af18 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts @@ -16,7 +16,9 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; +import { isCheckpointRestoreIsolated } from "./CheckpointRestoreSafety.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { EventSinkV2 } from "./EventSink.ts"; @@ -105,6 +107,7 @@ it.effect.each([ NodeServices.layer, ThreadCommandExecutor.layer, IdAllocator.layer, + Layer.mock(GitWorkflowService.GitWorkflowService)({}), Layer.mock(ProjectStore.ProjectStoreV2)({ get: () => Effect.succeed(Option.some({ workspaceRoot: parent } as never)), }), @@ -197,3 +200,25 @@ it.effect.each([ } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("lets rollback recreate a worktree storage cleanup removed", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-v2-restore-removed-" }); + const worktreePath = path.join(parent, "worktree"); + const isolated = yield* isCheckpointRestoreIsolated( + { id: ThreadId.make("restore-removed"), worktreePath }, + { cwd: worktreePath }, + { + fileSystem, + path, + projections: { + getShellSnapshot: () => Effect.succeed({ threads: [], archivedThreads: [] }), + } as never, + projects: {} as never, + }, + ); + assert.isTrue(isolated); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts index 157db3c3478e4561bad7ba64cacbab0fc68051c1..7a8dbad5cfb2e7c72564c4400166df68b1bb80a8 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts @@ -33,8 +33,19 @@ export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpoi const worktreePath = thread.worktreePath; let shared = worktreePath == null; if (!shared && worktreePath !== null) { - const cwd = yield* fileSystem.realPath(scope.cwd); - const worktreeCwd = yield* fileSystem.realPath(worktreePath); + // Storage cleanup may have removed the worktree; rollback recreates it. + const resolve = (target: string) => + fileSystem + .realPath(target) + .pipe( + Effect.catch((error) => + error.reason._tag === "NotFound" + ? Effect.succeed(path.resolve(target)) + : Effect.fail(error), + ), + ); + const cwd = yield* resolve(scope.cwd); + const worktreeCwd = yield* resolve(worktreePath); shared = cwd !== worktreeCwd; if (!shared) { const shell = yield* projections.getShellSnapshot(); diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index 018b4f8f49b14871d591cca4ef5ac0b37fc8a5ee..dd51d168a3a854270d0ff57937709fd2f7d92edd 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -4,18 +4,24 @@ import { CheckpointId, CheckpointScopeId, type OrchestrationV2ThreadProjection, + ProjectId, ProviderInstanceId, ProviderSessionId, ProviderThreadId, ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Option from "effect/Option"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as Layer from "effect/Layer"; +import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; +import { ProviderWorkspaceMissingError } from "../provider/Errors.ts"; import { resolveCodexRollbackTurnCount } from "./Adapters/CodexAdapterV2.ts"; import { isCheckpointRestoreIsolated } from "./CheckpointRestoreSafety.ts"; import * as CheckpointService from "./CheckpointService.ts"; @@ -38,76 +44,121 @@ const layerCheckpointRollbackService = CheckpointRollbackService.layer.pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, + Layer.mock(GitWorkflow.GitWorkflowService)({}), Layer.mock(ProjectStore.ProjectStoreV2)({ get: () => Effect.succeed(unrelatedProject) }), ), ), ); -it.effect("rejects a non-ready checkpoint before opening a session or restoring files", () => { - const threadId = ThreadId.make("thread:rollback-non-ready"); - const providerThreadId = ProviderThreadId.make("provider-thread:rollback-non-ready"); - const providerSessionId = ProviderSessionId.make("provider-session:rollback-non-ready"); - const checkpointId = CheckpointId.make("checkpoint:rollback-non-ready"); - const scopeId = CheckpointScopeId.make("checkpoint-scope:rollback-non-ready"); - const providerInstanceId = ProviderInstanceId.make("provider_rollback_non_ready"); - const restore = vi.fn(() => Effect.die("checkpoint restore must not run")); - const open = vi.fn(() => Effect.die("provider session open must not run")); - const resolveRuntimePolicy = vi.fn(() => Effect.die("runtime policy resolution must not run")); - const projection = { - thread: { - worktreePath: process.cwd(), - activeProviderThreadId: providerThreadId, - modelSelection: { instanceId: providerInstanceId, model: "test-model" }, - }, - providerThreads: [{ id: providerThreadId, providerSessionId, providerInstanceId }], - checkpoints: [{ id: checkpointId, scopeId, status: "stale" }], - checkpointScopes: [{ id: scopeId, cwd: process.cwd() }], - } as unknown as OrchestrationV2ThreadProjection; - const layerTest = layerCheckpointRollbackService.pipe( - Layer.provide( - Layer.mergeAll( - ThreadCommandExecutor.layer, - Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), - Layer.mock(EventSink.EventSinkV2)({}), - IdAllocator.layer, - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getThreadRecords: () => Effect.succeed(projection), - getShellSnapshot: () => - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 0, - threads: [], - archivedThreads: [], - }), - }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open }), - Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: resolveRuntimePolicy }), +it.effect.each(["initial", "waiting"])( + "rejects a non-ready checkpoint from %s before opening a session or restoring files", + (state) => { + const threadId = ThreadId.make("thread:rollback-non-ready"); + const providerThreadId = ProviderThreadId.make("provider-thread:rollback-non-ready"); + const providerSessionId = ProviderSessionId.make("provider-session:rollback-non-ready"); + const checkpointId = CheckpointId.make("checkpoint:rollback-non-ready"); + const scopeId = CheckpointScopeId.make("checkpoint-scope:rollback-non-ready"); + const providerInstanceId = ProviderInstanceId.make("provider_rollback_non_ready"); + const restore = vi.fn(() => Effect.die("checkpoint restore must not run")); + const open = vi.fn(() => Effect.die("provider session open must not run")); + const resolveRuntimePolicy = vi.fn(() => Effect.die("runtime policy resolution must not run")); + const readEntered = Deferred.makeUnsafe(); + const release = Deferred.makeUnsafe(); + let readCount = 0; + let projection = { + thread: { + worktreePath: process.cwd(), + activeProviderThreadId: providerThreadId, + modelSelection: { instanceId: providerInstanceId, model: "test-model" }, + }, + providerThreads: [{ id: providerThreadId, providerSessionId, providerInstanceId }], + checkpoints: [ + { + id: checkpointId, + scopeId, + status: state === "waiting" ? "ready" : "stale", + appRunOrdinal: 0, + }, + ], + checkpointScopes: [{ id: scopeId, cwd: process.cwd() }], + } as unknown as OrchestrationV2ThreadProjection; + const getThreadRecords = vi.fn( + () => { + const current = projection; + readCount++; + return readCount === 1 + ? Deferred.succeed(readEntered, undefined).pipe(Effect.as(current)) + : Effect.succeed(current); + }, + ); + const testLayer = layerCheckpointRollbackService.pipe( + Layer.provide( + Layer.mergeAll( + ThreadCommandExecutor.layer, + Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), + Layer.mock(EventSink.EventSinkV2)({}), + IdAllocator.layer, + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadRecords, + getShellSnapshot: () => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 0, + threads: [], + archivedThreads: [], + }), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open }), + Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: resolveRuntimePolicy }), + ), ), - ), - ); - - return Effect.gen(function* () { - const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2; - const error = yield* service - .execute({ - threadId, - providerThreadId, - checkpointId, - scopeId, - }) - .pipe(Effect.flip); - - assert.equal(error.reason, "rollback-target-invalid"); - assert.equal( - error.message, - `Rollback target ${checkpointId} for provider thread ${providerThreadId} on thread ${threadId} is incomplete or invalid.`, ); - assert.equal(error.cause, undefined); - assert.equal(resolveRuntimePolicy.mock.calls.length, 0); - assert.equal(open.mock.calls.length, 0); - assert.equal(restore.mock.calls.length, 0); - }).pipe(Effect.provide(layerTest)); -}); + + return Effect.gen(function* () { + const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2; + const holder = + state === "waiting" + ? yield* withWorkspaceLease(process.cwd(), Deferred.await(release)).pipe( + Effect.forkChild({ startImmediately: true }), + ) + : undefined; + const rollback = yield* service + .execute({ + threadId, + providerThreadId, + checkpointId, + scopeId, + }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + if (state === "waiting") { + yield* Deferred.await(readEntered); + projection = { + ...projection, + checkpoints: projection.checkpoints.map((checkpoint) => ({ + ...checkpoint, + status: "stale", + })), + }; + yield* Deferred.succeed(release, undefined); + if (holder !== undefined) yield* Fiber.join(holder); + } + const error = yield* Fiber.join(rollback); + + assert.equal(getThreadRecords.mock.calls.length, 2); + assert.deepEqual(getThreadRecords.mock.calls[0]?.[1], []); + assert.equal(getThreadRecords.mock.calls[1]?.[1].includes("checkpoints"), true); + assert.equal(error.reason, "rollback-target-invalid"); + assert.equal( + error.message, + `Rollback target ${checkpointId} for provider thread ${providerThreadId} on thread ${threadId} is incomplete or invalid.`, + ); + assert.equal(error.cause, undefined); + assert.equal(resolveRuntimePolicy.mock.calls.length, 0); + assert.equal(open.mock.calls.length, 0); + assert.equal(restore.mock.calls.length, 0); + }).pipe(Effect.provide(testLayer)); + }, +); it.effect("rejects a rollback when another provider thread became active", () => { const threadId = ThreadId.make("thread:rollback-inactive-provider-thread"); @@ -128,6 +179,7 @@ it.effect("rejects a rollback when another provider thread became active", () => const resolveRuntimePolicy = vi.fn(() => Effect.die("runtime policy resolution must not run")); const projection = { thread: { + worktreePath: null, activeProviderThreadId, modelSelection: { instanceId: providerInstanceId, model: "test-model" }, }, @@ -268,7 +320,7 @@ it.effect("rejects a rollback when provider selection changed before execution", }).pipe(Effect.provide(layerTest)); }); -it.effect("reports a missing provider turn as a structured rollback failure", () => { +it.effect("rejects an unavailable rollback target before opening a session", () => { const threadId = ThreadId.make("thread:rollback-provider-turn-unavailable"); const providerThreadId = ProviderThreadId.make( "provider-thread:rollback-provider-turn-unavailable", @@ -280,9 +332,11 @@ it.effect("reports a missing provider turn as a structured rollback failure", () const scopeId = CheckpointScopeId.make("checkpoint-scope:rollback-provider-turn-unavailable"); const providerInstanceId = ProviderInstanceId.make("provider_rollback_provider_turn_unavailable"); const restore = vi.fn(() => Effect.die("checkpoint restore must not run")); + const open = vi.fn(() => Effect.die("provider session open must not run")); const projection = { thread: { - worktreePath: process.cwd(), + worktreePath: "/tmp/missing-invalid-rollback-worktree", + branch: "feature/rollback", activeProviderThreadId: providerThreadId, modelSelection: { instanceId: providerInstanceId, model: "test-model" }, }, @@ -312,7 +366,7 @@ it.effect("reports a missing provider turn as a structured rollback failure", () }), }), Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ - open: () => Effect.succeed({} as never), + open, }), Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: () => Effect.succeed({} as never), @@ -324,14 +378,9 @@ it.effect("reports a missing provider turn as a structured rollback failure", () return Effect.gen(function* () { const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2; const error = yield* service - .execute({ - threadId, - providerThreadId, - checkpointId, - scopeId, - }) + .execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles: false }) .pipe(Effect.flip); - + assert.equal(open.mock.calls.length, 0); assert.equal(error.reason, "provider-turn-unavailable"); assert.equal( error.message, @@ -342,6 +391,169 @@ it.effect("reports a missing provider turn as a structured rollback failure", () }).pipe(Effect.provide(layerTest)); }); +it.effect.each([ + { order: "normal", restoreFiles: true }, + { order: "cleanup-first", restoreFiles: true }, + { order: "cleanup-first", restoreFiles: false }, + { order: "rollback-first", restoreFiles: true }, +])("locks rollback with %s", ({ order, restoreFiles }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-rollback-" }); + const worktreePath = path.join(workspaceRoot, "worktree"); + yield* fileSystem.makeDirectory(worktreePath); + const threadId = ThreadId.make("rollback-worktree"); + const projectId = ProjectId.make("rollback-worktree-project"); + const providerThreadId = ProviderThreadId.make("rollback-worktree-provider-thread"); + const providerSessionId = ProviderSessionId.make("rollback-worktree-provider-session"); + const providerInstanceId = ProviderInstanceId.make("rollback-worktree-provider-instance"); + const checkpointId = CheckpointId.make("rollback-worktree-checkpoint"); + const scopeId = CheckpointScopeId.make("rollback-worktree-scope"); + const branch = "feature/rollback"; + const release = Deferred.makeUnsafe(); + const openEntered = Deferred.makeUnsafe(); + const calls: string[] = []; + let cleanupEntered = false; + const providerThread = { id: providerThreadId, providerSessionId, providerInstanceId }; + const projection = { + thread: { + id: threadId, + projectId, + worktreePath, + branch, + activeProviderThreadId: providerThreadId, + modelSelection: { instanceId: providerInstanceId, model: "test-model" }, + }, + providerThreads: [providerThread], + providerSessions: [], + checkpoints: [{ id: checkpointId, scopeId, status: "ready", appRunOrdinal: 0 }], + checkpointScopes: [{ id: scopeId, cwd: worktreePath }], + runs: [], + attempts: [], + nodes: [], + providerTurns: [], + } as unknown as OrchestrationV2ThreadProjection; + const testLayer = CheckpointRollbackService.layer.pipe( + Layer.provide( + Layer.mergeAll( + ThreadCommandExecutor.layer, + NodeServices.layer, + Layer.mock(ProjectStore.ProjectStoreV2)({ + get: (id) => { + assert.equal(id, projectId); + return Effect.succeed(Option.some({ workspaceRoot } as never)); + }, + }), + Layer.mock(GitWorkflow.GitWorkflowService)({ + pruneWorktrees: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot }); + calls.push("prune"); + }), + createWorktree: (input) => + Effect.gen(function* () { + assert.deepEqual(input, { + cwd: workspaceRoot, + refName: branch, + path: worktreePath, + }); + calls.push("create"); + yield* fileSystem.makeDirectory(worktreePath).pipe(Effect.orDie); + return {} as never; + }), + }), + Layer.mock(CheckpointService.CheckpointServiceV2)({ + restore: () => + Effect.gen(function* () { + assert.isTrue(yield* fileSystem.exists(worktreePath).pipe(Effect.orDie)); + calls.push("files"); + }), + }), + Layer.mock(EventSink.EventSinkV2)({ + write: () => + Effect.sync(() => { + calls.push("projection"); + return []; + }), + }), + IdAllocator.layer, + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadRecords: () => Effect.succeed(projection), + getShellSnapshot: () => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 0, + threads: [], + archivedThreads: [], + }), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + open: (input) => + Effect.gen(function* () { + assert.equal(input.runtimePolicy.cwd, worktreePath); + if (!(yield* fileSystem.exists(worktreePath).pipe(Effect.orDie))) { + return yield* new ProviderWorkspaceMissingError({ threadId, cwd: worktreePath }); + } + calls.push("session"); + yield* Deferred.succeed(openEntered, undefined); + if (order === "rollback-first") yield* Deferred.await(release); + return {} as never; + }), + }), + Layer.mock(RuntimePolicy.RuntimePolicyV2)({ + resolve: ({ thread }) => { + assert.equal(thread.worktreePath, worktreePath); + return Effect.succeed({ cwd: thread.worktreePath } as never); + }, + }), + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2; + const cleanupEffect = withWorkspaceLease( + worktreePath, + Effect.gen(function* () { + cleanupEntered = true; + if (order === "cleanup-first") yield* Deferred.await(release); + yield* fileSystem.remove(worktreePath, { recursive: true, force: true }); + calls.push("cleanup"); + }), + ); + const cleanup = + order === "cleanup-first" + ? yield* cleanupEffect.pipe(Effect.forkChild({ startImmediately: true })) + : undefined; + const rollback = yield* service + .execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles }) + .pipe(Effect.forkChild({ startImmediately: true })); + if (order === "cleanup-first") assert.deepEqual(calls, []); + if (order === "rollback-first") yield* Deferred.await(openEntered); + const laterCleanup = + order === "rollback-first" + ? yield* cleanupEffect.pipe(Effect.forkChild({ startImmediately: true })) + : undefined; + if (order === "rollback-first") assert.isFalse(cleanupEntered); + yield* Deferred.succeed(release, undefined); + if (cleanup !== undefined) yield* Fiber.join(cleanup); + yield* Fiber.join(rollback); + if (laterCleanup !== undefined) yield* Fiber.join(laterCleanup); + const rollbackCalls = restoreFiles + ? ["session", "files", "projection"] + : ["session", "projection"]; + assert.deepEqual( + calls, + order === "cleanup-first" + ? ["cleanup", "prune", "create", ...rollbackCalls] + : order === "rollback-first" + ? [...rollbackCalls, "cleanup"] + : rollbackCalls, + ); + }).pipe(Effect.provide(testLayer)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect.each([ { restoreFiles: true, shared: "none" }, { restoreFiles: false, shared: "root" }, @@ -350,143 +562,161 @@ it.effect.each([ { restoreFiles: false, shared: "worktree" }, { restoreFiles: true, shared: "historical" }, { restoreFiles: false, shared: "none", targetOrdinal: 1 }, -])("rewinds safely with %s", ({ restoreFiles, shared, targetOrdinal = 0 }) => { - const threadId = ThreadId.make("rewind-files"); - const providerThreadId = ProviderThreadId.make("rewind-provider"); - const providerSessionId = ProviderSessionId.make("rewind-session"); - const instanceId = ProviderInstanceId.make("rewind-instance"); - const checkpointId = CheckpointId.make("rewind-start"); - const scopeId = CheckpointScopeId.make("rewind-scope"); - const calls: string[] = []; - let lockedThreads = Effect.succeed>([]); - const layerThreadCommands = Layer.effect( - ThreadCommandExecutor.ThreadCommandExecutor, - Effect.tap(KeyedLock.make(), (lock) => - Effect.sync(() => { - lockedThreads = lock.activeKeys; - }), - ), - ); - const providerThread = { - id: providerThreadId, - providerSessionId, - providerInstanceId: instanceId, - }; - const projection = { - thread: { - worktreePath: shared === "root" ? null : process.cwd(), - activeProviderThreadId: providerThreadId, - modelSelection: { instanceId, model: "test" }, - }, - providerThreads: [providerThread], - providerSessions: [], - // Turn 3 remains in the audit history after an earlier rollback. - providerTurns: [1, 2, 3].map((ordinal) => ({ - id: `turn-${ordinal}`, - providerThreadId, - runAttemptId: `attempt-${ordinal}`, - ordinal, - status: "completed", - })), - nodes: [], - attempts: [1, 2, 3].map((ordinal) => ({ id: `attempt-${ordinal}`, runId: `run-${ordinal}` })), - checkpoints: [ - { id: checkpointId, scopeId, status: "ready", appRunOrdinal: targetOrdinal || null }, - ], - checkpointScopes: [{ id: scopeId, cwd: process.cwd() }], - runs: [1, 2, 3].map((ordinal) => ({ - id: `run-${ordinal}`, - ordinal, - status: ordinal === 3 ? "rolled_back" : "completed", - rootNodeId: null, - activeAttemptId: `attempt-${ordinal}`, - })), - } as unknown as OrchestrationV2ThreadProjection; - const layerTest = layerCheckpointRollbackService.pipe( - Layer.provide( - Layer.mergeAll( - layerThreadCommands, - Layer.mock(CheckpointService.CheckpointServiceV2)({ - restore: () => - Effect.sync(() => { - calls.push("files"); - }), + { restoreFiles: false, shared: "none", targetOrdinal: 1, multipleTurns: true }, +])( + "rewinds safely with %s", + ({ restoreFiles, shared, targetOrdinal = 0, multipleTurns = false }) => { + const threadId = ThreadId.make("rewind-files"); + const providerThreadId = ProviderThreadId.make("rewind-provider"); + const providerSessionId = ProviderSessionId.make("rewind-session"); + const instanceId = ProviderInstanceId.make("rewind-instance"); + const checkpointId = CheckpointId.make("rewind-start"); + const scopeId = CheckpointScopeId.make("rewind-scope"); + const calls: string[] = []; + let lockedThreads = Effect.succeed>([]); + const layerThreadCommands = Layer.effect( + ThreadCommandExecutor.ThreadCommandExecutor, + Effect.tap(KeyedLock.make(), (lock) => + Effect.sync(() => { + lockedThreads = lock.activeKeys; }), - Layer.mock(EventSink.EventSinkV2)({ - write: ({ events }) => - Effect.map(lockedThreads, (locked) => { - // A thread command that planned against the old provider thread - // must not commit after this and undo the rollback. - assert.include(locked, threadId); - assert.ok( - events.some( - (event) => event.type === "run.updated" && event.payload.status === "rolled_back", - ), - ); - calls.push("projection"); - return []; - }), - }), - IdAllocator.layer, - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getThreadRecords: () => Effect.succeed(projection), - getThreadProviderContext: () => Effect.succeed({ providerSessions: [] } as never), - getCheckpointContext: () => - Effect.succeed({ - checkpointScopes: [{ cwd: process.cwd() }], - runs: [], - checkpoints: [], - } as never), - getShellSnapshot: () => - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 0, - threads: [], - archivedThreads: - shared === "worktree" || shared === "historical" - ? [ - { - id: ThreadId.make("other-thread"), - deletedAt: null, - worktreePath: shared === "worktree" ? process.cwd() : null, - } as never, - ] - : [], - }), - }), - Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ - open: () => - Effect.succeed({ - rollbackThread: (input: ProviderAdapter.ProviderAdapterV2RollbackThreadInput) => - Effect.gen(function* () { - const count = yield* resolveCodexRollbackTurnCount(input); - assert.equal(count, 2 - targetOrdinal); - calls.push("provider"); - return { providerThread }; - }), - } as never), - }), - Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: () => Effect.succeed({} as never) }), ), - ), - ); - return Effect.gen(function* () { - const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2; - if (restoreFiles && shared !== "none") { - const error = yield* Effect.flip( - service.execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles }), + ); + const providerThread = { + id: providerThreadId, + providerSessionId, + providerInstanceId: instanceId, + }; + const projection = { + thread: { + worktreePath: shared === "root" ? null : process.cwd(), + activeProviderThreadId: providerThreadId, + modelSelection: { instanceId, model: "test" }, + }, + providerThreads: [providerThread], + providerSessions: [], + // Turn 3 remains in the audit history after an earlier rollback. + providerTurns: [ + ...(multipleTurns + ? [ + { + id: "turn-0", + providerThreadId, + runAttemptId: "attempt-1", + ordinal: 0, + status: "completed", + }, + ] + : []), + ...[1, 2, 3].map((ordinal) => ({ + id: `turn-${ordinal}`, + providerThreadId, + runAttemptId: `attempt-${ordinal}`, + ordinal, + status: "completed", + })), + ], + nodes: [], + attempts: [1, 2, 3].map((ordinal) => ({ id: `attempt-${ordinal}`, runId: `run-${ordinal}` })), + checkpoints: [ + { id: checkpointId, scopeId, status: "ready", appRunOrdinal: targetOrdinal || null }, + ], + checkpointScopes: [{ id: scopeId, cwd: process.cwd() }], + runs: [1, 2, 3].map((ordinal) => ({ + id: `run-${ordinal}`, + ordinal, + status: ordinal === 3 ? "rolled_back" : "completed", + rootNodeId: null, + activeAttemptId: `attempt-${ordinal}`, + })), + } as unknown as OrchestrationV2ThreadProjection; + const layerTest = layerCheckpointRollbackService.pipe( + Layer.provide( + Layer.mergeAll( + layerThreadCommands, + Layer.mock(CheckpointService.CheckpointServiceV2)({ + restore: () => + Effect.sync(() => { + calls.push("files"); + }), + }), + Layer.mock(EventSink.EventSinkV2)({ + write: ({ events }) => + Effect.map(lockedThreads, (locked) => { + // A thread command that planned against the old provider thread + // must not commit after this and undo the rollback. + assert.include(locked, threadId); + assert.ok( + events.some( + (event) => + event.type === "run.updated" && event.payload.status === "rolled_back", + ), + ); + calls.push("projection"); + return []; + }), + }), + IdAllocator.layer, + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadRecords: () => Effect.succeed(projection), + getThreadProviderContext: () => Effect.succeed({ providerSessions: [] } as never), + getCheckpointContext: () => + Effect.succeed({ + checkpointScopes: [{ cwd: process.cwd() }], + runs: [], + checkpoints: [], + } as never), + getShellSnapshot: () => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 0, + threads: [], + archivedThreads: + shared === "worktree" || shared === "historical" + ? [ + { + id: ThreadId.make("other-thread"), + deletedAt: null, + worktreePath: shared === "worktree" ? process.cwd() : null, + } as never, + ] + : [], + }), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + open: () => + Effect.succeed({ + rollbackThread: (input: ProviderAdapter.ProviderAdapterV2RollbackThreadInput) => + Effect.gen(function* () { + const count = yield* resolveCodexRollbackTurnCount(input); + assert.equal(count, 2 - targetOrdinal); + calls.push("provider"); + return { providerThread }; + }), + } as never), + }), + Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: () => Effect.succeed({} as never) }), + ), + ), + ); + return Effect.gen(function* () { + const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2; + if (restoreFiles && shared !== "none") { + const error = yield* Effect.flip( + service.execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles }), + ); + assert.equal(error.reason, "shared-workspace"); + assert.deepEqual(calls, []); + return; + } + yield* service.execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles }); + assert.deepEqual( + calls, + restoreFiles ? ["provider", "files", "projection"] : ["provider", "projection"], ); - assert.equal(error.reason, "shared-workspace"); - assert.deepEqual(calls, []); - return; - } - yield* service.execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles }); - assert.deepEqual( - calls, - restoreFiles ? ["provider", "files", "projection"] : ["provider", "projection"], - ); - }).pipe(Effect.provide(layerTest)); -}); + }).pipe(Effect.provide(layerTest)); + }, +); it.effect.skipIf(!symlinksSupported)( "rejects an archived thread sharing a worktree through a symlink", diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index acacfed521c6f641fe808cb96934e2bb59cfe7a4..a0b7899c666163f252636ee98deb629b7276eb84 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -11,9 +11,12 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { isCheckpointRestoreIsolated, SHARED_WORKSPACE_RESTORE_MESSAGE, @@ -84,6 +87,7 @@ export const layer: Layer.Layer< CheckpointRollbackServiceV2, never, | CheckpointServiceV2 + | GitWorkflowService.GitWorkflowService | EventSinkV2 | IdAllocator.IdAllocatorV2 | ProjectionStoreV2 @@ -97,6 +101,7 @@ export const layer: Layer.Layer< CheckpointRollbackServiceV2, Effect.gen(function* () { const checkpoints = yield* CheckpointServiceV2; + const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const eventSink = yield* EventSinkV2; const ids = yield* IdAllocator.IdAllocatorV2; const projections = yield* ProjectionStoreV2; @@ -158,6 +163,52 @@ export const layer: Layer.Layer< }); } + const targetOrdinal = checkpoint.appRunOrdinal ?? 0; + const rollbackTarget: ProviderAdapter.ProviderAdapterV2RollbackTarget = + targetOrdinal === 0 + ? { + type: "thread_start", + checkpointId: checkpoint.id, + appRunOrdinal: 0, + } + : yield* Effect.gen(function* () { + const targetRun = projection.runs.find((run) => run.ordinal === targetOrdinal); + const targetAttempt = projection.attempts.find( + (attempt) => attempt.id === targetRun?.activeAttemptId, + ); + // A goal run can span several native turns; roll back to its last. + const targetTurn = + latestProviderTurnForAttempt(projection.providerTurns, targetAttempt?.id) ?? + projection.providerTurns.find((turn) => turn.id === targetAttempt?.providerTurnId); + if (targetTurn === undefined || targetTurn.providerThreadId !== providerThread.id) { + return yield* new CheckpointRollbackExecutionError({ + reason: "provider-turn-unavailable", + threadId: input.threadId, + providerThreadId: input.providerThreadId, + checkpointId: input.checkpointId, + }); + } + return { + type: "provider_turn" as const, + checkpointId: checkpoint.id, + appRunOrdinal: targetOrdinal, + providerTurn: targetTurn, + }; + }); + + const { worktreePath, branch } = projection.thread; + if (worktreePath != null && branch != null && !(yield* fileSystem.exists(worktreePath))) { + const project = yield* projects.get(projection.thread.projectId); + if (Option.isSome(project)) { + yield* gitWorkflow.pruneWorktrees({ cwd: project.value.workspaceRoot }); + yield* gitWorkflow.createWorktree({ + cwd: project.value.workspaceRoot, + refName: branch, + path: worktreePath, + }); + } + } + if ( input.restoreFiles !== false && !(yield* isCheckpointRestoreIsolated(projection.thread, scope, { @@ -199,7 +250,6 @@ export const layer: Layer.Layer< }), }); - const targetOrdinal = checkpoint.appRunOrdinal ?? 0; // Stopped and failed runs after the target leave the provider // conversation too, so they must not stay visible. const runsToRollback = projection.runs.filter( @@ -225,37 +275,6 @@ export const layer: Layer.Layer< turn.providerThreadId === providerThread.id && (turn.runAttemptId === null || !rolledBackAttemptIds.has(turn.runAttemptId)), ); - const rollbackTarget: ProviderAdapter.ProviderAdapterV2RollbackTarget = - targetOrdinal === 0 - ? { - type: "thread_start", - checkpointId: checkpoint.id, - appRunOrdinal: 0, - } - : yield* Effect.gen(function* () { - const targetRun = projection.runs.find((run) => run.ordinal === targetOrdinal); - const targetAttempt = projection.attempts.find( - (attempt) => attempt.id === targetRun?.activeAttemptId, - ); - // A goal run can span several native turns; roll back to its last. - const targetTurn = - latestProviderTurnForAttempt(projection.providerTurns, targetAttempt?.id) ?? - projection.providerTurns.find((turn) => turn.id === targetAttempt?.providerTurnId); - if (targetTurn === undefined || targetTurn.providerThreadId !== providerThread.id) { - return yield* new CheckpointRollbackExecutionError({ - reason: "provider-turn-unavailable", - threadId: input.threadId, - providerThreadId: input.providerThreadId, - checkpointId: input.checkpointId, - }); - } - return { - type: "provider_turn" as const, - checkpointId: checkpoint.id, - appRunOrdinal: targetOrdinal, - providerTurn: targetTurn, - }; - }); const snapshot = runsToRollback.length === 0 @@ -355,7 +374,13 @@ export const layer: Layer.Layer< return CheckpointRollbackServiceV2.of({ execute: (input) => - execute(input).pipe( + Effect.gen(function* () { + // Storage cleanup removes worktrees under the same lease. + const { thread } = yield* projections.getThreadRecords(input.threadId, []); + yield* thread.worktreePath === null + ? execute(input) + : withWorkspaceLease(path.resolve(thread.worktreePath), execute(input)); + }).pipe( Effect.mapError((cause) => isCheckpointRollbackExecutionError(cause) ? cause diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index 93cc4f6b6930c15767f3ab21ec0bee8d7a25be25..ecdd62577facb4addbdd447e4491aa8e0ad2c111 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -105,6 +105,7 @@ function layerExecutorFor(input: { shutdown: Effect.void, open: () => Effect.die("unused open"), get: () => Effect.succeed(Option.none()), + isLive: () => Effect.succeed(false), close: () => Effect.void, closeInstance: () => Effect.void, release: () => record("release"), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 36fa2ced0d551ac7140ab63bd2bd6cc1dd71a054..62a03c0de2ab66322846175aef6ba059c2a03a00 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -2899,6 +2899,25 @@ it.effect( }), ); +it.effect("ProviderSessionManagerV2 checks liveness without delaying idle release", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const { providerSessionId } = yield* openSessionWithBackgroundCommand( + "provider-session-manager-is-live", + ); + yield* TestClock.adjust("600 millis"); + assert.isTrue(yield* manager.isLive(providerSessionId)); + yield* TestClock.adjust("600 millis"); + yield* Effect.yieldNow; + assert.isFalse(yield* manager.isLive(providerSessionId)); + }); + + yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 }))); + }), +); + it.effect("ProviderSessionManagerV2 clears the background roster of an idle-released session", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index a9c6e2fc9f7fe1e0ba5827d970f415d7186e488f..cf0afa6b9eb1306a151872a382e87d6821efbc73 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -169,6 +169,8 @@ export interface ProviderSessionManagerV2Shape { Option.Option, ProviderSessionManagerV2Error >; + /** Whether the session's runtime is running. Unlike `get`, not activity. */ + readonly isLive: (providerSessionId: ProviderSessionId) => Effect.Effect; readonly close: ( providerSessionId: ProviderSessionId, ) => Effect.Effect; @@ -2346,6 +2348,10 @@ export const layerWithOptions = ( return exposedRuntime; }), ), + isLive: (providerSessionId) => + Ref.get(sessions).pipe( + Effect.map((current) => current.has(sessionKey(providerSessionId))), + ), get: (providerSessionId) => Effect.gen(function* () { const entry = (yield* Ref.get(sessions)).get(sessionKey(providerSessionId)); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index fc3465e6885f1595430727548150e1238fcd972e..52515e72baa481bbba343d72055d1129d3dadffc 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -273,6 +273,7 @@ it.effect( Effect.succeed( providerSessionId === oldSessionId ? Option.some(runtime) : Option.none(), ), + isLive: (providerSessionId) => Effect.succeed(providerSessionId === oldSessionId), close: () => Effect.void, closeInstance: () => Effect.void, release: () => Effect.void, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index d6c12fab7ee15eedabf45632b887e836d677294e..0b216806552887b906bfdb2ed80c820a8de5e4b2 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it, vi } from "vite-plus/test"; import { it as effectIt } from "@effect/vitest"; import { @@ -18,12 +19,16 @@ import { } 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 Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; @@ -95,7 +100,8 @@ it("does not commit running state when inherited background routing cannot be re Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, - Layer.succeed(FileSystem.FileSystem, { exists: () => Effect.succeed(false) } as never), + Path.layer, + FileSystem.layerNoop({ exists: () => Effect.succeed(false) }), Layer.mock(GitWorkflow.GitWorkflowService)({ pruneWorktrees, createWorktree }), Layer.mock(ProjectService.ProjectService)({ getById: () => @@ -104,6 +110,10 @@ it("does not commit running state when inherited background routing cannot be re ), }), Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadRecords: () => { + projectionReadCount += 1; + return Effect.succeed(projection); + }, getTurnStartContext: () => { projectionReadCount += 1; return Effect.succeed({ @@ -141,7 +151,7 @@ it("does not commit running state when inherited background routing cannot be re .pipe(Effect.flip); expect(error._tag).toBe("ProviderTurnStartError"); - expect(projectionReadCount).toBe(2); + expect(projectionReadCount).toBe(3); expect(pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-turn-start-project" }); expect(createWorktree).toHaveBeenCalledWith({ cwd: "/tmp/provider-turn-start-project", @@ -155,6 +165,8 @@ it("does not commit running state when inherited background routing cannot be re function makeLocalCommandHarness(input: { readonly text: string; + readonly worktreePath?: string; + readonly fileSystem?: FileSystem.FileSystem; readonly previousNativeSession?: boolean; readonly previousMessages?: ReadonlyArray; readonly logoutFailure?: string; @@ -236,8 +248,9 @@ function makeLocalCommandHarness(input: { thread: { id: threadId, activeProviderThreadId: providerThreadId, - branch: null, - worktreePath: null, + projectId: ProjectId.make("project-native-account-command"), + branch: input.worktreePath === undefined ? null : "feature/restore", + worktreePath: input.worktreePath ?? null, } as OrchestrationV2ThreadProjection["thread"], runs: [ ...(input.previousNativeSession @@ -483,6 +496,24 @@ function makeLocalCommandHarness(input: { return { committed, storedEvents: [] }; }), ); + const resolveRuntimePolicy = vi.fn( + ({ thread }: Parameters[0]) => + Effect.succeed({ cwd: thread.worktreePath ?? "/tmp/native-account-command" } as never), + ); + const createWorktree = vi.fn(() => Effect.succeed({} as never)); + const getThreadRecords = vi.fn(() => + Effect.succeed(projection), + ); + const getTurnStartContext = vi.fn(() => + Effect.succeed({ + ...projection, + hasConversation: projection.messages.some( + (m) => + m.role === "user" && + (m.text.trim().toLowerCase() !== "/compact" || m.attachments.length > 0), + ), + }), + ); const layer = ProviderTurnStart.layer.pipe( Layer.provide( Layer.mergeAll( @@ -491,19 +522,21 @@ function makeLocalCommandHarness(input: { }), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, - FileSystem.layerNoop({}), - Layer.mock(GitWorkflow.GitWorkflowService)({}), - Layer.mock(ProjectService.ProjectService)({}), + Path.layer, + input.fileSystem === undefined + ? FileSystem.layerNoop({ exists: () => Effect.succeed(true) }) + : Layer.succeed(FileSystem.FileSystem, input.fileSystem), + Layer.mock(GitWorkflow.GitWorkflowService)({ + pruneWorktrees: () => Effect.void, + createWorktree, + }), + Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeed(Option.some({ workspaceRoot: "/tmp/native-account-project" } as never)), + }), Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getTurnStartContext: () => - Effect.succeed({ - ...projection, - hasConversation: projection.messages.some( - (m) => - m.role === "user" && - (m.text.trim().toLowerCase() !== "/compact" || m.attachments.length > 0), - ), - }), + getThreadRecords, + getTurnStartContext, getRuntimeRecoveryProjection: () => Effect.as(failReadIfRunning, { ...projection, @@ -525,12 +558,19 @@ function makeLocalCommandHarness(input: { Layer.mock(ProviderAuthService.ProviderAuthService)({ tryHandlePromptCommand }), Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }), Layer.mock(RuntimePolicy.RuntimePolicyV2)({ - resolve: () => Effect.succeed({} as never), + resolve: resolveRuntimePolicy, }), ), ), ); return { + getThreadRecords, + getTurnStartContext, + setProjection: (next: OrchestrationV2ThreadProjection) => { + projection = next; + }, + resolveRuntimePolicy, + createWorktree, open, writeIfRunCurrent, startRootRun, @@ -553,6 +593,163 @@ function makeLocalCommandHarness(input: { }; } +effectIt.effect.each(["cleanup-first", "startup-first"])( + "coordinates provider startup with %s", + (order) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-start-" }); + const worktreePath = path.join(workspaceRoot, "worktree"); + yield* fileSystem.makeDirectory(worktreePath); + const release = yield* Deferred.make(); + const openEntered = yield* Deferred.make(); + const harness = makeLocalCommandHarness({ + text: "Continue", + worktreePath, + fileSystem, + failReadsAfterRunning: true, + }); + harness.createWorktree.mockImplementation(() => + fileSystem.makeDirectory(worktreePath).pipe(Effect.as({} as never), Effect.orDie), + ); + const open = harness.open.getMockImplementation()!; + harness.open.mockImplementation(() => + Effect.gen(function* () { + expect(yield* fileSystem.exists(worktreePath).pipe(Effect.orDie)).toBe(true); + yield* Deferred.succeed(openEntered, undefined); + if (order === "startup-first") yield* Deferred.await(release); + return yield* open(); + }), + ); + let cleanupEntered = false; + const cleanupEffect = withWorkspaceLease( + worktreePath, + Effect.gen(function* () { + cleanupEntered = true; + if (order === "cleanup-first") yield* Deferred.await(release); + else { + expect(harness.projection().providerSessions).toHaveLength(1); + expect(harness.projection().runs.at(-1)?.status).toBe("running"); + } + yield* fileSystem.remove(worktreePath, { recursive: true, force: true }); + }), + ); + const cleanup = + order === "cleanup-first" + ? yield* cleanupEffect.pipe(Effect.forkChild({ startImmediately: true })) + : undefined; + const startup = yield* harness.start.pipe(Effect.forkChild({ startImmediately: true })); + if (order === "cleanup-first") expect(harness.open).not.toHaveBeenCalled(); + if (order === "startup-first") yield* Deferred.await(openEntered); + const laterCleanup = + order === "startup-first" + ? yield* cleanupEffect.pipe(Effect.forkChild({ startImmediately: true })) + : undefined; + if (order === "startup-first") expect(cleanupEntered).toBe(false); + yield* Deferred.succeed(release, undefined); + if (cleanup !== undefined) yield* Fiber.join(cleanup); + yield* Fiber.join(startup); + if (laterCleanup !== undefined) yield* Fiber.join(laterCleanup); + if (order === "cleanup-first") { + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: "/tmp/native-account-project", + refName: "feature/restore", + path: worktreePath, + }); + } + expect(harness.resolveRuntimePolicy).toHaveBeenCalledWith( + expect.objectContaining({ + thread: expect.objectContaining({ worktreePath }), + }), + ); + expect(harness.open).toHaveBeenCalledWith( + expect.objectContaining({ + runtimePolicy: expect.objectContaining({ cwd: worktreePath }), + }), + ); + expect(harness.startRootRun).toHaveBeenCalledWith( + expect.objectContaining({ + appThread: expect.objectContaining({ worktreePath }), + runtimePolicy: expect.objectContaining({ cwd: worktreePath }), + }), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +effectIt.effect("reads only thread state before taking the startup lease", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ text: "/logout", previousNativeSession: true }); + yield* harness.start; + expect(harness.getThreadRecords).toHaveBeenCalledExactlyOnceWith( + harness.projection().thread.id, + [], + ); + expect(harness.getTurnStartContext).toHaveBeenCalledOnce(); + expect(harness.projection().runs.at(-1)?.status).toBe("completed"); + }), +); + +effectIt.effect("reads current startup state after waiting for the workspace lease", () => + Effect.gen(function* () { + const worktreePath = "/tmp/provider-start-fresh-state"; + const release = yield* Deferred.make(); + const readEntered = yield* Deferred.make(); + const harness = makeLocalCommandHarness({ + text: "Continue", + worktreePath, + failReadsAfterRunning: true, + }); + harness.getThreadRecords.mockImplementationOnce(() => + Deferred.succeed(readEntered, undefined).pipe(Effect.as(harness.projection())), + ); + const holder = yield* withWorkspaceLease(worktreePath, Deferred.await(release)).pipe( + Effect.forkChild({ startImmediately: true }), + ); + const startup = yield* harness.start.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(readEntered); + expect(harness.getTurnStartContext).not.toHaveBeenCalled(); + const projection = harness.projection(); + harness.setProjection({ + ...projection, + runs: projection.runs.map((run) => ({ ...run, status: "running" })), + }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(holder); + yield* Fiber.join(startup); + expect(harness.open).not.toHaveBeenCalled(); + expect(harness.startRootRun).not.toHaveBeenCalled(); + }), +); + +effectIt.effect("starts another provider in the same checkout while a turn is pending", () => + Effect.gen(function* () { + const worktreePath = "/tmp/provider-start-shared-checkout"; + const turnEntered = yield* Deferred.make(); + const releaseTurn = yield* Deferred.make(); + const first = makeLocalCommandHarness({ + text: "Continue", + worktreePath, + failReadsAfterRunning: true, + }); + const second = makeLocalCommandHarness({ + text: "Continue", + worktreePath, + failReadsAfterRunning: true, + }); + first.startRootRun.mockImplementation(() => + Deferred.succeed(turnEntered, undefined).pipe(Effect.andThen(Deferred.await(releaseTurn))), + ); + const pending = yield* first.start.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(turnEntered); + yield* second.start; + expect(second.startRootRun).toHaveBeenCalledOnce(); + expect(second.projection().runs.at(-1)?.status).toBe("running"); + yield* Deferred.succeed(releaseTurn, undefined); + yield* Fiber.join(pending); + }), +); + effectIt.effect("terminalizes a starting run when its provider session cannot open", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 684bde2c066952ebc68edb040613159344f9403c..8520412e6a181f55bc3283e2594b165517a2faae 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -19,9 +19,11 @@ 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 Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; +import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; @@ -90,6 +92,7 @@ export const layer: Layer.Layer< | ContextHandoffService.ContextHandoffServiceV2 | IdAllocator.IdAllocatorV2 | FileSystem.FileSystem + | Path.Path | GitWorkflowService.GitWorkflowService | ProjectService.ProjectService | ProviderAuthService.ProviderAuthService @@ -104,6 +107,7 @@ export const layer: Layer.Layer< const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const projects = yield* ProjectService.ProjectService; const providerAuth = yield* ProviderAuthService.ProviderAuthService; @@ -1224,7 +1228,7 @@ export const layer: Layer.Layer< !noteContinuation ? session : makeDeliverySession(session, startWithHandoffs); - yield* runExecution.startRootRun({ + return runExecution.startRootRun({ commandId: CommandId.make(`command:effect:provider-turn.start:${run.id}`), appThread: projection.thread, providerSessionId, @@ -1279,7 +1283,15 @@ export const layer: Layer.Layer< return ProviderTurnStartServiceV2.of({ start: (input) => - start(input).pipe( + Effect.gen(function* () { + // Storage cleanup removes worktrees under the same lease. Only startup + // holds it; the provider turn runs after it is released. + const { thread } = yield* projectionStore.getThreadRecords(input.threadId, []); + const providerTurn = yield* thread.worktreePath === null + ? start(input) + : withWorkspaceLease(path.resolve(thread.worktreePath), start(input)); + if (providerTurn !== undefined) yield* providerTurn; + }).pipe( Effect.mapError((cause) => isProviderTurnStartError(cause) ? cause diff --git a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs index 203360ee9215b9f91dea4eb0b5775f0e5320b5dc..bf07a5bf25a9b874ece33d85f0bb9ea56326b746 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs +++ b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs @@ -7,10 +7,11 @@ const mode = process.argv[3]; const withHandoff = mode.startsWith("handoff"); const require = NodeModule.createRequire(root + "/apps/server/package.json"); const load = (name) => import(NodeURL.pathToFileURL(require.resolve("effect/" + name))); -const [Effect, Layer, FileSystem] = await Promise.all([ +const [Effect, Layer, FileSystem, Path] = await Promise.all([ load("Effect"), load("Layer"), load("FileSystem"), + load("Path"), ]); const app = (file) => import(NodeURL.pathToFileURL(root + "/apps/server/src/" + file + ".ts")); const providerCore = (file) => @@ -31,6 +32,7 @@ const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project ]); let current; let fullReads = 0; +let threadReads = 0; const liveRuns = []; const refs = []; const checkpoints = []; @@ -48,6 +50,7 @@ const dependencies = Layer.mergeAll( Layer.mock(Handoff.ContextHandoffServiceV2)({}), Id.layer, FileSystem.layerNoop({}), + Path.layer, Layer.mock(Git.GitWorkflowService)({}), Layer.mock(Project.ProjectService)({}), Layer.mock(Auth.ProviderAuthService)({}), @@ -58,6 +61,13 @@ const dependencies = Layer.mergeAll( throw new Error("full transcript read"); }), hasUnpairedRunInterruptRequest: () => Effect.succeed(false), + getThreadRecords: (threadId, fields) => + Effect.sync(() => { + NodeAssert.equal(threadId, current.thread.id); + NodeAssert.deepEqual(fields, []); + threadReads++; + return { thread: current.thread }; + }), getTurnStartContext: () => Effect.sync(() => { fullReads++; @@ -181,8 +191,10 @@ await Effect.runPromise( NodeAssert.equal(yield* controls.shouldFinalizeRun(), false); NodeAssert.deepEqual(yield* controls.loadInheritedBackgroundTurnItems(), []); NodeAssert.equal(fullReads, i + 1); + NodeAssert.equal(threadReads, i + 1); NodeAssert.equal(yield* controls.hasUnpairedRunInterruptRequest(), false); NodeAssert.equal(fullReads, i + 1); + NodeAssert.equal(threadReads, i + 1); current = null; if (i === Math.floor(count / 2) - 1 || i === count - 1) { yield* Effect.promise(async () => { diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 83936691e785620748cc302d162a8039598039c4..da696c2bc9eb70477d9d6c70a175810cd64ac55f 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -580,7 +580,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(layerServerSettings())), ); - it.effect("persists custom usage prices and removes them from the settings file", () => + it.effect("preserves saved usage settings during cleanup edits and removes custom prices", () => Effect.scoped( Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -596,8 +596,30 @@ it.layer(NodeServices.layer)("server settings", (it) => { .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)))); yield* serverSettings.updateSettings({ usagePriceOverrides: { "example-model": prices } }); + const usageModelAliases = { "example-preview": "example-model" }; + const sourceId = UsageLimitSourceId.make("hub"); + const usageLimitSources = { + [sourceId]: { + kind: "cliproxy" as const, + url: "https://hub.example.test", + managementKey: "", + enabled: true, + }, + }; + const saved = yield* serverSettings.updateSettings({ + usageModelAliases, + usageLimitSources, + }); + const updated = yield* serverSettings.updateSettings({ + storageCleanup: { worktreeSettledAfterDays: 0, worktreeKeepWhen: "any-local-files" }, + }); + assert.deepStrictEqual(updated.usageLimitSources, saved.usageLimitSources); const persisted = yield* readPersisted; assert.deepStrictEqual(persisted.usagePriceOverrides, { "example-model": prices }); + assert.deepStrictEqual(persisted.usageModelAliases, usageModelAliases); + assert.deepStrictEqual(persisted.usageLimitSources, usageLimitSources); + assert.equal(persisted.storageCleanup.worktreeSettledAfterDays, 0); + assert.equal(persisted.storageCleanup.worktreeKeepWhen, "any-local-files"); yield* serverSettings.updateSettings({ usagePriceOverrides: { "example-model": null } }); const restored = yield* readPersisted; diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 2b5c1f69400615b90e9b38645127fac7b9cdb98f..a29e5d2a051ecec68380841ddbc5cc082a089692 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -9,11 +9,14 @@ import type * as Path from "effect/Path"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Schema from "effect/Schema"; +import { SCAN_CACHE_FILE_NAME } from "./usage/usageScanCache.ts"; +import { TestClock } from "effect/testing"; import * as SqlClient from "effect/sql/SqlClient"; import { DEFAULT_SERVER_SETTINGS, StorageCleanupReport, type WorktreeKeepWhen, + type StorageCleanupSettings, } from "@t3tools/contracts"; import * as ServerConfig from "./config.ts"; import * as StorageCleanup from "./storageCleanup.ts"; @@ -21,6 +24,7 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Settings from "./serverSettings.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; +import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -231,178 +235,197 @@ describe("merged pull request cleanup", () => { }); }); -const cleanupFixture = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const config = yield* ServerConfig.ServerConfig; - const git = yield* GitVcsDriver.GitVcsDriver; - const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" }); - const command = (cwd: string, args: string[]) => - git.execute({ operation: "cleanup-test", cwd, args }); - yield* command(repo, ["init", "-b", "main"]); - yield* command(repo, ["config", "user.email", "cleanup@example.test"]); - yield* command(repo, ["config", "user.name", "Cleanup test"]); - yield* fs.writeFileString(`${repo}/tracked.txt`, "original\n"); - yield* fs.writeFileString(`${repo}/.gitignore`, ".env\ndist/\nnode_modules/\n"); - yield* command(repo, ["add", "."]); - yield* command(repo, ["commit", "-m", "initial"]); - yield* fs.makeDirectory(config.worktreesDir, { recursive: true }); - const worktree = `${config.worktreesDir}/feature`; - yield* command(repo, ["worktree", "add", "-b", "feature", worktree]); - // worktreeOnDelete lets domain events request sweeps once a test calls activate. - let settings: import("@t3tools/contracts").ServerSettings = { - ...DEFAULT_SERVER_SETTINGS, - storageCleanup: { - ...DEFAULT_SERVER_SETTINGS.storageCleanup, - worktreeAfterDays: 8, - worktreeOnDelete: true, - }, - }; - let threads = [shell({ branch: "feature", worktreePath: worktree })]; - let editBeforeRemoval = false; - let openSessionCwd: string | null = null; - let fullStatusCalls = 0; - let snapshotReads = 0; - let events: Stream.Stream = Stream.empty; - const activated = yield* Deferred.make(); - const context = yield* Layer.build(StorageCleanup.layer).pipe( - Effect.provideService(GitVcsDriver.GitVcsDriver, { - ...git, - execute: (input) => - Effect.gen(function* () { - if (editBeforeRemoval && input.args.includes("remove")) - yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); - return yield* git.execute(input); - }), - statusDetailsLocal: (...args) => { - fullStatusCalls++; - return git.statusDetailsLocal(...args); - }, - }), - Effect.provideService(Settings.ServerSettingsService, { - getSettings: Effect.sync(() => settings), - subscribeChanges: Effect.succeed(Stream.empty), - } as unknown as Settings.ServerSettingsService["Service"]), - Effect.provideService(ProjectStore.ProjectStoreV2, { - listShells: () => Effect.succeed([{ id: ProjectId.make("project-1"), workspaceRoot: repo }]), - } as unknown as ProjectStore.ProjectStoreV2["Service"]), - Effect.provideService(ProjectionStore.ProjectionStoreV2, { - getShellSnapshot: (input?: { location?: string }) => - Effect.sync(() => { - snapshotReads++; - return { threads: input?.location === "archive" ? [] : threads }; - }), - getThreadWorktreePaths: () => +const cleanupFixture = ( + storageCleanup: StorageCleanupSettings = { + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: 8, + worktreeOnDelete: true, + }, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const git = yield* GitVcsDriver.GitVcsDriver; + const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" }); + const command = (cwd: string, args: string[]) => + git.execute({ operation: "cleanup-test", cwd, args }); + yield* command(repo, ["init", "-b", "main"]); + yield* command(repo, ["config", "user.email", "cleanup@example.test"]); + yield* command(repo, ["config", "user.name", "Cleanup test"]); + yield* fs.writeFileString(`${repo}/tracked.txt`, "original\n"); + yield* fs.writeFileString(`${repo}/.gitignore`, ".env\ndist/\nnode_modules/\n"); + yield* command(repo, ["add", "."]); + yield* command(repo, ["commit", "-m", "initial"]); + yield* fs.makeDirectory(config.worktreesDir, { recursive: true }); + const worktree = `${config.worktreesDir}/feature`; + yield* command(repo, ["worktree", "add", "-b", "feature", worktree]); + // worktreeOnDelete lets domain events request sweeps once a test calls activate. + let settings: import("@t3tools/contracts").ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + storageCleanup, + }; + let threads = [shell({ branch: "feature", worktreePath: worktree })]; + let editBeforeRemoval = false; + let openSessionCwd: string | null = null; + let sessionLive = true; + let fullStatusCalls = 0; + let snapshotReads = 0; + let events: Stream.Stream = Stream.empty; + const activated = yield* Deferred.make(); + const context = yield* Layer.build(StorageCleanup.layer).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, { + ...git, + execute: (input) => + Effect.gen(function* () { + if (editBeforeRemoval && input.args.includes("remove")) + yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); + return yield* git.execute(input); + }), + statusDetailsLocal: (...args) => { + fullStatusCalls++; + return git.statusDetailsLocal(...args); + }, + }), + Effect.provideService(Settings.ServerSettingsService, { + getSettings: Effect.sync(() => settings), + subscribeChanges: Effect.succeed(Stream.empty), + } as unknown as Settings.ServerSettingsService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => + Effect.succeed([{ id: ProjectId.make("project-1"), workspaceRoot: repo }]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService(ProjectionStore.ProjectionStoreV2, { + getShellSnapshot: (input?: { location?: string }) => + Effect.sync(() => { + snapshotReads++; + return { threads: input?.location === "archive" ? [] : threads }; + }), + getThreadWorktreePaths: () => + Effect.sync(() => + threads.flatMap((thread) => + thread.worktreePath === null + ? [] + : [{ threadId: thread.id, worktreePath: thread.worktreePath }], + ), + ), + getThreadShell: (threadId: ThreadId) => + Effect.sync(() => threads.find((thread) => thread.id === threadId) ?? null), + } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), + Effect.provideService(ProviderSessionManager.ProviderSessionManagerV2, { + isLive: () => Effect.sync(() => sessionLive), + } as unknown as ProviderSessionManager.ProviderSessionManagerV2["Service"]), + Effect.provideService(Orchestrator.OrchestratorV2, { + streamDomainEvents: Stream.suspend(() => events), + } as unknown as Orchestrator.OrchestratorV2["Service"]), + Effect.provideService(SqlClient.SqlClient, ((strings: TemplateStringsArray) => Effect.sync(() => - threads.flatMap((thread) => - thread.worktreePath === null - ? [] - : [{ threadId: thread.id, worktreePath: thread.worktreePath }], - ), - ), - getThreadShell: (threadId: ThreadId) => - Effect.sync(() => threads.find((thread) => thread.id === threadId) ?? null), - } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), - Effect.provideService(Orchestrator.OrchestratorV2, { - streamDomainEvents: Stream.suspend(() => events), - } as unknown as Orchestrator.OrchestratorV2["Service"]), - Effect.provideService(SqlClient.SqlClient, ((strings: TemplateStringsArray) => - Effect.sync(() => - openSessionCwd !== null && strings.join("").includes("projection_provider_sessions") - ? [ - { - payload_json: JSON.stringify({ - id: "session-1", - driver: "codex", - providerInstanceId: "codex", - status: "ready", - cwd: openSessionCwd, - model: null, - capabilities: CodexProviderCapabilitiesV2, - createdAt: "2026-06-01T12:00:00.000Z", - updatedAt: "2026-06-01T12:00:00.000Z", - lastError: null, - }), - }, - ] - : [], - )) as unknown as SqlClient.SqlClient), - Effect.provideService(GitManager.GitManager, { - invalidateStatus: () => Effect.void, - } as unknown as GitManager.GitManager["Service"]), - Effect.provideService(TerminalManager.TerminalManager, { - subscribeMetadata: () => Effect.succeed(() => {}), - } as unknown as TerminalManager.TerminalManager["Service"]), - Effect.provideService(ServerActivation, Deferred.await(activated)), - ); - return { - service: Context.get(context, StorageCleanup.StorageCleanup), - editBeforeRemoval: () => { - editBeforeRemoval = true; - }, - fs, - worktree, - config, - command, - enableFiles: () => { - settings = { - ...settings, - storageCleanup: { - ...settings.storageCleanup, - browserArtifactsAfterDays: 8, - logsAfterDays: 8, - }, - }; - }, - setPolicy: (worktreeKeepWhen: WorktreeKeepWhen) => { - settings = { ...settings, storageCleanup: { ...settings.storageCleanup, worktreeKeepWhen } }; - }, - disable: () => { - settings = { - ...settings, - storageCleanup: { - ...settings.storageCleanup, - worktreeAfterDays: null, - worktreeOnDelete: false, - }, - }; - }, - setThreads: (next: typeof threads) => { - threads = next; - }, - setOpenSessionCwd: (cwd: string | null) => { - openSessionCwd = cwd; - }, - fullStatusCalls: () => fullStatusCalls, - snapshotReads: () => snapshotReads, - /** Starts the hourly sweep and the subscription to `domainEvents`, which wait until now. */ - activate: (domainEvents: Stream.Stream) => { - events = domainEvents; - return Deferred.succeed(activated, undefined); - }, - }; -}); + openSessionCwd !== null && strings.join("").includes("projection_provider_sessions") + ? [ + { + payload_json: JSON.stringify({ + id: "session-1", + driver: "codex", + providerInstanceId: "codex", + status: "ready", + cwd: openSessionCwd, + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: "2026-06-01T12:00:00.000Z", + updatedAt: "2026-06-01T12:00:00.000Z", + lastError: null, + }), + }, + ] + : [], + )) as unknown as SqlClient.SqlClient), + Effect.provideService(GitManager.GitManager, { + invalidateStatus: () => Effect.void, + } as unknown as GitManager.GitManager["Service"]), + Effect.provideService(TerminalManager.TerminalManager, { + subscribeMetadata: () => Effect.succeed(() => {}), + } as unknown as TerminalManager.TerminalManager["Service"]), + Effect.provideService(ServerActivation, Deferred.await(activated)), + ); + return { + service: Context.get(context, StorageCleanup.StorageCleanup), + repo, + editBeforeRemoval: () => { + editBeforeRemoval = true; + }, + fs, + worktree, + config, + command, + enableFiles: () => { + settings = { + ...settings, + storageCleanup: { + ...settings.storageCleanup, + browserArtifactsAfterDays: 8, + logsAfterDays: 8, + }, + }; + }, + setSettings: (next: Partial) => { + settings = { ...settings, ...next }; + }, + setPolicy: (worktreeKeepWhen: WorktreeKeepWhen) => { + settings = { + ...settings, + storageCleanup: { ...settings.storageCleanup, worktreeKeepWhen }, + }; + }, + disable: () => { + settings = { + ...settings, + storageCleanup: { + ...settings.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: false, + }, + }; + }, + setThreads: (next: typeof threads) => { + threads = next; + }, + setSessionLive: (live: boolean) => { + sessionLive = live; + }, + setOpenSessionCwd: (cwd: string | null) => { + openSessionCwd = cwd; + }, + fullStatusCalls: () => fullStatusCalls, + snapshotReads: () => snapshotReads, + /** Starts the hourly sweep and the subscription to `domainEvents`, which wait until now. */ + activate: (domainEvents: Stream.Stream) => { + events = domainEvents; + return Deferred.succeed(activated, undefined); + }, + }; + }); /** The fixture, with the size walk of its worktree held until `resume` succeeds. */ -const pausedMeasurementFixture = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const measuring = yield* Deferred.make(); - const resume = yield* Deferred.make(); - let measuredPath: string | undefined; - const fixture = yield* cleanupFixture.pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fs, - readDirectory: (directory, options) => - Effect.gen(function* () { - if (directory === measuredPath) { - yield* Deferred.succeed(measuring, undefined); - yield* Deferred.await(resume); - } - return yield* fs.readDirectory(directory, options); - }), - }), - ); - measuredPath = yield* fs.realPath(fixture.worktree); - return { ...fixture, measuring, resume }; -}); +const pausedMeasurementFixture = (storageCleanup?: StorageCleanupSettings) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const measuring = yield* Deferred.make(); + const resume = yield* Deferred.make(); + let measuredPath: string | undefined; + const fixture = yield* cleanupFixture(storageCleanup).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readDirectory: (directory, options) => + Effect.gen(function* () { + if (directory === measuredPath) { + yield* Deferred.succeed(measuring, undefined); + yield* Deferred.await(resume); + } + return yield* fs.readDirectory(directory, options); + }), + }), + ); + measuredPath = yield* fs.realPath(fixture.worktree); + return { ...fixture, measuring, resume }; + }); const sessionUpdated = ( index: number, status: OrchestrationV2ProviderSession["status"], @@ -444,7 +467,7 @@ describe("storage cleanup reports and local file policies", () => { it.live("measures nested regular files without following symlinks", () => runCleanupTest( Effect.gen(function* () { - const { service, fs, worktree, setPolicy } = yield* cleanupFixture; + const { service, fs, worktree, setPolicy } = yield* cleanupFixture(); setPolicy("tracked-changes"); const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), @@ -468,7 +491,7 @@ describe("storage cleanup reports and local file policies", () => { runCleanupTest( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const { service, worktree } = yield* cleanupFixture.pipe( + const { service, worktree } = yield* cleanupFixture().pipe( Effect.provideService(FileSystem.FileSystem, { ...fs, readDirectory: (directory) => fs.readDirectory(`${directory}/missing-directory`), @@ -498,7 +521,7 @@ describe("storage cleanup reports and local file policies", () => { ] as const)("keeps a worktree when %s during size measurement", ([, threadsWhileMeasuring]) => runCleanupTest( Effect.gen(function* () { - const fixture = yield* pausedMeasurementFixture; + const fixture = yield* pausedMeasurementFixture(); const { fs, worktree } = fixture; fixture.setPolicy("tracked-changes"); yield* fs.writeFileString(`${worktree}/notes.txt`, "untracked\n"); @@ -521,7 +544,7 @@ describe("storage cleanup reports and local file policies", () => { it.live("reads the thread snapshot once per sweep, not per worktree", () => runCleanupTest( Effect.gen(function* () { - const { service, snapshotReads } = yield* cleanupFixture; + const { service, snapshotReads } = yield* cleanupFixture(); expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); expect(snapshotReads()).toBe(1); }), @@ -535,7 +558,7 @@ describe("storage cleanup reports and local file policies", () => { ([first, second, sweeps]) => runCleanupTest( Effect.gen(function* () { - const fixture = yield* pausedMeasurementFixture; + const fixture = yield* pausedMeasurementFixture(); const handled = yield* Deferred.make(); // 20 updates arrive while the first automatic sweep measures the worktree. yield* fixture.activate( @@ -560,7 +583,7 @@ describe("storage cleanup reports and local file policies", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const walked: Array = []; - const fixture = yield* cleanupFixture.pipe( + const fixture = yield* cleanupFixture().pipe( Effect.provideService(FileSystem.FileSystem, { ...fs, readDirectory: (directory, options) => { @@ -578,7 +601,8 @@ describe("storage cleanup reports and local file policies", () => { bytes: null, }); expect(walked.filter((directory) => directory.startsWith(root))).toEqual([]); - fixture.setOpenSessionCwd(null); + // The stored session stays ready after its runtime was released. + fixture.setSessionLive(false); const removed = yield* fixture.service.runNow; expect(removed.entries[0]).toMatchObject({ outcome: "removed" }); expect(removed.entries[0]?.bytes).toBeGreaterThan(0); @@ -590,7 +614,7 @@ describe("storage cleanup reports and local file policies", () => { runCleanupTest( Effect.gen(function* () { const { service, fs, worktree, command, setThreads, fullStatusCalls } = - yield* cleanupFixture; + yield* cleanupFixture(); const changed = { outcome: "kept", reason: "Repository or branch changed" }; yield* command(worktree, ["checkout", "--detach"]); expect((yield* service.runNow).entries[0]).toMatchObject(changed); @@ -656,7 +680,7 @@ describe("storage cleanup reports and local file policies", () => { ] as const)("applies %j", ([policy, file, outcome]) => runCleanupTest( Effect.gen(function* () { - const { service, fs, worktree, setPolicy } = yield* cleanupFixture; + const { service, fs, worktree, setPolicy } = yield* cleanupFixture(); setPolicy(policy); expect(yield* service.latestReport).toBeNull(); yield* fs.writeFileString(`${worktree}/${file}`, "local data\n"); @@ -690,7 +714,7 @@ describe("storage cleanup reports and local file policies", () => { it.live("preserves tracked edits written after the final status check", () => runCleanupTest( Effect.gen(function* () { - const fixture = yield* cleanupFixture; + const fixture = yield* cleanupFixture(); fixture.setPolicy("tracked-changes"); fixture.editBeforeRemoval(); yield* fixture.fs.writeFileString(`${fixture.worktree}/notes.txt`, "untracked\n"); @@ -708,7 +732,7 @@ describe("storage cleanup reports and local file policies", () => { it.live("streams the current report and subsequent runs", () => runCleanupTest( Effect.gen(function* () { - const { service } = yield* cleanupFixture; + const { service } = yield* cleanupFixture(); const reports = yield* service.reports.pipe( Stream.take(2), Stream.tap((report) => (report === null ? service.runNow : Effect.void)), @@ -723,7 +747,7 @@ describe("storage cleanup reports and local file policies", () => { it.live("caps entries while preserving totals and prioritizing removals", () => runCleanupTest( Effect.gen(function* () { - const { service, fs, worktree, setThreads } = yield* cleanupFixture; + const { service, fs, worktree, setThreads } = yield* cleanupFixture(); const threads = []; for (let i = 0; i < 205; i++) { const worktreePath = `${worktree}-${i}`; @@ -752,7 +776,7 @@ describe("storage cleanup reports and local file policies", () => { it.live("keeps staged edits in tracked-only mode", () => runCleanupTest( Effect.gen(function* () { - const { service, fs, worktree, setPolicy, command } = yield* cleanupFixture; + const { service, fs, worktree, setPolicy, command } = yield* cleanupFixture(); setPolicy("tracked-changes"); yield* fs.writeFileString(`${worktree}/tracked.txt`, "staged\n"); yield* command(worktree, ["add", "tracked.txt"]); @@ -769,7 +793,7 @@ describe("storage cleanup reports and local file policies", () => { () => runCleanupTest( Effect.gen(function* () { - const fixture = yield* cleanupFixture; + const fixture = yield* cleanupFixture(); const { service, fs, worktree, setThreads } = fixture; setThreads([shell({ branch: "feature", worktreePath: worktree, status: "running" })]); expect((yield* service.runNow).entries[0]?.reason).toContain("Thread is running"); @@ -790,7 +814,7 @@ describe("storage cleanup reports and local file policies", () => { it.live("serializes concurrent requests and returns each run's report", () => runCleanupTest( Effect.gen(function* () { - const { service } = yield* cleanupFixture; + const { service } = yield* cleanupFixture(); const reports = yield* Effect.all([service.runNow, service.runNow], { concurrency: "unbounded", }); @@ -805,7 +829,7 @@ describe("storage cleanup reports and local file policies", () => { it.live("aggregates expired artifacts and rotated logs while retaining current logs", () => runCleanupTest( Effect.gen(function* () { - const { service, fs, config, enableFiles, disable } = yield* cleanupFixture; + const { service, fs, config, enableFiles, disable } = yield* cleanupFixture(); disable(); enableFiles(); yield* fs.makeDirectory(config.browserArtifactsDir, { recursive: true }); @@ -848,7 +872,7 @@ describe("storage cleanup reports and local file policies", () => { it.live("reports git failures without aborting the remaining categories", () => runCleanupTest( Effect.gen(function* () { - const { service, worktree, command, enableFiles } = yield* cleanupFixture; + const { service, worktree, command, enableFiles } = yield* cleanupFixture(); enableFiles(); yield* command(worktree, ["worktree", "lock", worktree]); const report = yield* service.runNow; @@ -863,3 +887,202 @@ describe("storage cleanup reports and local file policies", () => { ), ); }); + +describe("settled worktree retention", () => { + it.effect.each([ + "immediate", + "settle-script", + "expired", + "boundary", + "wait", + "recent-update", + "active", + "dirty", + "project-off", + "project-custom", + "inactive-settling", + ] as const)("applies retention and safety checks (%s)", (scenario) => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture(); + const days = ["wait", "boundary", "expired", "recent-update"].includes(scenario) ? 8 : 0; + const rules = { + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeSettledAfterDays: + scenario === "project-custom" || scenario === "inactive-settling" ? null : days, + // Another rule must not bypass the settle-script grace either. + worktreeAfterDays: scenario === "inactive-settling" ? 1 : null, + }; + fixture.setSettings({ + storageCleanup: rules, + projectSettingsOverrides: { + [ProjectId.make("project-1")]: + scenario === "project-off" + ? { worktreeCleanup: { mode: "off" } } + : scenario === "project-custom" + ? { + worktreeCleanup: { + mode: "custom", + rules: { ...rules, worktreeSettledAfterDays: 0 }, + }, + } + : {}, + }, + }); + const thread = shell({ + branch: "feature", + worktreePath: fixture.worktree, + settledAt: + scenario === "active" + ? null + : at( + scenario === "expired" || + scenario === "recent-update" || + scenario === "inactive-settling" + ? -20 * DAY_MS + : scenario === "boundary" + ? -8 * DAY_MS + : scenario === "wait" + ? -8 * DAY_MS + 1 + : scenario === "settle-script" + ? 0 + : -60_000, + ), + updatedAt: at( + scenario === "recent-update" || + scenario === "settle-script" || + scenario === "inactive-settling" + ? 0 + : -10 * DAY_MS, + ), + }); + fixture.setThreads([thread]); + if (scenario === "dirty") + yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "edit\n"); + const report = yield* fixture.service.runNow; + const removed = ["immediate", "expired", "boundary", "project-custom"].includes(scenario); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(!removed); + expect(report.counts.removed).toBe(removed ? 1 : 0); + if (scenario !== "project-off") { + const reason = + scenario === "active" + ? "Not settled" + : scenario === "wait" + ? "Settled for 7 of 8 days" + : ["settle-script", "recent-update", "inactive-settling"].includes(scenario) + ? "Settled moments ago" + : scenario === "dirty" + ? "Has uncommitted changes (1 file)" + : `Settled for ${scenario === "expired" ? 20 : scenario === "boundary" ? 8 : 0} days`; + expect(report.entries[0]).toMatchObject({ outcome: removed ? "removed" : "kept", reason }); + } + expect(thread.branch).toBe("feature"); + expect( + (yield* fixture.command(fixture.repo, ["rev-parse", "--verify", "refs/heads/feature"])) + .exitCode, + ).toBe(0); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); +}); + +describe("settled worktree rechecks", () => { + it.live.each([{ settledAt: null }, { settledAt: at(DAY_MS) }])( + "keeps a checkout changed during measurement (%j)", + (change) => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* pausedMeasurementFixture(); + const thread = shell({ + branch: "feature", + worktreePath: fixture.worktree, + settledAt: at(0), + }); + fixture.setSettings({ + storageCleanup: { + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeSettledAfterDays: 0, + }, + }); + fixture.setThreads([thread]); + const run = yield* fixture.service.runNow.pipe(Effect.forkChild); + yield* Deferred.await(fixture.measuring); + fixture.setThreads([{ ...thread, ...change }]); + yield* Deferred.succeed(fixture.resume, undefined); + const report = yield* Fiber.join(run); + expect(report.entries[0]?.reason).toBe( + "Thread activity or shared worktree changed since check", + ); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }), + ), + ); +}); + +it.live("keeps saved usage data when removing a settled checkout", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + fixture.setSettings({ + storageCleanup: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeSettledAfterDays: 0 }, + }); + fixture.setThreads([ + shell({ branch: "feature", worktreePath: fixture.worktree, settledAt: at(0) }), + ]); + yield* fixture.fs.makeDirectory(fixture.config.stateDir, { recursive: true }); + const cachePath = `${fixture.config.stateDir}/${SCAN_CACHE_FILE_NAME}`; + const savedUsage = JSON.stringify({ tokens: 12345, cost: 1.25 }); + yield* fixture.fs.writeFileString(cachePath, savedUsage); + expect((yield* fixture.service.runNow).counts.removed).toBe(1); + expect(yield* fixture.fs.readFileString(cachePath)).toBe(savedUsage); + }), + ), +); + +it.live.each(["thread.settled", "stopped", "ready"] as const)( + "leaves settled cleanup to the regular sweeps after %s", + (trigger) => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* pausedMeasurementFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeSettledAfterDays: 0, + }); + fixture.setSettings({ + storageCleanup: { + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeSettledAfterDays: 0, + }, + }); + const thread = shell({ + branch: "feature", + worktreePath: fixture.worktree, + settledAt: at(0), + }); + fixture.setThreads([thread]); + const handled = yield* Deferred.make(); + yield* fixture.activate( + Stream.fromEffect(Deferred.await(fixture.measuring)).pipe( + Stream.flatMap(() => Stream.range(0, 19)), + Stream.map((index): OrchestrationV2DomainEvent => + trigger === "thread.settled" + ? { + id: EventId.make(`settled-${index}`), + type: "thread.settled", + threadId: thread.id, + occurredAt: at(0), + payload: { ...thread, lastVisitedAt: null }, + } + : sessionUpdated(index, trigger), + ), + Stream.concat( + Stream.fromEffect(Deferred.succeed(handled, undefined)).pipe(Stream.drain), + ), + ), + ); + yield* Deferred.await(handled); + yield* Deferred.succeed(fixture.resume, undefined); + yield* fixture.service.drain; + expect(fixture.snapshotReads()).toBe(1); + }), + ), +); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 20f1ed9f6ed92f83c226f1658fdde6ee4ab71c95..627bc410c52bb18b301abce39131689da00c1149 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -38,6 +38,7 @@ import * as GitManager from "./git/GitManager.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; +import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementService.ts"; import { forkParked } from "./serverActivation.ts"; import * as Settings from "./serverSettings.ts"; @@ -56,6 +57,9 @@ const decodeCleanupSession = Schema.decodeUnknownEffect( ); const DAY_MS = 86_400_000; +// After a settle, the project's settle script needs time to open its +// terminal, which then protects the worktree. +const SETTLE_SCRIPT_GRACE_MS = 60_000; const sentence = (text: string) => text.charAt(0).toUpperCase() + text.slice(1); const REPORT_ENTRY_LIMIT = 200; @@ -69,6 +73,7 @@ function cleanupFailureReason(error: { readonly message: string }) { const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) => rules.worktreeAfterDays !== null || + rules.worktreeSettledAfterDays !== null || rules.worktreeOnMerge || rules.worktreeOnDelete || rules.worktreeUnchanged; @@ -172,6 +177,7 @@ const make = Effect.gen(function* () { const projectStore = yield* ProjectStore.ProjectStoreV2; const engine = yield* Orchestrator.OrchestratorV2; const projections = yield* ProjectionStore.ProjectionStoreV2; + const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const sql = yield* SqlClient.SqlClient; const git = yield* GitVcsDriver.GitVcsDriver; const gitManager = yield* GitManager.GitManager; @@ -483,15 +489,35 @@ const make = Effect.gen(function* () { return keep("repository or branch changed"); const changes = yield* localChangesReason(worktreePath, settings); if (changes !== null) return keep(changes); + // The project's settle script starts in the worktree right after a + // settle. Settling stamps updatedAt now, even when automatic + // settlement backdates settledAt to the thread's last activity. + if ( + !deleted && + thread.settledAt !== null && + now - DateTime.toEpochMillis(thread.updatedAt) < SETTLE_SCRIPT_GRACE_MS + ) + return keep("settled moments ago"); const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); const old = !deleted && settings.worktreeAfterDays !== null && storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS; - let eligible = deleted || old; + const settledFor = + deleted || thread.settledAt === null + ? null + : now - DateTime.toEpochMillis(thread.settledAt); + const settledDays = settledFor === null ? null : Math.floor(settledFor / DAY_MS); + const settled = + settledFor !== null && + settings.worktreeSettledAfterDays !== null && + settledFor >= settings.worktreeSettledAfterDays * DAY_MS; + let eligible = deleted || old || settled; let removalReason = deleted ? "thread was deleted" - : `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} days`; + : old + ? `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} days` + : `settled for ${settledDays} days`; if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { const repositoryCwd = path.resolve(project.workspaceRoot); const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd); @@ -539,9 +565,13 @@ const make = Effect.gen(function* () { return keep( !deleted && settings.worktreeAfterDays !== null ? `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} of ${settings.worktreeAfterDays} days` - : settings.worktreeOnMerge - ? "not merged" - : "has commits beyond the default branch", + : settings.worktreeSettledAfterDays !== null + ? settledFor === null + ? "not settled" + : `settled for ${settledDays} of ${settings.worktreeSettledAfterDays} days` + : settings.worktreeOnMerge + ? "not merged" + : "has commits beyond the default branch", ); // Threads, queued deletion work and provider sessions as they are now, so a // queued turn, resumed session or new thread sharing this path cancels @@ -577,7 +607,8 @@ const make = Effect.gen(function* () { if ( latestShell === null || !storageCleanupThreadIdle(latestShell, now) || - storageCleanupActivityAt(latestShell) !== storageCleanupActivityAt(thread) + storageCleanupActivityAt(latestShell) !== storageCleanupActivityAt(thread) || + !Equal.equals(latestShell.settledAt, thread.settledAt) ) return "thread activity or shared worktree changed since check"; } @@ -589,13 +620,16 @@ const make = Effect.gen(function* () { const sessions = yield* Effect.forEach(sessionRows, (row) => decodeCleanupSession(row.payload_json), ); - if ( - sessions.some((session) => { - const cwd = path.resolve(session.cwd); - return cwd === worktreePath || inside(worktreePath, cwd); - }) - ) - return "provider session is still open"; + // A stored session can stay ready after its runtime was released, + // for example once every thread detached; only a live one holds it. + for (const session of sessions) { + const cwd = path.resolve(session.cwd); + if ( + (cwd === worktreePath || inside(worktreePath, cwd)) && + (yield* providerSessions.isLive(session.id)) + ) + return "provider session is still open"; + } return null; }); // Most cancelled removals are caught here, before the worktree is walked. diff --git a/apps/web/src/components/settings/StorageSettings.test.tsx b/apps/web/src/components/settings/StorageSettings.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..13b027ad31cbbba983958314bc1890391abd875e --- /dev/null +++ b/apps/web/src/components/settings/StorageSettings.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +import { RegistryProvider } from "@effect/atom-react"; +import { DEFAULT_SERVER_SETTINGS, ProjectId, type StorageCleanupReport } from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + settings: {} as typeof DEFAULT_SERVER_SETTINGS, + run: vi.fn(), +})); + +vi.mock("../../state/server", async () => { + const { Atom } = await import("effect/reactivity"); + const report = Atom.make({ _tag: "Success", value: null, waiting: false }); + const allowed = Atom.make(true); + return { + serverEnvironment: { + storageCleanupReport: () => report, + runStorageCleanup: { permissionAtom: () => allowed }, + }, + }; +}); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.run })); +vi.mock("./SettingsScopeContext", () => ({ + useSettingsScope: () => { + const environment = { + environmentId: "env", + label: "Machine", + serverConfig: { + settings: state.settings, + environment: { + capabilities: { + storageCleanup: true, + storageCleanupRun: true, + settledWorktreeCleanup: true, + }, + }, + }, + }; + return { + scope: { kind: "environment" }, + environments: [environment], + connectedEnvironments: [environment], + targets: [], + target: null, + }; + }, +})); +vi.mock("./useScopedSettings", () => ({ + useScopedSettings: () => state.settings, + useScopedSettingsMixed: () => false, + useUpdateScopedSettings: () => vi.fn(), + useClearScopedSettings: () => vi.fn(), +})); +vi.mock("./settingsLayout", () => ({ + SettingsPageContainer: ({ children }: { children: ReactNode }) => children, + SettingsSection: ({ children }: { children: ReactNode }) => children, + SettingsRow: ({ description, control }: { description: ReactNode; control: ReactNode }) => ( +
+ {description} + {control} +
+ ), + SettingResetButton: () => null, + useRelativeTimeTick: () => undefined, +})); +vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); + +import { StorageSettingsPanel } from "./StorageSettings"; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.settings = { + ...DEFAULT_SERVER_SETTINGS, + storageCleanup: { + worktreeKeepWhen: "uncommitted-changes", + worktreeAfterDays: null, + worktreeSettledAfterDays: null, + worktreeOnMerge: false, + worktreeOnDelete: false, + worktreeUnchanged: false, + browserArtifactsAfterDays: null, + logsAfterDays: null, + }, + projectSettingsOverrides: {}, + }; + state.run.mockReset(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +async function render() { + await act(async () => + root.render( + + + , + ), + ); + return Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Delete now", + )!; +} + +it.each(["machine-wide immediate", "project custom"])( + "runs cleanup when only %s settled retention is enabled", + async (scope) => { + if (scope === "machine-wide immediate") { + state.settings = { + ...state.settings, + storageCleanup: { ...state.settings.storageCleanup, worktreeSettledAfterDays: 0 }, + }; + } else { + state.settings = { + ...state.settings, + projectSettingsOverrides: { + [ProjectId.make("project")]: { + worktreeCleanup: { + mode: "custom", + rules: { ...state.settings.storageCleanup, worktreeSettledAfterDays: 7 }, + }, + }, + }, + }; + } + const report: StorageCleanupReport = { + trigger: "manual", + startedAt: "2026-10-14T12:00:00.000Z", + finishedAt: "2026-10-14T12:00:01.000Z", + entries: [], + counts: { removed: 0, kept: 0, failed: 0 }, + omittedCount: 0, + bytesFreed: 0, + }; + let complete!: (result: { _tag: "Success"; value: StorageCleanupReport }) => void; + state.run.mockImplementation(() => new Promise((resolve) => (complete = resolve))); + const button = await render(); + + expect(button.disabled).toBe(false); + await act(async () => button.click()); + expect(state.run).toHaveBeenCalledExactlyOnceWith({ environmentId: "env", input: {} }); + expect(button.disabled).toBe(true); + expect(button.textContent).toBe("Deleting…"); + + await act(async () => complete({ _tag: "Success", value: report })); + expect(button.disabled).toBe(false); + expect(button.textContent).toBe("Delete now"); + }, +); + +it("keeps cleanup disabled when settled retention and all other rules are off", async () => { + const button = await render(); + + expect(button.disabled).toBe(true); + await act(async () => button.click()); + expect(state.run).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index 28e7b4f0e24cdd422eee3feeed408bb79a8a7f9a..92ca6ed1806e27c0bfd16e12ef03f301e13defd4 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -106,10 +106,12 @@ function RetentionControl({ label, value, onChange, + minimum = 1, }: { label: string; value: number | null; onChange: (value: number | null) => void; + minimum?: number; }) { const [draft, setDraft] = useState(value); const [savedValue, setSavedValue] = useState(value); @@ -123,7 +125,7 @@ function RetentionControl({ {value !== null ? ( { if (next === null) setDraft(value); else { - const days = Math.min(3650, Math.max(1, Math.round(next))); + const days = Math.min(3650, Math.max(minimum, Math.round(next))); setDraft(days); onChange(days); } @@ -271,6 +273,7 @@ function CleanupSection() { if (!settings) return false; const enabled = (rules: WorktreeCleanupRules) => rules.worktreeAfterDays !== null || + rules.worktreeSettledAfterDays !== null || rules.worktreeOnMerge || rules.worktreeOnDelete || rules.worktreeUnchanged; @@ -397,7 +400,7 @@ function CleanupSection() { } export function StorageSettingsPanel() { - const { scope, connectedEnvironments, targets, target } = useSettingsScope(); + const { scope, environments, connectedEnvironments, targets, target } = useSettingsScope(); const scopedSettings = useScopedSettings(); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const settings = { @@ -538,6 +541,36 @@ export function StorageSettingsPanel() { /> } /> + {connectedEnvironments.length > 0 && + connectedEnvironments.every( + (environment) => + environment.serverConfig?.environment.capabilities.settledWorktreeCleanup === + true, + ) && ( + connectedEnvironments.length + ? "Offline machines will not get this setting" + : undefined, + ] + .filter(Boolean) + .join(". ")} + description="Remove worktrees this many days after their thread settles. Set 0 days to remove them at the next cleanup. Branches and thread history are kept." + serverScoped={!isProjectScope} + control={ + + updateWorktree({ worktreeSettledAfterDays }) + } + /> + } + /> + )} { mode: "custom", rules: { worktreeAfterDays: 12, + worktreeSettledAfterDays: null, worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, @@ -182,6 +183,7 @@ describe("scoped settings writes", () => { expect(policies).toEqual([ { worktreeAfterDays: 12, + worktreeSettledAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, @@ -189,6 +191,7 @@ describe("scoped settings writes", () => { }, { worktreeAfterDays: null, + worktreeSettledAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, @@ -381,6 +384,7 @@ describe("scoped settings writes", () => { mode: "custom", rules: { worktreeAfterDays: 8, + worktreeSettledAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, @@ -394,6 +398,15 @@ describe("scoped settings writes", () => { const plan = planScopedSettingsPatch(project, [machine, customized], { worktreeCleanup: { mode: "custom", rules: { worktreeOnDelete: true } }, }); + const settledPlan = planScopedSettingsPatch(project, [machine, customized], { + worktreeCleanup: { mode: "custom", rules: { worktreeSettledAfterDays: 0 } }, + }); + expect( + settledPlan.serverWrites.map((write) => write.patch.projectSettingsOverrides), + ).toMatchObject([ + { [projectId]: { worktreeCleanup: { rules: { worktreeSettledAfterDays: 0 } } } }, + { [laptopProjectId]: { worktreeCleanup: { rules: { worktreeSettledAfterDays: 0 } } } }, + ]); expect(plan.serverWrites.map((write) => write.patch.projectSettingsOverrides)).toEqual([ { [projectId]: { @@ -402,6 +415,7 @@ describe("scoped settings writes", () => { mode: "custom", rules: { worktreeAfterDays: 8, + worktreeSettledAfterDays: null, worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, @@ -416,6 +430,7 @@ describe("scoped settings writes", () => { mode: "custom", rules: { worktreeAfterDays: 30, + worktreeSettledAfterDays: null, worktreeOnDelete: true, worktreeOnMerge: false, worktreeUnchanged: false, diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 7506b92f725dde2665f8946bd2e2c33994d0d373..5b0cad9df14e8c9cd4777cffaeef8550adcbf0f0 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -149,9 +149,10 @@ Inherit follows each machine's rules; Off keeps that project's worktrees until y manually. Custom applies separate worktree rules to the selected project or checkout. Browser captures and log retention remain machine-wide. -Worktrees can be removed after a chosen number of inactive days, after merging, or when they -have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active -sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults +Worktrees can be removed after a chosen number of inactive days, after a thread settles, after +merging, or when they have no commits beyond the default branch. Set settled retention to 0 days +to remove a worktree at the next cleanup after settlement. Only T3-managed worktrees are eligible. +Active sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults to **Uncommitted changes**, which protects tracked edits and untracked files but deletes ignored files such as `.env` and build output. **Any local files** also protects ignored files other than `node_modules`. **Edited tracked files** protects only tracked edits and allows untracked and diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 96e176bccba119f1dd6b134111b34ea8aca28206..ed6a67d6272acbf41ad8a8d1e6f965e09fae419b 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -14,6 +14,20 @@ const descriptor = { } as const; describe("ExecutionEnvironmentDescriptor", () => { + it("keeps settled cleanup optional alongside current worktree capabilities", () => { + expect(decodeDescriptor(descriptor).capabilities.settledWorktreeCleanup).toBeUndefined(); + const capabilities = { + settledWorktreeCleanup: true, + storageCleanupRun: true, + projectWorktreeCleanup: true, + worktreesDirectory: true, + threadAutoSettlementScope: true, + }; + expect(decodeDescriptor({ ...descriptor, capabilities }).capabilities).toMatchObject( + capabilities, + ); + }); + it("decodes old, recognized and future manual installation descriptors", () => { expect(decodeDescriptor(descriptor).capabilities.serverInstallation).toBeUndefined(); for (const installation of [{ kind: "npx" }, { kind: "npm-global", prefix: "/opt/node" }]) { diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 22e82e5a8d5b05debc4e19e4902646058d9b18b3..53170883dd55b58f3af179807ef1582b2c77b396 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -129,6 +129,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server checks OTLP endpoints for the telemetry export settings. */ otlpEndpointCheck: Schema.optionalKey(Schema.Boolean), projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), + settledWorktreeCleanup: Schema.optionalKey(Schema.Boolean), /** Server honors the `worktreesDirectory` setting. */ worktreesDirectory: Schema.optionalKey(Schema.Boolean), /** Server can exclude PR-linked threads from inactivity settlement. */ diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index deafcf507165a8564ef08ac17cef5300b5a82e50..c289b4a2592547298c6736b982a194d431875ac5 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; -import type { CommandId, ProjectId } from "./baseSchemas.ts"; +import { type CommandId, ProjectId } from "./baseSchemas.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, @@ -56,6 +56,7 @@ describe("storage cleanup settings", () => { expect(decodeServerSettings({}).worktreeCleanup).toBeNull(); expect(decodeServerSettings({}).storageCleanup).toEqual({ worktreeAfterDays: null, + worktreeSettledAfterDays: null, worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, @@ -72,6 +73,9 @@ describe("storage cleanup settings", () => { expect(decodeServerSettingsPatch({ storageCleanup: { worktreeAfterDays: null } })).toEqual({ storageCleanup: { worktreeAfterDays: null }, }); + expect(decodeServerSettingsPatch({ storageCleanup: { worktreeSettledAfterDays: 0 } })).toEqual({ + storageCleanup: { worktreeSettledAfterDays: 0 }, + }); }); it("accepts partial custom patches but requires complete stored project rules", () => { @@ -87,6 +91,26 @@ describe("storage cleanup settings", () => { }, }), ).toThrow(); + expect( + decodeServerSettings({ + projectSettingsOverrides: { + project: { + worktreeCleanup: { + mode: "custom", + rules: { + worktreeAfterDays: null, + worktreeOnMerge: false, + worktreeOnDelete: false, + worktreeUnchanged: false, + }, + }, + }, + }, + }).projectSettingsOverrides[ProjectId.make("project")]?.worktreeCleanup, + ).toMatchObject({ + mode: "custom", + rules: { worktreeSettledAfterDays: null, worktreeKeepWhen: "uncommitted-changes" }, + }); }); it("decodes older custom rules with the default local-file policy", () => { @@ -117,6 +141,12 @@ describe("storage cleanup settings", () => { decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }), ).toThrow(); }); + + it.each([-1, 1.5, 3651])("rejects invalid settled retention %s", (days) => { + expect(() => + decodeServerSettingsPatch({ storageCleanup: { worktreeSettledAfterDays: days } }), + ).toThrow(); + }); }); describe("ClientSettings rich text composer", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a47f793e5777bbb8296e2fdb6b66535ec0838034..52ebb8947c7762c7a4de47520d6db445b1dd2502 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -934,6 +934,9 @@ export type ResponseStreamingMode = typeof ResponseStreamingMode.Type; const StorageRetentionDays = Schema.NullOr( Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), ); +const SettledWorktreeRetentionDays = Schema.NullOr( + Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 3650 })), +); export const WorktreeKeepWhen = Schema.Literals([ "any-local-files", @@ -947,6 +950,9 @@ export const WorktreeCleanupRules = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), ), worktreeAfterDays: StorageRetentionDays, + worktreeSettledAfterDays: SettledWorktreeRetentionDays.pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), worktreeOnMerge: Schema.Boolean, worktreeOnDelete: Schema.Boolean, worktreeUnchanged: Schema.Boolean, @@ -1089,6 +1095,9 @@ export const StorageCleanupSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), ), worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))), + worktreeSettledAfterDays: SettledWorktreeRetentionDays.pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), worktreeOnDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), worktreeUnchanged: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1519,6 +1528,7 @@ export const ServerSettingsPatch = Schema.Struct({ rules: Schema.Struct({ worktreeKeepWhen: Schema.optionalKey(WorktreeKeepWhen), worktreeAfterDays: Schema.optionalKey(StorageRetentionDays), + worktreeSettledAfterDays: Schema.optionalKey(SettledWorktreeRetentionDays), worktreeOnMerge: Schema.optionalKey(Schema.Boolean), worktreeOnDelete: Schema.optionalKey(Schema.Boolean), worktreeUnchanged: Schema.optionalKey(Schema.Boolean), @@ -1531,6 +1541,7 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Struct({ worktreeKeepWhen: Schema.optionalKey(WorktreeKeepWhen), worktreeAfterDays: Schema.optionalKey(StorageRetentionDays), + worktreeSettledAfterDays: Schema.optionalKey(SettledWorktreeRetentionDays), worktreeOnMerge: Schema.optionalKey(Schema.Boolean), worktreeOnDelete: Schema.optionalKey(Schema.Boolean), worktreeUnchanged: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index 89d38cae5d1c342cbd783825c5bdfd743de02289..50bd96c1879a512ef89e196b23a7667b116649c6 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -422,9 +422,19 @@ describe("projectSettingsOverrides patches", () => { describe("resolveWorktreeCleanup", () => { it("inherits machine rules, disables one project and keeps custom rules isolated", () => { const machine = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { - storageCleanup: { worktreeAfterDays: 8, worktreeOnDelete: true, logsAfterDays: 3 }, + storageCleanup: { + worktreeAfterDays: 8, + worktreeSettledAfterDays: 2, + worktreeKeepWhen: "any-local-files", + worktreeOnDelete: true, + logsAfterDays: 3, + }, }); const inherited = resolveWorktreeCleanup(machine, projectId); + expect(inherited).toMatchObject({ + worktreeSettledAfterDays: 2, + worktreeKeepWhen: "any-local-files", + }); const off = applyServerSettingsPatch(machine, { projectSettingsOverrides: { [projectId]: { worktreeCleanup: { mode: "off" } }, @@ -432,20 +442,26 @@ describe("resolveWorktreeCleanup", () => { }); expect(resolveWorktreeCleanup(off, projectId)).toEqual({ worktreeAfterDays: null, + worktreeSettledAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, - worktreeKeepWhen: "uncommitted-changes", + worktreeKeepWhen: "any-local-files", }); expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited); const custom = applyServerSettingsPatch(off, { projectSettingsOverrides: { [projectId]: { - worktreeCleanup: { mode: "custom", rules: { ...inherited, worktreeAfterDays: 15 } }, + worktreeCleanup: { + mode: "custom", + rules: { ...inherited, worktreeAfterDays: 15, worktreeSettledAfterDays: 0 }, + }, }, }, }); expect(resolveWorktreeCleanup(custom, projectId).worktreeAfterDays).toBe(15); + expect(resolveWorktreeCleanup(custom, projectId).worktreeSettledAfterDays).toBe(0); + expect(resolveWorktreeCleanup(custom, otherProjectId)).toEqual(inherited); expect(custom.storageCleanup.logsAfterDays).toBe(3); const reset = applyServerSettingsPatch(custom, { projectSettingsOverrides: { @@ -456,7 +472,7 @@ describe("resolveWorktreeCleanup", () => { }); it("completes partial machine custom rules and preserves them across edits", () => { const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { - storageCleanup: { worktreeAfterDays: 8, worktreeOnDelete: true }, + storageCleanup: { worktreeAfterDays: 8, worktreeSettledAfterDays: 0, worktreeOnDelete: true }, }); const custom = applyServerSettingsPatch(initial, { worktreeCleanup: { mode: "custom", rules: { worktreeOnMerge: true } }, @@ -466,6 +482,7 @@ describe("resolveWorktreeCleanup", () => { }); expect(resolveWorktreeCleanup(edited, null)).toEqual({ worktreeAfterDays: 15, + worktreeSettledAfterDays: 0, worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 0d3f7ae4b03b87774f270f4c82203c1090507014..445b216bac1ed62893ca37d9ff900d237f8044f9 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -279,6 +279,7 @@ export function resolveWorktreeCleanup( return { worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen, worktreeAfterDays: null, + worktreeSettledAfterDays: null, worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, @@ -286,6 +287,7 @@ export function resolveWorktreeCleanup( const { worktreeKeepWhen, worktreeAfterDays, + worktreeSettledAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged, @@ -293,6 +295,7 @@ export function resolveWorktreeCleanup( return { worktreeKeepWhen, worktreeAfterDays, + worktreeSettledAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged, diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index ab5b3db0cd7ed69982a2e3f10c14aa3824ab815a..84db4b8c656a8105e5fc77b71feba98b91367a05 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -63,6 +63,7 @@ describe("serverSettings helpers", () => { }).storageCleanup, ).toEqual({ worktreeAfterDays: null, + worktreeSettledAfterDays: null, worktreeOnMerge: true, worktreeOnDelete: false, worktreeUnchanged: false, diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 77662e6a9308afe79a6948a5d2a9166ef4c57ff8..c4c9e389527bc3b0f56d9d3e44e789d67c8114aa 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -523,6 +523,7 @@ export function applyServerSettingsPatch( rules: { worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen, worktreeAfterDays: next.storageCleanup.worktreeAfterDays, + worktreeSettledAfterDays: next.storageCleanup.worktreeSettledAfterDays, worktreeOnMerge: next.storageCleanup.worktreeOnMerge, worktreeOnDelete: next.storageCleanup.worktreeOnDelete, worktreeUnchanged: next.storageCleanup.worktreeUnchanged,