diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index a8fb530d3f05..5488b366c246 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -24,6 +24,7 @@ import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as TerminalManager from "./terminal/Manager.ts"; +import { CodexProviderCapabilitiesV2 } from "./orchestration-v2/Adapters/CodexAdapterV2.ts"; import { ServerActivation } from "./serverActivation.ts"; import { describe, expect, it } from "@effect/vitest"; import { @@ -248,6 +249,8 @@ const cleanupFixture = Effect.gen(function* () { }; let threads = [shell({ branch: "feature", worktreePath: worktree })]; let editBeforeRemoval = false; + let openSessionCwd: string | null = null; + let fullStatusCalls = 0; const context = yield* Layer.build(StorageCleanup.layer).pipe( Effect.provideService(GitVcsDriver.GitVcsDriver, { ...git, @@ -257,6 +260,10 @@ const cleanupFixture = Effect.gen(function* () { yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); return yield* git.execute(input); }), + statusDetailsLocal: (...args) => { + fullStatusCalls++; + return git.statusDetailsLocal(...args); + }, }), Effect.provideService(Settings.ServerSettingsService, { getSettings: Effect.sync(() => settings), @@ -272,8 +279,27 @@ const cleanupFixture = Effect.gen(function* () { Effect.provideService(Orchestrator.OrchestratorV2, { streamDomainEvents: Stream.empty, } as unknown as Orchestrator.OrchestratorV2["Service"]), - Effect.provideService(SqlClient.SqlClient, (() => - Effect.succeed([])) as unknown as SqlClient.SqlClient), + Effect.provideService(SqlClient.SqlClient, ((strings: TemplateStringsArray) => + Effect.sync(() => + openSessionCwd !== null && strings.join("").includes("projection_provider_sessions") + ? [ + { + payload_json: JSON.stringify({ + id: "session-1", + driver: "codex", + providerInstanceId: "codex", + status: "ready", + cwd: openSessionCwd, + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: "2026-06-01T12:00:00.000Z", + updatedAt: "2026-06-01T12:00:00.000Z", + lastError: null, + }), + }, + ] + : [], + )) as unknown as SqlClient.SqlClient), Effect.provideService(GitManager.GitManager, { invalidateStatus: () => Effect.void, } as unknown as GitManager.GitManager["Service"]), @@ -313,6 +339,10 @@ const cleanupFixture = Effect.gen(function* () { setThreads: (next: typeof threads) => { threads = next; }, + setOpenSessionCwd: (cwd: string | null) => { + openSessionCwd = cwd; + }, + fullStatusCalls: () => fullStatusCalls, }; }); const cleanupTestLayer = GitVcsDriver.layer.pipe( @@ -413,6 +443,73 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("does not measure a worktree kept for an open provider session", () => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const walked: Array = []; + const fixture = yield* cleanupFixture.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readDirectory: (directory, options) => { + walked.push(directory); + return fs.readDirectory(directory, options); + }, + }), + ); + const root = yield* fs.realPath(fixture.worktree); + fixture.setOpenSessionCwd(fixture.worktree); + const kept = yield* fixture.service.runNow; + expect(kept.entries[0]).toMatchObject({ + outcome: "kept", + reason: "Provider session is still open", + bytes: null, + }); + expect(walked.filter((directory) => directory.startsWith(root))).toEqual([]); + fixture.setOpenSessionCwd(null); + const removed = yield* fixture.service.runNow; + expect(removed.entries[0]).toMatchObject({ outcome: "removed" }); + expect(removed.entries[0]?.bytes).toBeGreaterThan(0); + expect(walked).toContain(root); + }), + ), + ); + it.live("compares the checked-out branch without a full status", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, command, setThreads, fullStatusCalls } = + yield* cleanupFixture; + const changed = { outcome: "kept", reason: "Repository or branch changed" }; + yield* command(worktree, ["checkout", "--detach"]); + expect((yield* service.runNow).entries[0]).toMatchObject(changed); + // A branch that has no commit yet is still a different branch. + yield* command(worktree, ["switch", "--orphan", "other"]); + expect((yield* service.runNow).entries[0]).toMatchObject(changed); + // Status output reserves a leading "(" for a detached HEAD. + yield* command(worktree, ["switch", "-c", "(feature)", "feature"]); + setThreads([shell({ branch: "(feature)", worktreePath: worktree })]); + expect((yield* service.runNow).entries[0]).toMatchObject(changed); + setThreads([shell({ branch: "feature", worktreePath: worktree })]); + // Tags named after the branch or HEAD make Git's short names ambiguous. + yield* command(worktree, ["tag", "feature", "feature"]); + // Newer Git refuses to create a tag named HEAD, but still reads one. + yield* command(worktree, ["update-ref", "refs/tags/HEAD", "refs/heads/feature"]); + yield* command(worktree, ["switch", "feature"]); + const index = (yield* command(worktree, ["rev-parse", "--git-path", "index"])).stdout; + yield* fs.writeFileString(`${index.trim()}.lock`, ""); + const locked = (yield* service.runNow).entries[0]; + expect(locked).toMatchObject({ outcome: "failed" }); + expect(locked?.reason).toContain("Git index is locked"); + yield* fs.remove(`${index.trim()}.lock`); + const gitFile = yield* fs.readFileString(`${worktree}/.git`); + yield* fs.writeFileString(`${worktree}/.git`, "gitdir: /missing-git-directory\n"); + expect((yield* service.runNow).entries[0]).toMatchObject(changed); + yield* fs.writeFileString(`${worktree}/.git`, gitFile); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(fullStatusCalls()).toBe(0); + }), + ), + ); it("decodes reports from servers without size fields", () => { const report = decodeCleanupReport({ trigger: "manual", diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 481abe7da8cc..60b37521c0d0 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -271,6 +271,63 @@ const make = Effect.gen(function* () { return false; }); + // Whether cwd stopped being a checkout of this branch. Two small Git reads, + // since every worktree in a sweep is asked; statusDetailsLocal also diffs and + // counts. + const branchChanged = Effect.fn("StorageCleanup.branchChanged")(function* ( + cwd: string, + branch: string | null, + ) { + const failure = (detail: string) => + new GitCommandError({ + operation: "StorageCleanup.branchChanged", + command: "git", + cwd, + detail, + }); + // Null when cwd is not a repository, or is gone. Git's own messages are + // matched, so they must not be translated. + const read = Effect.fnUntraced(function* (args: string[], allowedExitCode?: number) { + const result = yield* git + .execute({ + operation: "StorageCleanup.branchChanged", + cwd, + args, + env: { LC_ALL: "C" }, + allowNonZeroExit: true, + }) + .pipe( + Effect.catch((error) => + Effect.flatMap(fs.exists(cwd), (exists) => + exists ? Effect.fail(error) : Effect.succeed(null), + ), + ), + ); + if (result === null) return null; + if (result.exitCode === 0 || result.exitCode === allowedExitCode) return result; + if (result.stderr.toLowerCase().includes("not a git repository")) return null; + return yield* failure( + result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", + ); + }); + const index = yield* read(["rev-parse", "--git-path", "index"]); + if (index === null) return true; + // Another Git process is writing to this checkout. + if (yield* fs.exists(`${path.resolve(cwd, index.stdout.trim())}.lock`)) + return yield* failure( + "Git index is locked. Status will resume when the index lock is removed.", + ); + // Exits 1 for a detached HEAD. Reads the full ref name, which is never + // shortened to "heads/" when a tag shares the branch name. + const head = yield* read(["symbolic-ref", "--quiet", "HEAD"], 1); + if (head === null) return true; + const ref = head.exitCode === 0 ? head.stdout.trim() : ""; + const name = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length).trim() : null; + // Status output writes a detached HEAD as "(detached)", so a branch named + // with a leading "(" has always been read as one. + return (name?.startsWith("(") ? null : name) !== branch; + }); + const localChangesReason = Effect.fn("StorageCleanup.localChangesReason")(function* ( cwd: string, rules: WorktreeCleanupRules, @@ -415,8 +472,7 @@ const make = Effect.gen(function* () { // A linked worktree has a .git file. Never remove a main checkout. if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") return keep("not a linked worktree"); - const status = yield* git.statusDetailsLocal(worktreePath); - if (!status.isRepo || status.branch !== thread.branch) + if (yield* branchChanged(worktreePath, thread.branch)) return keep("repository or branch changed"); const changes = yield* localChangesReason(worktreePath, settings); if (changes !== null) return keep(changes); @@ -480,55 +536,64 @@ const make = Effect.gen(function* () { ? "not merged" : "has commits beyond the default branch", ); - const bytes = yield* measureWorktree(worktreePath); - // Re-read after Git/host calls and size measurement so a queued turn, - // resumed session or new thread sharing this path cancels the removal. - const latestSnapshot = yield* readThreads(); - if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) - return keep("contains a project checkout"); - const latest = latestSnapshot.threads.filter( - (entry) => - entry.worktreePath !== null && path.resolve(entry.worktreePath) === worktreePath, - ); - if (hasTerminal(worktreePath)) return keep("open terminal"); - if (deleted) { - if ( - latest.length > 0 || - !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) - .worktreeOnDelete + // Threads, queued deletion work and provider sessions as they are now, so a + // queued turn, resumed session or new thread sharing this path cancels + // the removal. + const blockedSinceCheck = Effect.fnUntraced(function* () { + const latestSnapshot = yield* readThreads(); + if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) + return "contains a project checkout"; + const latest = latestSnapshot.threads.filter( + (entry) => + entry.worktreePath !== null && path.resolve(entry.worktreePath) === worktreePath, + ); + if (hasTerminal(worktreePath)) return "open terminal"; + if (deleted) { + if ( + latest.length > 0 || + !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) + .worktreeOnDelete + ) + return "thread or cleanup settings changed since check"; + // V2 deletion queues durable cleanup. Do not remove its checkout until + // every effect has finished successfully or was explicitly cancelled. + const pendingCleanup = yield* sql` + SELECT 1 FROM orchestration_v2_effect_outbox + WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 + `; + if (pendingCleanup.length > 0) return "thread deletion is still pending"; + } else if ( + latest.length !== 1 || + latest[0]!.id !== thread.id || + !storageCleanupThreadIdle(latest[0]!, now) || + storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) ) - return keep("thread or cleanup settings changed since check"); - // V2 deletion queues durable cleanup. Do not remove its checkout until - // every effect has finished successfully or was explicitly cancelled. - const pendingCleanup = yield* sql` - SELECT 1 FROM orchestration_v2_effect_outbox - WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 + return "thread activity or shared worktree changed since check"; + // Sessions can outlive their run and can be shared across app threads. + const sessionRows = yield* sql<{ payload_json: string }>` + SELECT payload_json FROM orchestration_v2_projection_provider_sessions + WHERE status != 'stopped' `; - if (pendingCleanup.length > 0) return keep("thread deletion is still pending"); - } else if ( - latest.length !== 1 || - latest[0]!.id !== thread.id || - !storageCleanupThreadIdle(latest[0]!, now) || - storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) - ) - return keep("thread activity or shared worktree changed since check"); - // Sessions can outlive their run and can be shared across app threads. - const sessionRows = yield* sql<{ payload_json: string }>` - SELECT payload_json FROM orchestration_v2_projection_provider_sessions - WHERE status != 'stopped' - `; - const sessions = yield* Effect.forEach(sessionRows, (row) => - decodeCleanupSession(row.payload_json), - ); - if ( - sessions.some((session) => { - const cwd = path.resolve(session.cwd); - return cwd === worktreePath || inside(worktreePath, cwd); - }) - ) - return keep("provider session is still open"); - const finalStatus = yield* git.statusDetailsLocal(worktreePath); - if (!finalStatus.isRepo || finalStatus.branch !== thread.branch) + const sessions = yield* Effect.forEach(sessionRows, (row) => + decodeCleanupSession(row.payload_json), + ); + if ( + sessions.some((session) => { + const cwd = path.resolve(session.cwd); + return cwd === worktreePath || inside(worktreePath, cwd); + }) + ) + return "provider session is still open"; + return null; + }); + // Most cancelled removals are caught here, before the worktree is walked. + const blocked = yield* blockedSinceCheck(); + if (blocked !== null) return keep(blocked); + const bytes = yield* measureWorktree(worktreePath); + // The walk can take a while; ask again for anything that started during it. + const blockedWhileMeasuring = yield* blockedSinceCheck(); + if (blockedWhileMeasuring !== null) return keep(blockedWhileMeasuring); + if (yield* branchChanged(worktreePath, thread.branch)) return keep("repository or branch changed since check"); if ( (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !==