Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions lib/sandbox/__tests__/createSandboxHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof connectSandbox>>,
);

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());

Expand Down
14 changes: 9 additions & 5 deletions lib/sandbox/createSandboxHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -124,14 +125,17 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe

if (sessionRow && sandbox.getState) {
const nextState = sandbox.getState() as Json;
const expiresAt =
typeof sandbox.expiresAt === "number" ? new Date(sandbox.expiresAt).toISOString() : null;
// Match open-agents' contract: derive lifecycle fields from the
// state object's `expiresAt` (always populated by the SDK, even on
// prebuilt-snapshot paths) rather than `sandbox.expiresAt`, which
// is only set on some creation paths and was leaving
// `sandbox_expires_at: null` for org-snapshot-restored provisions —
// which the lifecycle workflow then interpreted as "no live runtime"
// and immediately wrote `lifecycle_state: "hibernated"`.
await updateSession(sessionRow.id, {
sandbox_state: nextState,
lifecycle_state: "active",
lifecycle_version: sessionRow.lifecycle_version + 1,
sandbox_expires_at: expiresAt,
last_activity_at: new Date().toISOString(),
...buildActiveLifecycleUpdate(nextState),
snapshot_url: null,
snapshot_created_at: null,
});
Expand Down
Loading