diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md index e03d218631a..67006f986d0 100644 --- a/doc/native-workspace-finalization-recovery.md +++ b/doc/native-workspace-finalization-recovery.md @@ -414,6 +414,9 @@ stop-and-retain receipts and must not have a competing allocation owner. Active warm allocations keep the same retention retry path. Only historical stopping or stopped sources require the ephemeral policy, because a reusable released allocation may already have a new owner. +Daytona stop-only cleanup also accepts a freshly verified archived allocation. +It first confirms that provider auto-delete is disabled. It leaves the archive +cold, with its files preserved, and records the process as stopped. For a cancelled native run, the file-recovery form also offers an optional confirmation that no external actions were performed. Leave it unchecked unless the run history verifies that fact. The default records files without certifying diff --git a/maps/feature-map/workspaces.md b/maps/feature-map/workspaces.md index 64d735e10ec..7709d432293 100644 --- a/maps/feature-map/workspaces.md +++ b/maps/feature-map/workspaces.md @@ -28,6 +28,7 @@ Later cancelled runs require their own confirmed stop and board file repair. Their evidence is preserved with the reconstruction and rechecked before dispatch. Stop retains unfinished native files; startup also repairs missing historical retention without replaying the cancelled turn. +Daytona stop-only cleanup confirms retention and accepts an already archived source. Cancelled native file recovery can record a verified no-action outcome. Its confirmation stays optional and resets when the source changes. Retained sandboxes keep their existing export recovery path. Automated coverage: diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 1803bcd1dbd..5ccce8d2442 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -1961,6 +1961,39 @@ describe("Daytona sandbox provider plugin", () => { expect(sandbox.delete).not.toHaveBeenCalled(); }); + it.each([true, false])("preserves an archived source only after fresh retention confirmation: %s", async confirmed => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox({ id: "sandbox-already-archived", state: "archived" }); + sandbox.stop.mockRejectedValue(new Error("The provider rejects Stop for an archived sandbox")); + sandbox.refreshData.mockImplementation(async () => { sandbox.autoDeleteInterval = confirmed ? -1 : 0; }); + mockGet.mockResolvedValue(sandbox); + const stop = plugin.definition.onEnvironmentStopLease!({ + driverKey: "daytona", companyId: "company-1", environmentId: "env-1", + providerLeaseId: sandbox.id, config: { reuseLease: false }, + }); + if (confirmed) await expect(stop).resolves.toEqual({ providerLeaseId: sandbox.id, state: "stopped" }); + else await expect(stop).rejects.toThrow("retention policy was not confirmed"); + expect(sandbox.setAutoDeleteInterval).toHaveBeenCalledWith(-1); + expect(sandbox.refreshData).toHaveBeenCalled(); + expect(sandbox.stop).not.toHaveBeenCalled(); + expect(sandbox.start).not.toHaveBeenCalled(); + expect(sandbox.archive).not.toHaveBeenCalled(); + expect(sandbox.delete).not.toHaveBeenCalled(); + }); + + it("stops an archived handle that a fresh provider read shows has resumed", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox({ id: "sandbox-archive-resumed", state: "archived" }); + sandbox.refreshData.mockImplementation(async () => { sandbox.state = "started"; }); + mockGet.mockResolvedValue(sandbox); + await expect(plugin.definition.onEnvironmentStopLease!({ + driverKey: "daytona", companyId: "company-1", environmentId: "env-1", + providerLeaseId: sandbox.id, config: { reuseLease: false }, + })).resolves.toEqual({ providerLeaseId: sandbox.id, state: "stopped" }); + expect(sandbox.stop).toHaveBeenCalledOnce(); + expect(sandbox.delete).not.toHaveBeenCalled(); + }); + it("stops reusable leases and deletes ephemeral leases on release", async () => { process.env.DAYTONA_API_KEY = "host-key"; const reusable = createMockSandbox({ id: "sandbox-reusable" }); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index 96b9c3f0d04..1df4747794f 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -2800,7 +2800,9 @@ const plugin = definePlugin({ 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"); - if (sandbox.state !== "stopped") { + // 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);