Merge commit '45a3a92e6dc52d20923b9aa34a22fee2ec5443f9' into codex/sleep-status-card-evaluation

* commit '45a3a92e6dc52d20923b9aa34a22fee2ec5443f9':
  fix(environments): persist sandbox ownership before creation (#18357)
  fix(server): bound failed lifecycle verification (#18383)
This commit is contained in:
Dotta committed 2026-10-30 17:50:38 -05:00
commit 74ac3ec39b
58 files changed
+2225 -1922

No files matched your search

+8 -2
View File
@@ -1,5 +1,8 @@
/** Run from the server package with its configured storage and DATABASE_URL. */
import { createDb } from "@paperclipai/db";
import { loadStorageConfig } from "../src/storage/config.js";
import { createStorageProviderFromConfig } from "../src/storage/provider-registry.js";
import { createStorageService } from "../src/storage/service.js";
import { skillPackageMaintenance } from "../src/services/skill-package-maintenance.js";
import { skillInstallationMaintenance } from "../src/services/skill-installation-maintenance.js";
import { isCloudManagedInstance } from "../src/services/cloud-instance.js";
@@ -19,8 +22,11 @@ try {
if (!current) throw new Error("Initialize Cloud runtime configuration before running skill package maintenance");
setCloudRuntimeConfigSnapshot(current);
}
const service = skillPackageMaintenance(db);
const installations = skillInstallationMaintenance(db);
// Maintenance must inspect the old provider before Cloud cutover. Server
// startup prerequisites still apply to the normal global storage service.
const storage = createStorageService(createStorageProviderFromConfig(loadStorageConfig()));
const service = skillPackageMaintenance(db, storage);
const installations = skillInstallationMaintenance(db, storage);
const actions = { snapshots: service.migrateSnapshots, versions: service.migrateVersions,
orphans: service.collectOrphans, deletions: service.deleteObjects,
installations: installations.installations, eligibility: installations.eligibility, verification: installations.verifyVersions };
@@ -0,0 +1,67 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
// Keep the real directory, command and adapter checks. Runtime staging is a
// separate operation and must not copy files or contact a provider in this test.
vi.mock("@paperclipai/adapter-utils/execution-target", async importOriginal => ({
...await importOriginal<typeof import("@paperclipai/adapter-utils/execution-target")>(),
prepareAdapterExecutionTargetRuntime: async () => ({
workspaceRemoteDir: "/workspace", runtimeRootDir: "/workspace/.paperclip-runtime",
assetDirs: {}, restoreWorkspace: async () => {},
}),
}));
import { testEnvironment } from "@paperclipai/adapter-opencode-local/server";
import { assertHarnessTestPassed } from "../services/agent-harness-verification.js";
import { lifecycleFailureMessage, lifecycleVerificationNeedsRetry } from "../modules/agent-lifecycle/application/failure.js";
describe("remote harness verification recovery", () => {
let configHome: string;
beforeAll(async () => { configHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-verification-")); });
afterAll(async () => { if (configHome) await rm(configHome, { recursive: true, force: true }); });
const context = { stage: "host" as const, phase: "verifying" as const, verificationFailures: 100 };
it.each([
["directory", "timeout"], ["directory", "transport"],
["command", "timeout"], ["command", "transport"],
["directory", "missing"], ["command", "missing"],
] as const)("retains %s %s through the real adapter check and recovers when repaired", async (check, fault) => {
let failing = true;
const target: AdapterExecutionTarget = {
kind: "remote", transport: "sandbox", remoteCwd: "/workspace", runner: {
execute: async input => {
const script = input.args?.join(" ") ?? "";
const selected = check === "directory" ? script.includes("[ -d ") : script.includes("command -v ");
if (failing && selected && fault === "transport") {
throw Object.assign(new Error("private provider output"), { code: "ECONNRESET" });
}
return {
exitCode: failing && selected ? fault === "timeout" ? null : 1 : 0,
timedOut: failing && selected && fault === "timeout", signal: null, stderr: "", pid: null,
startedAt: new Date().toISOString(),
stdout: input.command === "opencode" ? JSON.stringify({ type: "text", part: { text: "hello" } }) : "",
};
},
},
};
const run = () => testEnvironment({ companyId: "company", adapterType: "opencode_local", executionTarget: target,
config: { command: "opencode", model: "provider/model", env: { XDG_CONFIG_HOME: configHome } } });
const result = await run();
const failed = result.checks.find(entry => entry.code === (check === "directory" ? "opencode_cwd_invalid" : "opencode_command_unresolvable"));
expect(failed?.level).toBe("error");
expect(failed?.retryable === true).toBe(fault !== "missing");
let error: unknown;
try { assertHarnessTestPassed(result); } catch (caught) { error = caught; }
expect(error).toMatchObject({ code: fault !== "missing" ? "test_environment_unavailable"
: check === "directory" ? "harness_cwd_invalid" : "harness_command_unavailable" });
expect(lifecycleVerificationNeedsRetry(context, error)).toBe(fault === "missing");
expect(lifecycleFailureMessage(context, error)).not.toContain("private provider output");
if (fault !== "missing") expect(lifecycleFailureMessage(context, error)).toContain("retry automatically");
failing = false;
const recovered = await run();
expect(() => assertHarnessTestPassed(recovered)).not.toThrow();
});
});
@@ -24,6 +24,13 @@ describe("saved harness verification", () => {
expect(error).toMatchObject({ code, message: "The harness test failed" });
}
});
it("preserves a retryable failed check before classifying configuration failures", () => {
const temporary = result("fail", ["opencode_cwd_invalid", "opencode_command_unresolvable"]);
temporary.checks[0] = { ...temporary.checks[0], level: "error", retryable: true };
expect(() => assertHarnessTestPassed(temporary)).toThrow(expect.objectContaining({ code: "test_environment_unavailable" }));
temporary.checks[0].level = "info";
expect(() => assertHarnessTestPassed(temporary)).toThrow(expect.objectContaining({ code: "harness_command_unavailable" }));
});
it.each([
["fail", []], ["warn", ["claude_hello_probe_auth_required"]],
["warn", ["gemini_hello_probe_timed_out"]], ["pass", ["claude_hello_probe_skipped_custom_command"]],
@@ -1,3 +1,5 @@
import { readIdleSleepSafety } from "../services/idle-sleep-safety.js";
import { createOwnedSandbox } from "../services/sandbox-creation-operations.js";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -7,7 +9,7 @@ import { idleWorkSnapshot } from "../services/task-admission.js";
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq, sql } from "drizzle-orm";
import { companies, createDb, environmentLeases, environments, type Db } from "@paperclipai/db";
import { companies, createDb, sandboxCreationOperations, environmentLeases, environments, type Db } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { registerEnvironmentCleanupWork } from "../services/environment-cleanup-work.js";
@@ -34,7 +36,7 @@ describe.skipIf(!support.supported)("environment cleanup scheduling", () => {
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.01)`)));
}, 90_000);
beforeEach(async () => {
await db.execute(sql`TRUNCATE companies, environments CASCADE`);
await db.execute(sql`TRUNCATE sandbox_creation_operations, companies, environments CASCADE`);
companyId = randomUUID(); environmentId = randomUUID(); draining = false; failing = false; errors.length = 0;
await db.insert(companies).values({ id: companyId, name: "Cleanup worker", issuePrefix: "CLN" });
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
@@ -99,6 +101,64 @@ describe.skipIf(!support.supported)("environment cleanup scheduling", () => {
await settle(180_000); expect(inspections).toHaveBeenCalledTimes(3);
await quiet();
});
it("reconstructs a creation deadline after restart and leaves manual reconciliation quiet", async () => {
await start(); await quiet();
await createOwnedSandbox(db, { companyId, environmentId, provider: "fixture", driverKey: "fixture", config: {}, metadata: {} },
async () => ({ providerLeaseId: "fixture-unattached" }));
await settle();
expect(coordinator!.nextWakeAt()).toBeGreaterThan(Date.now());
worker!.stop(); await coordinator!.stop();
await start();
expect(coordinator!.nextWakeAt()).toBeGreaterThan(Date.now());
await settle(31 * 60_000);
await vi.waitFor(async () => expect(await db.select().from(sandboxCreationOperations)).toEqual([
expect.objectContaining({ state: "needs_reconciliation" }),
]));
await quiet();
});
it("settles a deleted-company creation as manual history across restart without idle polling", async () => {
await start(); await quiet();
await createOwnedSandbox(db, { companyId, environmentId, provider: "fixture", driverKey: "fixture", config: {}, metadata: {} },
async () => ({ providerLeaseId: "fixture-deleted-company" }));
await db.delete(companies).where(eq(companies.id, companyId));
await settle(31 * 60_000);
await vi.waitFor(async () => expect(await db.select().from(sandboxCreationOperations)).toEqual([
expect.objectContaining({ state: "needs_reconciliation", metadata: expect.objectContaining({ creationReconciliationReason: "company_deleted" }) }),
]));
await quiet();
worker!.stop(); await coordinator!.stop();
await start(); await quiet();
expect(releases).not.toHaveBeenCalled(); expect(inspections).not.toHaveBeenCalled();
const ownerId = randomUUID();
const hold = { ownerId, draining: true, startedAt: new Date(Date.now() - 1000), expiresAt: new Date(Date.now() + 60_000), activeRuns: 0, pendingWakes: 0 };
expect(await readIdleSleepSafety(db, () => hold, Date.now, ownerId, async () => "none"))
.toEqual({ version: 1, backgroundWork: "none" });
await quiet();
});
it("verifies ordinary cleanup carrying creation provenance and stops provider polling", async () => {
const lease = await pending();
await environmentService(db).updateLeaseMetadata(lease.id, { driver: "sandbox", creationOperationId: randomUUID() });
await environmentService(db).releaseLease(lease.id, "failed", { cleanupStatus: "success" });
await start(true);
expect(inspections).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ providerLeaseId: lease.providerLeaseId,
metadata: expect.objectContaining({ creationOperationId: expect.any(String) }) }));
await quiet();
worker!.stop(); await coordinator!.stop();
await start(true); expect(inspections).toHaveBeenCalledTimes(1); await quiet();
});
it("preserves a first creation signal during drain and disarms after atomic attachment", async () => {
await start(); await quiet(); draining = true;
const created = await createOwnedSandbox(db, { companyId, environmentId, provider: "fixture", driverKey: "fixture", config: {}, metadata: {} },
async () => ({ providerLeaseId: "fixture-attached" }));
const count = reads.mock.calls.length;
await settle(60_000, false); expect(reads).toHaveBeenCalledTimes(count);
draining = false;
await environmentService(db).acquireLease({ companyId, environmentId, provider: "fixture", providerLeaseId: created.providerLeaseId,
creationOperationId: String(created.metadata!.creationOperationId), metadata: created.metadata });
await settle();
expect(await db.select().from(sandboxCreationOperations)).toEqual([expect.objectContaining({ state: "attached" })]);
await quiet();
});
it("wakes on a real orphan insert, waits for its backoff, then disarms after deletion", async () => {
await start(); await quiet();
const lease = await pending(); await settle();
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,34 @@
import { createDb } from "@paperclipai/db";
import { environmentRuntimeService } from "../../services/environment-runtime.js";
import { environmentService } from "../../services/environments.js";
import type { PluginWorkerManager } from "../../services/plugin-worker-manager.js";
const db = createDb(process.env.CREATION_FIXTURE_DATABASE_URL!);
const workers = {
isRunning: () => true,
getWorker: () => ({ supportedMethods: ["environmentAcquireLease", "environmentInspectLease", "environmentDestroyLease"] }),
call: async (_pluginId: string, method: string, input: unknown) => {
const response = await fetch(process.env.CREATION_FIXTURE_PROVIDER_URL!, {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ method, input }),
});
if (!response.ok) throw new Error("Controlled provider unavailable");
return response.json();
},
} as unknown as PluginWorkerManager;
try {
const runtime = environmentRuntimeService(db, {
pluginWorkerManager: workers, orphanCleanupSpoolDir: process.env.CREATION_FIXTURE_EMPTY_DISK,
});
if (process.env.CREATION_FIXTURE_ACTION === "acquire") {
await runtime.acquireRunLease({ companyId: process.env.CREATION_FIXTURE_COMPANY_ID!,
environment: (await environmentService(db).getById(process.env.CREATION_FIXTURE_ENVIRONMENT_ID!))!,
heartbeatRunId: process.env.CREATION_FIXTURE_RUN_ID!, issueId: null, persistedExecutionWorkspace: null });
} else {
await runtime.flushDeferredOrphanCleanups();
}
process.stdout.write(JSON.stringify({ ok: true }));
} catch {
process.stdout.write(JSON.stringify({ ok: false }));
} finally {
await db.$client.end({ timeout: 1 });
}
@@ -278,6 +278,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
}, { timeout: 5_000 });
expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentAcquireLease", {
creationOperationId: expect.any(String),
driverKey: "sandbox",
companyId,
environmentId,
@@ -745,6 +746,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
}, { timeout: 5_000 });
expect(workerManager.call).toHaveBeenNthCalledWith(1, pluginId, "environmentAcquireLease", {
creationOperationId: expect.any(String),
driverKey: "sandbox",
companyId,
environmentId: newEnvironmentId,
@@ -77,7 +77,12 @@ describe.skipIf(!support.supported)("managed skills use committed versions", ()
.toMatchObject({ content: "complete root guide" });
});
it("loads persisted Cloud configuration for read-only maintenance without seeding it", async () => {
it.each([
{ storage: "legacy local", env: { PAPERCLIP_STORAGE_PROVIDER: "local_disk" }, expectedStatus: "local_storage" },
{ storage: "shared S3", env: { PAPERCLIP_STORAGE_PROVIDER: "s3",
PAPERCLIP_STORAGE_S3_BUCKET: "managed-skill-cli-fixture", PAPERCLIP_STORAGE_S3_REGION: "us-east-1",
PAPERCLIP_SECRETS_MASTER_KEY: "12345678901234567890123456789012" }, expectedStatus: "provider_mismatch" },
])("loads persisted Cloud configuration for read-only $storage maintenance without seeding it", async ({ env, expectedStatus }) => {
const companyId = await company();
const skill = await companySkillService(db).createLocalSkill(companyId, { name: "Cloud CLI", markdown: "# Cloud CLI" });
const local = await skillInstallationMaintenance(db).verifyVersions({ companyId, limit: 50 });
@@ -89,8 +94,7 @@ describe.skipIf(!support.supported)("managed skills use committed versions", ()
`--phase=${phase}`, `--company=${companyId}`, "--limit=50",
], { env: { ...process.env, DATABASE_URL: database.connectionString,
PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "disposable-cloud-cli-fixture",
PAPERCLIP_STORAGE_PROVIDER: "s3", PAPERCLIP_STORAGE_S3_BUCKET: "managed-skill-cli-fixture",
PAPERCLIP_STORAGE_S3_REGION: "us-east-1", PAPERCLIP_SECRETS_MASTER_KEY: "12345678901234567890123456789012" },
PAPERCLIP_STORAGE_LOCAL_DIR: path.join(root, "objects"), ...env },
timeout: 60_000, maxBuffer: 1024 * 1024 });
await expect(run("eligibility")).rejects.toThrow("Initialize Cloud runtime configuration");
expect(await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, key))).toHaveLength(0);
@@ -99,7 +103,7 @@ describe.skipIf(!support.supported)("managed skills use committed versions", ()
const eligibility = JSON.parse((await run("eligibility")).stdout.trim().split("\n").at(-1)!);
expect(eligibility.results).toEqual([expect.objectContaining({ versionId: skill.currentVersionId, status: "version_backed" })]);
const verification = JSON.parse((await run("verification")).stdout.trim().split("\n").at(-1)!);
expect(verification.results).toEqual([expect.objectContaining({ versionId: skill.currentVersionId, status: "provider_mismatch" })]);
expect(verification.results).toEqual([expect.objectContaining({ versionId: skill.currentVersionId, status: expectedStatus })]);
expect(await db.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, key))).toEqual([persisted]);
} finally {
await db.delete(instanceSettings).where(eq(instanceSettings.singletonKey, key));
@@ -1,11 +1,13 @@
import { flushDeferredEnvironmentCleanup, hasDeferredEnvironmentCleanup, registerDeferredEnvironmentCleanup, subscribeDeferredEnvironmentCleanup } from "../services/environment-cleanup-notifications.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { mkdtemp, readdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { agents, companies, createDb, applyPendingMigrations, environmentLeases, environments, heartbeatRuns, issues, plugins, projects } from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import { agents, companies, createDb, applyPendingMigrations, environmentLeases, environments, heartbeatRuns, issues, plugins, projects, sandboxCreationOperations } from "@paperclipai/db";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import * as orphanCleanup from "../services/sandbox-orphan-cleanup-spool.js";
import { recordSandboxCreationResource } from "../services/sandbox-creation-operations.js";
import { resolvePluginTaskResume } from "../services/environment-task-runtime.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
@@ -51,7 +53,7 @@ const support=external ? {supported:true} : await getEmbeddedPostgresTestSupport
if(p.operation.kind==="describe") return {kind:"description",taskId:p.taskId,runner:{version:"fixture",digest:`sha256:${"a".repeat(64)}`},warmTaskVersion:1};
return {kind:"accepted",taskId:p.taskId};
}} as unknown as PluginWorkerManager;
const runtime=()=>environmentRuntimeService(db,{pluginWorkerManager:workers});
const runtime=(orphanCleanupSpoolDir?:string)=>environmentRuntimeService(db,{pluginWorkerManager:workers,orphanCleanupSpoolDir});
const acquire=()=>runtime().acquireRunLease({companyId,environment:environment as never,issueId,agentId,heartbeatRunId:runId,persistedExecutionWorkspace:null,adapterType:"paperclip_runner",runnerTaskProjectIds:projectIds,requestedExpiresAt:deadline});
const first=await acquire();
const identity={runnerInstanceId:"runner",environmentLeaseId:first.lease.id,runId,normalizedSessionId:"session",turnId:"turn",itemId:"item"};
@@ -126,58 +128,64 @@ const support=external ? {supported:true} : await getEmbeddedPostgresTestSupport
await expect(resolvePluginTaskResume(db,{companyId:f.companyId,leaseId:next.lease.id,priorIdentity:f.identity})).resolves.toBe(f.first.lease.providerLeaseId);
});
it.each(["ownership-lost","database-error","cleanup-error","spool"] as const)("compensates an unowned replacement task: %s",async mode=>{
it.each(["ownership-lost","database-error"] as const)("retains a durable operation for an unattached replacement task: %s",async mode=>{
const f=await fixture();await f.nextRun();const next=await f.acquire();f.stopped();
if(mode==="cleanup-error" || mode==="spool") f.failRelease();
const append=vi.fn(async()=>true);
let delayFlush=false,flushCalls=0,releaseFlush!:()=>void;
const flushGate=new Promise<void>(resolve=>{releaseFlush=resolve;});
const stopGate=mode==="spool" ? registerDeferredEnvironmentCleanup(db,async()=>{
if(delayFlush && ++flushCalls===1) await flushGate;
return {recovered:0,pending:0};
}) : undefined;
const wake=vi.fn();
const unsubscribe=subscribeDeferredEnvironmentCleanup(db,wake);
const spool=mode==="spool" ? vi.spyOn(orphanCleanup,"createSandboxOrphanCleanupSpool").mockReturnValue({append,load:async()=>[],remove:async()=>{}}) : undefined;
let replacementFailed=false;
const transact=db.transaction.bind(db);
const transaction=mode==="spool" ? vi.spyOn(db,"transaction").mockImplementation((...args)=>replacementFailed
? Promise.reject(Error("database unavailable")) : transact(...args)) : undefined;
const original=db.update.bind(db);
const update=vi.spyOn(db,"update").mockImplementation(((table:any)=>{
const query=original(table),set=query.set.bind(query);
query.set=((values:any)=>values.providerLeaseId === "task-2"
? {where:()=>({returning:async()=>{replacementFailed=true;if(mode!=="ownership-lost") throw Error("replacement write failed");return [];}})}
: set(values)) as never;
return query;
}) as never);
const disk=await mkdtemp(join(tmpdir(),"replacement-intent-"));
const runtime=()=>f.runtime(disk);
await db.execute(sql.raw(`create or replace function reject_task_replacement() returns trigger language plpgsql as $$ begin
if new.provider_lease_id = 'task-2' then ${mode === "ownership-lost" ? "return null;" : "raise exception 'replacement write failed';"} end if; return new; end $$`));
await db.execute(sql`create trigger reject_task_replacement before update on environment_leases for each row execute function reject_task_replacement()`);
try {
await expect(f.runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}}))
.rejects.toThrow("reconcile the same task");
if(mode==="spool") {
expect(wake).toHaveBeenCalledOnce();
expect(hasDeferredEnvironmentCleanup(db)).toBe(true);
await expect(flushDeferredEnvironmentCleanup(db)).rejects.toThrow("Deferred orphan cleanup failed");
expect(hasDeferredEnvironmentCleanup(db)).toBe(true);
}
} finally {update.mockRestore();transaction?.mockRestore();spool?.mockRestore();unsubscribe();}
expect(f.calls).toContain("environmentReleaseLease");
if(mode==="spool") expect(append).toHaveBeenCalledWith(expect.objectContaining({providerLeaseId:"task-2",metadata:expect.objectContaining({driver:"plugin",runnerTaskDispatched:false})}));
const rows=await db.select().from(environmentLeases).where(eq(environmentLeases.companyId,f.companyId));
expect(rows.filter(row=>row.providerLeaseId === "task-2")).toEqual(mode==="cleanup-error"
? [expect.objectContaining({status:"pending_cleanup",failureReason:"plugin_task_replacement_unowned",metadata:expect.objectContaining({runnerTaskDispatched:false})})] : []);
if(mode==="spool") {
delayFlush=true;
const delayed=flushDeferredEnvironmentCleanup(db);
try { expect(await flushDeferredEnvironmentCleanup(db)).toEqual({recovered:1,pending:0}); }
finally { releaseFlush(); stopGate!(); }
expect(await delayed).toEqual({recovered:1,pending:0});
expect(hasDeferredEnvironmentCleanup(db)).toBe(false);
const recovered=await db.select().from(environmentLeases).where(eq(environmentLeases.companyId,f.companyId));
expect(recovered.filter(row=>row.providerLeaseId==="task-2")).toEqual([
expect.objectContaining({status:"pending_cleanup",failureReason:"plugin_task_replacement_unowned"}),
]);
}
await expect(runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}})).rejects.toThrow("reconcile the same task");
} finally { await db.execute(sql`drop trigger reject_task_replacement on environment_leases`); }
expect(f.calls).not.toContain("environmentReleaseLease");
const [lease]=await db.select().from(environmentLeases).where(eq(environmentLeases.id,next.lease.id));
expect(lease.providerLeaseId).toBe(next.lease.providerLeaseId);
const operations=await db.select().from(sandboxCreationOperations).where(eq(sandboxCreationOperations.companyId,f.companyId));
const pending=operations.filter(row=>row.state === "pending");
expect(pending).toHaveLength(1);expect(pending[0].requestKey).toBe(next.lease.providerLeaseId);
expect(pending[0].metadata.creationProviderLeaseId).toBe("task-2");expect(pending[0].config).toEqual({});
expect(f.acquireRequests.at(-1)?.creationOperationId).toBe(pending[0].id);
const creates=f.acquireRequests.length;
await expect(runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}})).rejects.toThrow("reconcile the same task");
expect(f.acquireRequests).toHaveLength(creates);
await db.update(sandboxCreationOperations).set({nextAttemptAt:new Date(0)}).where(eq(sandboxCreationOperations.id,pending[0].id));
await runtime().flushDeferredOrphanCleanups();
const [manual]=await db.select().from(sandboxCreationOperations).where(eq(sandboxCreationOperations.id,pending[0].id));
expect(manual.state).toBe("needs_reconciliation");expect(manual.metadata.creationProviderLeaseId).toBe("task-2");
await runtime().flushDeferredOrphanCleanups();expect(f.acquireRequests).toHaveLength(creates);
expect(f.calls).not.toContain("environmentReleaseLease");
expect(await readdir(disk)).toEqual([]);
await rm(disk,{recursive:true,force:true});
});
it("retains the known replacement identity after a committed attachment response is lost",async()=>{
const f=await fixture();await f.nextRun();const next=await f.acquire();f.stopped();
const transaction=db.transaction.bind(db);let lost=false;
const response=vi.spyOn(db,"transaction").mockImplementation((async(...args:any[])=>{
const result=await (transaction as any)(...args);
if(!lost && result?.providerLeaseId === "task-2") {lost=true;throw new Error("Attachment commit response lost");}
return result;
}) as typeof db.transaction);
try {
await expect(f.runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}})).rejects.toThrow("reconcile the same task");
} finally {response.mockRestore();}
expect(lost).toBe(true);
const operations=await db.select().from(sandboxCreationOperations).where(eq(sandboxCreationOperations.companyId,f.companyId));
const replacement=operations.find(operation=>operation.requestKey === next.lease.providerLeaseId)!;
expect(replacement).toMatchObject({state:"attached",leaseId:next.lease.id,metadata:{creationProviderLeaseId:"task-2"}});
const identity={id:replacement.id,companyId:f.companyId,environmentId:replacement.originalEnvironmentId,providerLeaseId:"task-2"};
await recordSandboxCreationResource(db,identity);
await expect(recordSandboxCreationResource(db,{...identity,providerLeaseId:"different-resource"})).rejects.toThrow("identity changed");
await expect(recordSandboxCreationResource(db,{...identity,companyId:randomUUID()})).rejects.toThrow("identity changed");
expect((await db.select().from(sandboxCreationOperations).where(eq(sandboxCreationOperations.id,replacement.id)))[0])
.toMatchObject({state:"attached",metadata:{creationProviderLeaseId:"task-2"}});
const [saved]=await db.select().from(environmentLeases).where(eq(environmentLeases.id,next.lease.id));
expect(saved).toMatchObject({providerLeaseId:"task-2",status:"active"});
await db.update(sandboxCreationOperations).set({nextAttemptAt:new Date(0)}).where(eq(sandboxCreationOperations.id,replacement.id));
await f.runtime().flushDeferredOrphanCleanups();
expect((await f.runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}})).taskId).toBe("task-2");
expect(f.acquireRequests).toHaveLength(2);expect(f.calls).not.toContain("environmentReleaseLease");
});
it.each(["added","removed"] as const)("retires retained mounts when an authorized project is %s after controller restart",async mode=>{
@@ -203,7 +211,9 @@ const support=external ? {supported:true} : await getEmbeddedPostgresTestSupport
const replacing=f.runtime().task({companyId:f.companyId,leaseId:next.lease.id,operation:{kind:"describe"}});
if(["violating","missing-bounded","invalid","expired"].includes(mode)) {
await expect(replacing).rejects.toThrow("reconcile the same task");
expect(f.calls).toContain("environmentReleaseLease");
expect(f.calls).not.toContain("environmentReleaseLease");
const operations=await db.select().from(sandboxCreationOperations).where(eq(sandboxCreationOperations.companyId,f.companyId));
expect(operations.filter(row=>row.state === "pending")).toHaveLength(1);
} else {
const description=await replacing;
const [saved]=await db.select().from(environmentLeases).where(eq(environmentLeases.id,next.lease.id));
@@ -0,0 +1,333 @@
import { execFile } from "node:child_process";
import { createServer } from "node:http";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq, sql } from "drizzle-orm";
import { agents, builtInManagedResources, companies, companySecretVersions, createDb, environmentLeases, environments, heartbeatRuns, plugins, sandboxCreationOperations } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import { environmentService } from "../services/environments.js";
import { HttpError } from "../errors.js";
import * as secretOwner from "../services/secrets.js";
import { readIdleSleepSafety } from "../services/idle-sleep-safety.js";
import { nextEnvironmentCleanupAt } from "../services/environment-cleanup-state.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
describe("durable sandbox creation through the runtime entry point", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let companyId: string, environmentId: string, pluginId: string, runId: string, spool: string;
let creates: number, destroys: number, loseReply: boolean, failDestroy: boolean;
let beforeReply: (() => Promise<void>) | undefined;
const resources = new Map<string, string>();
let workers: PluginWorkerManager;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("sandbox-creation-operations-");
db = createDb(database.connectionString);
}, 90_000);
beforeEach(async () => {
await db.execute(sql`truncate sandbox_creation_operations, companies, environments, plugins cascade`);
companyId = randomUUID(); environmentId = randomUUID(); pluginId = randomUUID(); runId = randomUUID();
const agentId = randomUUID(); creates = 0; destroys = 0; loseReply = false; failDestroy = false; beforeReply = undefined; resources.clear();
spool = await mkdtemp(join(tmpdir(), "empty-sandbox-web-"));
await db.insert(companies).values({ id: companyId, name: "Creation fixture", issuePrefix: "CRT" });
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", role: "engineer", adapterType: "codex_local" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", invocationSource: "manual" });
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox", config: { provider: "creation-fixture", image: "fixture", reuseLease: false } });
await db.insert(plugins).values({ id: pluginId, pluginKey: "paperclip.creation-fixture", packageName: "creation-fixture", version: "1.0.0", apiVersion: 1,
status: "ready", categories: ["automation"], manifestJson: { id: "paperclip.creation-fixture", version: "1.0.0", apiVersion: 1,
displayName: "Creation fixture", description: "Controlled remote provider", author: "Paperclip", categories: ["automation"],
capabilities: ["environment.drivers.register"], entrypoints: { worker: "fixture.js" }, environmentDrivers: [{
driverKey: "creation-fixture", kind: "sandbox_provider", displayName: "Fixture", creationOperationVersion: 1, configSchema: { type: "object" },
}] } });
workers = {
isRunning: () => true,
getWorker: () => ({ supportedMethods: ["environmentAcquireLease", "environmentInspectLease", "environmentDestroyLease"] }),
call: vi.fn(async (_pluginId: string, method: string, input: any) => {
if (method === "environmentAcquireLease") {
expect(await db.select().from(sandboxCreationOperations)).toContainEqual(expect.objectContaining({ id: input.creationOperationId, state: "pending" }));
creates++;
const resource = `provider-${input.creationOperationId}`;
resources.set(input.creationOperationId, resource);
await beforeReply?.();
if (loseReply) throw new Error("Lost provider reply");
return { providerLeaseId: resource, metadata: { remoteCwd: "/workspace" } };
}
if (method === "environmentInspectLease") {
const id = input.creationOperation.id;
const resource = resources.get(id);
if (!resource) return { providerLeaseId: id, state: "unknown" };
return { providerLeaseId: resource, state: "active", creationCleanup: { providerLeaseId: resource,
observedProviderLeaseId: resource, companyId, environmentId, runId: input.creationOperation.runId, attemptId: id,
accountFingerprint: "a".repeat(64), labels: { "paperclip-create-attempt": id } } };
}
if (method === "environmentDestroyLease") {
destroys++;
if (failDestroy) throw new Error("Fixture teardown failed");
resources.delete(input.leaseMetadata.creationOperationId);
return { providerLeaseId: input.providerLeaseId, state: "destroyed" };
}
throw new Error(`Unexpected fixture method ${method}`);
}),
} as unknown as PluginWorkerManager;
});
afterEach(async () => {
await db.execute(sql`drop trigger if exists reject_fixture_lease on environment_leases`);
await db.execute(sql`drop trigger if exists reject_fixture_creation on sandbox_creation_operations`);
await rm(spool, { recursive: true, force: true });
});
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); });
function runtime() { return environmentRuntimeService(db, { pluginWorkerManager: workers, orphanCleanupSpoolDir: spool }); }
async function acquire() {
return runtime().acquireRunLease({ companyId, environment: (await environmentService(db).getById(environmentId))!,
heartbeatRunId: runId, issueId: null, persistedExecutionWorkspace: null, assertCompanyBinding: true });
}
async function due() { await db.update(sandboxCreationOperations).set({ nextAttemptAt: new Date(0) }); }
async function rejectWrites(table: "environment_leases" | "sandbox_creation_operations", trigger: string) {
await db.execute(sql`create or replace function reject_fixture_write() returns trigger language plpgsql as $$ begin raise exception 'Fixture database write unavailable'; end $$`);
await db.execute(sql.raw(`create trigger ${trigger} before insert on ${table} for each row execute function reject_fixture_write()`));
}
it("can reapply the creation migration without losing a committed intent", async () => {
loseReply = true; await expect(acquire()).rejects.toThrow("Lost provider reply");
const [before] = await db.select().from(sandboxCreationOperations);
const migration = await readFile(new URL("../../../packages/db/src/migrations/0418_curved_lady_mastermind.sql", import.meta.url), "utf8");
for (let attempt = 0; attempt < 2; attempt++) {
for (const statement of migration.split("--> statement-breakpoint").map(value => value.trim()).filter(Boolean)) {
await db.execute(sql.raw(statement));
}
}
expect(await db.select().from(sandboxCreationOperations)).toEqual([before]);
expect(creates).toBe(1);
});
it("fails before the external action when the durable admission write fails", async () => {
await rejectWrites("sandbox_creation_operations", "reject_fixture_creation");
await expect(acquire()).rejects.toThrow();
expect(creates).toBe(0); expect(resources.size).toBe(0); expect(await readdir(spool)).toEqual([]);
});
it("attaches once and reuses the committed transfer after a lost response", async () => {
const first = await acquire();
const retry = await acquire();
expect(retry.lease.id).toBe(first.lease.id); expect(creates).toBe(1); expect(destroys).toBe(0);
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(environmentLeases)).toHaveLength(1);
expect(await nextEnvironmentCleanupAt(db)).toBeNull();
});
it("recovers a failed lease write with a fresh receiver and empty web disk", async () => {
await rejectWrites("environment_leases", "reject_fixture_lease");
await expect(acquire()).rejects.toThrow();
expect(creates).toBe(1); expect(await readdir(spool)).toEqual([]);
expect(await db.select().from(sandboxCreationOperations)).toEqual([expect.objectContaining({ state: "pending" })]);
await db.execute(sql`drop trigger reject_fixture_lease on environment_leases`);
await due();
await Promise.all([runtime().flushDeferredOrphanCleanups(), runtime().flushDeferredOrphanCleanups()]);
const leases = await db.select().from(environmentLeases);
expect(leases).toEqual([expect.objectContaining({ status: "pending_cleanup", providerLeaseId: [...resources.values()][0] })]);
expect(creates).toBe(1);
failDestroy = true;
await expect(runtime().retryPendingSandboxTeardown({ environment: null, lease: (await environmentService(db).getLeaseById(leases[0]!.id))! })).rejects.toThrow("Fixture teardown failed");
expect(resources.size).toBe(1);
failDestroy = false;
await runtime().retryPendingSandboxTeardown({ environment: null, lease: (await environmentService(db).getLeaseById(leases[0]!.id))! });
expect(resources.size).toBe(0); expect(destroys).toBe(2);
});
it("reconstructs ownership in a replacement receiver process with a different empty web disk", async () => {
const provider = createServer(async (request, response) => {
try {
let body = ""; for await (const chunk of request) body += chunk.toString();
const { method, input } = JSON.parse(body);
response.setHeader("content-type", "application/json");
response.end(JSON.stringify(await (workers.call as any)(pluginId, method, input)));
} catch { response.statusCode = 500; response.end("{}"); }
});
await new Promise<void>(resolve => provider.listen(0, "127.0.0.1", resolve));
const address = provider.address() as { port: number };
const replacementDisk = await mkdtemp(join(tmpdir(), "replacement-sandbox-web-"));
const fixture = fileURLToPath(new URL("./fixtures/sandbox-creation-receiver.ts", import.meta.url));
async function receiver(action: string, disk: string) {
const result = await promisify(execFile)(process.execPath, ["--import", fileURLToPath(import.meta.resolve("tsx")), fixture], {
cwd: fileURLToPath(new URL("../../..", import.meta.url)), timeout: 60_000,
env: { ...process.env, CREATION_FIXTURE_ACTION: action, CREATION_FIXTURE_EMPTY_DISK: disk,
CREATION_FIXTURE_DATABASE_URL: database.connectionString, CREATION_FIXTURE_PROVIDER_URL: `http://127.0.0.1:${address.port}`,
CREATION_FIXTURE_COMPANY_ID: companyId, CREATION_FIXTURE_ENVIRONMENT_ID: environmentId, CREATION_FIXTURE_RUN_ID: runId },
});
return result.stdout;
}
try {
await rejectWrites("environment_leases", "reject_fixture_lease");
expect(await receiver("acquire", spool)).toContain('"ok":false');
expect(creates).toBe(1); expect(resources.size).toBe(1); expect(await readdir(spool)).toEqual([]);
await db.execute(sql`drop trigger reject_fixture_lease on environment_leases`);
await due();
expect(await receiver("recover", replacementDisk)).toContain('"ok":true');
expect(await receiver("recover", replacementDisk)).toContain('"ok":true');
expect(await db.select().from(environmentLeases)).toEqual([
expect.objectContaining({ status: "pending_cleanup", providerLeaseId: [...resources.values()][0] }),
]);
expect(creates).toBe(1); expect(await readdir(replacementDisk)).toEqual([]);
} finally {
await new Promise<void>((resolve, reject) => provider.close(error => error ? reject(error) : resolve()));
await rm(replacementDisk, { recursive: true, force: true });
}
}, 180_000);
it("never repeats creation after a lost provider reply", async () => {
loseReply = true;
await expect(acquire()).rejects.toThrow("Lost provider reply");
await expect(acquire()).rejects.toThrow("requires reconciliation");
expect(creates).toBe(1);
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(environmentLeases)).toEqual([expect.objectContaining({ status: "pending_cleanup" })]);
});
it("retains cleanup ownership when the environment is deleted during creation", async () => {
beforeReply = async () => { await environmentService(db).remove(environmentId); };
await expect(acquire()).rejects.toThrow();
expect(await db.select().from(sandboxCreationOperations)).toEqual([expect.objectContaining({ environmentId: null, originalEnvironmentId: environmentId, state: "pending" })]);
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(environmentLeases)).toEqual([expect.objectContaining({ environmentId: null, status: "pending_cleanup" })]);
});
it("rejects attachment after a company rebind and retains cleanup", async () => {
beforeReply = async () => {
const foreign = randomUUID(); await db.insert(companies).values({ id: foreign, name: "Foreign", issuePrefix: "FOR" });
await db.insert(builtInManagedResources).values({ companyId: foreign, bundleKey: "managed-environment", resourceKind: "environment",
resourceKey: "sandbox", resourceId: environmentId, stockVersion: "1", stockHash: "fixture" });
};
await expect(acquire()).rejects.toMatchObject({ status: 403 });
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(environmentLeases)).toEqual([expect.objectContaining({ companyId, status: "pending_cleanup" })]);
});
it("backs off a failed lookup and recovers unrelated creations in the same pass", async () => {
loseReply = true; await expect(acquire()).rejects.toThrow("Lost provider reply");
const [first] = await db.select().from(sandboxCreationOperations);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
runId = randomUUID(); await db.insert(heartbeatRuns).values({ ...run!, id: runId });
await expect(acquire()).rejects.toThrow("Lost provider reply");
const originalCall = workers.call;
(workers as any).call = vi.fn(async (plugin: string, method: string, params: any, timeout: any) => {
if (method === "environmentInspectLease" && params.creationOperation.id === first!.id) throw new Error("Transient fixture lookup");
return originalCall(plugin, method as any, params, timeout);
});
await due(); await runtime().flushDeferredOrphanCleanups();
const rows = await db.select().from(sandboxCreationOperations);
expect(rows.find(value => value.id === first!.id)).toMatchObject({ state: "pending" });
expect(rows.find(value => value.id === first!.id)!.nextAttemptAt.getTime()).toBeGreaterThan(Date.now());
expect(rows.filter(value => value.state === "cleanup_pending")).toHaveLength(1);
});
it("does not attach a sandbox to a run stopped during creation", async () => {
beforeReply = async () => { await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, runId)); };
await expect(acquire()).rejects.toThrow("run authority changed");
expect(await db.select().from(environmentLeases)).toHaveLength(0);
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(environmentLeases)).toEqual([expect.objectContaining({ status: "pending_cleanup" })]);
});
it.each(["before lookup", "during lookup", "lookup fails after deletion"])("records manual reconciliation when the company is deleted %s", async (deletion) => {
loseReply = true; await expect(acquire()).rejects.toThrow("Lost provider reply");
async function deleteCompany() {
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
}
if (deletion === "before lookup") await deleteCompany();
const originalCall = workers.call;
const calls = vi.fn(async (plugin: string, method: string, params: any, timeout: any) => {
if (method === "environmentInspectLease" && deletion !== "before lookup") {
await deleteCompany();
if (deletion === "lookup fails after deletion") throw new Error("Company credential no longer exists");
}
return originalCall(plugin, method as any, params, timeout);
});
(workers as any).call = calls;
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(sandboxCreationOperations)).toEqual([
expect.objectContaining({ companyId, state: "needs_reconciliation", metadata: expect.objectContaining({ creationReconciliationReason: "company_deleted" }) }),
]);
expect(await db.select().from(environmentLeases)).toEqual([]);
expect(calls.mock.calls.filter(value => value[1] === "environmentInspectLease")).toHaveLength(deletion === "before lookup" ? 0 : 1);
const count = calls.mock.calls.length;
await runtime().flushDeferredOrphanCleanups(); await runtime().flushDeferredOrphanCleanups();
expect(calls).toHaveBeenCalledTimes(count);
expect(await nextEnvironmentCleanupAt(db)).toBeNull(); expect(creates).toBe(1);
expect(await readdir(spool)).toEqual([]);
});
async function savedCreationCredential(providerConfigId?: string) {
const secrets = secretOwner.secretService(db);
const secret = await secrets.create(companyId, {
name: `creation-key-${randomUUID()}`, provider: "local_encrypted", providerConfigId, value: "fixture-private-key",
});
await secrets.createBinding({ companyId, secretId: secret.id, targetType: "environment", targetId: environmentId, configPath: "apiKey" });
await db.update(plugins).set({ manifestJson: sql`jsonb_set(${plugins.manifestJson}, '{environmentDrivers,0,configSchema}',
'{"type":"object","properties":{"apiKey":{"type":"string","format":"secret-ref"}}}'::jsonb)` }).where(eq(plugins.id, pluginId));
await db.update(environments).set({ config: { provider: "creation-fixture", image: "fixture", reuseLease: false, apiKey: secret.id } })
.where(eq(environments.id, environmentId));
loseReply = true;
await expect(acquire()).rejects.toThrow("Lost provider reply");
return secret;
}
it.each(["deleted", "disabled", "archived", "version-disabled", "vault-disabled", "vault-coming-soon"] as const)(
"retains a %s creation credential as quiet manual reconciliation across restart", async state => {
const secrets = secretOwner.secretService(db);
const vault = state.startsWith("vault-") ? await secrets.createProviderConfig(companyId, {
provider: "local_encrypted", displayName: "Creation vault", status: "ready", config: {},
}) : undefined;
const secret = await savedCreationCredential(vault?.id);
if (state === "deleted") await secrets.remove(secret.id);
else if (state === "disabled" || state === "archived") await secrets.update(secret.id, { status: state });
else if (state === "version-disabled") await db.update(companySecretVersions).set({ status: "disabled" })
.where(eq(companySecretVersions.secretId, secret.id));
else await secrets.updateProviderConfig(vault!.id, { status: state === "vault-disabled" ? "disabled" : "coming_soon" });
const inspectCalls = () => vi.mocked(workers.call).mock.calls.filter(value => value[1] === "environmentInspectLease").length;
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(sandboxCreationOperations)).toEqual([
expect.objectContaining({ state: "needs_reconciliation" }),
]);
expect(await db.select().from(environmentLeases)).toEqual([]);
expect(inspectCalls()).toBe(0);
expect(await nextEnvironmentCleanupAt(db)).toBeNull();
const restarted = runtime();
await restarted.flushDeferredOrphanCleanups(); await restarted.flushDeferredOrphanCleanups();
expect(inspectCalls()).toBe(0); expect(creates).toBe(1); expect(destroys).toBe(0);
expect(await nextEnvironmentCleanupAt(db)).toBeNull();
await db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
const ownerId = randomUUID();
const hold = { ownerId, draining: true, startedAt: new Date(Date.now() - 1000), expiresAt: new Date(Date.now() + 60_000), activeRuns: 0, pendingWakes: 0 };
// An installed plugin is a separate blocker until its worker proves quiet.
expect(await readIdleSleepSafety(db, () => hold, Date.now, ownerId, async () => "none"))
.toEqual({ version: 1, backgroundWork: "present" });
expect(await readIdleSleepSafety(db, () => hold, Date.now, ownerId, async () => "none",
async () => ({ backgroundWork: "none", pluginIds: [pluginId] })))
.toEqual({ version: 1, backgroundWork: "none" });
expect(await readdir(spool)).toEqual([]);
});
it("retries a temporary creation credential resolution failure without losing ownership", async () => {
await savedCreationCredential();
const original = secretOwner.secretService;
const resolution = vi.fn(async () => { throw new HttpError(503, "Temporary credential provider failure"); });
const owner = vi.spyOn(secretOwner, "secretService").mockImplementation(client => ({
...original(client), resolveSecretValueForSandboxCleanup: resolution,
}));
try {
await due(); await runtime().flushDeferredOrphanCleanups();
expect(resolution).toHaveBeenCalledTimes(1);
expect(await db.select().from(sandboxCreationOperations)).toEqual([
expect.objectContaining({ state: "pending", nextAttemptAt: expect.any(Date) }),
]);
expect(await nextEnvironmentCleanupAt(db)).toBeGreaterThan(Date.now());
expect(vi.mocked(workers.call).mock.calls.filter(value => value[1] === "environmentInspectLease")).toHaveLength(0);
} finally { owner.mockRestore(); }
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(sandboxCreationOperations)).toEqual([expect.objectContaining({ state: "cleanup_pending" })]);
expect(await db.select().from(environmentLeases)).toHaveLength(1);
expect(creates).toBe(1); expect(destroys).toBe(0); expect(await readdir(spool)).toEqual([]);
});
it("makes unsupported uncertain outcomes explicit without another creation", async () => {
loseReply = true; await expect(acquire()).rejects.toThrow();
await db.update(plugins).set({ manifestJson: sql`${plugins.manifestJson} #- '{environmentDrivers,0,creationOperationVersion}'` }).where(eq(plugins.id, pluginId));
await due(); await runtime().flushDeferredOrphanCleanups();
expect(await db.select().from(sandboxCreationOperations)).toEqual([expect.objectContaining({ state: "needs_reconciliation" })]);
expect(await nextEnvironmentCleanupAt(db)).toBeNull();
await expect(acquire()).rejects.toThrow("requires reconciliation"); expect(creates).toBe(1);
});
});
@@ -5,7 +5,7 @@ import type { LifecycleAgent, LifecycleStore, LifecycleDriver } from "../applica
import { startAgentLifecycle } from "../index.js";
import { createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
import { logger } from "../../../middleware/logger.js";
import { lifecycleFailureMessage } from "../application/failure.js";
import { lifecycleFailureMessage, lifecycleVerificationNeedsRetry } from "../application/failure.js";
const agent: LifecycleAgent = { id: "agent", companyId: "company", lifecycleState: "verifying", lifecycleVersion: 3,
lifecycleRequiredPluginIds: null, lifecycleError: null,
@@ -67,6 +67,20 @@ describe("lifecycle readiness handoff", () => {
});
describe("lifecycle failure diagnostics", () => {
it.each(["harness_auth_failed", "harness_model_unavailable", "harness_command_unavailable", "harness_cwd_invalid", "harness_probe_unexpected_output"])("waits for repair only for a host verification failure: %s", code => {
const error = { code };
expect(lifecycleVerificationNeedsRetry({ stage: "host", phase: "verifying" }, error)).toBe(true);
expect(lifecycleVerificationNeedsRetry({ stage: "plugin", phase: "verifying" }, error)).toBe(false);
expect(lifecycleVerificationNeedsRetry({ stage: "host", phase: "cleaning_up" }, error)).toBe(false);
});
it("allows two automatic retries of a generic failed test before waiting for repair", () => {
for (const verificationFailures of [1, 2, 3, 8_000]) {
const context = { stage: "host" as const, phase: "verifying" as const, attempts: 8_000, verificationFailures };
expect(lifecycleVerificationNeedsRetry(context, { code: "harness_test_failed" })).toBe(verificationFailures >= 3);
}
});
it.each([
["host", "harness_model_unavailable", "Execution configuration check failed. The selected model is unavailable."],
["plugin", "required_plugin_unavailable", "Execution configuration check: required plugin step failed. A required plugin is unavailable."],
@@ -80,7 +94,8 @@ describe("lifecycle failure diagnostics", () => {
if (stage === "select_plugins") driver.requiredPluginIds.mockRejectedValue(error);
if (stage === "transition") store.change.mockRejectedValue(error);
await worker.process(agent.id);
expect(store.defer).toHaveBeenCalledWith(agent, expect.any(String), expect.stringContaining(expected), expect.any(Date));
expect(store.defer).toHaveBeenCalledWith(agent, expect.any(String), expect.stringContaining(expected), expect.any(Date),
...(stage === "host" ? [{ retryRequired: true }] : []));
expect(JSON.stringify(store.defer.mock.calls)).not.toContain("secret provider output");
expect(JSON.stringify(store.defer.mock.calls)).not.toContain("unknown private code");
await worker.stop();
@@ -0,0 +1,182 @@
import { randomUUID } from "node:crypto";
import { agents, companies, createDb, applyPendingMigrations, closeRegisteredClients, type Db } from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
import { createAgentLifecycle, configureAgentLifecycle } from "../../../services/agent-lifecycle.js";
import { createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
import { startAgentLifecycle } from "../index.js";
import { createLifecycleStore } from "../adapters/postgres.js";
import { createLifecycleWorker } from "../application/worker.js";
import type { LifecycleAgent } from "../application/ports.js";
import { resolveExternalTestDatabaseUrl } from "../../../__tests__/helpers/external-test-database.js";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_LIFECYCLE_VERIFICATION_RETRY_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("failed lifecycle verification retries", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
let companyId: string;
const workers: Array<{ stop(): Promise<void> }> = [];
beforeAll(async () => {
if (external) { await applyPendingMigrations(external); db = createDb(external); }
else { database = await startEmbeddedPostgresTestDatabase("paperclip-verification-retry-"); db = createDb(database.connectionString); }
}, 90_000);
afterAll(async () => { if (external) await closeRegisteredClients(external); else await database?.cleanup(); });
beforeEach(async () => {
companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Verification retry", issuePrefix: `V${companyId.slice(0, 7)}` });
});
afterEach(async () => {
for (const worker of workers.splice(0)) await worker.stop();
// Retire only this test's synthetic records so later coordinator reads stay isolated.
await db.update(agents).set({ lifecycleState: "terminated", status: "terminated" }).where(eq(agents.companyId, companyId));
});
const current = async (id: string) => (await db.select().from(agents).where(eq(agents.id, id)))[0]!;
async function failed(code = "harness_test_failed") {
const runHost = vi.fn(async (agent: LifecycleAgent) => {
if (agent.lifecycleState === "verifying") throw Object.assign(new Error("private provider detail"), { code });
return "complete" as const;
});
const driver = { requiredPluginIds: async () => [], runHost, runPlugin: async () => "complete" as const };
const work = configureAgentLifecycle(db, driver); workers.push(work);
const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Retry fixture", adapterType: "process", adapterConfig: { command: "missing-command" } });
await work.process(agent.id);
if (code === "harness_test_failed") {
for (let retry = 0; retry < 2; retry++) {
expect((await current(agent.id)).lifecycleOperation?.retryRequired).toBeUndefined();
await db.update(agents).set({ lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt'` }).where(eq(agents.id, agent.id));
await work.process(agent.id);
}
}
return { agent: await current(agent.id), work, driver, runHost };
}
it("persists a failed test across worker restarts without scanning, claiming, or probing it again", async () => {
const { agent, work, driver, runHost } = await failed();
expect(agent).toMatchObject({ lifecycleState: "verifying", status: "paused", lifecycleOperation: { retryRequired: true } });
expect(agent.lifecycleOperation).not.toHaveProperty("retryAt");
expect(agent.lifecycleOperation).not.toHaveProperty("leaseOwner");
expect(agent.lifecycleError).toContain("Automatic verification is paused");
expect(agent.lifecycleError).not.toContain("private provider detail");
await work.stop();
const restarted = startAgentLifecycle(db, createAgentLifecycleEffects(), driver, () => true); workers.push(restarted);
const calls = runHost.mock.calls.length;
expect(await restarted.hasPending()).toBe(false);
expect(restarted.nextRunAt()).toBeNull();
await restarted.sweep();
const store = createLifecycleStore(db, createAgentLifecycleEffects());
const tomorrow = new Date(Date.now() + 86_400_000);
expect(await store.pending(100, tomorrow)).not.toContain(agent.id);
expect(await store.claim(agent.id, "new-owner", tomorrow)).toBeNull();
await work.process(agent.id);
expect(runHost).toHaveBeenCalledTimes(calls);
expect((await current(agent.id)).lifecycleOperation).toEqual(agent.lifecycleOperation);
});
it("permits an explicit retry of the same operation and completes verification before readiness", async () => {
const { agent, work, runHost } = await failed();
runHost.mockResolvedValue("complete");
await createAgentLifecycle(db).retry(agent.id);
expect((await current(agent.id)).lifecycleOperation).not.toHaveProperty("retryRequired");
expect((await current(agent.id)).lifecycleOperation).not.toHaveProperty("verificationFailures");
expect((await current(agent.id)).lifecycleOperation!.id).toBe(agent.lifecycleOperation!.id);
await work.process(agent.id);
expect(await current(agent.id)).toMatchObject({ lifecycleState: "ready", status: "idle", lifecycleError: null });
});
it("does not spend verification retries on claims deferred before the host test", async () => {
const [agent] = await db.insert(agents).values({ companyId, name: "Deferred verification", status: "paused",
lifecycleState: "verifying", lifecycleRequiredPluginIds: [],
lifecycleOperation: { id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0 } }).returning();
const store = createLifecycleStore(db, createAgentLifecycleEffects());
let allowed = true;
let deferBeforeHost = true;
const runHost = vi.fn(async () => { throw Object.assign(new Error("test failed"), { code: "harness_test_failed" }); });
const work = createLifecycleWorker({ ...store, async claim(...args) {
const claimed = await store.claim(...args);
if (claimed && deferBeforeHost) allowed = false;
return claimed;
} }, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost }, () => {}, () => allowed);
workers.push(work);
const due = async () => {
allowed = true;
await db.update(agents).set({ lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt'` }).where(eq(agents.id, agent.id));
await work.process(agent.id);
};
await due(); await due();
expect(runHost).not.toHaveBeenCalled();
expect((await current(agent.id)).lifecycleOperation).toMatchObject({ attempts: 2 });
expect((await current(agent.id)).lifecycleOperation).not.toHaveProperty("verificationFailures");
deferBeforeHost = false;
await due();
expect((await current(agent.id)).lifecycleOperation).toMatchObject({ attempts: 3, verificationFailures: 1 });
expect((await current(agent.id)).lifecycleOperation?.retryRequired).toBeUndefined();
// Another deferred claim preserves the already recorded failed test.
deferBeforeHost = true; await due();
expect((await current(agent.id)).lifecycleOperation?.verificationFailures).toBe(1);
deferBeforeHost = false;
await due();
expect((await current(agent.id)).lifecycleOperation?.retryRequired).toBeUndefined();
await due();
expect(runHost).toHaveBeenCalledTimes(3);
expect((await current(agent.id)).lifecycleOperation).toMatchObject({ verificationFailures: 3, retryRequired: true });
});
it("recovers from a generic failed test before the automatic retry budget is exhausted", async () => {
const { agent, work, runHost } = await failed("harness_probe_timed_out");
runHost.mockRejectedValueOnce(Object.assign(new Error("temporary test failure"), { code: "harness_test_failed" }));
await db.update(agents).set({ lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt'` }).where(eq(agents.id, agent.id));
await work.process(agent.id);
expect((await current(agent.id)).lifecycleOperation?.retryRequired).toBeUndefined();
runHost.mockResolvedValue("complete");
await db.update(agents).set({ lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt'` }).where(eq(agents.id, agent.id));
await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("ready");
});
it("rechecks changed execution configuration but leaves a name edit waiting", async () => {
const { agent, work, runHost } = await failed();
const lifecycle = createAgentLifecycle(db);
await lifecycle.updateConfiguration(agent.id, { name: "Renamed fixture" });
expect((await current(agent.id)).lifecycleOperation!.retryRequired).toBe(true);
runHost.mockResolvedValue("complete");
await lifecycle.updateConfiguration(agent.id, { adapterConfig: { command: process.execPath } });
const changed = await current(agent.id);
expect(changed.lifecycleOperation).not.toHaveProperty("retryRequired");
expect(changed.lifecycleOperation).not.toHaveProperty("verificationFailures");
expect(changed.lifecycleOperation!.id).not.toBe(agent.lifecycleOperation!.id);
await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("ready");
});
it("does not let a stale verification failure park newly saved configuration", async () => {
const { agent, work } = await failed("harness_auth_failed");
await work.stop();
const store = createLifecycleStore(db, createAgentLifecycleEffects());
await store.change(agent.id, "retry");
const claimed = await store.claim(agent.id, "old-verification", new Date());
expect(claimed).not.toBeNull();
await createAgentLifecycle(db).updateConfiguration(agent.id, { adapterConfig: { command: process.execPath } });
expect(await store.defer(claimed!, "old-verification", "Old failure", new Date(), { retryRequired: true })).toBe(false);
expect((await current(agent.id)).lifecycleOperation?.retryRequired).toBeUndefined();
});
it.each(["harness_probe_timed_out", "test_environment_unavailable", "ECONNRESET", "unclassified"])("keeps automatic recovery for %s", async code => {
const { agent } = await failed(code);
expect(agent.lifecycleOperation).not.toHaveProperty("retryRequired");
expect(Date.parse(agent.lifecycleOperation!.retryAt!)).toBeGreaterThan(Date.now());
const store = createLifecycleStore(db, createAgentLifecycleEffects());
expect(await store.pending(100, new Date(Date.now() + 120_000))).toContain(agent.id);
expect(await store.claim(agent.id, "automatic-retry", new Date(Date.now() + 120_000))).not.toBeNull();
});
it("still completes termination and cleanup when verification needs repair", async () => {
const { agent, work, runHost } = await failed();
runHost.mockResolvedValue("complete");
await createAgentLifecycle(db).terminateAgent(agent.id);
await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("terminated");
expect(runHost.mock.calls.map(([a]) => a.lifecycleState)).toContain("cleaning_up");
});
});
@@ -37,7 +37,8 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects
...(!prepareSetup && current.lifecycleHolds.includes("setup") && ["preparing", "verifying"].includes(current.lifecycleState)
? { lifecycleState: "pausing" as const } : {}),
lifecycleOperation: { ...current.lifecycleOperation!, id: randomUUID(), hostComplete: false,
completedPluginIds: [], leaseOwner: undefined, leaseUntil: undefined, retryAt: undefined },
completedPluginIds: [], attempts: 0, leaseOwner: undefined, leaseUntil: undefined,
retryAt: undefined, retryRequired: undefined, verificationFailures: undefined },
} : {}) }).where(eq(agents.id, id)).returning();
await effects.completeConfiguration(tx, current, updated, patch, options, publications);
return agentRecordQueries(tx).getById(id);
@@ -17,6 +17,7 @@ type ChangeOptions = { version?: number; owner?: string; reason?: string };
export const pendingLifecycleWork = and(
inArray(agents.lifecycleState, ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"]),
sql`not (${agents.lifecycleState} = 'verifying' and ${agents.lifecycleHolds} @> '["setup"]'::jsonb)`,
sql`not (${agents.lifecycleState} = 'verifying' and coalesce(${agents.lifecycleOperation}->>'retryRequired', 'false') = 'true')`,
);
/** The caller owns the accounting transaction and has locked this agent. */
@@ -33,7 +34,7 @@ async function changeLockedAgent(tx: Db, effects: LifecycleEffects, agent: Lifec
if (agent.lifecycleOperation?.leaseUntil && Date.parse(agent.lifecycleOperation.leaseUntil) > Date.now()) return agent;
await effects.notifyWork(tx);
const [updated] = await tx.update(agents).set({ lifecycleError: null,
lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt' - 'leaseOwner' - 'leaseUntil'`, updatedAt: new Date() }).where(eq(agents.id, agent.id)).returning();
lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt' - 'retryRequired' - 'verificationFailures' - 'leaseOwner' - 'leaseUntil'`, updatedAt: new Date() }).where(eq(agents.id, agent.id)).returning();
return updated;
}
if (command === "resume" && !["ready", "paused", "pausing", "resuming"].includes(agent.lifecycleState)) transition(agent.lifecycleState, command);
@@ -177,11 +178,17 @@ export function createLifecycleStore(db: Db, effects: LifecycleEffects): Lifecyc
sql`${agents.lifecycleOperation}->>'leaseOwner' = ${owner}`)).returning({ id: agents.id });
return rows.length > 0;
},
async defer(agent, owner, error, now) {
async defer(agent, owner, error, now, options) {
const retry: Partial<AgentLifecycleOperation> = options?.retryRequired && agent.lifecycleState === "verifying"
? { retryRequired: true }
: { retryAt: new Date(now.getTime() + (error ? 60_000 : 2_000)).toISOString() };
if (options?.verificationFailures !== undefined && agent.lifecycleState === "verifying") {
retry.verificationFailures = options.verificationFailures;
}
const rows = await db.update(agents).set({
lifecycleError: error,
lifecycleOperation: sql`(${agents.lifecycleOperation} - 'leaseOwner' - 'leaseUntil')
|| jsonb_build_object('retryAt', ${new Date(now.getTime() + (error ? 60_000 : 2_000)).toISOString()}::text)`,
lifecycleOperation: sql`(${agents.lifecycleOperation} - 'leaseOwner' - 'leaseUntil' - 'retryAt' - 'retryRequired')
|| ${JSON.stringify(retry)}::jsonb`,
updatedAt: now,
}).where(and(eq(agents.id, agent.id), eq(agents.lifecycleVersion, agent.lifecycleVersion),
sql`${agents.lifecycleOperation}->>'leaseOwner' = ${owner}`)).returning({ id: agents.id });
@@ -15,6 +15,16 @@ const reasons = {
} as const;
const transientCodes = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "40001", "40P01", "53300", "57P01"]);
const configurationFailures = new Set([
"harness_auth_failed", "harness_model_unavailable",
"harness_command_unavailable", "harness_cwd_invalid", "harness_probe_unexpected_output",
]);
export function lifecycleVerificationNeedsRetry(context: LifecycleFailure, error: unknown): boolean {
const code = lifecycleFailureCode(error) ?? "";
return context.phase === "verifying" && context.stage === "host"
&& (configurationFailures.has(code) || (code === "harness_test_failed" && (context.verificationFailures ?? 0) >= 3));
}
export function lifecycleFailureCode(error: unknown): string | undefined {
const candidate = error as { code?: unknown; cause?: { code?: unknown } } | null;
@@ -36,5 +46,6 @@ export function lifecycleFailureMessage(context: LifecycleFailure, error: unknow
const code = lifecycleFailureCode(error);
const reason = code && Object.hasOwn(reasons, code) ? reasons[code as keyof typeof reasons]
: "Paperclip could not complete this step. It will retry automatically. If this continues, contact your instance administrator.";
return `${step} failed. ${reason}`;
return `${step} failed. ${reason}${lifecycleVerificationNeedsRetry(context, error)
? " Automatic verification is paused. Update Configuration or choose Retry now." : ""}`;
}
@@ -20,7 +20,7 @@ export interface LifecycleStore {
setRequiredPlugins(agent: LifecycleAgent, owner: string, pluginIds: string[]): Promise<boolean>;
completeHost(agent: LifecycleAgent, owner: string): Promise<boolean>;
completePlugin(agent: LifecycleAgent, owner: string, pluginId: string): Promise<boolean>;
defer(agent: LifecycleAgent, owner: string, error: string | null, now: Date): Promise<boolean>;
defer(agent: LifecycleAgent, owner: string, error: string | null, now: Date, options?: { retryRequired: boolean; verificationFailures?: number }): Promise<boolean>;
pending(limit: number, now: Date): Promise<string[]>;
}
@@ -40,5 +40,7 @@ export interface LifecycleFailure {
phase?: AgentLifecycleState;
operationId?: string;
version?: number;
attempts?: number;
verificationFailures?: number;
pluginId?: string;
}
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import type { LifecycleDriver, LifecycleStore, LifecycleFailure } from "./ports.js";
import { isLifecycleWorkPending } from "../domain/policy.js";
import { lifecycleFailureMessage } from "./failure.js";
import { lifecycleFailureCode, lifecycleFailureMessage, lifecycleVerificationNeedsRetry } from "./failure.js";
/** Database leases protect each attempt across server processes. */
export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDriver, reportFailure: (context: LifecycleFailure, error: unknown) => void, canRun = () => true) {
@@ -20,7 +20,8 @@ export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDr
const agent = await attempt({ stage: "claim", agentId: id }, () => store.claim(id, owner, new Date()));
if (!agent || !isLifecycleWorkPending(agent.lifecycleState)) return;
const context = { agentId: agent.id, companyId: agent.companyId, phase: agent.lifecycleState,
operationId: agent.lifecycleOperation?.id, version: agent.lifecycleVersion };
operationId: agent.lifecycleOperation?.id, version: agent.lifecycleVersion,
attempts: agent.lifecycleOperation?.attempts };
let failureContext: LifecycleFailure = { ...context, stage: "host" };
const runStep = <T>(stage: LifecycleFailure["stage"], work: () => Promise<T>, pluginId?: string) => {
const stepContext = { ...context, stage, ...(pluginId ? { pluginId } : {}) };
@@ -70,8 +71,16 @@ export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDr
}
if (changed && !isLifecycleWorkPending(changed.lifecycleState)) return;
} catch (error) {
// Claims can defer before runHost. Only a failed host test spends this budget.
const verificationFailures = failureContext.stage === "host" && agent.lifecycleState === "verifying"
&& lifecycleFailureCode(error) === "harness_test_failed"
? (agent.lifecycleOperation?.verificationFailures ?? 0) + 1 : undefined;
failureContext = { ...failureContext, verificationFailures };
const message = lifecycleFailureMessage(failureContext, error);
if (!await runStep("defer", () => store.defer(agent, owner, message, new Date()))) continue;
const retryRequired = lifecycleVerificationNeedsRetry(failureContext, error);
if (!await runStep("defer", () => retryRequired || verificationFailures !== undefined
? store.defer(agent, owner, message, new Date(), { retryRequired, ...(verificationFailures !== undefined ? { verificationFailures } : {}) })
: store.defer(agent, owner, message, new Date()))) continue;
return;
} finally {
clearInterval(renewal);
@@ -23,7 +23,8 @@ export function assertHarnessTestPassed(result: AdapterEnvironmentTestResult) {
const incomplete = !verified && (result.status === "warn" || result.checks.some(check => check.code.includes("hello_probe")));
if (result.status === "fail" || authenticationFailed || incomplete) {
const has = (suffix: string) => result.checks.some(check => check.code.endsWith(suffix));
const code = authenticationFailed ? "harness_auth_failed"
const code = result.checks.some(check => check.level === "error" && check.retryable === true) ? "test_environment_unavailable"
: authenticationFailed ? "harness_auth_failed"
: has("_hello_probe_model_unavailable") || has("_model_invalid") ? "harness_model_unavailable"
: has("_command_unresolvable") ? "harness_command_unavailable"
: has("_cwd_invalid") ? "harness_cwd_invalid"
@@ -1,5 +1,5 @@
import { eq, sql } from "drizzle-orm";
import { environmentLeases, type Db } from "@paperclipai/db";
import { environmentLeases, sandboxCreationOperations, type Db } from "@paperclipai/db";
import { hasDeferredEnvironmentCleanup } from "./environment-cleanup-notifications.js";
export const ENVIRONMENT_CLEANUP_RETRY_MS = 5 * 60_000;
@@ -20,7 +20,10 @@ export async function nextEnvironmentCleanupAt(db: Db, backoffMs = ENVIRONMENT_C
const [row] = await db.select({ next: sql<string | null>`min(greatest(
extract(epoch from ${environmentLeases.updatedAt}) * 1000 + ${backoffMs},
${pendingCleanupDeadlineSql()}))` }).from(environmentLeases).where(eq(environmentLeases.status, "pending_cleanup"));
const saved = row?.next == null ? null : Number(row.next);
const [creation] = await db.select({ next: sql<string | null>`min(extract(epoch from ${sandboxCreationOperations.nextAttemptAt}) * 1000)` })
.from(sandboxCreationOperations).where(eq(sandboxCreationOperations.state, "pending"));
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);
}
+152 -493
View File
@@ -1,3 +1,6 @@
import { HttpError } from "../errors.js";
import { captureEnvironmentAcquisitionDiagnostic } from "./environment-acquisition-diagnostics.js";
import { createOwnedSandbox, recoverSandboxCreations } from "./sandbox-creation-operations.js";
import { dueRunLeaseReleaseCondition, pendingRunLeaseReleaseCondition } from "./environment-lease-release-state.js";
import { flushDeferredEnvironmentCleanup, registerDeferredEnvironmentCleanup, writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { executeEnvironmentTask, pluginEnvironmentSupportsTasks, settleDispatchedEnvironmentTask } from "./environment-task-runtime.js";
@@ -11,7 +14,6 @@ import type { NativeWorkspaceFinalizationOwnership } from "./native-runtime/nati
import { hasStopOnlyCleanup, prepareSandboxStopAndRetain, readStopOnlyCleanup, settleStopOnlyCleanup, stopOnlyCleanupKey } from "./sandbox-stop-and-retain.js";
import { JsonRpcCallError, readEnvironmentAcquisitionDiagnostic, readEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk";
import { preserveEnvironmentSyncOutErrorDiagnostic } from "./environment-sync-out-error.js";
import { captureEnvironmentAcquisitionDiagnostic } from "./environment-acquisition-diagnostics.js";
import { remoteTerminationReceipt } from "./remote-execution-termination.js";
import { hasNativeWorkspaceExportResume, releaseCompletedNativeWorkspaceExportRetention } from "./native-runtime/native-workspace-export-resume.js";
import { createHash, randomUUID } from "node:crypto";
@@ -93,7 +95,7 @@ import {
resolvePluginExecuteRpcTimeoutMs,
resumePluginEnvironmentLease,
} from "./plugin-environment-driver.js";
import { collectSecretRefPaths } from "./json-schema-secret-refs.js";
import { collectSecretRefPaths, readConfigValueAtPath } from "./json-schema-secret-refs.js";
import { buildWorkspaceRealizationRecordFromDriverInput } from "./workspace-realization.js";
import {
createSandboxOrphanCleanupSpool,
@@ -698,39 +700,6 @@ export interface EnvironmentRuntimeDriver {
flushDeferredOrphanCleanups?(): Promise<{ recovered: number; pending: number }>;
}
/**
* The acquire provisioned a remote sandbox, the lease insert rejected it, the
* compensating teardown failed, and every retry of the durable pending-cleanup
* write also failed. A live sandbox now has no lease row and no cleanup record,
* so no sweep can find it. The acquire throws this error so the failure stays visible
* and never resolves to a clean rejection. The `cause` field holds the original
* lease insert rejection. The `cleanupWriteError` field holds the write failure.
*/
export class SandboxOrphanCleanupWriteError extends Error {
readonly provider: string;
readonly providerLeaseId: string | null;
readonly cleanupWriteError: unknown;
constructor(input: {
provider: string;
providerLeaseId: string | null;
cause: unknown;
cleanupWriteError: unknown;
}) {
super(
`Sandbox provider "${input.provider}" leaked lease ` +
`"${input.providerLeaseId ?? "unknown"}": the acquire could not tear it down ` +
"and could not record a durable pending-cleanup row. The live sandbox has no " +
"durable cleanup state and needs a manual teardown.",
{ cause: input.cause },
);
this.name = "SandboxOrphanCleanupWriteError";
this.provider = input.provider;
this.providerLeaseId = input.providerLeaseId;
this.cleanupWriteError = input.cleanupWriteError;
}
}
/** A reusable sandbox could not be resumed, but has not been proven lost. */
export class ReusableSandboxResumeError extends Error {
readonly provider: string;
@@ -785,17 +754,6 @@ export type ProviderResourceDisposition =
const DEFAULT_PLUGIN_SANDBOX_WORKER_READY_TIMEOUT_MS = 5_000;
const DEFAULT_PLUGIN_SANDBOX_WORKER_READY_POLL_MS = 100;
// The durable pending-cleanup write is the only automated way to find a leaked
// sandbox. A single insert can lose to a transient database fault (a dropped
// connection, a lock timeout, a serialization conflict). So the acquire retries
// the write a few times with a short backoff before it gives up. A later
// attempt can still land the durable row that a sweep finds.
const DEFAULT_SANDBOX_ORPHAN_CLEANUP_WRITE_ATTEMPTS = 3;
const DEFAULT_SANDBOX_ORPHAN_CLEANUP_WRITE_BACKOFF_MS = 100;
// A prolonged database outage after a failed teardown can buffer many orphan
// records in-process. The bound stops that buffer from growing without limit. A
// full buffer keeps the error log as the last durable handle for a new orphan.
const DEFAULT_DEFERRED_ORPHAN_CLEANUP_BUFFER_LIMIT = 256;
function delay(ms: number): Promise<void> {
@@ -1311,8 +1269,6 @@ function createSandboxEnvironmentDriver(
pluginWorkerManager?: PluginWorkerManager;
pluginWorkerReadyTimeoutMs?: number;
pluginWorkerReadyPollMs?: number;
pendingCleanupWriteAttempts?: number;
pendingCleanupWriteBackoffMs?: number;
deferredOrphanCleanupBufferLimit?: number;
orphanCleanupSpool?: SandboxOrphanCleanupSpool;
orphanCleanupSpoolDir?: string;
@@ -1321,16 +1277,6 @@ function createSandboxEnvironmentDriver(
const pluginWorkerManager = options.pluginWorkerManager;
const pluginWorkerReadyTimeoutMs = options.pluginWorkerReadyTimeoutMs ?? DEFAULT_PLUGIN_SANDBOX_WORKER_READY_TIMEOUT_MS;
const pluginWorkerReadyPollMs = options.pluginWorkerReadyPollMs ?? DEFAULT_PLUGIN_SANDBOX_WORKER_READY_POLL_MS;
// The retry count is at least one attempt, so a zero or negative override
// never skips the durable write. The backoff is at least zero milliseconds.
const pendingCleanupWriteAttempts = Math.max(
1,
options.pendingCleanupWriteAttempts ?? DEFAULT_SANDBOX_ORPHAN_CLEANUP_WRITE_ATTEMPTS,
);
const pendingCleanupWriteBackoffMs = Math.max(
0,
options.pendingCleanupWriteBackoffMs ?? DEFAULT_SANDBOX_ORPHAN_CLEANUP_WRITE_BACKOFF_MS,
);
// The buffer holds at least one orphan, so a zero or negative override never
// drops every buffered record.
const deferredOrphanCleanupBufferLimit = Math.max(
@@ -1339,15 +1285,6 @@ function createSandboxEnvironmentDriver(
);
const environmentsSvc = environmentService(db);
// A live sandbox whose teardown failed needs a durable `pending_cleanup` row,
// so a sweep can find and release it. When every synchronous write attempt
// fails, the database is down but the process still runs. So the driver keeps
// the orphan record in this in-process buffer, and a later cleanup sweep
// flushes it back to the database. The buffer closes the common window where
// the database recovers after the synchronous attempts but while the process
// still runs. The `pending_cleanup` failure reason matches the synchronous
// write, so a flushed row and a synchronous row are indistinguishable to the
// sweep.
const deferredOrphanCleanups: DeferredOrphanCleanupRecord[] = [];
// Keep the token through queue splices, failed flushes and buffer overflow.
// Release only when the database durably owns cleanup of this exact orphan.
@@ -1363,16 +1300,6 @@ function createSandboxEnvironmentDriver(
};
const DEFERRED_ORPHAN_CLEANUP_FAILURE_REASON = "acquire_rejected_teardown_failed";
// The in-process buffer alone loses an orphan on a restart. So the driver also
// persists each buffered orphan to this durable local spool. The spool keeps
// the record on the local disk, next to the other durable server state, so a
// restart still finds it. The first flush after a restart loads the spool back
// into the buffer, so the sweep re-inserts the durable row. The spool holds no
// secret value: it persists the same sanitized metadata the database row
// holds, so a restart-recovered record still authorizes the teardown but never
// exposes a credential. Only a crash during the outage before the durable
// spool write lands loses the record; the error log stays the last handle for
// that narrow case.
const orphanCleanupSpool =
options.orphanCleanupSpool ?? createSandboxOrphanCleanupSpool(options.orphanCleanupSpoolDir);
@@ -1460,143 +1387,6 @@ function createSandboxEnvironmentDriver(
return orphanFlush;
};
// Build the safe diagnostic fields for an orphan record. The provider
// identifiers are the only fields any diagnostic log emits, and they carry no
// secret. A caught write exception never enters a log: it can carry a
// credential in its name, code, message, cause, or stack.
const orphanDiagnosticFields = (record: DeferredOrphanCleanupRecord) => ({
errorKind: SANDBOX_ORPHAN_CLEANUP_WRITE_ERROR_KIND,
provider: record.provider,
providerLeaseId: record.providerLeaseId,
companyId: record.companyId,
environmentId: record.environmentId,
executionWorkspaceId: record.executionWorkspaceId,
issueId: record.issueId,
heartbeatRunId: record.heartbeatRunId,
});
// Try to write the durable `pending_cleanup` row for an orphan sandbox, with a
// few retries. Report the new lease id on success, or a null lease id and the
// last write error on total failure. This function never buffers, never logs
// at error level, and never throws. The caller decides whether a total failure
// is fatal, because a later successful teardown removes the orphan and needs
// no durable row.
//
// The row carries the provider lease id and the same resolution metadata a
// normal lease holds, so a later cleanup sweep finds and releases the orphan.
// One atomic insert writes the row directly in the terminal `pending_cleanup`
// state, and the insert skips the company-binding assertion, so it records the
// orphan even for a foreign-bound environment. A transient database fault (a
// dropped connection, a lock timeout, a serialization conflict) can reject one
// insert but clear on the next, so the write retries a few times with a short
// backoff.
const tryWriteDurablePendingCleanup = async (
record: DeferredOrphanCleanupRecord,
): Promise<{ leaseId: string | null; lastError: unknown }> => {
const diagnosticFields = orphanDiagnosticFields(record);
let lastCleanupWriteError: unknown;
for (let attempt = 1; attempt <= pendingCleanupWriteAttempts; attempt += 1) {
try {
const lease = await environmentsSvc.insertPendingCleanupLease({
companyId: record.companyId,
environmentId: record.environmentId,
executionWorkspaceId: record.executionWorkspaceId,
issueId: record.issueId,
heartbeatRunId: record.heartbeatRunId,
provider: record.provider,
providerLeaseId: record.providerLeaseId,
metadata: record.metadata,
failureReason: DEFERRED_ORPHAN_CLEANUP_FAILURE_REASON,
});
// The durable row exists now, so a sweep can find and release the orphan.
return { leaseId: lease.id, lastError: undefined };
} catch (cleanupWriteError) {
lastCleanupWriteError = cleanupWriteError;
if (attempt < pendingCleanupWriteAttempts) {
// The write failed, but attempts remain. Log the retry at warn level
// and back off, then try again. A later attempt can still land the
// durable row.
logger.warn(
{ ...diagnosticFields, attempt, maxAttempts: pendingCleanupWriteAttempts },
"sandbox orphan cleanup write failed; retrying the durable pending-cleanup write",
);
await delay(pendingCleanupWriteBackoffMs * attempt);
}
}
}
return { leaseId: null, lastError: lastCleanupWriteError };
};
// Release the durable `pending_cleanup` row after a successful inline teardown.
// The teardown removed the orphan sandbox, so the row is no longer needed. The
// release moves the row to the terminal `expired` state with a `success`
// cleanup status, the same state the sweep records for a successful teardown.
//
// The release is best-effort. If it fails, the row stays `pending_cleanup`, so
// a later sweep runs the idempotent teardown on the already-gone sandbox and
// releases the row itself. A failed release never loses or leaks the orphan.
const releaseCleanedUpOrphanRow = async (
leaseId: string,
diagnosticFields: Record<string, unknown>,
receipt: unknown,
): Promise<void> => {
try {
const lease = await environmentsSvc.getLeaseById(leaseId);
await environmentsSvc.releaseLease(leaseId, "expired", {
...(lease ? { remoteExecutionTermination: remoteTerminationReceipt(lease, receipt) } : {}),
cleanupStatus: "success",
failureReason: "acquire_rejected_teardown_succeeded",
});
} catch {
// The caught release exception never enters a log: it can carry a
// credential in its name, code, message, cause, or stack.
logger.warn(
diagnosticFields,
"could not release the pending-cleanup row after a successful orphan teardown; a later sweep reconciles the released row",
);
}
};
// Neither the durable write nor the inline teardown worked, so the orphan
// sandbox is live with no durable row. Persist the orphan to the durable spool
// and buffer it in-process, so a later cleanup-sweep flush lands the durable
// row. The spool survives a restart, so the flush recovers the orphan even
// after a crash. Then log the plain provider identifiers at error level, so an
// operator keeps a handle to tear the sandbox down by hand. Only a crash before
// the spool write lands loses the record, so the error log is the last handle
// for that narrow case. Throw `SandboxOrphanCleanupWriteError`, which the
// caller propagates. The error keeps the original insert rejection as its
// `cause`, so the real reason the acquire failed stays visible in the error
// chain.
const escalateUnwrittenOrphan = async (
record: DeferredOrphanCleanupRecord,
cause: unknown,
cleanupWriteError: unknown,
): Promise<never> => {
trackOrphanIdleWork(record);
const persisted = await orphanCleanupSpool.append(record);
const buffered = enqueueDeferredOrphanCleanup(record);
logger.error(
{
...orphanDiagnosticFields(record),
persisted,
buffered,
deferredBufferSize: deferredOrphanCleanups.length,
},
persisted
? "sandbox orphan cleanup write failed; persisted the orphan to the durable spool for a later cleanup-sweep flush"
: buffered
? "sandbox orphan cleanup write failed and the durable spool write failed; buffered the orphan in-process only for a later cleanup-sweep flush"
: "sandbox orphan cleanup write failed and neither the durable spool nor the in-process buffer kept the orphan; live sandbox needs a manual teardown",
);
throw new SandboxOrphanCleanupWriteError({
provider: record.provider,
providerLeaseId: record.providerLeaseId,
cause,
cleanupWriteError,
});
};
// Resolve an uncertain creation to a verified provider ID, journal it, then
// authorize deletion. This makes a later missing-ID response conclusive even
// when the destructive RPC reply or the final lease update was lost.
@@ -1628,68 +1418,7 @@ function createSandboxEnvironmentDriver(
}
};
// Clean up an orphan after a rejected insert or uncertain provider creation.
// The acquire provisioned a remote sandbox, then the insert rejected (a
// foreign-company binding), so no lease row tracks the live sandbox. This
// handler records the durable `pending_cleanup` row FIRST, before the inline
// teardown, then tears the sandbox down.
//
// The write goes first on purpose. The conditional insert just returned a
// definitive rejection, so the database is reachable at this instant. An
// inline teardown can run long, and the database can drop during it. So a
// write after the teardown can fail and leave the orphan only in memory, which
// a restart loses. With the write first, the durable row lands while the
// database is proven up, so a crash after it still leaves a row that a sweep
// finds and releases.
//
// The teardown then runs. On success the orphan is gone, so the handler
// releases the durable row. On a failed teardown the row stays for the sweep.
// If the durable write itself failed and the teardown also failed, the handler
// buffers the orphan and throws `SandboxOrphanCleanupWriteError`. A successful
// teardown after a failed write leaves no orphan, so it needs no error.
const cleanUpRejectedOrphanSandbox = async (input: {
record: DeferredOrphanCleanupRecord;
cause: unknown;
canTeardown: boolean;
teardown: (pendingLeaseId: string | null) => Promise<unknown>;
}): Promise<boolean> => {
const durable = await tryWriteDurablePendingCleanup(input.record);
let teardownFailed = !input.canTeardown;
let receipt: unknown;
if (!teardownFailed) {
try {
receipt = await input.teardown(durable.leaseId);
} catch {
teardownFailed = true;
}
}
if (!teardownFailed) {
// The teardown removed the orphan, so drop the durable row if we wrote one.
if (durable.leaseId !== null) {
await releaseCleanedUpOrphanRow(durable.leaseId, orphanDiagnosticFields(input.record), receipt);
}
return true;
}
// The teardown failed, so the orphan sandbox is still live.
if (durable.leaseId !== null) {
// The durable row already tracks the orphan, so a sweep finds and releases
// it. The caller rethrows the original insert rejection.
return false;
}
return await escalateUnwrittenOrphan(input.record, input.cause, durable.lastError);
};
// The run-time exec parent context, held per lease id. A plugin sandbox
// provider can open a persistent session on the first command and delete it
// on lease release. The session open runs inside `execute`, under the run
// parent (the same context the `sandbox.exec` span reads). The session delete
// runs inside the lease-release RPC, which the run orchestrator calls after
// the run, outside that scope. Without a parent the host mints no
// `traceparent` and drops the provider `session.teardown` span. So `execute`
// records the exec parent here, and the release paths replay it around the
// release RPC. The host still mints and validates the `traceparent` itself;
// the value only widens which host calls carry a run parent. An entry is
// removed on release, so the map holds at most one context per live lease.
const runExecParentByLeaseId = new Map<string, StartupSpanContext>();
// Run a lease-release RPC under the lease's recorded exec parent context, and
@@ -2128,12 +1857,19 @@ function createSandboxEnvironmentDriver(
});
}
}
const acquisitionRunId = input.heartbeatRunId ?? randomUUID();
const acquiredLease = providerLease ?? await (async () => {
const acquiredLease = providerLease ?? await createOwnedSandbox(db, {
companyId: input.companyId, environmentId: input.environment.id,
heartbeatRunId: input.heartbeatRunId, issueId: input.issueId, executionWorkspaceId: input.executionWorkspaceId,
provider: parsed.config.provider, pluginId: pluginProvider.resolved.plugin.id, driverKey: parsed.config.provider,
resolvedConfig: { ...parsed.config },
configSchema: pluginProvider.resolved.driver.configSchema as Record<string, unknown> | undefined,
config: sandboxConfigForLeaseMetadata(storedConfig), requestKey: createHash("sha256").update(JSON.stringify(storedConfig)).digest("hex"),
metadata: { ...sandboxConfigForLeaseMetadata(storedConfig), driver: input.environment.driver,
pluginId: pluginProvider.resolved.plugin.id, pluginKey: pluginProvider.resolved.plugin.pluginKey, sandboxProviderPlugin: true },
timeoutMs: resolvePluginSandboxRpcTimeoutMs(workerConfig, pluginProvider.resolved.driver.defaultAcquireTimeoutMs),
}, async identity => {
try {
return await pluginWorkerManager.call(
pluginProvider.resolved.plugin.id,
"environmentAcquireLease",
return await pluginWorkerManager.call(pluginProvider.resolved.plugin.id, "environmentAcquireLease",
{
driverKey: parsed.config.provider,
companyId: input.companyId,
@@ -2143,7 +1879,8 @@ function createSandboxEnvironmentDriver(
// Plugin SDK requires a string; ad-hoc test leases use a fresh
// UUID so providers that validate or persist the runId still see
// a well-formed identifier.
runId: acquisitionRunId,
runId: identity.runId,
creationOperationId: identity.id,
workspaceMode: input.executionWorkspaceMode ?? undefined,
agentId: input.agentId ?? undefined,
executionWorkspaceId: input.executionWorkspaceId ?? undefined,
@@ -2162,62 +1899,18 @@ function createSandboxEnvironmentDriver(
? { requestedExpiresAt: requestedExpiresAtParam(input.requestedExpiresAt) }
: {}),
},
resolvePluginSandboxRpcTimeoutMs(
workerConfig,
pluginProvider.resolved.driver.defaultAcquireTimeoutMs,
),
);
resolvePluginSandboxRpcTimeoutMs(workerConfig, pluginProvider.resolved.driver.defaultAcquireTimeoutMs));
} catch (error) {
captureEnvironmentAcquisitionDiagnostic(error, {
companyId: input.companyId, environmentId: input.environment.id, runId: acquisitionRunId,
}, null);
const cleanup = readEnvironmentCreationCleanupError(error);
// The authenticated worker may identify its uncertain allocation,
// but cannot redirect cleanup to another company, environment, or run.
if (cleanup && cleanup.companyId === input.companyId &&
cleanup.environmentId === input.environment.id && cleanup.runId === acquisitionRunId) {
if (error instanceof JsonRpcCallError) captureEnvironmentAcquisitionDiagnostic(error, {
companyId: input.companyId, environmentId: input.environment.id, runId: acquisitionRunId,
}, readEnvironmentAcquisitionDiagnostic(error));
const cleanupMetadata = {
...sandboxConfigForLeaseMetadata(storedConfig),
failedCreateCleanup: cleanup,
driver: input.environment.driver,
pluginId: pluginProvider.resolved.plugin.id,
pluginKey: pluginProvider.resolved.plugin.pluginKey,
sandboxProviderPlugin: true,
};
// Record before retrying the provider. Existing pending-cleanup
// recovery (including its durable spool) survives controller loss.
const cleanupConfirmed = await cleanUpRejectedOrphanSandbox({
record: {
companyId: input.companyId, environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId ?? null,
issueId: input.issueId ?? null, heartbeatRunId: input.heartbeatRunId ?? null,
provider: parsed.config.provider, providerLeaseId: cleanup.providerLeaseId,
metadata: cleanupMetadata,
},
cause: error,
canTeardown: pluginWorkerManager.isRunning(pluginProvider.resolved.plugin.id),
teardown: (pendingLeaseId) => destroyWithCreationObservation({
leaseId: pendingLeaseId, metadata: cleanupMetadata,
teardown: (metadata) => pluginWorkerManager.call(pluginProvider.resolved.plugin.id, "environmentDestroyLease", {
driverKey: parsed.config.provider, companyId: input.companyId,
environmentId: input.environment.id, issueId: input.issueId,
config: workerConfig, providerLeaseId: cleanup.providerLeaseId,
leaseMetadata: metadata,
}, resolvePluginSandboxRpcTimeoutMs(workerConfig)),
}),
});
if (cleanupConfirmed) {
throw new Error("Sandbox creation failed; allocated sandbox cleanup was confirmed.", { cause: error });
}
}
// A cleanup record is not a usable lease. Keep acquisition failed,
// even when its compensating deletion succeeds on this attempt.
const diagnostic = error instanceof JsonRpcCallError && cleanup?.companyId === input.companyId &&
cleanup.environmentId === input.environment.id && cleanup.runId === identity.runId
? readEnvironmentAcquisitionDiagnostic(error) : null;
captureEnvironmentAcquisitionDiagnostic(error, {
companyId: input.companyId, environmentId: input.environment.id, runId: identity.runId,
}, diagnostic);
throw error;
}
})();
});
// Ad-hoc test leases are never publishable for reuse: storing them
// as `reuse_by_environment` would let a concurrent heartbeat resume
@@ -2275,82 +1968,34 @@ function createSandboxEnvironmentDriver(
: {}),
...(reusableScope ? { reusableSandboxLease: reusableScope } : {}),
};
try {
return await environmentsSvc.acquireLease({
companyId: input.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId,
issueId: input.issueId,
heartbeatRunId: input.heartbeatRunId,
assertCompanyBinding: input.assertCompanyBinding,
leasePolicy: resolvedLeasePolicy,
provider: parsed.config.provider,
providerLeaseId: acquiredLease.providerLeaseId,
expiresAt: providerAttestedLeaseExpiry(
input.requestedExpiresAt,
acquiredLease.expiresAt ? new Date(acquiredLease.expiresAt) : undefined,
),
metadata: pluginLeaseMetadata,
reusesReusableLeaseId:
providerLease &&
reusableLease?.heartbeatRunId === input.heartbeatRunId
? reusableLease.id
: null,
replacesReusableLeaseId:
providerLease &&
reusableLease?.heartbeatRunId !== input.heartbeatRunId
? reusableLease?.id
: null,
});
} catch (error) {
// The conditional lease insert rejected, so no lease row exists. A
// managed reconciliation can bind the environment to another company
// between the route guard and this insert, so the insert fails closed
// with `environment_company_mismatch`. This call already provisioned the
// remote plugin sandbox above, so tear it down now. Without this step
// the rejected insert leaks a live sandbox that no lease row tracks. The
// Claude login runs on a plugin-backed sandbox, so this path is the one
// the login uses. Do not tear down a reused sandbox that an earlier
// lease still owns.
if (!reusableLease || acquiredLease.providerLeaseId !== reusableLease.providerLeaseId) {
// Record the durable pending-cleanup row before the teardown, then
// tear the sandbox down. On a successful teardown the handler releases
// the row. If the durable write and the teardown both fail, the
// handler throws a `SandboxOrphanCleanupWriteError` that carries the
// original rejection as its cause.
await cleanUpRejectedOrphanSandbox({
record: {
companyId: input.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId ?? null,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
provider: parsed.config.provider,
providerLeaseId: acquiredLease.providerLeaseId,
metadata: pluginLeaseMetadata,
},
cause: error,
canTeardown: pluginWorkerManager.isRunning(pluginProvider.resolved.plugin.id),
teardown: async () => {
return await pluginWorkerManager.call(
pluginProvider.resolved.plugin.id,
"environmentDestroyLease",
{
driverKey: parsed.config.provider,
companyId: input.companyId,
environmentId: input.environment.id,
issueId: input.issueId,
config: workerConfig,
providerLeaseId: acquiredLease.providerLeaseId,
leaseMetadata: acquiredLease.metadata ?? undefined,
},
resolvePluginSandboxRpcTimeoutMs(workerConfig),
);
},
});
}
throw error;
}
return await environmentsSvc.acquireLease({
creationOperationId: providerLease ? undefined : acquiredLease.metadata?.creationOperationId as string | undefined,
companyId: input.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId,
issueId: input.issueId,
heartbeatRunId: input.heartbeatRunId,
assertCompanyBinding: input.assertCompanyBinding,
leasePolicy: resolvedLeasePolicy,
provider: parsed.config.provider,
providerLeaseId: acquiredLease.providerLeaseId,
expiresAt: providerAttestedLeaseExpiry(
input.requestedExpiresAt,
acquiredLease.expiresAt ? new Date(acquiredLease.expiresAt) : undefined,
),
metadata: pluginLeaseMetadata,
reusesReusableLeaseId:
providerLease &&
reusableLease?.heartbeatRunId === input.heartbeatRunId
? reusableLease.id
: null,
replacesReusableLeaseId:
providerLease &&
reusableLease?.heartbeatRunId !== input.heartbeatRunId
? reusableLease?.id
: null,
});
}
// Built-in sandbox provider path. Same guard as the plugin-backed path:
@@ -2441,10 +2086,16 @@ function createSandboxEnvironmentDriver(
let providerLease;
try {
providerLease = await acquireSandboxProviderLease({
providerLease = await createOwnedSandbox(db, {
companyId: input.companyId, environmentId: input.environment.id, heartbeatRunId: input.heartbeatRunId,
issueId: input.issueId, executionWorkspaceId: input.executionWorkspaceId, provider: parsed.config.provider,
driverKey: parsed.config.provider, config: sandboxConfigForLeaseMetadata(parsed.config),
requestKey: createHash("sha256").update(JSON.stringify(parsed.config)).digest("hex"),
metadata: { ...sandboxConfigForLeaseMetadata(parsed.config), driver: input.environment.driver },
}, identity => acquireSandboxProviderLease({
config: parsed.config,
environmentId: input.environment.id,
heartbeatRunId: input.heartbeatRunId ?? randomUUID(),
heartbeatRunId: identity.runId,
issueId: input.issueId,
agentId: input.agentId,
executionWorkspaceId: input.executionWorkspaceId,
@@ -2452,7 +2103,7 @@ function createSandboxEnvironmentDriver(
// Forward a caller deadline so the provider configures a provider-side
// expiry at or before it and returns the real provider expiry.
requestedExpiresAt: requestedExpiresAtParam(input.requestedExpiresAt),
});
}));
} catch (error) {
if (reusableLease) {
throw new ReusableSandboxResumeError({
@@ -2521,72 +2172,35 @@ function createSandboxEnvironmentDriver(
: {}),
...(reusableScope ? { reusableSandboxLease: reusableScope } : {}),
};
try {
return await environmentsSvc.acquireLease({
companyId: input.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId,
issueId: input.issueId,
heartbeatRunId: input.heartbeatRunId,
assertCompanyBinding: input.assertCompanyBinding,
leasePolicy: resolvedLeasePolicy,
provider: parsed.config.provider,
providerLeaseId: providerLease.providerLeaseId,
expiresAt: providerAttestedLeaseExpiry(
input.requestedExpiresAt,
providerLease.expiresAt ? new Date(providerLease.expiresAt) : undefined,
),
metadata: builtinLeaseMetadata,
reusesReusableLeaseId:
reusableLease &&
providerLease.providerLeaseId === reusableLease.providerLeaseId &&
reusableLease.heartbeatRunId === input.heartbeatRunId
? reusableLease.id
: null,
replacesReusableLeaseId:
reusableLease &&
providerLease.providerLeaseId === reusableLease.providerLeaseId &&
reusableLease.heartbeatRunId !== input.heartbeatRunId
? reusableLease.id
: null,
});
} catch (error) {
// The conditional lease insert rejected, so no lease row exists. A managed
// reconciliation can bind the environment to another company between the
// route guard and this insert, so the insert fails closed with
// `environment_company_mismatch`. This call already provisioned the remote
// sandbox above, so release it now. Without this teardown the rejected
// insert leaks a live sandbox that no lease row tracks. Do not tear down a
// reused sandbox that an earlier lease still owns.
if (!reusableLease || providerLease.providerLeaseId !== reusableLease.providerLeaseId) {
// Record the durable pending-cleanup row before the teardown, then
// release the remote sandbox. On a successful teardown the handler
// releases the row. If the durable write and the teardown both fail,
// the handler throws a `SandboxOrphanCleanupWriteError` that carries the
// original rejection as its cause.
await cleanUpRejectedOrphanSandbox({
record: {
companyId: input.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId ?? null,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
provider: parsed.config.provider,
providerLeaseId: providerLease.providerLeaseId,
metadata: builtinLeaseMetadata,
},
cause: error,
canTeardown: true,
teardown: async () => {
await destroySandboxProviderLease({
config: parsed.config,
providerLeaseId: providerLease.providerLeaseId,
});
},
});
}
throw error;
}
return await environmentsSvc.acquireLease({
creationOperationId: providerLease.metadata?.creationOperationId as string | undefined,
companyId: input.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId,
issueId: input.issueId,
heartbeatRunId: input.heartbeatRunId,
assertCompanyBinding: input.assertCompanyBinding,
leasePolicy: resolvedLeasePolicy,
provider: parsed.config.provider,
providerLeaseId: providerLease.providerLeaseId,
expiresAt: providerAttestedLeaseExpiry(
input.requestedExpiresAt,
providerLease.expiresAt ? new Date(providerLease.expiresAt) : undefined,
),
metadata: builtinLeaseMetadata,
reusesReusableLeaseId:
reusableLease &&
providerLease.providerLeaseId === reusableLease.providerLeaseId &&
reusableLease.heartbeatRunId === input.heartbeatRunId
? reusableLease.id
: null,
replacesReusableLeaseId:
reusableLease &&
providerLease.providerLeaseId === reusableLease.providerLeaseId &&
reusableLease.heartbeatRunId !== input.heartbeatRunId
? reusableLease.id
: null,
});
},
async releaseRunLease(input) {
@@ -2719,7 +2333,7 @@ function createSandboxEnvironmentDriver(
`Sandbox provider "${recordedProvider}" needs a plugin worker manager for cleanup, but none is available.`,
);
}
const pinnedPluginId = exportResume ? readString(input.lease.metadata?.pluginId) : null;
const pinnedPluginId = exportResume || input.lease.metadata?.creationOperationId ? readString(input.lease.metadata?.pluginId) : null;
if (exportResume && !pinnedPluginId) throw new Error("Workspace export cleanup has no recorded provider plugin.");
const pluginProvider = await resolveSandboxProviderPlugin({ provider: recordedProvider, ...(pinnedPluginId ? { pluginId: pinnedPluginId } : {}) });
if (pluginProvider.state !== "running") {
@@ -3534,6 +3148,7 @@ const INTERNAL_PLUGIN_SANDBOX_CONFIG_KEYS = new Set([
"remoteCwd",
"shellCommand",
"sandboxProviderPlugin",
"creationOperationId",
"sandboxLeaseAcquisition",
"nativeHarnessBackup",
"nativeWorkspaceSync",
@@ -3698,13 +3313,20 @@ function createPluginEnvironmentDriver(
resumed = undefined;
}
}
const providerLease = resumed ?? await workerManager.call(plugin.id, "environmentAcquireLease", {
const providerLease = resumed ?? await createOwnedSandbox(db, {
companyId: input.companyId, environmentId: input.environment.id, heartbeatRunId: input.heartbeatRunId,
issueId: input.issueId, executionWorkspaceId: input.executionWorkspaceId,
provider: `plugin:${parsed.config.pluginKey}:${parsed.config.driverKey}`, pluginId: plugin.id, driverKey: parsed.config.driverKey,
config: {}, timeoutMs: rpcTimeoutMs, requestKey: fingerprint,
metadata: { driver: "plugin", pluginId: plugin.id, pluginKey: parsed.config.pluginKey, driverKey: parsed.config.driverKey },
}, identity => workerManager.call(plugin.id, "environmentAcquireLease", {
driverKey: parsed.config.driverKey,
companyId: input.companyId,
environmentId: input.environment.id,
issueId: input.issueId,
config: parsed.config.driverConfig,
runId: input.heartbeatRunId ?? randomUUID(),
runId: identity.runId,
creationOperationId: identity.id,
workspaceMode: input.executionWorkspaceMode ?? undefined,
agentId: input.agentId ?? undefined,
executionWorkspaceId: input.executionWorkspaceId ?? undefined,
@@ -3715,9 +3337,10 @@ function createPluginEnvironmentDriver(
...(requestedExpiresAtParam(input.requestedExpiresAt) !== undefined
? { requestedExpiresAt: requestedExpiresAtParam(input.requestedExpiresAt) }
: {}),
} as PluginEnvironmentAcquireLeaseParams, ...timeoutOverride);
} as PluginEnvironmentAcquireLeaseParams, ...timeoutOverride));
return await environmentsSvc.acquireLease({
creationOperationId: resumed ? undefined : providerLease.metadata?.creationOperationId as string | undefined,
companyId: input.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.executionWorkspaceId,
@@ -3964,14 +3587,13 @@ export function environmentRuntimeService(
pluginWorkerManager?: PluginWorkerManager;
pluginWorkerReadyTimeoutMs?: number;
pluginWorkerReadyPollMs?: number;
pendingCleanupWriteAttempts?: number;
pendingCleanupWriteBackoffMs?: number;
deferredOrphanCleanupBufferLimit?: number;
orphanCleanupSpool?: SandboxOrphanCleanupSpool;
orphanCleanupSpoolDir?: string;
} = {},
) {
const environmentsSvc = environmentService(db);
let orphanRecoveryFlush: Promise<{ recovered: number; pending: number }> | null = null;
const drivers = new Map<string, EnvironmentRuntimeDriver>();
const defaultDrivers = [
@@ -3981,8 +3603,6 @@ export function environmentRuntimeService(
pluginWorkerManager: options.pluginWorkerManager,
pluginWorkerReadyTimeoutMs: options.pluginWorkerReadyTimeoutMs,
pluginWorkerReadyPollMs: options.pluginWorkerReadyPollMs,
pendingCleanupWriteAttempts: options.pendingCleanupWriteAttempts,
pendingCleanupWriteBackoffMs: options.pendingCleanupWriteBackoffMs,
deferredOrphanCleanupBufferLimit: options.deferredOrphanCleanupBufferLimit,
orphanCleanupSpool: options.orphanCleanupSpool,
orphanCleanupSpoolDir: options.orphanCleanupSpoolDir,
@@ -4439,14 +4059,53 @@ export function environmentRuntimeService(
return driver.isPendingCleanupWorkerReady(input);
},
// Flush the in-process orphan-cleanup buffers of every driver that keeps one.
// The cleanup sweep calls this each tick, so a buffered orphan lands a durable
// `pending_cleanup` row once the database recovers, and the same sweep tears
// it down. The dispatcher sums the recovered and pending counts across all
// drivers. A driver with no buffer contributes nothing.
// Recover committed creation operations before replaying legacy cleanup inputs.
// New creation failures retain their database operation and do not make files.
// Legacy buffers and files remain until their cleanup rows are durable.
async flushDeferredOrphanCleanups(): Promise<{ recovered: number; pending: number }> {
return flushDeferredEnvironmentCleanup(db, Array.from(drivers.values())
.flatMap(driver => driver.flushDeferredOrphanCleanups ? [driver.flushDeferredOrphanCleanups] : []));
orphanRecoveryFlush ??= (async () => {
await recoverSandboxCreations(db, async operation => {
if (!operation.pluginId || operation.metadata.sandboxProviderPlugin !== true) return null;
if (!options.pluginWorkerManager) throw new Error("Sandbox creation recovery worker is unavailable");
const plugin = await pluginRegistryService(db).getById(operation.pluginId);
const driver = plugin?.manifestJson.environmentDrivers?.find(value => value.driverKey === operation.driverKey);
if (driver?.creationOperationVersion !== 1) return null;
if (!options.pluginWorkerManager.isRunning(operation.pluginId) ||
!options.pluginWorkerManager.getWorker(operation.pluginId)?.supportedMethods.includes("environmentInspectLease")) {
throw new Error("Sandbox creation recovery worker is unavailable");
}
let resolved: SandboxEnvironmentConfig;
try {
resolved = await resolveSandboxCleanupConfigSecrets(db, operation.companyId,
operation.config as SandboxEnvironmentConfig, { issueId: operation.issueId });
} catch (error) {
// Missing or explicitly inactive saved credentials need an operator.
// Temporary database/provider failures still retain retry ownership.
const code = error instanceof HttpError ? (error.details as { code?: string } | undefined)?.code : undefined;
if (error instanceof HttpError && (
(error.status === 404 && (code === "secret_deleted" || code === "version_missing" ||
(error.details === undefined && error.message === "Secret not found"))) ||
(error.status === 422 && (code === "secret_inactive" || code === "version_inactive" ||
(error.details === undefined && (error.message === "Provider vault is disabled" ||
error.message === "Provider vault is locked while coming soon"))))
)) return null;
throw error;
}
const credentialHashes = operation.metadata.creationCredentialHashes as Record<string, string> | undefined;
for (const [path, expected] of Object.entries(credentialHashes ?? {})) {
const actual = readConfigValueAtPath({ ...resolved }, path);
if (typeof actual !== "string" || createHash("sha256").update(actual).digest("hex") !== expected) return null;
}
return options.pluginWorkerManager.call(operation.pluginId, "environmentInspectLease", {
driverKey: operation.driverKey, companyId: operation.companyId, environmentId: operation.originalEnvironmentId,
config: stripSandboxProviderEnvelope(resolved), providerLeaseId: operation.id,
creationOperation: { id: operation.id, runId: operation.runId },
});
});
return flushDeferredEnvironmentCleanup(db, Array.from(drivers.values())
.flatMap(driver => driver.flushDeferredOrphanCleanups ? [driver.flushDeferredOrphanCleanups] : []));
})().finally(() => { orphanRecoveryFlush = null; });
return orphanRecoveryFlush;
},
async destroyReusableSandboxLeases(input: {
+22 -45
View File
@@ -1,8 +1,6 @@
import { beginIdleTrackedWork } from "./task-admission.js";
import { registerDeferredEnvironmentCleanup, writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { environmentService } from "./environments.js";
import { createSandboxOrphanCleanupSpool, type DeferredOrphanCleanupRecord } from "./sandbox-orphan-cleanup-spool.js";
import { logger } from "../middleware/logger.js";
import { attachSandboxCreation, environmentService } from "./environments.js";
import { createOwnedSandbox, recordSandboxCreationResource } from "./sandbox-creation-operations.js";
import { writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { releaseProviderRecoveryTransfers, stageProviderFreshWorkspace } from "./provider-fresh-workspace.js";
import { readWorkspaceProviderBinding } from "@paperclipai/shared";
import { settlePluginTaskCredentialHandoff } from "./plugin-task-credential-writeback.js";
@@ -234,59 +232,38 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({runnerTaskExecutionStopped:true,
runnerTaskSessionRetained:state.sessionStateAvailable === true})}::jsonb`,
}).where(and(eq(environmentLeases.id, metadata.runnerTaskPreviousLeaseId), eq(environmentLeases.companyId, input.companyId)));
const fresh = await workers.call(plugin.id, "environmentAcquireLease", {driverKey:metadata.driverKey,
const replacementDriverKey = metadata.driverKey;
const fresh = await createOwnedSandbox(db, {
companyId: input.companyId, environmentId: environment!.id, heartbeatRunId: lease.heartbeatRunId,
issueId: lease.issueId, executionWorkspaceId: lease.executionWorkspaceId, provider: lease.provider!,
pluginId: plugin.id, driverKey: metadata.driverKey, config: {}, requestKey: taskId,
metadata: { driver: "plugin", pluginId: plugin.id, pluginKey: plugin.pluginKey, driverKey: metadata.driverKey, runnerTaskLease: true },
}, identity => workers.call(plugin.id, "environmentAcquireLease", {driverKey:replacementDriverKey,
companyId:input.companyId,environmentId:environment!.id,issueId:lease.issueId,config:driverConfig,
runId:run!.id,agentId:run!.agentId,adapterType:"paperclip_runner",
...(typeof metadata.runnerTaskRequestedExpiresAt === "string" ? {requestedExpiresAt:metadata.runnerTaskRequestedExpiresAt} : {})});
runId:identity.runId,creationOperationId:identity.id,agentId:run!.agentId,adapterType:"paperclip_runner",
...(typeof metadata.runnerTaskRequestedExpiresAt === "string" ? {requestedExpiresAt:metadata.runnerTaskRequestedExpiresAt} : {})}));
if (!fresh.providerLeaseId || fresh.providerLeaseId === taskId) throw new Error("Plugin replacement task identity unavailable");
await recordSandboxCreationResource(db, { id: String(fresh.metadata!.creationOperationId), companyId: input.companyId,
environmentId: environment!.id, providerLeaseId: fresh.providerLeaseId });
const {runnerTaskIdentity:_identity,runnerTaskPriorIdentity:_prior,runnerTaskCredentialHandoff:_handoff,
runnerTaskResumeFrom:_resume,runnerTaskPreviousLeaseId:_previous,...rest}=metadata;
const replacementMetadata={...rest,providerMetadata:fresh.metadata ?? {},runnerTaskReused:false,runnerTaskDispatched:false,
runnerTaskExecutionStopped:false,runnerTaskSessionRetained:false};
try {
{
const expiresAt=fresh.expiresAt ? new Date(fresh.expiresAt) : null;
const deadline=typeof metadata.runnerTaskRequestedExpiresAt === "string" ? Date.parse(metadata.runnerTaskRequestedExpiresAt) : null;
if ((expiresAt && (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() <= Date.now())) ||
(deadline !== null && (!Number.isFinite(deadline) || !expiresAt || expiresAt.getTime() > deadline)))
throw new Error("Plugin replacement task expiry is not bounded");
const replacement = await db.update(environmentLeases).set({providerLeaseId:fresh.providerLeaseId,expiresAt,
metadata:replacementMetadata,
await attachSandboxCreation(db, String(fresh.metadata!.creationOperationId), { companyId: input.companyId, environmentId: environment!.id, heartbeatRunId: lease.heartbeatRunId }, async tx => {
const [updated] = await tx.update(environmentLeases).set({providerLeaseId:fresh.providerLeaseId,expiresAt,
metadata:{...replacementMetadata,creationOperationId:fresh.metadata!.creationOperationId},
}).where(and(eq(environmentLeases.id,lease.id),eq(environmentLeases.providerLeaseId,taskId),
eq(environmentLeases.status,"active"))).returning({id:environmentLeases.id});
if (replacement.length !== 1) throw new Error("Plugin task replacement ownership changed");
} catch (error) {
try {
await workers.call(plugin.id,"environmentReleaseLease",{driverKey:metadata.driverKey,companyId:input.companyId,
environmentId:environment!.id,issueId:lease.issueId,config:driverConfig,providerLeaseId:fresh.providerLeaseId,
leaseMetadata:replacementMetadata});
} catch {
const orphan:DeferredOrphanCleanupRecord={companyId:input.companyId,environmentId:environment!.id,
executionWorkspaceId:lease.executionWorkspaceId,issueId:lease.issueId,heartbeatRunId:lease.heartbeatRunId,
provider:lease.provider!,providerLeaseId:fresh.providerLeaseId,metadata:replacementMetadata};
try {
await environmentService(db).insertPendingCleanupLease({...orphan,failureReason:"plugin_task_replacement_unowned"});
} catch {
// The existing orphan spool recovers cleanup even when the database is unavailable.
const spool = createSandboxOrphanCleanupSpool();
const persisted = await spool.append(orphan);
const finish = beginIdleTrackedWork();
let flushing: Promise<{ recovered: number; pending: number }> | null = null;
const unregister = registerDeferredEnvironmentCleanup(db, () => {
flushing ??= (async () => {
await environmentService(db).insertPendingCleanupLease({...orphan,failureReason:"plugin_task_replacement_unowned"});
await spool.remove(orphan);
unregister();
finish();
return { recovered: 1, pending: 0 };
})().catch(error => { flushing = null; throw error; });
// Keep successful completion for a concurrent sweep that already
// captured this callback before it was unregistered.
return flushing;
});
logger.error({providerLeaseId:fresh.providerLeaseId,persisted},"Plugin replacement cleanup requires recovery");
}
}
throw error;
if (!updated) throw new Error("Plugin task replacement ownership changed");
return (await environmentService(tx).getLeaseById(updated.id))!;
});
}
return executeEnvironmentTask(db, workers, input);
}
+51 -42
View File
@@ -16,6 +16,7 @@ import {
companySecretBindings,
environmentCustomImageSetupSessions,
environmentLeases,
sandboxCreationOperations,
environments,
executionWorkspaces,
heartbeatRuns,
@@ -48,6 +49,43 @@ import {
type ManagedResourceStockStatus,
} from "./managed-resource-drift.js";
export async function attachSandboxCreation(db: Db, id: string, scope: { companyId: string; environmentId: string; heartbeatRunId?: string | null },
attach: (tx: Db) => Promise<EnvironmentLease>): Promise<EnvironmentLease> {
return db.transaction(async tx => {
await assertEnvironmentCompanyBinding(tx as unknown as Db, scope.environmentId, scope.companyId);
const [operation] = await tx.select().from(sandboxCreationOperations)
.where(and(eq(sandboxCreationOperations.id, id), eq(sandboxCreationOperations.companyId, scope.companyId),
eq(sandboxCreationOperations.originalEnvironmentId, scope.environmentId))).for("update");
if (scope.heartbeatRunId) {
const [run] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, scope.heartbeatRunId), eq(heartbeatRuns.companyId, scope.companyId), eq(heartbeatRuns.status, "running"),
)).for("update");
if (!run || operation?.runId !== scope.heartbeatRunId) throw new Error("Sandbox creation run authority changed");
}
if (operation?.state === "attached" && operation.leaseId) {
const existing = await environmentService(tx as unknown as Db).getLeaseById(operation.leaseId);
if (existing?.status === "active") return existing;
}
if (!operation || operation.state !== "pending") throw new Error("Sandbox creation ownership has already transferred");
const lease = await attach(tx as unknown as Db);
await notifyEnvironmentCleanup(tx, { companyId: scope.companyId });
await tx.update(sandboxCreationOperations).set({ state: "attached", leaseId: lease.id, updatedAt: new Date() })
.where(eq(sandboxCreationOperations.id, id));
return lease;
});
}
export async function assertEnvironmentCompanyBinding(db: Db, environmentId: string, companyId: string): Promise<void> {
const [environment] = await db.select({ id: environments.id }).from(environments)
.where(eq(environments.id, environmentId)).for("update");
if (!environment) throw new Error("Sandbox creation environment no longer exists");
const bindings = await db.select({ companyId: builtInManagedResources.companyId }).from(builtInManagedResources)
.where(and(eq(builtInManagedResources.resourceKind, "environment"), eq(builtInManagedResources.resourceId, environmentId)));
if (bindings.length && !bindings.some(binding => binding.companyId === companyId)) {
throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" });
}
}
type EnvironmentRow = typeof environments.$inferSelect;
type EnvironmentLeaseRow = typeof environmentLeases.$inferSelect;
const DEFAULT_LOCAL_ENVIRONMENT_NAME = "Local";
@@ -1428,6 +1466,7 @@ export function environmentService(db: Db) {
},
acquireLease: async (input: {
creationOperationId?: string;
companyId: string;
environmentId: string;
executionWorkspaceId?: string | null;
@@ -1465,6 +1504,11 @@ export function environmentService(db: Db) {
*/
assertCompanyBinding?: boolean;
}): Promise<EnvironmentLease> => {
if (input.creationOperationId) {
const { creationOperationId, ...leaseInput } = input;
return attachSandboxCreation(db, creationOperationId, { companyId: input.companyId, environmentId: input.environmentId, heartbeatRunId: input.heartbeatRunId },
tx => environmentService(tx).acquireLease({ ...leaseInput, assertCompanyBinding: true }));
}
const now = new Date();
const replacementLeaseId = input.replacesReusableLeaseId ? randomUUID() : null;
const values = {
@@ -1515,48 +1559,7 @@ export function environmentService(db: Db) {
companyId: input.companyId, runId: input.heartbeatRunId,
});
if (input.assertCompanyBinding) {
// Lock the environment row first. Managed reconciliation locks the
// same sandbox environment rows with `for update` before it writes a
// company binding, so this lock serializes the two transactions on
// this row and closes the time-of-check to time-of-use window.
await tx
.select({ id: environments.id })
.from(environments)
.where(eq(environments.id, input.environmentId))
.for("update");
// Re-read the company binding inside the locked transaction. A
// binding a reconciliation committed after the route guard now
// appears here. Reject a foreign-company environment before the
// insert, so the login holds no lease.
const boundRows = await tx
.select({ companyId: builtInManagedResources.companyId })
.from(builtInManagedResources)
.where(
and(
eq(
builtInManagedResources.resourceKind,
MANAGED_ENVIRONMENT_RESOURCE_KIND,
),
eq(
builtInManagedResources.resourceId,
input.environmentId,
),
),
);
const boundCompanyIds = Array.from(
new Set(boundRows.map((boundRow) => boundRow.companyId)),
);
if (
boundCompanyIds.length > 0 &&
!boundCompanyIds.includes(input.companyId)
) {
throw forbidden(
"The selected environment belongs to another company.",
{
code: "environment_company_mismatch",
},
);
}
await assertEnvironmentCompanyBinding(tx as unknown as Db, input.environmentId, input.companyId);
}
if (input.replacesReusableLeaseId) {
await notifyChatInboundWakeNotices(tx, { companyId: input.companyId });
@@ -1699,6 +1702,7 @@ export function environmentService(db: Db) {
const [previous] = await tx.select({ companyId: environmentLeases.companyId, issueId: environmentLeases.issueId,
heartbeatRunId: environmentLeases.heartbeatRunId, status: environmentLeases.status,
cleanupStatus: environmentLeases.cleanupStatus, releasedAt: environmentLeases.releasedAt,
creationOperationId: sql<string | null>`${environmentLeases.metadata}->>'creationOperationId'`,
}).from(environmentLeases).where(condition).for("update");
if (!previous) return null;
await notifyEnvironmentCleanup(tx, { companyId: previous.companyId, runId: previous.heartbeatRunId ?? undefined });
@@ -1715,6 +1719,11 @@ export function environmentService(db: Db) {
companyId: previous.companyId, runId: previous.heartbeatRunId,
});
}
if (previous.creationOperationId && previous.status === "pending_cleanup" && options?.cleanupStatus === "success" && status !== "pending_cleanup") {
await tx.update(sandboxCreationOperations).set({ state: "resolved", updatedAt: now })
.where(and(eq(sandboxCreationOperations.id, previous.creationOperationId), eq(sandboxCreationOperations.companyId, previous.companyId),
eq(sandboxCreationOperations.leaseId, id), eq(sandboxCreationOperations.state, "cleanup_pending")));
}
return tx.update(environmentLeases)
.set({
status,
+1
View File
@@ -54,6 +54,7 @@ const WORK_CHECKS = [
state NOT IN ('saved', 'unchanged', 'resolved', 'unavailable', 'conflict')
OR process_stopped_at IS NULL OR next_attempt_at IS NOT NULL
OR receipt ? 'baseline' OR receipt->>'cleanupPending' = 'true'`,
`SELECT 1 FROM sandbox_creation_operations WHERE state = 'pending'`,
`SELECT 1 FROM budget_reservations WHERE state NOT IN ('settled', 'released')`,
`SELECT 1 FROM budget_policies WHERE enforcement_version > enforcement_delivered_version`,
`SELECT 1 FROM agents WHERE lifecycle_state IN
@@ -0,0 +1,150 @@
import { collectSecretRefPaths, isUuidSecretRef, parseSecretRefBindingObject, readConfigValueAtPath } from "./json-schema-secret-refs.js";
import { createHash, randomUUID } from "node:crypto";
import { and, eq, lte, or, sql } from "drizzle-orm";
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 } from "./environment-cleanup-state.js";
import { logger } from "../middleware/logger.js";
import { notifyEnvironmentCleanup } from "./environment-cleanup-notifications.js";
export type SandboxCreationOperation = typeof sandboxCreationOperations.$inferSelect;
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 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 },
nextAttemptAt: new Date(Date.now() + Math.max(30 * 60_000, (owner.timeoutMs ?? 0) + 60_000)),
}).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 } };
}
const lease = await create({ id, runId });
return { ...lease, metadata: { ...lease.metadata, creationOperationId: id } };
}
/** 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;
}): Promise<void> {
const [recorded] = await db.update(sandboxCreationOperations).set({
metadata: sql`coalesce(${sandboxCreationOperations.metadata}, '{}'::jsonb) || ${JSON.stringify({ creationProviderLeaseId: input.providerLeaseId })}::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");
}
export async function recoverSandboxCreations(db: Db,
inspect: (operation: SandboxCreationOperation) => Promise<PluginEnvironmentLeaseObservation | null>,
): Promise<void> {
const due = await db.select().from(sandboxCreationOperations).where(and(
eq(sandboxCreationOperations.state, "pending"), lte(sandboxCreationOperations.nextAttemptAt, new Date()),
)).limit(100);
for (const candidate of due) {
try {
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;
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");
}
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), eq(sandboxCreationOperations.state, "pending")));
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");
}
}
}