Files
PaperClipAI/server/src/services/environment-run-lease-settlement.ts
T
DottaandClaude Opus 5.5 2fed77086f fix(recovery): date a settled orphan's first cleanup even under a scalar metadata root (#19989)
<!-- 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
> - #19980 made the environment cleanup queue the owner of an unclaimed
pending-cleanup item, with a due time and an overdue check (I7)
> - A settled orphan's first cleanup attempt is dated by
`pendingCleanupRecoveredAtMs`, which the orphaned-run-lease settle
writes
> - The settle merged that key with `coalesce(metadata, '{}') || ...`.
For an array or scalar metadata root that produces an array, so the key
is not at the top level
> - The queue owner then falls back to the old `updatedAt` and can
report a fresh item as overdue
> - This pull request builds the merge from the object-root reader that
the cleanup sweep already uses
> - The benefit is that a fresh orphan is never reported overdue because
of a malformed metadata root

## Linked Issues or Issue Description

Refs #19980
Refs #19964

**What happened?**
The review of #19980 found that the orphaned-run-lease settle wrote
`pendingCleanupRecoveredAtMs` under an array metadata root as an array
element. #19980 merged before this fix landed on it.

**Expected behavior**
The key is always top level, so the queue owner dates the first cleanup
attempt correctly.

**Steps to reproduce**
1. Save an active run lease whose metadata root is a JSON array.
2. Let its run end and the ownership settle move it to
`pending_cleanup`.
3. Run the ownership pass: the item can read as `queue_overdue`.

## What Changed

- `environment-run-lease-settlement.ts`: the merge starts from
`pendingCleanupMetadataObjectSql()`, which reads a scalar or array root
as an empty object.
- `environment-run-lease-ownership.test.ts`: a legacy lease with an
array metadata root is settled with the key at the top level.

## Verification

- `cd server && npx vitest run
src/__tests__/environment-run-lease-ownership.test.ts
src/__tests__/environment-pending-cleanup-queue-owner.test.ts` (embedded
Postgres).
- Full typecheck is left to CI (local memory limit).

## Risks

- Low risk. It changes only how one key is merged into a lease that the
settle moves to `pending_cleanup`. A malformed root becomes an object,
which the cleanup sweep already does for its own keys.

> 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:20:00 -06:00

159 lines
10 KiB
TypeScript

import { and, eq, inArray, ne, notInArray, or, sql } from "drizzle-orm";
import { environmentLeases, environments, heartbeatRuns, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
import { currentControllerBootId } from "./controller-process-identity.js";
import { writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { pendingCleanupMetadataObjectSql } from "./environment-cleanup-state.js";
import { readEnvironmentLeaseController } from "./environment-lease-controller.js";
import { allowLegacyShutdownWorkspaceCleanup } from "./legacy-shutdown-workspace-settlement.js";
import { nativeWorkspaceExportPendingForLease } from "./native-runtime/native-workspace-finalization-state.js";
import type { OwnerProof } from "./owner-proof.js";
import type { InProgressRecord, SettleOutcome } from "./recovery/ownership-registry.js";
import { isBuiltinSandboxProvider } from "./sandbox-provider-runtime.js";
import { prepareSandboxStopAndRetain } from "./sandbox-stop-and-retain.js";
/** Failure reason written on a run lease the shared ownership path settled. */
export const ORPHANED_RUN_LEASE_FAILURE_REASON = "orphaned_active_lease_recovered";
const TERMINAL_RUN_STATUSES = ["succeeded", "failed", "cancelled", "timed_out", "interrupted"];
const TERMINAL_NATIVE_PHASES = ["committed", "terminal_failure", "applied"];
type LeaseRow = typeof environmentLeases.$inferSelect;
/**
* Settle of the `environment_run_lease_active` ownership kind (plan Phase 2).
*
* A run's executor writes the terminal run status and then releases the run's
* environment lease from its `finally`. A crash between the two writes, or a
* release that skipped the lease, leaves the lease `active` with no holder.
* Previously a sweep released such a lease once it had been idle for five
* minutes. The shared reconciler now calls this only after the lease holder
* (the run while it is active, then the stamped lease holder) is proven gone.
*
* The settle compares and swaps on the exact lease row the proof judged: the
* same run, status `active`, and the same metadata (which holds the holder
* stamp). It settles ownership only:
* - a sandbox is stopped and kept (the stop-and-retain intent), never
* destroyed: deciding whether its files are still needed is the workspace
* preservation obligation, not this one;
* - a lease without a remote sandbox moves to `pending_cleanup`, as before;
* - a lease still needed by another obligation is left alone: a pending native
* workspace export, a legacy shutdown fence, another live lease on the same
* provider resource, or an accepted native result whose settlement can
* release the lease (its environment still exists) with the run's own
* resource disposition.
*/
export async function settleEnvironmentRunLease(db: Db, record: InProgressRecord,
context: { now: Date; proof: OwnerProof }): Promise<SettleOutcome> {
if (!record.runId) return { settled: false, reason: "lease_has_no_run" };
const runId = record.runId;
return db.transaction(async transaction => {
const tx = transaction as unknown as Db;
const [lease] = await tx.select().from(environmentLeases)
.where(and(eq(environmentLeases.id, record.recordId), eq(environmentLeases.status, "active"),
eq(environmentLeases.heartbeatRunId, runId)))
.for("update");
if (!lease) return { settled: false, reason: "lease_no_longer_active" };
if (!holderUnchanged(lease, record)) return { settled: false, reason: "owner_changed_since_proof" };
const [run] = await tx.select({ status: heartbeatRuns.status, controllerBootId: heartbeatRuns.controllerBootId })
.from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
if (run && !TERMINAL_RUN_STATUSES.includes(run.status)) return { settled: false, reason: "owner_run_active" };
if (record.owner.kind === "run" && !context.proof.localExecutionsKnown
&& await currentBootOwnsRun(tx, lease, run?.controllerBootId ?? null)) {
// A lease saved before holders were stamped: this boot may be releasing
// it right now (a hot-restart adoption), so only an earlier boot's
// lease is released by the run fact alone, unless the caller named every
// run this process executes.
return { settled: false, reason: "current_boot_owns_run" };
}
const kept = await keptByAnotherObligation(tx, lease);
if (kept) return { settled: false, reason: kept };
const recovery = { recoveredOrphanedRunLease: true, ownerProof: context.proof.reason };
if (await stopsASandbox(tx, lease)) {
const stopped = await prepareSandboxStopAndRetain(tx, lease, recovery);
return stopped ? { settled: true, reason: context.proof.reason } : { settled: false, reason: "lease_no_longer_active" };
}
// Keep the row's updatedAt, so the pending_cleanup sweep accepts it in the
// same tick instead of waiting out its own retry backoff.
const flipped = await writeEnvironmentCleanup(tx, write => write.update(environmentLeases)
.set({ status: "pending_cleanup", failureReason: ORPHANED_RUN_LEASE_FAILURE_REASON,
// When the first cleanup attempt became due (the row keeps updatedAt).
// A scalar or array root reads as an empty object, so the key is top level.
metadata: sql`${pendingCleanupMetadataObjectSql()} || ${JSON.stringify({
pendingCleanupRecoveredAtMs: context.now.getTime() })}::jsonb` })
.where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.status, "active"),
eq(environmentLeases.heartbeatRunId, runId),
sql`${environmentLeases.metadata} is not distinct from ${lease.metadata === null ? null : JSON.stringify(lease.metadata)}::jsonb`))
.returning({ id: environmentLeases.id }), { companyId: lease.companyId, runId, leaseId: lease.id });
return flipped.length ? { settled: true, reason: context.proof.reason } : { settled: false, reason: "lease_no_longer_active" };
});
}
/** The proof judged a holder stamp (or its absence, for a legacy row); a lease
* re-stamped since then belongs to someone else. */
function holderUnchanged(lease: LeaseRow, record: InProgressRecord): boolean {
const holder = readEnvironmentLeaseController(lease.metadata);
if (record.owner.kind === "run") return holder === null;
if (record.owner.kind !== "process") return false;
const stamp = record.owner.stamp;
return holder !== null && holder.bootId === stamp.bootId && holder.pid === stamp.pid
&& holder.hostHash === stamp.processSpace && holder.processStartedAt === (stamp.processStartedAt ?? null);
}
async function currentBootOwnsRun(tx: Db, lease: LeaseRow, runControllerBootId: string | null): Promise<boolean> {
const bootId = currentControllerBootId();
if (runControllerBootId === bootId) return true;
const [native] = await tx.select({ controllerBootId: nativeRunFinalizations.controllerBootId })
.from(nativeRunFinalizations).where(and(eq(nativeRunFinalizations.runId, lease.heartbeatRunId!),
eq(nativeRunFinalizations.companyId, lease.companyId)));
return native?.controllerBootId === bootId;
}
async function keptByAnotherObligation(tx: Db, lease: LeaseRow): Promise<string | null> {
if (await nativeWorkspaceExportPendingForLease(tx, lease)) return "workspace_export_pending";
if (!(await allowLegacyShutdownWorkspaceCleanup(tx, lease))) return "legacy_shutdown_fence";
// One provider resource id names one physical sandbox; another live lease
// on it still owns that sandbox.
if (lease.provider && lease.providerLeaseId) {
const [other] = await tx.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
ne(environmentLeases.id, lease.id), eq(environmentLeases.provider, lease.provider),
eq(environmentLeases.providerLeaseId, lease.providerLeaseId),
inArray(environmentLeases.status, ["active", "retained", "pending_cleanup"]))).limit(1);
if (other) return "provider_resource_shared";
}
// An accepted native result is finalized and then releases this lease with
// the run's resource disposition (native environment settlement).
const [accepted] = await tx.select({ runId: nativeRunFinalizations.runId }).from(nativeRunFinalizations)
.innerJoin(nativeRunResults, and(eq(nativeRunResults.id, nativeRunFinalizations.resultId),
eq(nativeRunResults.runId, nativeRunFinalizations.runId), eq(nativeRunResults.companyId, nativeRunFinalizations.companyId),
eq(nativeRunResults.schemaStatus, "accepted")))
.where(and(eq(nativeRunFinalizations.runId, lease.heartbeatRunId!), eq(nativeRunFinalizations.companyId, lease.companyId),
or(notInArray(nativeRunFinalizations.phase, TERMINAL_NATIVE_PHASES), eq(nativeRunFinalizations.phase, "committed"),
and(eq(nativeRunFinalizations.phase, "terminal_failure"),
sql`${nativeRunFinalizations.failureDetail} ? 'workspaceFinalizeAttempt'`)))).limit(1);
// Native settlement releases through the lease's environment; with the
// environment row gone it skips the lease, so this settle must not wait.
if (accepted && lease.environmentId) {
const [environment] = await tx.select({ id: environments.id }).from(environments)
.where(eq(environments.id, lease.environmentId));
if (environment) return "native_result_settles_lease";
}
return null;
}
/** A sandbox lease whose stop can be dispatched from the recorded lease alone
* (the same pins the stop-only cleanup requires). */
async function stopsASandbox(tx: Db, lease: LeaseRow): Promise<boolean> {
if (!lease.provider || lease.provider === "local" || !lease.providerLeaseId) return false;
let driver = typeof lease.metadata?.driver === "string" ? lease.metadata.driver : null;
if (!driver && lease.environmentId) {
const [environment] = await tx.select({ driver: environments.driver }).from(environments)
.where(eq(environments.id, lease.environmentId));
driver = environment?.driver ?? null;
}
if ((driver ?? "sandbox") !== "sandbox") return false;
const plugin = lease.metadata?.pluginId;
if (isBuiltinSandboxProvider(lease.provider)) return plugin == null && !lease.metadata?.sandboxProviderPlugin;
return typeof plugin === "string" && plugin.length > 0;
}