History

Switch worker approval waits to Convex onUpdate

Move Cloudflare sandbox approval handling from retry polling to Convex websocket updates so approved tool calls resume immediately without hammering callback RPCs.

Rhys Sullivan committed 9 months ago

Commit 8e4b94e1 parent 55a88e94 files · +115 −32Browse files

Unified · Split

4 files
  1. executor/packages/convex/executor_node/external_tool_call.ts +1 −1
  2. executor/packages/convex/runtimeCallbacks.ts +22 −1
  3. executor/packages/runner-sandbox-host/src/index.ts +88 −16
  4. executor/packages/runner-sandbox-host/src/isolate/harness.isolate.js +4 −14
executor/packages/convex/executor_node/external_tool_call.ts+1−1
@@ -43,7 +43,7 @@ export async function handleExternalToolCallRequest( · 42 unmodified lines
43 ok: false,43 ok: false,
44 kind: "pending",44 kind: "pending",
45 approvalId,45 approvalId,
46 retryAfterMs: 500,46 retryAfterMs: 0,
47 error: "Approval pending",47 error: "Approval pending",
48 };48 };
49 }49 }
executor/packages/convex/runtimeCallbacks.ts+22−1
@@ -1,6 +1,6 @@
1import { v } from "convex/values";1import { v } from "convex/values";
2import { internal } from "./_generated/api";2import { internal } from "./_generated/api";
3import { action, mutation } from "./_generated/server";3import { action, mutation, query } from "./_generated/server";
4import type { ToolCallResult } from "../core/src/types";4import type { ToolCallResult } from "../core/src/types";
5 5
6function requireInternalSecret(secret: string): void {6function requireInternalSecret(secret: string): void {
@@ -55,3 +55,24 @@ export const completeRun = mutation({ · 48 unmodified lines
55 });55 });
56 },56 },
57});57});
58
59export const getApprovalStatus = query({
60 args: {
61 internalSecret: v.string(),
62 runId: v.string(),
63 approvalId: v.string(),
64 },
65 handler: async (ctx, args) => {
66 requireInternalSecret(args.internalSecret);
67
68 const approval = await ctx.runQuery(internal.database.getApproval, {
69 approvalId: args.approvalId,
70 });
71
72 if (!approval || approval.taskId !== args.runId) {
73 return { status: "missing" as const };
74 }
75
76 return { status: approval.status };
77 },
78});
executor/packages/runner-sandbox-host/src/index.ts+88−16
@@ -36,7 +36,7 @@ · 35 unmodified lines
36import { Result } from "better-result";36import { Result } from "better-result";
37import { WorkerEntrypoint } from "cloudflare:workers";37import { WorkerEntrypoint } from "cloudflare:workers";
38import { api } from "@executor/convex/_generated/api";38import { api } from "@executor/convex/_generated/api";
39import { ConvexHttpClient } from "convex/browser";39import { ConvexClient, ConvexHttpClient } from "convex/browser";
40 40
41// Import isolate modules as raw text — these are loaded as JS modules inside41// Import isolate modules as raw text — these are loaded as JS modules inside
42// the dynamic isolate, NOT executed in the host worker. The *.isolate.js42// the dynamic isolate, NOT executed in the host worker. The *.isolate.js
@@ -113,6 +113,8 @@ interface BridgeProps { · 70 unmodified lines
113 taskId: string;113 taskId: string;
114}114}
115 115
116const APPROVAL_SUBSCRIPTION_TIMEOUT_MS = 10 * 60 * 1000;
117
116// ── Auth ──────────────────────────────────────────────────────────────────────118// ── Auth ──────────────────────────────────────────────────────────────────────
117 119
118/** Constant-time string comparison to prevent timing side-channels. */120/** Constant-time string comparison to prevent timing side-channels. */
@@ -168,6 +170,58 @@ export class ToolBridge extends WorkerEntrypoint<Env> { · 49 unmodified lines
168 });170 });
169 }171 }
170 172
173 private createRealtimeClient(): ConvexClient {
174 return new ConvexClient(this.props.callbackConvexUrl, {
175 skipConvexDeploymentUrlCheck: true,
176 });
177 }
178
179 private async waitForApprovalUpdate(approvalId: string): Promise<void> {
180 const client = this.createRealtimeClient();
181
182 await new Promise<void>((resolve, reject) => {
183 let settled = false;
184
185 const timer = setTimeout(() => {
186 if (settled) return;
187 settled = true;
188 unsubscribe();
189 client.close();
190 reject(new Error(`Timed out waiting for approval update: ${approvalId}`));
191 }, APPROVAL_SUBSCRIPTION_TIMEOUT_MS);
192
193 const unsubscribe = client.onUpdate(
194 (api as any).runtimeCallbacks.getApprovalStatus,
195 {
196 internalSecret: this.props.callbackInternalSecret,
197 runId: this.props.taskId,
198 approvalId,
199 },
200 (value: { status?: "pending" | "approved" | "denied" | "missing" } | null | undefined) => {
201 const status = value?.status;
202 if (!status || status === "pending") {
203 return;
204 }
205 if (status === "missing") {
206 if (settled) return;
207 settled = true;
208 clearTimeout(timer);
209 unsubscribe();
210 client.close();
211 reject(new Error(`Approval not found: ${approvalId}`));
212 return;
213 }
214 if (settled) return;
215 settled = true;
216 clearTimeout(timer);
217 unsubscribe();
218 client.close();
219 resolve();
220 },
221 );
222 });
223 }
224
171 /** Forward a tool call to the Convex callback RPC action. */225 /** Forward a tool call to the Convex callback RPC action. */
172 async callTool(toolPath: string, input: unknown, callId?: string): Promise<ToolCallResult> {226 async callTool(toolPath: string, input: unknown, callId?: string): Promise<ToolCallResult> {
173 const { callbackInternalSecret, taskId } = this.props;227 const { callbackInternalSecret, taskId } = this.props;
@@ -175,24 +229,42 @@ export class ToolBridge extends WorkerEntrypoint<Env> { · 1 unmodified line
175 ? callId229 ? callId
176 : `call_${crypto.randomUUID()}`;230 : `call_${crypto.randomUUID()}`;
177 231
178 const response = await Result.tryPromise(async () => {232 while (true) {
179 const convex = this.createConvexClient();233 const response = await Result.tryPromise(async () => {
180 return await convex.action(api.runtimeCallbacks.handleToolCall, {234 const convex = this.createConvexClient();
181 internalSecret: callbackInternalSecret,235 return await convex.action(api.runtimeCallbacks.handleToolCall, {
182 runId: taskId,236 internalSecret: callbackInternalSecret,
183 callId: effectiveCallId,237 runId: taskId,
184 toolPath,238 callId: effectiveCallId,
185 input,239 toolPath,
240 input,
241 });
186 });242 });
187 });
188 243
189 if (response.isErr()) {244 if (response.isErr()) {
190 const cause = response.error.cause;245 const cause = response.error.cause;
191 const message = cause instanceof Error ? cause.message : String(cause);246 const message = cause instanceof Error ? cause.message : String(cause);
192 return { ok: false, kind: "failed", error: `Tool callback failed: ${message}` };247 return { ok: false, kind: "failed", error: `Tool callback failed: ${message}` };
248 }
249
250 const result = response.value as ToolCallResult;
251 if (!result.ok && result.kind === "pending") {
252 if (!result.approvalId) {
253 return { ok: false, kind: "failed", error: "Approval pending without approvalId" };
254 }
255
256 const approvalId = result.approvalId;
257 const wait = await Result.tryPromise(() => this.waitForApprovalUpdate(approvalId));
258 if (wait.isErr()) {
259 const cause = wait.error.cause;
260 const message = cause instanceof Error ? cause.message : String(cause);
261 return { ok: false, kind: "failed", error: `Approval subscription failed: ${message}` };
262 }
263 continue;
264 }
265
266 return result;
193 }267 }
194
195 return response.value as ToolCallResult;
196 }268 }
197}269}
198 270
executor/packages/runner-sandbox-host/src/isolate/harness.isolate.js+4−14
@@ -3,10 +3,6 @@ import { run } from "./user-code.js"; · 2 unmodified lines
3 3
4const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:";4const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:";
5 5
6async function sleep(ms) {
7 await new Promise((resolve) => setTimeout(resolve, ms));
8}
9
10function createToolsProxy(bridge, path = []) {6function createToolsProxy(bridge, path = []) {
11 const callable = () => {};7 const callable = () => {};
12 return new Proxy(callable, {8 return new Proxy(callable, {
@@ -21,16 +17,10 @@ function createToolsProxy(bridge, path = []) { · 8 unmodified lines
21 const input = args.length > 0 ? args[0] : {};17 const input = args.length > 0 ? args[0] : {};
22 const callId = "call_" + crypto.randomUUID();18 const callId = "call_" + crypto.randomUUID();
23 19
24 while (true) {20 const result = await bridge.callTool(toolPath, input, callId);
25 const result = await bridge.callTool(toolPath, input, callId);21 if (result.ok) return result.value;
26 if (result.ok) return result.value;22 if (result.kind === "denied") throw new Error(APPROVAL_DENIED_PREFIX + result.error);
27 if (result.kind === "pending") {23 throw new Error(result.error);
28 await sleep(Math.max(50, result.retryAfterMs ?? 500));
29 continue;
30 }
31 if (result.kind === "denied") throw new Error(APPROVAL_DENIED_PREFIX + result.error);
32 throw new Error(result.error);
33 }
34 },24 },
35 });25 });
36}26}