mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 05:31:46 +02:00
fix(runtime): honor provider acquisition timeout defaults (#14097)
Declare provider acquisition budgets so slow Daytona creation does not hit the host’s 30-second fallback. Bound creation and setup to one deadline and preserve scoped cleanup ownership after timeout. Legacy drivers retain their original call shape. Validated by 238 provider/manifest tests, focused database and heartbeat regressions, root and standalone provider typecheck/build, and full CI. Local broad tests also expose recorded Mac baseline limitations. Greptile 5/5. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7f3c06dac4
commit
ffa32373bc
9 files changed
+295
-77
No files matched your search
@@ -10,6 +10,29 @@ Read [Sandbox file-sync lifecycle hooks](./SANDBOX_FILE_SYNC_HOOKS.md) for the
|
||||
native file-transfer hooks. Read the driver declaration shape in
|
||||
[the plugin specification](./PLUGIN_SPEC.md).
|
||||
|
||||
## Fresh lease acquisition timeout
|
||||
|
||||
A driver can declare `defaultAcquireTimeoutMs` as a positive integer of at most
|
||||
86,400,000 milliseconds. The host uses it for `environmentAcquireLease` when the
|
||||
resolved config has no positive finite numeric `timeoutMs`. A positive numeric
|
||||
`bridgeRequestTimeoutMs` can extend that budget. The host adds 30 seconds for RPC
|
||||
overhead. This applies to both sandbox providers and generic plugin drivers.
|
||||
|
||||
Daytona declares 300,000 milliseconds for the entire fresh acquisition. Creation,
|
||||
workspace setup, shell detection, expiry setup, and inline failure cleanup share
|
||||
that budget. Its host RPC can therefore wait 330 seconds instead of the worker's
|
||||
normal 30 seconds. On timeout the provider returns the attempt's scoped cleanup
|
||||
record for durable host reconciliation. Late SDK results cannot admit the lease
|
||||
or start the next setup phase.
|
||||
Drivers that omit the declaration keep the existing fallback. The host does not
|
||||
infer this budget from config-schema defaults: a field named `timeoutMs` can
|
||||
describe sandbox lifetime instead of the time needed to acquire it.
|
||||
|
||||
This declaration does not change provider config, lease expiry, the resume
|
||||
deadline, or other lifecycle calls. Providers must still enforce their operation
|
||||
timeouts and report uncertain allocations for cleanup. A bundled plugin must bump
|
||||
its manifest version when adding the field so existing installations receive it.
|
||||
|
||||
## How the host resolves an effective capability
|
||||
|
||||
The host never trusts a declaration alone. For every run it resolves each
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip.daytona-sandbox-provider";
|
||||
export const DEFAULT_DAYTONA_OPERATION_TIMEOUT_MS = 300_000;
|
||||
// The bundled-plugin boot reconcile refreshes the persisted manifest for an
|
||||
// existing install only when PLUGIN_VERSION changes. A manifest change without a
|
||||
// version bump never reaches an existing install. The reconcile also reads the
|
||||
@@ -13,7 +14,8 @@ const PLUGIN_ID = "paperclip.daytona-sandbox-provider";
|
||||
// 0.1.5 adds the `duplexCommandStream` sandbox capability to the driver.
|
||||
// 0.1.6 adds private authenticated WebSocket ingress for paperclip_runner.
|
||||
// 0.1.7 exposes host-owned warm/cold runner lifecycle controls.
|
||||
const PLUGIN_VERSION = "0.1.7";
|
||||
// 0.1.8 declares the default provider acquisition budget to the host.
|
||||
const PLUGIN_VERSION = "0.1.8";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
@@ -31,6 +33,7 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "daytona",
|
||||
defaultAcquireTimeoutMs: DEFAULT_DAYTONA_OPERATION_TIMEOUT_MS,
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Daytona Sandbox",
|
||||
description:
|
||||
@@ -139,8 +142,8 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "number",
|
||||
description: "Timeout for Daytona create/start/stop/execute operations in milliseconds.",
|
||||
default: 300000,
|
||||
description: "Timeout for Daytona operations in milliseconds. Fresh lease acquisition shares one budget across creation, setup, and inline cleanup.",
|
||||
default: DEFAULT_DAYTONA_OPERATION_TIMEOUT_MS,
|
||||
},
|
||||
livenessTimeoutMs: {
|
||||
type: "number",
|
||||
|
||||
@@ -135,6 +135,7 @@ describe("Daytona sandbox provider plugin", () => {
|
||||
expect(plugin.definition.onEnvironmentStartInteractiveSetup).toBeTypeOf("function");
|
||||
expect(plugin.definition.onEnvironmentCaptureTemplate).toBeTypeOf("function");
|
||||
expect(manifest.environmentDrivers?.[0]).toMatchObject({
|
||||
defaultAcquireTimeoutMs: 300_000,
|
||||
supportsInteractiveSetup: true,
|
||||
interactiveSetupConnectionTypes: ["ssh"],
|
||||
supportsTemplateCapture: true,
|
||||
@@ -249,9 +250,9 @@ describe("Daytona sandbox provider plugin", () => {
|
||||
|
||||
it("bumps the plugin version so the server reconciles the stored manifest", () => {
|
||||
// The bundled-plugin boot reconcile refreshes the stored manifest for an
|
||||
// existing install only when the version changes. The duplex capability needs
|
||||
// existing install only when the version changes. The acquisition budget needs
|
||||
// the bump to reach an existing install.
|
||||
expect(manifest.version).toBe("0.1.7");
|
||||
expect(manifest.version).toBe("0.1.8");
|
||||
});
|
||||
|
||||
it.each([false, true])("closes duplex routes on lease release even when bridge drain hangs: %s", async (hangDrain) => {
|
||||
@@ -524,6 +525,85 @@ describe("Daytona sandbox provider plugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fresh acquisition deadline", () => {
|
||||
const params = {
|
||||
driverKey: "daytona", companyId: "company-1", environmentId: "env-1", runId: "run-1",
|
||||
config: { image: "node:20", reuseLease: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DAYTONA_API_KEY = "host-key";
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it("allows a slow create and setup that finish inside the total budget", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
mockCreate.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(sandbox), 280_000)));
|
||||
sandbox.process.executeCommand.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ result: "bash" }), 15_000)));
|
||||
const pending = plugin.definition.onEnvironmentAcquireLease!(params);
|
||||
await vi.advanceTimersByTimeAsync(295_000);
|
||||
expect(await pending).toMatchObject({ providerLeaseId: sandbox.id });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("journals ownership before the host deadline when setup outlasts creation", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
mockCreate.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(sandbox), 280_000)));
|
||||
sandbox.process.executeCommand.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ result: "bash" }), 55_000)));
|
||||
const pending = plugin.definition.onEnvironmentAcquireLease!(params).catch(error => error);
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
const cleanup = readEnvironmentCreationCleanupError(await pending);
|
||||
expect(cleanup).toMatchObject({ companyId: params.companyId, environmentId: params.environmentId,
|
||||
runId: params.runId, observedProviderLeaseId: sandbox.id, providerLeaseId: mockCreate.mock.calls[0][0].name });
|
||||
await vi.advanceTimersByTimeAsync(35_000);
|
||||
expect(sandbox.setTtl).not.toHaveBeenCalled();
|
||||
expect(sandbox.fs.uploadFile).not.toHaveBeenCalled();
|
||||
expect(sandbox.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records an uncertain create and rejects its late result without starting setup", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
mockCreate.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(sandbox), 310_000)));
|
||||
const pending = plugin.definition.onEnvironmentAcquireLease!(params).catch(error => error);
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
const cleanup = readEnvironmentCreationCleanupError(await pending);
|
||||
expect(cleanup?.providerLeaseId).toBe(mockCreate.mock.calls[0][0].name);
|
||||
expect(cleanup?.observedProviderLeaseId).toBeUndefined();
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(sandbox.getWorkDir).not.toHaveBeenCalled();
|
||||
expect(sandbox.process.executeCommand).not.toHaveBeenCalled();
|
||||
expect(sandbox.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a failed setup's ownership when inline deletion does not finish", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
mockCreate.mockResolvedValue(sandbox);
|
||||
sandbox.getWorkDir.mockRejectedValue(new Error("workspace unavailable"));
|
||||
sandbox.delete.mockImplementation(() => new Promise(() => {}));
|
||||
const pending = plugin.definition.onEnvironmentAcquireLease!({ ...params,
|
||||
config: { ...params.config, timeoutMs: 2_000 },
|
||||
}).catch(error => error);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(readEnvironmentCreationCleanupError(await pending)).toMatchObject({
|
||||
observedProviderLeaseId: sandbox.id, companyId: params.companyId, runId: params.runId,
|
||||
});
|
||||
expect(sandbox.delete).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps ownership when setup and its inline deletion both reject", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
mockCreate.mockResolvedValue(sandbox);
|
||||
sandbox.getWorkDir.mockRejectedValue(new Error("workspace unavailable"));
|
||||
sandbox.delete.mockRejectedValue(new Error("delete unavailable"));
|
||||
const error = await plugin.definition.onEnvironmentAcquireLease!(params).catch(error => error);
|
||||
expect(readEnvironmentCreationCleanupError(error)).toMatchObject({
|
||||
observedProviderLeaseId: sandbox.id, companyId: params.companyId, runId: params.runId,
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("failed sandbox creation cleanup", () => {
|
||||
const params = {
|
||||
driverKey: "daytona", companyId: "company-1", environmentId: "env-1", runId: "run-1",
|
||||
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
PluginSyncOperation,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { performSyncIn, performSyncOut, withProviderSpan } from "./file-sync.js";
|
||||
import { DEFAULT_DAYTONA_OPERATION_TIMEOUT_MS } from "./manifest.js";
|
||||
|
||||
// The Claude `setup-token` login pseudo-terminal (PTY) session for this provider.
|
||||
// The session runs the login command on a real pseudo-terminal, streams the
|
||||
@@ -275,7 +276,7 @@ function parseOptionalNumber(value: unknown): number | null {
|
||||
}
|
||||
|
||||
function parseDriverConfig(raw: Record<string, unknown>): DaytonaDriverConfig {
|
||||
const timeoutMs = Number(raw.timeoutMs ?? 300_000);
|
||||
const timeoutMs = Number(raw.timeoutMs ?? DEFAULT_DAYTONA_OPERATION_TIMEOUT_MS);
|
||||
const livenessTimeoutMs = Number(raw.livenessTimeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
|
||||
return {
|
||||
apiKey: parseOptionalString(raw.apiKey),
|
||||
@@ -284,7 +285,7 @@ function parseDriverConfig(raw: Record<string, unknown>): DaytonaDriverConfig {
|
||||
snapshot: parseOptionalString(raw.snapshot),
|
||||
image: parseOptionalString(raw.image),
|
||||
language: parseOptionalString(raw.language),
|
||||
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 300_000,
|
||||
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : DEFAULT_DAYTONA_OPERATION_TIMEOUT_MS,
|
||||
livenessTimeoutMs: Number.isFinite(livenessTimeoutMs) ? Math.trunc(livenessTimeoutMs) : DEFAULT_LIVENESS_TIMEOUT_MS,
|
||||
cpu: parseOptionalNumber(raw.cpu),
|
||||
memory: parseOptionalNumber(raw.memory),
|
||||
@@ -483,7 +484,7 @@ async function drainSandboxBeforeTermination(sandbox: Sandbox, scope: SandboxSco
|
||||
}
|
||||
|
||||
async function terminateAtProvider<T>(scope: SandboxScope, operation: string, action: () => Promise<T>) {
|
||||
const timeoutMs = scope.config.timeoutMs > 0 ? scope.config.timeoutMs : 300_000;
|
||||
const timeoutMs = scope.config.timeoutMs > 0 ? scope.config.timeoutMs : DEFAULT_DAYTONA_OPERATION_TIMEOUT_MS;
|
||||
return withLivenessTimeout(operation, timeoutMs + LIVENESS_START_TIMEOUT_MARGIN_MS, action);
|
||||
}
|
||||
|
||||
@@ -936,7 +937,10 @@ function resolveSyncRemoteDir(lease: { metadata?: Record<string, unknown> | null
|
||||
async function createSandbox(
|
||||
params: PluginEnvironmentAcquireLeaseParams | PluginEnvironmentProbeParams | PluginEnvironmentStartInteractiveSetupParams,
|
||||
config: DaytonaDriverConfig,
|
||||
options: { purpose?: string } = {},
|
||||
options: {
|
||||
purpose?: string;
|
||||
onCreateAttempt?: (cleanup: PluginEnvironmentCreationCleanup) => void;
|
||||
} = {},
|
||||
): Promise<Sandbox> {
|
||||
const resourceRequestError = validateRuntimeResourceRequest(config);
|
||||
if (resourceRequestError) {
|
||||
@@ -958,16 +962,17 @@ async function createSandbox(
|
||||
// The SDK mutates params.labels (for example, code-toolbox-language).
|
||||
// Preserve our immutable ownership snapshot for validation and retry.
|
||||
const createParams = { ...buildCreateParams(config, { ...labels }), name };
|
||||
const cleanup: PluginEnvironmentCreationCleanup = {
|
||||
providerLeaseId: name, companyId: params.companyId, environmentId: params.environmentId,
|
||||
...("runId" in params ? { runId: params.runId } : {}),
|
||||
attemptId, labels, accountFingerprint: sandboxAccountDiscriminator(config),
|
||||
};
|
||||
options.onCreateAttempt?.(cleanup);
|
||||
try {
|
||||
return await client.create(createParams, {
|
||||
timeout: toTimeoutSeconds(config.timeoutMs),
|
||||
});
|
||||
} catch (createError) {
|
||||
const cleanup: PluginEnvironmentCreationCleanup = {
|
||||
providerLeaseId: name, companyId: params.companyId, environmentId: params.environmentId,
|
||||
...("runId" in params ? { runId: params.runId } : {}),
|
||||
attemptId, labels, accountFingerprint: sandboxAccountDiscriminator(config),
|
||||
};
|
||||
try {
|
||||
// A not-found lookup after an uncertain create is not a deletion receipt:
|
||||
// the provider may still materialize the request. Keep the name in the
|
||||
@@ -2223,60 +2228,118 @@ const plugin = definePlugin({
|
||||
params: PluginEnvironmentAcquireLeaseParams,
|
||||
): Promise<PluginEnvironmentLease> {
|
||||
const config = parseDriverConfig(params.config);
|
||||
const sandbox = await createSandbox(params, config);
|
||||
try {
|
||||
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
||||
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
|
||||
// Configure a provider-side destroy time at or before a caller deadline, so
|
||||
// an abandoned sandbox self-destroys even if Paperclip is down. The lease
|
||||
// carries the real provider expiry (or none) as evidence of the bound.
|
||||
const expiresAt = await configureSandboxExpiry({
|
||||
sandbox,
|
||||
requestedExpiresAt: params.requestedExpiresAt,
|
||||
nowMs: Date.now(),
|
||||
// One budget covers creation, setup, and inline cleanup. The host leaves
|
||||
// 30 seconds beyond this deadline to receive/journal the cleanup record.
|
||||
const budgetMs = config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_DAYTONA_OPERATION_TIMEOUT_MS;
|
||||
const deadline = Date.now() + budgetMs;
|
||||
let expired = false;
|
||||
let phase = "create";
|
||||
let cleanup: PluginEnvironmentCreationCleanup | undefined;
|
||||
const timeoutFailure = () => {
|
||||
expired = true;
|
||||
const cause = new Error(`Daytona lease acquisition exceeded ${budgetMs} ms during ${phase}`);
|
||||
return cleanup
|
||||
? new PluginEnvironmentCreationCleanupError([cause],
|
||||
"Daytona lease acquisition timed out; allocation cleanup is pending",
|
||||
{ ...cleanup, labels: { ...cleanup.labels } })
|
||||
: cause;
|
||||
};
|
||||
const assertActive = () => {
|
||||
if (expired || Date.now() >= deadline) throw timeoutFailure();
|
||||
};
|
||||
const acquire = async (): Promise<PluginEnvironmentLease> => {
|
||||
const sandbox = await createSandbox(params, config, {
|
||||
onCreateAttempt: (attempt) => { cleanup = attempt; },
|
||||
});
|
||||
const workspaceSentinel = await writeWorkspaceSentinel({
|
||||
sandbox,
|
||||
remoteCwd,
|
||||
params,
|
||||
config,
|
||||
timeoutSeconds: toTimeoutSeconds(config.timeoutMs),
|
||||
});
|
||||
sandboxHandleLeaseAdmissionStates.open({
|
||||
driverKey: params.driverKey,
|
||||
companyId: params.companyId,
|
||||
environmentId: params.environmentId,
|
||||
providerLeaseId: sandbox.id,
|
||||
config,
|
||||
});
|
||||
// Seed the handle cache with the fresh handle under the exact scope that
|
||||
// `onEnvironmentRealizeWorkspace` reads (providerLeaseId === sandbox.id).
|
||||
// Realize then reuses this handle instead of paying a real `client.get`.
|
||||
sandboxHandleCache.seed(
|
||||
{
|
||||
try {
|
||||
assertActive();
|
||||
if (cleanup) cleanup.observedProviderLeaseId = sandbox.id;
|
||||
phase = "workspace";
|
||||
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
||||
assertActive();
|
||||
phase = "shell";
|
||||
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(Math.max(1, deadline - Date.now())));
|
||||
assertActive();
|
||||
// Configure a provider-side destroy time at or before a caller deadline, so
|
||||
// an abandoned sandbox self-destroys even if Paperclip is down. The lease
|
||||
// carries the real provider expiry (or none) as evidence of the bound.
|
||||
phase = "expiry";
|
||||
const expiresAt = await configureSandboxExpiry({
|
||||
sandbox,
|
||||
requestedExpiresAt: params.requestedExpiresAt,
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
assertActive();
|
||||
phase = "sentinel";
|
||||
const workspaceSentinel = await writeWorkspaceSentinel({
|
||||
sandbox,
|
||||
remoteCwd,
|
||||
params,
|
||||
config,
|
||||
timeoutSeconds: toTimeoutSeconds(Math.max(1, deadline - Date.now())),
|
||||
});
|
||||
assertActive();
|
||||
sandboxHandleLeaseAdmissionStates.open({
|
||||
driverKey: params.driverKey,
|
||||
companyId: params.companyId,
|
||||
environmentId: params.environmentId,
|
||||
providerLeaseId: sandbox.id,
|
||||
config,
|
||||
},
|
||||
sandbox,
|
||||
);
|
||||
return {
|
||||
providerLeaseId: sandbox.id,
|
||||
expiresAt,
|
||||
metadata: leaseMetadata({
|
||||
config,
|
||||
});
|
||||
// Seed the handle cache with the fresh handle under the exact scope that
|
||||
// `onEnvironmentRealizeWorkspace` reads (providerLeaseId === sandbox.id).
|
||||
// Realize then reuses this handle instead of paying a real `client.get`.
|
||||
sandboxHandleCache.seed(
|
||||
{
|
||||
driverKey: params.driverKey,
|
||||
companyId: params.companyId,
|
||||
environmentId: params.environmentId,
|
||||
providerLeaseId: sandbox.id,
|
||||
config,
|
||||
},
|
||||
sandbox,
|
||||
shellCommand,
|
||||
remoteCwd,
|
||||
resumedLease: false,
|
||||
workspaceSentinel,
|
||||
);
|
||||
return {
|
||||
providerLeaseId: sandbox.id,
|
||||
expiresAt,
|
||||
metadata: leaseMetadata({
|
||||
config,
|
||||
sandbox,
|
||||
shellCommand,
|
||||
remoteCwd,
|
||||
resumedLease: false,
|
||||
workspaceSentinel,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
// After timeout the host owns the durable cleanup record. A late SDK
|
||||
// completion must not admit this lease or start another setup phase.
|
||||
if (!expired) {
|
||||
phase = "cleanup";
|
||||
try {
|
||||
await sandbox.delete(toTimeoutSeconds(Math.max(1, deadline - Date.now())));
|
||||
} catch (cleanupError) {
|
||||
if (cleanup) {
|
||||
throw new PluginEnvironmentCreationCleanupError([error, cleanupError],
|
||||
"Daytona lease setup failed; allocation cleanup is pending",
|
||||
{ ...cleanup, labels: { ...cleanup.labels } });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
acquire(),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(timeoutFailure()), budgetMs);
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
|
||||
throw error;
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -205,6 +205,13 @@ export interface PluginEnvironmentDriverDeclaration {
|
||||
displayName: string;
|
||||
/** Optional description for operator-facing docs or UI affordances. */
|
||||
description?: string;
|
||||
/**
|
||||
* Default provider budget for a fresh lease acquisition, in milliseconds.
|
||||
* The host adds RPC overhead. A valid explicit config.timeoutMs overrides
|
||||
* this default; bridgeRequestTimeoutMs can extend the resulting budget.
|
||||
* Omit to retain the worker's normal RPC timeout. This is not lease lifetime.
|
||||
*/
|
||||
defaultAcquireTimeoutMs?: number;
|
||||
/**
|
||||
* Sandbox providers must opt in before the host retains and resumes provider
|
||||
* leases across runs. Providers without this flag keep per-run acquire/release
|
||||
|
||||
@@ -276,6 +276,24 @@ describe("plugin UI slot validators", () => {
|
||||
});
|
||||
|
||||
describe("sandbox provider capability declaration validators", () => {
|
||||
it("preserves a declared acquisition budget and keeps it optional", () => {
|
||||
const parsed = pluginManifestV1Schema.parse(
|
||||
buildSandboxProviderManifest({ defaultAcquireTimeoutMs: 300_000 }),
|
||||
);
|
||||
expect(parsed.environmentDrivers?.[0]?.defaultAcquireTimeoutMs).toBe(300_000);
|
||||
const legacy = pluginManifestV1Schema.parse(buildSandboxProviderManifest({}));
|
||||
expect(legacy.environmentDrivers?.[0]?.defaultAcquireTimeoutMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([0, -1, 1.5, Infinity, NaN, "300000", 86_400_001])(
|
||||
"rejects an invalid acquisition budget: %s",
|
||||
(defaultAcquireTimeoutMs) => {
|
||||
expect(pluginManifestV1Schema.safeParse(
|
||||
buildSandboxProviderManifest({ defaultAcquireTimeoutMs }),
|
||||
).success).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("test_manifest_accepts_sandbox_capabilities_and_rejects_unknown_capability_keys", () => {
|
||||
const parsed = pluginManifestV1Schema.parse(
|
||||
buildSandboxProviderManifest({
|
||||
|
||||
@@ -184,6 +184,7 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({
|
||||
kind: z.enum(["environment_driver", "sandbox_provider"]).optional(),
|
||||
displayName: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
defaultAcquireTimeoutMs: z.number().int().positive().max(86_400_000).optional(),
|
||||
supportsReusableLeases: z.boolean().optional(),
|
||||
sandboxCapabilities: sandboxProviderCapabilitiesSchema.optional(),
|
||||
supportsInteractiveSetup: z.boolean().optional(),
|
||||
|
||||
@@ -4685,14 +4685,20 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
expect(checks).toBe(readyAfterChecks);
|
||||
});
|
||||
|
||||
it("extends plugin-backed sandbox lease RPC timeouts from provider config", async () => {
|
||||
it.each([
|
||||
{ label: "explicit provider and bridge budgets", config: { timeoutMs: 1_234, bridgeRequestTimeoutMs: 40_000 }, declared: 300_000, expected: 70_000 },
|
||||
{ label: "declared acquisition default", config: {}, declared: 300_000, expected: 330_000 },
|
||||
{ label: "explicit shorter provider budget", config: { timeoutMs: 5_000 }, declared: 300_000, expected: 35_000 },
|
||||
{ label: "bridge budget does not shorten acquisition default", config: { bridgeRequestTimeoutMs: 40_000 }, declared: 300_000, expected: 330_000 },
|
||||
{ label: "bridge extends the acquisition default", config: { bridgeRequestTimeoutMs: 400_000 }, declared: 300_000, expected: 430_000 },
|
||||
{ label: "undeclared provider retains worker default", config: {}, declared: undefined, expected: undefined },
|
||||
])("uses $label for plugin-backed sandbox acquisition", async ({ config, declared, expected }) => {
|
||||
const pluginId = randomUUID();
|
||||
const { companyId, environment: baseEnvironment, runId } = await seedEnvironment();
|
||||
const providerConfig = {
|
||||
provider: "fake-plugin",
|
||||
image: "fake:test",
|
||||
timeoutMs: 1_234,
|
||||
bridgeRequestTimeoutMs: 40_000,
|
||||
...config,
|
||||
reuseLease: false,
|
||||
};
|
||||
const environment = {
|
||||
@@ -4728,7 +4734,10 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
driverKey: "fake-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Fake Plugin",
|
||||
configSchema: { type: "object" },
|
||||
defaultAcquireTimeoutMs: declared,
|
||||
// A timeoutMs schema default can mean lease lifetime (for example
|
||||
// E2B). Only the dedicated declaration sets the host RPC budget.
|
||||
configSchema: { type: "object", properties: { timeoutMs: { type: "number", default: 3_600_000 } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -4746,8 +4755,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
metadata: {
|
||||
provider: "fake-plugin",
|
||||
image: "fake:test",
|
||||
timeoutMs: 1_234,
|
||||
bridgeRequestTimeoutMs: 40_000,
|
||||
...config,
|
||||
reuseLease: false,
|
||||
},
|
||||
};
|
||||
@@ -4774,12 +4782,11 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
driverKey: "fake-plugin",
|
||||
config: {
|
||||
image: "fake:test",
|
||||
timeoutMs: 1_234,
|
||||
bridgeRequestTimeoutMs: 40_000,
|
||||
...config,
|
||||
reuseLease: false,
|
||||
},
|
||||
}),
|
||||
70_000,
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7068,7 +7075,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("delegates plugin environment leases through the plugin worker manager", async () => {
|
||||
it.each([undefined, 300_000])("delegates plugin environment leases with acquisition budget %s", async (defaultAcquireTimeoutMs) => {
|
||||
const pluginId = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 60_000).toISOString();
|
||||
const workerManager = {
|
||||
@@ -7129,6 +7136,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
{
|
||||
driverKey: "fake-plugin",
|
||||
displayName: "Fake plugin",
|
||||
defaultAcquireTimeoutMs,
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
@@ -7158,7 +7166,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
adapterType: undefined,
|
||||
runId,
|
||||
workspaceMode: undefined,
|
||||
});
|
||||
}, ...(defaultAcquireTimeoutMs === undefined ? [] : [330_000]));
|
||||
expect(acquired.lease.providerLeaseId).toBe("plugin-lease-1");
|
||||
expect(acquired.lease.expiresAt?.toISOString()).toBe(expiresAt);
|
||||
expect(acquired.lease.metadata).toMatchObject({
|
||||
|
||||
@@ -485,9 +485,16 @@ export interface EnvironmentDriverReleaseInput {
|
||||
status: Extract<EnvironmentLeaseStatus, "released" | "expired" | "failed">;
|
||||
}
|
||||
|
||||
function resolvePluginSandboxRpcTimeoutMs(config: Record<string, unknown>): number | undefined {
|
||||
function resolvePluginSandboxRpcTimeoutMs(
|
||||
config: Record<string, unknown>,
|
||||
defaultTimeoutMs?: number,
|
||||
): number | undefined {
|
||||
const configuredTimeoutMs = typeof config.timeoutMs === "number" &&
|
||||
Number.isFinite(config.timeoutMs) && config.timeoutMs > 0
|
||||
? config.timeoutMs
|
||||
: defaultTimeoutMs;
|
||||
const timeoutCandidates = [
|
||||
typeof config.timeoutMs === "number" ? config.timeoutMs : undefined,
|
||||
configuredTimeoutMs,
|
||||
typeof config.bridgeRequestTimeoutMs === "number" ? config.bridgeRequestTimeoutMs : undefined,
|
||||
]
|
||||
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0)
|
||||
@@ -2084,7 +2091,10 @@ function createSandboxEnvironmentDriver(
|
||||
? { requestedExpiresAt: requestedExpiresAtParam(input.requestedExpiresAt) }
|
||||
: {}),
|
||||
},
|
||||
resolvePluginSandboxRpcTimeoutMs(workerConfig),
|
||||
resolvePluginSandboxRpcTimeoutMs(
|
||||
workerConfig,
|
||||
pluginProvider.resolved.driver.defaultAcquireTimeoutMs,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const cleanup = readEnvironmentCreationCleanupError(error);
|
||||
@@ -3390,7 +3400,7 @@ function createPluginEnvironmentDriver(
|
||||
if (!workerManager.isRunning(plugin.id)) {
|
||||
throw new Error(`Plugin environment driver "${pluginDriverProviderKey(config)}" has no running worker.`);
|
||||
}
|
||||
return { plugin };
|
||||
return { plugin, driver };
|
||||
}
|
||||
|
||||
async function resolvePluginDriverForRelease(input: EnvironmentDriverReleaseInput) {
|
||||
@@ -3459,7 +3469,12 @@ function createPluginEnvironmentDriver(
|
||||
if (parsed.driver !== "plugin") {
|
||||
throw new Error(`Expected plugin environment config for driver "${input.environment.driver}".`);
|
||||
}
|
||||
const { plugin } = await resolvePluginDriver(parsed.config);
|
||||
const { plugin, driver } = await resolvePluginDriver(parsed.config);
|
||||
const rpcTimeoutMs = resolvePluginSandboxRpcTimeoutMs(
|
||||
parsed.config.driverConfig,
|
||||
driver.defaultAcquireTimeoutMs,
|
||||
);
|
||||
const timeoutOverride: [number?] = rpcTimeoutMs === undefined ? [] : [rpcTimeoutMs];
|
||||
const providerLease = await workerManager.call(plugin.id, "environmentAcquireLease", {
|
||||
driverKey: parsed.config.driverKey,
|
||||
companyId: input.companyId,
|
||||
@@ -3477,7 +3492,7 @@ function createPluginEnvironmentDriver(
|
||||
...(requestedExpiresAtParam(input.requestedExpiresAt) !== undefined
|
||||
? { requestedExpiresAt: requestedExpiresAtParam(input.requestedExpiresAt) }
|
||||
: {}),
|
||||
} as PluginEnvironmentAcquireLeaseParams);
|
||||
} as PluginEnvironmentAcquireLeaseParams, ...timeoutOverride);
|
||||
|
||||
return await environmentsSvc.acquireLease({
|
||||
companyId: input.companyId,
|
||||
|
||||
Reference in new issue
Block a user