Skip to content

Commit 34b1041

Browse files
sweetmantechclaude
andcommitted
fix(sandbox): adopt open-agents scheduleBackgroundWork pattern for lifecycle kick
Smoke test caught: the lifecycle kick fired but its async chain (selectSessions → claim lease → start workflow) never completed because the serverless function tore down on response. No [kickSandboxLifecycleWorkflow] log lines, no workflow run started. Original PR placed `after()` from next/server inside the kick lib — which violated open-agents' architecture: the kick is a generic fan-out lib, the platform-specific scheduler belongs at the call site. Refactored to match open-agents' open / api conventions: - `kickSandboxLifecycleWorkflow` accepts an optional `scheduleBackgroundWork: (task: Promise<void>) => void` parameter. Default behavior is `void task` (matches open-agents fallback), scheduler used when provided. - createSandboxHandler + getSandboxStatusHandler pass `scheduleBackgroundWork: task => after(() => task)`. Same pattern api already uses in `lib/agents/createPlatformRoutes.ts:62` and `app/api/chat/slack/route.ts:16` for waitUntil-style background work. Test updated to use `expect.objectContaining` since the kick now receives an extra `scheduleBackgroundWork` callback. Suite stays at 2579. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4b70015 commit 34b1041

4 files changed

Lines changed: 42 additions & 13 deletions

File tree

lib/sandbox/__tests__/createSandboxHandler.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,13 @@ describe("createSandboxHandler", () => {
288288
it("kicks the sandbox lifecycle workflow with reason='sandbox-created' when sessionId is provided", async () => {
289289
await createSandboxHandler(makeReq());
290290

291-
expect(kickSandboxLifecycleWorkflow).toHaveBeenCalledWith({
292-
sessionId: "sess-1",
293-
reason: "sandbox-created",
294-
});
291+
expect(kickSandboxLifecycleWorkflow).toHaveBeenCalledWith(
292+
expect.objectContaining({
293+
sessionId: "sess-1",
294+
reason: "sandbox-created",
295+
scheduleBackgroundWork: expect.any(Function),
296+
}),
297+
);
295298
});
296299

297300
it("does not kick the lifecycle workflow when no sessionId is provided", async () => {

lib/sandbox/createSandboxHandler.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import ms from "ms";
2-
import { NextRequest, NextResponse } from "next/server";
2+
import { NextRequest, NextResponse, after } from "next/server";
33
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
44
import { validateCreateSandboxBody } from "@/lib/sandbox/validateCreateSandboxBody";
55
import { selectSessions } from "@/lib/supabase/sessions/selectSessions";
@@ -145,11 +145,17 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe
145145
}
146146

147147
// Register the new sandbox with the lifecycle workflow so it gets
148-
// auto-paused after SANDBOX_INACTIVITY_TIMEOUT_MS of idle. Fire-and-
149-
// forget — failure to start the workflow doesn't fail the request,
150-
// and a future status read will reclaim a stale lease if the
151-
// workflow never picked up.
152-
kickSandboxLifecycleWorkflow({ sessionId: sessionRow.id, reason: "sandbox-created" });
148+
// auto-paused after SANDBOX_INACTIVITY_TIMEOUT_MS of idle. The
149+
// kick chain (selectSessions → claim lease → start workflow) is
150+
// registered with `after()` so the serverless platform keeps the
151+
// function alive past the response until the chain completes —
152+
// without that, the chain dies on function teardown and the
153+
// workflow never starts. Failures are logged and never surfaced.
154+
kickSandboxLifecycleWorkflow({
155+
sessionId: sessionRow.id,
156+
reason: "sandbox-created",
157+
scheduleBackgroundWork: task => after(() => task),
158+
});
153159
}
154160

155161
return NextResponse.json(

lib/sandbox/getSandboxStatusHandler.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { NextRequest, NextResponse } from "next/server";
1+
import { NextRequest, NextResponse, after } from "next/server";
22
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
33
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
44
import { buildLifecycle } from "@/lib/sandbox/buildLifecycle";
@@ -55,7 +55,11 @@ export async function getSandboxStatusHandler(request: NextRequest): Promise<Nex
5555
const active = isSandboxActive(row);
5656

5757
if (active && row.lifecycle_state === "active" && Date.now() >= getLifecycleDueAtMs(row)) {
58-
kickSandboxLifecycleWorkflow({ sessionId: row.id, reason: "status-check-overdue" });
58+
kickSandboxLifecycleWorkflow({
59+
sessionId: row.id,
60+
reason: "status-check-overdue",
61+
scheduleBackgroundWork: task => after(() => task),
62+
});
5963
}
6064

6165
return NextResponse.json(

lib/sandbox/kickSandboxLifecycleWorkflow.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ import type { Tables } from "@/types/database.types";
1212
interface KickInput {
1313
sessionId: string;
1414
reason: SandboxLifecycleReason;
15+
/**
16+
* Optional scheduler for the kick chain (selectSessions → claim
17+
* lease → start workflow). Callers in serverless contexts should
18+
* pass `p => after(() => p)` (or `waitUntil(p)`) so the platform
19+
* keeps the function alive until the chain completes — without it
20+
* the chain dies when the request returns. Mirrors open-agents'
21+
* `scheduleBackgroundWork` parameter.
22+
*/
23+
scheduleBackgroundWork?: (task: Promise<void>) => void;
1524
}
1625

1726
/**
@@ -29,9 +38,16 @@ interface KickInput {
2938
* is considered stale and gets reclaimed.
3039
*/
3140
export function kickSandboxLifecycleWorkflow(input: KickInput): void {
32-
void runKick(input).catch(error =>
41+
const task = runKick(input).catch(error =>
3342
console.error(`[kickSandboxLifecycleWorkflow] failed for session ${input.sessionId}:`, error),
3443
);
44+
45+
if (input.scheduleBackgroundWork) {
46+
input.scheduleBackgroundWork(task);
47+
return;
48+
}
49+
50+
void task;
3551
}
3652

3753
async function runKick(input: KickInput): Promise<void> {

0 commit comments

Comments
 (0)