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.

293 lines (279 loc) 14.2 kB
/** * The gate that turns a HOPELESS attach into an error instead of an infinite * wait. * * `journalFollowCommand` creates the journal before tailing it (`: >> file`), * because `tail -f` on a missing path prints a diagnostic and EXITS rather than * waiting — a defect that made a legitimate attach racing the driver's first * write deliver zero lines. But creating the file has a cost: an attach for a * `runId` that never had a journal creates an EMPTY one and tails it forever. No * `{"__exit":N}` sentinel can ever arrive, so the caller waits indefinitely with * no error, no timeout, and no log line — for what is the single most likely * mistake on this path (a stale link, a typo, a run whose journal was cleaned up * after completing). * * Absence of the journal alone cannot decide the question, which is exactly why * `: >> file` exists: "not written YET" and "will never be written" look * identical on the filesystem. The RUN RECORD is what distinguishes them, and it * is authoritative — `runs.get(runId)` says whether the run exists at all, * whether it is terminal, and (via `detachedSince`) whether anyone is expected * to be driving it. So the policy is: * * | journal | record | decision | * | --------- | ----------------------- | ------------------------------------- | * | exists | (not consulted) | attach, under the reader's own bound | * | absent | unknown (`null`) | fail fast, `'unknown-run'` | * | absent | terminal | fail fast, `'terminal-run'` | * | absent | running / interrupted | BOUNDED wait, then `'journal-timeout'`| * | unusable | running / interrupted | BOUNDED wait, then `'journal-timeout'`| * * Four deliberate choices in that table: * * 1. **An existing journal short-circuits this gate**, before the store is read * at all — because gating it would make a perfectly readable journal * unreadable whenever a store lost its record. It does NOT mean the read is * unbounded: this module used to justify the short-circuit with "a journal * that exists either carries a sentinel or is still being appended to, neither * hangs", and that trichotomy was FALSE. `journalFollowCommand`'s first act is * `: >> file`, so the reader itself manufactures the third state — a file that * exists, receives nothing, and can never receive a sentinel — and the same * state is independently reachable by SIGKILL/OOM of the agent's shell before * its `printf`. The bound for it lives where it belongs, on the read: * `journal-reader.ts` fails a follow/poll that receives no bytes at all within * {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS} with `'journal-stalled'`. * 2. **A terminal record with no journal fails rather than waiting.** Nothing * will ever be appended: the run is over and `journalCleanupCommand` deletes a * terminal run's files by design. Its transcript lives in the event log, which * the resume response serves independently of this path. * 3. **A live or detached record waits, but not forever.** A driver that has * claimed the run and not yet written its first line is the normal case, not * the unlucky one, so failing fast here would break the very race * `journalFollowCommand` was fixed to tolerate. `detachedSince` does NOT * change the decision — a detached run's journal is exactly what a successor * is supposed to read, and a driver that died before its first write leaves an * identical filesystem state — but it IS reported in the timeout message, * since "detached with no journal after N ms" and "attached with no journal * after N ms" point at different causes. * 4. **An UNUSABLE probe falls through to the bounded wait; it does not skip the * gate.** This module used to fail open here — `if (existence === 'unknown') * return` — on the reasoning that a diagnostic gate must not break an attach * that would otherwise have worked. That reasoning inverted the actual risk. * Returning handed control to a reader whose very first act CREATES the * journal (`journalFollowCommand`'s `: >> file`) and then tails it forever, so * the fail-open path did not preserve a working attach — it manufactured the * exact infinite wait this module exists to prevent. Worse, it was * self-perpetuating: the file it created made `test -f` succeed from then on, * so every LATER attach short-circuited at choice 1 and hung too, permanently, * long after the transient probe failure had cleared. An unanswerable probe is * precisely when a deadline matters most, so an unusable probe is re-polled * (it may recover) and, failing that, times out. The store checks still run * first and need no probe, so an unknown or terminal `runId` still fails fast. */ import { isTerminalRunStatus } from '@tanstack/ai' import { journalExistsCommand } from './journal' import type { JournalPaths } from './journal' import type { SandboxHandle } from './contracts' import type { InternalLogger } from '@tanstack/ai/adapter-internals' import type { RunRecord, RunStore } from '@tanstack/ai' /** * How long an attach waits for a live run's journal to appear before failing. * * User-relevant, hence exported: this bounds how long an attach REQUEST can sit * before it answers, so an application that fronts the attach route with its own * timeout needs to know the number. Generous relative to the gap between a * driver claiming a run and its first journal write (a `spawn` plus one line), * and short relative to any sane HTTP timeout. Override per run with * `SandboxDurabilityOptions.attachWaitMs`. */ export const DEFAULT_ATTACH_JOURNAL_WAIT_MS = 10_000 /** * How often the bounded wait re-probes for the journal. Not user-facing: it * trades a `test -f` per interval for attach latency, and neither number is * something an application tunes. */ export const DEFAULT_ATTACH_PROBE_INTERVAL_MS = 100 /** * Which of the three hopeless-attach cases was hit. Exported so a consumer can * branch (a 404 for `'unknown-run'`, a 410 for `'terminal-run'`, a 504 for * `'journal-timeout'`) instead of matching on message text. */ export type AttachUnavailableReason = | 'unknown-run' | 'terminal-run' | 'journal-timeout' /** * The journal EXISTS but produced no bytes at all within the deadline, so no * sentinel can be coming and the follow would tail an empty (or abandoned) * file forever. Raised by `journal-reader.ts`, not by the preflight: the * preflight cannot see this state, because `test -f` succeeds for it. * * A 504 at an attach route, exactly like `'journal-timeout'`, which is why it * shares {@link JournalAttachUnavailableError} — but a distinct value, because * the cause is different: `'journal-timeout'` means nobody created the * journal, `'journal-stalled'` means somebody did and then stopped (a * SIGKILLed agent shell, a destroyed sandbox, a reader that created the file * itself on a fail-open path). */ | 'journal-stalled' /** * An attach cannot succeed, and waiting longer would not change that. * * One class with a {@link AttachUnavailableReason} discriminant rather than three * classes: every consumer of this path handles all three cases at the same seam * (the attach route), so one `instanceof` plus a `switch (error.reason)` is the * shape that is actually written, while the message names the specific case for a * human reading a log. */ export class JournalAttachUnavailableError extends Error { constructor( readonly runId: string, readonly reason: AttachUnavailableReason, detail: string, ) { super(`cannot attach to run ${runId}: ${detail}`) this.name = 'JournalAttachUnavailableError' } } /** Existence of the journal, or `'unknown'` when the probe itself failed. */ type JournalExistence = 'yes' | 'no' | 'unknown' export interface AwaitAttachableJournalOptions { /** The run's journal paths, as {@link journalPaths} derived them. */ paths: JournalPaths /** Run id, for the store lookup and the error messages. */ runId: string /** * The authoritative run record store. Omitted only by a caller with no store * wired, which loses the unknown/terminal classification but keeps the bound. */ runs?: RunStore /** Bounded wait. Defaults to {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS}. */ waitMs?: number /** Re-probe interval. Defaults to {@link DEFAULT_ATTACH_PROBE_INTERVAL_MS}. */ probeIntervalMs?: number /** * The consumer's abort. An aborted wait returns rather than throwing: the * caller stopped caring, which is not a diagnosis about the run. */ signal?: AbortSignal logger?: InternalLogger } /** * Shell `test -f`, never `handle.fs.exists` — `journal.ts` rule 3: on * local-process the two resolve `/tmp` differently, so `fs.exists` would probe a * path the journal was never written to and report `false` for every run. */ async function probeJournal( handle: SandboxHandle, options: AwaitAttachableJournalOptions, ): Promise<JournalExistence> { try { const result = await handle.process.exec( journalExistsCommand(options.paths), ) return result.exitCode === 0 ? 'yes' : 'no' } catch (error) { options.logger?.provider( `attach preflight: journal existence probe failed for run ${options.runId}; re-probing under the bounded wait rather than attaching blind`, { runId: options.runId, error }, ) return 'unknown' } } /** * `null` means the store answered "no such run" — a real, actionable fact. * `undefined` means there is no answer to be had (no store, or `get` threw), and * the caller must not treat that as "unknown run". */ async function readRecord( options: AwaitAttachableJournalOptions, ): Promise<RunRecord | null | undefined> { if (options.runs === undefined) return undefined try { return await options.runs.get(options.runId) } catch (error) { options.logger?.errors( `attach preflight: reading the run record failed for run ${options.runId}`, { runId: options.runId, error }, ) return undefined } } function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> { if (ms <= 0) return Promise.resolve() return new Promise<void>((resolve) => { const timer = setTimeout(finish, ms) function finish(): void { clearTimeout(timer) signal?.removeEventListener('abort', finish) resolve() } signal?.addEventListener('abort', finish, { once: true }) }) } function describeRecord(record: RunRecord): string { return record.detachedSince === undefined ? `status '${record.status}' with a viewer attached` : `status '${record.status}', detached since ${new Date(record.detachedSince).toISOString()}` } /** * Resolve once the run's journal can be tailed, or reject with a * {@link JournalAttachUnavailableError} explaining why it never will be. * * Call this BEFORE the first follow/poll read of an attach, never on a fresh * run: a fresh run's journal is created by its own `journaledCommand` spawn, * which has not happened yet, so gating it would fail every new run. */ export async function awaitAttachableJournal( handle: SandboxHandle, options: AwaitAttachableJournalOptions, ): Promise<void> { const existence = await probeJournal(handle, options) if (existence === 'yes') return const record = await readRecord(options) if (record === null) { throw new JournalAttachUnavailableError( options.runId, 'unknown-run', `no run record exists and the journal (${options.paths.journal}) has never been written, so nothing will ever be appended to it. ` + `The runId is unknown to the RunStore — it is mistyped, from another deployment, or its record has been evicted.`, ) } if (record !== undefined && isTerminalRunStatus(record.status)) { throw new JournalAttachUnavailableError( options.runId, 'terminal-run', `the run is already '${record.status}' and its journal (${options.paths.journal}) does not exist, so nothing will ever be appended to it. ` + `A terminal run's transcript lives in its event log, not in a journal — serve the log instead of attaching.`, ) } // NOTE: no fail-open branch here. An `existence === 'unknown'` probe falls // through into the bounded wait below — see choice 4 in the module doc for why // returning was worse than timing out, not safer. const waitMs = options.waitMs ?? DEFAULT_ATTACH_JOURNAL_WAIT_MS const probeIntervalMs = options.probeIntervalMs ?? DEFAULT_ATTACH_PROBE_INTERVAL_MS const deadline = Date.now() + waitMs let lastExistence: JournalExistence = existence for (;;) { const remaining = deadline - Date.now() if (remaining <= 0) { throw new JournalAttachUnavailableError( options.runId, 'journal-timeout', `the run record says ${record === undefined ? 'nothing (no run store is wired)' : describeRecord(record)}, ` + (lastExistence === 'unknown' ? `and its journal (${options.paths.journal}) could not be probed at all within ${waitMs}ms — every '${journalExistsCommand(options.paths)}' failed. ` + `Attaching anyway would create that journal and tail it forever, so this fails instead. Check that the sandbox is still alive and that its exec transport works.` : `but its journal (${options.paths.journal}) did not appear within ${waitMs}ms. ` + `Either the driver died before writing its first line, or the journal directory does not match the one the agent was started with.`), ) } // The consumer gave up (client gone, lease lost). Returning hands control // back to the reader, whose own AbortSignal handling ends the read — a // caller's abort is not a diagnosis about the run. if (options.signal?.aborted) return await sleep(Math.min(probeIntervalMs, remaining), options.signal) lastExistence = await probeJournal(handle, options) if (lastExistence === 'yes') return } }