1import {
2 GitCommandError,
3 OrchestrationV2AppThreadJson,
4 OrchestrationV2ProviderSessionJson,
5} from "@t3tools/contracts";
6import * as Schema from "effect/Schema";
7import * as SqlClient from "effect/sql/SqlClient";
8import type {
9 OrchestrationV2ThreadShell,
10 ProjectId,
11 ServerSettings,
12 ServerSettingsError,
13 TerminalSummary,
14 WorktreeCleanupRules,
15 StorageCleanupReport,
16 StorageCleanupReportEntry,
17} from "@t3tools/contracts";
18import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
19import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
20import * as Context from "effect/Context";
21import * as Config from "effect/Config";
22import * as Deferred from "effect/Deferred";
23import * as Layer from "effect/Layer";
24import * as Clock from "effect/Clock";
25import * as DateTime from "effect/DateTime";
26import * as Effect from "effect/Effect";
27import * as Equal from "effect/Equal";
28import * as FileSystem from "effect/FileSystem";
29import * as Option from "effect/Option";
30import * as Path from "effect/Path";
31import * as Ref from "effect/Ref";
32import type { PlatformError } from "effect/PlatformError";
33import * as Schedule from "effect/Schedule";
34import * as Stream from "effect/Stream";
35import * as SubscriptionRef from "effect/SubscriptionRef";
36
37import * as ServerConfig from "./config.ts";
38import * as GitManager from "./git/GitManager.ts";
39import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
40import * as Orchestrator from "./orchestration-v2/Orchestrator.ts";
41import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts";
42import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts";
43import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementService.ts";
44import { forkParked } from "./serverActivation.ts";
45import * as Settings from "./serverSettings.ts";
46import * as TerminalManager from "./terminal/Manager.ts";
47import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts";
48import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
49import { withWorkspaceLease } from "./workspace/workspaceLease.ts";
50import * as HostProcess from "@t3tools/shared/HostProcess";
51import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
52
53const decodeCleanupThread = Schema.decodeUnknownEffect(
54 Schema.fromJsonString(OrchestrationV2AppThreadJson),
55);
56const decodeCleanupSession = Schema.decodeUnknownEffect(
57 Schema.fromJsonString(OrchestrationV2ProviderSessionJson),
58);
59
60const DAY_MS = 86_400_000;
61// After a settle, the project's settle script needs time to open its
62// terminal, which then protects the worktree.
63const SETTLE_SCRIPT_GRACE_MS = 60_000;
64
65const sentence = (text: string) => text.charAt(0).toUpperCase() + text.slice(1);
66const REPORT_ENTRY_LIMIT = 200;
67const isGitCommandError = Schema.is(GitCommandError);
68
69function cleanupFailureReason(error: { readonly message: string }) {
70 return isGitCommandError(error)
71 ? `${error.command}: ${error.detail.split(/\r?\n/)[0]}${error.reason ? ` (${error.reason})` : ""}`
72 : (error.message.split(/\r?\n/)[0] ?? error.message);
73}
74
75const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) =>
76 rules.worktreeAfterDays !== null ||
77 rules.worktreeSettledAfterDays !== null ||
78 rules.worktreeOnMerge ||
79 rules.worktreeOnDelete ||
80 rules.worktreeUnchanged;
81
82function anyWorktreePolicy(
83 settings: ServerSettings,
84 predicate: (rules: WorktreeCleanupRules) => boolean,
85): boolean {
86 return (
87 predicate(resolveWorktreeCleanup(settings, null)) ||
88 Object.keys(settings.projectSettingsOverrides).some((projectId) =>
89 predicate(resolveWorktreeCleanup(settings, projectId as ProjectId)),
90 )
91 );
92}
93
94function sameProjectWorktreePolicies(left: ServerSettings, right: ServerSettings): boolean {
95 return [
96 ...new Set([
97 ...Object.keys(left.projectSettingsOverrides),
98 ...Object.keys(right.projectSettingsOverrides),
99 ]),
100 ].every((projectId) =>
101 Equal.equals(
102 left.projectSettingsOverrides[projectId as ProjectId]?.worktreeCleanup,
103 right.projectSettingsOverrides[projectId as ProjectId]?.worktreeCleanup,
104 ),
105 );
106}
107
108/** Live sessions keep their cwd even when no turn is currently running. */
109export function storageCleanupThreadIdle(thread: OrchestrationV2ThreadShell, now: number): boolean {
110 return (
111 thread.branch !== null &&
112 thread.worktreePath !== null &&
113 thread.activeRunId === null &&
114 (thread.status === "idle" ||
115 thread.status === "completed" ||
116 thread.status === "interrupted" ||
117 thread.status === "failed" ||
118 thread.status === "cancelled" ||
119 thread.status === "rolled_back") &&
120 (thread.pendingBackgroundTasks?.length ?? 0) === 0 &&
121 thread.pendingRuntimeRequest === null &&
122 !threadHasQueuedTurnStart(thread, now)
123 );
124}
125
126/** PR metadata refreshes must not reset the inactivity clock. */
127export function storageCleanupActivityAt(thread: OrchestrationV2ThreadShell): number {
128 return Math.max(
129 ...[
130 thread.createdAt,
131 thread.latestUserMessageAt,
132 thread.latestRunRequestedAt,
133 thread.latestRunStartedAt,
134 thread.latestRunCompletedAt,
135 ].flatMap((value) => (value == null ? [] : [DateTime.toEpochMillis(value)])),
136 );
137}
138
139/**
140 * Whether the host's pull request proves this worktree's head was merged. A
141 * squash or rebase merge leaves the head outside the default branch, so the
142 * merged pull request then has to name this exact commit.
143 */
144export function storageCleanupPullRequestMerged(
145 pullRequest: Pick<
146 GitManager.GitBranchPullRequest,
147 "state" | "headRef" | "baseRef" | "headSha"
148 > | null,
149 worktree: {
150 readonly branch: string;
151 readonly defaultBranch: string;
152 readonly headSha: string;
153 readonly integrated: boolean;
154 },
155): boolean {
156 return (
157 pullRequest?.state === "merged" &&
158 (worktree.integrated ||
159 (pullRequest.headRef === worktree.branch &&
160 pullRequest.baseRef === worktree.defaultBranch &&
161 pullRequest.headSha === worktree.headSha))
162 );
163}
164
165export class StorageCleanup extends Context.Service<
166 StorageCleanup,
167 {
168 readonly runNow: Effect.Effect<StorageCleanupReport, ServerSettingsError>;
169 readonly latestReport: Effect.Effect<StorageCleanupReport | null>;
170 readonly reports: Stream.Stream<StorageCleanupReport | null>;
171 readonly drain: Effect.Effect<void>;
172 }
173>()("t3/storageCleanup") {}
174
175const make = Effect.gen(function* () {
176 const config = yield* ServerConfig.ServerConfig;
177 const settingsService = yield* Settings.ServerSettingsService;
178 const projectStore = yield* ProjectStore.ProjectStoreV2;
179 const engine = yield* Orchestrator.OrchestratorV2;
180 const projections = yield* ProjectionStore.ProjectionStoreV2;
181 const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
182 const sql = yield* SqlClient.SqlClient;
183 const archiveOffload = yield* Config.Boolean("T3CODE_ARCHIVE_OFFLOAD").pipe(
184 Config.withDefault(false),
185 Effect.orDie,
186 );
187 const git = yield* GitVcsDriver.GitVcsDriver;
188 const gitManager = yield* GitManager.GitManager;
189 const terminals = yield* TerminalManager.TerminalManager;
190 const fs = yield* FileSystem.FileSystem;
191 const path = yield* Path.Path;
192 const liveTerminals = new Map<string, Map<string, TerminalSummary>>();
193 const noteTerminal = (terminal: TerminalSummary) => {
194 const threadTerminals =
195 liveTerminals.get(terminal.threadId) ?? new Map<string, TerminalSummary>();
196 threadTerminals.set(terminal.terminalId, terminal);
197 liveTerminals.set(terminal.threadId, threadTerminals);
198 };
199
200 const inside = (root: string, target: string) => {
201 const relative = path.relative(root, target);
202 return (
203 relative !== "" &&
204 relative !== ".." &&
205 !relative.startsWith(`..${path.sep}`) &&
206 !path.isAbsolute(relative)
207 );
208 };
209 const hasTerminal = (worktreePath: string) =>
210 [...liveTerminals.values()]
211 .flatMap((entries) => [...entries.values()])
212 .some((terminal) => {
213 if (terminal.status !== "starting" && terminal.status !== "running") return false;
214 const cwd = path.resolve(terminal.cwd);
215 return (
216 (terminal.worktreePath !== null &&
217 path.resolve(terminal.worktreePath) === worktreePath) ||
218 cwd === worktreePath ||
219 inside(worktreePath, cwd)
220 );
221 });
222
223 const measureWorktree = (worktreePath: string) =>
224 Effect.gen(function* () {
225 const root = yield* fs.realPath(worktreePath);
226 const pending = [root];
227 let entries = 1;
228 let bytes = 0;
229 while (pending.length > 0) {
230 const target = pending.pop()!;
231 // Effect's stat follows links. Probe with readLink first to get lstat
232 // semantics, including skipping dangling links and directory links.
233 const isLink = yield* fs.readLink(target).pipe(
234 Effect.as(true),
235 Effect.catchIf(
236 (error) =>
237 error.cause instanceof Error &&
238 "code" in error.cause &&
239 error.cause.code === "EINVAL",
240 () => Effect.succeed(false),
241 ),
242 );
243 if (isLink) continue;
244 if ((yield* fs.realPath(target)) !== target) return null;
245 const stat = yield* fs.stat(target);
246 if (stat.type === "File") bytes += Number(stat.size);
247 else if (stat.type === "Directory") {
248 const names = yield* fs.readDirectory(target);
249 entries += names.length;
250 if (entries > 2_000_000) return null;
251 for (const name of names) {
252 const child = path.join(target, name);
253 if (!inside(root, child)) return null;
254 pending.push(child);
255 }
256 }
257 }
258 return bytes;
259 }).pipe(
260 Effect.timeout("30 seconds"),
261 Effect.orElseSucceed(() => null),
262 );
263
264 // Active threads only: archived threads are not candidates and do not count
265 // as sharing a worktree. The recheck's getThreadWorktreePaths reads the same set.
266 // The snapshot takes seconds on large databases, so it runs on the reader
267 // worker instead of the event loop.
268 const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () {
269 const { threads } = yield* projections
270 .getShellSnapshot({ location: "active" })
271 .pipe(NodeSqliteClient.readOnly);
272 const projects = yield* projectStore.listShells();
273 return { projects, threads };
274 });
275
276 // Local threads under another project need not have a worktreePath of their own.
277 const containsProjectRoot = Effect.fn("StorageCleanup.containsProjectRoot")(function* (
278 worktreePath: string,
279 projects: ReadonlyArray<{ readonly workspaceRoot: string }>,
280 ) {
281 for (const project of projects) {
282 const projectPath = path.resolve(project.workspaceRoot);
283 if (projectPath === worktreePath || inside(worktreePath, projectPath)) return true;
284 const realPath = yield* fs
285 .realPath(projectPath)
286 .pipe(Effect.orElseSucceed(() => projectPath));
287 if (realPath === worktreePath || inside(worktreePath, realPath)) return true;
288 }
289 return false;
290 });
291
292 // Whether cwd stopped being a checkout of this branch. Two small Git reads,
293 // since every worktree in a sweep is asked; statusDetailsLocal also diffs and
294 // counts.
295 const branchChanged = Effect.fn("StorageCleanup.branchChanged")(function* (
296 cwd: string,
297 branch: string | null,
298 ) {
299 const failure = (detail: string) =>
300 new GitCommandError({
301 operation: "StorageCleanup.branchChanged",
302 command: "git",
303 cwd,
304 detail,
305 });
306 // Null when cwd is not a repository, or is gone. Git's own messages are
307 // matched, so they must not be translated.
308 const read = Effect.fnUntraced(function* (args: string[], allowedExitCode?: number) {
309 const result = yield* git
310 .execute({
311 operation: "StorageCleanup.branchChanged",
312 cwd,
313 args,
314 env: { LC_ALL: "C" },
315 allowNonZeroExit: true,
316 })
317 .pipe(
318 Effect.catch((error) =>
319 Effect.flatMap(fs.exists(cwd), (exists) =>
320 exists ? Effect.fail(error) : Effect.succeed(null),
321 ),
322 ),
323 );
324 if (result === null) return null;
325 if (result.exitCode === 0 || result.exitCode === allowedExitCode) return result;
326 if (result.stderr.toLowerCase().includes("not a git repository")) return null;
327 return yield* failure(
328 result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed",
329 );
330 });
331 const index = yield* read(["rev-parse", "--git-path", "index"]);
332 if (index === null) return true;
333 // Another Git process is writing to this checkout.
334 if (yield* fs.exists(`${path.resolve(cwd, index.stdout.trim())}.lock`))
335 return yield* failure(
336 "Git index is locked. Status will resume when the index lock is removed.",
337 );
338 // Exits 1 for a detached HEAD. Reads the full ref name, which is never
339 // shortened to "heads/<branch>" when a tag shares the branch name.
340 const head = yield* read(["symbolic-ref", "--quiet", "HEAD"], 1);
341 if (head === null) return true;
342 const ref = head.exitCode === 0 ? head.stdout.trim() : "";
343 const name = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length).trim() : null;
344 // Status output writes a detached HEAD as "(detached)", so a branch named
345 // with a leading "(" has always been read as one.
346 return (name?.startsWith("(") ? null : name) !== branch;
347 });
348
349 const localChangesReason = Effect.fn("StorageCleanup.localChangesReason")(function* (
350 cwd: string,
351 rules: WorktreeCleanupRules,
352 ) {
353 const status = yield* git.execute({
354 operation: "StorageCleanup.localChanges",
355 cwd,
356 args: [
357 "status",
358 "--porcelain=v1",
359 "-z",
360 "--ignore-submodules=none",
361 rules.worktreeKeepWhen === "tracked-changes"
362 ? "--untracked-files=no"
363 : "--untracked-files=all",
364 ],
365 maxOutputBytes: 64 * 1024,
366 });
367 if (status.stdoutTruncated) return "local changes exceed the inspection limit";
368 const records = status.stdout.split("\0").filter(Boolean);
369 let count = 0;
370 for (let i = 0; i < records.length; i++) {
371 count++;
372 if (/^[RC]|^.[RC]/.test(records[i]!)) i++;
373 }
374 if (count > 0) return `has uncommitted changes (${count} ${count === 1 ? "file" : "files"})`;
375 if (rules.worktreeKeepWhen !== "any-local-files") return null;
376 const ignored = yield* git.execute({
377 operation: "StorageCleanup.ignoredFiles",
378 cwd,
379 args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"],
380 maxOutputBytes: 64 * 1024,
381 });
382 if (ignored.stdoutTruncated) return "ignored files exceed the inspection limit";
383 const file = ignored.stdout
384 .split("\0")
385 .find((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry));
386 return file === undefined ? null : `has ignored files (${file})`;
387 });
388
389 const cleanupGit = Effect.fn("StorageCleanup.cleanupGit")(function* (
390 cwd: string,
391 args: string[],
392 command: string,
393 ) {
394 const result = yield* git.execute({
395 operation: "StorageCleanup.cleanupGit",
396 cwd,
397 args,
398 allowNonZeroExit: true,
399 timeoutMs: 300_000,
400 });
401 if (result.exitCode !== 0)
402 return yield* Effect.fail(
403 new GitCommandError({
404 operation: "StorageCleanup.cleanupGit",
405 command,
406 cwd,
407 detail: result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed",
408 }),
409 );
410 });
411
412 const cleanWorktrees = Effect.fn("StorageCleanup.cleanWorktrees")(function* (
413 serverSettings: ServerSettings,
414 now: number,
415 entries: StorageCleanupReportEntry[],
416 ) {
417 const roots: Array<string> = [];
418 for (const directory of managedWorktreesDirectories(
419 serverSettings,
420 config.worktreesDir,
421 path,
422 yield* HostProcess.HomeDirectory,
423 )) {
424 // An unmounted drive only skips its own worktrees.
425 const root = yield* fs.exists(directory).pipe(
426 Effect.flatMap((exists) => (exists ? fs.realPath(directory) : Effect.succeed(null))),
427 Effect.orElseSucceed(() => null),
428 );
429 if (root !== null && !isFilesystemRoot(root, path)) roots.push(root);
430 }
431 const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>`
432 SELECT t.payload_json, p.workspace_root AS "workspaceRoot"
433 FROM orchestration_v2_projection_threads t
434 JOIN projection_projects p ON p.project_id = t.project_id
435 WHERE t.deleted_at IS NOT NULL
436 `;
437 const deletedThreads = (yield* Effect.forEach(deletedRows, (row) =>
438 decodeCleanupThread(row.payload_json).pipe(
439 Effect.map((thread) => ({ ...thread, workspaceRoot: row.workspaceRoot })),
440 ),
441 )).filter((thread) => thread.worktreePath !== null && thread.branch !== null);
442 const snapshot = yield* readThreads();
443 const refreshedDefaultRefs = new Map<string, Set<string>>();
444 const groups = Map.groupBy(
445 snapshot.threads.filter((thread) => thread.worktreePath !== null),
446 (thread) => path.resolve(thread.worktreePath!),
447 );
448 const candidates = [
449 ...[...groups.values()].map((group) => group[0]!),
450 ...deletedThreads.filter((thread) => !groups.has(path.resolve(thread.worktreePath!))),
451 ];
452 for (const thread of candidates) {
453 const settings = resolveWorktreeCleanup(serverSettings, thread.projectId);
454 if (!worktreeCleanupEnabled(settings)) continue;
455 const worktreePath = path.resolve(thread.worktreePath!);
456 const deleted = "workspaceRoot" in thread;
457 const project = deleted
458 ? { workspaceRoot: thread.workspaceRoot }
459 : snapshot.projects.find((entry) => entry.id === thread.projectId);
460 const entry: StorageCleanupReportEntry = {
461 kind: "worktree",
462 outcome: "kept",
463 reason: "",
464 path: worktreePath,
465 threadId: thread.id,
466 threadTitle: thread.title,
467 bytes: null,
468 files: null,
469 };
470 const keep = (reason: string) => entries.push({ ...entry, reason: sentence(reason) });
471 yield* Effect.gen(function* () {
472 if (!(yield* fs.exists(worktreePath))) return;
473 if (deleted && !settings.worktreeOnDelete) return keep("no rules apply");
474 const shared = groups.get(worktreePath)?.length ?? 0;
475 if (shared > 1) return keep(`shared by ${shared} threads`);
476 if (project === undefined) return keep("project is unavailable");
477 if (!deleted && !storageCleanupThreadIdle(thread, now))
478 return keep("thread is running or has pending work");
479 if (hasTerminal(worktreePath)) return keep("open terminal");
480 // Roots are canonical, so compare canonical paths. A symlinked parent
481 // (a linked drive) is fine; a symlinked worktree directory is not.
482 const realPath = yield* fs.realPath(worktreePath);
483 const realParent = yield* fs.realPath(path.dirname(worktreePath));
484 if (realPath !== path.join(realParent, path.basename(worktreePath)))
485 return keep("worktree is a symbolic link");
486 if (!roots.some((root) => inside(root, realPath)))
487 return keep("outside the managed worktree folder");
488 if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects]))
489 return keep("contains a project checkout");
490 // A linked worktree has a .git file. Never remove a main checkout.
491 if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File")
492 return keep("not a linked worktree");
493 if (yield* branchChanged(worktreePath, thread.branch))
494 return keep("repository or branch changed");
495 const changes = yield* localChangesReason(worktreePath, settings);
496 if (changes !== null) return keep(changes);
497 // The project's settle script starts in the worktree right after a
498 // settle. Settling stamps updatedAt now, even when automatic
499 // settlement backdates settledAt to the thread's last activity.
500 if (
501 !deleted &&
502 thread.settledAt !== null &&
503 now - DateTime.toEpochMillis(thread.updatedAt) < SETTLE_SCRIPT_GRACE_MS
504 )
505 return keep("settled moments ago");
506 const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" });
507 const old =
508 !deleted &&
509 settings.worktreeAfterDays !== null &&
510 storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS;
511 const settledFor =
512 deleted || thread.settledAt === null
513 ? null
514 : now - DateTime.toEpochMillis(thread.settledAt);
515 const settledDays = settledFor === null ? null : Math.floor(settledFor / DAY_MS);
516 const settled =
517 settledFor !== null &&
518 settings.worktreeSettledAfterDays !== null &&
519 settledFor >= settings.worktreeSettledAfterDays * DAY_MS;
520 let eligible = deleted || old || settled;
521 let removalReason = deleted
522 ? "thread was deleted"
523 : old
524 ? `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} days`
525 : `settled for ${settledDays} days`;
526 if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) {
527 const repositoryCwd = path.resolve(project.workspaceRoot);
528 const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd);
529 const branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote);
530 if (branch === null) return keep("default branch is unavailable");
531 const defaultRef = `refs/remotes/${remote}/${branch}`;
532 const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set<string>();
533 if (!refreshed.has(defaultRef)) {
534 yield* git.fetchRemoteTrackingBranch({
535 cwd: repositoryCwd,
536 remoteName: remote,
537 remoteBranch: branch,
538 });
539 refreshed.add(defaultRef);
540 refreshedDefaultRefs.set(repositoryCwd, refreshed);
541 }
542 const base = yield* git.resolveCommit({
543 cwd: worktreePath,
544 revision: defaultRef,
545 });
546 const ancestor = yield* git.execute({
547 operation: "StorageCleanup.integratedBranch",
548 cwd: worktreePath,
549 args: ["merge-base", "--is-ancestor", head.commitSha, base.commitSha],
550 allowNonZeroExit: true,
551 });
552 const integrated = ancestor.exitCode === 0;
553 eligible = integrated && settings.worktreeUnchanged;
554 if (eligible) removalReason = "no commits beyond the default branch";
555 if (!eligible && settings.worktreeOnMerge && thread.branch !== null) {
556 const pullRequest = yield* gitManager.branchPullRequest(
557 { cwd: worktreePath, branch: thread.branch },
558 { refresh: true },
559 );
560 eligible = storageCleanupPullRequestMerged(pullRequest, {
561 branch: thread.branch,
562 defaultBranch: branch,
563 headSha: head.commitSha,
564 integrated,
565 });
566 if (eligible) removalReason = "pull request was merged";
567 }
568 }
569 if (!eligible)
570 return keep(
571 !deleted && settings.worktreeAfterDays !== null
572 ? `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} of ${settings.worktreeAfterDays} days`
573 : settings.worktreeSettledAfterDays !== null
574 ? settledFor === null
575 ? "not settled"
576 : `settled for ${settledDays} of ${settings.worktreeSettledAfterDays} days`
577 : settings.worktreeOnMerge
578 ? "not merged"
579 : "has commits beyond the default branch",
580 );
581 // Threads, queued deletion work and provider sessions as they are now, so a
582 // queued turn, resumed session or new thread sharing this path cancels
583 // the removal. Read only this path and this thread: a full snapshot per
584 // worktree takes seconds on large databases.
585 const blockedSinceCheck = Effect.fnUntraced(function* () {
586 if (archiveOffload) {
587 const pendingArchive =
588 yield* sql`SELECT 1 FROM orchestration_v2_projection_threads t WHERE t.archived_at IS NOT NULL AND t.deleted_at IS NULL AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath} AND NOT EXISTS (SELECT 1 FROM transcript_archives a WHERE a.thread_id = t.thread_id) LIMIT 1`;
589 if (pendingArchive.length) return "transcript archive is not verified";
590 }
591 const latestProjects = yield* projectStore.listShells();
592 if (yield* containsProjectRoot(worktreePath, [project, ...latestProjects]))
593 return "contains a project checkout";
594 const latest = (yield* projections.getThreadWorktreePaths()).filter(
595 (entry) => path.resolve(entry.worktreePath) === worktreePath,
596 );
597 if (hasTerminal(worktreePath)) return "open terminal";
598 if (deleted) {
599 if (
600 latest.length > 0 ||
601 !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId)
602 .worktreeOnDelete
603 )
604 return "thread or cleanup settings changed since check";
605 // V2 deletion queues durable cleanup. Do not remove its checkout until
606 // every effect has finished successfully or was explicitly cancelled.
607 const pendingCleanup = yield* sql`
608 SELECT 1 FROM orchestration_v2_effect_outbox
609 WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1
610 `;
611 if (pendingCleanup.length > 0) return "thread deletion is still pending";
612 } else {
613 const latestShell =
614 latest.length === 1 && latest[0]!.threadId === thread.id
615 ? yield* projections.getThreadShell(thread.id)
616 : null;
617 if (
618 latestShell === null ||
619 !storageCleanupThreadIdle(latestShell, now) ||
620 storageCleanupActivityAt(latestShell) !== storageCleanupActivityAt(thread) ||
621 !Equal.equals(latestShell.settledAt, thread.settledAt)
622 )
623 return "thread activity or shared worktree changed since check";
624 }
625 // Sessions can outlive their run and can be shared across app threads.
626 const sessionRows = yield* sql<{ payload_json: string }>`
627 SELECT payload_json FROM orchestration_v2_projection_provider_sessions
628 WHERE status != 'stopped'
629 `;
630 const sessions = yield* Effect.forEach(sessionRows, (row) =>
631 decodeCleanupSession(row.payload_json),
632 );
633 // A stored session can stay ready after its runtime was released,
634 // for example once every thread detached; only a live one holds it.
635 for (const session of sessions) {
636 const cwd = path.resolve(session.cwd);
637 if (
638 (cwd === worktreePath || inside(worktreePath, cwd)) &&
639 (yield* providerSessions.isLive(session.id))
640 )
641 return "provider session is still open";
642 }
643 return null;
644 });
645 // Most cancelled removals are caught here, before the worktree is walked.
646 const blocked = yield* blockedSinceCheck();
647 if (blocked !== null) return keep(blocked);
648 const bytes = yield* measureWorktree(worktreePath);
649 // The walk can take a while; ask again for anything that started during it.
650 const blockedWhileMeasuring = yield* blockedSinceCheck();
651 if (blockedWhileMeasuring !== null) return keep(blockedWhileMeasuring);
652 if (yield* branchChanged(worktreePath, thread.branch))
653 return keep("repository or branch changed since check");
654 if (
655 (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !==
656 head.commitSha
657 )
658 return keep("commit changed since check");
659 const finalChanges = yield* localChangesReason(worktreePath, settings);
660 if (finalChanges !== null) return keep(finalChanges);
661 const current = resolveWorktreeCleanup(
662 yield* settingsService.getSettings,
663 thread.projectId,
664 );
665 if (
666 Object.keys(settings).some(
667 (key) =>
668 current[key as keyof typeof settings] !== settings[key as keyof typeof settings],
669 )
670 )
671 return keep("settings changed since check");
672 // Clean only untracked files; Git must still refuse removal if a tracked
673 // edit arrives after our last status check.
674 if (settings.worktreeKeepWhen === "tracked-changes")
675 yield* cleanupGit(worktreePath, ["clean", "-ffdx"], "git clean");
676 yield* cleanupGit(
677 project.workspaceRoot,
678 ["-c", "status.showUntrackedFiles=normal", "worktree", "remove", worktreePath],
679 "git worktree remove",
680 );
681 entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes });
682 yield* gitManager.invalidateStatus(project.workspaceRoot);
683 // Preserve branch and path: ProviderTurnStartService recreates the checkout
684 // from that branch when the thread is resumed.
685 yield* Effect.logInfo("storage cleanup removed worktree", { threadId: thread.id });
686 }).pipe(
687 (effect) => withWorkspaceLease(worktreePath, effect),
688 Effect.catch((error) =>
689 Effect.gen(function* () {
690 entries.push({
691 ...entry,
692 outcome: "failed",
693 reason: cleanupFailureReason(error),
694 });
695 yield* Effect.logWarning("storage cleanup failed for worktree", {
696 threadId: thread.id,
697 error,
698 });
699 }),
700 ),
701 );
702 }
703 });
704
705 const cleanFiles = Effect.fn("StorageCleanup.cleanFiles")(function* (
706 root: string,
707 days: number | null,
708 now: number,
709 rotatedLogs: boolean,
710 // Owned by the caller so files removed before a failure still count.
711 removed: { files: number; bytes: number },
712 ) {
713 if (days === null || !(yield* fs.exists(root))) return removed;
714 const realRoot = yield* fs.realPath(root);
715 if (realRoot !== path.resolve(root)) return removed;
716 const visit = Effect.fn("StorageCleanup.visitFiles")(function* (
717 directory: string,
718 ): Effect.fn.Return<void, PlatformError | ServerSettingsError> {
719 for (const name of yield* fs.readDirectory(directory)) {
720 const target = path.join(directory, name);
721 if ((yield* fs.realPath(target)) !== target || !inside(realRoot, target)) continue;
722 const stat = yield* fs.stat(target);
723 if (stat.type === "Directory" && rotatedLogs) {
724 yield* visit(target);
725 } else if (stat.type === "File" && (!rotatedLogs || /\.(?:log|ndjson)\.\d+$/.test(name))) {
726 const modified = Option.getOrNull(stat.mtime);
727 if (modified !== null && modified.getTime() < now - days * DAY_MS) {
728 const current = (yield* settingsService.getSettings).storageCleanup;
729 if ((rotatedLogs ? current.logsAfterDays : current.browserArtifactsAfterDays) !== days)
730 return;
731 yield* fs.remove(target);
732 removed.files++;
733 removed.bytes += Number(stat.size);
734 }
735 }
736 }
737 });
738 yield* visit(realRoot);
739 return removed;
740 });
741
742 const reportRef = yield* SubscriptionRef.make<StorageCleanupReport | null>(null);
743 const sweep = Effect.fn("StorageCleanup.sweep")(function* (
744 trigger: StorageCleanupReport["trigger"],
745 ) {
746 const now = yield* Clock.currentTimeMillis;
747 const serverSettings = yield* settingsService.getSettings;
748 const settings = serverSettings.storageCleanup;
749 const entries: StorageCleanupReportEntry[] = [];
750 yield* cleanWorktrees(serverSettings, now, entries).pipe(
751 Effect.catch((error) =>
752 Effect.gen(function* () {
753 entries.push({
754 kind: "worktree",
755 outcome: "failed",
756 reason: cleanupFailureReason(error),
757 path: null,
758 threadId: null,
759 threadTitle: null,
760 bytes: null,
761 files: null,
762 });
763 yield* Effect.logWarning("worktree cleanup failed", { error });
764 }),
765 ),
766 );
767 for (const category of [
768 {
769 kind: "browser-artifacts" as const,
770 root: config.browserArtifactsDir,
771 days: settings.browserArtifactsAfterDays,
772 label: "browser artifacts",
773 },
774 {
775 kind: "logs" as const,
776 root: config.logsDir,
777 days: settings.logsAfterDays,
778 label: "rotated logs",
779 },
780 ]) {
781 if (category.days === null) continue;
782 const removed = { files: 0, bytes: 0 };
783 yield* cleanFiles(category.root, category.days, now, category.kind === "logs", removed).pipe(
784 Effect.map(({ files, bytes }) =>
785 entries.push({
786 kind: category.kind,
787 outcome: files > 0 ? "removed" : "kept",
788 reason: files === 0 ? "No expired files" : `Removed ${files} ${category.label}`,
789 path: null,
790 threadId: null,
791 threadTitle: null,
792 bytes: files > 0 ? bytes : null,
793 files: files > 0 ? files : null,
794 }),
795 ),
796 Effect.catch((error) =>
797 Effect.gen(function* () {
798 entries.push({
799 kind: category.kind,
800 outcome: "failed",
801 reason: cleanupFailureReason(error),
802 path: null,
803 threadId: null,
804 threadTitle: null,
805 bytes: removed.files > 0 ? removed.bytes : null,
806 files: removed.files > 0 ? removed.files : null,
807 });
808 yield* Effect.logWarning("storage file cleanup failed", { kind: category.kind, error });
809 }),
810 ),
811 );
812 }
813 const counts = { removed: 0, kept: 0, failed: 0 };
814 let bytesFreed = 0;
815 for (const entry of entries) {
816 counts[entry.outcome]++;
817 bytesFreed += entry.bytes ?? 0;
818 }
819 const priority = { failed: 0, removed: 1, kept: 2 };
820 const latestReport: StorageCleanupReport = {
821 trigger,
822 startedAt: DateTime.formatIso(DateTime.makeUnsafe(now)),
823 finishedAt: DateTime.formatIso(yield* DateTime.now),
824 entries: entries
825 .sort((a, b) => priority[a.outcome] - priority[b.outcome])
826 .slice(0, REPORT_ENTRY_LIMIT),
827 counts,
828 bytesFreed,
829 omittedCount: Math.max(0, entries.length - REPORT_ENTRY_LIMIT),
830 };
831 yield* SubscriptionRef.set(reportRef, latestReport);
832 return latestReport;
833 });
834 // At most one automatic sweep waits at a time. An automatic request made
835 // while one is waiting joins it, since that sweep has not read any state yet.
836 // Each manual run still gets its own sweep and report.
837 const automaticSweepQueued = yield* Ref.make(false);
838 const worker = yield* makeDrainableWorker(
839 (completion: Deferred.Deferred<StorageCleanupReport, ServerSettingsError> | undefined) =>
840 (completion === undefined ? Ref.set(automaticSweepQueued, false) : Effect.void).pipe(
841 Effect.andThen(sweep(completion === undefined ? "automatic" : "manual")),
842 Effect.exit,
843 Effect.flatMap((exit) =>
844 completion === undefined
845 ? exit.pipe(
846 Effect.asVoid,
847 Effect.catchCause((cause) =>
848 Effect.logWarning("storage cleanup failed", { cause }),
849 ),
850 )
851 : Deferred.done(completion, exit).pipe(Effect.asVoid),
852 ),
853 ),
854 );
855 const requestSweep = Ref.getAndSet(automaticSweepQueued, true).pipe(
856 Effect.flatMap((queued) => (queued ? Effect.void : worker.enqueue(undefined))),
857 );
858 const runNow = Effect.gen(function* () {
859 const completion = yield* Deferred.make<StorageCleanupReport, ServerSettingsError>();
860 yield* worker.enqueue(completion);
861 return yield* Deferred.await(completion);
862 });
863
864 const start = Effect.fn("StorageCleanup.start")(function* () {
865 const unsubscribe = yield* terminals.subscribeMetadata((event) =>
866 Effect.sync(() => {
867 if (event.type === "snapshot") {
868 liveTerminals.clear();
869 for (const terminal of event.terminals) noteTerminal(terminal);
870 } else if (event.type === "upsert") {
871 noteTerminal(event.terminal);
872 } else {
873 const threadTerminals = liveTerminals.get(event.threadId);
874 threadTerminals?.delete(event.terminalId);
875 if (threadTerminals?.size === 0) liveTerminals.delete(event.threadId);
876 }
877 }),
878 );
879 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
880 const changes = yield* settingsService.subscribeChanges;
881 const events = engine.streamDomainEvents;
882 let lastSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
883 yield* forkParked(
884 requestSweep.pipe(
885 Effect.andThen(worker.drain),
886 Effect.repeat(Schedule.spaced("1 hour")),
887 Effect.asVoid,
888 ),
889 );
890 yield* forkParked(
891 Stream.runForEach(changes, (settings) => {
892 if (
893 Equal.equals(settings.storageCleanup, lastSettings.storageCleanup) &&
894 Equal.equals(settings.worktreeCleanup, lastSettings.worktreeCleanup) &&
895 sameProjectWorktreePolicies(settings, lastSettings)
896 )
897 return Effect.void;
898 lastSettings = settings;
899 return requestSweep;
900 }),
901 );
902 yield* forkParked(
903 // A deleted thread's worktree waits for its sessions to end. Moving
904 // between live statuses, such as running to ready, cannot free one.
905 Stream.runForEach(events, (event) =>
906 (event.type === "thread.deleted" ||
907 (event.type === "provider-session.updated" &&
908 (event.payload.status === "stopped" || event.payload.status === "error"))) &&
909 anyWorktreePolicy(lastSettings, (rules) => rules.worktreeOnDelete)
910 ? requestSweep
911 : Effect.void,
912 ).pipe(
913 Effect.catchCause((cause) =>
914 Effect.logWarning("Storage cleanup event stream failed", { cause }),
915 ),
916 ),
917 );
918 });
919 yield* start();
920 return StorageCleanup.of({
921 runNow,
922 latestReport: SubscriptionRef.get(reportRef),
923 reports: SubscriptionRef.changes(reportRef),
924 drain: worker.drain,
925 });
926});
927
928export const layer = Layer.effect(StorageCleanup, make);