@tanstack/ai-sandbox-cloudflare 0.3.14 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,21 +31,30 @@ import { EventType, isTerminalRunStatus } from '@tanstack/ai'
31
31
  // migrated on read; see './run-log').
32
32
  import { RunController } from '@tanstack/ai-sandbox'
33
33
  import { runLogStore, runLogStream } from './durability'
34
+ import { hasInFlightCallback } from './coordinator-callbacks'
34
35
  import { DurableObjectRunEventLog } from './run-log-do'
35
36
  import type { ModelMessage, StreamChunk } from '@tanstack/ai'
36
37
  import type { RunLogRecord } from './run-log'
37
38
 
38
- /** Re-arm window for the liveness watchdog while a run is in flight (ms). */
39
+ /** Upper bound on the watchdog check interval while a run is in flight (ms). */
39
40
  const WATCHDOG_MS = 30_000
40
41
 
41
- /**
42
- * How long a non-terminal run may go without ANY new event before the watchdog
43
- * presumes the orchestrator driving it is dead (eviction that lost the
44
- * `waitUntil` promise, an uncaught fault, a hung container) and fails the run so
45
- * tailing clients stop waiting forever. Generous so a legitimately slow agent
46
- * step (a long tool call that emits no chunks) is not killed prematurely.
47
- */
48
- const WATCHDOG_STALL_MS = 5 * 60_000
42
+ /** Default permitted period without persisted run activity (ms). */
43
+ const DEFAULT_STALL_TIMEOUT_MS = 5 * 60_000
44
+
45
+ /** @internal Shared validation for direct subclasses and the eager factory path. */
46
+ export function normalizeStallTimeoutMs(
47
+ stallTimeoutMs: number | false | undefined,
48
+ ): number | false {
49
+ if (stallTimeoutMs === undefined) return DEFAULT_STALL_TIMEOUT_MS
50
+ if (stallTimeoutMs === false) return false
51
+ if (!Number.isSafeInteger(stallTimeoutMs) || stallTimeoutMs <= 0) {
52
+ throw new TypeError(
53
+ 'stallTimeoutMs must be a positive safe integer or false',
54
+ )
55
+ }
56
+ return stallTimeoutMs
57
+ }
49
58
 
50
59
  /** What the Worker hands the coordinator to start a run. */
51
60
  export interface StartRunInput {
@@ -106,9 +115,15 @@ export abstract class SandboxCoordinator<
106
115
  * running — double-delivering events and racing the persisted cursor.
107
116
  */
108
117
  private readonly pumping = new WeakSet<WebSocket>()
118
+ private readonly stallTimeoutMs: number | false
109
119
 
110
- constructor(ctx: DurableObjectState, env: TEnv) {
120
+ constructor(
121
+ ctx: DurableObjectState,
122
+ env: TEnv,
123
+ stallTimeoutMs?: number | false,
124
+ ) {
111
125
  super(ctx, env)
126
+ this.stallTimeoutMs = normalizeStallTimeoutMs(stallTimeoutMs)
112
127
  this.log = new DurableObjectRunEventLog(ctx.storage)
113
128
  this.controller = new RunController({
114
129
  runs: runLogStore(this.log),
@@ -154,7 +169,12 @@ export abstract class SandboxCoordinator<
154
169
 
155
170
  async startRun(input: StartRunInput): Promise<{ runId: string }> {
156
171
  const existing = await this.log.get(input.runId)
157
- if (existing) return { runId: input.runId } // idempotent re-trigger
172
+ if (existing) {
173
+ if (!isTerminalRunStatus(existing.status)) await this.armWatchdog()
174
+ return { runId: input.runId }
175
+ }
176
+
177
+ await this.armWatchdog()
158
178
 
159
179
  // Open the run BEFORE building the stream. `pipeToRunLog`'s never-rejects
160
180
  // guarantee only covers failures AFTER the stream is handed to it — a throw
@@ -189,7 +209,6 @@ export abstract class SandboxCoordinator<
189
209
  // running the settle hook.
190
210
  const settle = (): void => this.onRunSettled(input.runId)
191
211
  this.ctx.waitUntil(done.then(settle, settle))
192
- await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)
193
212
  return { runId: input.runId }
194
213
  }
195
214
 
@@ -320,41 +339,58 @@ export abstract class SandboxCoordinator<
320
339
  // ===========================================================================
321
340
 
322
341
  override async alarm(): Promise<void> {
342
+ // A previously configured alarm may still be delivered once after watchdogs
343
+ // are disabled. It must self-extinguish before reading storage or entering a
344
+ // catch path that could re-arm it. Deliberately do not call deleteAlarm().
345
+ if (this.stallTimeoutMs === false) return
346
+
323
347
  try {
324
348
  // Through the log (not a raw `rec:` list) so legacy records are migrated
325
349
  // on the way out — the storage layout is the log's private concern.
326
350
  const runs = await this.log.list()
327
- const now = Date.now()
351
+ const cutoff = Date.now() - this.stallTimeoutMs
328
352
  let active = false
329
353
  for (const record of runs) {
330
354
  if (isTerminalRunStatus(record.status)) continue
331
- if (now - record.updatedAt > WATCHDOG_STALL_MS) {
332
- // No progress for too long — the driver is presumed dead. Fail the run
333
- // so tailing clients stop waiting forever (the whole point of the
334
- // watchdog; without this a stuck run sits at `running` indefinitely).
335
- await this.failStalledRun(record.runId)
336
- } else {
355
+ if (
356
+ hasInFlightCallback(this, record.runId) ||
357
+ record.updatedAt >= cutoff
358
+ ) {
337
359
  active = true
360
+ continue
338
361
  }
362
+
363
+ // Re-check and terminalize atomically against the same strict cutoff.
364
+ // A callback touch or normal completion racing this alarm wins cleanly.
365
+ const finished = await this.failStalledRun(record.runId, cutoff)
366
+ if (finished) this.onRunSettled(record.runId)
367
+ else active = true
339
368
  }
340
- if (active) await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)
369
+ if (active) await this.armWatchdog()
341
370
  } catch (error) {
342
371
  // Never let the watchdog die silently: a transient storage error must not
343
372
  // permanently disable liveness detection. Re-arm and try again next tick.
344
373
  console.error('[sandbox-coordinator] watchdog alarm failed:', error)
345
- await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)
374
+ await this.armWatchdog()
346
375
  }
347
376
  }
348
377
 
349
- /** Mark a stalled (orchestrator-presumed-dead) run as a terminal error. */
350
- private async failStalledRun(runId: string): Promise<void> {
351
- const message = 'run watchdog: no progress; orchestrator presumed dead'
352
- try {
353
- await this.log.append(runId, { type: EventType.RUN_ERROR, message })
354
- } catch {
355
- // The run may have just reached terminal concurrently; finish is idempotent.
378
+ /** Arm without allowing a new run to postpone an earlier pending check. */
379
+ private async armWatchdog(): Promise<void> {
380
+ if (this.stallTimeoutMs === false) return
381
+ const next = Date.now() + Math.min(WATCHDOG_MS, this.stallTimeoutMs)
382
+ const pending = await this.ctx.storage.getAlarm()
383
+ if (pending === null || pending > next) {
384
+ await this.ctx.storage.setAlarm(next)
356
385
  }
357
- await this.log.finish(runId, 'failed', { message })
358
- this.onRunSettled(runId)
386
+ }
387
+
388
+ /** Mark a strictly stale run as a terminal error if it still qualifies. */
389
+ private failStalledRun(runId: string, cutoff: number): Promise<boolean> {
390
+ const message = 'run watchdog: no progress; orchestrator presumed dead'
391
+ return this.log.finishIfStale(runId, cutoff, {
392
+ type: EventType.RUN_ERROR,
393
+ message,
394
+ })
359
395
  }
360
396
  }
package/src/factory.ts CHANGED
@@ -45,7 +45,7 @@ import { cloudflareSandbox } from './provider'
45
45
  import { ChatSandboxCoordinator } from './chat-coordinator'
46
46
  import { ContainerSandboxCoordinator } from './container-coordinator'
47
47
  import { createSandboxAgentWorker } from './worker'
48
- import { resolvePreviewHost } from './coordinator'
48
+ import { normalizeStallTimeoutMs, resolvePreviewHost } from './coordinator'
49
49
  import type { ChatCoordinatorEnv, ChatRunConfig } from './chat-coordinator'
50
50
  import type {
51
51
  ContainerCoordinatorEnv,
@@ -83,6 +83,11 @@ export interface SandboxAgentEnv
83
83
  interface BaseAgentConfig<TEnv extends SandboxAgentEnv> {
84
84
  /** chat()-provided server tools, resolved per run (DO-drives: bridged over MCP). */
85
85
  tools?: (input: StartRunInput, env: TEnv) => Array<AnyTool>
86
+ /**
87
+ * Fail a run after this many milliseconds without persisted activity. Omitted
88
+ * defaults to five minutes; `false` disables the watchdog.
89
+ */
90
+ stallTimeoutMs?: number | false
86
91
  }
87
92
 
88
93
  /** DO-drives config: the DO runs `chat()` with the given adapter. */
@@ -188,11 +193,16 @@ function resolveCoordinator<TEnv extends SandboxAgentEnv>(
188
193
  export function createCloudflareSandboxAgent<
189
194
  TEnv extends SandboxAgentEnv = SandboxAgentEnv,
190
195
  >(config: CloudflareSandboxAgentConfig<TEnv>): CloudflareSandboxAgent<TEnv> {
196
+ const stallTimeoutMs = normalizeStallTimeoutMs(config.stallTimeoutMs)
191
197
  const worker = createSandboxAgentWorker<TEnv>(resolveCoordinator)
192
198
 
193
199
  if (config.mode === 'colocated') {
194
200
  const colocated = config
195
201
  class ConfiguredContainerCoordinator extends ContainerSandboxCoordinator<TEnv> {
202
+ constructor(ctx: DurableObjectState, env: TEnv) {
203
+ super(ctx, env, stallTimeoutMs)
204
+ }
205
+
196
206
  protected override config(input: StartRunInput): ContainerRunConfig {
197
207
  return {
198
208
  hostTools: colocated.tools?.(input, this.env) ?? [],
@@ -207,6 +217,10 @@ export function createCloudflareSandboxAgent<
207
217
 
208
218
  const doDrives = config
209
219
  class ConfiguredChatCoordinator extends ChatSandboxCoordinator<TEnv> {
220
+ constructor(ctx: DurableObjectState, env: TEnv) {
221
+ super(ctx, env, stallTimeoutMs)
222
+ }
223
+
210
224
  protected override config(input: StartRunInput): ChatRunConfig {
211
225
  const tools = doDrives.tools?.(input, this.env)
212
226
  return {
package/src/run-log-do.ts CHANGED
@@ -149,15 +149,79 @@ export class DurableObjectRunEventLog implements RunEventLog {
149
149
  this.wake(runId)
150
150
  }
151
151
 
152
+ async touch(runId: string): Promise<void> {
153
+ await this.storage.transaction(async (txn) => {
154
+ const stored = await txn.get<StoredRunRecord>(recKey(runId))
155
+ if (!stored) return
156
+ const { record, migrated } = migrateStoredRunRecord(stored)
157
+ if (isTerminalRunStatus(record.status)) {
158
+ if (migrated) await txn.put(recKey(runId), record)
159
+ return
160
+ }
161
+ await txn.put(recKey(runId), { ...record, updatedAt: Date.now() })
162
+ })
163
+ // Activity without a new event or status transition gives a tailing reader
164
+ // nothing to observe, so intentionally do not wake it.
165
+ }
166
+
167
+ async finishIfStale(
168
+ runId: string,
169
+ cutoff: number,
170
+ chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
171
+ ): Promise<boolean> {
172
+ const finished = await this.storage.transaction(async (txn) => {
173
+ const stored = await txn.get<StoredRunRecord>(recKey(runId))
174
+ if (!stored) return false
175
+ const { record, migrated } = migrateStoredRunRecord(stored)
176
+ if (isTerminalRunStatus(record.status) || record.updatedAt >= cutoff) {
177
+ if (migrated) await txn.put(recKey(runId), record)
178
+ return false
179
+ }
180
+
181
+ const now = Date.now()
182
+ const seq = record.lastSeq + 1
183
+ const next: RunLogRecord = {
184
+ ...record,
185
+ status: 'failed',
186
+ lastSeq: seq,
187
+ error: {
188
+ message: chunk.message,
189
+ ...(chunk.code !== undefined ? { code: chunk.code } : {}),
190
+ },
191
+ finishedAt: now,
192
+ updatedAt: now,
193
+ }
194
+ // Read the current activity clock and commit the terminal event + record
195
+ // in this one transaction. Keep wake-ups outside: transaction callbacks
196
+ // may be retried and must contain no external work.
197
+ await txn.put(evtKey(runId, seq), chunk)
198
+ await txn.put(recKey(runId), next)
199
+ return true
200
+ })
201
+ if (finished) this.wake(runId)
202
+ return finished
203
+ }
204
+
152
205
  async update(runId: string, patch: RunRecordPatch): Promise<void> {
153
- const record = await this.getRecord(runId)
154
- if (!record) return // unknown runId is a no-op
155
- const next: RunLogRecord = { ...record, ...patch, updatedAt: Date.now() }
156
- await this.storage.put(recKey(runId), next)
206
+ const updated = await this.storage.transaction(async (txn) => {
207
+ const stored = await txn.get<StoredRunRecord>(recKey(runId))
208
+ if (!stored) return false
209
+ const { record, migrated } = migrateStoredRunRecord(stored)
210
+ if (isTerminalRunStatus(record.status)) {
211
+ if (migrated) await txn.put(recKey(runId), record)
212
+ return false
213
+ }
214
+ await txn.put(recKey(runId), {
215
+ ...record,
216
+ ...patch,
217
+ updatedAt: Date.now(),
218
+ })
219
+ return true
220
+ })
157
221
  // A patch may terminalize the shared status field (core's driver writes its
158
- // terminal status through `RunStore.update`) — parked readers must see it
159
- // now, not a TAIL_POLL_MS later.
160
- this.wake(runId)
222
+ // terminal status through `RunStore.update`) — wake only after that update
223
+ // commits. A late update racing a terminal writer is an atomic no-op.
224
+ if (updated) this.wake(runId)
161
225
  }
162
226
 
163
227
  async get(runId: string): Promise<RunLogRecord | null> {
package/src/run-log.ts CHANGED
@@ -97,7 +97,8 @@ export interface RunEventLogReadOptions {
97
97
  * - `append` assigns the next `seq` (0, 1, 2, …) and returns it.
98
98
  * - `read` yields the backlog after `fromSeq` in order, then live-tails new
99
99
  * events, and RETURNS once the run is terminal and the cursor has caught up.
100
- * - All methods reject for an unknown `runId` except `get`, which resolves null.
100
+ * - Unknown-run behavior is method-specific: `append`/`finish`/`read` reject,
101
+ * `get` resolves null, and `update` is a no-op.
101
102
  */
102
103
  export interface RunEventLog {
103
104
  /**
@@ -120,9 +121,9 @@ export interface RunEventLog {
120
121
  error?: RunError,
121
122
  ) => Promise<void>
122
123
  /**
123
- * Patch the record's mutable fields ({@link RunRecordPatch}). Unknown `runId`
124
- * is a NO-OP (never a throw, never a create) — core's `RunStore.update`
125
- * invariant, which `runLogStore` maps onto this method.
124
+ * Patch the record's mutable fields ({@link RunRecordPatch}). Unknown and
125
+ * already-terminal runs are NO-OPs (never a throw, never a create), preserving
126
+ * the first terminal status against late driver updates.
126
127
  *
127
128
  * MUST wake blocked readers, exactly like `append`/`finish`: the record and
128
129
  * the event log share one status field here, so a driver that terminalizes
@@ -299,9 +300,49 @@ export class InMemoryRunEventLog implements RunEventLog {
299
300
  return Promise.resolve()
300
301
  }
301
302
 
303
+ touch(runId: string): Promise<void> {
304
+ const state = this.runs.get(runId)
305
+ if (!state || isTerminalRunStatus(state.record.status)) {
306
+ return Promise.resolve()
307
+ }
308
+ state.record.updatedAt = this.now()
309
+ return Promise.resolve()
310
+ }
311
+
312
+ finishIfStale(
313
+ runId: string,
314
+ cutoff: number,
315
+ chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
316
+ ): Promise<boolean> {
317
+ const state = this.runs.get(runId)
318
+ if (
319
+ !state ||
320
+ isTerminalRunStatus(state.record.status) ||
321
+ state.record.updatedAt >= cutoff
322
+ ) {
323
+ return Promise.resolve(false)
324
+ }
325
+
326
+ const now = this.now()
327
+ const seq = state.record.lastSeq + 1
328
+ state.chunks.push(chunk)
329
+ state.record.lastSeq = seq
330
+ state.record.status = 'failed'
331
+ state.record.error = {
332
+ message: chunk.message,
333
+ ...(chunk.code !== undefined ? { code: chunk.code } : {}),
334
+ }
335
+ state.record.finishedAt = now
336
+ state.record.updatedAt = now
337
+ this.wake(state)
338
+ return Promise.resolve(true)
339
+ }
340
+
302
341
  update(runId: string, patch: RunRecordPatch): Promise<void> {
303
342
  const state = this.runs.get(runId)
304
- if (!state) return Promise.resolve() // unknown runId is a no-op
343
+ if (!state || isTerminalRunStatus(state.record.status)) {
344
+ return Promise.resolve()
345
+ }
305
346
  state.record = { ...state.record, ...patch, updatedAt: this.now() }
306
347
  // A patch may terminalize the shared status field (core's driver writes its
307
348
  // terminal status through `RunStore.update`) — parked readers must see it.