From bfadb66556884d4b7606f4d62f15ecbdc0083269 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:50:49 -0500 Subject: [PATCH] fix(daytona): retain already archived workspaces on stop (#16710) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Stop-only recovery must keep unfinished remote workspace files safe. > - Daytona can archive a stopped sandbox while those files remain in object storage. > - The stop handler sent another Stop request for that archived source. > - Daytona rejected that request, so recovery could not record its required receipt. > - This change accepts the freshly confirmed archived state after retention is verified. > - Recovery can proceed while the source stays cold and its files remain available. ## Linked Issues or Issue Description Refs: #16583, #16700. **What happened?** Stop-only cleanup rejected a retained workspace after Daytona archived it. The live provider returned `DaytonaValidationError` for Stop while its state was `archived` and auto-delete was already disabled. The host correctly kept the recovery gate closed. **Expected behavior** Confirm the exact source and its retention policy. Treat an archived source as having no running process. Record the stop-only receipt without starting or deleting the source. **Steps to reproduce** 1. Keep an unfinished native workspace with stop-and-retain intent. 2. Let its stopped Daytona sandbox become archived. 3. Request stop-only cleanup. 4. Observe the rejected Stop request and missing receipt. ## What Changed - Accept `archived` after a fresh provider read confirms that auto-delete is disabled. - Stop the source if that fresh read instead shows it has resumed. - Add regression cases for confirmed retention, unconfirmed retention, and a resumed cached handle. - Update the recovery runbook and capability map. ## Verification - The new baseline fixture failed on the old handler: 1 failed, 2 passed. - Daytona provider suites: 417 passed, 6 skipped. - Full workspace `pnpm -r typecheck`: passed. - Full workspace `pnpm build`: passed on `fc8b1c1bb8`. - Maps and Git whitespace checks: passed. - The live provider rejection was reproduced on the exact retained source. No model turn, file change or delete was performed. The fixed source handler was then run against that same archived allocation with the declared SDK version. It returned the stopped-process receipt while the provider stayed archived with auto-delete disabled. No process was started. All 58 exact-head CI checks passed; independent review is 5/5. A full local run is also in progress on a separate frozen checkout of this revision; it is not reported as complete. - [Daytona lifecycle documentation](https://www.daytona.io/docs/en/sandboxes/) confirms that archive follows Stop and preserves the filesystem in object storage. ## Risks - An archived source grants a generic stopped-process receipt. Its physical provider state stays archived. - Retention confirmation remains required. A stale archived handle that has resumed still requires Stop. - The host still checks the original allocation, ownership, stop receipt and file-recovery evidence before continuation. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tool use and code execution. The session does not expose an exact backend model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- doc/native-workspace-finalization-recovery.md | 3 ++ maps/feature-map/workspaces.md | 1 + .../daytona/src/plugin.test.ts | 33 +++++++++++++++++++ .../sandbox-providers/daytona/src/plugin.ts | 4 ++- 4 files changed, 40 insertions(+), 1 deletion(-) 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);