From b3209486ca2238a07d9e3091d729643eea42eeeb Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 23 Jun 2026 13:45:54 -0700 Subject: [PATCH] fix: default Daytona sandboxes to auto-archive so they leave the disk quota (#8561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run their work inside sandboxes, provisioned through pluggable sandbox-provider plugins; the Daytona plugin is one of them > - Daytona bills storage against an org-wide disk quota, and a *stopped* sandbox still counts against that quota — only an *archived* sandbox is moved to cold storage and stops counting > - The Daytona plugin created sandboxes without supplying any auto-stop/auto-archive/auto-delete intervals, so Daytona fell back to its own defaults (archive after 7 days, never auto-delete) > - When in-product cleanup fails or never runs (crashed runs, failed lease destroys, orphaned probes), stopped sandboxes then sit for a week at a few GiB each until the org storage quota fills and blocks all workers > - This pull request makes the Daytona plugin apply quota-safe defaults on create (auto-stop 15m, auto-archive 60m, auto-delete 7d) so every sandbox eventually leaves the disk quota on its own, even when our own cleanup fails > - The benefit is that the storage quota no longer fills from leaked/idle sandboxes, while operators keep full control to override the intervals per environment ## Linked Issues or Issue Description No public GitHub issue. Describing in-PR (bug report): ### What happened Running many agents in Daytona sandboxes eventually exhausted the org's storage quota, and Daytona returned an out-of-disk error that blocked all sandbox creation. Root cause: a *stopped* Daytona sandbox still consumes disk quota; only an *archived* sandbox is moved to cold storage and frees it. The plugin created sandboxes without specifying auto-stop/auto-archive/auto-delete intervals, so Daytona used its built-in defaults (auto-archive after 7 days, auto-delete disabled). Any sandbox that our own cleanup failed to remove — or that was orphaned by a crashed run — therefore lingered for up to a week, accumulating until the quota filled. ### Expected behavior Idle/leaked sandboxes should leave the storage quota automatically within a short window, without relying solely on in-product cleanup succeeding. ### Steps to reproduce 1. Configure the Daytona sandbox provider with no explicit auto-stop/auto-archive/auto-delete intervals. 2. Run many agent sandboxes over time (or leak some via crashed/failed runs that skip in-product cleanup). 3. Observe stopped-but-not-archived sandboxes accumulating against the org's 30 GiB storage quota until Daytona returns an out-of-disk error and blocks new sandbox creation. ### Deployment mode Self-hosted Paperclip using the Daytona sandbox-provider plugin. ## What Changed - `daytona/src/plugin.ts`: `parseDriverConfig` now defaults `autoStopInterval` → 15 min, `autoArchiveInterval` → 60 min, `autoDeleteInterval` → 7 days when the value is unset. Explicit per-environment values (including `0` and `-1`) are preserved and passed through unchanged. - `daytona/src/manifest.ts`: documents the new defaults and the quota rationale on each field, and sets the manifest `default` so the values surface in the environment configuration screen where operators can override them. - `daytona/src/plugin.test.ts`: adds tests covering the new defaults and that explicit values / disabling sentinels (`0`, `-1`) are respected. ## Verification ``` pnpm --filter @paperclipai/plugin-daytona test ``` - 24 tests pass, including the new default-behavior tests. - Confirmed an explicit `autoArchiveInterval: 0` / `autoDeleteInterval: -1` in config is still forwarded unchanged (defaults only apply when the field is absent). ## Risks Low risk. Behavior change is limited to sandboxes created with no explicit interval config: they now auto-stop/archive/delete on Daytona-managed timers instead of Daytona's longer built-in defaults. Auto-archive is reversible (resuming an archived sandbox restores it). The only persistent action is auto-delete, which is a 7-day backstop for sandboxes nobody resumes, matching prior intent. Any environment that needs long-lived warm sandboxes can override the intervals (including disabling them with `0`/`-1`). ## Model Used Claude Opus (claude-opus-4-8), via the Paperclip agent harness with tool use. Extended reasoning enabled. ## 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 - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --- .../sandbox-providers/daytona/src/manifest.ts | 11 ++- .../daytona/src/plugin.test.ts | 72 +++++++++++++++++++ .../sandbox-providers/daytona/src/plugin.ts | 21 +++++- 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index c78ef2c00f..d1ebbc7096 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -84,16 +84,21 @@ const manifest: PaperclipPluginManifestV1 = { }, autoStopInterval: { type: "number", - description: "Optional Daytona auto-stop interval in minutes. `0` disables auto-stop.", + description: + "Daytona auto-stop interval in minutes. `0` disables auto-stop. Defaults to 15 when unset.", + default: 15, }, autoArchiveInterval: { type: "number", - description: "Optional Daytona auto-archive interval in minutes. `0` uses Daytona's max interval.", + description: + "Daytona auto-archive interval in minutes. Stopped sandboxes still count against the storage quota until archived, so this defaults to 60 when unset. `0` uses Daytona's max interval.", + default: 60, }, autoDeleteInterval: { type: "number", description: - "Optional Daytona auto-delete interval in minutes. `-1` disables auto-delete and `0` deletes immediately after stop.", + "Daytona auto-delete interval in minutes. Backstop reaper for sandboxes nobody resumes; defaults to 10080 (7 days) when unset. `-1` disables auto-delete and `0` deletes immediately after stop.", + default: 10080, }, reuseLease: { type: "boolean", diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index f594a07e27..fd8a3d24d9 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -115,6 +115,78 @@ describe("Daytona sandbox provider plugin", () => { }); }); + it("applies quota-safety auto-stop/archive/delete defaults when unset", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + + const result = await plugin.definition.onEnvironmentValidateConfig?.({ + driverKey: "daytona", + config: { + snapshot: "base-snapshot", + timeoutMs: 300000, + reuseLease: true, + }, + }); + + expect(result).toMatchObject({ + ok: true, + normalizedConfig: { + autoStopInterval: 15, + autoArchiveInterval: 60, + autoDeleteInterval: 10080, + }, + }); + }); + + it("preserves an explicit 0/-1 to disable auto intervals", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + + const result = await plugin.definition.onEnvironmentValidateConfig?.({ + driverKey: "daytona", + config: { + snapshot: "base-snapshot", + timeoutMs: 300000, + autoStopInterval: 0, + autoArchiveInterval: 0, + autoDeleteInterval: -1, + reuseLease: true, + }, + }); + + expect(result).toMatchObject({ + ok: true, + normalizedConfig: { + autoStopInterval: 0, + autoArchiveInterval: 0, + autoDeleteInterval: -1, + }, + }); + }); + + it("forwards auto-archive/auto-delete defaults to the Daytona create call", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox(); + mockCreate.mockResolvedValue(sandbox); + + await plugin.definition.onEnvironmentAcquireLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + runId: "run-1", + config: { + image: "node:20", + timeoutMs: 300000, + reuseLease: false, + }, + }); + + const [createParams] = mockCreate.mock.calls[0] as [Record]; + expect(createParams).toMatchObject({ + autoStopInterval: 15, + autoArchiveInterval: 60, + autoDeleteInterval: 10080, + }); + }); + it("rejects ambiguous or invalid config", async () => { await expect(plugin.definition.onEnvironmentValidateConfig?.({ driverKey: "daytona", diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index fb367debe2..b004c05195 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -52,6 +52,21 @@ type WorkspaceSentinelResult = { const WORKSPACE_SENTINEL_RELATIVE_PATH = ".paperclip-runtime/reusable-sandbox-lease.json"; +// Quota-safety defaults (minutes). Daytona counts *stopped* sandboxes against +// the storage quota; only *archived* sandboxes move to cold object storage and +// stop counting. Without these, stopped/leaked sandboxes accumulate until the +// org quota fills. We apply sane defaults so every sandbox eventually leaves the +// quota on its own even when our own cleanup fails or never runs (crashed runs, +// failed lease destroys, orphaned probes). All three stay overridable per +// environment; an explicit 0/-1 in config is preserved. +// +// - autoStop: stop idle *running* sandboxes (frees CPU/RAM, starts the archive clock). +// - autoArchive: archive *stopped* sandboxes so they leave the disk quota. +// - autoDelete: backstop reaper for sandboxes nobody resumes. +const DEFAULT_AUTO_STOP_INTERVAL_MINUTES = 15; +const DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES = 60; +const DEFAULT_AUTO_DELETE_INTERVAL_MINUTES = 7 * 24 * 60; // 7 days + function parseOptionalString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -82,9 +97,9 @@ function parseDriverConfig(raw: Record): DaytonaDriverConfig { memory: parseOptionalNumber(raw.memory), disk: parseOptionalNumber(raw.disk), gpu: parseOptionalNumber(raw.gpu), - autoStopInterval: parseOptionalInteger(raw.autoStopInterval), - autoArchiveInterval: parseOptionalInteger(raw.autoArchiveInterval), - autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval), + autoStopInterval: parseOptionalInteger(raw.autoStopInterval) ?? DEFAULT_AUTO_STOP_INTERVAL_MINUTES, + autoArchiveInterval: parseOptionalInteger(raw.autoArchiveInterval) ?? DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES, + autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval) ?? DEFAULT_AUTO_DELETE_INTERVAL_MINUTES, reuseLease: raw.reuseLease === true, }; }