mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
<!-- 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>
159 lines
10 KiB
TypeScript
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;
|
|
}
|