| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 5 | import * as Deferred from "effect/Deferred"; |
| 6 | import * as Fiber from "effect/Fiber"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import type * as Path from "effect/Path"; |
| 10 | import type * as Scope from "effect/Scope"; |
| 11 | import * as Stream from "effect/Stream"; |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | import { SCAN_CACHE_FILE_NAME } from "./usage/usageScanCache.ts"; |
| 14 | import { TestClock } from "effect/testing"; |
| 15 | import * as SqlClient from "effect/sql/SqlClient"; |
| 16 | import { |
| 17 | DEFAULT_SERVER_SETTINGS, |
| 18 | StorageCleanupReport, |
| 19 | type WorktreeKeepWhen, |
| 20 | type StorageCleanupSettings, |
| 21 | } from "@t3tools/contracts"; |
| 22 | import * as ServerConfig from "./config.ts"; |
| 23 | import * as StorageCleanup from "./storageCleanup.ts"; |
| 24 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 25 | import * as GitManager from "./git/GitManager.ts"; |
| 26 | import * as Settings from "./serverSettings.ts"; |
| 27 | import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; |
| 28 | import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; |
| 29 | import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; |
| 30 | import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; |
| 31 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 32 | import { CodexProviderCapabilitiesV2 } from "./orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 33 | import { ServerActivation } from "./serverActivation.ts"; |
| 34 | import { describe, expect, it } from "@effect/vitest"; |
| 35 | import { |
| 36 | EventId, |
| 37 | ProjectId, |
| 38 | ProviderDriverKind, |
| 39 | ProviderInstanceId, |
| 40 | ProviderSessionId, |
| 41 | RunId, |
| 42 | RuntimeRequestId, |
| 43 | ThreadId, |
| 44 | type OrchestrationV2DomainEvent, |
| 45 | type OrchestrationV2ProviderSession, |
| 46 | type OrchestrationV2ThreadShell, |
| 47 | } from "@t3tools/contracts"; |
| 48 | import * as DateTime from "effect/DateTime"; |
| 49 | import { |
| 50 | storageCleanupActivityAt, |
| 51 | storageCleanupPullRequestMerged, |
| 52 | storageCleanupThreadIdle, |
| 53 | } from "./storageCleanup.ts"; |
| 54 | |
| 55 | const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); |
| 56 | const DAY_MS = 24 * 60 * 60 * 1_000; |
| 57 | const decodeCleanupReport = Schema.decodeSync(StorageCleanupReport); |
| 58 | |
| 59 | function at(offsetMs: number): DateTime.Utc { |
| 60 | return DateTime.makeUnsafe(NOW_MS + offsetMs); |
| 61 | } |
| 62 | |
| 63 | function shell(overrides: Partial<OrchestrationV2ThreadShell> = {}): OrchestrationV2ThreadShell { |
| 64 | return { |
| 65 | id: ThreadId.make("thread-1"), |
| 66 | projectId: ProjectId.make("project-1"), |
| 67 | title: "Thread", |
| 68 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 69 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, |
| 70 | runtimeMode: "full-access", |
| 71 | interactionMode: "default", |
| 72 | worktreePath: null, |
| 73 | activeProviderThreadId: null, |
| 74 | lineage: { |
| 75 | rootThreadId: ThreadId.make("thread-1"), |
| 76 | parentThreadId: null, |
| 77 | relationshipToParent: null, |
| 78 | }, |
| 79 | forkedFrom: null, |
| 80 | createdBy: "user", |
| 81 | creationSource: "web", |
| 82 | activeRunId: null, |
| 83 | latestVisibleMessage: null, |
| 84 | hasActionableProposedPlan: false, |
| 85 | itemCount: 0, |
| 86 | visibleItemCount: 0, |
| 87 | lastVisitedAt: null, |
| 88 | deletedAt: null, |
| 89 | branch: null, |
| 90 | linkedPullRequest: null, |
| 91 | status: "idle", |
| 92 | activityRunStatus: null, |
| 93 | pendingRuntimeRequest: null, |
| 94 | pendingBackgroundTasks: [], |
| 95 | latestRunId: null, |
| 96 | latestRunRequestedAt: null, |
| 97 | latestRunStartedAt: null, |
| 98 | latestRunCompletedAt: null, |
| 99 | latestUserMessageAt: null, |
| 100 | createdAt: at(-30 * DAY_MS), |
| 101 | updatedAt: at(-10 * DAY_MS), |
| 102 | archivedAt: null, |
| 103 | settledOverride: null, |
| 104 | settledAt: null, |
| 105 | snoozedUntil: null, |
| 106 | snoozedAt: null, |
| 107 | pinnedAt: null, |
| 108 | ...overrides, |
| 109 | }; |
| 110 | } |
| 111 | |
| 112 | describe("V2 storage cleanup eligibility", () => { |
| 113 | const candidate = () => shell({ branch: "feature", worktreePath: "/worktrees/feature" }); |
| 114 | |
| 115 | it("allows an idle worktree and rejects the project checkout", () => { |
| 116 | expect(storageCleanupThreadIdle(candidate(), NOW_MS)).toBe(true); |
| 117 | expect(storageCleanupThreadIdle(shell(), NOW_MS)).toBe(false); |
| 118 | }); |
| 119 | |
| 120 | it.each(["running", "starting", "preparing", "waiting", "queued"] as const)( |
| 121 | "retains a worktree while its thread is %s", |
| 122 | (status) => { |
| 123 | expect(storageCleanupThreadIdle(candidateWithStatus(status), NOW_MS)).toBe(false); |
| 124 | }, |
| 125 | ); |
| 126 | |
| 127 | it.each(["idle", "completed", "interrupted", "failed", "cancelled", "rolled_back"] as const)( |
| 128 | "allows cleanup once its thread is %s", |
| 129 | (status) => { |
| 130 | expect(storageCleanupThreadIdle(candidateWithStatus(status), NOW_MS)).toBe(true); |
| 131 | }, |
| 132 | ); |
| 133 | |
| 134 | it.each(["completed", "interrupted", "cancelled", "rolled_back"] as const)( |
| 135 | "retains %s while background work is pending", |
| 136 | (status) => { |
| 137 | expect( |
| 138 | storageCleanupThreadIdle( |
| 139 | { |
| 140 | ...candidateWithStatus(status), |
| 141 | pendingBackgroundTasks: [{ taskId: "task-1", kind: "command" }], |
| 142 | }, |
| 143 | NOW_MS, |
| 144 | ), |
| 145 | ).toBe(false); |
| 146 | }, |
| 147 | ); |
| 148 | |
| 149 | it.each(["completed", "interrupted", "cancelled", "rolled_back"] as const)( |
| 150 | "retains %s while a runtime request is pending", |
| 151 | (status) => { |
| 152 | expect( |
| 153 | storageCleanupThreadIdle( |
| 154 | { |
| 155 | ...candidateWithStatus(status), |
| 156 | pendingRuntimeRequest: { |
| 157 | id: RuntimeRequestId.make("request-1"), |
| 158 | kind: "command", |
| 159 | createdAt: at(0), |
| 160 | }, |
| 161 | }, |
| 162 | NOW_MS, |
| 163 | ), |
| 164 | ).toBe(false); |
| 165 | }, |
| 166 | ); |
| 167 | |
| 168 | it("retains an active run even if the shell status is idle", () => { |
| 169 | expect( |
| 170 | storageCleanupThreadIdle({ ...candidate(), activeRunId: RunId.make("run") }, NOW_MS), |
| 171 | ).toBe(false); |
| 172 | }); |
| 173 | |
| 174 | it("retains a queued prompt before the new run has been projected", () => { |
| 175 | expect( |
| 176 | storageCleanupThreadIdle({ ...candidate(), latestUserMessageAt: at(-1_000) }, NOW_MS), |
| 177 | ).toBe(false); |
| 178 | }); |
| 179 | |
| 180 | it("uses V2 run activity instead of metadata refreshes for retention", () => { |
| 181 | const thread = candidate(); |
| 182 | const runTime = at(-3 * DAY_MS); |
| 183 | expect( |
| 184 | storageCleanupActivityAt({ ...thread, latestRunCompletedAt: runTime, updatedAt: at(0) }), |
| 185 | ).toBe(DateTime.toEpochMillis(runTime)); |
| 186 | }); |
| 187 | |
| 188 | function candidateWithStatus(status: OrchestrationV2ThreadShell["status"]) { |
| 189 | return { ...candidate(), status }; |
| 190 | } |
| 191 | }); |
| 192 | |
| 193 | describe("merged pull request cleanup", () => { |
| 194 | const HEAD_SHA = "a".repeat(40); |
| 195 | const integrated = { |
| 196 | branch: "feature", |
| 197 | defaultBranch: "main", |
| 198 | headSha: HEAD_SHA, |
| 199 | integrated: true, |
| 200 | }; |
| 201 | const squashed = { ...integrated, integrated: false }; |
| 202 | const pullRequest = ( |
| 203 | overrides: Partial<NonNullable<Parameters<typeof storageCleanupPullRequestMerged>[0]>> = {}, |
| 204 | ) => ({ |
| 205 | state: "merged" as const, |
| 206 | headRef: "feature", |
| 207 | baseRef: "main", |
| 208 | headSha: HEAD_SHA, |
| 209 | ...overrides, |
| 210 | }); |
| 211 | |
| 212 | it("removes a worktree whose head reached the default branch through a merged pull request", () => { |
| 213 | expect(storageCleanupPullRequestMerged(pullRequest({ headSha: null }), integrated)).toBe(true); |
| 214 | }); |
| 215 | |
| 216 | it("removes a squash-merged worktree when the pull request names its exact head", () => { |
| 217 | expect(storageCleanupPullRequestMerged(pullRequest(), squashed)).toBe(true); |
| 218 | }); |
| 219 | |
| 220 | it.each([ |
| 221 | ["has a later commit than the merged head", { headSha: "c".repeat(40) }], |
| 222 | ["was merged into a release branch", { baseRef: "release" }], |
| 223 | ["was merged into its stack parent", { baseRef: "stack-parent" }], |
| 224 | ["was merged without a reported head commit", { headSha: null }], |
| 225 | ["belongs to a different branch", { headRef: "other" }], |
| 226 | ["is still open", { state: "open" }], |
| 227 | ["was closed without merging", { state: "closed" }], |
| 228 | ] as const)("keeps a squash worktree whose pull request %s", (_name, overrides) => { |
| 229 | expect(storageCleanupPullRequestMerged(pullRequest(overrides), squashed)).toBe(false); |
| 230 | }); |
| 231 | |
| 232 | it("keeps a worktree with no pull request, or one that is not merged", () => { |
| 233 | expect(storageCleanupPullRequestMerged(null, squashed)).toBe(false); |
| 234 | expect(storageCleanupPullRequestMerged(null, integrated)).toBe(false); |
| 235 | expect(storageCleanupPullRequestMerged(pullRequest({ state: "open" }), integrated)).toBe(false); |
| 236 | }); |
| 237 | }); |
| 238 | |
| 239 | const cleanupFixture = ( |
| 240 | storageCleanup: StorageCleanupSettings = { |
| 241 | ...DEFAULT_SERVER_SETTINGS.storageCleanup, |
| 242 | worktreeAfterDays: 8, |
| 243 | worktreeOnDelete: true, |
| 244 | }, |
| 245 | archiveOffload = false, |
| 246 | ) => |
| 247 | Effect.gen(function* () { |
| 248 | const fs = yield* FileSystem.FileSystem; |
| 249 | const storedConfig = yield* ServerConfig.ServerConfig; |
| 250 | yield* fs.makeDirectory(storedConfig.browserArtifactsDir, { recursive: true }); |
| 251 | yield* fs.makeDirectory(storedConfig.logsDir, { recursive: true }); |
| 252 | const config = { |
| 253 | ...storedConfig, |
| 254 | browserArtifactsDir: yield* fs.realPath(storedConfig.browserArtifactsDir), |
| 255 | logsDir: yield* fs.realPath(storedConfig.logsDir), |
| 256 | }; |
| 257 | const git = yield* GitVcsDriver.GitVcsDriver; |
| 258 | const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" }); |
| 259 | const command = (cwd: string, args: string[]) => |
| 260 | git.execute({ |
| 261 | operation: "cleanup-test", |
| 262 | cwd, |
| 263 | args: ["-c", "commit.gpgSign=false", "-c", "tag.gpgSign=false", ...args], |
| 264 | }); |
| 265 | yield* command(repo, ["init", "-b", "main"]); |
| 266 | yield* command(repo, ["config", "user.email", "cleanup@example.test"]); |
| 267 | yield* command(repo, ["config", "user.name", "Cleanup test"]); |
| 268 | yield* fs.writeFileString(`${repo}/tracked.txt`, "original\n"); |
| 269 | yield* fs.writeFileString(`${repo}/.gitignore`, ".env\ndist/\nnode_modules/\n"); |
| 270 | yield* command(repo, ["add", "."]); |
| 271 | yield* command(repo, ["commit", "-m", "initial"]); |
| 272 | yield* fs.makeDirectory(config.worktreesDir, { recursive: true }); |
| 273 | const worktree = `${config.worktreesDir}/feature`; |
| 274 | yield* command(repo, ["worktree", "add", "-b", "feature", worktree]); |
| 275 | // worktreeOnDelete lets domain events request sweeps once a test calls activate. |
| 276 | let settings: import("@t3tools/contracts").ServerSettings = { |
| 277 | ...DEFAULT_SERVER_SETTINGS, |
| 278 | storageCleanup, |
| 279 | }; |
| 280 | let threads = [shell({ branch: "feature", worktreePath: worktree })]; |
| 281 | let editBeforeRemoval = false; |
| 282 | let openSessionCwd: string | null = null; |
| 283 | let sessionLive = true; |
| 284 | let fullStatusCalls = 0; |
| 285 | let snapshotReads = 0; |
| 286 | let events: Stream.Stream<OrchestrationV2DomainEvent> = Stream.empty; |
| 287 | let pendingArchive = false; |
| 288 | const activated = yield* Deferred.make<void>(); |
| 289 | const context = yield* Layer.build( |
| 290 | StorageCleanup.layer.pipe( |
| 291 | Layer.provide( |
| 292 | ConfigProvider.layer( |
| 293 | ConfigProvider.fromUnknown({ T3CODE_ARCHIVE_OFFLOAD: archiveOffload }), |
| 294 | ), |
| 295 | ), |
| 296 | ), |
| 297 | ).pipe( |
| 298 | Effect.provideService(ServerConfig.ServerConfig, config), |
| 299 | Effect.provideService(GitVcsDriver.GitVcsDriver, { |
| 300 | ...git, |
| 301 | execute: (input) => |
| 302 | Effect.gen(function* () { |
| 303 | if (editBeforeRemoval && input.args.includes("remove")) |
| 304 | yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); |
| 305 | return yield* git.execute(input); |
| 306 | }), |
| 307 | statusDetailsLocal: (...args) => { |
| 308 | fullStatusCalls++; |
| 309 | return git.statusDetailsLocal(...args); |
| 310 | }, |
| 311 | }), |
| 312 | Effect.provideService(Settings.ServerSettingsService, { |
| 313 | getSettings: Effect.sync(() => settings), |
| 314 | subscribeChanges: Effect.succeed(Stream.empty), |
| 315 | } as unknown as Settings.ServerSettingsService["Service"]), |
| 316 | Effect.provideService(ProjectStore.ProjectStoreV2, { |
| 317 | listShells: () => |
| 318 | Effect.succeed([{ id: ProjectId.make("project-1"), workspaceRoot: repo }]), |
| 319 | } as unknown as ProjectStore.ProjectStoreV2["Service"]), |
| 320 | Effect.provideService(ProjectionStore.ProjectionStoreV2, { |
| 321 | getShellSnapshot: (input?: { location?: string }) => |
| 322 | Effect.sync(() => { |
| 323 | snapshotReads++; |
| 324 | return { threads: input?.location === "archive" ? [] : threads }; |
| 325 | }), |
| 326 | getThreadWorktreePaths: () => |
| 327 | Effect.sync(() => |
| 328 | threads.flatMap((thread) => |
| 329 | thread.worktreePath === null |
| 330 | ? [] |
| 331 | : [{ threadId: thread.id, worktreePath: thread.worktreePath }], |
| 332 | ), |
| 333 | ), |
| 334 | getThreadShell: (threadId: ThreadId) => |
| 335 | Effect.sync(() => threads.find((thread) => thread.id === threadId) ?? null), |
| 336 | } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), |
| 337 | Effect.provideService(ProviderSessionManager.ProviderSessionManagerV2, { |
| 338 | isLive: () => Effect.sync(() => sessionLive), |
| 339 | } as unknown as ProviderSessionManager.ProviderSessionManagerV2["Service"]), |
| 340 | Effect.provideService(Orchestrator.OrchestratorV2, { |
| 341 | streamDomainEvents: Stream.suspend(() => events), |
| 342 | } as unknown as Orchestrator.OrchestratorV2["Service"]), |
| 343 | Effect.provideService(SqlClient.SqlClient, ((strings: TemplateStringsArray) => |
| 344 | Effect.sync(() => |
| 345 | pendingArchive && strings.join("").includes("transcript_archives") |
| 346 | ? [{}] |
| 347 | : openSessionCwd !== null && strings.join("").includes("projection_provider_sessions") |
| 348 | ? [ |
| 349 | { |
| 350 | payload_json: JSON.stringify({ |
| 351 | id: "session-1", |
| 352 | driver: "codex", |
| 353 | providerInstanceId: "codex", |
| 354 | status: "ready", |
| 355 | cwd: openSessionCwd, |
| 356 | model: null, |
| 357 | capabilities: CodexProviderCapabilitiesV2, |
| 358 | createdAt: "2026-06-01T12:00:00.000Z", |
| 359 | updatedAt: "2026-06-01T12:00:00.000Z", |
| 360 | lastError: null, |
| 361 | }), |
| 362 | }, |
| 363 | ] |
| 364 | : [], |
| 365 | )) as unknown as SqlClient.SqlClient), |
| 366 | Effect.provideService(GitManager.GitManager, { |
| 367 | invalidateStatus: () => Effect.void, |
| 368 | } as unknown as GitManager.GitManager["Service"]), |
| 369 | Effect.provideService(TerminalManager.TerminalManager, { |
| 370 | subscribeMetadata: () => Effect.succeed(() => {}), |
| 371 | } as unknown as TerminalManager.TerminalManager["Service"]), |
| 372 | Effect.provideService(ServerActivation, Deferred.await(activated)), |
| 373 | ); |
| 374 | return { |
| 375 | service: Context.get(context, StorageCleanup.StorageCleanup), |
| 376 | repo, |
| 377 | editBeforeRemoval: () => { |
| 378 | editBeforeRemoval = true; |
| 379 | }, |
| 380 | fs, |
| 381 | worktree, |
| 382 | config, |
| 383 | command, |
| 384 | enableFiles: () => { |
| 385 | settings = { |
| 386 | ...settings, |
| 387 | storageCleanup: { |
| 388 | ...settings.storageCleanup, |
| 389 | browserArtifactsAfterDays: 8, |
| 390 | logsAfterDays: 8, |
| 391 | }, |
| 392 | }; |
| 393 | }, |
| 394 | setSettings: (next: Partial<typeof settings>) => { |
| 395 | settings = { ...settings, ...next }; |
| 396 | }, |
| 397 | setPolicy: (worktreeKeepWhen: WorktreeKeepWhen) => { |
| 398 | settings = { |
| 399 | ...settings, |
| 400 | storageCleanup: { ...settings.storageCleanup, worktreeKeepWhen }, |
| 401 | }; |
| 402 | }, |
| 403 | disable: () => { |
| 404 | settings = { |
| 405 | ...settings, |
| 406 | storageCleanup: { |
| 407 | ...settings.storageCleanup, |
| 408 | worktreeAfterDays: null, |
| 409 | worktreeOnDelete: false, |
| 410 | }, |
| 411 | }; |
| 412 | }, |
| 413 | setThreads: (next: typeof threads) => { |
| 414 | threads = next; |
| 415 | }, |
| 416 | setSessionLive: (live: boolean) => { |
| 417 | sessionLive = live; |
| 418 | }, |
| 419 | setOpenSessionCwd: (cwd: string | null) => { |
| 420 | openSessionCwd = cwd; |
| 421 | }, |
| 422 | setPendingArchive: (pending: boolean) => { |
| 423 | pendingArchive = pending; |
| 424 | }, |
| 425 | fullStatusCalls: () => fullStatusCalls, |
| 426 | snapshotReads: () => snapshotReads, |
| 427 | /** Starts the hourly sweep and the subscription to `domainEvents`, which wait until now. */ |
| 428 | activate: (domainEvents: Stream.Stream<OrchestrationV2DomainEvent>) => { |
| 429 | events = domainEvents; |
| 430 | return Deferred.succeed(activated, undefined); |
| 431 | }, |
| 432 | }; |
| 433 | }); |
| 434 | /** The fixture, with the size walk of its worktree held until `resume` succeeds. */ |
| 435 | const pausedMeasurementFixture = (storageCleanup?: StorageCleanupSettings) => |
| 436 | Effect.gen(function* () { |
| 437 | const fs = yield* FileSystem.FileSystem; |
| 438 | const measuring = yield* Deferred.make<void>(); |
| 439 | const resume = yield* Deferred.make<void>(); |
| 440 | let measuredPath: string | undefined; |
| 441 | const fixture = yield* cleanupFixture(storageCleanup).pipe( |
| 442 | Effect.provideService(FileSystem.FileSystem, { |
| 443 | ...fs, |
| 444 | readDirectory: (directory, options) => |
| 445 | Effect.gen(function* () { |
| 446 | if (directory === measuredPath) { |
| 447 | yield* Deferred.succeed(measuring, undefined); |
| 448 | yield* Deferred.await(resume); |
| 449 | } |
| 450 | return yield* fs.readDirectory(directory, options); |
| 451 | }), |
| 452 | }), |
| 453 | ); |
| 454 | measuredPath = yield* fs.realPath(fixture.worktree); |
| 455 | return { ...fixture, measuring, resume }; |
| 456 | }); |
| 457 | const sessionUpdated = ( |
| 458 | index: number, |
| 459 | status: OrchestrationV2ProviderSession["status"], |
| 460 | ): OrchestrationV2DomainEvent => ({ |
| 461 | id: EventId.make(`event-${index}`), |
| 462 | type: "provider-session.updated", |
| 463 | threadId: ThreadId.make("thread-1"), |
| 464 | occurredAt: at(0), |
| 465 | payload: { |
| 466 | id: ProviderSessionId.make(`session-${index}`), |
| 467 | driver: ProviderDriverKind.make("codex"), |
| 468 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 469 | status, |
| 470 | cwd: "/elsewhere", |
| 471 | model: null, |
| 472 | capabilities: CodexProviderCapabilitiesV2, |
| 473 | createdAt: at(0), |
| 474 | updatedAt: at(0), |
| 475 | lastError: null, |
| 476 | }, |
| 477 | }); |
| 478 | const cleanupTestLayer = GitVcsDriver.layer.pipe( |
| 479 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })), |
| 480 | Layer.provideMerge(NodeServices.layer), |
| 481 | ); |
| 482 | const runCleanupTest = <A, E>( |
| 483 | effect: Effect.Effect< |
| 484 | A, |
| 485 | E, |
| 486 | | FileSystem.FileSystem |
| 487 | | ServerConfig.ServerConfig |
| 488 | | GitVcsDriver.GitVcsDriver |
| 489 | | Path.Path |
| 490 | | Scope.Scope |
| 491 | >, |
| 492 | ) => effect.pipe(Effect.provide(cleanupTestLayer), Effect.scoped); |
| 493 | |
| 494 | describe("storage cleanup reports and local file policies", () => { |
| 495 | it.live.each([false, true])( |
| 496 | "keeps unverified transcript media before worktree cleanup only when offloading is enabled (%s)", |
| 497 | (enabled) => |
| 498 | runCleanupTest( |
| 499 | Effect.gen(function* () { |
| 500 | const fixture = yield* cleanupFixture(undefined, enabled); |
| 501 | fixture.setPendingArchive(true); |
| 502 | const report = yield* fixture.service.runNow; |
| 503 | expect(report.entries[0]?.outcome).toBe(enabled ? "kept" : "removed"); |
| 504 | if (enabled) { |
| 505 | expect(report.entries[0]?.reason).toContain("Transcript archive is not verified"); |
| 506 | fixture.setPendingArchive(false); |
| 507 | expect((yield* fixture.service.runNow).entries[0]?.outcome).toBe("removed"); |
| 508 | } |
| 509 | }), |
| 510 | ), |
| 511 | ); |
| 512 | |
| 513 | it.live("measures nested regular files without following symlinks", () => |
| 514 | runCleanupTest( |
| 515 | Effect.gen(function* () { |
| 516 | const { service, fs, worktree, setPolicy } = yield* cleanupFixture(); |
| 517 | setPolicy("tracked-changes"); |
| 518 | const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => |
| 519 | fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), |
| 520 | ); |
| 521 | const outside = yield* fs.makeTempDirectoryScoped(); |
| 522 | yield* fs.writeFileString(`${outside}/external`, "must not count"); |
| 523 | yield* fs.makeDirectory(`${worktree}/node_modules/nested`, { recursive: true }); |
| 524 | yield* fs.writeFileString(`${worktree}/node_modules/nested/file`, "count me"); |
| 525 | yield* fs.symlink(outside, `${worktree}/node_modules/directory-link`); |
| 526 | yield* fs.symlink(`${outside}/external`, `${worktree}/node_modules/file-link`); |
| 527 | yield* fs.symlink(`${outside}/missing`, `${worktree}/node_modules/dangling-link`); |
| 528 | const report = yield* service.runNow; |
| 529 | const bytes = sizes.reduce((sum, size) => sum + size, 8); |
| 530 | expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes, files: null }); |
| 531 | expect(report.bytesFreed).toBe(bytes); |
| 532 | expect(yield* fs.readFileString(`${outside}/external`)).toBe("must not count"); |
| 533 | }), |
| 534 | ), |
| 535 | ); |
| 536 | it.live("still removes a worktree when measuring its size fails", () => |
| 537 | runCleanupTest( |
| 538 | Effect.gen(function* () { |
| 539 | const fs = yield* FileSystem.FileSystem; |
| 540 | const { service, worktree } = yield* cleanupFixture().pipe( |
| 541 | Effect.provideService(FileSystem.FileSystem, { |
| 542 | ...fs, |
| 543 | readDirectory: (directory) => fs.readDirectory(`${directory}/missing-directory`), |
| 544 | }), |
| 545 | ); |
| 546 | const report = yield* service.runNow; |
| 547 | expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes: null, files: null }); |
| 548 | expect(report.bytesFreed).toBe(0); |
| 549 | expect(yield* fs.exists(worktree)).toBe(false); |
| 550 | }), |
| 551 | ), |
| 552 | ); |
| 553 | it.live.each([ |
| 554 | [ |
| 555 | "its thread starts", |
| 556 | (worktree: string) => [ |
| 557 | shell({ branch: "feature", worktreePath: worktree, status: "running" }), |
| 558 | ], |
| 559 | ], |
| 560 | [ |
| 561 | "another thread starts using it", |
| 562 | (worktree: string) => [ |
| 563 | shell({ branch: "feature", worktreePath: worktree }), |
| 564 | shell({ id: ThreadId.make("thread-2"), branch: "feature", worktreePath: worktree }), |
| 565 | ], |
| 566 | ], |
| 567 | ] as const)("keeps a worktree when %s during size measurement", ([, threadsWhileMeasuring]) => |
| 568 | runCleanupTest( |
| 569 | Effect.gen(function* () { |
| 570 | const fixture = yield* pausedMeasurementFixture(); |
| 571 | const { fs, worktree } = fixture; |
| 572 | fixture.setPolicy("tracked-changes"); |
| 573 | yield* fs.writeFileString(`${worktree}/notes.txt`, "untracked\n"); |
| 574 | const cleanup = yield* fixture.service.runNow.pipe(Effect.forkChild); |
| 575 | yield* Deferred.await(fixture.measuring); |
| 576 | fixture.setThreads(threadsWhileMeasuring(worktree)); |
| 577 | yield* Deferred.succeed(fixture.resume, undefined); |
| 578 | const report = yield* Fiber.join(cleanup); |
| 579 | expect(report.entries[0]).toMatchObject({ |
| 580 | outcome: "kept", |
| 581 | reason: "Thread activity or shared worktree changed since check", |
| 582 | bytes: null, |
| 583 | }); |
| 584 | expect(report.bytesFreed).toBe(0); |
| 585 | expect(yield* fs.exists(worktree)).toBe(true); |
| 586 | expect(yield* fs.readFileString(`${worktree}/notes.txt`)).toBe("untracked\n"); |
| 587 | }), |
| 588 | ), |
| 589 | ); |
| 590 | it.live("reads the thread snapshot once per sweep, not per worktree", () => |
| 591 | runCleanupTest( |
| 592 | Effect.gen(function* () { |
| 593 | const { service, snapshotReads } = yield* cleanupFixture(); |
| 594 | expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); |
| 595 | expect(snapshotReads()).toBe(1); |
| 596 | }), |
| 597 | ), |
| 598 | ); |
| 599 | it.live.each([ |
| 600 | ["running", "ready", 1], |
| 601 | ["stopped", "error", 2], |
| 602 | ] as const)( |
| 603 | "session updates to %s or %s during a sweep make %i sweep(s) in total", |
| 604 | ([first, second, sweeps]) => |
| 605 | runCleanupTest( |
| 606 | Effect.gen(function* () { |
| 607 | const fixture = yield* pausedMeasurementFixture(); |
| 608 | const handled = yield* Deferred.make<void>(); |
| 609 | // 20 updates arrive while the first automatic sweep measures the worktree. |
| 610 | yield* fixture.activate( |
| 611 | Stream.fromEffect(Deferred.await(fixture.measuring)).pipe( |
| 612 | Stream.flatMap(() => Stream.range(0, 19)), |
| 613 | Stream.map((index) => sessionUpdated(index, index % 2 === 0 ? first : second)), |
| 614 | Stream.concat( |
| 615 | Stream.fromEffect(Deferred.succeed(handled, undefined)).pipe(Stream.drain), |
| 616 | ), |
| 617 | ), |
| 618 | ); |
| 619 | yield* Deferred.await(handled); |
| 620 | yield* Deferred.succeed(fixture.resume, undefined); |
| 621 | yield* fixture.service.drain; |
| 622 | // One snapshot read per sweep. |
| 623 | expect(fixture.snapshotReads()).toBe(sweeps); |
| 624 | }), |
| 625 | ), |
| 626 | ); |
| 627 | it.live("does not measure a worktree kept for an open provider session", () => |
| 628 | runCleanupTest( |
| 629 | Effect.gen(function* () { |
| 630 | const fs = yield* FileSystem.FileSystem; |
| 631 | const walked: Array<string> = []; |
| 632 | const fixture = yield* cleanupFixture().pipe( |
| 633 | Effect.provideService(FileSystem.FileSystem, { |
| 634 | ...fs, |
| 635 | readDirectory: (directory, options) => { |
| 636 | walked.push(directory); |
| 637 | return fs.readDirectory(directory, options); |
| 638 | }, |
| 639 | }), |
| 640 | ); |
| 641 | const root = yield* fs.realPath(fixture.worktree); |
| 642 | fixture.setOpenSessionCwd(fixture.worktree); |
| 643 | const kept = yield* fixture.service.runNow; |
| 644 | expect(kept.entries[0]).toMatchObject({ |
| 645 | outcome: "kept", |
| 646 | reason: "Provider session is still open", |
| 647 | bytes: null, |
| 648 | }); |
| 649 | expect(walked.filter((directory) => directory.startsWith(root))).toEqual([]); |
| 650 | // The stored session stays ready after its runtime was released. |
| 651 | fixture.setSessionLive(false); |
| 652 | const removed = yield* fixture.service.runNow; |
| 653 | expect(removed.entries[0]).toMatchObject({ outcome: "removed" }); |
| 654 | expect(removed.entries[0]?.bytes).toBeGreaterThan(0); |
| 655 | expect(walked).toContain(root); |
| 656 | }), |
| 657 | ), |
| 658 | ); |
| 659 | it.live("compares the checked-out branch without a full status", () => |
| 660 | runCleanupTest( |
| 661 | Effect.gen(function* () { |
| 662 | const { service, fs, worktree, command, setThreads, fullStatusCalls } = |
| 663 | yield* cleanupFixture(); |
| 664 | const changed = { outcome: "kept", reason: "Repository or branch changed" }; |
| 665 | yield* command(worktree, ["checkout", "--detach"]); |
| 666 | expect((yield* service.runNow).entries[0]).toMatchObject(changed); |
| 667 | // A branch that has no commit yet is still a different branch. |
| 668 | yield* command(worktree, ["switch", "--orphan", "other"]); |
| 669 | expect((yield* service.runNow).entries[0]).toMatchObject(changed); |
| 670 | // Status output reserves a leading "(" for a detached HEAD. |
| 671 | yield* command(worktree, ["switch", "-c", "(feature)", "feature"]); |
| 672 | setThreads([shell({ branch: "(feature)", worktreePath: worktree })]); |
| 673 | expect((yield* service.runNow).entries[0]).toMatchObject(changed); |
| 674 | setThreads([shell({ branch: "feature", worktreePath: worktree })]); |
| 675 | // Tags named after the branch or HEAD make Git's short names ambiguous. |
| 676 | yield* command(worktree, ["tag", "feature", "feature"]); |
| 677 | // Newer Git refuses to create a tag named HEAD, but still reads one. |
| 678 | yield* command(worktree, ["update-ref", "refs/tags/HEAD", "refs/heads/feature"]); |
| 679 | yield* command(worktree, ["switch", "feature"]); |
| 680 | const index = (yield* command(worktree, ["rev-parse", "--git-path", "index"])).stdout; |
| 681 | yield* fs.writeFileString(`${index.trim()}.lock`, ""); |
| 682 | const locked = (yield* service.runNow).entries[0]; |
| 683 | expect(locked).toMatchObject({ outcome: "failed" }); |
| 684 | expect(locked?.reason).toContain("Git index is locked"); |
| 685 | yield* fs.remove(`${index.trim()}.lock`); |
| 686 | const gitFile = yield* fs.readFileString(`${worktree}/.git`); |
| 687 | yield* fs.writeFileString(`${worktree}/.git`, "gitdir: /missing-git-directory\n"); |
| 688 | expect((yield* service.runNow).entries[0]).toMatchObject(changed); |
| 689 | yield* fs.writeFileString(`${worktree}/.git`, gitFile); |
| 690 | expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); |
| 691 | expect(fullStatusCalls()).toBe(0); |
| 692 | }), |
| 693 | ), |
| 694 | ); |
| 695 | it("decodes reports from servers without size fields", () => { |
| 696 | const report = decodeCleanupReport({ |
| 697 | trigger: "manual", |
| 698 | startedAt: "2026-06-10T12:00:00.000Z", |
| 699 | finishedAt: "2026-06-10T12:00:01.000Z", |
| 700 | entries: [ |
| 701 | { |
| 702 | kind: "worktree", |
| 703 | outcome: "removed", |
| 704 | reason: "Removed", |
| 705 | path: null, |
| 706 | threadId: null, |
| 707 | threadTitle: null, |
| 708 | }, |
| 709 | ], |
| 710 | counts: { removed: 1, kept: 0, failed: 0 }, |
| 711 | omittedCount: 0, |
| 712 | }); |
| 713 | expect(report.bytesFreed).toBe(0); |
| 714 | expect(report.entries[0]).toMatchObject({ bytes: null, files: null }); |
| 715 | }); |
| 716 | it.live.each([ |
| 717 | ["any-local-files", ".env", "kept"], |
| 718 | ["uncommitted-changes", ".env", "removed"], |
| 719 | ["tracked-changes", ".env", "removed"], |
| 720 | ["any-local-files", "notes.txt", "kept"], |
| 721 | ["uncommitted-changes", "notes.txt", "kept"], |
| 722 | ["tracked-changes", "notes.txt", "removed"], |
| 723 | ["any-local-files", "tracked.txt", "kept"], |
| 724 | ["uncommitted-changes", "tracked.txt", "kept"], |
| 725 | ["tracked-changes", "tracked.txt", "kept"], |
| 726 | ] as const)("applies %j", ([policy, file, outcome]) => |
| 727 | runCleanupTest( |
| 728 | Effect.gen(function* () { |
| 729 | const { service, fs, worktree, setPolicy } = yield* cleanupFixture(); |
| 730 | setPolicy(policy); |
| 731 | expect(yield* service.latestReport).toBeNull(); |
| 732 | yield* fs.writeFileString(`${worktree}/${file}`, "local data\n"); |
| 733 | const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => |
| 734 | fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), |
| 735 | ); |
| 736 | const bytes = sizes.reduce((sum, size) => sum + size, 0); |
| 737 | const report = yield* service.runNow; |
| 738 | yield* service.drain; |
| 739 | expect(report.trigger).toBe("manual"); |
| 740 | expect(report.entries.filter((entry) => entry.kind === "worktree")).toMatchObject([ |
| 741 | { |
| 742 | outcome, |
| 743 | path: worktree, |
| 744 | threadId: "thread-1", |
| 745 | threadTitle: "Thread", |
| 746 | bytes: outcome === "removed" ? bytes : null, |
| 747 | files: null, |
| 748 | }, |
| 749 | ]); |
| 750 | expect(report.bytesFreed).toBe(outcome === "removed" ? bytes : 0); |
| 751 | expect(yield* fs.exists(worktree)).toBe(outcome === "kept"); |
| 752 | expect(yield* service.latestReport).toEqual(report); |
| 753 | if (outcome === "kept") |
| 754 | expect(report.entries[0]?.reason).toContain( |
| 755 | file === ".env" ? "ignored files (.env)" : "uncommitted changes (1 file)", |
| 756 | ); |
| 757 | }), |
| 758 | ), |
| 759 | ); |
| 760 | it.live("preserves tracked edits written after the final status check", () => |
| 761 | runCleanupTest( |
| 762 | Effect.gen(function* () { |
| 763 | const fixture = yield* cleanupFixture(); |
| 764 | fixture.setPolicy("tracked-changes"); |
| 765 | fixture.editBeforeRemoval(); |
| 766 | yield* fixture.fs.writeFileString(`${fixture.worktree}/notes.txt`, "untracked\n"); |
| 767 | const report = yield* fixture.service.runNow; |
| 768 | expect(report.entries[0]).toMatchObject({ outcome: "failed", bytes: null, files: null }); |
| 769 | expect(report.bytesFreed).toBe(0); |
| 770 | expect(report.entries[0]?.reason).toContain("contains modified or untracked files"); |
| 771 | expect(yield* fixture.fs.readFileString(`${fixture.worktree}/tracked.txt`)).toBe( |
| 772 | "late edit\n", |
| 773 | ); |
| 774 | expect(yield* fixture.fs.exists(`${fixture.worktree}/notes.txt`)).toBe(false); |
| 775 | }), |
| 776 | ), |
| 777 | ); |
| 778 | it.live("streams the current report and subsequent runs", () => |
| 779 | runCleanupTest( |
| 780 | Effect.gen(function* () { |
| 781 | const { service } = yield* cleanupFixture(); |
| 782 | const reports = yield* service.reports.pipe( |
| 783 | Stream.take(2), |
| 784 | Stream.tap((report) => (report === null ? service.runNow : Effect.void)), |
| 785 | Stream.runCollect, |
| 786 | ); |
| 787 | expect(reports[0]).toBeNull(); |
| 788 | expect(reports[1]).toEqual(yield* service.latestReport); |
| 789 | expect(reports[1]?.counts).toEqual({ removed: 1, kept: 0, failed: 0 }); |
| 790 | }), |
| 791 | ), |
| 792 | ); |
| 793 | it.live("caps entries while preserving totals and prioritizing removals", () => |
| 794 | runCleanupTest( |
| 795 | Effect.gen(function* () { |
| 796 | const { service, fs, worktree, setThreads } = yield* cleanupFixture(); |
| 797 | const threads = []; |
| 798 | for (let i = 0; i < 205; i++) { |
| 799 | const worktreePath = `${worktree}-${i}`; |
| 800 | yield* fs.makeDirectory(worktreePath); |
| 801 | threads.push( |
| 802 | shell({ |
| 803 | id: ThreadId.make(`thread-${i + 2}`), |
| 804 | branch: "feature", |
| 805 | worktreePath, |
| 806 | status: "running", |
| 807 | }), |
| 808 | ); |
| 809 | } |
| 810 | threads.push(shell({ branch: "feature", worktreePath: worktree })); |
| 811 | setThreads(threads); |
| 812 | const report = yield* service.runNow; |
| 813 | expect(report.entries).toHaveLength(200); |
| 814 | expect(report.omittedCount).toBe(6); |
| 815 | expect(report.counts).toEqual({ removed: 1, kept: 205, failed: 0 }); |
| 816 | expect(report.entries[0]?.outcome).toBe("removed"); |
| 817 | expect(report.entries[0]?.bytes).toBeGreaterThan(0); |
| 818 | expect(report.bytesFreed).toBe(report.entries[0]?.bytes); |
| 819 | }), |
| 820 | ), |
| 821 | ); |
| 822 | it.live("keeps staged edits in tracked-only mode", () => |
| 823 | runCleanupTest( |
| 824 | Effect.gen(function* () { |
| 825 | const { service, fs, worktree, setPolicy, command } = yield* cleanupFixture(); |
| 826 | setPolicy("tracked-changes"); |
| 827 | yield* fs.writeFileString(`${worktree}/tracked.txt`, "staged\n"); |
| 828 | yield* command(worktree, ["add", "tracked.txt"]); |
| 829 | expect((yield* service.runNow).entries[0]).toMatchObject({ |
| 830 | outcome: "kept", |
| 831 | reason: "Has uncommitted changes (1 file)", |
| 832 | }); |
| 833 | expect(yield* fs.exists(worktree)).toBe(true); |
| 834 | }), |
| 835 | ), |
| 836 | ); |
| 837 | it.live( |
| 838 | "reports shared and running worktrees but omits disabled rules and missing directories", |
| 839 | () => |
| 840 | runCleanupTest( |
| 841 | Effect.gen(function* () { |
| 842 | const fixture = yield* cleanupFixture(); |
| 843 | const { service, fs, worktree, setThreads } = fixture; |
| 844 | setThreads([shell({ branch: "feature", worktreePath: worktree, status: "running" })]); |
| 845 | expect((yield* service.runNow).entries[0]?.reason).toContain("Thread is running"); |
| 846 | setThreads([ |
| 847 | shell({ branch: "feature", worktreePath: worktree }), |
| 848 | shell({ id: ThreadId.make("thread-2"), branch: "feature", worktreePath: worktree }), |
| 849 | ]); |
| 850 | expect((yield* service.runNow).entries[0]?.reason).toBe("Shared by 2 threads"); |
| 851 | fixture.disable(); |
| 852 | expect((yield* service.runNow).entries).toEqual([]); |
| 853 | yield* fs.remove(worktree, { recursive: true }); |
| 854 | expect( |
| 855 | (yield* service.runNow).entries.filter((entry) => entry.kind === "worktree"), |
| 856 | ).toEqual([]); |
| 857 | }), |
| 858 | ), |
| 859 | ); |
| 860 | it.live("serializes concurrent requests and returns each run's report", () => |
| 861 | runCleanupTest( |
| 862 | Effect.gen(function* () { |
| 863 | const { service } = yield* cleanupFixture(); |
| 864 | const reports = yield* Effect.all([service.runNow, service.runNow], { |
| 865 | concurrency: "unbounded", |
| 866 | }); |
| 867 | yield* service.drain; |
| 868 | expect( |
| 869 | reports.flatMap((report) => report.entries).filter((entry) => entry.kind === "worktree"), |
| 870 | ).toMatchObject([{ outcome: "removed" }]); |
| 871 | expect(yield* service.latestReport).toEqual(reports[1]); |
| 872 | }), |
| 873 | ), |
| 874 | ); |
| 875 | it.live("aggregates expired artifacts and rotated logs while retaining current logs", () => |
| 876 | runCleanupTest( |
| 877 | Effect.gen(function* () { |
| 878 | const { service, fs, config, enableFiles, disable } = yield* cleanupFixture(); |
| 879 | disable(); |
| 880 | enableFiles(); |
| 881 | yield* fs.makeDirectory(config.browserArtifactsDir, { recursive: true }); |
| 882 | yield* fs.makeDirectory(config.logsDir, { recursive: true }); |
| 883 | for (const name of ["one.png", "two.webm"]) { |
| 884 | const file = config.browserArtifactsDir + "/" + name; |
| 885 | yield* fs.writeFileString(file, "capture"); |
| 886 | yield* fs.utimes(file, 1, 1); |
| 887 | } |
| 888 | for (const name of ["server.log.1", "server.log.2", "server.log"]) { |
| 889 | const file = config.logsDir + "/" + name; |
| 890 | yield* fs.writeFileString(file, "log"); |
| 891 | yield* fs.utimes(file, 1, 1); |
| 892 | } |
| 893 | const report = yield* service.runNow; |
| 894 | expect(report.entries.filter((entry) => entry.kind !== "worktree")).toMatchObject([ |
| 895 | { |
| 896 | kind: "browser-artifacts", |
| 897 | outcome: "removed", |
| 898 | reason: "Removed 2 browser artifacts", |
| 899 | bytes: 14, |
| 900 | files: 2, |
| 901 | }, |
| 902 | { |
| 903 | kind: "logs", |
| 904 | outcome: "removed", |
| 905 | reason: "Removed 2 rotated logs", |
| 906 | bytes: 6, |
| 907 | files: 2, |
| 908 | }, |
| 909 | ]); |
| 910 | expect(report.bytesFreed).toBe(20); |
| 911 | expect(yield* fs.readDirectory(config.browserArtifactsDir)).toEqual([]); |
| 912 | expect(yield* fs.exists(config.logsDir + "/server.log")).toBe(true); |
| 913 | expect(yield* fs.exists(config.logsDir + "/server.log.1")).toBe(false); |
| 914 | expect(yield* fs.exists(config.logsDir + "/server.log.2")).toBe(false); |
| 915 | }), |
| 916 | ), |
| 917 | ); |
| 918 | it.live("reports git failures without aborting the remaining categories", () => |
| 919 | runCleanupTest( |
| 920 | Effect.gen(function* () { |
| 921 | const { service, worktree, command, enableFiles } = yield* cleanupFixture(); |
| 922 | enableFiles(); |
| 923 | yield* command(worktree, ["worktree", "lock", worktree]); |
| 924 | const report = yield* service.runNow; |
| 925 | expect(report.entries[0]).toMatchObject({ outcome: "failed" }); |
| 926 | expect(report.entries[0]?.reason).toContain("git worktree remove: fatal:"); |
| 927 | expect(report.entries.map((entry) => entry.kind)).toEqual([ |
| 928 | "worktree", |
| 929 | "browser-artifacts", |
| 930 | "logs", |
| 931 | ]); |
| 932 | }), |
| 933 | ), |
| 934 | ); |
| 935 | }); |
| 936 | |
| 937 | describe("settled worktree retention", () => { |
| 938 | it.effect.each([ |
| 939 | "immediate", |
| 940 | "settle-script", |
| 941 | "expired", |
| 942 | "boundary", |
| 943 | "wait", |
| 944 | "recent-update", |
| 945 | "active", |
| 946 | "dirty", |
| 947 | "project-off", |
| 948 | "project-custom", |
| 949 | "inactive-settling", |
| 950 | ] as const)("applies retention and safety checks (%s)", (scenario) => |
| 951 | Effect.gen(function* () { |
| 952 | yield* TestClock.setTime(NOW_MS); |
| 953 | const fixture = yield* cleanupFixture(); |
| 954 | const days = ["wait", "boundary", "expired", "recent-update"].includes(scenario) ? 8 : 0; |
| 955 | const rules = { |
| 956 | ...DEFAULT_SERVER_SETTINGS.storageCleanup, |
| 957 | worktreeSettledAfterDays: |
| 958 | scenario === "project-custom" || scenario === "inactive-settling" ? null : days, |
| 959 | // Another rule must not bypass the settle-script grace either. |
| 960 | worktreeAfterDays: scenario === "inactive-settling" ? 1 : null, |
| 961 | }; |
| 962 | fixture.setSettings({ |
| 963 | storageCleanup: rules, |
| 964 | projectSettingsOverrides: { |
| 965 | [ProjectId.make("project-1")]: |
| 966 | scenario === "project-off" |
| 967 | ? { worktreeCleanup: { mode: "off" } } |
| 968 | : scenario === "project-custom" |
| 969 | ? { |
| 970 | worktreeCleanup: { |
| 971 | mode: "custom", |
| 972 | rules: { ...rules, worktreeSettledAfterDays: 0 }, |
| 973 | }, |
| 974 | } |
| 975 | : {}, |
| 976 | }, |
| 977 | }); |
| 978 | const thread = shell({ |
| 979 | branch: "feature", |
| 980 | worktreePath: fixture.worktree, |
| 981 | settledAt: |
| 982 | scenario === "active" |
| 983 | ? null |
| 984 | : at( |
| 985 | scenario === "expired" || |
| 986 | scenario === "recent-update" || |
| 987 | scenario === "inactive-settling" |
| 988 | ? -20 * DAY_MS |
| 989 | : scenario === "boundary" |
| 990 | ? -8 * DAY_MS |
| 991 | : scenario === "wait" |
| 992 | ? -8 * DAY_MS + 1 |
| 993 | : scenario === "settle-script" |
| 994 | ? 0 |
| 995 | : -60_000, |
| 996 | ), |
| 997 | updatedAt: at( |
| 998 | scenario === "recent-update" || |
| 999 | scenario === "settle-script" || |
| 1000 | scenario === "inactive-settling" |
| 1001 | ? 0 |
| 1002 | : -10 * DAY_MS, |
| 1003 | ), |
| 1004 | }); |
| 1005 | fixture.setThreads([thread]); |
| 1006 | if (scenario === "dirty") |
| 1007 | yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "edit\n"); |
| 1008 | const report = yield* fixture.service.runNow; |
| 1009 | const removed = ["immediate", "expired", "boundary", "project-custom"].includes(scenario); |
| 1010 | expect(yield* fixture.fs.exists(fixture.worktree)).toBe(!removed); |
| 1011 | expect(report.counts.removed).toBe(removed ? 1 : 0); |
| 1012 | if (scenario !== "project-off") { |
| 1013 | const reason = |
| 1014 | scenario === "active" |
| 1015 | ? "Not settled" |
| 1016 | : scenario === "wait" |
| 1017 | ? "Settled for 7 of 8 days" |
| 1018 | : ["settle-script", "recent-update", "inactive-settling"].includes(scenario) |
| 1019 | ? "Settled moments ago" |
| 1020 | : scenario === "dirty" |
| 1021 | ? "Has uncommitted changes (1 file)" |
| 1022 | : `Settled for ${scenario === "expired" ? 20 : scenario === "boundary" ? 8 : 0} days`; |
| 1023 | expect(report.entries[0]).toMatchObject({ outcome: removed ? "removed" : "kept", reason }); |
| 1024 | } |
| 1025 | expect(thread.branch).toBe("feature"); |
| 1026 | expect( |
| 1027 | (yield* fixture.command(fixture.repo, ["rev-parse", "--verify", "refs/heads/feature"])) |
| 1028 | .exitCode, |
| 1029 | ).toBe(0); |
| 1030 | }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), |
| 1031 | ); |
| 1032 | }); |
| 1033 | |
| 1034 | describe("settled worktree rechecks", () => { |
| 1035 | it.live.each([{ settledAt: null }, { settledAt: at(DAY_MS) }])( |
| 1036 | "keeps a checkout changed during measurement (%j)", |
| 1037 | (change) => |
| 1038 | runCleanupTest( |
| 1039 | Effect.gen(function* () { |
| 1040 | const fixture = yield* pausedMeasurementFixture(); |
| 1041 | const thread = shell({ |
| 1042 | branch: "feature", |
| 1043 | worktreePath: fixture.worktree, |
| 1044 | settledAt: at(0), |
| 1045 | }); |
| 1046 | fixture.setSettings({ |
| 1047 | storageCleanup: { |
| 1048 | ...DEFAULT_SERVER_SETTINGS.storageCleanup, |
| 1049 | worktreeSettledAfterDays: 0, |
| 1050 | }, |
| 1051 | }); |
| 1052 | fixture.setThreads([thread]); |
| 1053 | const run = yield* fixture.service.runNow.pipe(Effect.forkChild); |
| 1054 | yield* Deferred.await(fixture.measuring); |
| 1055 | fixture.setThreads([{ ...thread, ...change }]); |
| 1056 | yield* Deferred.succeed(fixture.resume, undefined); |
| 1057 | const report = yield* Fiber.join(run); |
| 1058 | expect(report.entries[0]?.reason).toBe( |
| 1059 | "Thread activity or shared worktree changed since check", |
| 1060 | ); |
| 1061 | expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); |
| 1062 | }), |
| 1063 | ), |
| 1064 | ); |
| 1065 | }); |
| 1066 | |
| 1067 | it.live("keeps saved usage data when removing a settled checkout", () => |
| 1068 | runCleanupTest( |
| 1069 | Effect.gen(function* () { |
| 1070 | const fixture = yield* cleanupFixture(); |
| 1071 | fixture.setSettings({ |
| 1072 | storageCleanup: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeSettledAfterDays: 0 }, |
| 1073 | }); |
| 1074 | fixture.setThreads([ |
| 1075 | shell({ branch: "feature", worktreePath: fixture.worktree, settledAt: at(0) }), |
| 1076 | ]); |
| 1077 | yield* fixture.fs.makeDirectory(fixture.config.stateDir, { recursive: true }); |
| 1078 | const cachePath = `${fixture.config.stateDir}/${SCAN_CACHE_FILE_NAME}`; |
| 1079 | const savedUsage = JSON.stringify({ tokens: 12345, cost: 1.25 }); |
| 1080 | yield* fixture.fs.writeFileString(cachePath, savedUsage); |
| 1081 | expect((yield* fixture.service.runNow).counts.removed).toBe(1); |
| 1082 | expect(yield* fixture.fs.readFileString(cachePath)).toBe(savedUsage); |
| 1083 | }), |
| 1084 | ), |
| 1085 | ); |
| 1086 | |
| 1087 | it.live.each(["thread.settled", "stopped", "ready"] as const)( |
| 1088 | "leaves settled cleanup to the regular sweeps after %s", |
| 1089 | (trigger) => |
| 1090 | runCleanupTest( |
| 1091 | Effect.gen(function* () { |
| 1092 | const fixture = yield* pausedMeasurementFixture({ |
| 1093 | ...DEFAULT_SERVER_SETTINGS.storageCleanup, |
| 1094 | worktreeSettledAfterDays: 0, |
| 1095 | }); |
| 1096 | fixture.setSettings({ |
| 1097 | storageCleanup: { |
| 1098 | ...DEFAULT_SERVER_SETTINGS.storageCleanup, |
| 1099 | worktreeSettledAfterDays: 0, |
| 1100 | }, |
| 1101 | }); |
| 1102 | const thread = shell({ |
| 1103 | branch: "feature", |
| 1104 | worktreePath: fixture.worktree, |
| 1105 | settledAt: at(0), |
| 1106 | }); |
| 1107 | fixture.setThreads([thread]); |
| 1108 | const handled = yield* Deferred.make<void>(); |
| 1109 | yield* fixture.activate( |
| 1110 | Stream.fromEffect(Deferred.await(fixture.measuring)).pipe( |
| 1111 | Stream.flatMap(() => Stream.range(0, 19)), |
| 1112 | Stream.map((index): OrchestrationV2DomainEvent => |
| 1113 | trigger === "thread.settled" |
| 1114 | ? { |
| 1115 | id: EventId.make(`settled-${index}`), |
| 1116 | type: "thread.settled", |
| 1117 | threadId: thread.id, |
| 1118 | occurredAt: at(0), |
| 1119 | payload: { ...thread, lastVisitedAt: null }, |
| 1120 | } |
| 1121 | : sessionUpdated(index, trigger), |
| 1122 | ), |
| 1123 | Stream.concat( |
| 1124 | Stream.fromEffect(Deferred.succeed(handled, undefined)).pipe(Stream.drain), |
| 1125 | ), |
| 1126 | ), |
| 1127 | ); |
| 1128 | yield* Deferred.await(handled); |
| 1129 | yield* Deferred.succeed(fixture.resume, undefined); |
| 1130 | yield* fixture.service.drain; |
| 1131 | expect(fixture.snapshotReads()).toBe(1); |
| 1132 | }), |
| 1133 | ), |
| 1134 | ); |