From 1e235cd7580ee865322af8fbd715ae33c1bb5d93 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 8 May 2026 07:27:46 -0500 Subject: [PATCH] fix(sandbox): derive sandbox_expires_at from getState(), use buildActiveLifecycleUpdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores parity with open-agents' (working) `handleCreateSandboxRequest`, which used `buildActiveLifecycleUpdate(nextState)` to derive the lifecycle write — reading `expiresAt` from the state object that `sandbox.getState()` returns. api's `createSandboxHandler` was instead reading from the top-level `sandbox.expiresAt` handle property, which the SDK doesn't reliably set on prebuilt-snapshot creation paths (org-snapshot restore). For those provisions, api was writing `sandbox_expires_at: null` to the session row. Cascading consequence: the lifecycle workflow's `evaluateSandboxLifecycle` interprets a null expiry as "no live runtime" and immediately writes `lifecycle_state: "hibernated"` on the row — within a few seconds of provision, before the user has a chance to use the sandbox. The chat UI then sticks on "Sandbox is initializing…" because status reports `no_sandbox`. Side benefits picked up by switching to `buildActiveLifecycleUpdate`: - `hibernate_after` is now set on provision (was being left null, forcing the lifecycle workflow to fall back to inactivity-based due time computed from `last_activity_at + INACTIVITY_TIMEOUT`). - `lifecycle_error` is explicitly cleared on provision, matching open-agents. - `last_activity_at` is now sourced from the same `Date` instance as `hibernate_after`, so they're consistent. TDD: red tests for (a) expires sourced from `getState().expiresAt` when the top-level handle property is undefined, (b) `hibernate_after` is set on the update — both green after the switch. Tests 2627 / 2627 pass. Lint + tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/createSandboxHandler.test.ts | 41 +++++++++++++++++++ lib/sandbox/createSandboxHandler.ts | 14 ++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/lib/sandbox/__tests__/createSandboxHandler.test.ts b/lib/sandbox/__tests__/createSandboxHandler.test.ts index c48b0cb5..4275c82a 100644 --- a/lib/sandbox/__tests__/createSandboxHandler.test.ts +++ b/lib/sandbox/__tests__/createSandboxHandler.test.ts @@ -175,6 +175,47 @@ describe("createSandboxHandler", () => { ); }); + // Regression: org-snapshot-restored sandboxes don't always populate + // `sandbox.expiresAt` at the top level — that field comes from the + // SDK only on certain creation paths. The runtime expiry is *always* + // available inside `sandbox.getState().expiresAt`, which open-agents + // relies on via `buildActiveLifecycleUpdate`. Reading from the + // top-level handle was writing `sandbox_expires_at: null` for prebuilt + // org-snapshot provisions, which then caused the lifecycle workflow + // to immediately mark the session `hibernated` because it interprets + // a null expiry as "no live runtime". + it("derives sandbox_expires_at from sandbox.getState().expiresAt, not the top-level handle", async () => { + const stateExpiresAt = Date.parse("2030-06-15T00:00:00.000Z"); + vi.mocked(connectSandbox).mockResolvedValueOnce( + fakeSandbox({ + // Top-level expiresAt is undefined (mirrors the org-snapshot path) + expiresAt: undefined, + getState: () => ({ + type: "vercel", + sandboxName: "session-sess-1", + expiresAt: stateExpiresAt, + }), + }) as unknown as Awaited>, + ); + + await createSandboxHandler(makeReq()); + + expect(updateSession).toHaveBeenCalledWith( + "sess-1", + expect.objectContaining({ + sandbox_expires_at: new Date(stateExpiresAt).toISOString(), + }), + ); + }); + + it("sets hibernate_after on the session row so the lifecycle workflow has a deadline", async () => { + await createSandboxHandler(makeReq()); + + const updateArgs = vi.mocked(updateSession).mock.calls[0]?.[1] ?? {}; + expect(updateArgs).toHaveProperty("hibernate_after"); + expect(typeof (updateArgs as { hibernate_after: unknown }).hibernate_after).toBe("string"); + }); + it("plumbs the service github token into connectSandbox options", async () => { await createSandboxHandler(makeReq()); diff --git a/lib/sandbox/createSandboxHandler.ts b/lib/sandbox/createSandboxHandler.ts index 83922877..6d391000 100644 --- a/lib/sandbox/createSandboxHandler.ts +++ b/lib/sandbox/createSandboxHandler.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse, after } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateCreateSandboxBody } from "@/lib/sandbox/validateCreateSandboxBody"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { buildActiveLifecycleUpdate } from "@/lib/sandbox/buildActiveLifecycleUpdate"; import { connectSandbox } from "@/lib/sandbox/factory"; import { findOrgSnapshot } from "@/lib/sandbox/findOrgSnapshot"; import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; @@ -124,14 +125,17 @@ export async function createSandboxHandler(request: NextRequest): Promise