mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
fix(daytona): clear idle guard after verified sandbox cleanup (#16760)
Replace the lifetime provider-use guard with scoped allocation and pending-request evidence. Fence cleanup observations against overlapping work, retain timed-out raw requests, and wait for completed remote deletion before permitting idle sleep. Verified with 436 Daytona tests, package and workspace typecheck/build, full CI, and Greptile Apex 5/5 on the reviewed head. Four deletion-boundary regressions and two concurrency regressions fail on the earlier implementation and pass with the fixes. Live-provider and local full-suite limits are documented in the PR. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1558d9962c
commit
10d92eb468
5 files changed
+645
-70
No files matched your search
@@ -30,6 +30,7 @@ Notes:
|
||||
- Each cold create uses a unique provider name and ownership labels. If creation fails after Daytona has allocated a sandbox, the driver looks up that exact name, checks every ownership label, and waits for deletion. Failed, missing, or timed-out lookups and failed deletions report unconfirmed cleanup with the provider name; they never return a usable lease. A missing name lookup after an uncertain create is not proof that a delayed provider request cannot create a resource.
|
||||
- For plugin-backed sandbox lease acquisition, unresolved creation cleanup crosses the worker RPC as a validated ownership envelope. The host records `pending_cleanup` before retrying deletion. If only a creation name is known, the provider first returns an ownership-verified sandbox ID without deleting it. The host saves that observation before authorizing deletion. Its existing cleanup sweep and durable spool preserve retries across controller restarts and environment deletion. A missing observed ID confirms cleanup after a lost deletion reply or database update; a name that has never been observed remains unresolved. Provider exceptions and resolved credentials are excluded from that envelope. This requires the matching host and plugin SDK update; probe and custom-image interactive-setup calls still report unconfirmed immediate cleanup without this lease-recovery path.
|
||||
- Reusable leases map to Daytona stop/start semantics. Non-reusable leases are deleted on release. A provider-resolved `target` does not change the identity of an existing sandbox. Release closes the same scoped lease that a later sentinel-verified resume reopens.
|
||||
- Idle sleep tracks pending hooks, underlying provider requests, open terminals, and sandbox allocations. A successful stop or delete clears only that sandbox's company, environment, and account scope. Every sandbox deletion waits for completed remote destruction within its existing timeout; acceptance alone cannot clear idle evidence. A timeout does not settle the underlying request. Cleanup that overlaps other work on the same sandbox needs a later quiet stop/delete receipt or read-only observation of the exact stopped or missing sandbox. Proof is captured before the provider observation. Other work that starts or finishes before its reply invalidates that proof, even after the other request has settled. An unobserved creation name is never absence proof. Active resources and uncertain cleanup continue to prevent idle sleep; verified cleanup no longer blocks sleep for the worker's entire lifetime.
|
||||
- A command that has already exited returns its final separated log snapshot without opening a follow socket. The initial status check is bounded to 500ms and the caller's command deadline. Each follow connection also makes up to four early status reads, with 25/50/100/200ms delays and the same observation/deadline bounds. A confirmed exit still requires the final separated snapshot, but does not wait for the follow socket to close. Unconfirmed or unavailable status keeps streaming; the early reads stop after their bounded attempt window. The command is dispatched only once.
|
||||
- A session log socket can close while its command still runs. The driver requires a recorded command exit before returning a completed execution. It reconnects the log stream once, then polls status and log snapshots at most once per second. Replayed output is removed by byte offset, new output continues to reach the host, and the command is never dispatched again. A socket that delivers no output for 15 seconds switches directly to polling, even if the socket stays open or its initial connection never completes. Late callbacks from that socket are ignored. This recovers pending permission requests and cancellation results from saved output without replaying the command. Healthy initial and reconnected streams retain their existing lifetime under the caller’s RPC/run guard. After a clean close or an idle socket, each status or snapshot read gets a fresh operation timeout; successful recovery polling does not impose a new command lifetime. When both stream attempts fail without a clean close, fallback retains its original timeout budget starting after the stream attempts.
|
||||
- The SDK returns full log snapshots, so fallback bandwidth grows with retained output; it has no paged snapshot or stream cancellation API. A recovery-read timeout keeps partial output and explicitly reports whether command exit remains unconfirmed; it does not prove the remote command stopped. Provider/session teardown remains responsible for closing an outstanding socket. A final snapshot reconciles bytes missed by the stream before returning a recorded exit.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DaytonaIdleActivity, trackDaytonaHooks } from "./idle-activity.js";
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("Daytona idle evidence", () => {
|
||||
it("keeps the underlying request after its caller's deadline", async () => {
|
||||
const activity = new DaytonaIdleActivity();
|
||||
const request = deferred();
|
||||
const pending = activity.track(() => request.promise);
|
||||
await Promise.race([pending, Promise.resolve("deadline")]);
|
||||
expect(activity.present()).toBe(true);
|
||||
request.resolve();
|
||||
await pending;
|
||||
expect(activity.present()).toBe(false);
|
||||
});
|
||||
|
||||
it("settles rejected requests and synchronous failures", async () => {
|
||||
const activity = new DaytonaIdleActivity();
|
||||
await expect(activity.track(() => Promise.reject(new Error("failed")))).rejects.toThrow("failed");
|
||||
expect(() => activity.track(() => { throw new Error("sync"); })).toThrow("sync");
|
||||
expect(activity.present()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat absence of an unobserved creation name as deletion", () => {
|
||||
const activity = new DaytonaIdleActivity();
|
||||
const allocation = activity.resource("account/company/environment/create-name", true);
|
||||
activity.confirm(activity.proof("account/company/environment/create-name"), true);
|
||||
expect(activity.present()).toBe(true);
|
||||
activity.observe(allocation, "account/company/environment/immutable-id");
|
||||
activity.confirm(activity.proof("account/company/environment/immutable-id"), true);
|
||||
expect(activity.present()).toBe(false);
|
||||
activity.observe(allocation, "account/company/environment/immutable-id");
|
||||
expect(activity.present()).toBe(false);
|
||||
});
|
||||
|
||||
it("clears aliases only for the resource with exact scope and cleanup proof", () => {
|
||||
const activity = new DaytonaIdleActivity();
|
||||
const allocation = activity.resource("account-a/company-a/environment-a/create", true);
|
||||
activity.observe(allocation, "account-a/company-a/environment-a/id");
|
||||
for (const key of ["account-b/company-a/environment-a/id", "account-a/company-b/environment-a/id", "account-a/company-a/environment-b/id"]) {
|
||||
activity.resource(key);
|
||||
}
|
||||
activity.confirm(activity.proof("account-a/company-a/environment-a/id"));
|
||||
expect(activity.present()).toBe(true);
|
||||
for (const key of ["account-b/company-a/environment-a/id", "account-a/company-b/environment-a/id", "account-a/company-a/environment-b/id"]) {
|
||||
activity.confirm(activity.proof(key));
|
||||
}
|
||||
expect(activity.present()).toBe(false);
|
||||
});
|
||||
|
||||
it("requires another receipt after overlapping work settles", async () => {
|
||||
const activity = new DaytonaIdleActivity();
|
||||
activity.resource("resource");
|
||||
const request = deferred();
|
||||
const pending = activity.track(() => { activity.resource("resource"); return request.promise; });
|
||||
await activity.track(async () => { activity.confirm(activity.proof("resource")); });
|
||||
expect(activity.present()).toBe(true);
|
||||
request.resolve();
|
||||
await pending;
|
||||
expect(activity.present()).toBe(true);
|
||||
await activity.track(async () => { activity.confirm(activity.proof("resource"), true); });
|
||||
expect(activity.present()).toBe(false);
|
||||
});
|
||||
|
||||
it("clears unrelated cleanup concurrently without losing pending work", async () => {
|
||||
const activity = new DaytonaIdleActivity();
|
||||
const unrelated = deferred();
|
||||
const pending = activity.track(() => { activity.resource("other"); return unrelated.promise; });
|
||||
await activity.track(async () => {
|
||||
activity.resource("cleaned");
|
||||
activity.confirm(activity.proof("cleaned"));
|
||||
});
|
||||
unrelated.resolve(); await pending;
|
||||
activity.confirm(activity.proof("other"));
|
||||
expect(activity.present()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mistake a timed-out child request for the receipt's own completed work", async () => {
|
||||
const activity = new DaytonaIdleActivity();
|
||||
const child = deferred();
|
||||
let pending!: Promise<void>;
|
||||
await activity.track(async () => {
|
||||
activity.resource("resource");
|
||||
pending = activity.track(() => child.promise);
|
||||
await Promise.race([pending, Promise.resolve()]);
|
||||
activity.confirm(activity.proof("resource"));
|
||||
});
|
||||
child.resolve(); await pending;
|
||||
expect(activity.present()).toBe(true);
|
||||
activity.confirm(activity.proof("resource"));
|
||||
expect(activity.present()).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a stopped observation when overlapping work finishes before the reply", async () => {
|
||||
const activity = new DaytonaIdleActivity();
|
||||
const finishWork = deferred(), reply = deferred();
|
||||
const pending = activity.track(() => { activity.resource("resource"); return finishWork.promise; });
|
||||
const stoppedObservation = activity.track(async () => {
|
||||
const proof = activity.proof("resource");
|
||||
await reply.promise;
|
||||
activity.confirm(proof, true);
|
||||
});
|
||||
finishWork.resolve(); await pending;
|
||||
reply.resolve(); await stoppedObservation;
|
||||
expect(activity.present()).toBe(true);
|
||||
await activity.track(async () => { activity.confirm(activity.proof("resource"), true); });
|
||||
expect(activity.present()).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects proof captured before another resource generation was admitted", () => {
|
||||
const activity = new DaytonaIdleActivity();
|
||||
activity.resource("resource");
|
||||
const old = activity.proof("resource");
|
||||
activity.confirm(old);
|
||||
activity.resource("resource");
|
||||
activity.confirm(old);
|
||||
expect(activity.present()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let a late create callback clear a new generation of the same ID", () => {
|
||||
const activity = new DaytonaIdleActivity();
|
||||
const old = activity.resource("resource");
|
||||
activity.confirm(activity.proof("resource"));
|
||||
activity.resource("resource");
|
||||
activity.observe(old, "late-alias");
|
||||
activity.confirm(activity.proof("late-alias"));
|
||||
expect(activity.present()).toBe(true);
|
||||
activity.confirm(activity.proof("resource"));
|
||||
expect(activity.present()).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks hook setup before a terminal is registered without blocking its own idle check", async () => {
|
||||
const activity = new DaytonaIdleActivity();
|
||||
const setup = deferred();
|
||||
const definition = trackDaytonaHooks({
|
||||
setup: async () => {},
|
||||
onHealth: async () => ({ status: "ok" as const }),
|
||||
onIdleDrain: async () => activity.present() ? "present" as const : "none" as const,
|
||||
onShutdown: () => setup.promise,
|
||||
}, activity);
|
||||
const pending = definition.onShutdown!();
|
||||
expect(await definition.onIdleDrain!(new AbortController().signal)).toBe("present");
|
||||
setup.resolve();
|
||||
await pending;
|
||||
expect(await definition.onIdleDrain!(new AbortController().signal)).toBe("none");
|
||||
});
|
||||
|
||||
it("does not let a completion from a reset test clear a newer pending request", async () => {
|
||||
const activity = new DaytonaIdleActivity();
|
||||
const old = deferred(), current = deferred();
|
||||
const prior = activity.track(() => old.promise);
|
||||
activity.resetForTest();
|
||||
const pending = activity.track(() => current.promise);
|
||||
old.resolve(); await prior;
|
||||
expect(activity.present()).toBe(true);
|
||||
current.resolve(); await pending;
|
||||
expect(activity.present()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { PluginDefinition } from "@paperclipai/plugin-sdk";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
type Scope = { resources: Set<string>; active: number };
|
||||
type Request = { parent?: Request; scope: Scope };
|
||||
|
||||
type Resource = {
|
||||
keys: Set<string>;
|
||||
absenceProofKeys: Set<string>;
|
||||
confirmed: boolean;
|
||||
revision: number;
|
||||
scopeRevisions: Map<Scope, number>;
|
||||
};
|
||||
|
||||
export type DaytonaIdleProof = { key: string; scope?: Scope; revisions: Map<Resource, number> };
|
||||
|
||||
/** Worker-local evidence complements the host's durable lease and task gates. */
|
||||
export class DaytonaIdleActivity {
|
||||
private readonly requests = new Set<Request>();
|
||||
private readonly current = new AsyncLocalStorage<Request>();
|
||||
private readonly resources = new Map<string, Set<Resource>>();
|
||||
|
||||
track<T>(run: () => Promise<T>): Promise<T> {
|
||||
const parent = this.current.getStore();
|
||||
const scope = parent?.scope ?? { resources: new Set<string>(), active: 0 };
|
||||
const request: Request = { parent, scope };
|
||||
scope.active++;
|
||||
this.requests.add(request);
|
||||
this.bumpScope(scope);
|
||||
const settled = () => {
|
||||
this.bumpScope(scope);
|
||||
this.requests.delete(request);
|
||||
if (--scope.active === 0) {
|
||||
for (const key of scope.resources) {
|
||||
for (const resource of this.resources.get(key) ?? []) resource.scopeRevisions.delete(scope);
|
||||
}
|
||||
}
|
||||
};
|
||||
let pending: Promise<T>;
|
||||
try {
|
||||
pending = this.current.run(request, run);
|
||||
} catch (error) {
|
||||
settled();
|
||||
throw error;
|
||||
}
|
||||
return Promise.resolve(pending).then(
|
||||
(value) => { settled(); return value; },
|
||||
(error: unknown) => { settled(); throw error; },
|
||||
);
|
||||
}
|
||||
|
||||
resource(key: string, provisional = false): Resource {
|
||||
const existing = this.resources.get(key)?.values().next().value;
|
||||
if (existing) { this.bindKey(key); return existing; }
|
||||
const resource: Resource = {
|
||||
keys: new Set([key]),
|
||||
absenceProofKeys: new Set(provisional ? [] : [key]),
|
||||
confirmed: false,
|
||||
revision: 0,
|
||||
scopeRevisions: new Map(),
|
||||
};
|
||||
this.resources.set(key, new Set([resource]));
|
||||
this.bindKey(key);
|
||||
return resource;
|
||||
}
|
||||
|
||||
/** Attach an observed immutable ID without reviving a confirmed allocation. */
|
||||
observe(resource: Resource, key: string): void {
|
||||
if (resource.confirmed) return;
|
||||
resource.keys.add(key);
|
||||
resource.absenceProofKeys.add(key);
|
||||
const linked = this.resources.get(key) ?? new Set<Resource>();
|
||||
linked.add(resource);
|
||||
this.resources.set(key, linked);
|
||||
this.bindKey(key);
|
||||
}
|
||||
|
||||
private bump(resource: Resource, scope: Scope): void {
|
||||
resource.revision++;
|
||||
resource.scopeRevisions.set(scope, (resource.scopeRevisions.get(scope) ?? 0) + 1);
|
||||
}
|
||||
|
||||
private bumpScope(scope: Scope): void {
|
||||
const touched = new Set<Resource>();
|
||||
for (const key of scope.resources) for (const resource of this.resources.get(key) ?? []) touched.add(resource);
|
||||
for (const resource of touched) this.bump(resource, scope);
|
||||
}
|
||||
|
||||
private bindKey(key: string): void {
|
||||
const scope = this.current.getStore()?.scope;
|
||||
if (!scope || scope.resources.has(key)) return;
|
||||
scope.resources.add(key);
|
||||
for (const resource of this.resources.get(key) ?? []) this.bump(resource, scope);
|
||||
}
|
||||
|
||||
private foreignRevision(resource: Resource, scope?: Scope): number {
|
||||
return resource.revision - (scope ? resource.scopeRevisions.get(scope) ?? 0 : 0);
|
||||
}
|
||||
|
||||
/** Capture before the provider read/stop/delete, never after its reply. */
|
||||
proof(key: string): DaytonaIdleProof {
|
||||
this.bindKey(key);
|
||||
const scope = this.current.getStore()?.scope;
|
||||
return { key, scope, revisions: new Map([...(this.resources.get(key) ?? [])]
|
||||
.map(resource => [resource, this.foreignRevision(resource, scope)])) };
|
||||
}
|
||||
|
||||
confirm(proof: DaytonaIdleProof, absenceOnly = false): void {
|
||||
const { key, scope } = proof;
|
||||
const ownChain = new Set<Request>();
|
||||
for (let request = this.current.getStore(); request; request = request.parent) ownChain.add(request);
|
||||
for (const resource of [...(this.resources.get(key) ?? [])]) {
|
||||
// Work that began or finished during the provider observation makes that
|
||||
// observation stale even when every overlapping request has now settled.
|
||||
if (!proof.revisions.has(resource) || proof.revisions.get(resource) !== this.foreignRevision(resource, scope)) continue;
|
||||
// Ignore the receipt's own call chain, but never a child request that
|
||||
// outlived its deadline or another operation on the same allocation.
|
||||
// Unrelated sandbox cleanup must not leave a permanent blocker.
|
||||
if ([...this.requests].some(request => !ownChain.has(request) &&
|
||||
[...resource.keys].some(alias => request.scope.resources.has(alias)))) continue;
|
||||
// An unobserved create name can materialize after a not-found response.
|
||||
if (absenceOnly && !resource.absenceProofKeys.has(key)) continue;
|
||||
resource.confirmed = true;
|
||||
for (const alias of resource.keys) {
|
||||
const linked = this.resources.get(alias);
|
||||
linked?.delete(resource);
|
||||
if (linked?.size === 0) this.resources.delete(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
present(): boolean {
|
||||
return this.requests.size > 0 || this.resources.size > 0;
|
||||
}
|
||||
|
||||
resetForTest(): void {
|
||||
for (const allocations of this.resources.values()) {
|
||||
for (const allocation of allocations) allocation.confirmed = true;
|
||||
}
|
||||
this.requests.clear();
|
||||
this.resources.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Count hooks before they start, including terminal/PTY setup before registration. */
|
||||
export function trackDaytonaHooks(definition: PluginDefinition, activity: DaytonaIdleActivity): PluginDefinition {
|
||||
const tracked = { ...definition };
|
||||
for (const [name, hook] of Object.entries(definition)) {
|
||||
if (!name.startsWith("on") || name === "onIdleDrain" || name === "onHealth" || typeof hook !== "function") continue;
|
||||
Object.defineProperty(tracked, name, {
|
||||
enumerable: true,
|
||||
value: (...args: unknown[]) => activity.track(async () => Reflect.apply(hook, definition, args)),
|
||||
});
|
||||
}
|
||||
return tracked;
|
||||
}
|
||||
@@ -81,6 +81,11 @@ describe("Daytona read-only resource inspection", () => {
|
||||
});
|
||||
|
||||
describe("Daytona idle eligibility", () => {
|
||||
const params = { driverKey: "daytona", companyId: "fixture-company", environmentId: "fixture-environment",
|
||||
config: { apiKey: "fixture-not-a-key", reuseLease: true } };
|
||||
const signal = new AbortController().signal;
|
||||
beforeEach(() => { mockCreate.mockReset(); mockGet.mockReset(); __resetDaytonaSandboxHandleCacheForTest(); });
|
||||
afterEach(() => { vi.restoreAllMocks(); __resetDaytonaSandboxHandleCacheForTest(); });
|
||||
it("permits a fresh unused provider but remains conservative after provider access", async () => {
|
||||
__resetDaytonaSandboxHandleCacheForTest();
|
||||
const signal = new AbortController().signal;
|
||||
@@ -93,6 +98,186 @@ describe("Daytona idle eligibility", () => {
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("present");
|
||||
__resetDaytonaSandboxHandleCacheForTest();
|
||||
});
|
||||
|
||||
it("permits idle after a probe's exact sandbox deletion succeeds", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
mockCreate.mockResolvedValue(sandbox);
|
||||
expect((await plugin.definition.onEnvironmentProbe!(params)).ok).toBe(true);
|
||||
expect(sandbox.delete).toHaveBeenCalledTimes(1);
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("none");
|
||||
});
|
||||
|
||||
it.each(["probe", "failed acquisition", "failed interactive setup", "cancelled setup"] as const)(
|
||||
"waits for remote deletion before allowing idle after %s",
|
||||
async kind => {
|
||||
const sandbox = createMockSandbox();
|
||||
mockCreate.mockResolvedValue(sandbox); mockGet.mockResolvedValue(sandbox);
|
||||
if (kind === "failed acquisition" || kind === "failed interactive setup") {
|
||||
sandbox.fs.createFolder.mockRejectedValueOnce(new Error("workspace setup failed"));
|
||||
}
|
||||
let finishDeletion!: () => void;
|
||||
let remotelyDestroyed = false;
|
||||
const deletion = new Promise<void>(resolve => {
|
||||
finishDeletion = () => { remotelyDestroyed = true; resolve(); };
|
||||
});
|
||||
// The SDK's default resolves after acceptance. Only wait=true observes
|
||||
// completed deletion, which can happen after the request is acknowledged.
|
||||
sandbox.delete.mockImplementation(async (_timeout?: number, wait = false) => {
|
||||
if (wait) await deletion;
|
||||
});
|
||||
const input = { ...params, config: { ...params.config, reuseLease: false } };
|
||||
const pending = kind === "probe" ? plugin.definition.onEnvironmentProbe!(input)
|
||||
: kind === "failed acquisition" ? plugin.definition.onEnvironmentAcquireLease!({ ...input, runId: "fixture-run" })
|
||||
: kind === "failed interactive setup" ? plugin.definition.onEnvironmentStartInteractiveSetup!({ ...input, sessionId: "fixture-setup" })
|
||||
: plugin.definition.onEnvironmentCancelInteractiveSetup!({ ...input, providerLeaseId: sandbox.id, reason: "user_cancelled" });
|
||||
let settled = false;
|
||||
const outcome = pending.then(() => { settled = true; }, () => { settled = true; });
|
||||
try {
|
||||
await vi.waitFor(() => expect(sandbox.delete).toHaveBeenCalledTimes(1));
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(remotelyDestroyed).toBe(false);
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("present");
|
||||
expect(settled).toBe(false);
|
||||
finishDeletion(); await outcome;
|
||||
expect(remotelyDestroyed).toBe(true);
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("none");
|
||||
} finally {
|
||||
finishDeletion(); await outcome;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains a failed probe deletion until exact provider cleanup succeeds", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
sandbox.delete.mockRejectedValueOnce(new Error("delete unavailable"));
|
||||
mockCreate.mockResolvedValue(sandbox); mockGet.mockResolvedValue(sandbox);
|
||||
await plugin.definition.onEnvironmentProbe!(params);
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("present");
|
||||
await plugin.definition.onEnvironmentDestroyLease!({ ...params, providerLeaseId: sandbox.id });
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("none");
|
||||
});
|
||||
|
||||
it.each(["stop", "release", "retain", "destroy"])("clears verified %s without clearing another account", async kind => {
|
||||
const first = createMockSandbox({ id: "sandbox-a" });
|
||||
const second = createMockSandbox({ id: "sandbox-b" });
|
||||
mockCreate.mockResolvedValueOnce(first).mockResolvedValueOnce(second);
|
||||
first.delete.mockRejectedValueOnce(new Error("deferred"));
|
||||
second.delete.mockRejectedValueOnce(new Error("deferred"));
|
||||
await plugin.definition.onEnvironmentProbe!(params);
|
||||
const other = { ...params, config: { ...params.config, apiKey: "fixture-other-account" } };
|
||||
await plugin.definition.onEnvironmentProbe!(other);
|
||||
mockGet.mockResolvedValue(first);
|
||||
const cleanup = { ...params, providerLeaseId: first.id };
|
||||
if (kind === "stop") await plugin.definition.onEnvironmentStopLease!(cleanup);
|
||||
else if (kind === "release") await plugin.definition.onEnvironmentReleaseLease!(cleanup);
|
||||
else if (kind === "retain") await plugin.definition.onEnvironmentReleaseLease!({ ...cleanup, resourceDisposition: "stop_and_retain" });
|
||||
else await plugin.definition.onEnvironmentDestroyLease!(cleanup);
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("present");
|
||||
mockGet.mockResolvedValue(second);
|
||||
await plugin.definition.onEnvironmentDestroyLease!({ ...other, providerLeaseId: second.id });
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("none");
|
||||
});
|
||||
|
||||
it("keeps a pending create after exact cleanup until it settles and absence is re-proven", async () => {
|
||||
let finishCreate!: (sandbox: ReturnType<typeof createMockSandbox>) => void;
|
||||
const sandbox = createMockSandbox();
|
||||
mockCreate.mockImplementation(() => new Promise(resolve => { finishCreate = resolve; }));
|
||||
const probe = plugin.definition.onEnvironmentProbe!(params);
|
||||
await vi.waitFor(() => expect(finishCreate).toBeTypeOf("function"));
|
||||
mockGet.mockRejectedValue(new MockDaytonaNotFoundError("gone"));
|
||||
await plugin.definition.onEnvironmentDestroyLease!({ ...params, providerLeaseId: sandbox.id });
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("present");
|
||||
// The late create must finish its own deletion. Its tracked raw request
|
||||
// cannot be confused with a completed RPC or another resource's receipt.
|
||||
finishCreate(sandbox); await probe;
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("none");
|
||||
});
|
||||
|
||||
it("can clear verified absence after a failed deletion without starting the sandbox", async () => {
|
||||
const sandbox = createMockSandbox(); sandbox.delete.mockRejectedValue(new Error("unavailable"));
|
||||
mockCreate.mockResolvedValue(sandbox);
|
||||
await plugin.definition.onEnvironmentProbe!(params);
|
||||
mockGet.mockRejectedValue(new MockDaytonaNotFoundError("gone"));
|
||||
await plugin.definition.onEnvironmentInspectLease!({ ...params, providerLeaseId: sandbox.id });
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("none");
|
||||
expect(sandbox.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears both sandboxes when their successful stops overlap", async () => {
|
||||
const first = createMockSandbox({ id: "sandbox-a" });
|
||||
const second = createMockSandbox({ id: "sandbox-b" });
|
||||
for (const sandbox of [first, second]) sandbox.delete.mockRejectedValueOnce(new Error("deferred"));
|
||||
mockCreate.mockResolvedValueOnce(first).mockResolvedValueOnce(second);
|
||||
await plugin.definition.onEnvironmentProbe!(params);
|
||||
await plugin.definition.onEnvironmentProbe!(params);
|
||||
mockGet.mockImplementation(async id => id === first.id ? first : second);
|
||||
let finishFirst!: () => void, finishSecond!: () => void;
|
||||
first.stop.mockImplementation(() => new Promise<void>(resolve => { finishFirst = resolve; }));
|
||||
second.stop.mockImplementation(() => new Promise<void>(resolve => { finishSecond = resolve; }));
|
||||
const stopFirst = plugin.definition.onEnvironmentStopLease!({ ...params, providerLeaseId: first.id });
|
||||
const stopSecond = plugin.definition.onEnvironmentStopLease!({ ...params, providerLeaseId: second.id });
|
||||
await vi.waitFor(() => {
|
||||
expect(finishFirst).toBeTypeOf("function"); expect(finishSecond).toBeTypeOf("function");
|
||||
});
|
||||
finishFirst(); await stopFirst;
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("present");
|
||||
finishSecond(); await stopSecond;
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("none");
|
||||
});
|
||||
|
||||
it("keeps a stale stopped reply from clearing a start that finished during the read", async () => {
|
||||
const sandbox = createMockSandbox({ state: "stopped" });
|
||||
mockGet.mockResolvedValue(sandbox);
|
||||
let finishStart!: () => void, finishRead!: () => void;
|
||||
let remoteRunning = false;
|
||||
sandbox.start.mockImplementation(async () => {
|
||||
await new Promise<void>(resolve => { finishStart = resolve; });
|
||||
remoteRunning = true; sandbox.state = "started";
|
||||
});
|
||||
sandbox.stop.mockImplementation(async () => { remoteRunning = false; sandbox.state = "stopped"; });
|
||||
const execute = plugin.definition.onEnvironmentExecute!({ ...params, bypassSession: true,
|
||||
lease: { providerLeaseId: sandbox.id, metadata: {} }, command: "printf", args: ["hi"], timeoutMs: 1000 });
|
||||
await vi.waitFor(() => expect(finishStart).toBeTypeOf("function"));
|
||||
sandbox.refreshData.mockImplementationOnce(async () => {
|
||||
await new Promise<void>(resolve => { finishRead = resolve; });
|
||||
sandbox.state = "stopped"; // The reply contains the state captured before Start.
|
||||
});
|
||||
const stopped = plugin.definition.onEnvironmentStopLease!({ ...params, providerLeaseId: sandbox.id });
|
||||
await vi.waitFor(() => expect(finishRead).toBeTypeOf("function"));
|
||||
finishStart(); await execute;
|
||||
finishRead(); await stopped;
|
||||
expect(remoteRunning).toBe(true);
|
||||
expect(sandbox.stop).not.toHaveBeenCalled();
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("present");
|
||||
sandbox.state = "started"; // A subsequent fresh read observes the real state.
|
||||
await plugin.definition.onEnvironmentStopLease!({ ...params, providerLeaseId: sandbox.id });
|
||||
expect(remoteRunning).toBe(false);
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("none");
|
||||
});
|
||||
|
||||
it("retains a raw command dispatch after its deadline and an intervening stop", async () => {
|
||||
const sandbox = createMockSandbox(); mockGet.mockResolvedValue(sandbox);
|
||||
let finishDispatch!: (value: { cmdId: string }) => void;
|
||||
sandbox.process.executeSessionCommand.mockImplementation(() => new Promise(resolve => { finishDispatch = resolve; }));
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const execution = plugin.definition.onEnvironmentExecute!({ ...params,
|
||||
lease: { providerLeaseId: sandbox.id, metadata: {} }, command: "printf", args: ["hi"],
|
||||
timeoutMs: 1000, commandTimeoutMs: 1000 });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(finishDispatch).toBeTypeOf("function");
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(await execution).toMatchObject({ timedOut: true });
|
||||
await plugin.definition.onEnvironmentStopLease!({ ...params, providerLeaseId: sandbox.id });
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("present");
|
||||
finishDispatch({ cmdId: "late-command" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("present");
|
||||
await plugin.definition.onEnvironmentStopLease!({ ...params, providerLeaseId: sandbox.id });
|
||||
expect(sandbox.stop).toHaveBeenCalledTimes(2);
|
||||
expect(await plugin.definition.onIdleDrain!(signal)).toBe("none");
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
});
|
||||
|
||||
function createMockSandbox(overrides: {
|
||||
@@ -1025,7 +1210,7 @@ describe("Daytona sandbox provider plugin", () => {
|
||||
|
||||
expect(mockCreate).toHaveBeenCalled();
|
||||
expect(sandbox.fs.createFolder).toHaveBeenCalledWith("/home/daytona/paperclip-workspace", "755");
|
||||
expect(sandbox.delete).toHaveBeenCalledWith(300);
|
||||
expect(sandbox.delete).toHaveBeenCalledWith(300, true);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
metadata: {
|
||||
@@ -1254,7 +1439,7 @@ describe("Daytona sandbox provider plugin", () => {
|
||||
},
|
||||
})).rejects.toThrow("Sandbox.createSshAccess");
|
||||
|
||||
expect(sandbox.delete).toHaveBeenCalledWith(300);
|
||||
expect(sandbox.delete).toHaveBeenCalledWith(300, true);
|
||||
});
|
||||
|
||||
it("returns setup status without minting SSH access unless requested", async () => {
|
||||
@@ -1369,7 +1554,7 @@ describe("Daytona sandbox provider plugin", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(sandbox.delete).toHaveBeenCalledWith(300);
|
||||
expect(sandbox.delete).toHaveBeenCalledWith(300, true);
|
||||
expect(result).toMatchObject({
|
||||
status: "cancelled",
|
||||
metadata: {
|
||||
@@ -4000,6 +4185,7 @@ describe("Daytona sandbox provider plugin", () => {
|
||||
try {
|
||||
await vi.waitFor(() => expect(sandbox.stop).toHaveBeenCalledTimes(1), { timeout: 500 });
|
||||
await expect(cancellation).resolves.toEqual({ providerLeaseId: "lease-a", state: "stopped" });
|
||||
expect(await plugin.definition.onIdleDrain!(new AbortController().signal)).toBe("present");
|
||||
await expect(plugin.definition.onEnvironmentExecute!(execParams("lease-a"))).rejects.toThrow(/no longer active/);
|
||||
await expect(plugin.definition.onEnvironmentResumeLease!({
|
||||
driverKey: "daytona", companyId: "company-1", environmentId: "env-1",
|
||||
@@ -4011,6 +4197,15 @@ describe("Daytona sandbox provider plugin", () => {
|
||||
await execute;
|
||||
await cancellation;
|
||||
}
|
||||
// Completion of the old command is not a second stop receipt. Re-read
|
||||
// the exact resource after it settles before clearing worker evidence.
|
||||
expect(await plugin.definition.onIdleDrain!(new AbortController().signal)).toBe("present");
|
||||
sandbox.state = "stopped";
|
||||
await plugin.definition.onEnvironmentInspectLease!({
|
||||
driverKey: "daytona", companyId: "company-1", environmentId: "env-1",
|
||||
providerLeaseId: "lease-a", config: { apiKey: "host-key" },
|
||||
});
|
||||
expect(await plugin.definition.onIdleDrain!(new AbortController().signal)).toBe("none");
|
||||
});
|
||||
|
||||
it("does not report termination when the provider rejects Stop", async () => {
|
||||
|
||||
@@ -51,6 +51,7 @@ import type {
|
||||
import { performSyncIn, performSyncOut, withProviderSpan } from "./file-sync.js";
|
||||
import { DEFAULT_DAYTONA_OPERATION_TIMEOUT_MS } from "./manifest.js";
|
||||
import { observeSessionStreamExit } from "./session-stream-exit.js";
|
||||
import { DaytonaIdleActivity, trackDaytonaHooks, type DaytonaIdleProof } from "./idle-activity.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
|
||||
@@ -112,10 +113,9 @@ let timingNow: () => number = () => Date.now();
|
||||
// read the tracer through `getPluginTracer()`. Before `setup` runs (or in a
|
||||
// test) the tracer is a no-op, so a span never throws.
|
||||
let pluginContext: PluginContext | null = null;
|
||||
// Provider deadlines and terminal close failures do not prove remote work
|
||||
// stopped. Until those paths provide complete cleanup receipts, only a worker
|
||||
// which has never contacted the provider can opt into automatic idle sleep.
|
||||
let providerUsed = false;
|
||||
// A deadline does not settle the underlying request. Keep both that request
|
||||
// and its allocation until completion and scoped provider cleanup are proven.
|
||||
const idleActivity = new DaytonaIdleActivity();
|
||||
|
||||
/**
|
||||
* Return the plugin tracer. It is the injected `ctx.tracer` after `setup`, or a
|
||||
@@ -321,8 +321,7 @@ function resolveApiKey(config: DaytonaDriverConfig): string {
|
||||
return envApiKey;
|
||||
}
|
||||
|
||||
function createDaytonaClient(config: DaytonaDriverConfig, readOnly = false): Daytona {
|
||||
if (!readOnly) providerUsed = true;
|
||||
function createDaytonaClient(config: DaytonaDriverConfig): Daytona {
|
||||
const clientConfig: DaytonaConfig = {
|
||||
apiKey: resolveApiKey(config),
|
||||
};
|
||||
@@ -464,14 +463,14 @@ async function withLivenessTimeout<T>(
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
return run();
|
||||
return idleActivity.track(run);
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new SandboxLivenessTimeoutError(operation, timeoutMs)), timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([run(), deadline]);
|
||||
return await Promise.race([idleActivity.track(run), deadline]);
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
@@ -987,10 +986,13 @@ async function createSandbox(
|
||||
attemptId, labels, accountFingerprint: sandboxAccountDiscriminator(config),
|
||||
};
|
||||
options.onCreateAttempt?.(cleanup);
|
||||
const allocation = idleActivity.resource(providerActivityKey(params, config, name), true);
|
||||
try {
|
||||
return await client.create(createParams, {
|
||||
const sandbox = await idleActivity.track(() => client.create(createParams, {
|
||||
timeout: toTimeoutSeconds(config.timeoutMs),
|
||||
});
|
||||
}));
|
||||
idleActivity.observe(allocation, providerActivityKey(params, config, sandbox.id));
|
||||
return sandbox;
|
||||
} catch (createError) {
|
||||
// Observe the create failure before compensating deletion can stall or fail.
|
||||
const diagnostic = options.onCreateFailure?.();
|
||||
@@ -998,7 +1000,7 @@ async function createSandbox(
|
||||
// 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
|
||||
// error so cleanup can be reconciled rather than declaring success.
|
||||
await destroyFailedCreation(config, cleanup);
|
||||
await destroyFailedCreation(config, cleanup, params.driverKey);
|
||||
} catch (cleanupError) {
|
||||
throw new PluginEnvironmentCreationCleanupError([createError, cleanupError],
|
||||
`Daytona sandbox creation failed; cleanup could not be confirmed for ${name}`, cleanup, diagnostic);
|
||||
@@ -1013,6 +1015,7 @@ async function createSandbox(
|
||||
// conclusive, even if the deletion receipt or database update was lost.
|
||||
async function destroyFailedCreation(
|
||||
config: DaytonaDriverConfig, cleanup: PluginEnvironmentCreationCleanup,
|
||||
driverKey: string,
|
||||
requireDurableObservation = false,
|
||||
): Promise<void> {
|
||||
if (cleanup.providerLeaseId !== `paperclip-create-${cleanup.attemptId}` ||
|
||||
@@ -1024,15 +1027,25 @@ async function destroyFailedCreation(
|
||||
(cleanup.runId !== undefined && cleanup.labels["paperclip-run-id"] !== cleanup.runId)) {
|
||||
throw new Error("Failed-create sandbox ownership does not match");
|
||||
}
|
||||
const identity = { driverKey, companyId: cleanup.companyId, environmentId: cleanup.environmentId };
|
||||
const allocation = idleActivity.resource(providerActivityKey(identity, config, cleanup.providerLeaseId), true);
|
||||
if (cleanup.observedProviderLeaseId) {
|
||||
idleActivity.observe(allocation, providerActivityKey(identity, config, cleanup.observedProviderLeaseId));
|
||||
}
|
||||
const client = createDaytonaClient(config);
|
||||
let orphan: Sandbox;
|
||||
const observationProof = idleActivity.proof(providerActivityKey(identity, config,
|
||||
cleanup.observedProviderLeaseId ?? cleanup.providerLeaseId));
|
||||
try {
|
||||
orphan = await withLivenessTimeout("sandbox.failedCreateLookup", 10_000,
|
||||
() => client.get(cleanup.observedProviderLeaseId ?? cleanup.providerLeaseId));
|
||||
} catch (error) {
|
||||
// A name that has never been observed may still materialize. An ID already
|
||||
// observed with exact ownership cannot materialize as a new allocation.
|
||||
if (cleanup.observedProviderLeaseId && error instanceof DaytonaNotFoundError) return;
|
||||
if (cleanup.observedProviderLeaseId && error instanceof DaytonaNotFoundError) {
|
||||
idleActivity.confirm(observationProof, true);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if ((cleanup.observedProviderLeaseId && orphan.id !== cleanup.observedProviderLeaseId) ||
|
||||
@@ -1046,7 +1059,10 @@ async function destroyFailedCreation(
|
||||
throw new PluginEnvironmentCreationCleanupError([], "Persist observed sandbox identity before cleanup", cleanup);
|
||||
}
|
||||
}
|
||||
idleActivity.observe(allocation, providerActivityKey(identity, config, orphan.id));
|
||||
const deletionProof = idleActivity.proof(providerActivityKey(identity, config, orphan.id));
|
||||
await withLivenessTimeout("sandbox.failedCreateDelete", 15_000, () => orphan.delete(10, true));
|
||||
idleActivity.confirm(deletionProof);
|
||||
}
|
||||
|
||||
// Daytona states reported as-is after a create deadline; any other is `other`.
|
||||
@@ -1104,6 +1120,19 @@ type SandboxScope = {
|
||||
config: DaytonaDriverConfig;
|
||||
};
|
||||
|
||||
function providerActivityKey(
|
||||
identity: { driverKey: string; companyId: string; environmentId: string },
|
||||
config: DaytonaDriverConfig,
|
||||
providerLeaseId: string,
|
||||
): string {
|
||||
return sandboxHandleCacheKey({ ...identity, config, providerLeaseId });
|
||||
}
|
||||
|
||||
function confirmedTermination(scope: SandboxScope, state: "stopped" | "destroyed", proof: DaytonaIdleProof): PluginEnvironmentTerminationReceipt {
|
||||
idleActivity.confirm(proof);
|
||||
return { providerLeaseId: scope.providerLeaseId, state };
|
||||
}
|
||||
|
||||
// Non-secret provider/account fingerprint. Uses the *resolved* key (config or
|
||||
// DAYTONA_API_KEY env fallback) so an env-provided credential is still scoped,
|
||||
// but only its sha256 digest — never the key itself — enters the cache key (C1/C6).
|
||||
@@ -1188,6 +1217,7 @@ const sandboxHandleTeardownGates = (() => {
|
||||
const gates = new Map<string, SandboxHandleTeardownGate>();
|
||||
|
||||
function begin(scope: SandboxScope): SandboxHandleTeardownGate {
|
||||
idleActivity.resource(sandboxHandleCacheKey(scope));
|
||||
const key = sandboxHandleCacheKey(scope);
|
||||
const existing = gates.get(key);
|
||||
if (existing) {
|
||||
@@ -1342,6 +1372,7 @@ async function withSandboxActivityGate<T>(
|
||||
if (!options.allowClosed && sandboxHandleLeaseAdmissionStates.isClosed(scope)) {
|
||||
throw new Error(`Daytona sandbox lease ${scope.providerLeaseId} is no longer active.`);
|
||||
}
|
||||
idleActivity.resource(sandboxHandleCacheKey(scope));
|
||||
return await fn();
|
||||
} finally {
|
||||
sandboxHandleActivityGates.end(scope, activityGate);
|
||||
@@ -1605,7 +1636,7 @@ const sandboxHandleSessionStore = (() => {
|
||||
* Not used in production.
|
||||
*/
|
||||
export function __resetDaytonaSandboxHandleCacheForTest(): void {
|
||||
providerUsed = false;
|
||||
idleActivity.resetForTest();
|
||||
sandboxHandleCache.reset();
|
||||
sandboxHandleTeardownGates.reset();
|
||||
sandboxHandleActivityGates.reset();
|
||||
@@ -1642,10 +1673,12 @@ async function getSandbox(scope: SandboxScope, options: SandboxLookupOptions = {
|
||||
}
|
||||
|
||||
async function getSandboxOrNull(scope: SandboxScope, options: SandboxLookupOptions = {}): Promise<Sandbox | null> {
|
||||
const proof = idleActivity.proof(sandboxHandleCacheKey(scope));
|
||||
try {
|
||||
return await getSandbox(scope, options);
|
||||
} catch (error) {
|
||||
if (error instanceof DaytonaNotFoundError) {
|
||||
idleActivity.confirm(proof, true);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
@@ -1988,7 +2021,7 @@ function createCommandDeadline(timeoutMs: number | undefined) {
|
||||
}
|
||||
type CommandDeadline = ReturnType<typeof createCommandDeadline>;
|
||||
function withinCommandDeadline<T>(deadline: CommandDeadline, action: () => Promise<T>): Promise<T> {
|
||||
return deadline ? deadline.race(action) : action();
|
||||
return deadline ? deadline.race(() => idleActivity.track(action)) : idleActivity.track(action);
|
||||
}
|
||||
|
||||
async function sleepWithinCommandDeadline(deadline: CommandDeadline, delayMs: number): Promise<void> {
|
||||
@@ -2007,7 +2040,7 @@ async function beforeSessionDeadline<T>(deadlineMs: number, action: () => Promis
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
action(),
|
||||
idleActivity.track(action),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new SessionCommandDeadlineError()), remainingMs);
|
||||
}),
|
||||
@@ -2024,7 +2057,7 @@ async function observeSessionCommand<T>(timeoutMs: number, action: () => Promise
|
||||
// Use the earlier shared command timer instead of leaving a longer
|
||||
// observation timer alive after the command has already settled.
|
||||
if (commandDeadline && commandDeadline.remainingMs() <= timeoutMs) {
|
||||
return await commandDeadline.race(action);
|
||||
return await commandDeadline.race(() => idleActivity.track(action));
|
||||
}
|
||||
return await beforeSessionDeadline(Date.now() + timeoutMs, action);
|
||||
} catch (error) {
|
||||
@@ -2071,12 +2104,12 @@ async function runSessionLogStream(
|
||||
armIdleTimer();
|
||||
const observed = await withinCommandDeadline(commandDeadline, () => {
|
||||
exitObservation = observeSessionStreamExit(Promise.race([
|
||||
sandbox.process.getSessionCommandLogs(
|
||||
idleActivity.track(() => sandbox.process.getSessionCommandLogs(
|
||||
sessionId,
|
||||
commandId,
|
||||
(chunk) => receive("stdout", chunk),
|
||||
(chunk) => receive("stderr", chunk),
|
||||
).then(() => "closed" as const),
|
||||
)).then(() => "closed" as const),
|
||||
idle,
|
||||
]), async () => {
|
||||
const status = await observeSessionCommand(Math.min(timeoutMs, SESSION_INITIAL_STATUS_TIMEOUT_MS),
|
||||
@@ -2391,7 +2424,40 @@ async function closeDaytonaDuplexChannelsForLease(providerLeaseId: string): Prom
|
||||
await Promise.allSettled(matches.map(entry => entry.session.close()));
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async function stopLease(params: PluginEnvironmentReleaseLeaseParams): Promise<PluginEnvironmentTerminationReceipt> {
|
||||
if (!params.providerLeaseId) throw new Error("Daytona stop requires an exact sandbox identity");
|
||||
const config = parseDriverConfig(params.config);
|
||||
const scope: SandboxScope = { driverKey: params.driverKey, companyId: params.companyId,
|
||||
environmentId: params.environmentId, providerLeaseId: params.providerLeaseId, config };
|
||||
const teardownGate = sandboxHandleTeardownGates.begin(scope);
|
||||
sandboxHandleLeaseAdmissionStates.close(scope);
|
||||
const cleanupProof = idleActivity.proof(sandboxHandleCacheKey(scope));
|
||||
try {
|
||||
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
||||
if (!sandbox) throw new Error("Daytona retained sandbox is unavailable");
|
||||
evictSandboxHandle(scope);
|
||||
// Provider auto-delete can destroy a stopped sandbox without an explicit
|
||||
// delete call. Confirm the provider disabled it before stopping saved work;
|
||||
// the SDK setter also updates a local field, which is not sufficient proof.
|
||||
const deadline = Math.min(config.livenessTimeoutMs, 30_000);
|
||||
await withLivenessTimeout("sandbox.setAutoDeleteInterval", deadline, () => sandbox.setAutoDeleteInterval(-1));
|
||||
await withLivenessTimeout("sandbox.refreshData", deadline, () => sandbox.refreshData());
|
||||
if (sandbox.autoDeleteInterval !== -1) throw new Error("Daytona retention policy was not confirmed");
|
||||
// An archive already has no live process and retains its filesystem.
|
||||
// Daytona rejects Stop in this state; leave the confirmed archive cold.
|
||||
if (sandbox.state !== "stopped" && sandbox.state !== "archived") {
|
||||
await terminateAtProvider(scope, "sandbox.stop", () => sandbox.stop(Math.min(toTimeoutSeconds(config.timeoutMs), 30)));
|
||||
}
|
||||
sandboxHandleSessionStore.clear(scope);
|
||||
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
|
||||
return confirmedTermination(scope, "stopped", cleanupProof);
|
||||
} finally {
|
||||
sandboxHandleTeardownGates.end(scope, teardownGate);
|
||||
evictSandboxHandle(scope);
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = definePlugin(trackDaytonaHooks({
|
||||
async setup(ctx) {
|
||||
// Hoist the context to a module variable so the lifecycle hooks and the
|
||||
// file-sync helpers can read `ctx.tracer` — they have no closure over `ctx`.
|
||||
@@ -2404,7 +2470,7 @@ const plugin = definePlugin({
|
||||
},
|
||||
|
||||
async onIdleDrain() {
|
||||
return providerUsed || daytonaLoginPtyByRoute.size > 0 || daytonaDuplexChannelByRoute.size > 0
|
||||
return idleActivity.present() || daytonaLoginPtyByRoute.size > 0 || daytonaDuplexChannelByRoute.size > 0
|
||||
? "present" : "none";
|
||||
},
|
||||
|
||||
@@ -2497,7 +2563,11 @@ const plugin = definePlugin({
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
|
||||
const proof = idleActivity.proof(providerActivityKey(params, config, sandbox.id));
|
||||
await sandbox.delete(toTimeoutSeconds(config.timeoutMs), true).then(
|
||||
() => idleActivity.confirm(proof),
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -2632,7 +2702,9 @@ const plugin = definePlugin({
|
||||
if (!expired) {
|
||||
cleanupInProgress = true;
|
||||
try {
|
||||
await sandbox.delete(toTimeoutSeconds(Math.max(1, deadline - Date.now())));
|
||||
const proof = idleActivity.proof(providerActivityKey(params, config, sandbox.id));
|
||||
await sandbox.delete(toTimeoutSeconds(Math.max(1, deadline - Date.now())), true);
|
||||
idleActivity.confirm(proof);
|
||||
} catch (cleanupError) {
|
||||
if (cleanup) {
|
||||
throw new PluginEnvironmentCreationCleanupError([error, cleanupError],
|
||||
@@ -2651,7 +2723,7 @@ const plugin = definePlugin({
|
||||
return await Promise.race([
|
||||
// After the deadline only the timer settles the call: a late create
|
||||
// rejection cannot replace the timeout's ownership and observation.
|
||||
acquire().catch((error: unknown) => timedOut ? new Promise<never>(() => undefined) : Promise.reject(error)),
|
||||
idleActivity.track(acquire).catch((error: unknown) => timedOut ? new Promise<never>(() => undefined) : Promise.reject(error)),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => { timedOut = true; void deadlineFailure().then(reject); }, budgetMs);
|
||||
}),
|
||||
@@ -2782,43 +2854,13 @@ const plugin = definePlugin({
|
||||
);
|
||||
},
|
||||
|
||||
async onEnvironmentStopLease(params: PluginEnvironmentReleaseLeaseParams): Promise<PluginEnvironmentTerminationReceipt> {
|
||||
if (!params.providerLeaseId) throw new Error("Daytona stop requires an exact sandbox identity");
|
||||
const config = parseDriverConfig(params.config);
|
||||
const scope: SandboxScope = { driverKey: params.driverKey, companyId: params.companyId,
|
||||
environmentId: params.environmentId, providerLeaseId: params.providerLeaseId, config };
|
||||
const teardownGate = sandboxHandleTeardownGates.begin(scope);
|
||||
sandboxHandleLeaseAdmissionStates.close(scope);
|
||||
try {
|
||||
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
||||
if (!sandbox) throw new Error("Daytona retained sandbox is unavailable");
|
||||
evictSandboxHandle(scope);
|
||||
// Provider auto-delete can destroy a stopped sandbox without an explicit
|
||||
// delete call. Confirm the provider disabled it before stopping saved work;
|
||||
// the SDK setter also updates a local field, which is not sufficient proof.
|
||||
const deadline = Math.min(config.livenessTimeoutMs, 30_000);
|
||||
await withLivenessTimeout("sandbox.setAutoDeleteInterval", deadline, () => sandbox.setAutoDeleteInterval(-1));
|
||||
await withLivenessTimeout("sandbox.refreshData", deadline, () => sandbox.refreshData());
|
||||
if (sandbox.autoDeleteInterval !== -1) throw new Error("Daytona retention policy was not confirmed");
|
||||
// An archive already has no live process and retains its filesystem.
|
||||
// Daytona rejects Stop in this state; leave the confirmed archive cold.
|
||||
if (sandbox.state !== "stopped" && sandbox.state !== "archived") {
|
||||
await terminateAtProvider(scope, "sandbox.stop", () => sandbox.stop(Math.min(toTimeoutSeconds(config.timeoutMs), 30)));
|
||||
}
|
||||
sandboxHandleSessionStore.clear(scope);
|
||||
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
|
||||
return { providerLeaseId: params.providerLeaseId, state: "stopped" };
|
||||
} finally {
|
||||
sandboxHandleTeardownGates.end(scope, teardownGate);
|
||||
evictSandboxHandle(scope);
|
||||
}
|
||||
},
|
||||
onEnvironmentStopLease: stopLease,
|
||||
|
||||
async onEnvironmentReleaseLease(
|
||||
params: PluginEnvironmentReleaseLeaseParams,
|
||||
): Promise<PluginEnvironmentTerminationReceipt | void> {
|
||||
if (!params.providerLeaseId) return;
|
||||
if (params.resourceDisposition === "stop_and_retain") return plugin.definition.onEnvironmentStopLease!(params);
|
||||
if (params.resourceDisposition === "stop_and_retain") return stopLease(params);
|
||||
const config = parseDriverConfig(params.config);
|
||||
const scope: SandboxScope = {
|
||||
driverKey: params.driverKey,
|
||||
@@ -2832,6 +2874,7 @@ const plugin = definePlugin({
|
||||
// exec/sync calls cannot reacquire the same sandbox mid-stop/delete.
|
||||
const teardownGate = sandboxHandleTeardownGates.begin(scope);
|
||||
sandboxHandleLeaseAdmissionStates.close(scope);
|
||||
let cleanupProof = idleActivity.proof(sandboxHandleCacheKey(scope));
|
||||
try {
|
||||
const sandbox = await getSandboxOrNull(scope, { bypassTeardownGate: true });
|
||||
if (!sandbox) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
||||
@@ -2845,9 +2888,10 @@ const plugin = definePlugin({
|
||||
if (sandbox.state !== "stopped") await sandbox.stop(timeoutSeconds);
|
||||
sandboxHandleSessionStore.clear(scope);
|
||||
await closeDaytonaDuplexChannelsForLease(params.providerLeaseId);
|
||||
return { providerLeaseId: params.providerLeaseId, state: "stopped" };
|
||||
return confirmedTermination(scope, "stopped", cleanupProof);
|
||||
}
|
||||
await drainSandboxBeforeTermination(sandbox, scope);
|
||||
cleanupProof = idleActivity.proof(sandboxHandleCacheKey(scope));
|
||||
|
||||
// A cached stopped state is not a receipt: the resource could have been
|
||||
// resumed since the handle was cached. Read provider state at this boundary.
|
||||
@@ -2861,10 +2905,10 @@ const plugin = definePlugin({
|
||||
`Failed to stop Daytona sandbox during lease release: ${formatErrorMessage(error)}. Attempting delete instead.`,
|
||||
);
|
||||
await terminateAtProvider(scope, "sandbox.delete", () => sandbox.delete(toTimeoutSeconds(config.timeoutMs), true));
|
||||
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
||||
return confirmedTermination(scope, "destroyed", cleanupProof);
|
||||
}
|
||||
}
|
||||
return { providerLeaseId: params.providerLeaseId, state: "stopped" };
|
||||
return confirmedTermination(scope, "stopped", cleanupProof);
|
||||
}
|
||||
|
||||
if (config.archiveOnRelease) {
|
||||
@@ -2874,7 +2918,7 @@ const plugin = definePlugin({
|
||||
}
|
||||
await sandbox.setAutoDeleteInterval(ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES);
|
||||
await sandbox.archive();
|
||||
return { providerLeaseId: params.providerLeaseId, state: "stopped" };
|
||||
return confirmedTermination(scope, "stopped", cleanupProof);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to archive Daytona sandbox during lease release: ${formatErrorMessage(error)}. Falling back to delete.`,
|
||||
@@ -2883,7 +2927,7 @@ const plugin = definePlugin({
|
||||
}
|
||||
|
||||
await terminateAtProvider(scope, "sandbox.delete", () => sandbox.delete(toTimeoutSeconds(config.timeoutMs), true));
|
||||
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
||||
return confirmedTermination(scope, "destroyed", cleanupProof);
|
||||
} finally {
|
||||
sandboxHandleTeardownGates.end(scope, teardownGate);
|
||||
evictSandboxHandle(scope);
|
||||
@@ -2902,14 +2946,21 @@ const plugin = definePlugin({
|
||||
if (!config.apiUrl && (process.env.DAYTONA_API_URL?.trim() || process.env.DAYTONA_SERVER_URL?.trim())) {
|
||||
return { providerLeaseId: params.providerLeaseId, state: "unknown" };
|
||||
}
|
||||
const proof = idleActivity.proof(providerActivityKey(params, config, params.providerLeaseId));
|
||||
// Read directly from the provider. The setup-status path and cached lease
|
||||
// reconnect path can start a stopped sandbox, so neither is an observation.
|
||||
try {
|
||||
const sandbox = await createDaytonaClient(config, true).get(params.providerLeaseId);
|
||||
const sandbox = await createDaytonaClient(config).get(params.providerLeaseId);
|
||||
if (sandbox.id !== params.providerLeaseId) return { providerLeaseId: params.providerLeaseId, state: "unknown" };
|
||||
if (sandbox.state === "stopped" || sandbox.state === "archived") {
|
||||
idleActivity.confirm(proof, true);
|
||||
}
|
||||
return { providerLeaseId: sandbox.id, state: sandbox.state === "stopped" ? "stopped" : "active" };
|
||||
} catch (error) {
|
||||
if (error instanceof DaytonaNotFoundError) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
||||
if (error instanceof DaytonaNotFoundError) {
|
||||
idleActivity.confirm(proof, true);
|
||||
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -2927,7 +2978,7 @@ const plugin = definePlugin({
|
||||
cleanup.providerLeaseId !== params.providerLeaseId) {
|
||||
throw new Error("Failed-create sandbox cleanup scope does not match");
|
||||
}
|
||||
await destroyFailedCreation(config, cleanup, true);
|
||||
await destroyFailedCreation(config, cleanup, params.driverKey, true);
|
||||
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
||||
}
|
||||
const scope: SandboxScope = {
|
||||
@@ -2947,8 +2998,9 @@ const plugin = definePlugin({
|
||||
|
||||
evictSandboxHandle(scope);
|
||||
await drainSandboxBeforeTermination(sandbox, scope);
|
||||
const cleanupProof = idleActivity.proof(sandboxHandleCacheKey(scope));
|
||||
await terminateAtProvider(scope, "sandbox.delete", () => sandbox.delete(toTimeoutSeconds(config.timeoutMs), true));
|
||||
return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
|
||||
return confirmedTermination(scope, "destroyed", cleanupProof);
|
||||
} finally {
|
||||
sandboxHandleTeardownGates.end(scope, teardownGate);
|
||||
evictSandboxHandle(scope);
|
||||
@@ -3023,7 +3075,11 @@ const plugin = definePlugin({
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined);
|
||||
const proof = idleActivity.proof(providerActivityKey(params, config, sandbox.id));
|
||||
await sandbox.delete(toTimeoutSeconds(config.timeoutMs), true).then(
|
||||
() => idleActivity.confirm(proof),
|
||||
() => undefined,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -3186,7 +3242,9 @@ const plugin = definePlugin({
|
||||
evictSandboxHandle(scope);
|
||||
await sandboxHandleActivityGates.waitForIdle(scope);
|
||||
await teardownSession(sandbox, scope);
|
||||
await sandbox.delete(toTimeoutSeconds(config.timeoutMs));
|
||||
const proof = idleActivity.proof(sandboxHandleCacheKey(scope));
|
||||
await sandbox.delete(toTimeoutSeconds(config.timeoutMs), true);
|
||||
idleActivity.confirm(proof);
|
||||
return {
|
||||
status: params.reason === "timed_out" ? "timed_out" : "cancelled",
|
||||
metadata: {
|
||||
@@ -3646,6 +3704,6 @@ const plugin = definePlugin({
|
||||
sandboxHandleCache.reset();
|
||||
runnerIngressGenerationStore.reset();
|
||||
},
|
||||
});
|
||||
}, idleActivity));
|
||||
|
||||
export default plugin;
|
||||
Reference in new issue
Block a user