fix: default Daytona sandboxes to auto-archive so they leave the disk quota (#8561)
## 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
This commit is contained in:
parent
ea1e321d86
commit
b3209486ca
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>];
|
||||
expect(createParams).toMatchObject({
|
||||
autoStopInterval: 15,
|
||||
autoArchiveInterval: 60,
|
||||
autoDeleteInterval: 10080,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects ambiguous or invalid config", async () => {
|
||||
await expect(plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "daytona",
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): 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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue