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.

686 lines (685 loc) 27.6 kB
import { journalCleanupCommand, journalExistsCommand, journalPaths, journaledCommand } from "../journal.js"; import { chunkFingerprint, createRunScopedIdGen } from "../chunk-identity.js"; import { alignedIfAttaching, journalOptionsFor, resolveSandboxDurability } from "../durability.js"; import { JournalAttachUnavailableError, awaitAttachableJournal } from "../attach-preflight.js"; import { readJournalNdjson, startJournaledAgent } from "../runner.js"; import { fenceDurability, withRunClaim } from "../claim.js"; import { sandboxRunDriver } from "../driver.js"; import { EventType, InMemoryRunStore } from "@tanstack/ai"; import { InMemoryLockStore } from "@tanstack/ai/locks"; import { describe, expect, it } from "vitest"; //#region src/testkit/takeover-conformance.ts /** * Provider conformance for TAKEOVER: a second driver picking up a run whose * first driver died, against a REAL sandbox. * * WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS. Every takeover unit test in * this package drives fakes — a scripted `spawn`, a `test -f` that answers from * a boolean, a log that is an array. Fakes model what we believe the shell and * the filesystem do, and on this feature that belief has been wrong three times: * `base64` delivers zero bytes on a live pipe, `tail -f` on a missing file exits * instead of waiting, and a provider's `kill` does not always reap a grandchild. * Each one passed every fake. So the four properties a takeover actually rests * on are asserted here through a provider's real `spawn`/`exec` against a real * journal file: * * 1. **The delivered sequence is the run's sequence, with no duplicated * prefix.** Asserted as a TRANSCRIPT, never as "chunks arrived": a takeover * that replays the whole journal and re-appends everything satisfies the weak * assertion while showing the user the entire run twice. That is the exact * failure `alignToStoredLog` exists to prevent, and the only assertion that * can see it is one that compares the stored log to the expected sequence * element for element. * 2. **The attach preflight decides, or fails, but never hangs.** It probes with * the provider's real `exec` (`test -f`), which is the layer where a fake's * assumptions break, and its three verdicts (`unknown-run`, `terminal-run`, * `journal-timeout`) plus the legitimate late-journal race are all timing * against a real filesystem. * 3. **The epoch fence and its latch hold under real concurrency.** Two drivers * reading one real journal at once: the second wins, the first appends * NOTHING — not even `pipeToRunLog`'s recovery `RUN_ERROR` — and cannot * terminalize the record out from under the live successor. * 4. **A terminal run's journal is deleted, and a later attach says so.** The * deletion is a real `rm` of real files, and the follow-up attach must report * `terminal-run` rather than tailing the file that `journalFollowCommand` * would helpfully re-create. * * WHAT IS REAL HERE. The provider (its `spawn`, `exec`, and shell), the journal * (a real NDJSON file the agent's stdout is redirected into), the agent (a real * process writing real lines with a real pause in the middle), the reader * (`readJournalNdjson`, including the follow/poll strategy split and the attach * preflight), the alignment (`alignedIfAttaching` over the real * `resolveSandboxDurability` output), the claim and BOTH fences * (`sandboxRunDriver`), and the run record (`InMemoryRunStore`). The event log is * in-process, exactly as the recommended `memoryStream` backend is. * * A provider that cannot satisfy the contract MUST declare `unsupported.reason`. * As in the journal suite there is deliberately no silent-skip path: a * conformance case that quietly returns prints as a pass, which is how an * unimplemented capability ships green. * * FOUND BY THIS SUITE, FIXED IN THE PROVIDER, STILL NOT ASSERTED HERE. On * local-process under Windows (git-bash `sh`), the follow read's `tail` * grandchild used to SURVIVE `proc.kill()`: `LocalProcessHandle.killTree` ran * `taskkill /PID <sh> /T /F` and returned as soon as `spawnSync` reported no * `error`. Two things were wrong. It never checked taskkill's exit status — and * that alone would not have caught it, because MSYS's fork emulation leaves the * `tail.exe` pointing at an intermediate shell that has already exited, so * `taskkill /T` (live parent links only) cannot reach it and still exits `0`. * Measured by counting `tail.exe` before and after a run: this suite leaked 4 per * run and the shipped journal suite 2, accumulating for the life of the machine. * It was a provider defect, not a takeover defect — every case here still * delivered the right transcript, because `untilAborted` (see * `journal-reader.ts`) stops honoring the pipe once the signal fires rather than * waiting for the kill, which is exactly why it never failed a test. * `killTree` now resolves the tree through MSYS's own process table and verifies * the survivors are gone (0 per run), covered in * `ai-sandbox-local-process/tests/kill-tree.test.ts`. * Deliberately still NOT asserted in this suite: a per-provider process census is * not portable (Docker's `tail` dies with its container), and a conformance case * that counted host processes would fail for reasons unrelated to takeover. * * EVERY WAIT IN THIS FILE IS BOUNDED. A hang stalls CI instead of failing it, so * each journal read carries a timeout signal, each poll loop carries a deadline * and a message naming what never happened, and each case carries an explicit * per-test timeout. * * Vitest is an OPTIONAL peer dependency: this module is imported only from test * files, which already run under Vitest. */ /** * Journal directory for this suite, deliberately NOT * {@link DEFAULT_JOURNAL_DIR}: on local-process the sandbox shell shares the * host's real `/tmp`, so conformance runs must not write where an application's * runs live. */ var CONFORMANCE_JOURNAL_DIR = "/tmp/tanstack-takeover-conformance"; /** Poll interval handed to providers that cannot follow a growing file. */ var POLL_INTERVAL_MS = 50; /** * Quiescence window for the successor's first append. Short because the * predecessor in these cases has provably stopped (the suite sequenced it) — * the gate still runs, it just does not need to wait 5s to observe nothing. */ var FENCE_QUIET_MS = 25; /** * Bound on a real journal read, so a reader that delivers nothing FAILS instead * of parking CI. * * Never an assertion, and deliberately far above anything a healthy read needs * (measured: 10–18s for the follow cases on both providers). Every use site * pairs it with a `backstopped: false` witness, so a read the CLOCK ended fails * naming this backstop rather than as a downstream transcript mismatch — which * means this number can be raised freely and must never be the thing a case is * tuned against. */ var READ_BACKSTOP_MS = 9e4; /** * Unique per case, and it must be: `journalPaths` derives the file name from the * `runId` and the journal is append-only, so a reused id appends BEHIND the * previous run's `{"__exit":N}` sentinel and the new run appears to emit nothing * at all (see `journal.ts`). The counter covers two cases created inside the * same millisecond; the random suffix covers two suites sharing one `/tmp`. */ var caseCounter = 0; function uniqueRunId(label) { caseCounter += 1; const suffix = Math.random().toString(36).slice(2, 8); return `tko-${label}-${Date.now()}-${caseCounter}-${suffix}`; } function conformanceLog() { const entries = []; let closes = 0; return { log: { resumeFrom: () => null, append: (chunks) => Promise.resolve(chunks.map((chunk) => { const offset = `conf:${entries.length}`; entries.push({ offset, chunk }); return offset; })), read: () => (async function* empty() {})(), close: () => { closes += 1; return Promise.resolve(); }, snapshot: () => Promise.resolve(entries.map((entry) => ({ ...entry }))) }, stored: () => entries.map((entry) => entry.chunk), closes: () => closes }; } /** * A lock that grants every request immediately and never reports a loss. * * `InMemoryLockStore` SERIALIZES claims within one process, so a second attach * waits for the first to finish and the two drivers are never concurrent — which * means the epoch fence can never be observed there. `claim.ts` says exactly * that: in one process only layer 2, the `driverEpoch` fence, is provable. This * models a lease-less lock so the two drives overlap and layer 2 does the work. */ var permissiveLocks = { withLock: (_key, fn) => fn(new AbortController().signal) }; /** The event a journal line translates into. `timestamp` is excluded from `chunkFingerprint`. */ function contentChunk(messageId, delta) { return { type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta, timestamp: Date.now() }; } /** * Narrow one parsed journal line into its chunk. * * Fields are validated and the chunk is REBUILT from them rather than asserted * into shape: a cast would let a provider that mangles the bytes (a folded * stderr diagnostic, a truncated line) reach `chunkFingerprint` as a * structurally invalid chunk and fail somewhere unrelated. */ function toChunk(runId, messageId, value) { if (typeof value !== "object" || value === null || !("delta" in value)) throw new Error(`takeover conformance: run ${runId} journal line is not an agent event: ${JSON.stringify(value)}`); const delta = value.delta; if (typeof delta !== "string") throw new Error(`takeover conformance: run ${runId} journal line has a non-string delta: ${JSON.stringify(value)}`); return contentChunk(messageId, delta); } /** * The translator. Deterministic by construction, which is what makes alignment * possible at all: the message id comes from {@link createRunScopedIdGen}, so * re-translating the same journal from byte 0 reproduces byte-identical chunks * (modulo `timestamp`, the one field `chunkFingerprint` excludes). */ async function* translate(runId, lines) { const messageId = createRunScopedIdGen(runId)(); for await (const line of lines) yield toChunk(runId, messageId, line); } /** * A comparable transcript: each chunk reduced to its {@link chunkFingerprint}. * * The fingerprint, not the chunk object, and for the same reason alignment uses * it — `timestamp` is wall-clock and unreproducible, so a raw `toEqual` on * chunks would fail on the one field the feature deliberately ignores. Every * other field participates, so a duplicated prefix, a dropped chunk, or a * reordered one still fails. */ function transcript(chunks) { return chunks.map(chunkFingerprint); } /** The chunks a run over `deltas` must deliver, exactly once and in order. */ function expectedTranscript(runId, deltas) { const messageId = createRunScopedIdGen(runId)(); return deltas.map((delta) => contentChunk(messageId, delta)); } /** * A real agent: a shell command that prints one NDJSON line per delta, with an * optional real pause partway through, then exits. * * `printf '%s\n' a b c` reuses the format for every operand on GNU coreutils and * on busybox alike, so this needs no loop. The JSON contains only double quotes, * so it is safe inside the POSIX single-quoted words this builds. */ function agentCommand(deltas, pauseAfter) { const line = (delta) => `'{"delta":"${delta}"}'`; const head = deltas.slice(0, pauseAfter); const tail = deltas.slice(pauseAfter); const parts = [`printf '%s\\n' ${head.map(line).join(" ")}`]; if (tail.length > 0) parts.push("sleep 2", `printf '%s\\n' ${tail.map(line).join(" ")}`); return parts.join("; "); } /** Resolve durability through the production resolver, fresh or attaching. */ function durabilityFor(runs, log, attach) { const resolved = resolveSandboxDurability({ runs, durability: { adapter: log, journal: CONFORMANCE_JOURNAL_DIR, attach, pollIntervalMs: POLL_INTERVAL_MS } }); if (resolved === void 0) throw new Error("takeover conformance: resolveSandboxDurability returned undefined for a fully wired run"); return resolved; } /** * The reader's journal options for a resolved durability. * * `journalOptionsFor` answers `undefined` for a NON-durable run, which cannot * happen here — every run in this suite is fully wired. Narrowing it with a * thrown error rather than a non-null assertion keeps the impossible case loud * if the resolver's contract ever changes. */ function journalOptions(durability, runId) { const options = journalOptionsFor(durability, runId); if (options === void 0) throw new Error(`takeover conformance: journalOptionsFor answered undefined for durable run ${runId}`); return options; } /** A `'running'` record for `runId`, ready to be claimed. */ async function runningRun(runId, threadId) { const runs = new InMemoryRunStore(); await runs.createOrResume({ runId, threadId, startedAt: Date.now() }); return runs; } /** * Wrap a handle so the `process.exec` calls ONE operation makes can be counted. * * This is how the attach preflight's fail-fast cases are anchored, and the reason * they are not anchored on elapsed time. `awaitAttachableJournal` runs exactly one * `test -f` before it consults the run store, so a decision made from the record * costs one `exec` and a decision made by waiting costs one per * `probeIntervalMs`. The count separates those two behaviors exactly; elapsed time * does not, because a single `exec` is a provider round-trip whose latency the * suite does not control — a `docker exec` on a loaded daemon has been measured at * 9.6s, which fails a `< 4_000ms` bound while the preflight under test did * precisely the right thing. A timing bound that goes red on a busy machine * teaches people to ignore the suite. * * The spread copies the handle's own methods, so everything except `exec` is the * provider's; the wrapper delegates rather than reimplementing. */ function countingExec(handle) { let execs = 0; return { handle: { ...handle, process: { ...handle.process, exec: (command, options) => { execs += 1; return handle.process.exec(command, options); } } }, execs: () => execs }; } /** Poll `check` until it answers true, or fail with a message naming what never happened. */ async function waitUntil(check, options) { const deadline = Date.now() + options.timeoutMs; for (;;) { if (await check()) return; if (Date.now() > deadline) throw new Error(`takeover conformance: ${options.message} within ${options.timeoutMs}ms`); await sleep(25); } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** A one-shot gate, for sequencing two concurrent drivers deterministically. */ function gate() { let open = () => {}; return { promise: new Promise((resolve) => { open = () => resolve(); }), open }; } /** * Build the driver a host would build for one run. * * `drive` is the real journal path: read the run's journal from byte 0 (through * the attach preflight when attaching), translate, and align against the stored * log — `alignedIfAttaching`, so alignment runs on an attach and only on an * attach. * * Returns the driver alongside `backstopped()`, the causal witness for * {@link READ_BACKSTOP_MS}: every case that drives this must assert it is * `false` before its transcript assertions, so a read the CLOCK ended fails * naming the backstop instead of as a truncated-transcript diff. */ function driverFor(input) { const durability = durabilityFor(input.runs, input.log, input.attach); const backstops = []; return { driver: sandboxRunDriver({ request: new Request(`http://takeover.local/attach?runId=${encodeURIComponent(input.runId)}&offset=-1`), runs: input.runs, locks: input.locks, durability: () => input.log, fenceQuietMs: FENCE_QUIET_MS, drive: ({ runId, signal }) => { const backstop = AbortSignal.timeout(READ_BACKSTOP_MS); backstops.push(backstop); const bounded = AbortSignal.any([signal, backstop]); const lines = readJournalNdjson(input.handle, { signal: bounded, journal: journalOptions(durability, runId) }); const gated = input.beforeFirstChunk; const source = gated === void 0 ? lines : (async function* afterGate() { let first = true; for await (const value of lines) { if (first) { first = false; await gated(); } yield value; } })(); return alignedIfAttaching(translate(runId, source), durability); } }), backstopped: () => backstops.some((s) => s.aborted) }; } /** Exactly what core's `startRunDriver` does: claim, then pipe the drive. */ function takeOver(driver, input) { const { runs, runId, threadId } = input; return driver.claim({ runs, locks: driver.locks, runId }, (claim) => driver.pipe(driver.drive({ runId, threadId, signal: claim.signal }), { runId, threadId, signal: claim.signal })); } /** Best-effort removal of a case's journal files, through the shell (rule 3). */ async function cleanup(handle, runId) { try { await handle.process.exec(journalCleanupCommand(journalPaths(runId, CONFORMANCE_JOURNAL_DIR))); } catch {} } /** * Assert `createHandle` satisfies the takeover conformance contract. Each `it` * gets a fresh sandbox via `createHandle`/`dispose`, and a unique `runId`, so no * case can observe another's journal. */ function runTakeoverConformance(config) { describe(`takeover conformance — ${config.name}`, () => { if (config.unsupported) { it.skip(`unsupported: ${config.unsupported.reason}`, () => { expect(true).toBe(true); }); return; } it("delivers the run sequence exactly once when a second driver takes over mid-stream", { timeout: 18e4 }, async () => { const { handle, dispose } = await config.createHandle(); const runId = uniqueRunId("e2e"); const threadId = `${runId}-t`; const deltas = [ "1", "2", "3", "4", "5", "6" ]; const prefixLength = 3; const expected = expectedTranscript(runId, deltas); const runs = await runningRun(runId, threadId); const log = conformanceLog(); try { const fresh = durabilityFor(runs, log.log, false); const deliveredByFirst = []; const firstBackstop = AbortSignal.timeout(READ_BACKSTOP_MS); await withRunClaim({ runs, locks: new InMemoryLockStore(), runId }, async (claim) => { const fenced = fenceDurability(log.log, claim, { runs }); await startJournaledAgent(handle, agentCommand(deltas, prefixLength), { journal: journalOptions(fresh, runId) }); const lines = readJournalNdjson(handle, { signal: firstBackstop, journal: journalOptions(fresh, runId) }); for await (const chunk of translate(runId, lines)) { await fenced.append([chunk]); deliveredByFirst.push(chunk); if (deliveredByFirst.length === prefixLength) break; } }); expect({ backstopped: firstBackstop.aborted }).toEqual({ backstopped: false }); expect(transcript(deliveredByFirst)).toEqual(transcript(expected.slice(0, prefixLength))); const successor = driverFor({ handle, runs, locks: new InMemoryLockStore(), log: log.log, runId, attach: true }); const record = await takeOver(successor.driver, { runs, runId, threadId }); expect({ backstopped: successor.backstopped() }).toEqual({ backstopped: false }); expect(transcript(log.stored())).toEqual(transcript(expected)); expect(log.stored()).toHaveLength(deltas.length); expect(transcript(log.stored().slice(prefixLength))).toEqual(transcript(expected.slice(prefixLength))); const finalRecord = await runs.get(runId); expect(finalRecord?.status).toBe("completed"); expect(finalRecord?.driverEpoch).toBe(2); expect(record).not.toBeUndefined(); } finally { await cleanup(handle, runId); await dispose(); } }); it("fails an attach to an unknown runId with unknown-run, without waiting it out", { timeout: 12e4 }, async () => { const { handle, dispose } = await config.createHandle(); const runId = uniqueRunId("unknown"); try { expect.hasAssertions(); const probes = countingExec(handle); const error = await awaitAttachableJournal(probes.handle, { paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR), runId, runs: new InMemoryRunStore(), waitMs: 8e3, probeIntervalMs: POLL_INTERVAL_MS }).then(() => null, (reason) => reason); expect(error).toBeInstanceOf(JournalAttachUnavailableError); if (!(error instanceof JournalAttachUnavailableError)) return; expect(error.reason).toBe("unknown-run"); expect(probes.execs()).toBe(1); } finally { await dispose(); } }); it("fails an attach to a terminal run whose journal is gone with terminal-run", { timeout: 12e4 }, async () => { const { handle, dispose } = await config.createHandle(); const runId = uniqueRunId("terminal"); const threadId = `${runId}-t`; try { expect.hasAssertions(); const runs = await runningRun(runId, threadId); await runs.update(runId, { status: "completed", finishedAt: 2 }); const probes = countingExec(handle); const error = await awaitAttachableJournal(probes.handle, { paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR), runId, runs, waitMs: 8e3, probeIntervalMs: POLL_INTERVAL_MS }).then(() => null, (reason) => reason); expect(error).toBeInstanceOf(JournalAttachUnavailableError); if (!(error instanceof JournalAttachUnavailableError)) return; expect(error.reason).toBe("terminal-run"); expect(probes.execs()).toBe(1); } finally { await dispose(); } }); it("waits for a live run whose journal appears late — the legitimate race", { timeout: 12e4 }, async () => { const { handle, dispose } = await config.createHandle(); const runId = uniqueRunId("race"); const threadId = `${runId}-t`; const paths = journalPaths(runId, CONFORMANCE_JOURNAL_DIR); try { const runs = await runningRun(runId, threadId); const writer = sleep(400).then(() => handle.process.exec(journaledCommand(`printf '{"delta":"1"}\\n'`, paths))); try { await awaitAttachableJournal(handle, { paths, runId, runs, waitMs: 2e4, probeIntervalMs: POLL_INTERVAL_MS }); } finally { await writer; } expect((await handle.process.exec(journalExistsCommand(paths))).exitCode).toBe(0); } finally { await cleanup(handle, runId); await dispose(); } }); it("bounds the wait for a live run whose journal never appears, with journal-timeout", { timeout: 12e4 }, async () => { const { handle, dispose } = await config.createHandle(); const runId = uniqueRunId("timeout"); const threadId = `${runId}-t`; try { expect.hasAssertions(); const runs = await runningRun(runId, threadId); const error = await awaitAttachableJournal(handle, { paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR), runId, runs, waitMs: 600, probeIntervalMs: POLL_INTERVAL_MS }).then(() => null, (reason) => reason); expect(error).toBeInstanceOf(JournalAttachUnavailableError); if (!(error instanceof JournalAttachUnavailableError)) return; expect(error.reason).toBe("journal-timeout"); expect(error.message).toContain("600ms"); } finally { await dispose(); } }); it("lets the second of two concurrent drivers win, and the loser appends nothing at all", { timeout: 18e4 }, async () => { const { handle, dispose } = await config.createHandle(); const runId = uniqueRunId("fence"); const threadId = `${runId}-t`; const deltas = [ "1", "2", "3" ]; const expected = expectedTranscript(runId, deltas); try { const runs = await runningRun(runId, threadId); const log = conformanceLog(); await startJournaledAgent(handle, agentCommand(deltas, deltas.length), { journal: journalOptions(durabilityFor(runs, log.log, false), runId) }); const released = gate(); const losingDriver = driverFor({ handle, runs, locks: permissiveLocks, log: log.log, runId, attach: false, beforeFirstChunk: () => released.promise }); const loser = takeOver(losingDriver.driver, { runs, runId, threadId }); await waitUntil(async () => ((await runs.get(runId))?.driverEpoch ?? 0) >= 1, { timeoutMs: 3e4, message: `the first driver never claimed run ${runId}` }); const winner = driverFor({ handle, runs, locks: permissiveLocks, log: log.log, runId, attach: true }); await takeOver(winner.driver, { runs, runId, threadId }); expect({ backstopped: winner.backstopped() }).toEqual({ backstopped: false }); expect(transcript(log.stored())).toEqual(transcript(expected)); released.open(); await loser; expect({ backstopped: losingDriver.backstopped() }).toEqual({ backstopped: false }); expect(transcript(log.stored())).toEqual(transcript(expected)); expect(log.stored().some((chunk) => chunk.type === EventType.RUN_ERROR)).toBe(false); const record = await runs.get(runId); expect(record?.status).toBe("completed"); expect(record?.error).toBeUndefined(); expect(record?.driverEpoch).toBe(2); expect(log.closes()).toBe(2); } finally { await cleanup(handle, runId); await dispose(); } }); it("deletes a terminal run's journal, and a later attach reports terminal-run instead of hanging", { timeout: 18e4 }, async () => { const { handle, dispose } = await config.createHandle(); const runId = uniqueRunId("cleanup"); const threadId = `${runId}-t`; const deltas = ["1", "2"]; const paths = journalPaths(runId, CONFORMANCE_JOURNAL_DIR); try { const runs = await runningRun(runId, threadId); const fresh = durabilityFor(runs, conformanceLog().log, false); await startJournaledAgent(handle, agentCommand(deltas, deltas.length), { journal: journalOptions(fresh, runId) }); const seen = []; const backstop = AbortSignal.timeout(READ_BACKSTOP_MS); for await (const chunk of translate(runId, readJournalNdjson(handle, { signal: backstop, journal: journalOptions(fresh, runId) }))) seen.push(chunk); expect({ backstopped: backstop.aborted }).toEqual({ backstopped: false }); expect(transcript(seen)).toEqual(transcript(expectedTranscript(runId, deltas))); const journalProbe = await handle.process.exec(journalExistsCommand(paths)); const stderrProbe = await handle.process.exec(journalExistsCommand({ ...paths, journal: paths.stderr })); expect({ journalDeleted: journalProbe.exitCode !== 0, stderrSidecarDeleted: stderrProbe.exitCode !== 0 }).toEqual({ journalDeleted: true, stderrSidecarDeleted: true }); await runs.update(runId, { status: "completed", finishedAt: Date.now() }); const probes = countingExec(handle); const error = await awaitAttachableJournal(probes.handle, { paths, runId, runs, waitMs: 8e3, probeIntervalMs: POLL_INTERVAL_MS }).then(() => null, (reason) => reason); expect(error).toBeInstanceOf(JournalAttachUnavailableError); if (!(error instanceof JournalAttachUnavailableError)) return; expect(error.reason).toBe("terminal-run"); expect(probes.execs()).toBe(1); } finally { await cleanup(handle, runId); await dispose(); } }); }); } //#endregion export { runTakeoverConformance }; //# sourceMappingURL=takeover-conformance.js.map