Skip to content

feat: port upstream #880 — start sandbox provisioning on session create #115

Description

@dennisonbertram

Part 1 of 4 deferred upstream feature ports. Ports upstream PR #880 (vercel-labs/open-agents commit ed2aa3e7, "Start sandbox provisioning on session create") into dennisonbertram/fork-open-agents. This is a fork-aware port, not a clean cherry-pick: our chat-sandbox-runtime.ts carries a managed-runtime engine that upstream deleted, and the port must preserve it.

Epic: #119 — Upstream ports. This issue is the KEYSTONE#116 (lazy sandbox actions UI, soft-dep), #118 (parallelize chat startup, hard-dep) build on top of it. #117 (workflow v5 bump) is independent.

SDK note: This slice uses workflow@^4.2.0-beta.72 (v4). Port #117 (#883) targets v5 separately and is NOT a dependency here.

Why this matters

Today a session's sandbox is provisioned lazily on the first chat turn inside resolveChatSandboxRuntime. The user creates a session, lands on the chat page, types a message, and only then waits 10-30s+ for connectSandbox to boot the VM, clone the repo, mint/revoke a GitHub installation token, and install global skills — all blocking the first response. Upstream #880 moves provisioning to session-create time so the sandbox is warming (or already warm) by the time the user sends their first message. The chat path then becomes "wait for the in-flight provisioning run, then bare-reconnect," which is far cheaper and removes the cold-start tax from the first turn.

For our fork specifically, this also untangles the 857-line chat-sandbox-runtime.ts that mixes provisioning, managed-runtime profile execution, startup-log reporting, and observability into one function. Extracting provisioning into lib/sandbox/provisioning.ts is a prerequisite for #881 (soft-dep) and #884 (hard-dep).

User/operator path protected

Operator/user path: Create a session (POST /api/sessions) → land on the session chat page → send the first message → receive a streamed response from an agent running in a ready sandbox.

Concretely this slice makes that path faster and safer:

  • Provisioning starts the instant the session row exists, not on first chat turn.
  • The first chat turn waits for the already-running provisioning workflow instead of starting its own boot.
  • A session archived mid-provisioning stops its sandbox and does not leave an orphaned VM or write state onto an archived row.
  • Managed-runtime sessions still get full profile setup/probe/verify and observability — the protected managed-runtime path is preserved, not regressed.

Behavior contract

Scenario BT-001: provisioning starts on session create

  • Given an authenticated user with a valid repo selection
  • When they POST /api/sessions and creation succeeds
  • Then kickSandboxProvisioningWorkflow(result.session.id) is invoked with the new session id
  • And the response is still 200 with the created session/chat payload.

Scenario BT-002: a failing kick never blocks session creation

  • Given session creation otherwise succeeds
  • When kickSandboxProvisioningWorkflow throws/rejects
  • Then the error is caught and logged
  • And POST /api/sessions still returns 200 with the created session.

Scenario BT-003: provisioning run claim is idempotent (first-claim-wins)

  • Given a session with sandboxProvisioningRunId IS NULL
  • When two provisioning runs race to claim it
  • Then exactly one claimSessionSandboxProvisioningRunId succeeds and the duplicate run is a no-op (run-replaced / cancelled).

Scenario BT-004: archived mid-provisioning stops the sandbox and surfaces a typed error

  • Given a provisioning run is connecting/booting a sandbox
  • When the session is archived before updateSessionIfNotArchived commits
  • Then the persist is rejected (returns no row), the booted sandbox is stopped, and SessionArchivedDuringProvisioningError is thrown
  • And the archived row keeps no stale sandboxState.

Scenario BT-005: chat waits for in-flight provisioning instead of cold-starting

  • Given a session whose sandbox is not yet active and a provisioning run is in flight
  • When resolveChatSandboxRuntime runs for the first chat turn
  • Then it kicks/awaits the provisioning run (waitForSandboxProvisioningRun), re-reads the session, and bare-reconnects via connectSandbox(sandboxState) — it does not run the full provision ({ state, options }) form itself.

Scenario BT-006: managed-runtime sessions still execute their profile

  • Given a session with runtimeMode === "managed_runtime" and a managedRuntimeProfileId
  • When the first chat turn resolves the runtime
  • Then ensureManagedRuntimeEnvironment still runs, managed_runtime.profile.* events still fire, and the returned ResolvedChatSandboxRuntime still includes runtimeMode and the managedRuntime block.

Product and design spec

  • Entry point: Session creation API apps/web/app/api/sessions/route.ts (POST); first chat turn via the chat workflow that calls resolveChatSandboxRuntime in apps/web/app/workflows/chat-sandbox-runtime.ts.
  • Primary flow: create session → (server) fire-and-forget kickSandboxProvisioningWorkflow → durable sandboxProvisioningWorkflow claims the run lease and calls provisionSessionSandbox → on success persists sandboxState + active lifecycle, clears the provisioning lease, kicks the lifecycle workflow → first chat turn awaits the run, re-reads, bare-reconnects.
  • States:
    • setting-up workspace status is sent when the sandbox is not yet active on first chat turn (existing sendWorkspaceStatus copy: "Setting up the workspace...").
    • lifecycleState transitions: created with provisioning (already our default at create) → active on success → failed with lifecycleError on provisioning error.
    • sandboxProvisioningRunId is set on claim and cleared on completion/archive.
  • Accessibility / copy: No new user-facing copy beyond the existing workspace status messages; no new UI controls in this slice. The WorkspaceStartupReporter startup-log messages are preserved verbatim.
  • Permissions: Session-create auth/rate-limit/botid/composio/profile-validation are unchanged and must still run before any kick. Provisioning runs under the session owner; provisionSessionSandbox re-checks session.userId (and repoOwner/repoName presence) and re-verifies repo access before minting a scoped installation token.
  • Error / empty / loading behavior: Kick errors are swallowed (logged) at the API layer. Provisioning errors set lifecycleState: "failed" + lifecycleError; the chat path surfaces lifecycleError ?? "Workspace setup failed". Archived-during-provisioning is a typed no-throw-to-user outcome (session-archived skip) inside the workflow.

Integration spec (REAL paths + reconciliation)

Upstream source

Upstream commit ed2aa3e7 (PR #880, "Start sandbox provisioning on session create", +2202/-187 across 11 files). Inspect with git show ed2aa3e7 --stat. The upstream diff is reachable in the fork's git history via the upstream remote fetch.

This is an ADAPT port, not a blind cherry-pick. Our chat-sandbox-runtime.ts (857 lines) has diverged massively from upstream's post-#880 version (121 lines): we carry ensureManagedRuntimeEnvironment, WorkspaceStartupReporter, emitSessionEvent, sendStart, and managed-runtime observability that upstream never had. Applying git cherry-pick ed2aa3e7 would silently delete our managed-runtime engine. Do not do it.

Routes

apps/web/app/api/sessions/route.ts (currently 428 lines, no provisioning kick):

  • Add one import: import { kickSandboxProvisioningWorkflow } from "@/lib/sandbox/provisioning-kick";
  • Add a ~7-line non-blocking kick immediately before return Response.json(result):
    await kickSandboxProvisioningWorkflow(result.session.id).catch((error) => {
      console.error(
        `Failed to kick sandbox provisioning for session ${result.session.id}:`,
        error,
      );
    });
  • OMIT any managed-template-trial gating — that module does not exist in our fork and was not part of ed2aa3e7. Upstream's test suite (route.test.ts) has a "blocks additional sessions for managed template trial users" test using a module our fork does not carry; the fork's test file must handle that divergence.
  • Preserve every fork-only check: checkBotProtection, checkRateLimit, isComposioProfileAllowedForRepository, and isManagedRuntimeProfileId validation.

Schema / migration

apps/web/lib/db/schema.ts (sessions table):

  • Add sandboxProvisioningRunId: text("sandbox_provisioning_run_id") positioned between lifecycleRunId (line 278) and lifecycleError (line 279).
  • Current schema has lifecycleRunId and lifecycleError but NO sandboxProvisioningRunId — confirmed by inspection.

Migration: After editing schema.ts, run:

bun run --cwd apps/web db:generate

This produces migration 0048 (latest in fork is 0047_cheerful_thunderbolt_ross.sql — confirmed). Do NOT hand-copy upstream's 0036_faulty_yellow_claw.sql — the fork's migration journal has diverged (our 0047 vs upstream's 0036 at the time of #880). Regenerate.

DB helpers (new exports in apps/web/lib/db/sessions.ts, currently 842 lines)

Confirmed by inspecting ed2aa3e7: none of these exist today.

  1. SessionRecord type: export type SessionRecord = NonNullable<Awaited<ReturnType<typeof getSessionById>>>; — place after getSessionById.
  2. updateSessionIfNotArchived(sessionId, data) — conditional UPDATE where status != 'archived' AND (lifecycleState IS NULL OR lifecycleState != 'archived'). The exact Drizzle form is confirmed from the upstream diff; uses and, eq, ne, or, isNull which are already imported.
  3. claimSessionSandboxProvisioningRunId(sessionId, runId) — atomic UPDATE sessions SET sandbox_provisioning_run_id = runId WHERE id = sessionId AND sandbox_provisioning_run_id IS NULL RETURNING id. Returns Boolean(updated). Mirrors the existing claimSessionLifecycleRunId (line 323).
  4. clearSessionSandboxProvisioningRunIdIfOwned(sessionId, runId) — conditional UPDATE ... SET sandbox_provisioning_run_id = NULL WHERE ... AND sandbox_provisioning_run_id = runId. Confirmed from upstream diff; this is the clearIfOwned semantics the POC proved safe.

Workflow surfaces (new files)

apps/web/app/workflows/sandbox-provisioning.ts (new, ~60 lines, durable "use workflow"/"use step"):

  • Full source confirmed from git show ed2aa3e7 -- apps/web/app/workflows/sandbox-provisioning.ts.
  • sandboxProvisioningWorkflow(sessionId) reads getWorkflowMetadata().workflowRunId, claims/validates the run lease, calls provisionSessionSandbox, clears the lease.
  • Handles session-not-found, run-replaced, session-archived as skip returns; re-throws other errors after setting lifecycleState: "failed".
  • Register this workflow wherever workflows are registered for the v4 runtime (mirror how sandboxLifecycleWorkflow is registered).

Server modules (new files)

apps/web/lib/sandbox/provisioning.ts (~296 lines, "server-only"):

  • provisionSessionSandbox({ sessionId, userId? }), ProvisionSessionSandboxResult, and SessionArchivedDuringProvisioningError.
  • Uses the full-options connectSandbox({ state, options }) provision form (POC confirmed this form is already supported by our fork's packages/sandbox/factory.ts — zero changes needed to packages/sandbox).
  • Persists via updateSessionIfNotArchived, stops the sandbox on archive race, installs global skills, kicks the lifecycle workflow.
  • Fork reconciliation point: our getGitUser signature in chat-sandbox-runtime.ts takes a userId; upstream provisioning.ts takes a UserRecord (loads a UserRecord via getUserById first). Port must use the fork's actual signature or adapt accordingly.

apps/web/lib/sandbox/provisioning-kick.ts (~113 lines, "server-only"):

  • kickSandboxProvisioningWorkflow(sessionId) — start/dedupe/claim with live-run detection via getRun.
  • waitForSandboxProvisioningRun(runId) — awaited by the chat path.

CRUX — apps/web/app/workflows/chat-sandbox-runtime.ts (ours 857 lines vs upstream's slimmed 121)

Apply intent, not blind-copy. Upstream's version deletes the managed-runtime engine entirely. Our fork MUST NOT replicate that deletion.

Changes to make:

  • Add getReadySessionSandbox({ sessionId, userId }) — kick → waitForSandboxProvisioningRun → re-read → assert active.
  • Switch the chat reconnect path to the bare connectSandbox(sandboxState) form (no options).
  • Remove the inline full-options connectSandbox({ state, options }) provision logic from the chat path (it moves into provisioning.ts).

Changes NOT to make:

  • PRESERVE ensureManagedRuntimeEnvironment and its call site / ordering.
  • PRESERVE WorkspaceStartupReporter and all startup-log messages verbatim.
  • PRESERVE emitSessionEvent observability instrumentation.
  • PRESERVE sendStart(params.assistantId) and its current position in the execution order (do NOT pre-empt the fix: #879 honor guardrails embedded in the run's definitionSnapshot #884 responsibility shift).
  • PRESERVE assistantId / chatId / workflowRunId params on resolveChatSandboxRuntime.
  • PRESERVE runtimeMode and managedRuntime fields on ResolvedChatSandboxRuntime return type.

packages/sandbox — No change

POC confirmed: packages/sandbox/factory.ts already supports both the full-options provision form and the bare reconnect form. Zero changes to packages/sandbox are needed.

Double-fire check

provisionSessionSandbox calls kickSandboxLifecycleWorkflow({ reason: "sandbox-created" }). Today's resolveChatSandboxRuntime also kicks it after the inline connect. After this port, the chat path must NOT re-fire kickSandboxLifecycleWorkflow for the same provisioning — that responsibility moves into provisioning.ts. Verify and guard explicitly.

Compatibility

Migration is a nullable text column add — backward compatible, zero-downtime. Existing sessions with sandboxProvisioningRunId IS NULL behave correctly; the lazy chat-path kick still works as fallback.

In scope

  • Schema column sandbox_provisioning_run_id + generated migration 0048 (NOT hand-copied from upstream 0036).
  • Four new sessions.ts exports: SessionRecord type + 3 atomic helpers.
  • Three new files: provisioning.ts, provisioning-kick.ts, sandbox-provisioning.ts (+ workflow registration).
  • The non-blocking kick in POST /api/sessions.
  • Fork-aware rewrite of the chat-path reconnect in chat-sandbox-runtime.ts (getReadySessionSandbox + bare reconnect), preserving the managed-runtime engine, startup reporter, observability, and sendStart ordering.
  • All six behavior tests listed above (red-first).

Out of scope

This is part 1 of 4 deferred upstream ports. Dependency order: #880 (this) → #881/#116 (soft-dep) → #883/#117 (independent) → #884/#118 (hard-dep on #880 AND #883).

Research and context sources

  • Upstream commit ed2aa3e7 (PR fix: loop run detail page's live polling wedges — shows stale 'Running' snapshot long after the run failed #880, "Start sandbox provisioning on session create") — inspected directly via git show ed2aa3e7. 11 files, +2202/-187; key additions are lib/sandbox/provisioning.ts (+296), lib/sandbox/provisioning-kick.ts (+113), app/workflows/sandbox-provisioning.ts (+60), lib/db/sessions.ts (+66 — confirmed 4 new exports), and slimming of app/workflows/chat-sandbox-runtime.ts (-187 net to 121 lines). Schema adds sandboxProvisioningRunId: text("sandbox_provisioning_run_id").
  • POC/upstream-880-provisioning/FINDINGS.md — POC verdict PASS (low-risk). Proved: (a) dual connectSandbox form already in packages/sandbox/factory.ts → zero packages/sandbox changes; (b) atomic claim/clear semantics (first-claim-wins under 2-way and 50-way races; clearIfOwned only clears the owning run). Limitation: contract proof only, no live sandbox booted.
  • docs/plans/active-plan.md — the fix: loop run detail page's live polling wedges — shows stale 'Running' snapshot long after the run failed #880 digest (Phase 1 findings + Phase 2 POC results), dependency order, and the CRUX note that chat-sandbox-runtime.ts must preserve the managed-runtime engine.
  • docs/process/feature-ticket-format.md — required issue shape.
  • docs/process/managed-runtime-proof-standard.md — proof standard this issue must satisfy because it touches the managed-runtime path.

Agent todo checklist

  • Re-read git show ed2aa3e7, POC/upstream-880-provisioning/FINDINGS.md, and the CRUX note in docs/plans/active-plan.md.
  • Add sandboxProvisioningRunId: text("sandbox_provisioning_run_id") to the sessions table in apps/web/lib/db/schema.ts between lifecycleRunId and lifecycleError.
  • Run bun run --cwd apps/web db:generate; confirm it produces migration 0048 (do not hand-copy upstream 0036). Commit the generated .sql + meta.
  • Add to apps/web/lib/db/sessions.ts: SessionRecord type export, updateSessionIfNotArchived, claimSessionSandboxProvisioningRunId, clearSessionSandboxProvisioningRunIdIfOwned (mirror claimSessionLifecycleRunId).
  • Add apps/web/lib/sandbox/provisioning.ts (provisionSessionSandbox, ProvisionSessionSandboxResult, SessionArchivedDuringProvisioningError) — full-options connect, token mint/revoke, updateSessionIfNotArchived, stop-on-archive-race, global skills, lifecycle kick. Reconcile getGitUser signature (fork takes userId; upstream takes UserRecord).
  • Add apps/web/lib/sandbox/provisioning-kick.ts (kickSandboxProvisioningWorkflow, waitForSandboxProvisioningRun) with live-run dedupe.
  • Add apps/web/app/workflows/sandbox-provisioning.ts durable workflow (port from confirmed upstream source at ed2aa3e7) and register it with the workflow runtime.
  • Write the failing route test (kick called with new session id; kick-throws still returns 200) and confirm RED.
  • Add the non-blocking kick before return Response.json(result) in apps/web/app/api/sessions/route.ts; preserve botid/rate-limit/composio/profile-validation; OMIT managed-template-trial.
  • Write failing tests for atomic claim idempotency and archived-mid-provisioning; confirm RED.
  • Rewrite the chat-path reconnect in apps/web/app/workflows/chat-sandbox-runtime.ts: add getReadySessionSandbox, switch to bare connectSandbox(sandboxState), REMOVE the inline provision logic now living in provisioning.ts, PRESERVE ensureManagedRuntimeEnvironment / WorkspaceStartupReporter / emitSessionEvent / sendStart ordering / assistantId/chatId/workflowRunId params / runtimeMode + managedRuntime return fields.
  • Verify kickSandboxLifecycleWorkflow does not double-fire (provisioning owns it; chat path must not re-fire for the same provisioning).
  • Make all behavior tests GREEN.
  • Run bun test apps/web/app/api/sessions/route.test.ts and the adjacent sandbox/workflow suites.
  • Run git diff --check and bun --bun run ci.
  • Capture managed-runtime proof evidence (Level 1 minimum; Level 2 if sandbox infra is available) and record limitations.

Tests to add first (red-first)

Each test must be observed RED before implementation, with the named expected-failure reason.

  1. BT-001 — Kick fires on createapps/web/app/api/sessions/route.test.ts: mock @/lib/sandbox/provisioning-kick; assert a successful POST /api/sessions calls kickSandboxProvisioningWorkflow with String(body.session.id).

    • Expected RED: provisioningKickCalls is empty / module not imported (kick not wired yet).
    • Upstream test reference: ed2aa3e7 adds this exact test to route.test.ts with provisioningKickCalls tracking — use the same mock shape but adapt for fork (omit managed-template-trial mock).
  2. BT-002 — Kick failure does not block create — same file: make the mocked kick reject; assert the response is still 200 and the session payload is returned.

    • Expected RED: the unhandled rejection / missing .catch propagates and the request does not return 200.
  3. BT-003 — Claim idempotency (first-claim-wins) — a sessions.ts/provisioning-level test: two concurrent claimSessionSandboxProvisioningRunId(sessionId, runId) calls; assert exactly one returns true.

    • Expected RED: claimSessionSandboxProvisioningRunId does not exist (import error / undefined).
  4. BT-004 — Archived mid-provisioning — a provisioning.ts test: simulate the session becoming archived so updateSessionIfNotArchived returns no row; assert sandbox.stop() is called and SessionArchivedDuringProvisioningError is thrown.

    • Expected RED: provisionSessionSandbox / SessionArchivedDuringProvisioningError do not exist.
  5. BT-005 — Chat awaits provisioning — a chat-sandbox-runtime.ts test: with the sandbox not yet active, assert resolveChatSandboxRuntime calls waitForSandboxProvisioningRun and then bare-reconnects (connectSandbox(sandboxState) with no options), rather than running the full-options provision form.

    • Expected RED: chat path still runs the inline full-options connectSandbox({ state, options }) and never calls waitForSandboxProvisioningRun.
  6. BT-006 — Managed-runtime fields preserved — a chat-sandbox-runtime.ts test: assert resolveChatSandboxRuntime returns runtimeMode and managedRuntime fields populated for managed-runtime sessions.

    • Expected RED (pre-implementation): if the chat-path rewrite accidentally removes those return fields.

Observability and user feedback

This issue touches managed runtime, so it must satisfy the Managed Runtime Proof Standard with real evidence (not a label).

  • User-visible status: setting-up workspace status ("Setting up the workspace...") on the first chat turn when the sandbox is not yet active; lifecycleState provisioning → active (or failed with lifecycleError) visible on the session row.
  • Responsible modules / structured events:
    • sandboxProvisioningWorkflow run logs (claim outcome, skip reasons: session-not-found, run-replaced, session-archived).
    • provisioning-kick dedupe outcomes (started / existing / active / skipped) with runId.
    • emitSessionEvent / managed_runtime.profile.* events from ensureManagedRuntimeEnvironment must continue to fire for managed-runtime sessions (setup, probe, verification, finish).
  • Typed error kind: SessionArchivedDuringProvisioningError (name === "SessionArchivedDuringProvisioningError"); provisioning failures recorded as lifecycleState: "failed" + lifecycleError message.
  • Correlation IDs: sessionId, chatId, workflowRunId, sandboxName, profileId (and managedRuntimeProfileRunId for managed runs), runId for the provisioning run.
  • Redaction: Never log the GitHub installation token (setupToken.token is minted then revoked); never log raw sandboxState secrets, provider tokens, env values, or prompt/session content.
  • Debug recipes:
    • grep '"sessionId":"<id>"' logs | grep sandbox-provisioning — trace a provisioning run.
    • grep '"workflowRunId":"<runId>"' logs | grep managed_runtime.profile — confirm the managed profile still executed for the chat turn.
    • DB: select id, lifecycle_state, sandbox_provisioning_run_id, lifecycle_error from sessions where id = '<id>' — confirm the lease was set then cleared and lifecycle reached active.

Proof Level

  • Target level: Level 1 (Local Deterministic Proof) required; Level 2 (Local/Live Sandbox Proof) if sandbox infra is available at port time.
  • Why sufficient: the behavior contract (kick wiring, claim/clear atomicity, archive race, chat wait-then-reconnect, managed-runtime preservation) is fully assertable with deterministic tests; the live connectSandbox path is the same one production already exercises (POC contract-proved), so Level 1 + the existing managed-runtime suites bound the residual risk. Record the no-live-boot limitation explicitly.

Required Evidence

  • Claim named (e.g. "this run used managed runtime profile <id>@<version>").
  • Provenance captured (sessionId, chatId, workflowRunId, profileRunId, sandboxName, timestamps).
  • Coordinator enforcement covered (managed mode still strips direct coding tools — existing managed-runtime tests).
  • Profile setup/probe results captured.
  • Work attributed to managed worker/sandbox.
  • Independent verification captured (test commands + any sandbox/service evidence).
  • User-visible status/inspector feedback captured.
  • Limitations surfaced (no live VM boot in CI).
  • Secrets redacted.

Regression harness plan

  • Route test (apps/web/app/api/sessions/route.test.ts): kick-fires-on-create and kick-failure-still-200. Fail-before: assertions fail without the wired kick / .catch. Pass-after: both pass. This is the durable signal for the create-path protected behavior. Upstream adds provisioningKickCalls tracking with mock.module("@/lib/sandbox/provisioning-kick", ...) — use the same mock shape in the fork's test file.
  • Claim/clear unit test: first-claim-wins + clearIfOwned-only-by-owner. Fail-before: helpers missing. Pass-after: race resolves to one winner. Mirrors the proven claimSessionLifecycleRunId coverage style (line 323 of sessions.ts).
  • Chat-runtime test: wait-then-bare-reconnect + managed-runtime fields preserved on the return. Fail-before: chat path still inline-provisions. Pass-after: it awaits the run and bare-reconnects while runtimeMode/managedRuntime remain populated.
  • Managed-runtime suites: existing managed-runtime profile-execution tests must stay green (no regression to ensureManagedRuntimeEnvironment / managed_runtime.profile.*).
  • If a live-boot smoke is not practical in CI, the PR must state so and attach the manual/POC contract proof as the substitute.

TDD audit trail

All three commits must appear in git history in order:

  1. Red commit (test(red): TASK-115 failing tests for...): failing route + claim + archive + chat-wait tests committed test-only on the work branch. Record the RED command output and reason in the commit message.
  2. Green commit (feat: TASK-115 implement...): smallest implementation (schema/migration + helpers + 3 new files + kick wiring + chat-path rewrite) that turns the targeted tests green.
  3. Regression commit (test(regression): TASK-115 regression coverage for...): regression tests that would fail if the green commit were reverted.

Exception: none anticipated. If any behavior cannot be covered by a deterministic test, record the exception and the manual proof in the PR.

Regression risks and concerns

  • Managed-runtime engine deletion risk (highest): the temptation to mirror upstream's slimmed 121-line chat-sandbox-runtime.ts would delete ensureManagedRuntimeEnvironment, the startup reporter, and observability. Mitigation: preserve-and-extract, guarded by the managed-runtime suites and the chat-runtime test.
  • kickSandboxLifecycleWorkflow double-fire: provisioning now owns the lifecycle kick; the chat path must not re-fire it for the same provisioning. Mitigation: explicit checklist verification + review.
  • sendStart ordering / start-chunk: sendStart(assistantId) must keep its current position and not be duplicated; feat: port upstream #884 — parallelize chat workflow startup #118 later shifts this responsibility, so do not pre-empt it here.
  • Archive race: if updateSessionIfNotArchived is not used (or the stop path is skipped), an archived session could keep stale sandboxState or orphan a VM. Mitigation: the archive-race test (BT-004).
  • Migration numbering: hand-copying upstream 0036 would corrupt our journal. Mitigation: regenerate as 0048; checklist enforces it.
  • Workflow registration: forgetting to register sandboxProvisioningWorkflow means the kick starts an unknown workflow. Mitigation: registration checklist item + the kick test exercising start.
  • getGitUser signature divergence: upstream's provisioning.ts getGitUser takes a UserRecord; our runtime's took a userId. Reconcile during extraction (the new provisioning.ts loads a UserRecord via getUserById).
  • managed-template-trial omission: upstream route.test.ts tests a module our fork doesn't carry. The fork's route test must not import or mock that module.

Deploy or migration impact

  • Migration 0048: single nullable text column add to sessions — zero-downtime, backward compatible, no backfill. Runs automatically during bun run build on every Vercel deploy (preview gets its own Neon branch via Neon branching; prod uses main).
  • Workflow registration: the new sandboxProvisioningWorkflow must be deployed/registered for kicks to resolve.
  • Rollback safety: kick errors are swallowed at the API layer, and the chat path re-kicks provisioning on demand, so a partial rollout (column present, workflow not yet warm) degrades to lazy chat-path provisioning rather than failing session creation. The nullable column is safe to leave in place on rollback.

Definition of done

  • Schema column added and migration 0048 generated + committed (not hand-copied from upstream 0036).
  • Four sessions.ts exports added: SessionRecord type, updateSessionIfNotArchived, claimSessionSandboxProvisioningRunId, clearSessionSandboxProvisioningRunIdIfOwned.
  • provisioning.ts, provisioning-kick.ts, and sandbox-provisioning.ts added; workflow registered.
  • Non-blocking kick wired into POST /api/sessions with all fork-only checks preserved and managed-template-trial omitted.
  • chat-sandbox-runtime.ts rewritten to wait-then-bare-reconnect while preserving ensureManagedRuntimeEnvironment, WorkspaceStartupReporter, emitSessionEvent, sendStart ordering, assistantId/chatId/workflowRunId params, and runtimeMode/managedRuntime returns.
  • kickSandboxLifecycleWorkflow confirmed not double-firing.
  • All six behavior tests (BT-001 through BT-006) RED-first then GREEN; managed-runtime suites stay green.
  • git diff --check clean and bun --bun run ci passes.
  • Managed Runtime Proof Standard evidence bundle captured (Level 1 minimum) with limitations surfaced.
  • PR opened against dennisonbertram/fork-open-agents (never vercel-labs/open-agents) with a summary of what changed for users and what was verified.
  • Three TDD audit-trail commits in order (red → green → regression) visible in git log.

Assumptions and alternatives

Assumptions

  1. Upstream commit reachable: ed2aa3e7 is reachable via the upstream remote fetch in this fork's git history — verified by git log --all --oneline | grep ed2aa3e7. Full diff can be inspected at any time with git show ed2aa3e7.
  2. Migration 0048 is next free: Confirmed by listing apps/web/lib/db/migrations/ — latest is 0047_cheerful_thunderbolt_ross.sql. db:generate will produce 0048.
  3. packages/sandbox needs no changes: POC-proven — connectSandbox in packages/sandbox/factory.ts already supports both the full-options provision form and the bare reconnect form.
  4. Atomic claim pattern already production-proven: claimSessionLifecycleRunId (line 323 of sessions.ts) uses the identical IS NULL conditional-update pattern. Adding a second lease column mirrors a proven primitive.
  5. SDK v4 throughout: This port runs on workflow@^4.2.0-beta.72. The "use workflow" / "use step" directives in sandbox-provisioning.ts are confirmed from the upstream source at ed2aa3e7.
  6. No live VM boot in CI: Provisioning requires live Vercel sandbox credentials. Level 1 deterministic tests are the gate; the live connectSandbox path is the same production already exercises.

Alternatives considered

  1. Eager provisioning (this issue) vs lazy-with-warm-pool: Upstream chose eager kick-at-session-create. An alternative would be a warm pool of pre-provisioned sandboxes. Upstream's approach is simpler (no pool management), fits the existing lifecycle model, and is the keystone for dependent ports. Warm-pool is out of scope.
  2. Clean cherry-pick vs adapt-port: A clean git cherry-pick ed2aa3e7 would silently delete the managed-runtime engine. The adapt-port approach (apply intent, preserve fork constructs) is the only safe path. This was the decision made during the POC phase.
  3. Migration: db:generate vs hand-copy: Hand-copying upstream's 0036_faulty_yellow_claw.sql with a renumbered filename would corrupt the Drizzle journal (snapshot IDs would mismatch). db:generate is the only correct path.
  4. Provisioning in route handler vs durable workflow: Upstream uses a durable workflow (sandboxProvisioningWorkflow) with a lease claim/clear, not an inline async fire-and-forget. This guards against double-provisioning and gives observability. The inline approach would be simpler but unsafe under concurrent kicks.

Grooming clarifications (2026-05-31)

Groomer: issue-groomer agent, 2026-05-31.
Provenance: Body enriched by cross-referencing the following read-only sources:

  • gh issue view 115 — original issue body (already detailed; this enrichment adds upstream diff verification, confirmed file states, and reconciliation specifics).
  • git show ed2aa3e7 --stat — upstream commit reachable; 11 files, +2202/-187 confirmed.
  • git show ed2aa3e7 -- apps/web/app/api/sessions/route.ts — exact 7-line kick diff confirmed; no managed-template-trial in ed2aa3e7.
  • git show ed2aa3e7 -- apps/web/lib/db/sessions.ts — all 4 new exports confirmed (type + 3 helpers) with exact Drizzle predicate structure.
  • git show ed2aa3e7 -- apps/web/app/workflows/sandbox-provisioning.ts — full 60-line workflow source confirmed; v4 "use workflow"/"use step" directives confirmed.
  • git show ed2aa3e7 -- apps/web/app/api/sessions/route.test.tsprovisioningKickCalls mock shape confirmed; managed-template-trial test divergence noted.
  • apps/web/lib/db/schema.ts grep — sandboxProvisioningRunId NOT present; lifecycleRunId at line 278, lifecycleError at line 279; confirmed insertion point.
  • apps/web/app/api/sessions/route.ts grep — kickSandboxProvisioningWorkflow NOT imported; no provisioning kick; file is 428 lines.
  • apps/web/lib/db/sessions.ts grep — claimSessionLifecycleRunId exists at line 323; updateSessionIfNotArchived, claimSessionSandboxProvisioningRunId, clearSessionSandboxProvisioningRunIdIfOwned, SessionRecord type export — all ABSENT.
  • apps/web/app/workflows/chat-sandbox-runtime.ts grep — connectSandbox (full-options form, line 736), ensureManagedRuntimeEnvironment (line 247), sendStart (line 201), resolveChatSandboxRuntime (line 654) — all present; getReadySessionSandbox, waitForSandboxProvisioningRun — ABSENT (not yet ported).
  • apps/web/lib/db/migrations/ listing — latest is 0047_cheerful_thunderbolt_ross.sql; next will be 0048.
  • POC/upstream-880-provisioning/FINDINGS.md — verdict PASS (low-risk); dual-form and atomic claim both proven.
  • docs/plans/active-plan.md — dependency order and CRUX note confirmed.
  • docs/context/command-intent.md — canonical fix: loop run detail page's live polling wedges — shows stale 'Running' snapshot long after the run failed #880 description and reconciliation notes confirmed.

Changes from original body: No behavioral changes to requirements. Enrichments added:

  1. Upstream diff content verified from git show ed2aa3e7 (not just the commit stat).
  2. Exact Drizzle predicate structure for updateSessionIfNotArchived and clearSessionSandboxProvisioningRunIdIfOwned confirmed from upstream diff.
  3. Current fork file states confirmed by inspection (what exists vs what is absent).
  4. managed-template-trial omission noted as confirmed — not in ed2aa3e7, not in fork.
  5. BT-006 (managed-runtime fields preserved) added as a distinct behavioral test.
  6. getGitUser signature divergence elevated in the reconciliation spec (it is a concrete friction point, not an open question).
  7. Epic/dependent-issue references cross-referenced to issue numbers (feat: port upstream #881 — expose sandbox actions with lazy startup #116, feat: port upstream #883 — adopt Workflow v5 stream handling (WorkflowChatTransport) #117, feat: port upstream #884 — parallelize chat workflow startup #118, feat: epic — port 4 deferred upstream feature commits (#880/#881/#883/#884) #119) consistent with the repo's actual issue numbering.
  8. POC reproduce commands preserved verbatim for the implementing agent.

Metadata

Metadata

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions