Skip to content

feat(workspaces): Resume long snapshot builds across turn yields - #1599

Open
sentry-junior[bot] wants to merge 27 commits into
mainfrom
feat/workspace-snapshot-checkin
Open

feat(workspaces): Resume long snapshot builds across turn yields#1599
sentry-junior[bot] wants to merge 27 commits into
mainfrom
feat/workspace-snapshot-checkin

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Cold Workspace setup can exceed one agent-turn budget. Full Sentry bootstrap (clone + devenv sync) dies with the function even though the sandbox VM can run longer.

This change stops awaiting full setup on switchWorkspace.

Behavior

  • Start setup in a persistent 1h builder sandbox
  • Advance short control-plane slices: create builder, install deps, clone repos, detach setup, poll, snapshot
  • SQL build_phase checkpoints each slice; wall-clock budget is fixed from SQL startedAt (check-ins do not extend it)
  • Wait while budget remains; near the host deadline return timed_out + waiting: workspace_snapshot at a toolResult boundary
  • Host continues that wait across soft yields without model mediation
  • Always advance at least one slice before soft-yield so a fresh wake cannot spin without progress
  • Ready snapshots boot from Redis, or from SQL when Redis misses (lookup by profile hash)
  • Missing ready Vercel snapshot ids drop stale Redis/SQL pointers and rebuild on the durable path
  • Builders stop after ready, failed, timeout, or recipe change

Schema

  • New junior_snapshots table owns snapshot lifecycle (building | failed | ready)
  • Full ready details live on the row
  • Prior ready rows stay for later Vercel GC (TODO)
  • Recipe columns on junior_workspaces no longer store snapshot state
  • Migration backfills legacy recipe snapshot columns before drop

Structure

  • Snapshot SQL and builder live under sandbox/snapshot/
  • Recipe store stays recipe-focused
  • All workspace recipes use the durable path; baseline (no workspace) stays on the fast inline path

Not in this PR

  • Queue heartbeats without an agent call
  • Actual Vercel snapshot GC
  • Baseline snapshots in SQL

Requested by David Cramer.

--

View Junior Session [Sentry]

sentry-junior Bot and others added 5 commits August 16, 2026 15:21
Co-Authored-By: David Cramer <david@sentry.io>
Persist runtime, dependency count, and previous snapshot ids with the
Workspace snapshot record. Keep retired ids for later GC.

Co-Authored-By: David Cramer <david@sentry.io>
Snapshot build and ready artifacts are their own lifecycle. Keep prior
ready rows for later Vercel GC instead of packing history onto recipes.

Co-Authored-By: David Cramer <david@sentry.io>
Start only creates the builder. Install/clone and detached setup run on
later check-ins. SQL ready rows boot when Redis misses, and builders
stop after ready or failed.

Co-Authored-By: David Cramer <david@sentry.io>
Move snapshot SQL and the multi-slice builder under sandbox/snapshot.
switchWorkspace waits in a control-plane loop and throws
CooperativeTurnYieldError near the host deadline so conversation work
requeues like an agent turn. Resume attaches the same SQL job.

Co-Authored-By: David Cramer <david@sentry.io>
@vercel

vercel Bot commented Aug 16, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
junior-docs Ready Ready Preview Aug 17, 2026 2:27am

Request Review

Throwing CooperativeTurnYieldError mid-tool parked a non-continuable
assistant toolCall and failed the next wake on no_progress. Return
timed_out/building from switchWorkspace instead, keep waiting errors
through setup wrap, and always advance one slice before soft yield.

Co-Authored-By: David Cramer <david@sentry.io>

@sentry-junior sentry-junior Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adversarial self-review — a few real holes before this is safe for cold Sentry:

  1. continueBuild is still one long function slice. Start is create-only (good), but the next check-in still awaits install + full repo clone + detach setup under the Redis lock. That was half the original timeout. If the function dies mid-prep, SQL stays building with sandboxName and no commandId, and the next wake re-runs prep from scratch on a half-written builder. Detach setup alone is not enough — clone/install need their own checkpoints (or a detached prep script that writes status files), same as setup.

  2. Legacy SQL ready rows won't boot. Migration backfills runtime and dependency_count as NULL, and snapshotFromRow requires both. After deploy + Redis miss, previously ready workspace snapshots look missing and rebuild. Backfill real values, or treat null runtime/deps as valid legacy ready.

  3. Soft-yield continuation is model-mediated. timed_out + status: building is the right tool boundary (mid-tool CooperativeTurnYieldError was wrong). But nothing durable keeps waiting unless the agent calls switchWorkspace again after yield. That fails the "continue the wait across soft deadline" bar and also blocks unattended prebuild. Minimum: after this tool result, the runtime should keep the turn/work requeued on the same job until ready/failed/budget exhausted, not rely on the model to re-enter.

Bump the pre-scheduler migration residual count for 0032 and cast the
sandbox update mock call so typecheck accepts the workspace prep test.
Drop the generic status field and tool-description retry prose, move
waiting-error ownership into snapshot/waiting-error, and stop re-exporting
snapshot writes from the recipe store.

Co-Authored-By: David Cramer <david@sentry.io>
Checkpoint prep with durable builder markers so mid-prep death resumes
cleanly. Backfill legacy SQL ready runtime/deps and boot null rows.
Host-continue switchWorkspace waits across soft yields without model
mediation.

Co-Authored-By: David Cramer <david@sentry.io>
Co-Authored-By: David Cramer <david@sentry.io>
Assert scheduler row adoption behavior without hardcoding how many
core migrations remain after the pre-scheduler baseline.

Co-Authored-By: David Cramer <david@sentry.io>
Ship junior_snapshots with build_phase in a single 0032 migration
instead of a create-then-alter pair on this branch.

Co-Authored-By: David Cramer <david@sentry.io>
Resolve boots the ready junior_snapshots row for the current recipe
profile instead of the newest ready row of any profile, so reverting
setup_script reuses a retained snapshot when it still exists.

Co-Authored-By: David Cramer <david@sentry.io>
Co-Authored-By: David Cramer <david@sentry.io>
Drop the @vercel/sandbox mock so integration coverage matches policy
(only Slack + LLMs may be faked). Wire sandbox secrets into junior-tests
and skip the suite when credentials are absent.

Co-Authored-By: David Cramer <david@sentry.io>
State the integration-test boundary precisely in policy and harness
docs, and reject any vi.mock/vi.doMock in integration tests.

Co-Authored-By: David Cramer <david@sentry.io>
Vercel 5xx on builder runCommand was marking the snapshot job failed.
Keep the SQL phase and retry in the wait loop instead.
Builder Sandbox.get used resume:false, so later slices had no active
session and runCommand failed with API 500s. Resume the named builder
and simplify the live lifecycle test to wait through real prep.
Co-Authored-By: David Cramer <david@sentry.io>

@sentry-junior sentry-junior Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fresh pass on the current tip (b07dd16). The multi-slice control plane, profile-keyed ready lookup, host-owned wait continue, and real-sandbox lifecycle coverage are in good shape. A few merge blockers remain:

  1. Product timeout is still 1h after an explicit 24h ask. In snapshot/workspace.ts, BUILD_TIMEOUT_MS / builder timeout / error text are all one hour. Thread request was “the whole thing to have a 24 hour timeout” (builder lifetime + wall-clock budget from SQL startedAt). Right now a slow cold Sentry bootstrap dies at 1h even though the VM model was sold as multi-hour. Set the product budget to 24h; keep check-ins from extending it.

  2. CI is still sized as if the live cases run for the product budget. .github/workflows/ci.yml is timeout-minutes: 135 with a “two live one-hour cases” comment, and the lifecycle suite is LIVE_TEST_TIMEOUT_MS = 65m. Those cases only write a marker script; they should keep a realistic prep ceiling (previous ~40m/45m was about docker install cost), not track BUILD_TIMEOUT_MS. Don’t make default CI look like a deliberate hour-long soak.

  3. Missing ready snapshot falls off the durable path. In session.ts createSandboxFromResolvedSnapshot, isMissingError rebuilds via baseline resolveSnapshot(... prepareWorkspace) — i.e. inline clone/setup under the turn signal — even when workspace is set. After Redis/SQL say ready and Vercel deleted the snapshot (or GC later), cold switch regresses to the original timeout failure mode. Rebuild should re-enter resolveWorkspaceSnapshot / the multi-slice builder (and invalidate the stale ready row), not the baseline inline path.

Also worth fixing while here: clearNonReadySnapshots on recipe change deletes building rows without stopBuilder, so abandoned persistent builders leak until provider timeout. Stop-or-best-effort-stop before delete.

PR body is also stale (still says host re-invoke is out of scope; still describes a 24h builder while code is 1h) — refresh when the above lands.

Product snapshot budget stays one hour. Restore live-test ceilings to
prep cost, rebuild missing ready snapshots on the multi-slice path, and
stop abandoned builders when a recipe changes.

Co-Authored-By: David Cramer <david@sentry.io>

@sentry-junior sentry-junior Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correction on the previous review: product budget is intentionally 1h, not 24h. Misread the ask.

Landed on tip for the remaining real items:

  • CI live ceilings back to prep cost (45m job / 40m case), not product budget
  • missing ready snapshot rebuilds via durable multi-slice path (invalidate stale Redis/SQL pointers first)
  • recipe change stops abandoned builders before deleting non-ready rows
  • PR body refreshed for 1h + host continue

Move resolved-snapshot boot/rebuild into snapshot/boot so the missing
ready path stays durable without blowing the 1000-line session module.
@dcramer
dcramer marked this pull request as ready for review August 16, 2026 23:23
@github-actions github-actions Bot added the risk: high PR risk score: high label Aug 16, 2026
Comment thread packages/junior/src/chat/sandbox/snapshot/workspace.ts
Match wait yield to worker shouldYield when present, and poll the host
continue loop instead of immediately re-entering switchWorkspace.
Comment thread packages/junior/src/chat/sandbox/snapshot/workspace.ts
finishBuild now matches continueBuild: transient Sandbox API errors retry,
permanent errors (missing builder/command) mark the SQL row failed and stop
retrying the dead sandbox until the 1h wall clock.

Add a live lifecycle case that puts a real builder on the poll path, stops
the VM, and proves the next acquire fails the build instead of stalling.

Co-Authored-By: David Cramer <david@sentry.io>
Comment thread packages/junior/src/chat/sandbox/snapshot/workspace.ts
AbortSignal cancellation during finish/continue/start must not markFailed
or invent a failed SQL row. Detect abort like other cancel paths and leave
the durable builder for the next check-in; permanent errors still fail.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 778e653. Configure here.

Comment thread packages/junior/src/chat/sandbox/errors.ts
isAbortError now recognizes real signal.reason values (API Turn cancelled,
executeAgentRun timed out after …). Build catch paths also treat
signal.aborted as cancel so bare aborts do not markFailed a healthy job.
Comment on lines +151 to +173
);
// TODO: garbage-collect retired Vercel snapshots once retention policy exists.
await db
.delete(juniorSnapshots)
.where(
and(
eq(juniorSnapshots.workspaceId, workspaceId),
ne(juniorSnapshots.status, "ready"),
),
);
return builders
.map((row) => row.sandboxName)
.filter((name): name is string => Boolean(name));
}

/**
* Drop one ready SQL row whose Vercel snapshot id is gone.
* Prior ready rows for other ids stay for later GC.
*/
export async function invalidateMissingReadySnapshot(params: {
workspaceId: string;
profileHash: string;
snapshotId: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

clearNonReadySnapshots can delete a row without returning its builder for cleanup

clearNonReadySnapshots selects sandboxName in one statement and deletes in a second. Under READ COMMITTED, a concurrent startBuild insert that commits between the two statements is deleted without its sandboxName being captured, leaving the Vercel builder running but untracked.

Evidence
  • clearNonReadySnapshots issues a SELECT then a separate DELETE for non-ready rows.
  • startBuild calls Sandbox.create() and then commits its own transaction via setWorkspaceSnapshotBuild().
  • Under PostgreSQL READ COMMITTED, a committed insert between the two statements is visible to the DELETE but not to the preceding SELECT.
  • updateWorkspace stops only the builders captured by the SELECT; the deleted-but-missed builder is not stopped and leaks until Vercel's 1-hour builder timeout expires.

Identified by Warden · code-review · D2Q-EA8

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — select-then-delete race was real.

Fixed in the tip:

  • clearNonReadySnapshots now uses a single DELETE … RETURNING so every deleted row’s builder name is captured for stop, including concurrent startBuild inserts

Comment on lines +200 to +211

async function markFailed(
workspace: Workspace,
build: WorkspaceSnapshotBuild,
error: string,
): Promise<void> {
await setWorkspaceSnapshotBuild(workspace.id, {
...build,
status: "failed",
error,
});
await stopBuilder(build.sandboxName);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

markFailed leaks builder sandbox if DB write fails

If setWorkspaceSnapshotBuild throws, stopBuilder never runs, leaving a persistent sandbox running untracked until it times out.

Evidence
  • markFailed awaits setWorkspaceSnapshotBuild then stopBuilder with no guard or finally block.
  • setWorkspaceSnapshotBuild executes a SQL transaction that can throw on connection errors.
  • When the write fails, stopBuilder is skipped and the builder VM continues running.
  • The leak is bounded by the 1-hour sandbox timeout, but consumes resources and incurs cost.

Identified by Warden · code-review · NBQ-FFK

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — builder stop was gated on the SQL write.

Fixed in the tip:

  • markFailed stops the builder in a finally so a failed setWorkspaceSnapshotBuild still tears down the VM

Comment on lines +188 to +197
}): Promise<Snapshot> {
const value = profile.create(params.runtime, params.workspace);
if (value) {
await clearCachedSnapshot(value.hash);
await invalidateMissingReadySnapshot({
workspaceId: params.workspace.id,
profileHash: value.hash,
snapshotId: params.snapshotId,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Rebuild path leaves stale SQL pointer when DB deletion fails

rebuildMissingWorkspaceSnapshot clears the Redis cache before deleting the stale SQL row. If the deletion throws, the bad snapshot row remains and the next resolve will boot from it again, looping on missing snapshot errors.

Evidence
  • rebuildMissingWorkspaceSnapshot calls clearCachedSnapshot before invalidateMissingReadySnapshot.
  • invalidateMissingReadySnapshot performs a SQL transaction that can fail.
  • On failure the stale ready row stays in the database.
  • snapshotFromSql later loads that row and returns it as valid because status === "ready".
  • boot.ts tries to boot the missing snapshot id, fails, and repeats the rebuild cycle.

Identified by Warden · code-review · X7Y-CKB

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — Redis-before-SQL left a stale ready pointer on delete failure.

Fixed in the tip:

  • rebuildMissingWorkspaceSnapshot invalidates the SQL ready row first, then clears Redis
  • if the SQL delete throws, resolve still sees the same ready row and can retry invalidate rather than looping after a silent Redis clear

Comment on lines +117 to +124
await setCachedSnapshot({
profileHash: ready.profileHash,
snapshotId: ready.id,
runtime: ready.runtime,
createdAtMs: ready.generatedAt.getTime(),
dependencyCount: ready.dependencyCount,
buildDurationMs: ready.buildDurationMs,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redis failure during snapshotFromSql aborts a valid snapshot boot

snapshotFromSql attempts to refresh the Redis hot cache with setCachedSnapshot and lets any Redis error propagate. Because SQL is the durable source of truth, a cache-write failure should not prevent booting from the ready row.

Evidence
  • snapshotFromSql awaits setCachedSnapshot without any try/catch guard.
  • setCachedSnapshot writes to Redis via stateAdapter.set; network issues can throw.
  • If it throws, the function aborts instead of returning the valid SQL snapshot.
  • The caller propagates the error as a non-transient failure, causing the workspace switch to fail.

Identified by Warden · code-review · GS8-LEY

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — Redis warm was on the critical path.

Fixed in the tip:

  • snapshotFromSql treats setCachedSnapshot as best-effort
  • a Redis write failure still returns the durable SQL ready snapshot for boot

- clearNonReadySnapshots uses DELETE … RETURNING so concurrent builds
  cannot be deleted without capturing the builder name
- rebuildMissingWorkspaceSnapshot invalidates SQL before Redis
- snapshotFromSql treats Redis warm as best-effort
- markFailed always stops the builder even if the SQL write fails
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: high PR risk score: high

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants