Skip to content

fix(sandbox): handle every Daytona lifecycle state + recover dead containers mid-request - #2

Merged
builtbyrishabh merged 2 commits into
mainfrom
fix/sandbox-lifecycle-guardrails
Sep 1, 2026
Merged

fix(sandbox): handle every Daytona lifecycle state + recover dead containers mid-request#2
builtbyrishabh merged 2 commits into
mainfrom
fix/sandbox-lifecycle-guardrails

Conversation

@builtbyrishabh

Copy link
Copy Markdown
Owner

The incident

Production trace 65e76ce8 (found via runtime logs):

sandbox.acquired  { status: 'existing-other', id: '998a1333-…' }
tool.bash.error   { error: 'bad request: failed to resolve container IP after 3 attempts:
                            no IP address found. Is the Sandbox started?' }

A thread was reopened while Daytona was archiving its sandbox (auto-stop after 30 min idle → auto-archive after 60 min stopped; moving the ~3 GiB filesystem takes minutes). getOrCreateSandbox handled only 4 of the SDK's ~23 states (STARTED, STOPPED, ARCHIVED, STARTING); archiving fell through to existing-other and the sandbox was returned without being started — so every tool call in the run failed with the container-IP error. The code literally didn't know the sandbox was being archived.

Guardrails

  1. src/server/sandbox/lifecycle.ts (new, env-free)planSandboxStep maps every state to an explicit step: use / start / wait-started / wait / fail. Mid-transition states (archiving, stopping, pausing, …) are polled until they settle; unknown future states wait rather than silently pass; broken states (error, destroyed, …) fail fast with a readable message.
  2. daytona.tsensureSandboxStarted walks any observed state to STARTED, with a 180 s settle deadline, a 120 s start timeout (archived restores are slow), and a start-attempt cap. getOrCreateSandbox now returns the raw initial state and prepare logs it (sandbox.acquired { status, state }), so the next novel state is diagnosable from one log line.
  3. withRecoveredSandbox — all six sandbox tools (bash/read/write/edit/validate/publish) retry once via a route-supplied recoverSandbox when the container dies under a call (auto-stop/archive racing a request, or the prepare promise itself rejecting that way). Recovery re-runs the full prepareLessonSandbox — not a bare start() — because the s3fs R2 mount doesn't survive the container; prepare is already idempotent. Omitted in tests, so the tool set stays unit-testable.

Testing

  • pnpm typecheck
  • pnpm test ✅ — 67 passed, including new lifecycle.test.ts covering the state map, the unreachable-error matcher (with the exact production error string), and the recover-once semantics.
  • Not exercised against live Daytona in this PR; pnpm sandbox:smoke covers the happy path, and the archiving window is hard to reproduce on demand.

🤖 Generated with Claude Code

builtbyrishabh and others added 2 commits August 31, 2026 17:47
…e raw bucket URL

The Copy link / Open URL was the game's physical R2 path,
games/<clerkUserId>/<threadId>/current/index.html — every shared link
carried the owner's Clerk user id to whole classrooms.

A new public /play/[threadId] route resolves the owner from the
lessonplay_game_version index and streams current/index.html through,
so the bucket layout (and the user id) stays server-side. The tRPC
router now hands the UI a same-origin sharePath, the header builds the
absolute URL from the page's own origin at copy time, and the agent's
publishedUrl — what the model pastes into chat after a publish — points
at /play too, derived from the request origin so dev and preview
deploys hand out links on their own host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the container dies mid-request

Production incident (trace 65e76ce8): a thread reopened while Daytona was
archiving its sandbox got a handle in state "archiving", which the old
four-state walk in getOrCreateSandbox silently returned as "existing-other"
without starting it. Every tool call then failed with "bad request: failed to
resolve container IP after 3 attempts: no IP address found. Is the Sandbox
started?".

Three guardrails:

- src/server/sandbox/lifecycle.ts (new, env-free): planSandboxStep maps ALL
  ~23 SDK states to an explicit step — use / start / wait-started / wait /
  fail — so mid-transition states (archiving, stopping, pausing, ...) are
  polled until they settle instead of being used, unknown future states wait
  rather than pass, and broken states (error, destroyed) throw something
  readable up front.
- daytona.ts: ensureSandboxStarted walks any observed state to STARTED with a
  deadline and a start-attempt cap; getOrCreateSandbox now reports the raw
  initial state, and prepare logs it, so the next novel state shows up in one
  log line instead of needing to be inferred.
- withRecoveredSandbox: every sandbox tool (bash/read/write/edit/validate/
  publish) retries once via a route-supplied recoverSandbox when the container
  dies under a call (auto-stop or archive racing a request). Recovery re-runs
  the full prepareLessonSandbox — not a bare restart — because the s3fs mount
  does not survive the container; prepare is already idempotent.

Covered by unit tests in src/server/sandbox/__tests__/lifecycle.test.ts,
including the exact production error message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
lessonplay-mastra Error Error Aug 31, 2026 12:28pm

@builtbyrishabh
builtbyrishabh merged commit c19ae16 into main Sep 1, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant