UNPKG

@tanstack/ai-sandbox

Version:

Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.

480 lines (458 loc) 21.4 kB
/** * The single-writer claim: what makes a takeover safe to attempt at all. * * WHY THIS MODULE EXISTS. `alignToStoredLog` decides where its appends start by * reading `durability.snapshot()`, and `snapshot()` carries NO LOCK — core says * so explicitly (`packages/ai/src/stream-durability.ts`: "a concurrent `append` * may land immediately after the snapshot is taken"). If two hosts drive one * run, both snapshot, both compute a "remainder", and both append it. The log * then holds the same logical chunk twice under two different offsets, and the * client CANNOT survive that: `ai-client`'s de-dup is keyed on the adapter's * offset string, so a re-appended chunk looks new, and the stream processor * applies text and tool-argument deltas unconditionally. The visible result is * doubled message text and `{"a":1}{"a":1}` tool arguments. * * Takeover is by definition two hosts wanting one run, so nothing may read a * journal for a run it has not claimed. * * THREE LAYERS, strongest first: * * 1. **The lease.** {@link withRunClaim} runs the whole drive inside * `LockStore.withLock('run-driver:<runId>', …)`, so the snapshot and every * append that follows are one critical section. A lease-backed lock aborts * the callback signal the moment ownership is lost, and * {@link fenceDurability} turns that into a thrown {@link RunClaimLostError} * BEFORE the append reaches the log. * 2. **The epoch.** Each successful claim bumps `RunRecord.driverEpoch`. * {@link fenceDurability} re-reads it every * {@link DEFAULT_EPOCH_RECHECK_APPENDS} appends and refuses to append once a * higher epoch exists. This covers what a lease cannot: an * `InMemoryLockStore`, whose signal is a fresh `AbortController().signal` * that is never aborted, and any backend whose renewal is coarser than the * run's append rate. Once EITHER fence has refused an append, the fence * latches shut and every later append refuses without re-reading anything. * 3. **Quiescence.** {@link awaitLogQuiescence} requires the stored log to stop * growing before the successor appends anything, so a predecessor that is * still writing is OBSERVED rather than raced. * * THE LOG IS NOT THE ONLY AUTHORITATIVE CHANNEL. A host that has lost its claim * must not write authoritative facts about the run through ANY seam, and there * are two: the event log and the run RECORD. Fencing only the log moves the harm * rather than removing it — a superseded driver whose append was refused folds * that refusal into a terminal `runs.update`, so the record reads `'failed'` for * a run the successor is healthily streaming, and `isTerminalRunStatus` (which * `findActiveRun`, the resume driver, and `reapDetachedRuns` all branch on) then * answers `true` for a live run. {@link fenceRunStore} closes that seam; both * fences share one per-claim latch so they can never disagree about whether the * claim is still held. * * WHY THE EPOCH RE-CHECK COUNTS APPENDS, NOT MILLISECONDS. `pipeToRunLog` * appends ONE chunk per call, so a time-based interval couples the fence's * resolution to the run's chunk rate: at 500 chunks/sec a 2s interval lets a * superseded driver write ~1000 chunks before it notices. A count gives a hard * bound independent of rate — see {@link DEFAULT_EPOCH_RECHECK_APPENDS}. * * WHAT THIS IS NOT. It is not airtight fencing. * * - A predecessor paused (GC, VM suspend) for longer than the quiescence * window, between its last fence check and its append landing at the backend, * can still write one batch. Closing that requires a compare-and-set on the * durability write; `StreamDurability.append` has no such parameter and this * phase deliberately does not add one. * - Layer 3 is only meaningful across PROCESSES. On a single-process * `InMemoryLockStore` the two claims are serialized by the lock, not * concurrent, so `awaitLogQuiescence` can never observe a predecessor still * writing there — and consequently no unit test on that backend proves layer * 3 does anything. What the tests do prove on that backend is layer 2. * * The mitigation for both is deployment-level: use a lease-backed distributed * `LockStore`, and keep `fenceQuietMs` above the lease's renewal interval. */ import { isTerminalRunStatus } from '@tanstack/ai' import type { LockStore } from '@tanstack/ai/locks' import type { InternalLogger } from '@tanstack/ai/adapter-internals' import type { RunStore, StreamChunk, StreamDurability } from '@tanstack/ai' /** Quiescence window before a successor's first append. */ export const DEFAULT_FENCE_QUIET_MS = 5_000 /** * Appends a fenced log makes between `driverEpoch` re-reads. * * Deliberately a COUNT, not an interval: `pipeToRunLog` appends one chunk per * call, so this bounds a superseded driver to at most 31 further chunk batches * (the bump can land immediately after a check) regardless of how fast the run * streams. At 500 chunks/sec that worst case is ~62ms of writes; at 5 * chunks/sec it is ~6s of writes — either way 31 chunks, never ~1000. * * The cost of a smaller number is one extra `RunStore.get` per 32 chunks. */ export const DEFAULT_EPOCH_RECHECK_APPENDS = 32 /** Probes {@link awaitLogQuiescence} makes before giving up. */ const MAX_QUIESCENCE_PROBES = 6 /** Lock key for a run's driver. Per-run, so two runs never serialize. */ export function runDriverLockKey(runId: string): string { return `run-driver:${runId}` } /** The claim was never acquired, so the caller must not drive the run. */ export class RunClaimNotAcquiredError extends Error { constructor( readonly runId: string, readonly reason: 'terminal' | 'unknown' | 'superseded', ) { super(`run ${runId}: driver claim not acquired (${reason})`) this.name = 'RunClaimNotAcquiredError' } } /** The claim was held and has been superseded; stop writing immediately. */ export class RunClaimLostError extends Error { constructor( readonly runId: string, readonly heldEpoch: number, readonly observedEpoch: number | 'lease-lost', ) { super( `run ${runId}: driver claim lost (held epoch ${heldEpoch}, observed ${observedEpoch})`, ) this.name = 'RunClaimLostError' } } /** A held claim on one run. */ export interface RunClaim { runId: string /** This driver's fencing token; strictly greater than any predecessor's. */ epoch: number /** Aborts when the lock can no longer guarantee ownership. */ signal: AbortSignal } export interface WithRunClaimOptions { runs: RunStore locks: LockStore runId: string /** * Quiescence window for {@link awaitLogQuiescence}. Defaults to * {@link DEFAULT_FENCE_QUIET_MS}. * * `withRunClaim` itself does not read this: it has no durability handle. It * lives here so a caller assembling a drive passes ONE options object to * `withRunClaim`, `awaitLogQuiescence`, and {@link fenceDurability} instead of * three that can drift apart. */ fenceQuietMs?: number /** * Forwarded to {@link fenceDurability}. Defaults to * {@link DEFAULT_EPOCH_RECHECK_APPENDS}. Same rationale as `fenceQuietMs`. */ epochRecheckAppends?: number logger?: InternalLogger } /** * Claim exclusive driver rights on `runId` for the duration of `fn`. * * The ENTIRE body runs inside the lock, so a snapshot taken by `fn` and every * append that follows it sit in one critical section. * * Rejects with {@link RunClaimNotAcquiredError} when the run is unknown or * already terminal — a terminal run has nothing left to drive, and bumping its * epoch would fence out nobody while confusing an operator reading the record. * * The epoch is bumped INSIDE the lock and only after those checks pass, so a * refused claim leaves `driverEpoch` untouched. */ export async function withRunClaim<T>( options: WithRunClaimOptions, fn: (claim: RunClaim) => Promise<T>, ): Promise<T> { const { runs, locks, runId, logger } = options return locks.withLock(runDriverLockKey(runId), async (signal) => { const record = await runs.get(runId) if (record === null) { throw new RunClaimNotAcquiredError(runId, 'unknown') } if (isTerminalRunStatus(record.status)) { throw new RunClaimNotAcquiredError(runId, 'terminal') } const epoch = (record.driverEpoch ?? 0) + 1 await runs.update(runId, { driverEpoch: epoch }) logger?.sandbox(`run ${runId}: driver claim acquired at epoch ${epoch}`, { runId, epoch, }) return fn({ runId, epoch, signal }) }) } /** * Wait until the stored log stops growing, then answer how many entries it * holds. * * Uses `snapshot()`, never `read()`: `read` tails and only resolves once the log * is terminalized or the caller aborts, and a taken-over run's log is open by * definition — the host that would have closed it is the host that died. * * Rejects rather than looping forever. A log that never quiesces means a * predecessor is still actively writing, which is a condition to surface, not to * append into. * * This only detects a CONCURRENT predecessor, which means it can only fire when * the two drivers are in different processes. Within one process an * `InMemoryLockStore` serializes the claims, so the predecessor has already * stopped by the time the successor probes. */ export async function awaitLogQuiescence<TOffset extends string = string>( durability: StreamDurability<TOffset>, quietMs: number, ): Promise<number> { let previous = (await durability.snapshot()).length for (let probe = 0; probe < MAX_QUIESCENCE_PROBES; probe += 1) { await sleep(quietMs) const current = (await durability.snapshot()).length if (current === previous) return current previous = current } throw new Error( `journal takeover: the event log never quiesced after ${MAX_QUIESCENCE_PROBES} probes (${previous} entries and still growing); another host is still driving this run`, ) } function sleep(ms: number): Promise<void> { if (ms <= 0) return Promise.resolve() return new Promise<void>((resolve) => setTimeout(resolve, ms)) } /** * The one-way "this claim is gone" flag, latched by the first refusal. * * Keyed by the claim rather than held in one wrapper's closure because a claim * has TWO fenced seams — its log ({@link fenceDurability}) and its record * ({@link fenceRunStore}) — and a latch per wrapper would let them disagree: a * lease that flaps back to `aborted === false`, or an epoch read that fails, * would re-open the fence that had not refused yet. Losing a claim is not * transient, so one observation must close both. * * A `WeakMap` and not a field on {@link RunClaim} so the claim stays the plain * data structure core's `RunDriverOptions.claim` types it as, and so the latch is * collected with the claim. */ interface ClaimLatch { /** `undefined` while the fence is open; otherwise the refusal to replay. */ lost: RunClaimLostError | undefined } const CLAIM_LATCHES = new WeakMap<RunClaim, ClaimLatch>() function latchFor(claim: RunClaim): ClaimLatch { const existing = CLAIM_LATCHES.get(claim) if (existing !== undefined) return existing const latch: ClaimLatch = { lost: undefined } CLAIM_LATCHES.set(claim, latch) return latch } /** * The I/O-free half of the check: the latch and the lease. Synchronous on * purpose — a fenced write must be refused BEFORE anything can half-land. */ function claimLostSynchronously( claim: RunClaim, latch: ClaimLatch, ): RunClaimLostError | undefined { if (latch.lost !== undefined) return latch.lost if (claim.signal.aborted) { latch.lost = new RunClaimLostError(claim.runId, claim.epoch, 'lease-lost') return latch.lost } return undefined } /** * The other half: re-read `driverEpoch` and refuse once a successor exists. * * A store failure is NOT treated as loss. The lease is the primary fence and it * has not fired, so fencing ourselves out on a store blip would kill a healthy * driver — and, for the record fence, would suppress a legitimate terminal write * and strand the run at `'running'`, which is worse than the write it prevents. */ async function claimLostByEpoch( claim: RunClaim, latch: ClaimLatch, runs: RunStore, ): Promise<RunClaimLostError | undefined> { let observed: number | undefined try { observed = (await runs.get(claim.runId))?.driverEpoch } catch { return undefined } if (observed !== undefined && observed > claim.epoch) { latch.lost = new RunClaimLostError(claim.runId, claim.epoch, observed) return latch.lost } return undefined } /** * Wrap a log so every `append` is fenced by `claim`. * * `append` is the ONLY fenced method, deliberately: * * - `close()` must never be fenced. It runs on every teardown path including * the teardown caused by losing the claim, and a fenced `close` would leave * the record wedged at `'running'` with every live tailer parked forever (a * `read` only ends when the log closes). * - `read` / `snapshot` / `resumeFrom` do not mutate, so a superseded host * reading them is harmless. * * The lease check is synchronous and happens before any I/O, so a fenced append * cannot half-land. The epoch re-check is throttled to `epochRecheckAppends` * because it costs a store read and the append path is hot. * * ONE REFUSAL CLOSES THE FENCE FOR GOOD. The first `append` that is refused — * for EITHER cause, lost lease or moved epoch — latches this wrapper shut, and * every later `append` refuses immediately without consulting the throttle and * without a store read. This is not a nicety: * * - Losing a claim is not transient. Epochs only move forward and a lease is * never handed back, so a wrapper that has refused once can never legitimately * append again. Re-deciding per append can only produce a WRONG answer. * - The throttle makes that wrong answer reachable. A refusal consumes the * re-read budget, so the very next append rides a fresh throttle window and is * NOT re-checked. `pipeToRunLog`'s recovery path appends a `RUN_ERROR` right * after the refusal it is recovering from, and that log belongs to the * SUCCESSOR: a terminal `RUN_ERROR` from a dead host would fail the stream for * every client attached to the live, healthy run. * - It is also strictly cheaper: a latched boolean replaces a store read. * * The latch deliberately does NOT extend to `close()` — see above. * * PASSES THE OFFSET TYPE THROUGH, rather than collapsing it to `string`. The * fence sits mid-chain between a caller's log and `pipeToRunLog`, so widening * here would reintroduce the branded-offset wall one layer in: a * `StreamDurability<DurableStreamOffset>` would go in and a * `StreamDurability<string>` would come out, which is not assignable back to * the caller's own type. */ export function fenceDurability<TOffset extends string = string>( durability: StreamDurability<TOffset>, claim: RunClaim, options: { runs: RunStore; epochRecheckAppends?: number }, ): StreamDurability<TOffset> { const recheckAppends = Math.max( 1, Math.trunc(options.epochRecheckAppends ?? DEFAULT_EPOCH_RECHECK_APPENDS), ) // Seeded at the threshold so the FIRST append always re-reads the epoch: a // successor may have claimed between this fence being built and its first // write. let appendsSinceEpochRead = recheckAppends // Latched by the FIRST refusal and never cleared, and SHARED with this claim's // record fence so the two seams cannot disagree. const latch = latchFor(claim) async function assertHeld(): Promise<void> { // Layer 1 plus the latch: no I/O, so nothing has been written yet, and once // refused no throttle and no store read can let a later append through. const synchronous = claimLostSynchronously(claim, latch) if (synchronous !== undefined) throw synchronous if (appendsSinceEpochRead < recheckAppends) { appendsSinceEpochRead += 1 return } appendsSinceEpochRead = 1 // Layer 2, throttled because it costs a store read. const byEpoch = await claimLostByEpoch(claim, latch, options.runs) if (byEpoch !== undefined) throw byEpoch } return { resumeFrom: () => durability.resumeFrom(), append: async (chunks: Array<StreamChunk>) => { await assertHeld() return durability.append(chunks) }, read: (offset, signal) => durability.read(offset, signal), close: () => durability.close(), snapshot: () => durability.snapshot(), } } /** * Wrap a run store so a TERMINAL record write is fenced by `claim`. * * The record is the run's other authoritative channel, and the same rule applies * to it: a host that has lost its claim must not state that the run is over. It * reaches this seam by the most ordinary route — `pipeToRunLog` catches the * `RunClaimLostError` its refused append threw, folds it in, and calls * `finish(ctx, 'failed', …)` — so fencing the log alone only moves where the harm * surfaces. `'completed'` and `'aborted'` arrive the same way (an empty stream * that never appended; a lease loss that aborts `claim.signal`, which * `pipeToRunLog` reads as an abort before it appends anything), which is why the * gate is {@link isTerminalRunStatus} and not "did an append refuse". * * SUPPRESSED, NOT ATTEMPTED-AND-SWALLOWED, and not thrown either. `update` * resolves without writing. `pipeToRunLog` must not reject — `RunController.start` * consumes its promise fire-and-forget — and a rejection here would additionally * make `finish` report the run through the local rebuilt record as if the store * had broken, which is a different and false fact. * * WHAT IS *NOT* FENCED, deliberately: * * - **`close()`** is not on this seam at all, and must stay off it: see * {@link fenceDurability}. A wedged `'running'` record with tailers parked * forever is worse than the write being prevented. * - **Non-terminal writes pass through**, including `detachedSince` and * `sandboxKey` written by a superseded host. They are stale, but staleness is * not the harm being fixed: none of them can make a live run look finished, so * none can mislead `isTerminalRunStatus`, `findActiveRun`, or the reaper. They * are also self-healing — the successor owns those fields and overwrites them — * whereas over-suppressing strands a record: `createOrResume` is how the row * comes into existence at all, and refusing a non-terminal write on a * mis-observed loss would leave a run with no record to recover from. Suppress * the writes that assert an outcome; let bookkeeping through. * - **Reads** (`get`, `listByThread`, `listReclaimable`, `findActiveRun`) do not * mutate, so a superseded host reading them is harmless. `finish`'s terminal * re-read therefore still works and answers with the SUCCESSOR's live record, * which is the truthful thing to resolve with. * - **Another run's record.** The fence knows about `claim.runId` only; a write * aimed elsewhere is not this claim's to judge. * * The OPTIONAL methods (`listByThread`, `listReclaimable`) are forwarded only * when the wrapped store actually has them: consumers feature-detect * (`store.listReclaimable?.(…)`), so materializing one that delegates to a * missing method would turn a graceful degrade into a `TypeError`. * `findActiveRun` is required on the contract, so it forwards unconditionally. */ export function fenceRunStore( runs: RunStore, claim: RunClaim, options: { logger?: InternalLogger } = {}, ): RunStore { const latch = latchFor(claim) // Bound, not merely captured: the store may be a class instance // (`InMemoryRunStore`), whose methods need their receiver. const listByThread = runs.listByThread?.bind(runs) const listReclaimable = runs.listReclaimable?.bind(runs) return { createOrResume: (input) => runs.createOrResume(input), get: (runId) => runs.get(runId), findActiveRun: (threadId) => runs.findActiveRun(threadId), update: async (runId, patch) => { const status = patch.status if ( runId !== claim.runId || status === undefined || !isTerminalRunStatus(status) ) { return runs.update(runId, patch) } const lost = claimLostSynchronously(claim, latch) ?? // Unthrottled, unlike the append path: a terminal write happens once per // run, so one store read is not a hot cost — and it is the read that // catches a superseded driver whose stream ended without ever appending. (await claimLostByEpoch(claim, latch, runs)) if (lost === undefined) return runs.update(runId, patch) // Absorbing this silently would make it invisible: a detached run has no // caller to report to. The logger is consumer-supplied, so a throwing sink // must not turn a suppression into a rejection. try { options.logger?.sandbox( `run ${runId}: suppressed a terminal '${status}' record write from a superseded driver`, { runId, status, heldEpoch: claim.epoch, error: lost }, ) } catch { // Intentionally empty: there is no second channel to report on. } return undefined }, ...(listByThread === undefined ? {} : { listByThread }), ...(listReclaimable === undefined ? {} : { listReclaimable }), } }