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:
parent
393da0f67c
commit
fe803bedf1
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue