Files
PaperClipAI/server/src/services/native-runtime/native-harness-backup-expiration.ts
T
Devin FoleyandPaperclip 1f3695ac67 fix: expire native session failover backups after their issues close (#16866)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native agent sessions that run in a remote sandbox keep a host-side
failover copy of the sandbox's harness state, so a replacement sandbox
can resume the conversation
> - Each session keeps that copy forever, and one copy can reach several
gigabytes
> - On a busy instance these copies fill the volume, and no existing
cleanup touches them
> - Once a session's work is closed, its copy matters only if someone
reopens that work later
> - This pull request expires the copy after the session's issues have
been closed for a configurable window, and makes a reopened issue start
a fresh session
> - The benefit is bounded disk use without breaking active,
recoverable, or leased sessions

## Linked Issues or Issue Description

Bug report described in-PR (bug report template).

**What happened?**
After every remote turn, `checkpointRemoteRunner` writes the sandbox's
runner state and provider session home (for example `codex-home`) to
`durable-sessions/<scope>/failover-backups/current`. Only the same
session's next checkpoint replaces it. No code path deletes a session's
copy when the session ends, its issue closes, or its agent goes away, so
the instance volume fills. On one instance:
- 246 sessions held 17 GB of copies, 84 of them older than a week;
- 24 new sessions added 5 GB in a single day, one copy alone 3.2 GB.

**Expected behavior**
Disk used by failover copies stays bounded. A copy is kept while its
session can still resume (open issue, live run, pending recovery, or
live lease), and expires once the session's issues have been closed for
a while. A reopened issue then starts a fresh native session, instead of
failing with `runner_harness_backup_unavailable` on a replacement
sandbox.

**Steps to reproduce**
1. Run agents with a native runtime in a remote sandbox environment.
2. Let them complete many issues.
3. Watch
`<instance>/runtime/paperclip-runner/durable-sessions/*/failover-backups/current`
grow without bound after the issues close.

**Paperclip version or commit:** `master` at 9179d8dcc3.
**Deployment mode:** server with remote sandbox environments.
**Agent adapter(s) involved:** not adapter-specific (core bug); observed
with Codex sessions.

**Breaking changes**
- Reopening an issue that has been closed for longer than the window
(default 7 days) starts a fresh agent session instead of resuming the
old conversation.
- Disable with
`PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_ENABLED=false`, or lengthen
the window with `PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_DAYS`.

## What Changed

-
**`server/src/services/native-runtime/native-harness-backup-expiration.ts`
(new):** the sweep. It checks at most 200 sessions and expires at most
50 per batch, continuing from a cursor. A copy expires only when all of
these hold:
- every issue the session ran on is `done` or `cancelled`, closed before
the window;
  - the copy was written before the window;
  - no run of the session is queued, running, or unfinished;
- every native run finalization of the session is settled (`committed`
or `terminal_failure`), with no recovery state or lease owner;
- no `active`, `released`, `retained`, or `pending_cleanup` environment
lease references the copy (by backup stamp, session run, or recorded
lease);
- the session is not executing, initializing, warm, or closing in this
process, and its directory has no pending cleanup activation.
- **One transaction:** the database checks share-lock the issues and
delete the agent task session that points at the session, so a reopened
issue cannot resume the expired session.
- **Atomic, non-blocking removal:** the copy is renamed to
`failover-backups/.retiring-<UUID>`, and a final check looks for a run
of the session. If one appeared, or the check throws, the rename is
undone. Only a confirmed copy becomes `.expired-<UUID>` and is deleted
with asynchronous file operations, so a multi-gigabyte removal does not
block the event loop. A later sweep restores an unconfirmed
`.retiring-*` copy when `current` is missing, drops it when a newer
`current` exists, and finishes an interrupted deletion. The rest of the
session directory is kept.
- **`native-session-executor.ts`:** exports
`nativeSessionScopeDigestIsBusy`, which checks the in-process executing,
initializing, warm, and closing session maps.
- **`native-harness-backup-stamp.ts`:** exports the state base path the
sweep scans.
- **`config.ts` and `index.ts`:** three environment controls
(`_ENABLED`, `_DRY_RUN`, `_DAYS`) and the scheduled sweep. It starts two
minutes after boot, honors shutdown, standby, and idle drain, and logs
candidates, reclaimed bytes, cleared task sessions, and skip reasons.
- **Docs:** `doc/recoverable-disk-cleanup.md` describes the sweep;
`doc/DEVELOPING.md` lists the variables.
- No code comments were added.

## Verification

- `pnpm exec vitest run
src/__tests__/native-harness-backup-expiration.test.ts
src/__tests__/native-harness-backup-expiration-config.test.ts` (in
`server/`): 30 passed, 24 of them against embedded Postgres plus 6
config tests. Cases:
- expiry for done and cancelled issues, including clearing the task
session;
- each keep condition: open issue, recently closed, live run, pending
finalization, recovery in progress, live lease, recent copy, cleanup
activation, in-process session;
  - settled finalizations and expired leases do not block;
  - dry run changes nothing;
  - bounded batches continue across ticks;
- an interrupted deletion is finished, and an unconfirmed retiring copy
is restored or dropped;
  - the configured clock controls the closed window.
- **Mutation checks:** treating `in_progress` as closed, ignoring live
leases, ignoring the configured clock, or not restoring a retiring copy
each fail the corresponding tests.
- `pnpm exec vitest run native-harness-backup config` (in `server/`): 19
files, 293 tests passed.
- `pnpm run typecheck` (in `server/`): pass.
- **Real server:** after building and bundling,
`scripts/server-boot-smoke.sh node server/dist-runtime/index.mjs`
reached healthy.
- **Manual equivalent on a live instance:** applying these checks by
hand (with a 3-day window) to 242 copies freed 7.1 GB. Most of the
remainder belonged to open issues or live leases.

## Risks

- **Reopen after the window:** the agent starts a fresh session and
loses the old conversation's provider history. Issue comments,
documents, and work products are unaffected.
- **Race with a wake:** the checks share-lock the issues, so a reopen
waits for the transaction. A run created for the session between commit
and rename is caught by the post-rename check, which restores the copy.
Such a run would find no task session and start fresh.
- **Missing owner run:** if the owning run row is gone (for example,
after an agent was deleted), the copy is kept, because the sweep cannot
prove the session is closed.
- **Untouched:** quarantined session roots and `continuity-breaks`
archives are not touched by this change.
- **Rollback:** set
`PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_ENABLED=false`, or revert.
Expired copies cannot be restored.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file edits, local builds, tests and boots,
read-only measurements on a live instance).

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-20 07:57:43 -07:00

357 lines
13 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { and, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
import {
agentTaskSessions,
environmentLeases,
heartbeatRuns,
issues,
nativeRunFinalizations,
type Db,
} from "@paperclipai/db";
import { logger } from "../../middleware/logger.js";
import { nativeHarnessStateBase } from "./native-harness-backup-stamp.js";
import { nativeSessionScopeDigestIsBusy } from "./native-session-executor.js";
export const NATIVE_HARNESS_BACKUP_EXPIRATION_INTERVAL_MS = 60 * 60 * 1000;
export const NATIVE_HARNESS_BACKUP_EXPIRATION_STARTUP_DELAY_MS = 2 * 60 * 1000;
export const NATIVE_HARNESS_BACKUP_EXPIRATION_DEFAULT_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
const SCOPE_PATTERN = /^[0-9a-f]{64}$/;
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const EXPIRED_PREFIX = ".expired-";
const RETIRING_PREFIX = ".retiring-";
const LIVE_RUN_STATUSES = ["queued", "scheduled_retry", "running"];
const SETTLED_FINALIZATION_PHASES = ["committed", "terminal_failure"];
const LIVE_LEASE_STATUSES = ["active", "released", "retained", "pending_cleanup"];
const CLOSED_ISSUE_STATUSES = ["done", "cancelled"];
const MAX_CONTROL_PLANE_STATE_BYTES = 4 * 1024 * 1024;
export type NativeHarnessBackupExpirationOptions = {
closedForDays?: number;
dryRun?: boolean;
maxEntries?: number;
maxRemovals?: number;
now?: Date;
stateBase?: string;
shouldContinue?: () => boolean;
isSessionBusy?: (scopeDigest: string) => boolean;
};
export type NativeHarnessBackupExpirationResult = {
checked: number;
candidates: number;
candidateBytes: number;
expired: number;
reclaimedBytes: number;
clearedTaskSessions: number;
failed: number;
truncated: boolean;
skipped: Record<string, number>;
};
type SessionIdentity = {
runId: string;
normalizedSessionId: string;
environmentLeaseId: string | null;
};
function emptyResult(): NativeHarnessBackupExpirationResult {
return {
checked: 0,
candidates: 0,
candidateBytes: 0,
expired: 0,
reclaimedBytes: 0,
clearedTaskSessions: 0,
failed: 0,
truncated: false,
skipped: {},
};
}
async function isRealDirectory(target: string): Promise<boolean> {
const stat = await fs.lstat(target).catch(() => null);
return Boolean(stat?.isDirectory() && !stat.isSymbolicLink());
}
async function removeBackupTree(target: string): Promise<void> {
const prepare = async (current: string): Promise<void> => {
const stat = await fs.lstat(current).catch(() => null);
if (!stat?.isDirectory()) return;
await fs.chmod(current, (stat.mode & 0o777) | 0o700);
for (const name of await fs.readdir(current)) await prepare(path.join(current, name));
};
await prepare(target);
await fs.rm(target, { recursive: true, force: true });
}
async function allocatedBytes(target: string): Promise<number> {
const stat = await fs.lstat(target).catch(() => null);
if (!stat) return 0;
let bytes = stat.blocks * 512;
if (stat.isDirectory() && !stat.isSymbolicLink()) {
for (const entry of await fs.readdir(target).catch(() => [] as string[])) {
bytes += await allocatedBytes(path.join(target, entry));
}
}
return bytes;
}
async function readSessionIdentity(root: string): Promise<SessionIdentity | null> {
try {
const statePath = path.join(root, "control-plane", "control-plane-state.json");
const stat = await fs.lstat(statePath);
if (!stat.isFile() || stat.size > MAX_CONTROL_PLANE_STATE_BYTES) return null;
const state = JSON.parse(await fs.readFile(statePath, "utf8")) as { identity?: Record<string, unknown> };
const identity = state.identity ?? {};
const runId = identity.runId;
const normalizedSessionId = identity.normalizedSessionId;
const environmentLeaseId = identity.environmentLeaseId;
if (typeof runId !== "string" || !UUID_PATTERN.test(runId)) return null;
if (typeof normalizedSessionId !== "string" || !UUID_PATTERN.test(normalizedSessionId)) return null;
return {
runId,
normalizedSessionId,
environmentLeaseId:
typeof environmentLeaseId === "string" && UUID_PATTERN.test(environmentLeaseId)
? environmentLeaseId
: null,
};
} catch {
return null;
}
}
type Tx = Parameters<Parameters<Db["transaction"]>[0]>[0];
async function sessionRetentionBlocker(
tx: Tx,
input: { scopeDigest: string; identity: SessionIdentity; closedBefore: Date },
): Promise<{ reason: string } | { companyId: string; agentId: string }> {
const [owner] = await tx
.select({ companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.identity.runId));
if (!owner) return { reason: "run_missing" };
const runs = await tx
.select({
id: heartbeatRuns.id,
status: heartbeatRuns.status,
finishedAt: heartbeatRuns.finishedAt,
issueId: sql<string | null>`coalesce(${heartbeatRuns.nativeIssueId}, ${heartbeatRuns.issueId})`,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, owner.companyId),
eq(heartbeatRuns.agentId, owner.agentId),
eq(heartbeatRuns.nativeSessionId, input.identity.normalizedSessionId),
),
);
if (runs.some((run) => LIVE_RUN_STATUSES.includes(run.status) || !run.finishedAt)) {
return { reason: "live_run" };
}
const issueIds = [...new Set(runs.map((run) => run.issueId).filter((id): id is string => Boolean(id)))];
if (issueIds.length === 0) return { reason: "issue_unknown" };
const issueRows = await tx
.select({
id: issues.id,
status: issues.status,
closedAt: sql<Date | null>`coalesce(${issues.completedAt}, ${issues.cancelledAt})`,
})
.from(issues)
.where(and(eq(issues.companyId, owner.companyId), inArray(issues.id, issueIds)))
.for("share");
if (issueRows.length !== issueIds.length) return { reason: "issue_unknown" };
for (const issue of issueRows) {
if (!CLOSED_ISSUE_STATUSES.includes(issue.status)) return { reason: "issue_open" };
const closedAt = issue.closedAt ? new Date(issue.closedAt) : null;
if (!closedAt || closedAt >= input.closedBefore) return { reason: "recently_closed" };
}
const runIds = runs.map((run) => run.id);
const [pending] = await tx
.select({ runId: nativeRunFinalizations.runId })
.from(nativeRunFinalizations)
.where(
and(
inArray(nativeRunFinalizations.runId, runIds),
or(
notInArray(nativeRunFinalizations.phase, SETTLED_FINALIZATION_PHASES),
sql`${nativeRunFinalizations.recoveryState} is not null`,
sql`${nativeRunFinalizations.leaseOwner} is not null`,
),
),
)
.limit(1);
if (pending) return { reason: "pending_finalization" };
const leaseReferences = [
sql`${environmentLeases.metadata}->'nativeHarnessBackup'->>'sessionScopeSha256' = ${input.scopeDigest}`,
inArray(environmentLeases.heartbeatRunId, runIds),
];
if (input.identity.environmentLeaseId) {
leaseReferences.push(eq(environmentLeases.id, input.identity.environmentLeaseId));
}
const [lease] = await tx
.select({ id: environmentLeases.id })
.from(environmentLeases)
.where(and(inArray(environmentLeases.status, LIVE_LEASE_STATUSES), or(...leaseReferences)))
.limit(1);
if (lease) return { reason: "live_lease" };
return owner;
}
async function hasLiveRun(db: Db, owner: { companyId: string; agentId: string }, sessionId: string) {
const [run] = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, owner.companyId),
eq(heartbeatRuns.agentId, owner.agentId),
eq(heartbeatRuns.nativeSessionId, sessionId),
or(inArray(heartbeatRuns.status, LIVE_RUN_STATUSES), isNull(heartbeatRuns.finishedAt)),
),
)
.limit(1);
return Boolean(run);
}
export function nativeHarnessBackupExpirationService(
db: Db,
defaults: NativeHarnessBackupExpirationOptions = {},
) {
let cursor: string | null = null;
async function settleLeftovers(backupRoot: string) {
const current = path.join(backupRoot, "current");
for (const name of await fs.readdir(backupRoot).catch(() => [] as string[])) {
const target = path.join(backupRoot, name);
if (name.startsWith(RETIRING_PREFIX) && UUID_PATTERN.test(name.slice(RETIRING_PREFIX.length))) {
if (!(await isRealDirectory(current))) await fs.rename(target, current);
else await removeBackupTree(target);
} else if (name.startsWith(EXPIRED_PREFIX) && UUID_PATTERN.test(name.slice(EXPIRED_PREFIX.length))) {
await removeBackupTree(target);
}
}
}
async function expireSession(
root: string,
scopeDigest: string,
options: Required<Pick<NativeHarnessBackupExpirationOptions, "dryRun" | "isSessionBusy">> & {
closedBefore: Date;
},
result: NativeHarnessBackupExpirationResult,
) {
const skip = (reason: string) => {
result.skipped[reason] = (result.skipped[reason] ?? 0) + 1;
};
const backupRoot = path.join(root, "failover-backups");
const current = path.join(backupRoot, "current");
if (!options.dryRun) await settleLeftovers(backupRoot);
if (!(await isRealDirectory(current))) return skip("no_backup");
if (await fs.lstat(path.join(root, "cleanup-activation.json")).then(() => true, () => false)) {
return skip("cleanup_active");
}
if (options.isSessionBusy(scopeDigest)) return skip("session_active");
const backupStat = await fs.stat(current);
if (backupStat.mtime >= options.closedBefore) return skip("recent_backup");
const identity = await readSessionIdentity(root);
if (!identity) return skip("identity_unknown");
const decision = await db.transaction(async (tx) => {
const blocker = await sessionRetentionBlocker(tx, {
scopeDigest,
identity,
closedBefore: options.closedBefore,
});
if ("reason" in blocker) return blocker;
if (options.dryRun) return { owner: blocker, cleared: 0 };
const cleared = await tx
.delete(agentTaskSessions)
.where(
and(
eq(agentTaskSessions.companyId, blocker.companyId),
eq(agentTaskSessions.agentId, blocker.agentId),
sql`${agentTaskSessions.sessionParamsJson}->>'sessionId' = ${identity.normalizedSessionId}`,
),
)
.returning({ id: agentTaskSessions.id });
return { owner: blocker, cleared: cleared.length };
});
if ("reason" in decision) return skip(decision.reason);
const bytes = await allocatedBytes(current);
result.candidates++;
result.candidateBytes += bytes;
if (options.dryRun) return;
result.clearedTaskSessions += decision.cleared;
if (options.isSessionBusy(scopeDigest)) return skip("session_active");
const id = randomUUID();
const retiring = path.join(backupRoot, `${RETIRING_PREFIX}${id}`);
const trash = path.join(backupRoot, `${EXPIRED_PREFIX}${id}`);
await fs.rename(current, retiring);
let liveRun: boolean;
try {
liveRun = await hasLiveRun(db, decision.owner, identity.normalizedSessionId);
} catch (err) {
await fs.rename(retiring, current);
throw err;
}
if (liveRun) {
await fs.rename(retiring, current);
return skip("live_run");
}
await fs.rename(retiring, trash);
await removeBackupTree(trash);
result.expired++;
result.reclaimedBytes += bytes;
}
async function tick(options: NativeHarnessBackupExpirationOptions = {}) {
const closedForDays = Math.max(
1,
options.closedForDays ?? defaults.closedForDays ?? NATIVE_HARNESS_BACKUP_EXPIRATION_DEFAULT_DAYS,
);
const dryRun = options.dryRun ?? defaults.dryRun ?? false;
const maxEntries = Math.max(1, options.maxEntries ?? defaults.maxEntries ?? 200);
const maxRemovals = Math.max(1, options.maxRemovals ?? defaults.maxRemovals ?? 50);
const shouldContinue = options.shouldContinue ?? defaults.shouldContinue ?? (() => true);
const isSessionBusy = options.isSessionBusy ?? defaults.isSessionBusy ?? nativeSessionScopeDigestIsBusy;
const base = path.resolve(options.stateBase ?? defaults.stateBase ?? nativeHarnessStateBase());
const closedBefore = new Date((options.now ?? defaults.now ?? new Date()).getTime() - closedForDays * DAY_MS);
const result = emptyResult();
const names = (await fs.readdir(base).catch(() => [] as string[]))
.filter((name) => SCOPE_PATTERN.test(name))
.sort();
const pending = cursor ? names.filter((name) => name > cursor!) : names;
for (const name of pending) {
if (!shouldContinue() || result.checked >= maxEntries || result.candidates >= maxRemovals) {
result.truncated = true;
break;
}
result.checked++;
cursor = name;
const root = path.join(base, name);
if (path.dirname(root) !== base || !(await isRealDirectory(root))) {
result.skipped.unsafe_path = (result.skipped.unsafe_path ?? 0) + 1;
continue;
}
try {
await expireSession(root, name, { dryRun, isSessionBusy, closedBefore }, result);
} catch (err) {
result.failed++;
logger.warn({ err, scope: name }, "native harness backup expiration skipped a session");
}
}
if (!result.truncated) cursor = null;
return result;
}
return { tick };
}