@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
JavaScript
//#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