Skip to content

[feat] Reuse Daytona sandboxes across turns instead of deleting them every turn#5225

Merged
mmabrouk merged 5 commits into
big-agentsfrom
feat/warm-daytona-sessions
Jul 11, 2026
Merged

[feat] Reuse Daytona sandboxes across turns instead of deleting them every turn#5225
mmabrouk merged 5 commits into
big-agentsfrom
feat/warm-daytona-sessions

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Jul 11, 2026

Copy link
Copy Markdown
Member

Context

Every turn of a Daytona-backed agent conversation waited about 15 seconds, because the runner
deleted the sandbox at every turn end and rebuilt everything on the next turn. The warm-reuse
plumbing already existed (park at turn end, a stored sandbox id, native session reload), but
the Daytona provider had no pause or reconnect function, so "park" silently fell through to
delete. The plan and measurements behind this change are in PR #5214.

What this adds

The two reuse levels from the plan, plus the correctness work that makes reuse safe:

  • Park-to-running. After a clean turn, the sandbox stays running with its live harness
    session for a short window (AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS, default 120000 ms;
    0 disables it, there is no separate flag). A second turn inside the window continues the
    live session. Measured on the dev stack: 1.39 s against 12.5 s cold.
  • Park-to-stopped. When the window expires (or the warm cap is full, or the runner drains
    on SIGTERM while idle), the sandbox is stopped, not deleted. The next turn restarts the
    same instance and reloads the harness session natively. Measured: 7.7 s against 12.5 s
    cold, because session/load (1.2 s) replaces the 5.2 s session create.
  • A hard cap on idle warm sandboxes (AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM, default
    20). It bounds idle spend only; an active turn is never blocked. On overflow a finishing
    turn parks to stopped. The Daytona pool frees a warm slot only after a stop is confirmed,
    so an in-flight stop cannot let the billed count overshoot.

The correctness base underneath:

  • The vendored Daytona provider gets real pause and reconnect functions (a runner-side
    wrapper over sandbox-agent): stop only from a running state, wait out Daytona's
    transitional states with a bound, resume stopped or archived instances, and fail cleanly on
    error states. Two teardown bugs in the vendored package are fixed in the package patch: a
    failed pause used to clear the provider handle so the delete fallback silently did nothing
    (a leaked billed sandbox), and a reconnected sandbox that failed later attach steps leaked.
  • The sandbox pointer (session_states.sandbox_id) is trusted. A resumed turn reconnects the
    parked instance by id. Snapshot, image, and target drift is accepted as per-conversation
    version pinning, like a rolling deploy: an old parked sandbox keeps serving its conversation
    until an idle gap hits the delete ladder.
  • Network policy is converged at reconnect. The reconnect step reads the live sandbox's
    networkBlockAll and networkAllowList and calls Daytona's updateNetworkSettings only
    when they differ from the run's plan. That call applies the same runner-side iptables
    mechanism as create, so a parked sandbox picks up a policy change without a rebuild. A failed
    convergence logs and leaves the prior policy rather than aborting the reconnect.
  • The pointer write is awaited and guarded. The API applies it only when the writer's turn
    index is not older than the row's latest_turn_index (a compare-and-set; tokenless writes
    keep today's behavior). The guard uses only pre-existing columns, so this feature adds no
    migration.
  • Teardown takes a typed reason instead of a keepWarm boolean. Kill, failed, and aborted
    delete; a clean resumable turn and idle shutdown stop; in-flight shutdown deletes. The
    keepalive pool passes its eviction reason through the same mapping.
  • Auto-archive is removed entirely (create field, env override, compose and helm forwarding):
    restoring from archive measured slower than creating fresh. The ladder is stop after 15
    idle minutes (was 5), delete after 30.
  • One SessionPool per provider. Local behavior and env vars are unchanged; the local pool
    keeps its fire-and-forget eviction, the Daytona pool gets strict awaited capacity.
  • Acquire now logs per-stage [timing] lines (create, install, mounts, workspace, probe,
    session), so the next latency investigation reads the logs instead of hand-instrumenting.

Environment sync is deferred

Per-turn environment-variable delivery and value rotation are out of scope here. That is
per-turn delivery work and follows the daytona-secret-delivery direction (#5223). Create-time
env baking is unchanged in this PR.

Assumptions from the two open plan questions

  • Pointer-write guard: compare-and-set on the existing latest_turn_index turn counter (the
    plan's default wording; no schema migration). Two truly concurrent turns of the same
    conversation carry the same index and still race; the guard closes the older-write-lands-last
    window. The Redis owner claim remains the stronger future mechanism.
  • Shutdown split: delete when a turn is in flight, stop when idle, /kill stays a hard
    delete (the plan's proposal).

Tests

  • Runner unit suite: 877 passing (vitest).
  • API: session_states acceptance tests cover the guarded write (apply at latest turn, stale
    reject, tokenless unconditional, missing-row create).
  • Network convergence verified against a live Daytona sandbox: updateNetworkSettings took
    effect on a running sandbox (an allow-list blocked a public curl, then an open update
    restored it), the settings survived a stop and restart, and a second update on the restarted
    sandbox re-applied the block. Created 1 sandbox, deleted 1, final count 0.
  • Live E3 verification on the dev stack (one credit-controlled pass, zero sandbox leaks,
    leftovers deleted, final Daytona sandbox count 0): cold 12.5 s, live-warm 1.39 s,
    stopped-restart 7.7 s; one instance served all three turns; window expiry observed as a
    stop; SIGTERM drain observed stopping the idle parked sandbox; guarded pointer writes
    observed applied.
  • QA smoke: run_matrix.py smoke_chat_pi PASS on E2 local and E3 daytona after the change.

What to QA

  • Playground, Daytona sandbox: send two chat turns in one conversation within two minutes.
    The second reply should arrive in about 1 to 3 seconds instead of 10 or more.
  • Wait three minutes after a turn, send another. It should take about 7 to 8 seconds (the
    stopped restart) and the conversation context should be intact.
  • Regression: local-sandbox conversations behave exactly as before (same env vars, same
    latency); /kill still deletes the sandbox immediately.

Session: https://claude.ai/code/session_018MaXPNpvzN22kngHno3VMj

https://claude.ai/code/session_018MaXPNpvzN22kngHno3VMj

mmabrouk added 2 commits July 11, 2026 17:16
…t, pointer guard, typed teardown

Slice 1 of the warm-daytona-sessions plan (PR #5214): the correctness base,
with parking still inert (every teardown reason still deletes).

- daytona provider wrapper adds pause/reconnect with a bounded state machine
  over Daytona transitional states; deleteSandbox helper; create-spec
  fingerprint (snapshot/image/target/env NAMES/network policy).
- sandbox-agent package patch: a failed pause keeps the provider handles so
  the delete fallback works; a reconnected sandbox that fails later attach
  steps is paused, not leaked.
- sessions API: nullable session_states.sandbox_fingerprint column; the
  sandbox pointer write is guarded by a compare-and-set on
  data.latest_turn_index (sandbox_turn_index token; tokenless writes keep
  today's behavior).
- runner: pointer write is awaited, carries the fingerprint and guard token,
  and moved after continuity hydrate so a cold restart sends the right token;
  reconnect only on fingerprint match, best-effort delete on mismatch.
- typed teardown reasons replace the keepWarm boolean; reason-to-disposition
  mapping with PARK_CLEAN_RESUMABLE_TURNS=false (Slice 2 flips it).

Claude-Session: https://claude.ai/code/session_018MaXPNpvzN22kngHno3VMj
…ovider pools, park-to-running, verified live

Completes the warm-daytona-sessions plan (PR #5214) on top of the slice-1
correctness base.

- Slice 2 (park-to-stopped active): clean resumable Daytona turns and idle
  shutdown stop the sandbox instead of deleting it; stop timer default 15
  minutes; auto-archive removed entirely (create field, env override, compose
  and helm forwarding); terminal-vs-transient reconnect classification (only
  not-found/error/destroyed/unknown clear the stored pointer, guarded); clean
  remount before every remote mount so a reattached sandbox never serves a
  stale FUSE mount.
- Slice 3 (provider-aware pools): one SessionPool per provider; local env vars
  and behavior unchanged; Daytona configured by
  AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS (0 disables, no separate flag) and
  AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM (default 20); pool evictions carry
  typed teardown reasons; unknown providers fail closed to cold.
- Slice 4 (park-to-running warm slots): strict awaited capacity for the
  Daytona pool (a stopping sandbox keeps its warm slot until the stop
  confirms, so the billed count cannot overshoot); overflow parks to stopped;
  one-time activity refresh at live park, no recurring pings.
- Slice 5: per-stage [timing] log lines in acquire; Daytona live-window
  default 120000 ms after the E3 gate passed; the TTL env parse accepts 0 as
  a valid off switch (a positive-only parse would silently re-enable the
  default).
- Docs and config: the two new env vars forwarded in compose, env examples,
  and helm; self-host pages updated to the three-state lifecycle.
- Live E3 verification: cold 12.5 s, live-warm 1.39 s, stopped-restart 7.7 s;
  one instance served all three turns; expiry observed as stop; SIGTERM drain
  observed as stop; zero sandbox leaks.

Requires alembic migration core_oss oss000000011 (nullable
session_states.sandbox_fingerprint).

Carries shared-file hunks from the unmerged lanes feat/keepalive-project-scope
(protocol.ts runContext scope and pool key preference) and
fix/sessions-continuity-review (models.py replica_id validation, mdx edits)
per the coordination board's shared-file rule.

Claude-Session: https://claude.ai/code/session_018MaXPNpvzN22kngHno3VMj
@vercel

vercel Bot commented Jul 11, 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 Jul 11, 2026 7:53pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bce2419c-6916-45af-89e5-838a038664b5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Warm Daytona sessions now persist sandbox fingerprints and guarded turn pointers, reconnect compatible sandboxes, apply reason-aware stop/delete policies, route keep-alive sessions by provider, and expose matching runtime configuration, documentation, and tests.

Changes

Session state persistence

Layer / File(s) Summary
Guarded pointer persistence
api/oss/..., api/oss/tests/...
Session state accepts and stores sandbox_fingerprint and sandbox_turn_index; PostgreSQL upserts reject stale pointer writes while preserving existing state.
Daytona lifecycle and reconnect
services/runner/src/engines/sandbox_agent/*
Daytona sandboxes use lifecycle-aware pause, reconnect, deletion, fingerprint matching, pointer persistence, and timing instrumentation.
Keep-alive orchestration
services/runner/src/engines/sandbox_agent/session-pool.ts, services/runner/src/server.ts, services/runner/src/protocol.ts
Provider-specific pools, project-based scoping, strict capacity, typed teardown reasons, and Daytona live-park handling are added.
Configuration and documentation
hosting/..., docs/...
Warm-session environment variables, Helm values, lifecycle documentation, and QA procedures are updated; auto-archive settings are removed.
Validation
services/runner/tests/unit/*
Tests cover pointer guards, reconnect ladders, lifecycle transitions, mount cleanup, pool capacity, teardown reasons, dispatch, and pause fallback behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Runner
  participant SessionPool
  participant SessionStateAPI
  participant Daytona
  Client->>Runner: Submit session turn
  Runner->>SessionPool: Resolve provider and session scope
  SessionPool->>SessionStateAPI: Read guarded sandbox pointer
  SessionStateAPI-->>SessionPool: Return sandbox id and fingerprint
  SessionPool->>Daytona: Reconnect or create sandbox
  Daytona-->>SessionPool: Return active sandbox
  SessionPool->>SessionStateAPI: Write pointer with turn index
  SessionPool-->>Runner: Resume or park session
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.76% which is insufficient. The required threshold is 60.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main change: reusing Daytona sandboxes across turns instead of deleting them each turn.
Description check ✅ Passed The description is detailed and directly describes the same Daytona warm-reuse, pointer, teardown, and migration changes in the PR.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/warm-daytona-sessions

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.

@mmabrouk mmabrouk added the needs-review Agent updated; awaiting Mahmoud's review label Jul 11, 2026

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 The AI agent says: Inline notes at each decision point follow, in the diff's reading order. Suggested reading order for the whole diff: 1) teardown.ts (the reason model, small), 2) daytona-provider.ts (the pause/reconnect state machine and the fingerprint), 3) the package patch (two vendored teardown fixes), 4) sandbox-reconnect.ts plus the sessions API (the guarded pointer), 5) sandbox_agent.ts (reconnect gate, pointer write placement), 6) session-pool.ts and server.ts (per-provider pools, strict capacity), 7) hosting and docs.

* The destroy path escalates a failed stop to delete.
*/

export type TeardownReason =

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Why a typed reason instead of the old keepWarm boolean: different endings need different dispositions (a /kill must delete, a clean turn should stop, an idle shutdown should stop, an in-flight shutdown must delete), and a boolean cannot say why the teardown happened. TS note: this union type works like a Python Literal enum; teardownDisposition is the single mapping from reason to stop-or-delete, so review of "can this path leave a sandbox running" happens in one place.

]);
const FAILED_STATES = new Set(["error", "destroyed"]);

export class DaytonaReconnectTerminalError extends Error {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 The terminal-versus-transient split the plan's reviewer asked for: not-found, error, destroyed, and unknown states throw this typed error and the engine clears the stored pointer (guarded). A timeout waiting out a transitional state, or any network failure, stays a plain error and the pointer is retained, so one flaky API call cannot force a permanent cold chain.

}

/** Hash only resolved create fields that determine whether a Daytona sandbox is reusable. */
export function createSpecFingerprint(create: Record<string, unknown>): string {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 The compatibility fingerprint hashes env NAMES only, never values. It deliberately does not reuse the live pool's configFingerprint: that one hashes request fields and misses operator-level settings (DAYTONA_SNAPSHOT, DAYTONA_IMAGE, DAYTONA_TARGET) that also invalidate a stopped sandbox.

async pauseSandbox() {
const provider = this.sandboxProvider;
const rawSandboxId = this.sandboxProviderRawId;
+ let paused = false;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 The gap-A fix in one sentence: the vendored pauseSandbox() used to clear the provider handles in a finally that ran even when the pause threw, so the follow-up delete silently did nothing and a RUNNING billed sandbox leaked. Now the handles are cleared only when paused is true. The patch was regenerated canonically with pnpm patch-commit and verified to apply against the pristine 0.4.2 tarball.

if "data" in upsert.model_fields_set:
update_values["data"] = stmt.excluded.data
if "sandbox_id" in upsert.model_fields_set:
guarded_pointer_write = (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 The pointer guard: when the writer supplies sandbox_turn_index, sandbox_id and sandbox_fingerprint update only under this CASE compare-and-set against the row's latest_turn_index; a tokenless write behaves exactly as before, so existing callers are untouched. SQL note: inside ON CONFLICT DO UPDATE, the table-qualified column refers to the EXISTING row and excluded to the proposed one. The insert path (no existing row) applies unconditionally.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

we removed sandbox_fingerprint is this still relevant

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The fingerprint is gone but this guard stays relevant: it is the turn-index compare-and-set on sandbox_id itself, the multi-runner safety mechanism. It uses only pre-existing columns. Commit 20dbabb removed the fingerprint branch below it.

: undefined;
if (sessionForMount && runCred) {
const liveSandboxId = environment.sandbox?.sandboxId ?? plan.sandboxId;
const pointerWriteOutcome = await (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 The pointer write moved from right-after-sandbox-start to right-after-continuity-hydrate, and it is now awaited. Reason: the guard token is the conversation's next turn index, and only the hydrated store knows it after a runner restart; the in-memory counter alone would say 0 and the guard would wrongly reject. Live verification showed applied on every turn.

* TTL uses this because 0 is its documented off switch; with a nonzero shipping default, a
* positive-only parse would silently turn "0" back into the default.
*/
function nonNegativeIntEnv(name: string, fallback: number): number {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 A real bug caught during the default flip: the existing positive-only env parse treats "0" as invalid and falls back to the default. With the default now 120000, setting the TTL to 0 would have silently re-enabled the feature. The Daytona TTL is the documented off switch, so it gets this non-negative parse where zero is a valid value.

constructor(
private readonly config: Pick<KeepaliveConfig, "poolMax">,
private readonly logger: (message: string) => void = log,
private readonly options: { strictCapacity?: boolean } = {},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 strictCapacity is the Daytona pool's billing-safety mode: an evicted entry keeps its map seat (state destroyed) until its teardown RESOLVES, so size() counts stopping sandboxes and a park that races an in-flight stop parks to stopped instead of overshooting the billed cap. The local pool keeps today's fire-and-forget eviction (pinned by a test); its cap budgets host memory, not money.

return provider === "daytona" ? "daytona" : undefined;
}

export function resolveKeepaliveDispatch(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 The dispatch fails closed: only the two known provider names route to a pool; anything else runs today's cold path. Local requests resolve exactly as before, with the same env vars, so a local-only deployment is byte-identical.

DAYTONA_IMAGE: ${DAYTONA_IMAGE:-}
DAYTONA_AUTOSTOP: ${DAYTONA_AUTOSTOP:-}
DAYTONA_AUTOARCHIVE: ${DAYTONA_AUTOARCHIVE:-}
AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 The two new knobs, forwarded wherever the DAYTONA_* timers already were (all compose files, env examples, helm): the live-window TTL (default 120000 ms, 0 disables) and the idle-warm cap (default 20). DAYTONA_AUTOARCHIVE is gone everywhere: the archive state is removed from the ladder.

@mmabrouk

Copy link
Copy Markdown
Member Author

🤖 The AI agent says: This implements the approved plan in PR #5214, all five slices. What you need to know before reading:

What ran. Runner unit suite green on the exact pushed tree (875 passing, typecheck clean; extracted the pushed commit into a clean directory and ran the gates there, so the published tree is what was tested). API session_states unit tests 14/14, ruff clean. One credit-controlled live E3 pass on the dev stack: cold turn 12.5 s, live-warm second turn 1.39 s, stopped-restart after the window 7.7 s, one instance serving all three turns, expiry and SIGTERM drain both observed as stops, zero sandbox leaks (counted before and after, leftovers deleted). QA smoke chat passes on E2 local and E3 daytona. Browser playground sanity passed.

What remains. The API acceptance tests for the guarded write need a live stack in CI. Cap-overflow behavior (more than 20 concurrent warm sessions) is unit-tested, not live-tested. Approval turns on Daytona keep taking the cold path until the F-018 gate plan lands, by design.

Deploy note. One migration is required (core_oss oss000000011). Deploying the code without it silently breaks sessions continuity; the body has the details.

Shared hunks. The worktree is shared with two unmerged lanes, so this diff carries their hunks per the coordination board's shared-file rule: feat/keepalive-project-scope (protocol.ts runContext scope, pool-key preference in server.ts/session-pool.ts) and fix/sessions-continuity-review (models.py replica_id validation, small mdx edits). Whichever PR merges second sees a smaller diff.

Two assumptions from the plan's open questions, both the plan's own proposals: the pointer guard compares against the existing latest_turn_index counter, and shutdown deletes in-flight turns while stopping idle ones (/kill still hard-deletes). Object on either and I will change it.

@mmabrouk

Copy link
Copy Markdown
Member Author

@coderabbitai review

@mmabrouk
mmabrouk marked this pull request as ready for review July 11, 2026 16:28
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. enhancement New feature or request labels Jul 11, 2026
@coderabbitai

coderabbitai Bot commented Jul 11, 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.

@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: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/design/agent-workflows/projects/qa/scripts/warm_daytona_probe.py (1)

1-131: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Ruff format check is failing on this file.

CI reports ruff format --check would reformat this file. Run ruff format docs/design/agent-workflows/projects/qa/scripts/warm_daytona_probe.py and commit the result.

Sources: Coding guidelines, Pipeline failures

🧹 Nitpick comments (2)
services/runner/src/engines/sandbox_agent/sandbox-reconnect.ts (1)

79-152: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider deduplicating writeSandboxPointer/clearSandboxPointer.

Both functions share the same PUT/error-handling/logging scaffolding, differing only in the request body and the outcome-comparison expression. Extracting a shared putPointer(sessionId, body, deps, isApplied) helper would reduce the risk of the two diverging on error handling later.

♻️ Sketch of a shared helper
+async function putPointer(
+  sessionId: string,
+  body: Record<string, unknown>,
+  deps: SandboxPointerDeps,
+  isApplied: (sandboxId: string | null | undefined) => boolean,
+  logPrefix: string,
+): Promise<SandboxPointerWriteOutcome> {
+  const log = deps.log ?? defaultLog;
+  const doFetch = deps.fetchImpl ?? fetch;
+  const base = deps.apiBase ?? apiBase();
+  try {
+    const res = await doFetch(
+      `${base}/sessions/states/?session_id=${encodeURIComponent(sessionId)}`,
+      {
+        method: "PUT",
+        headers: { "content-type": "application/json", authorization: deps.authorization },
+        body: JSON.stringify(body),
+      },
+    );
+    if (!res.ok) {
+      log(`${logPrefix} HTTP ${res.status} session=${sessionId}`);
+      return "failed";
+    }
+    const resBody = (await res.json()) as { session_state?: { sandbox_id?: string | null } | null };
+    return isApplied(resBody.session_state?.sandbox_id) ? "applied" : "rejected";
+  } catch (err) {
+    log(`${logPrefix} failed session=${sessionId}: ${String(err instanceof Error ? err.message : err).slice(0, 120)}`);
+    return "failed";
+  }
+}
services/runner/patches/sandbox-agent@0.4.2.patch (1)

97-125: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider logging when remote session load fails before falling back to replay.

The empty catch block at lines 116-117 silently swallows all loadRemoteSession errors and falls back to replay-based creation. While the fallback is correct by design, a debug log would help operators diagnose why native session reload isn't being used.

♻️ Suggested addition
       } catch {
+        // loadSession failed; fall back to replay-based recreation below
       }

Or with a log:

       } catch (err) {
+        process.stderr.write(`[sandbox-agent] loadSession failed for session ${existing.id}: ${String(err).slice(0, 200)}\n`);
       }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 129ce70d-9087-4e4c-9a5d-71f115b98f8a

📥 Commits

Reviewing files that changed from the base of the PR and between 9111c8b and 51b18fb.

⛔ Files ignored due to path filters (1)
  • services/runner/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (45)
  • api/oss/databases/postgres/migrations/core_oss/versions/oss000000011_add_session_state_sandbox_fingerprint.py
  • api/oss/src/apis/fastapi/sessions/models.py
  • api/oss/src/core/sessions/states/dtos.py
  • api/oss/src/dbs/postgres/sessions/states/dao.py
  • api/oss/src/dbs/postgres/sessions/states/dbes.py
  • api/oss/src/dbs/postgres/sessions/states/mappings.py
  • api/oss/tests/pytest/acceptance/session_states/test_session_states_basics.py
  • docs/design/agent-workflows/projects/qa/scripts/warm_daytona_probe.py
  • docs/design/agent-workflows/projects/warm-daytona-sessions/implementation-status.md
  • docs/design/agent-workflows/projects/warm-daytona-sessions/pr-body.md
  • docs/docs/self-host/02-configuration.mdx
  • docs/docs/self-host/guides/09-agent-daytona-sandboxes.mdx
  • hosting/docker-compose/ee/docker-compose.dev.yml
  • hosting/docker-compose/ee/docker-compose.gh.local.yml
  • hosting/docker-compose/ee/docker-compose.gh.yml
  • hosting/docker-compose/ee/env.ee.dev.example
  • hosting/docker-compose/ee/env.ee.gh.example
  • hosting/docker-compose/oss/docker-compose.dev.yml
  • hosting/docker-compose/oss/docker-compose.gh.local.yml
  • hosting/docker-compose/oss/docker-compose.gh.ssl.yml
  • hosting/docker-compose/oss/docker-compose.gh.yml
  • hosting/docker-compose/oss/env.oss.dev.example
  • hosting/docker-compose/oss/env.oss.gh.example
  • hosting/kubernetes/helm/templates/runner-deployment.yaml
  • hosting/kubernetes/helm/values.yaml
  • services/runner/patches/sandbox-agent@0.4.2.patch
  • services/runner/src/engines/sandbox_agent.ts
  • services/runner/src/engines/sandbox_agent/daytona-provider.ts
  • services/runner/src/engines/sandbox_agent/mount.ts
  • services/runner/src/engines/sandbox_agent/provider.ts
  • services/runner/src/engines/sandbox_agent/sandbox-reconnect.ts
  • services/runner/src/engines/sandbox_agent/session-pool.ts
  • services/runner/src/engines/sandbox_agent/teardown.ts
  • services/runner/src/protocol.ts
  • services/runner/src/server.ts
  • services/runner/tests/unit/daytona-provider.test.ts
  • services/runner/tests/unit/sandbox-agent-acp-interactions.test.ts
  • services/runner/tests/unit/sandbox-agent-mount.test.ts
  • services/runner/tests/unit/sandbox-agent-provider.test.ts
  • services/runner/tests/unit/sandbox-lifecycle.test.ts
  • services/runner/tests/unit/sandbox-reconnect.test.ts
  • services/runner/tests/unit/session-keepalive-dispatch.test.ts
  • services/runner/tests/unit/session-pool.test.ts
  • services/runner/tests/unit/teardown.test.ts
  • services/runner/tests/unit/vendored-pause-fallback.test.ts

Comment thread api/oss/src/dbs/postgres/sessions/states/dao.py Outdated
Comment thread services/runner/src/engines/sandbox_agent.ts
Comment thread services/runner/src/engines/sandbox_agent.ts Outdated
Comment thread services/runner/src/engines/sandbox_agent/daytona-provider.ts
Comment thread services/runner/src/server.ts
…connect

Trust the stored sandbox pointer instead of comparing a create-spec fingerprint.
A resumed Daytona turn reconnects the parked instance by id. Snapshot, image, and
target drift is accepted as per-conversation version pinning. Network policy is
converged at reconnect: read the live networkBlockAll/networkAllowList and call
updateNetworkSettings only when they differ from the run's plan. Per-turn env sync
is deferred to the daytona-secret-delivery direction (#5223).

Removes the session_states.sandbox_fingerprint column and migration oss000000011,
the createSpecFingerprint/buildResolvedDaytonaCreate helpers, and the fingerprint
fields on the pointer read/write. Keeps the turn-index compare-and-set guard on
sandbox_id, which uses only pre-existing columns.

Claude-Session: https://claude.ai/code/session_018MaXPNpvzN22kngHno3VMj
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ mmabrouk
❌ Mahmoud Mabrouk


Mahmoud Mabrouk seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@mmabrouk

Copy link
Copy Markdown
Member Author

Rework: dropped the sandbox fingerprint, converge network policy instead

This pushes one additive commit (20dbabb) that reworks the reconnect path per review.

The stored sandbox pointer is now trusted. A resumed turn reconnects the parked instance by id, with no create-spec fingerprint and no delete-and-rebuild compatibility check. The reasoning, field by field:

  • Snapshot, image, and target drift is accepted as per-conversation version pinning, like a rolling deploy. An old parked sandbox keeps serving its conversation until an idle gap hits the delete ladder. No detection needed.
  • Environment variables are handled by per-turn delivery, which is the daytona-secret-delivery direction (docs(agent-workflows): Plan Daytona secret delivery #5223), not by drift detection. That path also covers value rotation, which a name-only fingerprint never caught. Deferred to docs(agent-workflows): Plan Daytona secret delivery #5223; create-time env baking is unchanged here.
  • Network policy converges at reconnect instead of triggering a rebuild. The reconnect step reads the live sandbox's networkBlockAll/networkAllowList and calls updateNetworkSettings only when they differ from the run's plan.

Verified updateNetworkSettings against a live Daytona sandbox (SDK 0.187.0): an allow-list blocked a public curl, an open update restored it on the running sandbox, the settings survived a stop and restart, and a second update on the restarted sandbox re-applied the block. Created 1 sandbox, deleted 1, final count 0.

Removed: the session_states.sandbox_fingerprint column, migration oss000000011, createSpecFingerprint, buildResolvedDaytonaCreate, and the fingerprint fields on the pointer read/write. The dev DB had oss000000011 applied by hand; it was reverted (column dropped, alembic_version_oss reset to oss000000010) so future migrations stay healthy.

Kept: the turn-index compare-and-set guard on sandbox_id (multi-runner safety, pre-existing columns only), the warm keepalive pool, the park/stop ladder, typed teardown reasons, the MAX_WARM cap, and the TTL=0 off switch.

Before/after latency numbers still stand: cold 12.5s, live-warm 1.39s, stopped-restart 7.7s.

}
const state = await waitForStableState(sandbox, sandboxId, "reconnect");
if (RUNNING_STATES.has(state)) {
await syncNetworkPolicy(sandbox, sandboxId);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Policy sync at reconnect. The desired policy comes from options.create (the same daytonaNetworkFields mapping used at create), so there is one source of truth. syncNetworkPolicy reads the live networkBlockAll/networkAllowList and calls updateNetworkSettings only when they differ. Verified against a live sandbox (SDK 0.187.0): the update takes effect on a running sandbox, survives a stop and restart, and re-applies on a restarted sandbox. A failed update logs and leaves the prior policy rather than aborting the reconnect.

else_=SessionStateDBE.sandbox_id,
)
elif "sandbox_id" in upsert.model_fields_set:
update_values["sandbox_id"] = stmt.excluded.sandbox_id

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Kept: the turn-index compare-and-set on sandbox_id (guarded_pointer_write above). This is the multi-runner safety mechanism and it uses only pre-existing columns. Only the fingerprint branch was removed here, so a stale write still cannot overwrite a newer pointer.

)
: undefined;
if (storedSandboxId) {
if (storedSandboxPointer) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Trusted-pointer semantics. A stored sandbox id is now reconnected by id with no create-spec fingerprint and no delete-and-rebuild compatibility check. Snapshot, image, and target drift is accepted as per-conversation version pinning. Network policy converges inside reconnect; per-turn env sync is deferred to #5223.

- Gate the sandbox pointer write on the Daytona plan so a local run
  cannot overwrite a conversation's remote pointer.
- Hydrate the continuity store before the guarded pointer clear so a
  post-restart clear carries the durable turn index.
- pause() throws for the error sandbox state so teardown falls back to
  delete instead of reporting a parked sandbox with a stale pointer.
- Catch live-park activity-refresh failures; the hook is best-effort.
- Dedupe writeSandboxPointer/clearSandboxPointer into one guarded PUT.
- Assert status_code in the tokenless pointer acceptance test.
- ruff format on the warm_daytona_probe QA script (CI red).

Claude-Session: https://claude.ai/code/session_018MaXPNpvzN22kngHno3VMj
The branch flipped AGENTA_AGENT_MCPS_ENABLED from false to true across
the compose files, env examples, and the helm comment. The flip is
unrelated to warm Daytona sessions and contradicts the SDK gate and the
helm template, which both default off. Restore false everywhere.

Claude-Session: https://claude.ai/code/session_018MaXPNpvzN22kngHno3VMj
if "data" in upsert.model_fields_set:
update_values["data"] = stmt.excluded.data
if "sandbox_id" in upsert.model_fields_set:
guarded_pointer_write = (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

we removed sandbox_fingerprint is this still relevant

# AGENTA_RUNNER_HOST=0.0.0.0
# AGENTA_API_KEY=
# AGENTA_AGENT_MCPS_ENABLED=false
# AGENTA_AGENT_MCPS_ENABLED=true

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

is this the default? why did we change it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No, off is the default: the SDK gate and the helm template both default to false. The flip to true rode along in this branch by accident and is unrelated to warm Daytona sessions. Reverted to false everywhere in a588b08.

const state = await waitForStableState(sandbox, sandboxId, "pause");
if (RUNNING_STATES.has(state)) await sandbox.stop();
else if (
!STOPPED_STATES.has(state) &&

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 The AI agent says:

[P1] Escalate failed Daytona states to deletion

When a sandbox is in Daytona’s error state, this condition treats the pause as successful because FAILED_STATES is excluded from the throwing branch. The caller consequently sets parked = true, skips destroySandbox(), and clears the provider handle, potentially leaving an errored but billable sandbox undeleted. Only already stopped or missing sandboxes should be successful no-ops. An error state must throw so the delete fallback runs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 59e49b9: pause now throws for the error state, so parked=true is never set and the delete fallback reclaims the errored sandbox. Only stopped, archived, and destroyed remain no-op successes. Same finding as CodeRabbit's, both serviced.

@mmabrouk

Copy link
Copy Markdown
Member Author

Review-servicing pass: two commits, all outstanding comments answered

Commits 59e49b9 and a588b08 service every open reviewer comment. Each thread has an in-line reply.

Changed:

  • Pointer write is gated on the Daytona plan. A local run no longer overwrites a conversation's remote pointer. Test added.
  • The terminal-reconnect clear hydrates the continuity store first, so a post-restart clear carries the durable turn index. Test added.
  • pause() throws for the error sandbox state. Teardown then falls back to delete instead of reporting a parked sandbox with a stale pointer. Test added. This closes both CodeRabbit's finding and the P1 in the review thread.
  • The live-park activity refresh is caught and logged. A parked turn can no longer fail on a best-effort hook. Test added.
  • writeSandboxPointer and clearSandboxPointer share one guarded PUT helper (CodeRabbit nitpick).
  • Added the missing status_code assertion in the tokenless pointer acceptance test.
  • ruff format on the warm_daytona_probe QA script (the CI red).
  • Restored AGENTA_AGENT_MCPS_ENABLED default to false across compose, env examples, and the helm comment. The flip to true was unrelated to this PR and contradicted the SDK gate and helm template.

Declined:

  • The dao.py combined ID+fingerprint guard: moot, the fingerprint was removed entirely in 20dbabb.
  • The vendored-patch debug log on loadRemoteSession failure (review-body nitpick): the silent fallback is by design and regenerating the patch for a log line is not worth the churn before merge.

Runner suite: 881 passing, typecheck clean. Ruff clean on all touched Python.

ardaerzin added a commit that referenced this pull request Jul 14, 2026
…n matrix

Runner respawn+remount and warm/reused sandboxes are merged and the runner remounts the object-store
cwd/agent prefixes; update the resume row so the remaining work reads as just the FE 'Resume'
affordance.
@coderabbitai coderabbitai Bot mentioned this pull request Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request needs-review Agent updated; awaiting Mahmoud's review size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants