Files
PaperClipAI/server/src/services/sandbox-creation-operations.ts
T
DottaandClaude Opus 5.5 a5fe5deebe fix(environments): reclaim sandboxes left by a restart during acquire or teardown (#19696)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Remote agent runs execute in provider sandboxes (for example
Daytona) that Paperclip creates and must clean up
> - Before each provider create, Paperclip saves a durable creation
intent (`sandbox_creation_operations`). Recovery uses it to find and
destroy a sandbox whose lease was never committed
> - Recovery waited 30 minutes after creation before it checked an
intent, because a live server could still be waiting for the provider
> - After a restart, the creating server is gone, but the intent still
waited the full 30 minutes. A short-lived or redeployed instance never
reclaimed those sandboxes
> - The teardown that follows has the same problem: a teardown claim
fences other sweeps for 15 minutes, and a restart during the claim kept
the sandbox alive until the fence expired
> - This pull request saves the server process identity with each intent
and each teardown claim, and ends the wait when same-host proof shows
that process exited
> - The benefit is that a restart during sandbox acquire or teardown no
longer leaves paid provider sandboxes behind

## Linked Issues or Issue Description

**What happened?**
A control-plane restart during Daytona sandbox acquire left the created
sandbox without an `environment_leases` row. In one restart chaos wave,
6 creation intents stayed `pending` with `next_attempt_at` = creation +
30 minutes. The wave ended (and its environment was deleted) before that
time, so 4 sandboxes stayed `stopped` in Daytona. Only Daytona's own
auto-delete would remove them. Two of the runs did not exist in
`heartbeat_runs` because they were non-run acquires; their intents still
had the run id.

**Expected behavior**
After a restart, each sandbox that this instance and company created,
with no committed lease and no live creator, is found by its exact
creation name and labels and destroyed through the plugin, after the
existing ownership checks. Sandboxes from another instance, another
host, or a live server stay untouched. Nothing acts org-wide.

**Steps to reproduce**
1. Start a Daytona-backed run.
2. Kill the server (SIGKILL) after the creation intent commits and
before the lease commits.
3. Restart the server. On master, the intent stays `pending` for 30
minutes and the sandbox stays in Daytona.
4. The regression tests in
`server/src/__tests__/sandbox-creation-operations.test.ts` reproduce
this with the controlled provider fixture.

Refs #18357 (added the durable creation intent this change completes).

**Paperclip version or commit**
master 07a548073a (live proof at 8d525dba7d + this change,
content-matched to the runner image)

## What Changed

- New `server/src/services/controller-process-identity.ts`: records the
server boot id, PID, a PID-space hash and the process start time. The
PID-space hash covers the hostname, the kernel boot id and the PID
namespace on Linux (the hostname and boot time elsewhere), so containers
that share a hostname but not a PID namespace never judge each other. It
proves that a recorded process exited only in the same PID space: the
PID is gone, or the PID is live with a definitely different start time.
Another PID space, an unreadable identity, an unidentified record, or
this server never counts as exited.
- `sandbox-creation-operations.ts`: each creation intent stores the
creating process and its settle deadline. Each recovery pass first moves
intents whose creator exited to "due now". If the early lookup finds no
sandbox before the settle deadline, the intent stays `pending` and
retries. It does not become manual reconciliation, because the provider
can still materialize an aborted request.
- `heartbeat/recovery.ts`: each pending-cleanup teardown claim stores
the claiming process, bound to its claim attempt id. Before the sweep
reads due leases, it ends the 15-minute fence of a claim whose claimer
exited. A newer claim from another path (workspace repair, export retry)
that kept an older record has a different attempt id, so it is never
released.
- Both recovery passes page through candidates with a stable id cursor.
Live, foreign or unidentified owners therefore cannot hide an abandoned
record behind one page.
- This covers every `createOwnedSandbox` caller, including non-task
probe and qualification acquires. Their intents have no issue and a
generated run id, which is why their run ids are missing from
`heartbeat_runs`.
- Recovery uses only the existing paths: exact
`paperclip-create-<operation id>` name and label inspection in the
plugin, `pending_cleanup` lease, and plugin teardown. No org-wide
listing or deletion was added.

## Verification

- Red, then green (controlled provider fixture, real embedded
PostgreSQL):
- `sandbox-creation-operations.test.ts` → "after the creating server
exits mid-acquire". Before the fix, the exited-creator case left no
cleanup lease. After the fix, the next sweep creates the
`pending_cleanup` lease and the teardown destroys the sandbox.
Counter-tests: a creation owned by this live server, another live
process, another host, or a committed lease is not inspected or
destroyed. An early "not found" keeps the intent pending, and a later
sweep still finds a late allocation.
- `heartbeat-pending-cleanup-sweep.test.ts` → "a teardown claim whose
server stopped mid-attempt". Before the fix, a lost claim blocked the
teardown. After the fix, it retries now. Counter-tests: claims from this
live server, another host, or without an identity keep their fence.
- Review regressions: "keeps the fence of a newer claim that kept an
earlier attempt's server record" and "pages past more fenced claims than
one sweep page" fail on the previous commit and pass now. "pages past
more unprovable creations than one page" guards the creation-side
cursor; the old unordered page happened to include the row, so it was
not red.
- After the review changes, `sandbox-creation-operations`,
`heartbeat-pending-cleanup-sweep`, `live-recovery-work` and
`environment-cleanup-work` pass (155 tests), and `tsc --noEmit` for
`server` passes.
- Related suites pass: sandbox-creation-operations (28),
environment-cleanup-work, task-creation-recovery,
native-startup-cleanup-recovery, sandbox-orphan-cleanup-spool,
plugin-warm-tasks, idle-completed-history (187 total), and the
pending-cleanup sweep suite. `tsc --noEmit` for `server` passes.
- Live Daytona restart chaos wave `w-20261107T001609-s11` (codex, A1,
K2+K4, concurrency 2, RC-06 masked, image `sha256:2767e91c…`, first fix
only): 4/4 tasks pass, all restart-crossing, R6 pass for every task,
`orphanSandboxes: []`. The server log and database show two creation
intents killed by K4 being reconciled right after restart and destroyed
through Paperclip. A third reclaimed sandbox lost its teardown claim to
the next K4 kill in the same second, so its 15-minute fence blocked the
environment delete. The second commit fixes that.
- Live Daytona wave `w-20261107T012048-s11`, same plan, both commits:
**4/4 pass, all restart-crossing, R6 pass for every task,
`orphanSandboxes: []`, no held leases.** The environment delete through
Paperclip succeeded with `remainingSandboxes: []`. One restart-time
sweep destroyed one sandbox through the plugin.

## Risks

- Same-host proof relies on PID liveness and process start time, as
native controller takeover already does. A false "exited" result is
limited to recovery running early. The plugin still checks exact name
and labels before any delete. An early not-found result stays pending.
- Multi-host deployments sharing one database keep the old deadlines,
because a different PID space is never judged. A redeploy into a new
host or PID namespace also keeps the old deadlines (fail closed).
- The live waves ran before the review commits. The review commits only
narrow when a record is judged, and they add paging.
- Intents and claims written before this change have no process
identity, so they keep the old deadlines.
- No schema change. The new fields live in existing JSON metadata.

> 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 editing, test execution). Extended reasoning
enabled.

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

280 lines
19 KiB
TypeScript

import { collectSecretRefPaths, isUuidSecretRef, parseSecretRefBindingObject, readConfigValueAtPath } from "./json-schema-secret-refs.js";
import { createHash, randomUUID } from "node:crypto";
import { and, asc, eq, gt, lte, or, sql } from "drizzle-orm";
import { controllerProcessExited, currentControllerBootId, currentControllerProcess, readControllerProcess } from "./controller-process-identity.js";
import { companies, environments, sandboxCreationOperations, type Db } from "@paperclipai/db";
import { readEnvironmentCreationCleanupError, type PluginEnvironmentLease, type PluginEnvironmentLeaseObservation } from "@paperclipai/plugin-sdk";
import { assertEnvironmentCompanyBinding, environmentService } from "./environments.js";
import { ENVIRONMENT_CLEANUP_RETRY_MS, recoverableSandboxCreationCondition } from "./environment-cleanup-state.js";
import { logger } from "../middleware/logger.js";
import { notifyEnvironmentCleanup, writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
export type SandboxCreationOperation = typeof sandboxCreationOperations.$inferSelect;
// A live creator may still be waiting for the provider, and a provider may still
// materialize an aborted request. Absence is conclusive only after this delay.
const CREATION_SETTLE_FLOOR_MS = 30 * 60_000;
const CREATION_PROMOTION_PAGE_SIZE = 100;
/** After a restart, creation intents from an exited server cannot still be in
* flight there. Reconcile them now instead of at the settle deadline. Absence
* stays inconclusive until that deadline; see recoverSandboxCreations. */
async function promoteAbandonedSandboxCreations(db: Db): Promise<void> {
const now = new Date();
// Page past live, foreign, and unidentified creators so they cannot hide an
// abandoned intent behind them.
let after: string | null = null;
for (;;) {
const waiting: Array<Pick<SandboxCreationOperation, "id" | "metadata">> = await db
.select({ id: sandboxCreationOperations.id, metadata: sandboxCreationOperations.metadata })
.from(sandboxCreationOperations).where(and(
eq(sandboxCreationOperations.state, "pending"), gt(sandboxCreationOperations.nextAttemptAt, now),
sql`${sandboxCreationOperations.metadata}->'creationController'->>'bootId' is distinct from ${currentControllerBootId()}`,
sql`coalesce(${sandboxCreationOperations.metadata}->>'creationControllerLost', '') <> 'true'`,
after === null ? undefined : gt(sandboxCreationOperations.id, after),
)).orderBy(asc(sandboxCreationOperations.id)).limit(CREATION_PROMOTION_PAGE_SIZE);
for (const operation of waiting) {
const controller = readControllerProcess(operation.metadata.creationController);
if (!controller || !await controllerProcessExited(controller)) continue;
await db.update(sandboxCreationOperations).set({ nextAttemptAt: now, updatedAt: now,
metadata: sql`coalesce(${sandboxCreationOperations.metadata}, '{}'::jsonb) || '{"creationControllerLost":true}'::jsonb` })
.where(and(eq(sandboxCreationOperations.id, operation.id), eq(sandboxCreationOperations.state, "pending"),
gt(sandboxCreationOperations.nextAttemptAt, now)));
}
if (waiting.length < CREATION_PROMOTION_PAGE_SIZE) return;
after = waiting[waiting.length - 1]!.id;
}
}
function creationAbsenceInconclusive(operation: SandboxCreationOperation): boolean {
const settleAt = Date.parse(typeof operation.metadata.creationSettleAt === "string" ? operation.metadata.creationSettleAt : "");
return operation.metadata.creationControllerLost === true && Number.isFinite(settleAt) && Date.now() < settleAt;
}
export function recoverableTaskCreation(operation: Pick<SandboxCreationOperation, "metadata">): boolean {
const metadata = operation.metadata;
return metadata.runnerTaskLease === true && metadata.creationOperationVersion === 1 &&
typeof metadata.runnerTaskCreationContextId === "string" && metadata.runnerTaskCreationContextId.length > 0 &&
metadata.providerMetadata !== null && typeof metadata.providerMetadata === "object" && !Array.isArray(metadata.providerMetadata);
}
type CreationOwner = {
companyId: string;
environmentId: string;
executionWorkspaceId?: string | null;
issueId?: string | null;
heartbeatRunId?: string | null;
provider: string;
pluginId?: string;
driverKey: string;
config: Record<string, unknown>;
metadata: Record<string, unknown>;
timeoutMs?: number;
requestKey?: string;
configSchema?: Record<string, unknown> | null;
resolvedConfig?: Record<string, unknown>;
};
export async function createOwnedSandbox(
db: Db, owner: CreationOwner,
create: (identity: { id: string; runId: string }) => Promise<PluginEnvironmentLease>,
): Promise<PluginEnvironmentLease> {
const credentialHashes: Record<string, string> = {};
for (const path of collectSecretRefPaths(owner.configSchema)) {
const value = readConfigValueAtPath(owner.config, path);
if (value != null && value !== "" && !(typeof value === "string" && isUuidSecretRef(value)) && !parseSecretRefBindingObject(value)) {
throw new Error("Sandbox creation requires saved secret references");
}
if (value != null && value !== "") {
const resolved = readConfigValueAtPath(owner.resolvedConfig ?? {}, path);
if (typeof resolved !== "string") throw new Error("Sandbox creation credential identity is unavailable");
credentialHashes[path] = createHash("sha256").update(resolved).digest("hex");
}
}
const id = randomUUID();
const runId = owner.heartbeatRunId ?? randomUUID();
const settleAt = new Date(Date.now() + Math.max(CREATION_SETTLE_FLOOR_MS, (owner.timeoutMs ?? 0) + 60_000));
const creationController = await currentControllerProcess();
const operation = await db.transaction(async tx => {
await notifyEnvironmentCleanup(tx, { companyId: owner.companyId, runId: owner.heartbeatRunId ?? undefined });
await assertEnvironmentCompanyBinding(tx as unknown as Db, owner.environmentId, owner.companyId);
const [inserted] = await tx.insert(sandboxCreationOperations).values({
id, companyId: owner.companyId, environmentId: owner.environmentId, originalEnvironmentId: owner.environmentId,
runId, requestKey: owner.requestKey ?? "run", issueId: owner.issueId, executionWorkspaceId: owner.executionWorkspaceId,
provider: owner.provider, pluginId: owner.pluginId, driverKey: owner.driverKey,
config: owner.config, metadata: { ...owner.metadata, creationCredentialHashes: credentialHashes,
creationController, creationSettleAt: settleAt.toISOString() },
nextAttemptAt: settleAt,
}).onConflictDoNothing().returning();
if (inserted) return inserted;
const [existing] = await tx.select().from(sandboxCreationOperations).where(and(
eq(sandboxCreationOperations.companyId, owner.companyId), eq(sandboxCreationOperations.originalEnvironmentId, owner.environmentId),
eq(sandboxCreationOperations.runId, runId), eq(sandboxCreationOperations.requestKey, owner.requestKey ?? "run"),
));
if (!existing) throw new Error("Sandbox creation ownership was not recorded");
return existing;
});
if (operation.id !== id) {
if (operation.state !== "attached" || !operation.leaseId) throw new Error("Sandbox creation requires reconciliation before another attempt");
const existing = await environmentService(db).getLeaseById(operation.leaseId);
if (!existing || existing.status !== "active" || !existing.providerLeaseId) throw new Error("Sandbox creation has already transferred ownership");
return { providerLeaseId: existing.providerLeaseId, expiresAt: existing.expiresAt?.toISOString(),
metadata: { ...existing.metadata, creationOperationId: operation.id } };
}
try {
const lease = await create({ id, runId });
return { ...lease, metadata: { ...lease.metadata, creationOperationId: id } };
} catch (error) {
if (recoverableTaskCreation(operation)) {
try {
await writeEnvironmentCleanup(db, tx => tx.update(sandboxCreationOperations).set({ nextAttemptAt: new Date(), updatedAt: new Date() })
.where(and(eq(sandboxCreationOperations.id, id), recoverableSandboxCreationCondition())),
{ companyId: operation.companyId, runId: operation.runId });
} catch {
logger.warn({ errorKind: "task_creation_recovery_wakeup_failed", operationId: id, companyId: operation.companyId },
"Task creation recovery retains its saved deadline");
}
}
throw error;
}
}
/** Keep an already returned resource identity even when task attachment fails.
* This enriches the existing owner; it never transfers or reopens ownership. */
export async function recordSandboxCreationResource(db: Db, input: {
id: string; companyId: string; environmentId: string; providerLeaseId: string;
providerMetadata?: Record<string, unknown>;
}): Promise<void> {
await writeEnvironmentCleanup(db, async tx => {
const [recorded] = await tx.update(sandboxCreationOperations).set({
nextAttemptAt: sql`case when ${sandboxCreationOperations.state} = 'needs_reconciliation' then clock_timestamp() else ${sandboxCreationOperations.nextAttemptAt} end`,
metadata: sql`coalesce(${sandboxCreationOperations.metadata}, '{}'::jsonb) || ${JSON.stringify({ creationProviderLeaseId: input.providerLeaseId, ...(input.providerMetadata ? { creationProviderMetadata: input.providerMetadata } : {}) })}::jsonb`,
updatedAt: new Date(),
}).where(and(eq(sandboxCreationOperations.id, input.id), eq(sandboxCreationOperations.companyId, input.companyId),
eq(sandboxCreationOperations.originalEnvironmentId, input.environmentId),
or(sql`${sandboxCreationOperations.metadata}->>'creationProviderLeaseId' is null`,
sql`${sandboxCreationOperations.metadata}->>'creationProviderLeaseId' = ${input.providerLeaseId}`),
)).returning({ id: sandboxCreationOperations.id });
if (!recorded) throw new Error("Sandbox creation resource identity changed");
}, { companyId: input.companyId });
}
export async function queueSandboxCreationCleanup(db: Db, input: {
id: string; companyId: string; environmentId: string; providerLeaseId: string;
providerMetadata?: Record<string, unknown>;
}): Promise<void> {
await db.transaction(async tx => {
await tx.select({ id: environments.id }).from(environments).where(eq(environments.id, input.environmentId)).for("update");
const [operation] = await tx.select().from(sandboxCreationOperations).where(and(eq(sandboxCreationOperations.id, input.id),
eq(sandboxCreationOperations.companyId, input.companyId), eq(sandboxCreationOperations.originalEnvironmentId, input.environmentId))).for("update");
if (!operation || (operation.metadata.creationProviderLeaseId && operation.metadata.creationProviderLeaseId !== input.providerLeaseId)) {
throw new Error("Sandbox creation cleanup ownership changed");
}
if (["attached", "cleanup_pending", "resolved"].includes(operation.state)) return;
const contextId = operation.metadata.runnerTaskLease === true && typeof operation.metadata.runnerTaskCreationContextId === "string"
? operation.metadata.runnerTaskCreationContextId : null;
if (operation.state !== "pending" && !(operation.state === "needs_reconciliation" && contextId &&
operation.metadata.creationProviderLeaseId === input.providerLeaseId)) throw new Error("Sandbox creation cleanup ownership changed");
await notifyEnvironmentCleanup(tx, { companyId: operation.companyId, runId: operation.runId });
const lease = await environmentService(tx as unknown as Db).insertPendingCleanupLease({
companyId: operation.companyId, environmentId: operation.originalEnvironmentId,
heartbeatRunId: operation.runId, issueId: operation.issueId, executionWorkspaceId: operation.executionWorkspaceId,
provider: operation.provider, providerLeaseId: input.providerLeaseId,
metadata: { ...operation.metadata, creationOperationId: operation.id, environmentId: operation.originalEnvironmentId,
providerMetadata: input.providerMetadata ?? operation.metadata.creationProviderMetadata ?? {}, runnerTaskRetiring: true,
...(contextId ? { runnerTaskStartupCleanup: { contextId, requiresPreparationStop: false } } : {}) },
failureReason: "interrupted_task_creation",
});
await tx.update(sandboxCreationOperations).set({ state: "cleanup_pending", leaseId: lease.id, updatedAt: new Date(),
metadata: { ...operation.metadata, creationProviderLeaseId: input.providerLeaseId,
creationProviderMetadata: input.providerMetadata ?? operation.metadata.creationProviderMetadata ?? {} },
}).where(eq(sandboxCreationOperations.id, operation.id));
});
}
export async function recoverSandboxCreations(db: Db,
inspect: (operation: SandboxCreationOperation) => Promise<PluginEnvironmentLeaseObservation | null>,
onTaskRecovered?: (operation: SandboxCreationOperation) => void | Promise<void>,
): Promise<void> {
await promoteAbandonedSandboxCreations(db);
const due = await db.select().from(sandboxCreationOperations).where(and(
recoverableSandboxCreationCondition(), lte(sandboxCreationOperations.nextAttemptAt, new Date()),
)).limit(100);
for (const candidate of due) {
try {
if (candidate.metadata.runnerTaskLease === true && typeof candidate.metadata.runnerTaskCreationContextId === "string" &&
typeof candidate.metadata.creationProviderLeaseId === "string") {
await queueSandboxCreationCleanup(db, { id: candidate.id, companyId: candidate.companyId,
environmentId: candidate.originalEnvironmentId, providerLeaseId: candidate.metadata.creationProviderLeaseId });
if (recoverableTaskCreation(candidate)) await onTaskRecovered?.(candidate);
continue;
}
const [existingCompany] = await db.select({ id: companies.id }).from(companies).where(eq(companies.id, candidate.companyId)).limit(1);
const observation = existingCompany ? await inspect(candidate) : null;
if (existingCompany && recoverableTaskCreation(candidate) && observation?.creationLease) {
const lease = observation.creationLease;
if (typeof lease.providerLeaseId !== "string" || lease.providerLeaseId.length === 0 || lease.providerLeaseId !== observation.providerLeaseId ||
!lease.metadata || typeof lease.metadata !== "object" || Array.isArray(lease.metadata)) {
throw new Error("Task creation recovery returned foreign ownership");
}
const identity = { id: candidate.id, companyId: candidate.companyId, environmentId: candidate.originalEnvironmentId,
providerLeaseId: lease.providerLeaseId, providerMetadata: lease.metadata };
await recordSandboxCreationResource(db, identity);
await queueSandboxCreationCleanup(db, identity);
await onTaskRecovered?.(candidate);
continue;
}
if (existingCompany && recoverableTaskCreation(candidate)) throw new Error("Task creation identity is not available yet");
const cleanup = observation?.creationCleanup ? readEnvironmentCreationCleanupError({ data: {
schema: "paperclip/environment-creation-cleanup/v1", cleanup: observation.creationCleanup,
} }) : null;
if (cleanup && (cleanup.companyId !== candidate.companyId || cleanup.environmentId !== candidate.originalEnvironmentId ||
cleanup.runId !== candidate.runId || cleanup.attemptId !== candidate.id || cleanup.providerLeaseId !== observation!.providerLeaseId)) {
throw new Error("Sandbox creation recovery returned foreign ownership");
}
// An early lookup after a lost creator may precede provider materialization.
if (existingCompany && !cleanup && creationAbsenceInconclusive(candidate)) {
throw new Error("Sandbox creation has not settled yet");
}
await db.transaction(async tx => {
const [company] = await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, candidate.companyId)).for("key share");
await tx.select({ id: environments.id }).from(environments).where(eq(environments.id, candidate.originalEnvironmentId)).for("update");
const [operation] = await tx.select().from(sandboxCreationOperations)
.where(eq(sandboxCreationOperations.id, candidate.id)).for("update");
if (!operation || operation.state !== "pending") return;
await notifyEnvironmentCleanup(tx, { companyId: operation.companyId });
if (!company || !cleanup) {
logger.warn({ errorKind: "sandbox_creation_needs_reconciliation", operationId: operation.id, companyId: operation.companyId },
"Sandbox creation could not be identified; operator reconciliation is required");
await tx.update(sandboxCreationOperations).set({ state: "needs_reconciliation", updatedAt: new Date(),
metadata: { ...operation.metadata, creationReconciliationReason: company ? "creation_identity_unavailable" : "company_deleted" },
})
.where(eq(sandboxCreationOperations.id, operation.id));
return;
}
const lease = await environmentService(tx as unknown as Db).insertPendingCleanupLease({
companyId: operation.companyId, environmentId: operation.originalEnvironmentId,
provider: operation.provider, providerLeaseId: cleanup.providerLeaseId,
metadata: { ...operation.metadata, creationOperationId: operation.id, failedCreateCleanup: cleanup },
failureReason: "interrupted_sandbox_creation",
});
await tx.update(sandboxCreationOperations).set({ state: "cleanup_pending", leaseId: lease.id, updatedAt: new Date() })
.where(eq(sandboxCreationOperations.id, operation.id));
});
} catch {
const retry = await db.transaction(async tx => {
const [company] = await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, candidate.companyId)).for("key share");
await notifyEnvironmentCleanup(tx, { companyId: candidate.companyId });
await tx.update(sandboxCreationOperations).set(company
? { nextAttemptAt: new Date(Date.now() + ENVIRONMENT_CLEANUP_RETRY_MS), updatedAt: new Date() }
: { state: "needs_reconciliation", updatedAt: new Date(),
metadata: sql`coalesce(${sandboxCreationOperations.metadata}, '{}'::jsonb) || '{"creationReconciliationReason":"company_deleted"}'::jsonb` })
.where(and(eq(sandboxCreationOperations.id, candidate.id), recoverableSandboxCreationCondition()));
return Boolean(company);
});
logger.warn({ errorKind: retry ? "sandbox_creation_recovery_failed" : "sandbox_creation_needs_reconciliation", operationId: candidate.id, companyId: candidate.companyId },
retry ? "Sandbox creation recovery will retry at its saved deadline" : "Sandbox creation company was deleted; operator reconciliation is required");
}
}
}