@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.
189 lines (188 loc) • 10.8 kB
JavaScript
import { journalExistsCommand } from "./journal.js";
import { isTerminalRunStatus } from "@tanstack/ai";
//#region src/attach-preflight.ts
/**
* 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.
*/
/**
* 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`.
*/
var DEFAULT_ATTACH_JOURNAL_WAIT_MS = 1e4;
/**
* 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.
*/
var DEFAULT_ATTACH_PROBE_INTERVAL_MS = 100;
/**
* 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.
*/
var JournalAttachUnavailableError = class extends Error {
runId;
reason;
constructor(runId, reason, detail) {
super(`cannot attach to run ${runId}: ${detail}`);
this.runId = runId;
this.reason = reason;
this.name = "JournalAttachUnavailableError";
}
};
/**
* 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, options) {
try {
return (await handle.process.exec(journalExistsCommand(options.paths))).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) {
if (options.runs === void 0) return void 0;
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;
}
}
function sleep(ms, signal) {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(finish, ms);
function finish() {
clearTimeout(timer);
signal?.removeEventListener("abort", finish);
resolve();
}
signal?.addEventListener("abort", finish, { once: true });
});
}
function describeRecord(record) {
return record.detachedSince === void 0 ? `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.
*/
async function awaitAttachableJournal(handle, options) {
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 !== void 0 && 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.`);
const waitMs = options.waitMs ?? 1e4;
const probeIntervalMs = options.probeIntervalMs ?? 100;
const deadline = Date.now() + waitMs;
let lastExistence = existence;
for (;;) {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new JournalAttachUnavailableError(options.runId, "journal-timeout", `the run record says ${record === void 0 ? "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.`));
if (options.signal?.aborted) return;
await sleep(Math.min(probeIntervalMs, remaining), options.signal);
lastExistence = await probeJournal(handle, options);
if (lastExistence === "yes") return;
}
}
//#endregion
export { DEFAULT_ATTACH_JOURNAL_WAIT_MS, DEFAULT_ATTACH_PROBE_INTERVAL_MS, JournalAttachUnavailableError, awaitAttachableJournal };
//# sourceMappingURL=attach-preflight.js.map