Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/quiet-runs-stall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-sandbox-cloudflare': minor
---

Add the app-wide `stallTimeoutMs` option for both coordinator modes, with authenticated `/_bridge` and `/tool-exec` callbacks refreshing run activity on arrival and completion.
5 changes: 3 additions & 2 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,8 @@
{
"label": "Reaping & Retention",
"to": "sandbox/reaping",
"addedAt": "2026-08-04"
"addedAt": "2026-08-04",
"updatedAt": "2026-08-20"
},
{
"label": "Provisioning",
Expand All @@ -717,7 +718,7 @@
"label": "Cloudflare (Edge)",
"to": "sandbox/cloudflare",
"addedAt": "2026-06-29",
"updatedAt": "2026-08-04"
"updatedAt": "2026-08-20"
}
]
},
Expand Down
36 changes: 36 additions & 0 deletions docs/sandbox/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,42 @@ migrated in place on first read — nothing to run, but note that
`GET /runs/:id` and the WebSocket terminal `status` frame now carry the
converged status strings and field names.

### Stall watchdog

`createCloudflareSandboxAgent` sets one stall policy for the whole app. The
`stallTimeoutMs` field applies to every run in both `do-drives` (the default)
and `colocated` mode:

| Value | Behavior |
| --- | --- |
| Omitted | Treat the run as stalled after `300000` ms (five minutes) without persisted activity |
| Positive safe integer | Use that many milliseconds as the stall threshold |
| `false` | Disable stall detection |
Comment thread
coderabbitai[bot] marked this conversation as resolved.

```ts
import { grokBuildText } from '@tanstack/ai-grok-build'
import { createCloudflareSandboxAgent } from '@tanstack/ai-sandbox-cloudflare/agent'

export const agent = createCloudflareSandboxAgent({
adapter: () => grokBuildText('grok-build'),
stallTimeoutMs: 10 * 60_000,
})
```

The alarm checks every 30 seconds, or every `stallTimeoutMs` when that is
shorter, so detection can lag the configured threshold by up to one check
interval. Persisted run events refresh
`updatedAt`; authenticated `/_bridge` and `/tool-exec` callbacks also refresh it
on arrival and completion. Unknown-run and unauthorized requests do not.

Choose a timeout longer than the longest legitimate quiet period. Native
operations that emit no event and make no callback are invisible to the
watchdog. An authenticated callback is protected while it remains in flight.
A callback that never completes can leave an orphaned `running` record.
Disabling the watchdog with `false` can do the same. When a stall is detected, the watchdog marks
the run log `failed`; it may not kill the underlying agent process or container.
Use provider lifecycle controls and [reaping](./reaping) for resource cleanup.

### Three layers, three homes

Keep these separate — each has a different home on Cloudflare, and conflating
Expand Down
24 changes: 18 additions & 6 deletions docs/sandbox/reaping.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,12 +371,24 @@ export class RunReaper {
}
```

One thing this is **not** interchangeable with: the coordinator from
`@tanstack/ai-sandbox-cloudflare` ships a *stall watchdog* — an alarm that
fails run records whose log has gone quiet for too long. That is log hygiene,
not reaping: it never probes a journal for the exit sentinel and never
reclaims a sandbox. On Cloudflare you still schedule `sweepDetachedRuns`, and
a DO alarm like the one above is the natural place for it.
### Cloudflare's stall watchdog is not a reaper

`createCloudflareSandboxAgent({ stallTimeoutMs })` sets an app-wide policy for
both `do-drives` and `colocated` modes. Omit it for the `300000` ms default, pass
a positive safe-integer millisecond value to customize it, or pass `false` to
disable it. Its alarm checks about every 30 seconds.

Persisted events and authenticated `/_bridge` or `/tool-exec` callback arrival
and completion refresh the run record's `updatedAt`; unknown-run and
unauthorized requests do not. Silent native operations therefore need a timeout
longer than their longest expected quiet period. In-flight authenticated
callbacks are protected. A callback that never completes can leave an orphaned
`running` record. Disabling the watchdog with `false` can do the same.

The watchdog atomically marks a stale log `failed`, but may not kill the
underlying process and never probes a journal or reclaims a sandbox. You must
still schedule `sweepDetachedRuns`; see the
[Cloudflare stall-watchdog details](./cloudflare#stall-watchdog).

## `pruneJournals`: bounding the journal directory

Expand Down
41 changes: 25 additions & 16 deletions packages/ai-sandbox-cloudflare/src/chat-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
withSandbox,
} from '@tanstack/ai-sandbox'
import { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'
import { runWithCallbackActivity } from './coordinator-callbacks'
import { timingSafeBearerEqualWeb } from './web-crypto'
import type { StartRunInput } from './coordinator'
import type {
Expand Down Expand Up @@ -233,21 +234,29 @@ export abstract class ChatSandboxCoordinator<
) {
return new Response('unauthorized', { status: 401 })
}
let message: unknown
try {
message = await request.json()
} catch {
// A malformed body must still produce a valid JSON-RPC error so the agent's
// MCP client can react, rather than an opaque DO 500 that can wedge the run.
return this.jsonResponse({
jsonrpc: '2.0',
id: null,
error: { code: -32700, message: 'Parse error' },
})
}
const reply = await handleBridgeJsonRpc(bridge.core, message)
// A notification (no id) yields null → MCP expects an empty 202 ack.
if (reply === null) return new Response(null, { status: 202 })
return this.jsonResponse(reply)
return runWithCallbackActivity(
this,
runId,
(id) => this.log.touch(id),
async () => {
let message: unknown
try {
message = await request.json()
} catch {
// A malformed body must still produce a valid JSON-RPC error so the
// agent's MCP client can react, rather than an opaque DO 500 that can
// wedge the run.
return this.jsonResponse({
jsonrpc: '2.0',
id: null,
error: { code: -32700, message: 'Parse error' },
})
}
const reply = await handleBridgeJsonRpc(bridge.core, message)
// A notification (no id) yields null → MCP expects an empty 202 ack.
if (reply === null) return new Response(null, { status: 202 })
return this.jsonResponse(reply)
},
)
}
}
61 changes: 37 additions & 24 deletions packages/ai-sandbox-cloudflare/src/container-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from '@tanstack/ai-sandbox'
import { getSandbox } from '@cloudflare/sandbox'
import { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'
import { runWithCallbackActivity } from './coordinator-callbacks'
import { timingSafeBearerEqualWeb } from './web-crypto'
import type { StartRunInput } from './coordinator'
import type { ContainerRunRequest, HarnessId } from './protocol'
Expand Down Expand Up @@ -409,29 +410,41 @@ export abstract class ContainerSandboxCoordinator<
) {
return new Response('unauthorized', { status: 401 })
}
let payload: unknown
try {
payload = await request.json()
} catch {
return this.jsonResponse({ error: 'body must be valid JSON' }, 400)
}
if (!isToolExecRequest(payload)) {
return this.jsonResponse({ error: 'body must be { name, args }' }, 400)
}
try {
const result = await executeHostTool(
state.hostTools,
payload.name,
payload.args,
{
...(state.context !== undefined ? { context: state.context } : {}),
signal: state.abort.signal,
},
)
return this.jsonResponse({ result })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return this.jsonResponse({ error: message }, 500)
}
return runWithCallbackActivity(
this,
runId,
(id) => this.log.touch(id),
async () => {
let payload: unknown
try {
payload = await request.json()
} catch {
return this.jsonResponse({ error: 'body must be valid JSON' }, 400)
}
if (!isToolExecRequest(payload)) {
return this.jsonResponse(
{ error: 'body must be { name, args }' },
400,
)
}
try {
const result = await executeHostTool(
state.hostTools,
payload.name,
payload.args,
{
...(state.context !== undefined
? { context: state.context }
: {}),
signal: state.abort.signal,
},
)
return this.jsonResponse({ result })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return this.jsonResponse({ error: message }, 500)
}
},
)
}
}
58 changes: 58 additions & 0 deletions packages/ai-sandbox-cloudflare/src/coordinator-callbacks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
type TouchRun = (runId: string) => Promise<void>

const inFlightCallbacks = new WeakMap<object, Map<string, number>>()

function increment(owner: object, runId: string): void {
let runs = inFlightCallbacks.get(owner)
if (!runs) {
runs = new Map()
inFlightCallbacks.set(owner, runs)
}
runs.set(runId, (runs.get(runId) ?? 0) + 1)
}

function decrement(owner: object, runId: string): void {
const runs = inFlightCallbacks.get(owner)
if (!runs) return
const count = runs.get(runId) ?? 0
if (count > 1) {
runs.set(runId, count - 1)
return
}
runs.delete(runId)
if (runs.size === 0) inFlightCallbacks.delete(owner)
}

export function hasInFlightCallback(owner: object, runId: string): boolean {
return (inFlightCallbacks.get(owner)?.get(runId) ?? 0) > 0
}

export async function runWithCallbackActivity<T>(
owner: object,
runId: string,
touch: TouchRun,
operation: () => Promise<T>,
): Promise<T> {
increment(owner, runId)
try {
// Failure here proves liveness could not be persisted, so do not execute the
// callback operation.
await touch(runId)
try {
return await operation()
} finally {
// This is best-effort bookkeeping after an operation has produced its
// result or error. It must never replace that original outcome.
try {
await touch(runId)
} catch (error) {
console.error(
`[sandbox-coordinator] completion activity touch failed for run ${runId}:`,
error,
)
}
}
} finally {
decrement(owner, runId)
}
}
Loading
Loading