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
awaitcreateSandboxHandler(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
importmsfrom"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
returnNextResponse.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
constactive=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
returnNextResponse.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
interfaceKickInput{
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
exportfunctionkickSandboxLifecycleWorkflow(input: KickInput): void{
32-
voidrunKick(input).catch(error=>
41+
consttask=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+
voidtask;
3551
}
3652

3753
asyncfunctionrunKick(input: KickInput): Promise<void>{

0 commit comments

Comments
 (0)