From fe803bedf163d187a0885a3f3721e707f06b3b49 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 18 Aug 2026 11:23:27 -0700 Subject: [PATCH] feat(server): add a configurable cooldown to the terminal workspace reaper (#11642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is an open source app that manages AI agents for work. > - The server manages execution workspaces and their worktrees. > - The terminal workspace reaper removes a workspace when its issue tree reaches a terminal state. > - Immediate removal prevents a person from reopening recently completed work. > - This pull request adds a configurable cooldown before the reaper archives the workspace. > - The cooldown keeps recent work available and keeps immediate cleanup available with value `0`. ## Linked Issues or Issue Description Refs: #7790 **Problem** The reaper archives an execution workspace and deletes its worktree as soon as the issue tree becomes terminal. A person cannot reopen recent work without extra effort. **Expected behavior** The reaper should keep a recently completed workspace during a configurable cooldown window. It should archive older work and support immediate cleanup when the value is `0`. **Proposed solution** Read the cooldown from `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS`. Use a seven-day default. Use the latest terminal timestamp in the source issue tree as the cooldown anchor. ## What Changed - Add `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS` with a seven-day default. - Treat `0` as no cooldown and use the default for negative or non-numeric values. - Use the latest `completedAt` or `cancelledAt` value in the source issue tree. - Use `updatedAt` when a terminal timestamp is null. - Skip candidates inside the cooldown and report them in `skippedCooldown`. - Recheck the cutoff during the guarded archive operation. - Document the environment variable and add focused tests. ## Verification - Run `npx vitest run server/src/__tests__/execution-workspaces-service.test.ts`. - Confirm that the test run passes 66 tests. - Confirm that the tests cover a recent tree, an old tree, value `0`, and a null terminal timestamp. - Confirm that the changed files pass `tsc --noEmit`. ## Risks The default changes terminal workspace cleanup from immediate removal to a seven-day delay. A value of `0` preserves immediate cleanup. The guarded archive check limits race risk during concurrent lifecycle changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap. ## Model Used OpenAI Codex, GPT-5, tool use and code execution. This model assisted with the implementation review and PR preparation. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- doc/DEVELOPING.md | 6 + .../execution-workspaces-service.test.ts | 109 ++++++++++++++++++ .../workspace-reaper-cooldown-config.test.ts | 54 +++++++++ server/src/config.ts | 17 +++ server/src/index.ts | 7 +- server/src/services/execution-workspaces.ts | 92 +++++++++++++++ 6 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 server/src/__tests__/workspace-reaper-cooldown-config.test.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index dd287b1ee5..b2bf7cc7d4 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -834,6 +834,12 @@ Environment overrides: stale-backup warning threshold - `PAPERCLIP_DB_BACKUP_ALERT_FILE=/path/to/failure-marker` lets external cron wrappers surface the last failed backup in `/api/health` +- `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS=` sets how long the + terminal-workspace reaper waits after an issue tree becomes terminal before it + archives the execution workspace and deletes the worktree. A person can reopen + the work inside this window. The default is `7`. A value of `0` disables the + cooldown and restores immediate reaping. A negative or non-numeric value falls + back to the default. Without `PAPERCLIP_DB_BACKUP_ALERT_FILE`, health checks look for `db-backup-to-s3.failure` in the backup directory, beside the backup directory, diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 6a5ee60160..9f3693e1b9 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -259,6 +259,10 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { pullRequestDetailsByKey.get(`${companyId}:${reference.number}`) ?? { state: "unknown", headRef: null, headSha: null } ), + // Disable the reaper cooldown for the delivery, terminal, race, and + // cleanup tests. They assert immediate reaping. The cooldown gets its own + // tests further down. + workspaceReaperCooldownDays: 0, }); }, 20_000); @@ -687,6 +691,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { // workspace first. const service = executionWorkspaceService(db, { resolvePullRequestDetails: async () => ({ state: "unknown", headRef: null, headSha: null }), + workspaceReaperCooldownDays: 0, }); const firstSweep = await service.sweepTerminalWorkspaces(1); @@ -727,6 +732,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { const service = executionWorkspaceService(db, { resolvePullRequestDetails: async () => ({ state: "unknown", headRef: null, headSha: null }), now: () => new Date(clockMs), + workspaceReaperCooldownDays: 0, }); // An eligible ancestry workspace with an old updatedAt. Its source issue @@ -829,6 +835,105 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { expect(finalState?.status).toBe("archived"); }, 30_000); + describe("reaper cooldown", () => { + const DAY_MS = 24 * 60 * 60 * 1000; + const nowMs = Date.UTC(2026, 5, 1); + + function cooldownService(cooldownDays: number) { + return executionWorkspaceService(db, { + resolvePullRequestDetails: async (companyId, reference) => + pullRequestDetailsByKey.get(`${companyId}:${reference.number}`) + ?? { state: "unknown", headRef: null, headSha: null }, + now: () => new Date(nowMs), + workspaceReaperCooldownDays: cooldownDays, + }); + } + + async function statusOf(executionWorkspaceId: string) { + const [row] = await db + .select({ status: executionWorkspaces.status }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, executionWorkspaceId)); + return row?.status ?? null; + } + + it("skips a terminal tree that is younger than the cooldown", async () => { + const seeded = await seedTerminalWorkspace({ mergedPr: true }); + // Keep the workspace inside the sweep boundary that the fixed clock sets. + await db + .update(executionWorkspaces) + .set({ updatedAt: new Date(nowMs - DAY_MS) }) + .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + // The issue became terminal one day ago. A cooldown of seven days is not + // over yet. + await db + .update(issues) + .set({ completedAt: new Date(nowMs - DAY_MS) }) + .where(eq(issues.id, seeded.sourceIssueId)); + + const sweep = await cooldownService(7).sweepTerminalWorkspaces(); + + expect(sweep).toMatchObject({ archived: 0, skippedCooldown: 1 }); + expect(await statusOf(seeded.executionWorkspaceId)).toBe("active"); + }, 20_000); + + it("archives a terminal tree that is older than the cooldown", async () => { + const seeded = await seedTerminalWorkspace({ mergedPr: true }); + await db + .update(executionWorkspaces) + .set({ updatedAt: new Date(nowMs - DAY_MS) }) + .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + // The issue became terminal ten days ago. The seven-day cooldown is over. + await db + .update(issues) + .set({ completedAt: new Date(nowMs - 10 * DAY_MS) }) + .where(eq(issues.id, seeded.sourceIssueId)); + + const sweep = await cooldownService(7).sweepTerminalWorkspaces(); + + expect(sweep).toMatchObject({ archived: 1, skippedCooldown: 0 }); + expect(await statusOf(seeded.executionWorkspaceId)).toBe("archived"); + }, 20_000); + + it("archives immediately when the cooldown is zero", async () => { + const seeded = await seedTerminalWorkspace({ mergedPr: true }); + await db + .update(executionWorkspaces) + .set({ updatedAt: new Date(nowMs - DAY_MS) }) + .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + // The issue became terminal now. A cooldown of zero disables the wait. + await db + .update(issues) + .set({ completedAt: new Date(nowMs) }) + .where(eq(issues.id, seeded.sourceIssueId)); + + const sweep = await cooldownService(0).sweepTerminalWorkspaces(); + + expect(sweep).toMatchObject({ archived: 1, skippedCooldown: 0 }); + expect(await statusOf(seeded.executionWorkspaceId)).toBe("archived"); + }, 20_000); + + it("falls back to updatedAt when completedAt is null and gates the archive", async () => { + const seeded = await seedTerminalWorkspace({ mergedPr: true }); + // The done issue has no completedAt. The anchor falls back to updatedAt. + // Set updatedAt two days ago, inside the seven-day cooldown, so the sweep + // must skip. + await db + .update(executionWorkspaces) + .set({ updatedAt: new Date(nowMs - 2 * DAY_MS) }) + .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + await db + .update(issues) + .set({ completedAt: null, updatedAt: new Date(nowMs - 2 * DAY_MS) }) + .where(eq(issues.id, seeded.sourceIssueId)); + + const sweep = await cooldownService(7).sweepTerminalWorkspaces(); + + expect(sweep).toMatchObject({ archived: 0, skippedCooldown: 1 }); + expect(await statusOf(seeded.executionWorkspaceId)).toBe("active"); + }, 20_000); + }); + it("does not treat an unrelated inbound issue mention as delivery evidence", async () => { const seeded = await seedTerminalWorkspace(); const unrelatedIssueId = randomUUID(); @@ -965,6 +1070,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { const racingService = executionWorkspaceService(db, { resolvePullRequestDetails: async (_companyId, reference) => pullRequestDetailsByKey.get(`${seeded.companyId}:${reference.number}`) ?? { state: "unknown" }, + workspaceReaperCooldownDays: 0, beforeTerminalWorkspaceCleanup: async () => { await fs.writeFile(path.join(seeded.worktreePath, "late-work.txt"), "not delivered\n", "utf8"); }, @@ -994,6 +1100,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { const racingService = executionWorkspaceService(db, { resolvePullRequestDetails: async (_companyId, reference) => pullRequestDetailsByKey.get(`${seeded.companyId}:${reference.number}`) ?? { state: "unknown" }, + workspaceReaperCooldownDays: 0, beforeTerminalWorkspaceCleanup: async (workspace) => { // Stand in for a reopen and a fresh archive that ran after this sweep // captured the generation. Raise the generation past the captured value, @@ -1597,6 +1704,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { const racingService = executionWorkspaceService(db, { resolvePullRequestDetails: async (_companyId, reference) => pullRequestDetailsByKey.get(`${failSeed.companyId}:${reference.number}`) ?? { state: "unknown" }, + workspaceReaperCooldownDays: 0, beforeTerminalWorkspaceCleanup: async (workspace) => { await db .update(executionWorkspaces) @@ -1639,6 +1747,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { const lockingService = executionWorkspaceService(db, { resolvePullRequestDetails: async (_companyId, reference) => pullRequestDetailsByKey.get(`${seeded.companyId}:${reference.number}`) ?? { state: "unknown" }, + workspaceReaperCooldownDays: 0, beforeTerminalWorkspaceCleanup: async () => { try { await runGit(seeded.worktreePath, ["commit", "--allow-empty", "-m", "Late commit"]); diff --git a/server/src/__tests__/workspace-reaper-cooldown-config.test.ts b/server/src/__tests__/workspace-reaper-cooldown-config.test.ts new file mode 100644 index 0000000000..2bcde2f4d6 --- /dev/null +++ b/server/src/__tests__/workspace-reaper-cooldown-config.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadConfig } from "../config.ts"; + +// The terminal-workspace reaper reads PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS. +// These tests lock the parser contract: the default is 7 days, an explicit 0 +// means immediate reaping, and empty, whitespace-only, negative, or non-numeric +// values fall back to the default. An empty or whitespace-only value must not +// become 0, because that would delete terminal workspaces immediately. + +describe("workspace reaper cooldown config parsing", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("uses the 7 day default when the variable is not set", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", undefined); + expect(loadConfig().workspaceReaperCooldownDays).toBe(7); + }); + + it("uses the 7 day default for an empty value", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", ""); + expect(loadConfig().workspaceReaperCooldownDays).toBe(7); + }); + + it("uses the 7 day default for a whitespace-only value", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", " "); + expect(loadConfig().workspaceReaperCooldownDays).toBe(7); + }); + + it("keeps an explicit 0 as immediate reaping", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "0"); + expect(loadConfig().workspaceReaperCooldownDays).toBe(0); + }); + + it("reads a positive whole number", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "14"); + expect(loadConfig().workspaceReaperCooldownDays).toBe(14); + }); + + it("trims surrounding whitespace before it reads the number", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", " 3 "); + expect(loadConfig().workspaceReaperCooldownDays).toBe(3); + }); + + it("uses the 7 day default for a negative value", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "-1"); + expect(loadConfig().workspaceReaperCooldownDays).toBe(7); + }); + + it("uses the 7 day default for a non-numeric value", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "soon"); + expect(loadConfig().workspaceReaperCooldownDays).toBe(7); + }); +}); diff --git a/server/src/config.ts b/server/src/config.ts index 90d6cbf6b1..332b9d9c89 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -69,6 +69,7 @@ export interface Config { databaseBackupIntervalMinutes: number; databaseBackupRetentionDays: number; databaseBackupDir: string; + workspaceReaperCooldownDays: number; serveUi: boolean; uiDevMiddleware: boolean; secretsProvider: SecretProvider; @@ -265,6 +266,21 @@ export function loadConfig(): Config { fileDatabaseBackup?.dir ?? resolveDefaultBackupDir(), ); + // The terminal-workspace reaper waits this many days after an issue tree + // becomes terminal before it archives the workspace. A person can reopen the + // work inside this window. A value of 0 disables the cooldown and restores + // immediate reaping. A negative or non-numeric value falls back to the + // default. The day granularity and the default of 7 obey the + // PAPERCLIP_DB_BACKUP_RETENTION_DAYS precedent above. + const workspaceReaperCooldownDaysEnv = + process.env.PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS?.trim(); + const workspaceReaperCooldownDaysRaw = Number(workspaceReaperCooldownDaysEnv); + const workspaceReaperCooldownDays = + workspaceReaperCooldownDaysEnv + && Number.isFinite(workspaceReaperCooldownDaysRaw) + && workspaceReaperCooldownDaysRaw >= 0 + ? workspaceReaperCooldownDaysRaw + : 7; const bindValidationErrors = validateConfiguredBindMode({ deploymentMode, deploymentExposure, @@ -307,6 +323,7 @@ export function loadConfig(): Config { databaseBackupIntervalMinutes, databaseBackupRetentionDays, databaseBackupDir, + workspaceReaperCooldownDays, serveUi: process.env.SERVE_UI !== undefined ? process.env.SERVE_UI === "true" diff --git a/server/src/index.ts b/server/src/index.ts index c399f1b8b8..ffbf98ec3d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1049,7 +1049,9 @@ export async function startServer(): Promise { const mergedPullRequestConfirmations = issueThreadInteractionService(db as any, { wakeup: heartbeat.wakeup, }); - const terminalWorkspaces = executionWorkspaceService(db as any); + const terminalWorkspaces = executionWorkspaceService(db as any, { + workspaceReaperCooldownDays: config.workspaceReaperCooldownDays, + }); const scheduleMergedPullRequestConfirmationSweep = () => { if (heartbeatSchedulerStopped) return; trackHeartbeatSchedulerWork(mergedPullRequestConfirmations @@ -1081,7 +1083,8 @@ export async function startServer(): Promise { result.skippedActiveRun + result.skippedNonTerminalTree + result.skippedUndelivered - + result.skippedRace; + + result.skippedRace + + result.skippedCooldown; const nowMs = Date.now(); if (skipped > 0 && nowMs - lastTerminalWorkspaceSkipLogAt >= terminalWorkspaceSkipLogIntervalMs) { lastTerminalWorkspaceSkipLogAt = nowMs; diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 7208bf0827..360a5042f1 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -67,6 +67,24 @@ type RuntimeServiceReadDb = Pick; type DbTransaction = Parameters[0]>[0]; const execFileAsync = promisify(execFile); const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); + +// Return the timestamp when an issue became terminal. A `done` issue uses +// `completedAt`. A `cancelled` issue uses `cancelledAt`. The reaper cooldown +// measures the age of the terminal transition from this timestamp. Fall back to +// `updatedAt` when the terminal timestamp is null, so an old issue that lacks a +// recorded transition time still gates the cooldown. Return null for a +// non-terminal issue. +function issueTerminalTimestamp(issue: { + status: string; + completedAt: Date | null; + cancelledAt: Date | null; + updatedAt: Date; +}): Date | null { + if (issue.status === "done") return issue.completedAt ?? issue.updatedAt; + if (issue.status === "cancelled") return issue.cancelledAt ?? issue.updatedAt; + return null; +} + const WORKSPACE_BRANCH_INCOHERENCE_REASON = "git_worktree_branch_incoherence"; const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed"; export const ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON = "issue_terminal"; @@ -208,6 +226,10 @@ export type ExecutionWorkspaceServiceOptions = { resolvePullRequestDetails?: PullRequestMergeDetailsResolver; now?: () => Date; beforeTerminalWorkspaceCleanup?: (workspace: ExecutionWorkspaceRow) => Promise; + // The terminal-workspace reaper waits this many days after an issue tree + // becomes terminal before it archives the workspace. A value of 0 disables + // the cooldown. The default is 7 days. + workspaceReaperCooldownDays?: number; }; function parseGitHubRepository(repoUrl: string | null) { @@ -1230,6 +1252,14 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic const recoveryActionsSvc = issueRecoveryActionService(db); const resolvePullRequestDetails = opts.resolvePullRequestDetails ?? createPullRequestMergeDetailsResolver(db); const now = opts.now ?? (() => new Date()); + // The reaper waits this long after an issue tree becomes terminal before it + // archives the workspace. A value of 0 disables the cooldown, so the reaper + // archives a terminal workspace on the same sweep. A negative value also + // disables the cooldown. + const workspaceReaperCooldownMs = Math.max( + 0, + (opts.workspaceReaperCooldownDays ?? 7) * 24 * 60 * 60 * 1000, + ); const pullRequestStateCache = new Map< string, { @@ -1268,6 +1298,9 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic .select({ id: issues.id, status: issues.status, + completedAt: issues.completedAt, + cancelledAt: issues.cancelledAt, + updatedAt: issues.updatedAt, }) .from(issues) .where(and( @@ -1323,6 +1356,17 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic const sourceIssue = issueTree.find((issue) => issue.id === workspace.sourceIssueId) ?? null; const sourceIssueTerminal = Boolean(sourceIssue && TERMINAL_ISSUE_STATUSES.has(sourceIssue.status)); const subtreeTerminal = Boolean(sourceIssue && issueTree.every((issue) => TERMINAL_ISSUE_STATUSES.has(issue.status))); + // The cooldown anchor is the most recent terminal timestamp across the whole + // issue tree. The reaper compares it against the cooldown window. A null + // anchor means no issue in the tree is terminal yet, so the cooldown never + // applies (the terminal-tree gates above already block the archive). + let cooldownAnchor: Date | null = null; + for (const issue of issueTree) { + const terminalAt = issueTerminalTimestamp(issue); + if (terminalAt && (!cooldownAnchor || terminalAt.getTime() > cooldownAnchor.getTime())) { + cooldownAnchor = terminalAt; + } + } let mergedPullRequest = false; let pullRequestStateUnknown = false; const workspaceHeadSha = git?.repoRoot && git.workspacePath @@ -1386,6 +1430,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic }), sourceIssueTerminal, subtreeTerminal, + cooldownAnchor, workspaceDirty: Boolean(git?.hasDirtyTrackedFiles || git?.hasUntrackedFiles), workspaceHeadSha, }; @@ -2460,6 +2505,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic skippedUndelivered: 0, skippedRace: 0, skippedReopened: 0, + skippedCooldown: 0, clearedStaleReopenPending: 0, }; } @@ -2523,6 +2569,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic skippedUndelivered: 0, skippedRace: 0, skippedReopened: 0, + skippedCooldown: 0, clearedStaleReopenPending: 0, }; @@ -2564,6 +2611,23 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic result.skippedUndelivered += 1; continue; } + // Hold the archive during the cooldown window. The anchor is the most + // recent terminal timestamp across the issue tree. A person can reopen + // the work inside this window. A cooldown of 0 disables the check, so the + // reaper archives the workspace on the same sweep. The archive statement + // below re-checks the same cutoff under the lifecycle lock, so the loop + // check and the guarded statement agree. + const cooldownCutoff = workspaceReaperCooldownMs > 0 + ? new Date(now().getTime() - workspaceReaperCooldownMs) + : null; + if ( + cooldownCutoff + && assessment.cooldownAnchor + && assessment.cooldownAnchor.getTime() > cooldownCutoff.getTime() + ) { + result.skippedCooldown += 1; + continue; + } if (reopenPending) { const pendingSince = readMetadataReopenPendingConsumptionSince( workspace.metadata as Record | null, @@ -2689,6 +2753,34 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic ) SELECT 1 FROM issue_tree WHERE status NOT IN ('done', 'cancelled') )`, + // Re-check the cooldown under the lifecycle lock. This predicate + // matches the loop check above: block the archive when any issue in + // the tree became terminal after the cutoff. The tree walk mirrors + // the terminal-tree walk above. A null cutoff means the cooldown is + // disabled, so this predicate drops out of the guard. + cooldownCutoff + ? sql`NOT EXISTS ( + WITH RECURSIVE cooldown_tree(id, status, completed_at, cancelled_at, updated_at) AS ( + SELECT root.id, root.status, root.completed_at, root.cancelled_at, root.updated_at + FROM ${issues} root + WHERE root.company_id = ${workspace.companyId} + AND root.id = ${workspace.sourceIssueId} + UNION ALL + SELECT child.id, child.status, child.completed_at, child.cancelled_at, child.updated_at + FROM ${issues} child + JOIN cooldown_tree parent ON child.parent_id = parent.id + WHERE child.company_id = ${workspace.companyId} + ) + SELECT 1 FROM cooldown_tree + WHERE COALESCE( + CASE + WHEN status = 'done' THEN completed_at + WHEN status = 'cancelled' THEN cancelled_at + END, + updated_at + ) > ${cooldownCutoff.toISOString()}::timestamptz + )` + : undefined, )) .returning() .then((rows) => rows[0] ?? null);