mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
fix(runner): persist warm Daytona workspaces
This commit is contained in:
1 parent
3da58b185e
commit
ee11cd65d9
44 files changed
+4019
-359
No files matched your search
@@ -1240,6 +1240,8 @@ describe("Daytona sandbox provider plugin", () => {
|
||||
providerLeaseId: "sandbox-reuse",
|
||||
metadata: {
|
||||
resumedLease: true,
|
||||
resumedFromState: "stopped",
|
||||
sandboxState: "started",
|
||||
workspaceSentinel: {
|
||||
result: "matched",
|
||||
token: "sentinel-token",
|
||||
@@ -4060,6 +4062,35 @@ describe("daytona native file-sync hooks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a deleted sandbox during syncOut with a stable unrecoverable code", async () => {
|
||||
const hostDir = await makeHostDir();
|
||||
mockGet.mockRejectedValue(
|
||||
new MockDaytonaNotFoundError("provider detail must not escape"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentSyncOut?.({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: { timeoutMs: 300000, reuseLease: true },
|
||||
lease: syncLease(),
|
||||
operations: [
|
||||
{
|
||||
operationId: "sync-op-missing-sandbox",
|
||||
files: [
|
||||
{
|
||||
sourcePath: `${REMOTE_DIR}/out/result.txt`,
|
||||
targetPath: path.join(hostDir, "result.txt"),
|
||||
kind: "file",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow("daytona_sandbox_not_found");
|
||||
});
|
||||
|
||||
it("syncOut snapshot guard re-checks the resolved source is a non-symlink regular file immediately before copying (validation→copy TOCTOU)", async () => {
|
||||
const hostDir = await makeHostDir();
|
||||
const sandbox = createMockSandbox();
|
||||
|
||||
@@ -619,6 +619,8 @@ function leaseMetadata(input: {
|
||||
shellCommand: "bash" | "sh";
|
||||
remoteCwd: string;
|
||||
resumedLease: boolean;
|
||||
resumedFromState?: string | null;
|
||||
sandboxState?: string | null;
|
||||
workspaceSentinel?: WorkspaceSentinelResult;
|
||||
}) {
|
||||
return {
|
||||
@@ -626,7 +628,7 @@ function leaseMetadata(input: {
|
||||
shellCommand: input.shellCommand,
|
||||
sandboxId: input.sandbox.id,
|
||||
sandboxName: input.sandbox.name,
|
||||
sandboxState: input.sandbox.state ?? null,
|
||||
sandboxState: input.sandboxState ?? input.sandbox.state ?? null,
|
||||
image: input.config.image,
|
||||
snapshot: input.config.snapshot,
|
||||
target: input.sandbox.target,
|
||||
@@ -637,6 +639,9 @@ function leaseMetadata(input: {
|
||||
...(input.config.archiveOnRelease ? { archiveOnRelease: true } : {}),
|
||||
remoteCwd: input.remoteCwd,
|
||||
resumedLease: input.resumedLease,
|
||||
...(input.resumedLease
|
||||
? { resumedFromState: input.resumedFromState ?? null }
|
||||
: {}),
|
||||
// Record the resources Paperclip attempted to request so future diagnosis
|
||||
// can compare requested allocation against what Daytona provisioned.
|
||||
...(input.config.cpu != null ? { cpu: input.config.cpu } : {}),
|
||||
@@ -2187,61 +2192,72 @@ const plugin = definePlugin({
|
||||
return { providerLeaseId: null, metadata: { expired: true } };
|
||||
}
|
||||
|
||||
// A stopped sandbox loses its session shell, so the stored session id is
|
||||
// stale after a real restart. Clear the id only when the sandbox is not
|
||||
// already running, and clear it before the restart. A stopped sandbox has
|
||||
// no live session, so the clear drops a dead id and a later command opens
|
||||
// a fresh session. A running sandbox keeps its live session, so the resume
|
||||
// leaves the id in place; a concurrent command still finds it and teardown
|
||||
// deletes one session. An unconditional clear would drop the id of a live
|
||||
// session and leak its shell until sandbox reaping.
|
||||
if (sandbox.state !== "started") {
|
||||
sandboxHandleSessionStore.clear(scope);
|
||||
// A stopped sandbox loses its pseudo-terminals, so a stored duplex channel
|
||||
// is dead after a real restart. Close and drop every channel on this lease
|
||||
// before the restart, so no stale channel id survives the resume.
|
||||
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
|
||||
}
|
||||
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
|
||||
try {
|
||||
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
||||
// C3: a resumed lease must clear the workspace sentinel before it is
|
||||
// trusted, even when the handle came from the cache. On any non-match we
|
||||
// evict the cached handle and expire the lease so a stale/foreign sandbox
|
||||
// is never reused on the subsequent (sentinel-skipping) exec path.
|
||||
const workspaceSentinel = await verifyWorkspaceSentinel({
|
||||
sandbox,
|
||||
remoteCwd,
|
||||
leaseMetadata: params.leaseMetadata,
|
||||
timeoutSeconds: toTimeoutSeconds(config.timeoutMs),
|
||||
});
|
||||
if (workspaceSentinel.result !== "matched") {
|
||||
evictSandboxHandle(scope);
|
||||
return { providerLeaseId: null, metadata: { expired: true, workspaceSentinel } };
|
||||
}
|
||||
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
|
||||
sandboxHandleCache.markFresh(scope);
|
||||
sandboxHandleLeaseAdmissionStates.open(scope);
|
||||
return {
|
||||
providerLeaseId: sandbox.id,
|
||||
metadata: leaseMetadata({
|
||||
config,
|
||||
sandbox,
|
||||
shellCommand,
|
||||
remoteCwd,
|
||||
resumedLease: true,
|
||||
workspaceSentinel,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
evictSandboxHandle(scope);
|
||||
// A timeout, rate limit, or provider 5xx does not prove this sandbox is
|
||||
// lost. Preserve the exact resource and let the host retry its recorded
|
||||
// lease; replacement is permitted only after an explicit not-found or
|
||||
// an immutable workspace identity mismatch.
|
||||
throw error;
|
||||
}
|
||||
}, { allowClosed: true });
|
||||
// A stopped sandbox loses its session shell, so the stored session id is
|
||||
// stale after a real restart. Clear the id only when the sandbox is not
|
||||
// already running, and clear it before the restart. A stopped sandbox has
|
||||
// no live session, so the clear drops a dead id and a later command opens
|
||||
// a fresh session. A running sandbox keeps its live session, so the resume
|
||||
// leaves the id in place; a concurrent command still finds it and teardown
|
||||
// deletes one session. An unconditional clear would drop the id of a live
|
||||
// session and leak its shell until sandbox reaping.
|
||||
if (sandbox.state !== "started") {
|
||||
sandboxHandleSessionStore.clear(scope);
|
||||
// A stopped sandbox loses its pseudo-terminals, so a stored duplex channel
|
||||
// is dead after a real restart. Close and drop every channel on this lease
|
||||
// before the restart, so no stale channel id survives the resume.
|
||||
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
|
||||
}
|
||||
const resumedFromState = sandbox.state ?? null;
|
||||
await ensureSandboxStarted(sandbox, toTimeoutSeconds(config.timeoutMs));
|
||||
try {
|
||||
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
||||
// C3: a resumed lease must clear the workspace sentinel before it is
|
||||
// trusted, even when the handle came from the cache. On any non-match we
|
||||
// evict the cached handle and expire the lease so a stale/foreign sandbox
|
||||
// is never reused on the subsequent (sentinel-skipping) exec path.
|
||||
const workspaceSentinel = await verifyWorkspaceSentinel({
|
||||
sandbox,
|
||||
remoteCwd,
|
||||
leaseMetadata: params.leaseMetadata,
|
||||
timeoutSeconds: toTimeoutSeconds(config.timeoutMs),
|
||||
});
|
||||
if (workspaceSentinel.result !== "matched") {
|
||||
evictSandboxHandle(scope);
|
||||
return {
|
||||
providerLeaseId: null,
|
||||
metadata: { expired: true, workspaceSentinel },
|
||||
};
|
||||
}
|
||||
const shellCommand = await detectSandboxShellCommand(
|
||||
sandbox,
|
||||
toTimeoutSeconds(config.timeoutMs),
|
||||
);
|
||||
sandboxHandleCache.markFresh(scope);
|
||||
sandboxHandleLeaseAdmissionStates.open(scope);
|
||||
return {
|
||||
providerLeaseId: sandbox.id,
|
||||
metadata: leaseMetadata({
|
||||
config,
|
||||
sandbox,
|
||||
shellCommand,
|
||||
remoteCwd,
|
||||
resumedLease: true,
|
||||
resumedFromState,
|
||||
sandboxState: "started",
|
||||
workspaceSentinel,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
evictSandboxHandle(scope);
|
||||
// A timeout, rate limit, or provider 5xx does not prove this sandbox is
|
||||
// lost. Preserve the exact resource and let the host retry its recorded
|
||||
// lease; replacement is permitted only after an explicit not-found or
|
||||
// an immutable workspace identity mismatch.
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ allowClosed: true },
|
||||
);
|
||||
},
|
||||
|
||||
async onEnvironmentReleaseLease(
|
||||
@@ -2828,18 +2844,28 @@ const plugin = definePlugin({
|
||||
providerLeaseId: params.lease.providerLeaseId,
|
||||
config,
|
||||
};
|
||||
return await withSandboxActivityGate(scope, async () => {
|
||||
const sandbox = await getSandbox(scope, { bypassTeardownGate: true });
|
||||
await ensureSandboxStarted(sandbox, timeoutSeconds);
|
||||
const result = await performSyncOut({
|
||||
sandbox,
|
||||
operations: params.operations,
|
||||
remoteDir,
|
||||
timeoutSeconds,
|
||||
try {
|
||||
return await withSandboxActivityGate(scope, async () => {
|
||||
const sandbox = await getSandbox(scope, { bypassTeardownGate: true });
|
||||
await ensureSandboxStarted(sandbox, timeoutSeconds);
|
||||
const result = await performSyncOut({
|
||||
sandbox,
|
||||
operations: params.operations,
|
||||
remoteDir,
|
||||
timeoutSeconds,
|
||||
});
|
||||
sandboxHandleCache.markFresh(scope);
|
||||
return result;
|
||||
});
|
||||
sandboxHandleCache.markFresh(scope);
|
||||
return result;
|
||||
});
|
||||
} catch (error) {
|
||||
// A deleted Daytona sandbox is the one provider failure that proves its
|
||||
// unexported workspace bytes no longer exist. Convert the SDK class to a
|
||||
// stable cross-worker message; every other error remains retryable.
|
||||
if (error instanceof DaytonaNotFoundError) {
|
||||
throw new Error("daytona_sandbox_not_found");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Open one live login pseudo-terminal. Resolve the cached sandbox by the
|
||||
|
||||
Reference in new issue
Block a user