Skip to content

fix(preview): fall back to the serving sandbox when the pinned provider kind has no entry - #5239

Merged
guitavano merged 1 commit into
mainfrom
fix/preview-sandbox-provider-kind-fallback
Jul 25, 2026
Merged

fix(preview): fall back to the serving sandbox when the pinned provider kind has no entry#5239
guitavano merged 1 commit into
mainfrom
fix/preview-sandbox-provider-kind-fallback

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Sending the first message on a fresh thread tears down a working preview into "Starting your preview" and boots a second, competing sandbox that then fails. It self-heals a moment later when the authoritative thread row lands, which is why it reads as a flicker rather than a hard break.

It takes two individually-reasonable commits to produce, ~4 weeks apart.

The latent half — #4025 (ad6bcfd10)

Preview entry selection became an exact-key lookup:

const vmEntry = sandboxProviderKind
  ? (branchMap[sandboxProviderKind] ?? null)   // exact key, no fallback
  : selectVmEntry(branchMap);

That was deliberate and correct — from the commit message, "so a codex-local thread always shows the desktop sandbox even when a cluster sibling exists." Pinning beats the heuristic.

What it doesn't cover is the pinned kind having no entry at all. That collapses to null instead of degrading to whatever is actually serving the branch. The same commit also threads sandboxProviderKind into SANDBOX_START and drops the !!branch gate from shouldAutoStart — so a lookup miss no longer just blanks the preview, it also boots a rival VM.

What made it reachable — #4558 (73b15a685)

That PR mirrors the first-message runtime pin into the thread store, so findReusableNewChat stops reusing a thread whose first message already ran (harness_id is pinned server-side on dispatch and never flows back through /watch). Correct, and needed.

It also mirrors sandbox_provider_kind — sourced from the client's picker:

manager.patchThread({
  id: capturedTaskId,
  harness_id: submitSettings.harnessId ?? "decopilot",
  sandbox_provider_kind: submitSettings.sandboxProviderKind ?? null,  // ← the client's guess
  updated_at: new Date().toISOString(),
});

The server resolves that field from state the client doesn't own — notably whether a desktop link is live. With a linked desktop it pins user-desktop while the picker says agent-sandbox. Mirroring the guess flips pendingSandboxProviderKind mid-session, the exact-key lookup misses the running desktop sandbox, and the preview drops out from under the user.

Neither commit is wrong on its own. The bug is the interaction: one made a miss fatal, the other made misses happen.

Reproduction

Linked desktop, agent whose branch map holds exactly one entry:

pSozqw…/gimenes-xiwzhzrm → { "user-desktop": "http://gimenes-xiwzhzrm-d7600347fc5a12ae.localhost:49361" }

New chat → send first message. The client fires a start for a provider that isn't there:

POST /tools/SANDBOX_START
{"branch":"gimenes-xiwzhzrm","sandboxProviderKind":"agent-sandbox"}  → 500
{"status":"failed","previewUrl":null,
 "failureReason":"SandboxClaim gimenes-xiwzhzrm-4993c564eb23f9e7 did not record an adopted Sandbox (status.sandbox.name) within 60s"}

Note the handle differs from the one serving the preview (d7600347fc5a12ae) — it's a second sandbox racing the healthy one. Meanwhile the server had pinned the thread sandbox_provider_kind: "user-desktop" all along.

Fix

1. resolveVmEntry(branchMap, kind) in @decocms/shared. An exact match still wins, so #4025's pinning is preserved intact; a missing entry falls back to the serving sandbox instead of null. Both call sites — SandboxLifecycleProvider and VmEventsBridge — now share it, retiring the duplicated logic whose comment already said the two "must always agree."

2. Stop mirroring sandbox_provider_kind at send time. harness_id is all the reuse gate needs. Never optimistically write a field the server resolves from state the client can't see; it stays null until the real row arrives.

(1) is the load-bearing one — it makes a transient wrong pin harmless rather than destructive. (2) removes the trigger.

Testing

4 unit tests on resolveVmEntry, including one encoding this exact failure shape (desktop serving, thread pinned to agent-sandbox).

Verified in a live dev app against a linked desktop:

before after
preview state IFRAME → STARTING IFRAME only
SANDBOX_START calls 1 → 500 0
iframe src dropped unchanged

Message dispatches normally; thread ends at harness_id: decopilot, sandbox_provider_kind: user-desktop, branch: gimenes-xiwzhzrm.

bun run check clean across all 14 workspaces · bun run lint 0 errors · bun run fmt applied · sandbox + chat unit suites 853/853.

Notes for reviewers

@pedrofrxncx — tagging you since #4558 is the half that surfaces this, and the mirror there is doing real work for findReusableNewChat that I don't want to regress. Please sanity-check that dropping only sandbox_provider_kind (keeping harness_id) still fully covers the reuse gate you were fixing.

Split out of #5231 — unrelated to that PR's new-chat branch-inheritance change, so it ships on its own.

🤖 Generated with Claude Code


Summary by cubic

Fixes preview flicker on the first message by falling back to the serving sandbox when the pinned sandbox_provider_kind has no entry, preventing a second, failing VM from starting. Also stops writing the client’s provider-kind guess to avoid mismatches mid-session.

  • Bug Fixes
    • Added resolveVmEntry(branchMap, kind) in @decocms/shared; exact match wins, otherwise falls back to selectVmEntry.
    • Replaced local lookups with resolveVmEntry in SandboxLifecycleProvider and VmEventsBridge to keep selection consistent.
    • Stopped mirroring sandbox_provider_kind at send time (still mirror harness_id); the server remains the source of truth.
    • Added unit tests for resolveVmEntry, including the desktop-serving/pinned-to-agent regression case.

Written for commit c833a7e. Summary will update on new commits.

Review in cubic

…as no entry

Sending the first message on a fresh thread tore down a working preview
("Starting your preview") and booted a second, competing sandbox that then
failed. Two independently-reasonable changes interact to cause it.

#4025 made preview entry selection an exact-key lookup, `branchMap[kind]`,
replacing `selectVmEntry`'s heuristic — deliberately, so a codex-local thread
keeps showing its desktop sandbox even when a cluster sibling exists. That
pinning is right; what it didn't cover is the pinned kind having NO entry at
all, which collapses to null rather than degrading to whatever is serving.
The same commit also threads sandboxProviderKind into SANDBOX_START and drops
the `!!branch` gate from shouldAutoStart, so a lookup miss no longer just
blanks the preview — it also boots a rival VM.

#4558 then made that reachable. It mirrors the first-message runtime pin into
the thread store so findReusableNewChat stops reusing a thread whose first
message already ran (harness_id never flows back through /watch) — correct and
needed. But it also mirrors `sandbox_provider_kind`, taken from the client's
picker. The server resolves that field from state the client doesn't own,
notably whether a desktop link is live: it pins `user-desktop` while the picker
says `agent-sandbox`. Mirroring the guess flips pendingSandboxProviderKind
mid-session, the exact-key lookup misses the running desktop sandbox, and the
preview drops out until the authoritative row lands.

Fixes:
- `resolveVmEntry(branchMap, kind)` in @decocms/shared — exact match still wins,
  but a missing entry falls back to the serving sandbox instead of null. Both
  call sites (SandboxLifecycleProvider, VmEventsBridge) now share it, so the
  "must always agree" duplication is gone.
- Stop mirroring `sandbox_provider_kind` at send time. harness_id is all the
  reuse gate needs; the provider kind stays null until the server's row arrives.

Verified against a linked desktop on a live branch. Before: preview flips
IFRAME -> STARTING and one SANDBOX_START returns 500 ("SandboxClaim did not
record an adopted Sandbox within 60s"). After: preview stays on IFRAME, zero
SANDBOX_START calls, message dispatches normally and the server pins
sandbox_provider_kind=user-desktop as it always did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tlgimenes
tlgimenes requested a review from pedrofrxncx July 25, 2026 02:48
@guitavano
guitavano merged commit 3bf67c3 into main Jul 25, 2026
14 checks passed
@guitavano
guitavano deleted the fix/preview-sandbox-provider-kind-fallback branch July 25, 2026 13:17
decocms Bot pushed a commit that referenced this pull request Jul 25, 2026
PR: #5239 fix(preview): fall back to the serving sandbox when the pinned provider kind has no entry
Bump type: patch

- decocms (apps/api/package.json): 4.127.0 -> 4.127.1
- @decocms/shared (packages/shared/package.json): 0.1.0 -> 0.1.1

Deploy-Scope: web
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.

3 participants