mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +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
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user