Files
PaperClipAI/server/src/services/environment-cleanup-state.ts
T
DottaandClaude Opus 5.5 dc0534d1b5 feat(recovery): destroy a retained orphan sandbox once nothing references it (#19976)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agent runs use remote sandboxes held by environment leases
> - #19964 settles an orphaned active lease when its holder is proven
gone. It stops and keeps the sandbox, because recovery may still need
its files
> - Nothing destroyed that retained orphan later, so a stopped sandbox
could stay at the provider indefinitely
> - This pull request destroys a retained orphan once no recovery,
preservation or retention obligation references it, from the reconciler
pass only
> - The benefit is that orphaned sandboxes do not leak, and a sandbox
that recovery still wants is never destroyed

## Linked Issues or Issue Description

Refs #19964
Refs #19933

**What happened?**
After #19964, an orphaned sandbox is stopped and kept (`released` with a
stop-and-retain receipt). No path destroyed it when recovery no longer
needed it.

**Expected behavior**
A retained orphan is destroyed once database facts show that no
obligation references it. It is never kept past the environment's
retention window (the provider-attested lease `expiresAt`) only because
of a soft reference. A sandbox that an active operation or a live
recovery path still needs is never destroyed.

**Steps to reproduce**
1. Let a run's server die after the run is terminal and before its
sandbox lease is released.
2. Let the ownership settle stop and keep the sandbox.
3. Resolve the run's recovery. The stopped sandbox stays at the
provider.

## What Changed

- `sandbox-stop-and-retain.ts`: an ownership settle's stop intent also
writes a top-level `orphanedRunLeaseRecovery` marker. The marker stays
after the stop intent is removed.
- `environment-retained-orphan-settlement.ts` (new): the settle for
`environment_retained_orphan_sandbox`. It locks the lease row and checks
database facts:
  - the run is terminal;
- always held: native export resume, stop-and-retain intent, in-flight
cleanup claim, workspace restore recovery, workspace recovery cleanup,
bound source-recovery consumer, pending legacy shutdown fence, or
another live lease on the same provider resource;
- held while the stop receipt is a usable source (`expiresAt` not
passed): pending native export or preservation fence, open recovery
action on the issue, or a queued, scheduled or running run on the issue.
- Then it moves the lease to `pending_cleanup` (ephemeral) with a
compare-and-swap. The pending-cleanup sweep destroys it from the
recorded lease. The settle never calls a provider.
- `recovery/ownership-registry.ts`: the kind lists only releasable
retained orphans (an SQL mirror of the checks), so a held orphan is
never polled. It reports `nextDueAt` = the earliest retention-window end
of a softly held orphan.
- `recovery/ownership-invariant-report.ts`: an `OwnableKind` may report
`nextDueAt`; the pass folds it into the report's next due time, so the
reconciler wakes when the window ends.
- `environment-cleanup-state.ts`: the first destroy attempt of a
released orphan is due at once.

## Verification

- `cd server && npx vitest run
src/__tests__/environment-retained-orphan-sandbox.test.ts
src/__tests__/ownership-reconciliation-report.test.ts
src/__tests__/environment-run-lease-ownership.test.ts
src/__tests__/heartbeat-orphaned-active-lease-sweep.test.ts
src/__tests__/heartbeat-pending-cleanup-sweep.test.ts` (embedded
Postgres).
- Red on master, green here (tests written after the code): the settle
marks the lease; an unreferenced retained orphan is destroyed through
the cleanup sweep; a softly held orphan waits until its window ends and
the pass reports that due time.
- Counter-cases (green): never destroyed with an open recovery action, a
queued run, a scheduled retry, a stop-and-retain intent, a native export
resume, a workspace restore recovery, an in-flight claim, a pending
legacy shutdown fence, another live lease on the resource, or an active
run; a read-only pass changes nothing; a retained sandbox without the
orphan marker is left alone.
- Full typecheck is left to CI (local memory limit).

## Risks

- A retained orphan saved by #19964 before this change has no marker, so
it is not destroyed by this path. The window was short (since #19964
merged).
- A held orphan with no `expiresAt` is released on the first reconciler
pass after its last reference clears. If that clearing writes no signal
the reconciler wakes on, the release waits for the next pass.
- The SQL listing mirrors the settle's checks except the pending native
export, which the settle checks in code. Such an orphan stays on the
settle watch until that export settles.

> 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
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edit, embedded Postgres tests).

## 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
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:38:22 -06:00

94 lines
5.6 KiB
TypeScript

import { and, eq, or, sql } from "drizzle-orm";
import { environmentLeases, sandboxCreationOperations, type Db } from "@paperclipai/db";
import { hasDeferredEnvironmentCleanup } from "./environment-cleanup-notifications.js";
export const ENVIRONMENT_CLEANUP_RETRY_MS = 5 * 60_000;
// Escalate and slow cleanup after this many attempts; never abandon a live lease.
export const PENDING_CLEANUP_SWEEP_ATTEMPT_CAP = 5;
// The reaper stores its retry attempt count under this key in the lease metadata.
export const PENDING_CLEANUP_ATTEMPTS_METADATA_KEY = "pendingCleanupRetryAttempts";
// Read the stored retry attempt count as a safe value, directly in SQL. A
// provider can write a malformed value under the attempts key. The type guard
// makes any non-number value read as zero. The reader computes as numeric and
// never casts to int, so a finite number outside the 32-bit range (for example
// 1e300) never throws. The reader clamps a negative value to zero and a positive
// value to the attempt cap. One malformed lease therefore never aborts the page
// sweep. This matches the TypeScript reader `readPendingCleanupRetryAttempts`,
// which clamps to the same range. The claim predicate compares the two readers,
// so both must yield the same value for every input.
export function pendingCleanupAttemptsSql() {
return sql`
case
when jsonb_typeof(${environmentLeases.metadata} -> ${PENDING_CLEANUP_ATTEMPTS_METADATA_KEY}) = 'number'
then least(
greatest(
floor((${environmentLeases.metadata} ->> ${PENDING_CLEANUP_ATTEMPTS_METADATA_KEY})::numeric),
0
),
${PENDING_CLEANUP_SWEEP_ATTEMPT_CAP}
)
else 0
end`;
}
// Choose the `jsonb_set` target root. A provider can write a scalar or array
// metadata root. `jsonb_set` fails on a non-object root, so the reader uses the
// stored metadata only when its root is an object. A NULL, scalar, or array root
// reads as an empty object. `jsonb_typeof(NULL)` is NULL, so the else branch also
// covers a NULL root.
export function pendingCleanupMetadataObjectSql() {
return sql`case when jsonb_typeof(${environmentLeases.metadata}) = 'object' then ${environmentLeases.metadata} else '{}'::jsonb end`;
}
export function recoverableSandboxCreationCondition() {
return or(eq(sandboxCreationOperations.state, "pending"), and(
eq(sandboxCreationOperations.state, "needs_reconciliation"),
sql`coalesce(${sandboxCreationOperations.metadata}->>'creationReconciliationReason', '') <> 'company_deleted'`,
sql`${sandboxCreationOperations.metadata}->>'runnerTaskLease' = 'true'`,
sql`jsonb_typeof(${sandboxCreationOperations.metadata}->'runnerTaskCreationContextId') = 'string' and ${sandboxCreationOperations.metadata}->>'runnerTaskCreationContextId' <> ''`,
or(sql`jsonb_typeof(${sandboxCreationOperations.metadata}->'creationProviderLeaseId') = 'string' and ${sandboxCreationOperations.metadata}->>'creationProviderLeaseId' <> ''`,
and(sql`${sandboxCreationOperations.metadata}->>'creationOperationVersion' = '1'`,
sql`jsonb_typeof(${sandboxCreationOperations.metadata}->'providerMetadata') = 'object'`)),
));
}
/** A run lease the ownership settle moved to cleanup (an orphaned active
* lease, or a retained orphan sandbox nothing references any more), before its first
* cleanup attempt. Its holder is proven gone, so the first stop or teardown is
* due now regardless of how recently the lease was used; a failed attempt
* counts and the normal retry backoff applies from then on. */
export function recoveredOrphanFirstAttemptSql() {
return sql`(${environmentLeases.failureReason} in ('orphaned_active_lease_recovered', 'retained_orphan_sandbox_released')
or ${environmentLeases.metadata}->'sandboxStopAndRetain'->>'recoveredOrphanedRunLease' = 'true')
and coalesce(${environmentLeases.metadata}->>'pendingCleanupRetryAttempts', '0') = '0'
and coalesce(${environmentLeases.metadata}->>'pendingCleanupInFlight', 'false') <> 'true'`;
}
export function pendingCleanupDeadlineSql(now = Date.now()) {
const deadline = sql`case
when ${environmentLeases.metadata}->>'pendingCleanupInFlight' = 'true'
and ${environmentLeases.metadata} ? 'pendingCleanupLeaseExpiresAtMs'
then ${environmentLeases.metadata}->'pendingCleanupLeaseExpiresAtMs'
else ${environmentLeases.metadata}->'pendingCleanupRetryAfterMs' end`;
return sql`case when jsonb_typeof(${deadline}) = 'number' then
case when (${deadline})::numeric <= ${now + 30 * 60_000 + 1_000}
then greatest(0, (${deadline})::numeric) else 0 end
else 0 end`;
}
export async function nextEnvironmentCleanupAt(db: Db, backoffMs = ENVIRONMENT_CLEANUP_RETRY_MS): Promise<number | null> {
const [row] = await db.select({ next: sql<string | null>`min(case when ${recoveredOrphanFirstAttemptSql()}
then ${pendingCleanupDeadlineSql()} else greatest(
extract(epoch from ${environmentLeases.updatedAt}) * 1000 + ${backoffMs},
${pendingCleanupDeadlineSql()}) end)` }).from(environmentLeases).where(eq(environmentLeases.status, "pending_cleanup"));
const [creation] = await db.select({ next: sql<string | null>`min(extract(epoch from ${sandboxCreationOperations.nextAttemptAt}) * 1000)` })
.from(sandboxCreationOperations).where(recoverableSandboxCreationCondition());
const deadlines = [row?.next, creation?.next].filter((value): value is string => value != null).map(Number);
const saved = deadlines.length ? Math.min(...deadlines) : null;
const buffered = hasDeferredEnvironmentCleanup(db) ? Date.now() + backoffMs : null;
return saved === null ? buffered : buffered === null ? saved : Math.min(saved, buffered);
}