diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 0e44d7feb81..fea6694f2e8 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2618,8 +2618,8 @@ Environment overrides: - `PAPERCLIP_GIT_RESTORE_POINTS_ENABLED=true` lets git cache expiration remove closed worktrees with unpushed or uncommitted work after saving an encrypted restore point (see `doc/recoverable-disk-cleanup.md`). It is off by default. - `PAPERCLIP_GIT_RESTORE_POINT_TERMINAL_DELAY_DAYS=` sets how long the issue tree - must be closed first. The default is `3`. A negative, empty, or non-numeric value falls + `PAPERCLIP_GIT_RESTORE_POINT_TERMINAL_DELAY_DAYS=` sets how long the issues that + use the workspace (the source issue and descendants bound to it) must be closed first. The default is `3`. A negative, empty, or non-numeric value falls back to the default. `PAPERCLIP_GIT_RESTORE_POINT_IDLE_DAYS=` sets how long an open issue tree's worktree must go untouched before it is removed the same way; runs restore it diff --git a/doc/recoverable-disk-cleanup.md b/doc/recoverable-disk-cleanup.md index 1fa8a1a892e..ea4aa982001 100644 --- a/doc/recoverable-disk-cleanup.md +++ b/doc/recoverable-disk-cleanup.md @@ -158,6 +158,17 @@ closed issue's worktree only when its remote can reproduce it exactly. With `PAPERCLIP_GIT_RESTORE_POINTS_ENABLED=true`, it can also remove a worktree that has unpushed commits, uncommitted or untracked files, or history-only commits, once its issue tree has been closed for `PAPERCLIP_GIT_RESTORE_POINT_TERMINAL_DELAY_DAYS` (default `3`). +For this, only the issues in the tree that use the workspace count: the source issue, and +each descendant that is bound to it (`execution_workspace_id`, which is how a child that +inherits the parent's workspace with `reuse_existing` is recorded) or that selects it as +its current or pending existing workspace. The delay runs from the latest close time among +those issues. A closed issue's worktree can therefore be removed while descendants that +work in their own workspaces stay open. Any issue still bound to the workspace, and any +live run, finalization, lease, recovery action or runtime service of an issue in the tree +or of the workspace, still keeps it. If an open descendant later binds to the removed +workspace, its next run or a comment reopens it from the restore point, as below. The +workspace reaper does not compact or archive such a worktree by its normal rules while +descendants are open; only expiration removes it. It also removes the worktree of an issue tree that is still open once nothing has touched it for `PAPERCLIP_GIT_RESTORE_POINT_IDLE_DAYS` (default `7`, `0` turns this off): no issue in the tree or bound to the workspace was updated and no run for those issues or diff --git a/server/src/__tests__/git-restore-point-expiration.test.ts b/server/src/__tests__/git-restore-point-expiration.test.ts index 510c9a536ed..49a05169abc 100644 --- a/server/src/__tests__/git-restore-point-expiration.test.ts +++ b/server/src/__tests__/git-restore-point-expiration.test.ts @@ -14,7 +14,8 @@ import { executionWorkspaceService } from "../services/execution-workspaces.js"; import { expireReconstructableGitWorkspace, gitWorkspaceLastActivityAt } from "../services/git-workspace-expiration.js"; import { getStorageService } from "../storage/index.js"; import { markGitRestorePointExpired } from "../services/git-restore-point-storage.js"; -import { GIT_RESTORE_POINT_EXPIRED_WARNING } from "../services/execution-workspaces.js"; +import { GIT_RESTORE_POINT_EXPIRED_WARNING, setMetadataReopenPendingConsumption } from "../services/execution-workspaces.js"; +import { issueService } from "../services/issues.js"; const gitEnv = { ...process.env, GIT_AUTHOR_NAME: "Fixture", GIT_AUTHOR_EMAIL: "fixture@example.test", GIT_COMMITTER_NAME: "Fixture", GIT_COMMITTER_EMAIL: "fixture@example.test", GIT_TERMINAL_PROMPT: "0" }; @@ -356,6 +357,181 @@ describeEmbeddedPostgres("git restore point expiration", () => { }); }); + describe("closed source issue with open descendants", () => { + async function child(f: Awaited>, values: Partial = {}) { + const id = randomUUID(); + await db.insert(issues).values({ id, companyId: f.companyId, parentId: f.issueId, title: "Follow-up", status: "blocked", ...values }); + return id; + } + async function ownWorkspace(f: Awaited>, sourceIssueId: string) { + const id = randomUUID(); + await db.insert(executionWorkspaces).values({ id, companyId: f.companyId, sourceIssueId, mode: "isolated_workspace", strategyType: "git_worktree", + name: "Child workspace", status: "active", providerType: "local_fs", cwd: path.join(root, "child-workspaces", id), providerRef: path.join(root, "child-workspaces", id) }); + await db.update(issues).set({ executionWorkspaceId: id, executionWorkspacePreference: "reuse_existing" }).where(eq(issues.id, sourceIssueId)); + return id; + } + const worker = (now = Date.now()) => executionWorkspaceService(db, { gitCacheExpirationEnabled: true, now: () => new Date(now), + gitRestorePoints: { storage: getStorageService(), terminalDelayMs: 3 * DAY_MS, keyDurable: true, idleMs: 7 * DAY_MS } }); + + it("expires the closed issue's worktree while open children work in their own workspaces", async () => { + const f = await fixture(); + const unpushed = await makeUnsavedWork(f.cwd); + const blocked = await child(f, { status: "blocked" }); + await ownWorkspace(f, blocked); + const reviewing = await child(f, { status: "in_review" }); + await ownWorkspace(f, reviewing); + const grandchild = await child(f, { parentId: reviewing, status: "in_progress" }); + await ownWorkspace(f, grandchild); + await child(f, { status: "todo" }); + expect(await f.expire()).toMatchObject({ candidate: true, expired: true, restorePoint: true, skipReason: null }); + expect(await exists(f.cwd)).toBe(false); + expect(await f.ledger()).toMatchObject([{ status: "active" }]); + expect(await f.reopen()).toMatchObject({ ok: true, reopened: true }); + expect(git(f.cwd, "rev-parse", "HEAD")).toBe(unpushed); + expect(await fs.readFile(path.join(f.cwd, "notes", "todo.md"), "utf8")).toBe("untracked work\n"); + }); + + it("lets the workspace reaper expire it, timed from the issues that use the workspace", async () => { + const f = await fixture(); + await makeUnsavedWork(f.cwd); + const open = await child(f, { status: "in_progress", updatedAt: new Date() }); + await ownWorkspace(f, open); + const closed = await child(f, { status: "done", completedAt: new Date(Date.now() - 60_000) }); + await ownWorkspace(f, closed); + expect(await worker().sweepTerminalWorkspaces()).toMatchObject({ archived: 1 }); + expect(await exists(f.cwd)).toBe(false); + expect((await f.row()).status).toBe("archived"); + expect(await f.ledger()).toMatchObject([{ status: "active" }]); + }); + + it("times the restore point from the issues that use the workspace once every child is closed", async () => { + const f = await fixture(); + await makeUnsavedWork(f.cwd); + const closed = await child(f, { status: "done", completedAt: new Date(Date.now() - 60_000) }); + await ownWorkspace(f, closed); + expect(await worker().sweepTerminalWorkspaces()).toMatchObject({ archived: 1 }); + expect(await exists(f.cwd)).toBe(false); + expect(await f.ledger()).toMatchObject([{ status: "active" }]); + }); + + it("waits for the terminal delay of a recently closed child that used the workspace", async () => { + const f = await fixture(); + await makeUnsavedWork(f.cwd); + await child(f, { status: "in_progress" }).then(id => ownWorkspace(f, id)); + const closedAt = new Date(Date.now() - DAY_MS); + await child(f, { status: "done", completedAt: closedAt, executionWorkspaceId: f.workspaceId }); + const early = worker(); + expect(await early.sweepTerminalWorkspaces()).toMatchObject({ archived: 0 }); + expect(await exists(f.cwd)).toBe(true); + expect(await worker(closedAt.getTime() + 3 * DAY_MS + 60_000).sweepTerminalWorkspaces()).toMatchObject({ archived: 1 }); + expect(await exists(f.cwd)).toBe(false); + }); + + it("never compacts a closed issue's worktree while children are open", async () => { + const f = await fixture(); + await makeUnsavedWork(f.cwd); + await db.update(executionWorkspaces).set({ providerType: "git_worktree" }).where(eq(executionWorkspaces.id, f.workspaceId)); + await db.update(issues).set({ completedAt: new Date(Date.now() - 2 * DAY_MS) }).where(eq(issues.id, f.issueId)); + await fs.mkdir(path.join(f.cwd, "node_modules", "pkg"), { recursive: true }); + await fs.writeFile(path.join(f.cwd, "node_modules", "pkg", "index.js"), "module.exports = 1;\n"); + await child(f, { status: "in_progress" }).then(id => ownWorkspace(f, id)); + for (const reaper of [executionWorkspaceService(db, { workspaceCompactionDelayMs: 0 }), + executionWorkspaceService(db, { workspaceCompactionDelayMs: 0, gitCacheExpirationEnabled: true, + gitRestorePoints: { storage: getStorageService(), terminalDelayMs: 3 * DAY_MS, keyDurable: true } })]) { + expect(await reaper.sweepTerminalWorkspaces()).toMatchObject({ archived: 0, compacted: 0 }); + expect(await exists(path.join(f.cwd, "node_modules", "pkg", "index.js"))).toBe(true); + } + }); + + it("keeps today's reopen fence handling while a reopen is pending", async () => { + const f = await fixture(); + await makeUnsavedWork(f.cwd); + await child(f, { status: "in_progress" }).then(id => ownWorkspace(f, id)); + await db.update(executionWorkspaces).set({ metadata: setMetadataReopenPendingConsumption((await f.row()).metadata, new Date()) }) + .where(eq(executionWorkspaces.id, f.workspaceId)); + expect(await worker().sweepTerminalWorkspaces()).toMatchObject({ archived: 0 }); + expect(await exists(f.cwd)).toBe(true); + expect((await f.row()).metadata?.reopenPendingConsumption).toBeUndefined(); + }); + + it("keeps the worktree while an open child is bound to it", async () => { + const f = await fixture(); + await makeUnsavedWork(f.cwd); + await child(f, { status: "blocked", executionWorkspaceId: f.workspaceId, executionWorkspacePreference: "reuse_existing" }); + expect(await f.expire()).toMatchObject({ expired: false, skipReason: "live_or_unresolved_consumer" }); + expect(await worker().sweepTerminalWorkspaces()).toMatchObject({ archived: 0, skippedNonTerminalTree: 1 }); + expect(await exists(f.cwd)).toBe(true); + }); + + it("keeps the worktree while an open grandchild is bound to it under a child with its own workspace", async () => { + const f = await fixture(); + const middle = await child(f, { status: "in_review" }); + await ownWorkspace(f, middle); + await child(f, { parentId: middle, status: "todo", executionWorkspaceId: f.workspaceId }); + expect(await f.expire()).toMatchObject({ expired: false, skipReason: "live_or_unresolved_consumer" }); + expect(await exists(f.cwd)).toBe(true); + }); + + it("keeps the worktree for a child created later that inherits it with reuse_existing", async () => { + const f = await fixture(); + const created = await issueService(db).create(f.companyId, { parentId: f.issueId, title: "Inherited follow-up", status: "todo" }); + expect(created).toMatchObject({ executionWorkspaceId: f.workspaceId, executionWorkspacePreference: "reuse_existing" }); + expect(await f.expire()).toMatchObject({ expired: false, skipReason: "live_or_unresolved_consumer" }); + expect(await worker().sweepTerminalWorkspaces()).toMatchObject({ archived: 0, skippedNonTerminalTree: 1 }); + expect(await exists(f.cwd)).toBe(true); + }); + + it("keeps the worktree while an open child selects it as its existing workspace", async () => { + const f = await fixture(); + const selection = { kind: "existing" as const, workspaceId: f.workspaceId }; + const selecting = await child(f, { status: "todo", workspaceSelection: { version: 1, selection, source: "explicit" } }); + expect(await f.expire()).toMatchObject({ expired: false, skipReason: "live_or_unresolved_consumer" }); + await db.update(issues).set({ workspaceSelection: null, workspacePendingSelection: { version: 1, requestKey: "fixture", + expectedBindingRevision: 0, intent: { version: 1, selection, source: "explicit" } } }).where(eq(issues.id, selecting)); + expect(await f.expire()).toMatchObject({ expired: false, skipReason: "live_or_unresolved_consumer" }); + await db.update(issues).set({ workspacePendingSelection: null }).where(eq(issues.id, selecting)); + expect(await f.expire()).toMatchObject({ expired: true }); + }); + + it("keeps the worktree while a child run referencing it is live", async () => { + const f = await fixture(); + const running = await child(f, { status: "in_progress" }); + await ownWorkspace(f, running); + const agentId = randomUUID(); + await db.insert(agents).values({ id: agentId, companyId: f.companyId, name: "Worker", adapterType: "process" }); + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId, status: "running", startedAt: new Date(), + contextSnapshot: { issueId: running, executionWorkspaceId: f.workspaceId } }).returning(); + expect(await f.expire()).toMatchObject({ expired: false, skipReason: "live_or_unresolved_consumer" }); + expect(await exists(f.cwd)).toBe(true); + await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, run!.id)); + expect(await f.expire()).toMatchObject({ expired: true }); + }); + + it("keeps the worktree while the closed issue was closed inside the terminal delay", async () => { + const f = await fixture(); + await db.update(issues).set({ completedAt: new Date(Date.now() - 60_000) }).where(eq(issues.id, f.issueId)); + await child(f, { status: "in_progress" }).then(id => ownWorkspace(f, id)); + expect(await f.expire()).toMatchObject({ expired: false, skipReason: "live_or_unresolved_consumer" }); + expect(await exists(f.cwd)).toBe(true); + }); + + it("restores the saved state when an open child later binds to the expired workspace", async () => { + const f = await fixture(); + const unpushed = await makeUnsavedWork(f.cwd); + const follower = await child(f, { status: "in_progress" }); + expect(await f.expire()).toMatchObject({ expired: true, restorePoint: true }); + await db.update(issues).set({ executionWorkspaceId: f.workspaceId, executionWorkspacePreference: "reuse_existing" }).where(eq(issues.id, follower)); + const reopened = await executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ workspaceId: f.workspaceId, + issue: { id: follower, companyId: f.companyId, projectId: null }, actor: { actorType: "user", agentId: null }, + authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true } }); + expect(reopened).toMatchObject({ ok: true, reopened: true }); + expect(git(f.cwd, "rev-parse", "HEAD")).toBe(unpushed); + expect(await fs.readFile(path.join(f.cwd, "marker.txt"), "utf8")).toBe("unstaged edit\n"); + expect(git(f.cwd, "show", ":marker.txt")).toBe("staged edit"); + expect(await fs.readFile(path.join(f.cwd, "notes", "todo.md"), "utf8")).toBe("untracked work\n"); + }); + }); + it("expires a worktree whose only extra files are ignored, and restores them on reopen", async () => { const f = await fixture(); await fs.mkdir(path.join(f.cwd, "ui"), { recursive: true }); diff --git a/server/src/__tests__/git-workspace-expiration.test.ts b/server/src/__tests__/git-workspace-expiration.test.ts index 7055ede59d1..80a1b510058 100644 --- a/server/src/__tests__/git-workspace-expiration.test.ts +++ b/server/src/__tests__/git-workspace-expiration.test.ts @@ -816,7 +816,8 @@ describeEmbeddedPostgres("reconstructable Git expiration", () => { it("preserves live descendants and pending reopening", async () => { const f = await fixture(); - await db.insert(issues).values({ companyId: f.companyId, title: "Active child", status: "in_progress", parentId: f.issueId }); + await db.insert(issues).values({ companyId: f.companyId, title: "Active child", status: "in_progress", parentId: f.issueId, + executionWorkspaceId: f.workspaceId, executionWorkspacePreference: "reuse_existing" }); expect((await f.expire()).expired).toBe(false); await db.delete(issues).where(eq(issues.parentId, f.issueId)); const row = await f.row(); diff --git a/server/src/__tests__/heartbeat-git-restore-point-resume.test.ts b/server/src/__tests__/heartbeat-git-restore-point-resume.test.ts index dc5ebb6d3f2..9620083333c 100644 --- a/server/src/__tests__/heartbeat-git-restore-point-resume.test.ts +++ b/server/src/__tests__/heartbeat-git-restore-point-resume.test.ts @@ -68,7 +68,7 @@ describe.skipIf(!support.supported)("heartbeat resume of an idle-expired workspa await fs.rm(root, { recursive: true, force: true }); }, 60_000); - async function seed() { + async function seed(options: { closedParent?: boolean } = {}) { const home = path.join(root, randomUUID()); vi.stubEnv("PAPERCLIP_HOME", home); vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default"); @@ -108,24 +108,31 @@ describe.skipIf(!support.supported)("heartbeat resume of an idle-expired workspa isolatedRemoteSource: { schema: "paperclip.isolated-remote-source.v1", companyId, issueId, repoUrl } } }); await db.update(issues).set({ executionWorkspaceId: workspaceId, executionWorkspacePreference: "reuse_existing", updatedAt: idleSince }) .where(eq(issues.id, issueId)); + const childId = randomUUID(); + if (options.closedParent) { + await db.update(issues).set({ status: "done", completedAt: new Date(Date.now() - 4 * DAY_MS) }).where(eq(issues.id, issueId)); + await db.insert(issues).values({ id: childId, companyId, projectId, parentId: issueId, title: "Open follow-up", status: "in_progress", + workMode: "standard", assigneeAgentId: agentId, responsibleUserId: "responsible-user", issueNumber: 2, identifier: `${issuePrefix}-2`, + executionWorkspaceSettings: { mode: "isolated_workspace" } }); + } await fs.writeFile(path.join(realized.cwd, "marker.txt"), "commit B (unpushed)\n"); git(realized.cwd, "commit", "-qam", "B"); const unpushed = git(realized.cwd, "rev-parse", "HEAD"); await fs.writeFile(path.join(realized.cwd, "notes.md"), "uncommitted notes\n"); const workspace = () => db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspaceId)).then(rows => rows[0]!); const expiration = await expireReconstructableGitWorkspace(db, await workspace(), { - dryRun: false, expireBase: false, now: new Date(), idleCutoff: new Date(Date.now() - 7 * DAY_MS), + dryRun: false, expireBase: false, now: new Date(), idleCutoff: options.closedParent ? undefined : new Date(Date.now() - 7 * DAY_MS), restorePoint: { storage: getStorageService(), notBefore: new Date(Date.now() - DAY_MS), keyDurable: true }, }); - const queueRun = async () => { + const queueRun = async (target = issueId) => { await db.insert(agentWakeupRequests).values({ id: wakeupId, companyId, agentId, source: "assignment", triggerDetail: "system", - reason: "issue_assigned", payload: { issueId }, status: "queued", runId }); + reason: "issue_assigned", payload: { issueId: target }, status: "queued", runId }); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "assignment", triggerDetail: "system", status: "queued", - wakeupRequestId: wakeupId, responsibleUserId: "responsible-user", contextSnapshot: { issueId, taskId: issueId, wakeReason: "issue_assigned" } }); + wakeupRequestId: wakeupId, responsibleUserId: "responsible-user", contextSnapshot: { issueId: target, taskId: target, wakeReason: "issue_assigned" } }); await db.update(issues).set({ checkoutRunId: runId, executionRunId: runId, executionAgentNameKey: "resumer", executionLockedAt: new Date() }) - .where(eq(issues.id, issueId)); + .where(eq(issues.id, target)); }; - return { issueId, workspaceId, runId, cwd: realized.cwd, unpushed, expiration, workspace, queueRun }; + return { issueId, childId, workspaceId, runId, cwd: realized.cwd, unpushed, expiration, workspace, queueRun }; } async function waitForRun(heartbeat: ReturnType, runId: string) { @@ -159,4 +166,23 @@ describe.skipIf(!support.supported)("heartbeat resume of an idle-expired workspa const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId)); expect(issue.executionWorkspaceId).toBe(seeded.workspaceId); }); + + it("restores a closed parent's expired workspace when an open child binds to it", async () => { + const seeded = await seed({ closedParent: true }); + expect(seeded.expiration).toMatchObject({ expired: true, restorePoint: true }); + await expect(fs.access(seeded.cwd)).rejects.toMatchObject({ code: "ENOENT" }); + await db.update(issues).set({ executionWorkspaceId: seeded.workspaceId, executionWorkspacePreference: "reuse_existing" }) + .where(eq(issues.id, seeded.childId)); + await seeded.queueRun(seeded.childId); + const heartbeat = heartbeatService(db); + await heartbeat.resumeQueuedRuns(); + const run = await waitForRun(heartbeat, seeded.runId); + await heartbeat.waitForRunExecutionDrain(seeded.runId); + expect(run).toMatchObject({ status: "succeeded" }); + expect(adapterExecute.mock.calls[0]?.[0]).toMatchObject({ context: { executionWorkspaceId: seeded.workspaceId, + paperclipWorkspace: { cwd: seeded.cwd } } }); + expect(git(seeded.cwd, "rev-parse", "HEAD")).toBe(seeded.unpushed); + expect(await fs.readFile(path.join(seeded.cwd, "notes.md"), "utf8")).toBe("uncommitted notes\n"); + expect((await seeded.workspace()).status).not.toBe("archived"); + }); }); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 9d42edcc6e1..caf9a90511c 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -5,7 +5,7 @@ import { measureRunTransaction } from "./execution-transaction-timing.js"; import { measureRunPhase } from "./execution-preparation-timing.js"; import { assertExecutionWorkspaceRecordAccess, assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-access.js"; import { verifiedRecordedIsolatedRemoteSource, withIsolatedRepositorySourceLock } from "./isolated-remote-workspace-source.js"; -import { expireReconstructableGitWorkspace, gitWorkspaceLastActivityAt, hasPendingGitExpiration } from "./git-workspace-expiration.js"; +import { expireReconstructableGitWorkspace, gitWorkspaceLastActivityAt, hasPendingGitExpiration, listIssuesUsingGitWorkspace } from "./git-workspace-expiration.js"; import { hasPendingIsolatedRepositoryRehomeCleanup, isIsolatedRepositoryRehomeCandidate, rehomeExpiredIsolatedRestoration, rehomeIsolatedRepositoryWorkspace, sweepOrphanedIsolatedRepositorySources } from "./isolated-repository-rehome.js"; import { hasPendingNestedCloneMigration, isNestedCloneMigrationCandidate, nextNestedCloneMigrationBatch, pendingNestedCloneMigrationNames, listNestedCloneMigrationNames, migrateNestedRepositoryClone, NESTED_CLONE_MIGRATION_RETRY_REASONS, type NestedCloneMigrationRotation } from "./nested-clone-migration.js"; @@ -3434,7 +3434,14 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic workspace.metadata as Record | null, ); if (reopenPending) terminalWorkspaceDeadlines.set(workspace.id, now().getTime()); - if (!sourceIssue || issueTree.some(issue => !TERMINAL_ISSUE_STATUSES.has(issue.status))) { + const treeOpen = issueTree.some(issue => !TERMINAL_ISSUE_STATUSES.has(issue.status)); + const gitExpirationCandidate = workspace.providerType !== "plugin" && Boolean(opts.gitCacheExpirationEnabled) + && (hasPendingGitExpiration(workspace) || (workspace.mode === "isolated_workspace" && workspace.strategyType === "git_worktree")); + const usingIssueIds = sourceIssue && gitExpirationCandidate && !reopenPending + && TERMINAL_ISSUE_STATUSES.has(sourceIssue.status) + ? await listIssuesUsingGitWorkspace(db, workspace, issueTree.map(issue => issue.id)) : null; + const expirationTree = usingIssueIds ? issueTree.filter(issue => usingIssueIds.has(issue.id)) : issueTree; + if (!sourceIssue || expirationTree.some(issue => !TERMINAL_ISSUE_STATUSES.has(issue.status))) { if (reopenPending) { await clearReopenPendingConsumptionUnderLock(workspace.id, { expectedGeneration: readExecutionWorkspaceLifecycleGeneration( @@ -3473,11 +3480,13 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic result.skippedNonTerminalTree += 1; continue; } - const latestTerminalAt = Math.max(...issueTree.map(issue => issueTerminalTimestamp(issue)!.getTime())); + const expirationTerminalAt = Math.max(...expirationTree.map(issue => issueTerminalTimestamp(issue)!.getTime())); + const latestTerminalAt = treeOpen ? expirationTerminalAt : Math.max(...issueTree.map(issue => issueTerminalTimestamp(issue)!.getTime())); // Compaction runs once per terminal cycle, after its own delay and // before the archive cooldown, so a workspace the reaper cannot archive // yet (or ever) stops holding regenerable files. const compactionPending = workspaceCompactionDelayMs !== null + && !treeOpen && !isClosedExecutionWorkspaceStatus(workspace.status) && !reopenPending && compactedTerminalAt.get(workspace.id) !== latestTerminalAt; @@ -3503,14 +3512,13 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic ); } } - if (workspace.providerType !== "plugin" && opts.gitCacheExpirationEnabled && (hasPendingGitExpiration(workspace) - || (workspace.mode === "isolated_workspace" && workspace.strategyType === "git_worktree"))) { + if (gitExpirationCandidate) { const expiration = await expireReconstructableGitWorkspace(db, workspace, { dryRun: opts.gitCacheExpirationDryRun ?? false, expireBase: opts.gitBaseExpirationEnabled ?? false, now: now(), signal, beforeDelete: opts.beforeTerminalWorkspaceCleanup, restorePoint: opts.gitRestorePoints ? { storage: opts.gitRestorePoints.storage, - notBefore: new Date(latestTerminalAt + opts.gitRestorePoints.terminalDelayMs), + notBefore: new Date(expirationTerminalAt + opts.gitRestorePoints.terminalDelayMs), keyDurable: opts.gitRestorePoints.keyDurable, } : undefined, }); diff --git a/server/src/services/git-workspace-expiration.ts b/server/src/services/git-workspace-expiration.ts index ff9e6c7ffc5..c551f7471a5 100644 --- a/server/src/services/git-workspace-expiration.ts +++ b/server/src/services/git-workspace-expiration.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { randomBytes } from "node:crypto"; import { acquireGitExpirationLocks } from "./git-workspace-expiration-locks.js"; -import { and, eq, ne, sql } from "drizzle-orm"; +import { and, eq, inArray, ne, sql } from "drizzle-orm"; import { executionWorkspaces, issues, heartbeatRuns, nativeRunFinalizations, environmentLeases, workspaceRuntimeServices, issueRecoveryActions, projectWorkspaces, withDedicatedDbConnection, type Db } from "@paperclipai/db"; import { createGitRemoteAuthProvider } from "./git-credentials.js"; @@ -124,6 +124,21 @@ export function expirationScope(workspace: Workspace) { )`; } +function issueUsesWorkspace(workspace: Pick) { + return sql`(${issues.id} = ${workspace.sourceIssueId} OR ${issues.executionWorkspaceId} = ${workspace.id} + OR (${issues.workspaceSelection}->'selection'->>'kind' = 'existing' + AND ${issues.workspaceSelection}->'selection'->>'workspaceId' = ${workspace.id}) + OR (${issues.workspacePendingSelection}->'intent'->'selection'->>'kind' = 'existing' + AND ${issues.workspacePendingSelection}->'intent'->'selection'->>'workspaceId' = ${workspace.id}))`; +} + +export async function listIssuesUsingGitWorkspace(db: Db | Tx, workspace: Pick, issueIds: string[]) { + if (issueIds.length === 0) return new Set(); + const rows = await db.select({ id: issues.id }).from(issues) + .where(and(eq(issues.companyId, workspace.companyId), inArray(issues.id, issueIds), issueUsesWorkspace(workspace))); + return new Set(rows.map(row => row.id)); +} + export async function gitWorkspaceLastActivityAt(db: Db | Tx, workspace: Workspace): Promise { if (!workspace.sourceIssueId) return null; const rows = await db.execute<{ at: string | Date | null }>(sql`${expirationScope(workspace)} @@ -142,8 +157,10 @@ export async function isGitWorkspaceExpirationEligible(tx: Tx, workspace: Worksp NOT EXISTS (SELECT 1 FROM tree WHERE updated_at > ${idleCutoff.toISOString()}::timestamptz) AND NOT EXISTS (SELECT 1 FROM bound WHERE updated_at > ${idleCutoff.toISOString()}::timestamptz) AND NOT EXISTS (SELECT 1 FROM relevant_runs WHERE greatest(created_at, started_at, finished_at) > ${idleCutoff.toISOString()}::timestamptz)` : sql` - NOT EXISTS (SELECT 1 FROM tree WHERE status NOT IN ('done', 'cancelled') - OR coalesce(CASE WHEN status = 'done' THEN completed_at ELSE cancelled_at END, updated_at) > ${new Date(now.getTime() - DAY_MS).toISOString()}::timestamptz) + NOT EXISTS (SELECT 1 FROM ${issues} WHERE ${issues.companyId} = ${workspace.companyId} + AND ${issues.id} IN (SELECT id FROM tree) AND ${issueUsesWorkspace(workspace)} + AND (${issues.status} NOT IN ('done', 'cancelled') + OR coalesce(CASE WHEN ${issues.status} = 'done' THEN ${issues.completedAt} ELSE ${issues.cancelledAt} END, ${issues.updatedAt}) > ${new Date(now.getTime() - DAY_MS).toISOString()}::timestamptz)) AND NOT EXISTS (SELECT 1 FROM ${issues} WHERE company_id = ${workspace.companyId} AND execution_workspace_id = ${workspace.id} AND status NOT IN ('done', 'cancelled'))`; const recoveryActionBlocks = idleCutoff ? sql`true` : sql`(cause = 'native_workspace_finalization_owner_unverified'