@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.
- package/dist/esm/chat-coordinator.js +19 -16
- package/dist/esm/chat-coordinator.js.map +1 -1
- package/dist/esm/container-coordinator.js +20 -17
- package/dist/esm/container-coordinator.js.map +1 -1
- package/dist/esm/coordinator-callbacks.d.ts +4 -0
- package/dist/esm/coordinator-callbacks.js +45 -0
- package/dist/esm/coordinator-callbacks.js.map +1 -0
- package/dist/esm/coordinator.d.ts +7 -2
- package/dist/esm/coordinator.js +43 -28
- package/dist/esm/coordinator.js.map +1 -1
- package/dist/esm/factory.d.ts +5 -0
- package/dist/esm/factory.js +8 -1
- package/dist/esm/factory.js.map +1 -1
- package/dist/esm/run-log-do.d.ts +4 -0
- package/dist/esm/run-log-do.js +59 -9
- package/dist/esm/run-log-do.js.map +1 -1
- package/dist/esm/run-log.d.ts +9 -4
- package/dist/esm/run-log.js +24 -1
- package/dist/esm/run-log.js.map +1 -1
- package/package.json +1 -1
- package/src/chat-coordinator.ts +25 -16
- package/src/container-coordinator.ts +37 -24
- package/src/coordinator-callbacks.ts +58 -0
- package/src/coordinator.ts +66 -30
- package/src/factory.ts +15 -1
- package/src/run-log-do.ts +71 -7
- package/src/run-log.ts +46 -5
package/src/coordinator.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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(
|
|
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)
|
|
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
|
|
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 (
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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.
|
|
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.
|
|
374
|
+
await this.armWatchdog()
|
|
346
375
|
}
|
|
347
376
|
}
|
|
348
377
|
|
|
349
|
-
/**
|
|
350
|
-
private async
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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`) —
|
|
159
|
-
//
|
|
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
|
-
* -
|
|
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
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
|
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.
|