@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.
349 lines (348 loc) • 16.6 kB
JavaScript
import { isTerminalRunStatus } from "@tanstack/ai";
//#region src/claim.ts
/**
* 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.
*/
/** Quiescence window before a successor's first append. */
var DEFAULT_FENCE_QUIET_MS = 5e3;
/** Probes {@link awaitLogQuiescence} makes before giving up. */
var MAX_QUIESCENCE_PROBES = 6;
/** Lock key for a run's driver. Per-run, so two runs never serialize. */
function runDriverLockKey(runId) {
return `run-driver:${runId}`;
}
/** The claim was never acquired, so the caller must not drive the run. */
var RunClaimNotAcquiredError = class extends Error {
runId;
reason;
constructor(runId, reason) {
super(`run ${runId}: driver claim not acquired (${reason})`);
this.runId = runId;
this.reason = reason;
this.name = "RunClaimNotAcquiredError";
}
};
/** The claim was held and has been superseded; stop writing immediately. */
var RunClaimLostError = class extends Error {
runId;
heldEpoch;
observedEpoch;
constructor(runId, heldEpoch, observedEpoch) {
super(`run ${runId}: driver claim lost (held epoch ${heldEpoch}, observed ${observedEpoch})`);
this.runId = runId;
this.heldEpoch = heldEpoch;
this.observedEpoch = observedEpoch;
this.name = "RunClaimLostError";
}
};
/**
* 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.
*/
async function withRunClaim(options, fn) {
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.
*/
async function awaitLogQuiescence(durability, quietMs) {
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) {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, ms));
}
var CLAIM_LATCHES = /* @__PURE__ */ new WeakMap();
function latchFor(claim) {
const existing = CLAIM_LATCHES.get(claim);
if (existing !== void 0) return existing;
const latch = { lost: void 0 };
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, latch) {
if (latch.lost !== void 0) return latch.lost;
if (claim.signal.aborted) {
latch.lost = new RunClaimLostError(claim.runId, claim.epoch, "lease-lost");
return latch.lost;
}
}
/**
* 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, latch, runs) {
let observed;
try {
observed = (await runs.get(claim.runId))?.driverEpoch;
} catch {
return;
}
if (observed !== void 0 && observed > claim.epoch) {
latch.lost = new RunClaimLostError(claim.runId, claim.epoch, observed);
return latch.lost;
}
}
/**
* 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.
*/
function fenceDurability(durability, claim, options) {
const recheckAppends = Math.max(1, Math.trunc(options.epochRecheckAppends ?? 32));
let appendsSinceEpochRead = recheckAppends;
const latch = latchFor(claim);
async function assertHeld() {
const synchronous = claimLostSynchronously(claim, latch);
if (synchronous !== void 0) throw synchronous;
if (appendsSinceEpochRead < recheckAppends) {
appendsSinceEpochRead += 1;
return;
}
appendsSinceEpochRead = 1;
const byEpoch = await claimLostByEpoch(claim, latch, options.runs);
if (byEpoch !== void 0) throw byEpoch;
}
return {
resumeFrom: () => durability.resumeFrom(),
append: async (chunks) => {
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.
*/
function fenceRunStore(runs, claim, options = {}) {
const latch = latchFor(claim);
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 === void 0 || !isTerminalRunStatus(status)) return runs.update(runId, patch);
const lost = claimLostSynchronously(claim, latch) ?? await claimLostByEpoch(claim, latch, runs);
if (lost === void 0) return runs.update(runId, patch);
try {
options.logger?.sandbox(`run ${runId}: suppressed a terminal '${status}' record write from a superseded driver`, {
runId,
status,
heldEpoch: claim.epoch,
error: lost
});
} catch {}
},
...listByThread === void 0 ? {} : { listByThread },
...listReclaimable === void 0 ? {} : { listReclaimable }
};
}
//#endregion
export { DEFAULT_FENCE_QUIET_MS, RunClaimLostError, RunClaimNotAcquiredError, awaitLogQuiescence, fenceDurability, fenceRunStore, runDriverLockKey, withRunClaim };
//# sourceMappingURL=claim.js.map