diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 9257b634..71b9ae67 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -12136,6 +12136,9 @@ Canonical profile overlay merged over the spawned profile. > `optional` **timeoutMs?**: `number` +Caller-owned deadline for each bridge turn. Runtime enforces it locally and sends the + same value in `execution.timeoutMs` so cli-bridge cannot substitute its own cutoff. + ##### sessionId? > `optional` **sessionId?**: `string` @@ -12228,6 +12231,9 @@ Canonical profile overlay merged over the spawned profile. > `optional` **timeoutMs?**: `number` +Caller-owned deadline for each bridge turn. Runtime enforces it locally and sends the + same value in `execution.timeoutMs` so the bridge-owned process follows the same policy. + ##### sessionId? > `optional` **sessionId?**: `string` diff --git a/src/runtime/supervise/runtime.ts b/src/runtime/supervise/runtime.ts index cf5ea6fa..5b9d1e98 100644 --- a/src/runtime/supervise/runtime.ts +++ b/src/runtime/supervise/runtime.ts @@ -221,6 +221,8 @@ export interface CliWorktreeBridgeSeam { model?: string /** Canonical profile overlay merged over the spawned profile. */ agentProfile?: AgentProfile + /** Caller-owned deadline for each bridge turn. Runtime enforces it locally and sends the + * same value in `execution.timeoutMs` so cli-bridge cannot substitute its own cutoff. */ timeoutMs?: number /** Stable cli-bridge session id. Defaults to `bridge-worktree-${runId}`. */ sessionId?: string @@ -283,6 +285,8 @@ export interface BridgeSeam { cwd?: string /** Canonical profile overlay merged over the spawned profile. */ agentProfile?: AgentProfile + /** Caller-owned deadline for each bridge turn. Runtime enforces it locally and sends the + * same value in `execution.timeoutMs` so the bridge-owned process follows the same policy. */ timeoutMs?: number /** Stable, caller-owned cli-bridge session id for harness-side resume. Defaults * to a freshly minted per-spawn id so each worker is its own resumable session. */ @@ -313,6 +317,7 @@ const routerSeamKey = 'router' const sandboxSeamKey = 'sandbox' const cliSeamKey = 'cli' const bridgeSeamKey = 'bridge' +const maxBridgeTimeoutMs = 2_147_483_647 const cliWorktreeSeamKey = 'cli-worktree' const providerSeamKey = 'provider' @@ -1316,6 +1321,16 @@ export const bridgeExecutor: ExecutorFactory = (spec, ctx) => { 'bridgeExecutor: bridgeUrl + bridgeBearer and a profile or bridge model are required', ) } + if ( + seam.timeoutMs !== undefined && + (!Number.isSafeInteger(seam.timeoutMs) || + seam.timeoutMs < 1 || + seam.timeoutMs > maxBridgeTimeoutMs) + ) { + throw new ValidationError( + `bridgeExecutor: timeoutMs must be an integer from 1 to ${maxBridgeTimeoutMs}`, + ) + } const maxTurns = seam.maxTurns ?? 200 // A stable per-spawn session id (caller can pin one) — cli-bridge keys harness // resume off this exactly as a box id keys a sandbox session. @@ -1696,12 +1711,13 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable { - timedOut = true - abortTurn() - }, seam.timeoutMs) - : undefined + const timer = + seam.timeoutMs !== undefined + ? setTimeout(() => { + timedOut = true + abortTurn() + }, seam.timeoutMs) + : undefined const cleanup = () => { external.removeEventListener('abort', abortTurn) if (timer) clearTimeout(timer) @@ -1719,6 +1735,10 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable { expect(lastBridgeUrl?.host).toBe('bridge.test') }) + it('carries the caller-owned turn timeout in the structured execution request', async () => { + const seen: Array> = [] + bridgeHttpHandler = (payload) => { + seen.push(payload) + return sse('done', 1, 1) + } + const client = inlineSandboxClient( + createExecutor({ + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'pi/tangle-router/deepseek-v4-flash', + timeoutMs: 14_400_000, + }), + ) + + await runOnce(client, 'work until the task is complete') + + expect(seen).toHaveLength(1) + expect(seen[0]?.execution).toEqual({ kind: 'host', timeoutMs: 14_400_000 }) + }) + + it.each([0, -1, 1.5, 2_147_483_648])( + 'rejects unsupported caller timeout %s before dispatch', + async (timeoutMs) => { + const client = inlineSandboxClient( + createExecutor({ + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'pi/tangle-router/deepseek-v4-flash', + timeoutMs, + }), + ) + + await expect(runOnce(client, 'do not dispatch')).rejects.toThrow( + /timeoutMs must be an integer/, + ) + expect(lastBridgeUrl).toBeNull() + }, + ) + it('a per-create backend override targets the cell model as harness/model', async () => { const seen: Array> = [] bridgeHttpHandler = (payload) => {