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
6 changes: 6 additions & 0 deletions docs/api/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand Down
32 changes: 26 additions & 6 deletions src/runtime/supervise/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -1316,6 +1321,16 @@ export const bridgeExecutor: ExecutorFactory<unknown> = (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.
Expand Down Expand Up @@ -1696,12 +1711,13 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable<Usage
else external.addEventListener('abort', abortTurn)
interruptSig.addEventListener('abort', abortTurn, { once: true })
let timedOut = false
const timer = seam.timeoutMs
? setTimeout(() => {
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)
Expand All @@ -1719,6 +1735,10 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable<Usage
run_id: activeRun.id,
session_id: args.sessionId,
...(seam.cwd ? { cwd: seam.cwd } : {}),
execution: {
kind: 'host' as const,
...(seam.timeoutMs !== undefined ? { timeoutMs: seam.timeoutMs } : {}),
},
agent_profile: args.profile,
messages,
}
Expand Down
42 changes: 42 additions & 0 deletions tests/runtime/bridge-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,48 @@ describe('bridgeExecutor over node:http', () => {
expect(lastBridgeUrl?.host).toBe('bridge.test')
})

it('carries the caller-owned turn timeout in the structured execution request', async () => {
const seen: Array<Record<string, unknown>> = []
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<Record<string, unknown>> = []
bridgeHttpHandler = (payload) => {
Expand Down