Skip to content

fix(sandbox): align Sprites sandbox + terminal with the hibernate-and-wake model - #1674

Merged
2witstudios merged 3 commits into
masterfrom
pu/sprites-config
Jun 22, 2026
Merged

2witstudios merged 3 commits into
masterfrom
pu/sprites-config

Conversation

@2witstudios

@2witstudios 2witstudios commented Jun 22, 2026 •

Copy link
Copy Markdown
Owner

Why

Agents lost their sandbox mid-conversation, and terminals hung on "Connecting to shell…". Root cause: the implementation fought the Fly Sprites model. Sprites are persistent environments that hibernate when idle and wake automatically on demand — but we destroyed them on a timer, hung on cold-VM file I/O, never retried the documented wake handshake, ran the terminal service on a Node version the SDK forbids, and collapsed every control-plane failure into one opaque provision_failed.

This PR aligns each discrepancy with the SDK's documented usage. Plan/thesis: tasks/sprites-hibernation-alignment.md.

What changed (root cause → fix)

Root cause Fix
C1 realtime ran Node 22 but @fly/sprites requires Node ≥ 24 → terminal PTY connect hung Bump apps/realtime/Dockerfile (builder + runner) to Node 24; add assertSandboxRuntime guard on the realtime terminal path; fix the misleading "Node 22 handles it natively" comment
C2 writeFile/readFile used sprite.filesystem() (bare fetch, no AbortSignal) → hung on a cold/hibernated VM Bound every fs op with a timeout; on first failure wake the VM via the exec/WebSocket path and retry once
C3 conversation lifecycle destroyed hibernated VMs on a 15‑min idle timer (stop = deleteSprite), discarding agent state + churning delete/recreate races Idle now resumes (platform wakes the VM); teardown reserved for session-end + a 24h hard-expiry reclaim ceiling (idleTimeoutMs → hardExpiryMs)
C4 no retry on the cold-start exec race ("closed before open" while the VM boots) Retry that pre-open wake signal with backoff; never retry post-open failures (timeout/overflow/non-zero/mid-run)
H1 resource caps never passed to createSprite despite the comment claiming so Thread provider-neutral SandboxResourceCaps (RAM/vCPU/storage) through the options seam → mapped to SpriteConfig in the driver
H2 terminal idle sessions were destroyed Pass persistent: true; treat the planner's noop as reconnect-and-wake
H3 distinct control-plane failures flattened into one opaque provision_failed Classify rate-limit/concurrent (→ rate_limited + retryAfter) and conflict (delete-recreate race) in the driver; surface a distinct provision_rate_limited tool reason; log the classified kind

Validation

  • @pagespace/lib + realtime typecheck (build) clean
  • lint clean (lib + realtime)
  • sandbox unit suite green, incl. new tests for the cold-start exec retry, fs wake-retry, error classifier, hibernate-on-idle lifecycle, and terminal persistence (240+ tests)
  • Full bun run typecheck / web build not run in the worktree (cost); the only web change is a signature-compatible one-liner in sprites-client.ts, and the type owner (@pagespace/lib) compiles.

Notes

  • No changelog entry: the agent code-execution + terminal surface is still admin-gated / default-OFF, so nothing user-visible shipped.
  • stop still destroys on genuine session-end and on the 24h hard-expiry reclaim — no orphan billing.

🤖 Generated with Claude Code

Review follow-ups (Codex)

  • P1 — terminal PTY wake (kept): a resumed (hibernated) terminal is woken by ensureSpriteAwake(sprite) before openPtyShell, with the cold-start retry, so the bash spawn lands on an awake VM. (a24d036ad)
  • P2 — egress on resume (reverted, push-back): removed the per-resume relock. A Sprite keeps its network policy across hibernation, the allowlist is a static empty deny-all, dropped wakes are recovered by per-op retry, and any future widening is bounded by the 24h reclaim — so reapplying on every resume was unnecessary complexity that drifts from the platform's configure-once model. Trivially re-addable if the allowlist becomes dynamic. (a24d036ad)

This net-removed ~69 lines vs the interim relock approach, keeping the implementation close to the Sprites docs' create→exec→hibernate→delete model.

2witstudios and others added 2 commits June 22, 2026 15:42
…-wake model

Agents lost their sandbox mid-conversation and terminals hung on "Connecting
to shell…" because the implementation fought the platform. Sprites hibernate
when idle and wake on demand; we were destroying them, hanging on cold file I/O,
never retrying the wake handshake, running the terminal service on an
unsupported Node, and flattening every control-plane failure into one opaque
reason. This aligns each with the SDK's documented usage.

C1 — realtime ran Node 22 but @fly/sprites requires Node >= 24: bump the
realtime image (builder + runner) to Node 24 and add an assertSandboxRuntime
guard on the terminal path so a wrong runtime fails loudly instead of hanging.
Fix the stale "Node 22 handles it natively" comment.

C2 — writeFile/readFile went through sprite.filesystem() (bare fetch, no
AbortSignal) and hung on a cold/hibernated VM. Bound every fs op with a timeout
and, on first failure, wake the VM via the exec/WebSocket path and retry once.

C3 — the conversation lifecycle destroyed hibernated VMs on a 15-min idle timer
(stop = deleteSprite), discarding agent state and churning into delete/recreate
races. Idle now RESUMES (the platform wakes the VM); teardown is reserved for
session-end and a 24h hard-expiry reclaim ceiling. (idleTimeoutMs → hardExpiryMs.)

C4 — no retry on the cold-start exec race: a hibernated VM's first exec can be
dropped ("closed before open") while it boots. Retry that pre-open wake signal
with backoff; never retry post-open failures (timeout/overflow/non-zero/mid-run).

H1 — resource caps were never passed to createSprite despite the comment. Thread
a provider-neutral SandboxResourceCaps (RAM/vCPU/storage) through the options
seam and map it to SpriteConfig in the driver.

H2 — terminal idle sessions were destroyed: pass persistent:true and treat the
planner's noop as reconnect-and-wake.

H3 — distinct provisioning failures were one opaque provision_failed. Classify
rate-limit/concurrent (→ rate_limited + retryAfter) and conflict (delete-recreate
race) in the driver, surface a distinct provision_rate_limited tool reason, and
log the classified kind.

Plan/thesis: tasks/sprites-hibernation-alignment.md. No changelog entry — the
code-execution surface is still admin-gated/default-OFF (nothing user-visible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBkG69PYNEYBYR6pF3TPRP
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBkG69PYNEYBYR6pF3TPRP
@coderabbitai

coderabbitai Bot commented Jun 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@2witstudios, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 11 minutes and 8 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e30e283d-bef6-42c8-a462-3c5a64a90364

📥 Commits

Reviewing files that changed from the base of the PR and between e1183a2 and 000ff5f.

📒 Files selected for processing (16)
  • apps/realtime/Dockerfile
  • apps/realtime/src/terminal/realtime-sprites-client.ts
  • apps/web/src/lib/sandbox/sprites-client.ts
  • packages/lib/src/services/sandbox/__tests__/lifecycle.test.ts
  • packages/lib/src/services/sandbox/__tests__/session-manager.test.ts
  • packages/lib/src/services/sandbox/__tests__/terminal-session-manager.test.ts
  • packages/lib/src/services/sandbox/execution-policy.ts
  • packages/lib/src/services/sandbox/lifecycle.ts
  • packages/lib/src/services/sandbox/sandbox-client/__tests__/sprites.test.ts
  • packages/lib/src/services/sandbox/sandbox-client/sprites.ts
  • packages/lib/src/services/sandbox/sandbox-client/types.ts
  • packages/lib/src/services/sandbox/sandbox-options.ts
  • packages/lib/src/services/sandbox/session-manager.ts
  • packages/lib/src/services/sandbox/terminal-session-manager.ts
  • packages/lib/src/services/sandbox/tool-runners.ts
  • tasks/sprites-hibernation-alignment.md
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/sprites-config

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b1d40f9b0b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Sprites hibernate when idle and wake on demand, so an idle terminal VM
// must be resumed, not destroyed — `persistent` makes the planner return
// `noop` on idle (handled below as a reconnect) instead of `teardown`.
persistent: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retry PTY wake before resuming idle terminals

When a terminal page has been idle past the 15-minute planner timeout, this now returns noop/resume instead of tearing the Sprite down. The realtime path then fetches the raw Sprite (apps/realtime/src/index.ts calls sdk.getSprite) and starts the PTY with a single sprite.spawn('bash', ...) in openPtyShell, so it bypasses the new runSpawnedWithWakeRetry that handles the hibernated-Sprite closed before open failure. In that idle-terminal reconnect scenario, the first wake WebSocket can close the shell instead of retrying; either add the same wake retry/warm-up to the PTY path before making terminals persistent, or keep reprovisioning idle terminals.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 000ff5f. The terminal PTY path now lands on an already-awake VM: terminal resume always relocks via client.get({ sandboxId, options }), and that relock's mkdir runs through runSpawnedWithWakeRetry — so the hibernated Sprite is woken (with the cold-start "closed before open" retry) before openPtyShell's bash spawn. A terminal acquire happens once per connection (not per keystroke), so the extra warm round-trip is negligible. See terminal-session-manager.ts reconnectExisting (relockOptions) + the driver get(options) path. Left open for your verification.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Update (a24d036): kept this fix but via a cleaner mechanism than the earlier relock. The realtime terminal path now calls a dedicated ensureSpriteAwake(sprite) (exported from the Sprites driver) before openPtyShell, but only when the acquire resumed an existing session (fresh creates are already warmed by the acquire's mkdir). ensureSpriteAwake runs a no-op exec through runSpawnedWithWakeRetry, so the hibernated VM is woken — with the cold-start "closed before open" retry — before the PTY's bash spawn. So the resumed-terminal cold-start race you flagged is handled. See apps/realtime/src/index.ts (resume branch) + ensureSpriteAwake in sprites.ts. Left open for verification.


// Within the window: resume. A hibernated VM wakes on the next exec, so we keep
// the session and its state rather than destroying a sleeping (near-free) VM.
return { action: 'resume', sandboxId: existingSession.sandboxId };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reapply sandbox lockdown on hibernation resumes

For conversation sandboxes idle longer than the old 15-minute timeout but shorter than the new 24-hour hard expiry, this now returns resume; production tool execution then reconnects via buildRealSandboxRunDeps.reconnect, which calls client.get, and the Sprites get implementation only wraps sdk.getSprite without running applyEgressLockdown. In that multi-hour resume scenario, a sandbox keeps whatever network policy it had before, so tightening SANDBOX_EGRESS_ALLOWLIST or recovering an older Sprite will not be enforced until hard expiry/session end; route hibernation resumes through a path that reapplies the egress policy before handing the VM back.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 000ff5f. get now accepts optional create options; when present it reapplies applyEgressLockdown before hand-back (never destroying a warm session — it rejects if the relock fails, so no command runs without a confirmed policy). The planner relocks a resume once idle past a new 5-min warm window (warmWindowMs), so a tightened SANDBOX_EGRESS_ALLOWLIST/recovered VM is enforced within minutes of the next use instead of waiting for the 24h hard-expiry — while a burst of rapid sequential commands still reconnects cheaply (no per-command relock). This makes the code match the sprites.ts header's reapply-on-every-hand-back invariant. Left open for your verification.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

After reflection I've taken a different approach here (a24d036) and reverted the per-resume relock, on the view that reapplying egress on every resume is unnecessary complexity that drifts from the platform's configure-once model. Rationale: (1) a Sprite retains its network policy across hibernation (platform-persisted), so a resumed VM is not running unprotected — reapply would only propagate an allowlist change; (2) SANDBOX_EGRESS_ALLOWLIST is currently a static empty deny-all, so there is nothing to propagate today; (3) any future widening is bounded by the 24h hard-expiry reclaim; (4) a dropped first wake is already recovered by the per-op cold-start retry (runCommand + fs wake-retry). If/when the allowlist becomes dynamic, reapply-on-resume is the right move and easy to re-add (the driver's getOrCreate already reapplies on create) — I'd rather not carry the warm-window/relock plumbing speculatively. Happy to reinstate if you think the dynamic-allowlist case is near-term. Left open for your call.

…1/P2)

Two review findings, both consequences of routing idle sessions through resume
instead of teardown:

P2 (lifecycle.ts) — resume reconnected via client.get, which never re-ran the
egress lockdown, so a tightened SANDBOX_EGRESS_ALLOWLIST or a recovered older VM
stayed on its stale policy until hard-expiry/session-end (the sprites.ts header
already claimed reapply-on-every-hand-back). Fix: get() now accepts optional
create options; when present it reapplies applyEgressLockdown before hand-back
(never destroying a warm session; rejecting if the relock fails). The planner
relocks a resume once it is idle past a 5-min warm window, so a burst of
sequential commands still reconnects cheaply while a long-hibernated VM is
refreshed.

P1 (terminal PTY) — openPtyShell spawns bash directly, bypassing the cold-start
wake retry, so a resumed (hibernated) terminal could hit "closed before open"
and die. Fix: terminal resume now always relocks via get(options), and the
relock's mkdir runs through runSpawnedWithWakeRetry — warming the VM (with the
cold-start retry) before the PTY's bash spawn lands on it.

Tests: +10 (warm-window relock planning, resume relock plumbing, driver
get(options) reapply/no-reapply/relock-failure). lib+realtime typecheck, lint,
250 sandbox tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBkG69PYNEYBYR6pF3TPRP
@2witstudios
2witstudios merged commit 9b7e22c into master Jun 22, 2026
10 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