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.

724 lines (693 loc) 32.2 kB
/** * The sweep `RunStore.listReclaimable` was always missing a consumer for: take a * detached run whose viewer never came back, save its transcript, terminalize its * record, and tear its sandbox down. * * THE ONE RULE THAT SHAPES EVERYTHING HERE: **never drive a run to find out * whether it finished.** * * The obvious design — hand the run to `pipeToRunLog` under a short * `runBudgetMs` and see whether it terminalizes — was measured and is broken. * `pipeToRunLog` is total by construction: it ALWAYS writes a terminal status and * ALWAYS calls `durability.close()`. Against a run that has not finished, all * three producer shapes are destructive: * * | producer's reaction to the budget signal | stored status | `close()` | * | ---------------------------------------- | ------------- | --------- | * | ignores it and keeps producing | `aborted` | called | * | returns on abort (the realistic `drive`) | `aborted` | called | * | throws an AbortError | `failed` | called | * * The middle row USED to read `completed`, which was the fatal one: a signal-aware * producer exits its loop NORMALLY, and `pipeToRunLog` only checked its signal * per chunk, so a healthy mid-flight run was recorded as `'completed'` with a * `finishedAt` — a false transcript. That gap is fixed (`run.ts` re-checks the * signal after the loop), so the status is now honest on all three rows. The rule * above is UNCHANGED, because the status was never the whole harm: every row * writes a terminal record and closes a log that commit `5a1f821c9` deliberately * leaves OPEN for takeover (ending every attached client's stream), and a terminal * record drops out of `listReclaimable` forever, so TTL expiry can never reclaim * that run's sandbox. A cost leak with no recovery path. There is therefore no * "still running" outcome in {@link ReapRunOutcome}: it is unreachable by * construction, not merely unlikely. * * So sentinel-reached is detected OUT OF BAND, through the in-sandbox journal * ({@link probeRunExit}), and `pipeToRunLog` is entered only for a run already * KNOWN to have finished, or for one whose TTL has expired (terminal either way). * On the FINALIZATION path `runBudgetMs` therefore degrades from a load-bearing * mechanism into a safety net whose expiry is a genuine anomaly — see * `'budget-exceeded'`. On the EXPIRY path it stays load-bearing: nothing polls the * cancel this module records, so the budget is what ends the drive of an expired * run whose agent is still producing, and its expiry there is the designed path. * * WHY THE PROBE IS INJECTED (`ReapOptions.hasFinished`) rather than resolved * here, exactly like `ReapOptions.reclaim`: * * - It cannot read `durability.snapshot()`. After a detach nothing appends to the * delivery log — the host that would have appended is the host that left — so * the log is frozen at the last delivered chunk while the JOURNAL keeps * growing. The log can only ever say "no news". * - It cannot resolve a `SandboxHandle` either. `SandboxInstanceStore` is * `get`/`upsert`/`delete` with no `list` (see `reclaim.ts` for why that is * deliberate), and only the application maps a `sandboxKey` to a live handle. * * NEVER REJECTS. This runs from a cron, an `alarm()`, or a `waitUntil` with * nobody to catch it, so every per-run failure is logged and folded into * {@link ReapResult} rather than escaping. * * NEVER CLEARS `detachedSince`. That field is what the reaper SELECTS on, and * `packages/ai/src/stream-to-response.ts`'s `startRunDriver` clears it because a * real viewer stopping the TTL clock is the opposite job. Its comment there names * borrowing that path "the single most likely bug in this phase"; clearing the * marker would reset the TTL on every sweep and a detached run would never * expire. */ import { isTerminalRunStatus, requestRunCancel } from '@tanstack/ai' import { DEFAULT_FENCE_QUIET_MS, RunClaimLostError, // Thrown, not merely caught: the expiry re-derivation under the lock refuses // its own claim when the run's viewer has come back. RunClaimNotAcquiredError, awaitLogQuiescence, fenceDurability, fenceRunStore, withRunClaim, } from './claim' import { pipeToRunLog } from './run' import { journalExitProbeCommand, journalPaths, parseJournalExit, } from './journal' import { decodeBase64Stream } from './journal-bytes' import type { SandboxHandle } from './contracts' import type { InternalLogger } from '@tanstack/ai/adapter-internals' import type { LockStore } from '@tanstack/ai/locks' import type { RunRecord, RunStatus, RunStore, StreamChunk, StreamDurability, } from '@tanstack/ai' /** * Safety net for a single run's drive. Not the mechanism that decides whether a * run finished — see the module doc for why that design was rejected — so this is * generous rather than tight: it only has to stop a drive that has genuinely * wedged on a run the journal already said was over. * * On the expiry path it is not merely a net: it is what stops a still-producing * agent, since nothing polls the cancel recorded before that drive. A caller that * expires live agents may want a tighter value there than a finalization replay * needs. */ export const DEFAULT_RUN_BUDGET_MS = 30_000 /** * Runs one sweep will touch. A cron invocation is bounded (a Worker's CPU * budget, a Lambda timeout), and an unbounded sweep over a backlog of thousands * would be killed mid-run rather than finishing 25 and returning; the next tick * takes the next batch. */ export const DEFAULT_MAX_RUNS = 25 /** Journal tail bytes {@link probeRunExit} reads. The sentinel is the last line. */ export const DEFAULT_EXIT_PROBE_BYTES = 4096 /** * What the out-of-band probe learned about a detached run's agent. * * THREE ARMS, not a boolean, because "could not tell" must not be * indistinguishable from "still working": both leave the run alone, but only one * of them is a condition an operator should see. A two-valued probe would also * invite the caller to treat a provider `exec` failure as "finished" and drive a * live run — the exact defect this module exists to prevent. */ export type RunExitProbe = /** The `{"__exit":N}` sentinel is in the journal. The agent is over. */ | { state: 'finished'; exitCode: number } /** No sentinel. The agent is mid-flight (or never started). LEAVE IT ALONE. */ | { state: 'producing' } /** The probe could not answer — no sandbox, `exec` rejected, frame undecodable. */ | { state: 'unknown'; error?: unknown } /** What one sweep did to one run. */ export type ReapRunOutcome = /** * The probe saw `{"__exit":N}`, the run was driven to a terminal status, and * its transcript is saved. The happy path. */ | 'finalized' /** * Past `detachedRunTtlMs`. Cancelled first, then driven to terminal. The probe * is skipped: the outcome is terminal whether the agent finished or not. * * Reported even when {@link ReapOptions.runBudgetMs} is what ended the drive — * on this path that is the mechanism rather than an anomaly, so `'expired'` is * the truthful outcome. The run's own `status` distinguishes the two shapes: * an agent that had already finished replays to `'completed'`, while one still * producing when the budget fired is `'aborted'`. */ | 'expired' /** * Still producing. `pipeToRunLog` was NEVER entered — nothing appended, no * terminal record written, `close()` not called, `detachedSince` untouched. */ | 'producing' /** The probe could not answer. Left exactly as untouched as `'producing'`. */ | 'unknown' /** * ANOMALY. The drive outran {@link ReapOptions.runBudgetMs} on a run the * journal already said was finished. The record IS terminal and the log IS * closed (`pipeToRunLog` guarantees both), so this is a diagnostic, not a leak * — but a finished run that would not replay in 30s means the journal read, the * translation, or the log is misbehaving. * * FINALIZATION ONLY. An expired run that outran its budget reports `'expired'`: * there was no probe on that path and the agent may legitimately still have been * producing, so the budget firing is the designed stop, not a misbehaving replay. */ | 'budget-exceeded' /** * Another host holds the claim, or held it and superseded us mid-drive. Normal: * a real viewer attaching mid-sweep is exactly this. Also covers a run that * reached terminal in another host's hands between the listing and the claim. */ | 'not-claimed' /** * The transcript IS saved and the record IS terminal — only * {@link ReapOptions.reclaim} threw, so the sandbox is still up. * * A DISTINCT outcome rather than `'failed'`, because the two need opposite * operator responses and `'failed'` cannot express this one: it carries no * `status` and no `exitCode`, so "transcript saved, sandbox NOT reclaimed" * read identically to "the sweep failed and the run was never finalized". * * NOT RETRYABLE BY THE SWEEP. The record is terminal by now, so the run has * left `listReclaimable` for good; the sandbox leaks until something else * tears it down. This entry, with its `error`, is the only notice of that. * * OVERWRITES `'budget-exceeded'` when both happened, because the leak is what * needs acting on — {@link ReapRunEntry.terminalizedAnyway} is what preserves * the budget half of that pair. * * `sandboxReclaimer` REJECTS on its `'destroy-failed'` arm precisely so this * outcome is reachable through the shipped reclaimer and not only through a * custom one; see `SandboxReclaimFailedError` in `reclaim.ts`. */ | 'reclaim-failed' /** Something threw. Logged, recorded here, and the sweep continued. */ | 'failed' /** One run's line in the sweep summary. */ export interface ReapRunEntry { runId: string outcome: ReapRunOutcome /** The run's status after the sweep, when the run was driven. */ status?: RunStatus /** The agent's exit code, when the probe read one. */ exitCode?: number /** * THE BUDGET ANOMALY MARKER, and the only field whose mere PRESENCE carries a * fact: it is set if and only if the drive outran * {@link ReapOptions.runBudgetMs} on the finalization path — the condition * `'budget-exceeded'` names. Its value is whether the record nonetheless * reached a terminal status, practically always `true` since `pipeToRunLog` is * total; it is reported rather than assumed so an operator does not have to * infer it. * * SURVIVES A FAILED RECLAIM. `reclaim` runs after the outcome is classified * and overwrites it with `'reclaim-failed'`, which is the more urgent fact (a * leaked sandbox nothing will retry) and so wins the single `outcome` slot. * This field is therefore what keeps the budget anomaly on the entry: an * operator seeing `'reclaim-failed'` WITH `terminalizedAnyway` present is * looking at a run that blew its budget and then leaked, and needs both halves. */ terminalizedAnyway?: boolean error?: unknown } export interface ReapResult { /** Runs in this batch — i.e. after the {@link ReapOptions.maxRuns} cap. */ considered: number /** Runs {@link ReapOptions.hasFinished} was actually called for. */ probed: number outcomes: Record<ReapRunOutcome, number> runs: Array<ReapRunEntry> } export interface ReapOptions<TOffset extends string = string> { runs: RunStore locks: LockStore /** * Per-run event log factory, same shape `RunDeps.durability` takes. * * Generic in the offset type, defaulted to `string` so an existing call site * needs no change — see {@link SandboxRunDriverOptions.durability} for why * hardcoding the default locked out branded-cursor backends. */ durability: (runId: string) => StreamDurability<TOffset> /** * The out-of-band "did the agent reach its sentinel?" probe. INJECTED, because * neither the delivery log nor this package can answer it — see the module doc. * {@link probeRunExit} is the implementation an application wires in once it has * resolved the run's `SandboxHandle`. */ hasFinished: (record: RunRecord) => Promise<RunExitProbe> /** Produce the run's remaining events. Called only once the claim is held. */ drive: (input: { runId: string threadId: string signal: AbortSignal }) => AsyncIterable<StreamChunk> /** Sweep clock, passed rather than read so a sweep is reproducible. */ now: number /** Detached-run TTL; `detachedSince <= now - ttl` expires, INCLUSIVELY. */ detachedRunTtlMs: number /** Safety net per drive. Defaults to {@link DEFAULT_RUN_BUDGET_MS}. */ runBudgetMs?: number /** Batch cap. Defaults to {@link DEFAULT_MAX_RUNS}. */ maxRuns?: number /** Quiescence window; defaults to `DEFAULT_FENCE_QUIET_MS`. */ fenceQuietMs?: number /** * Tear the run's sandbox down. Called ONLY after the run reached a terminal * status, and with the ORIGINALLY LISTED record — see {@link reapDetachedRuns}. * `sandboxReclaimer` in `reclaim.ts` is the ready-made implementation. */ reclaim?: (record: RunRecord) => Promise<void> logger?: InternalLogger } async function* singleValue(value: string): AsyncIterable<string> { yield value } /** Decode the base64 frame `journalExitProbeCommand` emits. */ async function decodeFrame(stdout: string): Promise<string> { const decoder = new TextDecoder() let text = '' for await (const bytes of decodeBase64Stream(singleValue(stdout))) { text += decoder.decode(bytes, { stream: true }) } return text + decoder.decode() } /** * Read the END of a run's journal and answer whether the agent reached its * `{"__exit":N}` sentinel. Read-only: no append, no record write, no `close()`. * * This is the whole reason the reaper is safe. It is the ONLY way to learn that a * detached run is over without driving it, because the delivery log stops growing * the moment the viewer leaves while the journal does not. * * ANY failure answers `'unknown'`, never `'finished'`: the caller drives a run it * is told finished, so a provider `exec` that rejected, a sandbox that is gone, or * a frame the provider truncated must never be read as "the agent exited". * * An EMPTY tail answers `'producing'` — the fail-safe direction. A journal that * does not exist yet is indistinguishable here from one with no sentinel, and both * mean "do not touch this run". */ export async function probeRunExit(input: { handle: SandboxHandle runId: string /** Journal directory; defaults to `DEFAULT_JOURNAL_DIR`, as `journalPaths` does. */ dir?: string /** Tail bytes to read. Defaults to {@link DEFAULT_EXIT_PROBE_BYTES}. */ maxBytes?: number }): Promise<RunExitProbe> { try { const paths = journalPaths(input.runId, input.dir) const result = await input.handle.process.exec( journalExitProbeCommand( paths, input.maxBytes ?? DEFAULT_EXIT_PROBE_BYTES, ), ) // `paths` supplies the per-run sentinel nonce: without it a mid-flight // agent that printed any JSON object carrying `__exit` would read as // `'finished'` here, and the caller would drive and reclaim a LIVE run. const exitCode = parseJournalExit(await decodeFrame(result.stdout), paths) return exitCode === null ? { state: 'producing' } : { state: 'finished', exitCode } } catch (error) { return { state: 'unknown', error } } } /** Every outcome key present at zero, so a consumer can read any of them. */ function emptyOutcomes(): Record<ReapRunOutcome, number> { return { finalized: 0, expired: 0, producing: 0, unknown: 0, 'budget-exceeded': 0, 'not-claimed': 0, 'reclaim-failed': 0, failed: 0, } } /** * Report through a consumer-supplied logger without letting it break the sweep. * Mirrors `run.ts`'s `safeLog`: this module's totality must not be defeated by a * sink that cannot serialize a thrown value. */ function safeLog( logger: InternalLogger | undefined, level: 'errors' | 'sandbox', message: string, context: Record<string, unknown>, ): void { try { if (level === 'errors') logger?.errors(message, context) else logger?.sandbox(message, context) } catch { // Intentionally empty: there is no second channel to report on. } } /** Resolved-once settings shared by every run in one sweep. */ interface ReapContext<TOffset extends string = string> { options: ReapOptions<TOffset> runBudgetMs: number fenceQuietMs: number /** Inclusive expiry cutoff: `detachedSince <= cutoff` is expired. */ cutoff: number } /** Whether a thrown value means "we do not own this run", which is normal. */ function isClaimRefusal(error: unknown): boolean { return ( error instanceof RunClaimNotAcquiredError || error instanceof RunClaimLostError ) } /** * Sweep ONE run. Never rejects: the caller folds the returned entry into the * summary and moves on. * * The ORDER of the steps below is the contract, not an implementation detail: * * 1. **Classify expiry first**, because an expired run needs no probe — its * outcome is terminal whether or not the agent finished, so a probe would only * add a provider round-trip and a way to fail. * 2. **Otherwise probe BEFORE touching anything.** `'producing'` and `'unknown'` * return here, having made no claim, no append, no record write, and no * `close()`. Driving past this point is the whole defect described in the * module doc. * 3. Claim, so two hosts never drive one run. * 4. **Re-derive expiry from a record read INSIDE the lock**, and only then * record the cancel. The listed record is stale by the time the claim is * held, and the cancel is sticky. * 5. Quiesce, so a predecessor still writing is observed rather than raced. * 6. **Arm the run budget**, so it bounds the drive rather than the queue the * two steps above stood in. * 7. Pipe with BOTH authoritative seams fenced, mirroring `driver.ts`. * 8. Reclaim, and ONLY once the record actually reached terminal. */ async function reapOne<TOffset extends string>( record: RunRecord, ctx: ReapContext<TOffset>, counters: { probed: number }, ): Promise<ReapRunEntry> { const { runs, locks, logger } = ctx.options const { runId, threadId } = record try { // INCLUSIVE, exactly as `RunStore.listReclaimable` documents its own cutoff: // a run detached at precisely `now - ttlMs` IS expired. The two must agree, // or a run would be listed as reclaimable and then classified as fresh on // every single sweep, forever. const expired = record.detachedSince !== undefined && record.detachedSince <= ctx.cutoff let exitCode: number | undefined if (!expired) { counters.probed += 1 const probe = await ctx.options.hasFinished(record) if (probe.state !== 'finished') { // THE LEAVE-ALONE PATH. Deliberately returns before `withRunClaim`, so // not even `driverEpoch` moves — and above all `detachedSince` is left // exactly as it was, since it is both this run's TTL evidence and the // field the next sweep selects on. safeLog(logger, 'sandbox', `reap: leaving run ${runId} alone`, { runId, state: probe.state, ...(probe.state === 'unknown' && probe.error !== undefined ? { error: probe.error } : {}), }) return { runId, outcome: probe.state, ...(probe.state === 'unknown' && probe.error !== undefined ? { error: probe.error } : {}), } } exitCode = probe.exitCode } // Armed INSIDE the claim, below. Read after it for the outcome, so it is // hoisted here rather than declared in the callback. let budget: AbortSignal | undefined const final = await withRunClaim( { runs, locks, runId, fenceQuietMs: ctx.fenceQuietMs, ...(logger === undefined ? {} : { logger }), }, async (claim) => { if (expired) { // RE-DERIVED FROM A RECORD READ INSIDE THE LOCK, never from the listed // one. `stream-to-response.ts`'s `startRunDriver` CLEARS // `detachedSince` when a real viewer attaches — deliberately stopping // the TTL clock — and it takes this same per-run lock, so an // expiry decided at listing time is stale by the time the claim is // held. Cancelling on the stale value poisoned a now-live run: // nothing in the tree ever clears `cancelRequested`, so on that // viewer's next ORDINARY disconnect `middleware.ts`'s // `wasCancelRequested` read skips the detach branch and destroys the // sandbox of a healthy, actively-viewed run. const current = await runs.get(runId) if (current === null) { throw new RunClaimNotAcquiredError(runId, 'unknown') } if ( current.detachedSince === undefined || current.detachedSince > ctx.cutoff ) { // The viewer came back. `'not-claimed'` already documents "a real // viewer attaching mid-sweep is exactly this", and refusing here // leaves the run as untouched as the leave-alone path does: no // cancel, no append, no terminal record, no `close()`. throw new RunClaimNotAcquiredError(runId, 'superseded') } // BEFORE the drive, never after — and never before the claim. // `withSandbox`'s `onAbort` resolves the out-of-band cancel band from // the record, so recording the intent first is what makes the teardown // an explicit cancel that DESTROYS the sandbox rather than a second // detach that re-arms `detachedSince` and leaves the run to be swept // again forever. Recorded after the drive it is pure bookkeeping on a // run that already tore down the wrong way. Recorded before the CLAIM // it is an unfenced, sticky write on a record this host does not own, // derived from a value the lock exists to make current. await requestRunCancel(runs, runId) } // Before the first append, never after: `pipeToRunLog` snapshots to align. await awaitLogQuiescence( ctx.options.durability(runId), ctx.fenceQuietMs, ) // A safety net, not a mechanism (see the module doc). `AbortSignal.any` // is this package's idiom for linking one — see // `testkit/takeover-conformance.ts`. // // ARMED HERE, not before `withRunClaim`. Both the lock wait and the // quiescence wait consume a timer started earlier: quiescence always // sleeps at least one `fenceQuietMs` and may sleep six, and lock // acquisition waits behind whoever holds it, unbounded. The effective // budget was silently `runBudgetMs − fenceQuietMs − lockWait`, and once // it went negative the claim was acquired with the timer already fired: // `pipeToRunLog` hit its entry `signal.aborted` check before pulling one // chunk, so a FINISHED agent's transcript was recorded `'aborted'` and // its log closed — and a terminal record leaves `listReclaimable` // forever, so that transcript was then unreplayable while `reclaim` // destroyed the sandbox holding the only copy. The budget bounds the // DRIVE, not the queue. budget = AbortSignal.timeout(ctx.runBudgetMs) // `claim.signal` is in the composed signal because losing the lease MUST // stop the drive: a successor that took the run over is appending to the // same log, and this drive continuing would double every chunk. const signal = AbortSignal.any([claim.signal, budget]) return pipeToRunLog(ctx.options.drive({ runId, threadId, signal }), { // BOTH seams, over the SAME claim, as `driver.ts` explains: fencing the // log alone just moves the harm to "a dead host marks the successor's // live run failed". runs: fenceRunStore(runs, claim, { ...(logger === undefined ? {} : { logger }), }), durability: (id) => fenceDurability(ctx.options.durability(id), claim, { runs }), runId, threadId, signal, ...(logger === undefined ? {} : { logger }), }) }, ) const terminal = isTerminalRunStatus(final.status) let outcome: ReapRunOutcome // `&& !expired` is the whole subtlety. The budget is an ANOMALY only on the // finalization path, where the probe already said the agent hit its sentinel // and a replay that will not finish in 30s means the journal read, the // translation, or the log is misbehaving. On the EXPIRY path there was no // probe and the agent may well be mid-sentence: `requestRunCancel` writes a // record field whose only reader is `withSandbox`'s `onAbort` (which runs // after something else has already aborted), so the budget is the sole thing // that ends the drive of a still-producing expired run. That is the designed // path, not a misbehaving one, and reporting it as the anomaly made // `'expired'` unreachable for exactly the runs the TTL exists to expire. // `budget` is armed inside the claim, so reaching here means it was armed; // `?? false` keeps the read total rather than asserting that. if ((budget?.aborted ?? false) && !expired) { outcome = 'budget-exceeded' } else if (!terminal) { // The terminal write was SUPPRESSED and `finish`'s re-read answered with a // live record, which `fenceRunStore` only does when this host lost the claim // to another one. That is the same fact as a refused claim, reported the // same way rather than as a success that wrote nothing. outcome = 'not-claimed' } else { outcome = expired ? 'expired' : 'finalized' } // CAPTURED BEFORE THE RECLAIM BLOCK, which may overwrite `outcome` with // `'reclaim-failed'`. Conditioning the `terminalizedAnyway` spread on the // post-reclaim `outcome` dropped the budget diagnostic from exactly the // entries that need it most: a run that blew its budget AND then failed to // reclaim reported neither fact but the leak, and an operator cannot // diagnose a leak on a run whose replay was already misbehaving without // knowing that it was. const budgetAnomaly = outcome === 'budget-exceeded' let reclaimError: unknown if (terminal && ctx.options.reclaim !== undefined) { try { // `record`, NOT `final`. When the terminal `update` fails, `finish` returns // a LOCALLY REBUILT record that carries only `runId`/`threadId`/`startedAt` // plus the terminal patch — no `sandboxKey` — so `reclaimSandbox` would see // `undefined`, answer `'no-sandbox-key'`, and the sandbox would leak // silently on exactly the path where something already went wrong. await ctx.options.reclaim(record) } catch (error) { // CAUGHT HERE rather than in the outer catch, which would report a bare // `'failed'` with no `status` and no `exitCode`. `reclaimSandbox` // deliberately does not guard `instances.get` (its contract is that the // CALLER records the failure) and neither does `sandboxReclaimer`, so a // throwing instance store landed there. By this point the record is // terminal and the log closed, so the run is out of `listReclaimable` // forever and no later sweep will retry: the sandbox leaks, and an // operator reading `'failed'` cannot tell "transcript saved, sandbox NOT // reclaimed" from "the sweep failed and the run was never finalized". reclaimError = error outcome = 'reclaim-failed' safeLog(logger, 'errors', `reap: reclaiming run ${runId} failed`, { runId, status: final.status, error, }) } } return { runId, outcome, status: final.status, ...(exitCode === undefined ? {} : { exitCode }), ...(budgetAnomaly ? { terminalizedAnyway: terminal } : {}), ...(reclaimError === undefined ? {} : { error: reclaimError }), } } catch (error) { if (isClaimRefusal(error)) { safeLog(logger, 'sandbox', `reap: not driving run ${runId}`, { runId, error, }) return { runId, outcome: 'not-claimed', error } } // Folded into the summary rather than rethrown: one bad run must not abandon // the rest of the batch, and there is no caller to receive a rejection. safeLog(logger, 'errors', `reap: sweeping run ${runId} failed`, { runId, error, }) return { runId, outcome: 'failed', error } } } /** * Sweep the detached runs a `RunStore` surfaces, saving each finished run's * transcript and reclaiming its sandbox. * * A plain async function with no timer and no daemon: call it from a cron, a * queue consumer, a Durable Object `alarm()`, or a `waitUntil`. It NEVER rejects * — every failure is logged and counted in the returned {@link ReapResult}. * * ONE `listReclaimable({ now, ttlMs: 0 })` call, deliberately: `ttlMs: 0` is * every detached run, which is the candidate set for FINALIZATION (a run that hit * its sentinel one second after the viewer left has an unsaved transcript and * must not wait out the TTL), and expiry is then classified in-process against * the same inclusive cutoff. Listing twice with two TTLs would cost a second * store round-trip to compute a subset. * * `listReclaimable` is OPTIONAL on `RunStore`. A backend without it cannot be * reaped, which answers `{ considered: 0 }` plus one log line rather than * throwing — the same graceful degrade every other optional-method call site in * the repo does (`store.findActiveRun?.(threadId)`). */ export async function reapDetachedRuns<TOffset extends string = string>( options: ReapOptions<TOffset>, ): Promise<ReapResult> { const logger = options.logger const outcomes = emptyOutcomes() const entries: Array<ReapRunEntry> = [] const empty = (): ReapResult => ({ considered: 0, probed: 0, outcomes, runs: entries, }) const list = options.runs.listReclaimable?.bind(options.runs) if (list === undefined) { safeLog( logger, 'sandbox', 'reap: the run store does not implement listReclaimable; nothing to sweep', {}, ) return empty() } let candidates: Array<RunRecord> try { candidates = await list({ now: options.now, ttlMs: 0 }) } catch (error) { safeLog(logger, 'errors', 'reap: listing reclaimable runs failed', { error, }) return empty() } // Capped so one invocation cannot outlive its platform's budget and be killed // mid-drive. `slice` and not a `break`, so `considered` reports the batch the // sweep actually took responsibility for. const maxRuns = Math.max(0, Math.trunc(options.maxRuns ?? DEFAULT_MAX_RUNS)) const batch = candidates.slice(0, maxRuns) const ctx: ReapContext<TOffset> = { options, runBudgetMs: options.runBudgetMs ?? DEFAULT_RUN_BUDGET_MS, fenceQuietMs: options.fenceQuietMs ?? DEFAULT_FENCE_QUIET_MS, cutoff: options.now - options.detachedRunTtlMs, } const counters = { probed: 0 } // Sequential on purpose: each run costs a lock, a provider round-trip, and a // full replay, and a cron invocation's budget is the scarce resource. Fanning // out would multiply peak load against the provider for no throughput a // subsequent tick cannot supply. for (const record of batch) { const entry = await reapOne(record, ctx, counters) outcomes[entry.outcome] += 1 entries.push(entry) } return { considered: batch.length, probed: counters.probed, outcomes, runs: entries, } }