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:
Devin Foley 2026-06-23 13:45:54 -07:00 committed by GitHub
parent ea1e321d86
commit b3209486ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 98 additions and 6 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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,
};
}