From 4b0403ffe2a1f4cf79962d8a3fa64f6cfefd89a5 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:20:13 +0000 Subject: [PATCH] fix: reap orphaned Daytona sandboxes from the sync media-fetch path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External API callers (e.g. a Hermes playbook step) that hit crayo.run_autoclip without an AgentRun runId fall into the older, synchronous fetchPageVideoToCrayoAsset() path instead of the tracked background job. That path's `finally { sandbox.delete() }` never runs if the caller's serverless function is killed on timeout rather than throwing, so a failing/retrying external caller can leak one Daytona sandbox per attempt well before its own 10-minute autoStopInterval catches up — and a burst of these can exhaust the account's sandbox concurrency limit, stalling unrelated, properly-tracked AutoClip runs (including ones started from the Agent tab UI). Add reapStaleMediaFetchSandboxes(), wired into the existing /api/cron/ops backstop (runs every 15 min), to force-stop/delete sandboxes labeled purpose=media-fetch older than 8 minutes. Scoped only to that label so it never touches media-fetch-job sandboxes, which are already tracked by tickMediaFetchJob/abortMediaFetchJob and have their own 60-minute autoStopInterval. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LYohqvCYqttwGukV8MDRYV --- src/lib/server/media-fetch-job.server.ts | 45 ++++++++++++++++++++++++ src/routes/api/cron/ops.ts | 8 +++++ 2 files changed, 53 insertions(+) diff --git a/src/lib/server/media-fetch-job.server.ts b/src/lib/server/media-fetch-job.server.ts index 9232e91..3f5a637 100644 --- a/src/lib/server/media-fetch-job.server.ts +++ b/src/lib/server/media-fetch-job.server.ts @@ -521,3 +521,48 @@ export async function abortMediaFetchJob(outputs: Record | nu const state = readState(outputs); if (state?.sandboxId) await deleteSandbox(state.sandboxId); } + + +/** + * Reap orphaned Daytona sandboxes from the synchronous (non-job) media-fetch path used by + * external API callers (see media-fetch.server.ts). That path has no AgentRun tracking it, and + * its `finally { sandbox.delete() }` never runs if the caller's Vercel function is killed on + * timeout instead of throwing — so a failing/retrying external caller (e.g. a Hermes playbook + * step) can leak one sandbox per attempt well before Daytona's own 10-minute autoStopInterval + * catches up, and a burst of leaked sandboxes can exhaust the account's concurrency limit and + * stall unrelated, properly-tracked runs. Called from /api/cron/ops as a backstop. + * + * Only targets `purpose: media-fetch` sandboxes (the untracked sync path). `media-fetch-job` + * sandboxes are already tracked by tickMediaFetchJob/abortMediaFetchJob and have their own + * 60-minute autoStopInterval, so reaping them here would risk killing a run still legitimately + * in progress. + */ +export async function reapStaleMediaFetchSandboxes(maxAgeMinutes = 8): Promise { + let daytona: Awaited>["daytona"]; + try { + ({ daytona } = await daytonaClient()); + } catch { + return 0; + } + const cutoff = Date.now() - maxAgeMinutes * 60_000; + let reaped = 0; + try { + const iter = daytona.list({ labels: { purpose: "media-fetch", app: "clippyos" }, limit: 20 }); + for await (const sandbox of iter as AsyncIterable) { + const state = String(sandbox.state ?? "").toLowerCase(); + if (state === "stopped" || state === "destroyed" || state === "destroying" || state === "archived") continue; + const createdAt = Date.parse(String(sandbox.createdAt ?? "")); + if (!Number.isFinite(createdAt) || createdAt > cutoff) continue; + try { + if (sandbox.delete) await sandbox.delete(); + else if (sandbox.stop) await sandbox.stop(); + reaped += 1; + } catch { + /* best-effort; auto-stop still applies */ + } + } + } catch { + /* Daytona list unavailable this tick; try again next cron run */ + } + return reaped; +} diff --git a/src/routes/api/cron/ops.ts b/src/routes/api/cron/ops.ts index 1cda074..e4767cd 100644 --- a/src/routes/api/cron/ops.ts +++ b/src/routes/api/cron/ops.ts @@ -35,6 +35,13 @@ export const Route = createFileRoute("/api/cron/ops")({ } catch { mediaFetchTicked = 0; } + let mediaFetchSandboxesReaped = 0; + try { + const { reapStaleMediaFetchSandboxes } = await import("@/lib/server/media-fetch-job.server"); + mediaFetchSandboxesReaped = await reapStaleMediaFetchSandboxes(); + } catch { + mediaFetchSandboxesReaped = 0; + } let machineState = "unknown"; try { const { getSocialMachineStatus } = await import("@/lib/server/daytona.server"); @@ -47,6 +54,7 @@ export const Route = createFileRoute("/api/cron/ops")({ ok: true, linearSwept, mediaFetchTicked, + mediaFetchSandboxesReaped, machineState, startedMachine: false, note: "Cron never starts the Social Machine. Idle pause is owned by Daytona. Hibernate is pause, not destroy.",