feat(server): add a configurable cooldown to the terminal workspace reaper (#11642)

## 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 <noreply@paperclip.ing>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Nicky Leach 2026-08-18 11:23:27 -07:00 committed by GitHub
parent 393da0f67c
commit fe803bedf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 283 additions and 2 deletions

View File

@ -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=<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,

View File

@ -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"]);

View File

@ -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);
});
});

View File

@ -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"

View File

@ -1049,7 +1049,9 @@ export async function startServer(): Promise<StartedServer> {
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<StartedServer> {
result.skippedActiveRun
+ result.skippedNonTerminalTree
+ result.skippedUndelivered
+ result.skippedRace;
+ result.skippedRace
+ result.skippedCooldown;
const nowMs = Date.now();
if (skipped > 0 && nowMs - lastTerminalWorkspaceSkipLogAt >= terminalWorkspaceSkipLogIntervalMs) {
lastTerminalWorkspaceSkipLogAt = nowMs;

View File

@ -67,6 +67,24 @@ type RuntimeServiceReadDb = Pick<Db, "select">;
type DbTransaction = Parameters<Parameters<Db["transaction"]>[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<void>;
// 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<string, unknown> | 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<boolean>`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);