mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
Merge origin/master and retain both Cloud skill fixture contracts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
7db456e235
58 files changed
+2225
-1922
No files matched your search
@@ -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,
|
||||
|
||||
@@ -166,7 +166,12 @@ describe.skipIf(!support.supported)("managed skills use committed versions", ()
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
it("reports a provider mismatch for local versions under persisted Cloud configuration", 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 });
|
||||
@@ -178,8 +183,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: "a".repeat(64) },
|
||||
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);
|
||||
@@ -188,7 +192,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);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user