Skip to content

fix(agent): stop Daytona sandbox leak (credit-burner) - #4856

Merged
mmabrouk merged 1 commit into
big-agentsfrom
fix/agent-daytona-sandbox-leak
Jun 25, 2026
Merged

fix(agent): stop Daytona sandbox leak (credit-burner)#4856
mmabrouk merged 1 commit into
big-agentsfrom
fix/agent-daytona-sandbox-leak

Conversation

@mmabrouk

Copy link
Copy Markdown
Member

The leak

Daytona sandboxes the runner created were leaking and burning credit. 10 were found alive long after their runs ended.

The per-run teardown (finally in runSandboxAgent) deletes the sandbox on every normal / error / client-disconnect path — that part works. But a process KILL (docker stop / SIGTERM / SIGKILL / OOM mid-run) skips the finally entirely, so the sandbox is never deleted.

There was also no server-side backstop. The sandbox-agent SDK wrapper hardcodes autoStopInterval: 0 (auto-stop OFF), while ephemeral: true only auto-deletes a sandbox ON STOP. Those two cancel out: a sandbox that never stops is never auto-deleted, so a leaked one self-reaps never.

The fix (runner-only, two-part backstop)

1. Server-side TTL backstop — provider.ts. The Daytona create object now sets a non-zero autoStopInterval alongside the existing ephemeral: true. The wrapper spreads our create object after its autoStopInterval: 0 hardcode, so our value wins. With auto-stop > 0, an idle leaked sandbox stops on its own, which then triggers the ephemeral auto-delete — it self-reaps. Configurable via a new SANDBOX_AGENT_DAYTONA_AUTOSTOP_MINUTES (default 15 min — the Daytona SDK's own documented default; clamped to >= 1 so a 0 cannot re-disable auto-stop and reintroduce the leak). autoStopInterval measures idle time and an actively prompting sandbox is busy, so this does not cut live runs short. Extracted buildDaytonaCreate so the create object is unit-testable (the real daytona() provider closes over it).

2. Shutdown signal handler — server.ts. registerShutdownHandler deletes any in-flight sandbox(es) on SIGTERM / SIGINT before exit, so a graceful docker stop cleans up immediately instead of waiting on the auto-stop. It drains a new in-flight registry in sandbox_agent.ts (destroyInFlightSandboxes; sandboxes register after startSandboxAgent, deregister in the finally). The handler is timeout-bounded (5 s race) and idempotent against a repeated signal, so it can never hang shutdown — and the auto-stop backstop covers the SIGKILL/OOM cases a signal can never reach.

Resources (cpu/mem/disk) are left untouched — they are snapshot-baked in build_snapshot.py. No /run wire change.

Tests

  • daytonaAutoStopMinutes env parsing: env value, fractional floor, unset/non-numeric/0/negative fall back to the default.
  • buildDaytonaCreate carries a positive autoStopInterval + ephemeral (default and env-configured).
  • registerShutdownHandler: registers a listener per signal, runs cleanup then exits, still exits when cleanup rejects, and cleans up only once on a repeated signal.
  • Full runner suite green: 265 tests + tsc.

https://claude.ai/code/session_01GYo3UEfvsZpncagqb28Mbc

A process KILL (docker stop/SIGTERM/SIGKILL/OOM) mid-run skips the per-run
`finally` that deletes the sandbox, so it leaks. There was also no server-side
backstop: the SDK wrapper hardcodes `autoStopInterval: 0` (auto-stop OFF) while
`ephemeral: true` only auto-deletes ON STOP, so they cancel out and a leaked
sandbox never self-reaps.

- provider.ts: set a non-zero `autoStopInterval` on the Daytona create object
  (env `SANDBOX_AGENT_DAYTONA_AUTOSTOP_MINUTES`, default 15, clamped >= 1). With
  auto-stop > 0 the ephemeral auto-delete fires, so a leaked sandbox self-reaps.
  Extracted `buildDaytonaCreate` so the create object is unit-testable.
- server.ts: `registerShutdownHandler` deletes in-flight sandbox(es) on
  SIGTERM/SIGINT before exit; timeout-bounded + idempotent so it cannot hang
  shutdown.
- sandbox_agent.ts: in-flight sandbox registry (`destroyInFlightSandboxes`) the
  handler drains; register after start, remove in the `finally`.

Resources (cpu/mem/disk) left untouched (snapshot-baked). No /run wire change.

Claude-Session: https://claude.ai/code/session_01GYo3UEfvsZpncagqb28Mbc
@mmabrouk

Copy link
Copy Markdown
Member Author

@coderabbitai review

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jun 25, 2026
@vercel

vercel Bot commented Jun 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview, Comment Jun 25, 2026 9:15pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added automatic cleanup for in-flight sandbox runs during shutdown signals.
    • Added support for configuring Daytona sandbox auto-stop timing through an environment setting.
  • Bug Fixes

    • Improved shutdown handling so sandbox cleanup still runs when a process exits unexpectedly.
    • Prevented auto-stop from being disabled by invalid or too-small values.
    • Added safeguards to avoid double-cleanup and reduce leftover sandbox resource usage.

Walkthrough

Adds Daytona sandbox auto-stop configuration via a new environment variable, documents the setting, and adds shutdown handling that tracks in-flight sandboxes and destroys them on SIGTERM/SIGINT.

Changes

Sandbox auto-stop and shutdown cleanup

Layer / File(s) Summary
Daytona auto-stop configuration and docs
services/agent/src/engines/sandbox_agent/provider.ts, services/agent/tests/unit/sandbox-agent-provider.test.ts, docs/design/agent-workflows/documentation/running-the-agent.md
buildDaytonaCreate() now sets autoStopInterval from SANDBOX_AGENT_DAYTONA_AUTOSTOP_MINUTES, and tests plus docs cover the parser and default behavior.
In-flight sandbox shutdown cleanup
services/agent/src/engines/sandbox_agent.ts, services/agent/src/server.ts, services/agent/tests/unit/server.test.ts
In-flight sandboxes are tracked, destroyInFlightSandboxes() is exported, registerShutdownHandler() installs signal cleanup, and tests cover the shutdown flow.

Sequence Diagram(s)

sequenceDiagram
  participant Process
  participant registerShutdownHandler
  participant destroyInFlightSandboxes
  participant SandboxHandle
  participant exit
  Process->>registerShutdownHandler: SIGTERM / SIGINT
  registerShutdownHandler->>destroyInFlightSandboxes: onCleanup(timeoutMs)
  destroyInFlightSandboxes->>SandboxHandle: destroySandbox()
  registerShutdownHandler->>exit: exit(0)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing Daytona sandbox leaks in the agent.
Description check ✅ Passed The description is directly related to the changeset and accurately describes the leak fix, shutdown cleanup, and tests.
Docstring Coverage ✅ Passed Docstring coverage is 77.78% which is sufficient. The required threshold is 60.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/agent-daytona-sandbox-leak

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@dosubot dosubot Bot added Backend bug Something isn't working typescript labels Jun 25, 2026
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mmabrouk

Copy link
Copy Markdown
Member Author

Review ask — please focus on:

  1. Is the auto-stop backstop sound? The sandbox-agent daytona wrapper hardcodes autoStopInterval: 0 then spreads our create object AFTER it (node_modules/sandbox-agent/dist/providers/daytona.js), so our non-zero value overrides it. The Daytona SDK doc (@daytonaio/sdk Daytona.d.ts) confirms autoStopInterval is in minutes (0 = disabled) and ephemeral: true sets autoDeleteInterval = 0 (delete on stop). Confirm the pairing self-reaps a leaked, idle sandbox and that 15 min idle is a safe default (does any legitimate run idle longer than 15 min between activity? parked HITL turns are the only candidate — SANDBOX_AGENT_DAYTONA_AUTOSTOP_MINUTES is the escape hatch).
  2. Signal handler shutdown-safety. registerShutdownHandler races destroyInFlightSandboxes (5 s) against a timeout and exits 0 regardless. Is exit-0-on-cleanup-failure right, or should a failed cleanup exit non-zero? Is the in-flight registry (a module-level Set in sandbox_agent.ts) the right place, and is the register-after-start / delete-in-finally pairing leak-free?
  3. Concurrency. Multiple concurrent runs each add their sandbox to the shared registry; the handler Promise.allSettleds all of them. Any race between a run's own finally delete and the handler's delete (both call destroySandbox, both swallow errors — should be benign double-delete).

Runner-only, no /run wire change. NOT to be merged yet — human review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 07440e73-8a62-44ce-9213-2ed41293a4e7

📥 Commits

Reviewing files that changed from the base of the PR and between 6324757 and 402fdcb.

📒 Files selected for processing (6)
  • docs/design/agent-workflows/documentation/running-the-agent.md
  • services/agent/src/engines/sandbox_agent.ts
  • services/agent/src/engines/sandbox_agent/provider.ts
  • services/agent/src/server.ts
  • services/agent/tests/unit/sandbox-agent-provider.test.ts
  • services/agent/tests/unit/server.test.ts

Comment on lines +110 to +113
const sweep = Promise.allSettled(
pending.map((sandbox) =>
Promise.resolve(sandbox.destroySandbox?.()).catch(() => {}),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
node <<'NODE'
const sandboxes = [
  { destroySandbox() { throw new Error("boom"); } },
  { destroySandbox() { console.log("second sandbox reached"); return Promise.resolve(); } },
];

try {
  Promise.allSettled(
    sandboxes.map((sandbox) =>
      Promise.resolve(sandbox.destroySandbox?.()).catch(() => {})
    )
  );
  console.log("allSettled created");
} catch (err) {
  console.log("sync throw aborted sweep:", err.message);
}
NODE

Repository: Agenta-AI/agenta

Length of output: 185


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant implementation around the cited lines and adjacent call sites.
file="services/agent/src/engines/sandbox_agent.ts"

echo "== outline =="
ast-grep outline "$file" --view expanded || true

echo
echo "== relevant lines =="
sed -n '95,125p' "$file" | cat -n

echo
echo "== call sites for destroyInFlightSandboxes =="
rg -n "destroyInFlightSandboxes|destroySandbox\\?\\(" services/agent/src -n

Repository: Agenta-AI/agenta

Length of output: 3066


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant implementation around the cited lines and adjacent call sites.
file="services/agent/src/engines/sandbox_agent.ts"

echo "== outline =="
ast-grep outline "$file" --view expanded || true

echo
echo "== relevant lines =="
sed -n '95,125p' "$file" | cat -n

echo
echo "== call sites for destroyInFlightSandboxes =="
rg -n "destroyInFlightSandboxes|destroySandbox\\?\\(" services/agent/src -n

Repository: Agenta-AI/agenta

Length of output: 3066


Make each destroy call exception-safe before Promise.allSettled()
Promise.resolve(sandbox.destroySandbox?.()) still invokes destroySandbox() immediately. If one sandbox throws synchronously, map() aborts and the remaining in-flight sandboxes are never attempted.

Suggested fix
   const sweep = Promise.allSettled(
     pending.map((sandbox) =>
-      Promise.resolve(sandbox.destroySandbox?.()).catch(() => {}),
+      Promise.resolve()
+        .then(() => sandbox.destroySandbox?.())
+        .catch(() => {}),
     ),
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const sweep = Promise.allSettled(
pending.map((sandbox) =>
Promise.resolve(sandbox.destroySandbox?.()).catch(() => {}),
),
const sweep = Promise.allSettled(
pending.map((sandbox) =>
Promise.resolve()
.then(() => sandbox.destroySandbox?.())
.catch(() => {}),
),

Comment on lines +238 to +240
void onCleanup()
.catch(() => {})
.finally(() => exit(0));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
node <<'NODE'
function run(onCleanup, exit) {
  try {
    onCleanup().catch(() => {}).finally(() => exit(0));
  } catch (err) {
    console.log("sync throw bypassed catch/finally:", err.message);
  }
}
run(() => { throw new Error("boom"); }, (code) => console.log("exit", code));
NODE

Repository: Agenta-AI/agenta

Length of output: 194


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== server.ts around shutdown handler ==\n'
sed -n '200,280p' services/agent/src/server.ts

printf '\n== registerShutdownHandler references ==\n'
rg -n "registerShutdownHandler|onCleanup" services/agent/src/server.ts services/agent/src -g '!**/node_modules/**'

Repository: Agenta-AI/agenta

Length of output: 4417


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== server.ts around shutdown handler ==\n'
sed -n '200,280p' services/agent/src/server.ts

printf '\n== registerShutdownHandler references ==\n'
rg -n "registerShutdownHandler|onCleanup" services/agent/src/server.ts services/agent/src -g '!**/node_modules/**'

Repository: Agenta-AI/agenta

Length of output: 4417


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== server.ts around shutdown handler ==\n'
sed -n '200,280p' services/agent/src/server.ts

printf '\n== registerShutdownHandler references ==\n'
rg -n "registerShutdownHandler|onCleanup" services/agent/src/server.ts services/agent/src -g '!**/node_modules/**'

Repository: Agenta-AI/agenta

Length of output: 4417


Normalize onCleanup before chaining the exit path.

onCleanup().catch(...).finally(...) only handles returned promises. A synchronous throw from a custom cleanup callback skips exit(0), so wrap the call in Promise.resolve() first.

Suggested fix
-    void onCleanup()
+    void Promise.resolve()
+      .then(() => onCleanup())
       .catch(() => {})
       .finally(() => exit(0));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void onCleanup()
.catch(() => {})
.finally(() => exit(0));
void Promise.resolve()
.then(() => onCleanup())
.catch(() => {})
.finally(() => exit(0));

@mmabrouk
mmabrouk merged commit b208bba into big-agents Jun 25, 2026
42 of 44 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend bug Something isn't working size:L This PR changes 100-499 lines, ignoring generated files. typescript

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant