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.

106 lines (105 loc) 4.86 kB
//#region src/reclaim.ts /** * Destroy the sandbox a terminal run was bound to. * * Two orderings are load-bearing: * * - **The provider check before either `destroy` or `delete`.** A multi-provider * application would otherwise hand a Docker container id to Daytona's * `destroy`, which at best errors and at worst matches an unrelated sandbox * in the other provider's id namespace. Getting this wrong destroys a * stranger's workload, so it is the first gate — a mismatch touches NOTHING, * including the record, which the right provider still needs. * - **`destroy` before `delete`, and `delete` regardless of whether `destroy` * succeeded.** The provider sandbox may already be gone (idle-reclaimed, the * region wiped, the container pruned). Keeping an instance record that points * at nothing guarantees a failed `resume` on the thread's next turn, which is * strictly worse than an orphaned provider sandbox — one is a broken user * experience, the other is a bounded cost the provider itself will reclaim. * The delete is therefore unconditional — but a failed `destroy` returns * `'destroy-failed'`, not `'destroyed'`: the record is gone either way, and an * operator has to be able to tell "torn down" from "possibly still billing and * no longer reachable from here". */ async function reclaimSandbox(record, options) { const key = record.sandboxKey; if (key === void 0) return "no-sandbox-key"; const instance = await options.instances.get(key); if (instance === null) return "not-found"; if (instance.provider !== options.provider.name) { options.logger?.warn("reclaim: instance record belongs to a different provider; refusing to destroy", { runId: record.runId, sandboxKey: key, recordProvider: instance.provider, reclaimerProvider: options.provider.name }); return "provider-mismatch"; } let destroyFailed = false; try { await options.provider.destroy({ id: instance.providerSandboxId }); } catch (error) { destroyFailed = true; options.logger?.warn("reclaim: provider destroy failed; deleting the record anyway", { runId: record.runId, sandboxKey: key, providerSandboxId: instance.providerSandboxId, error }); } await options.instances.delete(key); return destroyFailed ? "destroy-failed" : "destroyed"; } /** * Thrown by {@link sandboxReclaimer} when {@link reclaimSandbox} answers * `'destroy-failed'`. * * WHY AN EXCEPTION AND NOT A RETURN VALUE. `ReapOptions.reclaim` is * `(record) => Promise<void>`, and the sweep's only channel for "the sandbox was * NOT reclaimed" is a rejection — `reapOne` catches one and reports the run * `'reclaim-failed'` with its `status` and `exitCode` intact. A reclaimer that * logged this arm and returned normally therefore reported `'finalized'`, and * `outcomes['reclaim-failed']` read `0` on precisely the leak it watches for. * * It carries no `cause`: `reclaimSandbox` returns a {@link ReclaimOutcome}, not * the provider's rejection, and widening that return to smuggle the error out * would change an outcome contract whose ordering and arms are load-bearing. The * underlying `destroy` rejection is on `reclaimSandbox`'s own `warn` line, which * carries the same `runId` and `sandboxKey` this error does. */ var SandboxReclaimFailedError = class extends Error { runId; /** Absent only in the impossible case; see the throw site in `sandboxReclaimer`. */ sandboxKey; constructor(runId, sandboxKey) { super(`Reclaiming the sandbox for run "${runId}" failed: the provider's destroy rejected and the instance record${sandboxKey === void 0 ? "" : ` for "${sandboxKey}"`} was deleted anyway, so the sandbox may still be running and is no longer reachable from the instance store.`); this.name = "SandboxReclaimFailedError"; this.runId = runId; this.sandboxKey = sandboxKey; } }; /** * Adapt {@link reclaimSandbox} to `ReapOptions.reclaim`. * * REJECTS on `'destroy-failed'` — see {@link SandboxReclaimFailedError} for why * that arm must not resolve. Every other outcome resolves: `'destroyed'` did the * job, and `'no-sandbox-key'` / `'not-found'` / `'provider-mismatch'` all mean * there is nothing for this reclaimer to tear down, which is not a sweep failure. */ function sandboxReclaimer(options) { return async (record) => { const outcome = await reclaimSandbox(record, options); const meta = { runId: record.runId, ...record.sandboxKey === void 0 ? {} : { sandboxKey: record.sandboxKey } }; if (outcome === "destroy-failed") { options.logger?.errors("reclaim: destroy failed; sandbox may still be running", meta); throw new SandboxReclaimFailedError(record.runId, record.sandboxKey); } options.logger?.sandbox(`reclaim: ${outcome}`, meta); }; } //#endregion export { SandboxReclaimFailedError, reclaimSandbox, sandboxReclaimer }; //# sourceMappingURL=reclaim.js.map