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:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-26 00:47:21 -07:00
1 parent 7f3c06dac4
commit ffa32373bc
9 files changed
+295 -77

No files matched your search

@@ -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);
}
},