1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as Effect from "effect/Effect";
3import * as Context from "effect/Context";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Deferred from "effect/Deferred";
6import * as Fiber from "effect/Fiber";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import type * as Path from "effect/Path";
10import type * as Scope from "effect/Scope";
11import * as Stream from "effect/Stream";
12import * as Schema from "effect/Schema";
13import { SCAN_CACHE_FILE_NAME } from "./usage/usageScanCache.ts";
14import { TestClock } from "effect/testing";
15import * as SqlClient from "effect/sql/SqlClient";
16import {
17 DEFAULT_SERVER_SETTINGS,
18 StorageCleanupReport,
19 type WorktreeKeepWhen,
20 type StorageCleanupSettings,
21} from "@t3tools/contracts";
22import * as ServerConfig from "./config.ts";
23import * as StorageCleanup from "./storageCleanup.ts";
24import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
25import * as GitManager from "./git/GitManager.ts";
26import * as Settings from "./serverSettings.ts";
27import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts";
28import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts";
29import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
30import * as Orchestrator from "./orchestration-v2/Orchestrator.ts";
31import * as TerminalManager from "./terminal/Manager.ts";
32import { CodexProviderCapabilitiesV2 } from "./orchestration-v2/Adapters/CodexAdapterV2.ts";
33import { ServerActivation } from "./serverActivation.ts";
34import { describe, expect, it } from "@effect/vitest";
35import {
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";
48import * as DateTime from "effect/DateTime";
49import {
50 storageCleanupActivityAt,
51 storageCleanupPullRequestMerged,
52 storageCleanupThreadIdle,
53} from "./storageCleanup.ts";
54
55const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z");
56const DAY_MS = 24 * 60 * 60 * 1_000;
57const decodeCleanupReport = Schema.decodeSync(StorageCleanupReport);
58
59function at(offsetMs: number): DateTime.Utc {
60 return DateTime.makeUnsafe(NOW_MS + offsetMs);
61}
62
63function 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
112describe("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
193describe("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
239const 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. */
435const 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 });
457const 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});
478const cleanupTestLayer = GitVcsDriver.layer.pipe(
479 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })),
480 Layer.provideMerge(NodeServices.layer),
481);
482const 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
494describe("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
937describe("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
1034describe("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
1067it.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
1087it.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);