diff --git a/packages/core/src/fakes/sandbox-fake.ts b/packages/core/src/fakes/sandbox-fake.ts index e772a15..e927d38 100644 --- a/packages/core/src/fakes/sandbox-fake.ts +++ b/packages/core/src/fakes/sandbox-fake.ts @@ -29,7 +29,9 @@ export type CannedProgram = Record; /** Inspectable record of every call made to the fake. */ export type SandboxFakeState = { - readonly acquired: { image?: string }[]; + readonly acquired: { image?: string; key?: string }[]; + /** Container ids a run destroyed explicitly, in order. */ + readonly destroyed: string[]; readonly clones: { repo: string; sha: string }[]; /** every `exec` / `runDetached` call — `env` lets tests assert injection. */ readonly execs: { @@ -105,6 +107,7 @@ export const makeSandboxFake = ( ): { layer: Layer.Layer; state: SandboxFakeState } => { const state: SandboxFakeState = { acquired: [], + destroyed: [], clones: [], execs: [], exposed: [], @@ -121,11 +124,29 @@ export const makeSandboxFake = ( }; const service: SandboxService = { + // One container per execution, a DISTINCT one per `key` — the live layer's + // rule, mirrored here because the two used to disagree about exactly that. + // + // This fake minted a fresh id on every `acquire`, so a run that acquired + // twice appeared to get two containers; the CF layer returned the + // execution's single id both times and the two acquisitions raced for one + // filesystem. A suite written against this fake could not have caught it, + // and did not: five isolated stages went green here and died in production + // with `CheckoutFailed`, having wiped each other's checkout. acquire: (opts) => Effect.sync(() => { - state.acquired.push({ image: opts.image }); - containerSeq += 1; - return { id: `fake-container-${containerSeq}` } satisfies Container; + state.acquired.push({ + image: opts.image, + ...(opts.key !== undefined ? { key: opts.key } : {}), + }); + return { + id: opts.key === undefined ? "fake-container" : `fake-container:${opts.key}`, + } satisfies Container; + }), + + destroy: ({ container }) => + Effect.sync(() => { + state.destroyed.push(container.id); }), gitClone: ({ repo, sha }) => @@ -206,7 +227,11 @@ export const makeSandboxFake = ( detachedCommands.set(id, command); return Effect.succeed({ id, - container: { id: `fake-container-${containerSeq}` }, + // The container the process runs IN — the caller's when it named one, + // else this execution's. Not a fresh id per launch: a detached + // process does not get its own container, and pretending it did was + // the same fiction `acquire` used to tell. + container: opts.container ?? { id: "fake-container" }, } satisfies DetachedHandle); }), diff --git a/packages/core/src/primitives/primitives.test.ts b/packages/core/src/primitives/primitives.test.ts index 52586ab..57a59f2 100644 --- a/packages/core/src/primitives/primitives.test.ts +++ b/packages/core/src/primitives/primitives.test.ts @@ -7,6 +7,7 @@ // covers `workspace`, `installCached`, `sharded`, `bootApp`, `probeHttp`. import { Effect } from "effect"; +import { sandbox } from "../services/sandbox"; import { describe, expect, it } from "vitest"; import { bootApp } from "./boot-app"; import { installCached, TOOLS } from "./install-cached"; @@ -16,6 +17,27 @@ import { makeCFRuntimeTest } from "../testing"; import { workspace } from "./workspace"; describe("workspace", () => { + it("a keyed acquire is a DIFFERENT container; an unkeyed one is always the same", async () => { + const { layer, handles } = makeCFRuntimeTest(); + + const [a, b, c] = await Effect.runPromise( + Effect.all([ + sandbox.acquire({}), + sandbox.acquire({}), + sandbox.acquire({ key: "features" }), + ]).pipe(Effect.provide(layer)), + ); + + // The property the live layer enforces and this fake used to contradict: + // unkeyed is the execution's own container, every time. A fake that minted + // a fresh id per call let a run look isolated here and share one filesystem + // in production. + expect(a.id).toBe(b.id); + expect(c.id).not.toBe(a.id); + expect(handles.sandbox.acquired.map((x) => x.key)).toEqual([undefined, undefined, "features"]); + }); + + it("acquires a container, clones the repo, returns { container, dir }", async () => { const { layer, handles } = makeCFRuntimeTest(); @@ -23,7 +45,7 @@ describe("workspace", () => { workspace({ repo: "owner/myrepo", sha: "abc123" }).pipe(Effect.provide(layer)), ); - expect(out.container.id).toMatch(/^fake-container-/); + expect(out.container.id).toBe("fake-container"); expect(out.dir).toBe("/workspace/myrepo"); expect(handles.sandbox.acquired).toHaveLength(1); expect(handles.sandbox.clones).toEqual([{ repo: "owner/myrepo", sha: "abc123" }]); diff --git a/packages/core/src/primitives/workspace.ts b/packages/core/src/primitives/workspace.ts index 726c7c6..eb45d01 100644 --- a/packages/core/src/primitives/workspace.ts +++ b/packages/core/src/primitives/workspace.ts @@ -23,9 +23,24 @@ export const workspace = (opts: { sha: string; image?: string; // container image override install?: boolean; // run installCached after the clone + /** + * Put this workspace in a container of its own, named by this key. + * + * Omitted, the checkout lands in the execution's single container, which is + * what one workspace per run wants. Named, it lands in a second container — + * the difference between two workspaces and two names for one, since + * `git clone` clears its target directory and a shared container means the + * second checkout deletes the first. + * + * The container is the caller's to `destroy`; see `sandbox.acquire`. + */ + key?: string; }) => Effect.gen(function* () { - const container = yield* sandbox.acquire({ image: opts.image }); + const container = yield* sandbox.acquire({ + ...(opts.image !== undefined ? { image: opts.image } : {}), + ...(opts.key !== undefined ? { key: opts.key } : {}), + }); const dir = yield* sandbox.git.clone({ repo: opts.repo, sha: opts.sha, @@ -64,6 +79,7 @@ export const ensureWorkspace = (opts: { sha: string; image?: string; install?: boolean; + key?: string; }) => Effect.gen(function* () { const probe = yield* sandbox.exec({ diff --git a/packages/core/src/services/sandbox.ts b/packages/core/src/services/sandbox.ts index 9efea63..9e7549e 100644 --- a/packages/core/src/services/sandbox.ts +++ b/packages/core/src/services/sandbox.ts @@ -115,7 +115,38 @@ export interface SandboxService { image?: string; memMB?: number; vCPU?: number; + /** + * A SECOND container for this execution, addressed by name. + * + * Omitted, `acquire` returns the execution's own container — one per + * execution, which is what every run wanted until one wanted several. A + * `key` derives a distinct container id from the execution id and this + * name, so a run that needs work isolated from its own other work (stages + * that would otherwise race for one checkout directory) can have it. + * + * The key is part of an id that must stay DNS-safe and short — the live + * layer runs it through the same normalisation as the execution id, so an + * arbitrary string is safe to pass, but a long one is truncated into a + * digest and stops being readable in logs. Keep it short and stable: a + * stage label, a shard index. + * + * Reaping is the caller's. A keyed container is not covered by the + * dispatcher's end-of-run teardown, which destroys the execution's own id + * and cannot know what a run named; call `destroy` when the work is done, + * or accept the `sleepAfter` window as the backstop. + */ + key?: string; }) => Effect.Effect; + /** + * Destroy a container now rather than at `sleepAfter`. + * + * Best-effort and idempotent: destroying an already-dead container, or one + * the SDK never provisioned, succeeds. The point is the bill — a container + * idles for the full `sleepAfter` window after its last command and is + * charged wall-clock for it, which on a CI-shaped workload is most of the + * cost of the ones a run acquired and finished with early. + */ + readonly destroy: (opts: { container: Container }) => Effect.Effect; readonly gitClone: (opts: { repo: string; sha: string; @@ -168,8 +199,9 @@ export class Sandbox extends Context.Tag("@fractalboxdev/flare-dispatch-core/San * `Effect.flatMap(Sandbox, (s) => s.exec(...))`. */ export const sandbox = { - acquire: (opts: { image?: string; memMB?: number; vCPU?: number } = {}) => + acquire: (opts: { image?: string; memMB?: number; vCPU?: number; key?: string } = {}) => Effect.flatMap(Sandbox, (s) => s.acquire(opts)), + destroy: (opts: { container: Container }) => Effect.flatMap(Sandbox, (s) => s.destroy(opts)), git: { clone: (opts: { repo: string; sha: string; container?: Container }) => Effect.flatMap(Sandbox, (s) => s.gitClone(opts)), diff --git a/packages/runtime-cf/src/sandbox-cf.test.ts b/packages/runtime-cf/src/sandbox-cf.test.ts index b60165b..9bb920f 100644 --- a/packages/runtime-cf/src/sandbox-cf.test.ts +++ b/packages/runtime-cf/src/sandbox-cf.test.ts @@ -88,8 +88,17 @@ const { FakeSessionTerminatedError } = vi.hoisted(() => ({ // returns whatever the current test installed via `currentBox`. The error class // is needed for `execToResult`'s `instanceof` recovery path. let currentBox: ReturnType; +// Every id the Layer routed by, in order. The mock used to ignore its `id` +// argument entirely, which is why no test could see that `exec` was resolving +// the execution's container rather than the handle it was passed — the defect +// that made five "isolated" stages share one filesystem. +const requestedSandboxIds: string[] = []; + vi.mock("@cloudflare/sandbox", () => ({ - getSandbox: () => currentBox, + getSandbox: (_ns: unknown, id: string) => { + requestedSandboxIds.push(id); + return currentBox; + }, SessionTerminatedError: FakeSessionTerminatedError, })); @@ -278,6 +287,63 @@ describe("makeSandboxCloudflareLive — exposePort (C)", () => { // shell then exited) is a result the run can report + upload artifacts for; // only a could-not-launch error is an Effect failure. This is what lets a // failing `playwright-demo` still surface its videoUri/logUri. +describe("makeSandboxCloudflareLive — container routing", () => { + const routingLayer = () => makeSandboxCloudflareLive(ns, makeBucket().bucket, "route-1"); + + it.effect("acquire is one container per execution, and a distinct one per key", () => + Effect.gen(function* () { + currentBox = makeFakeBox({ proc: null }); + const [a, b, keyed, keyed2] = yield* Effect.flatMap(SandboxTag, (s) => + Effect.all([ + s.acquire({}), + s.acquire({}), + s.acquire({ key: "features" }), + s.acquire({ key: "workspace" }), + ]), + ).pipe(Effect.provide(routingLayer())); + + expect(a.id).toBe(b.id); + expect(keyed.id).not.toBe(a.id); + expect(keyed2.id).not.toBe(keyed.id); + // Ids stay DNS-safe and inside the preview-URL budget — the constraint + // `previewSafeSandboxId` exists for, now applied to keyed ids too. + for (const c of [a, keyed, keyed2]) { + expect(c.id).toMatch(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/); + expect(c.id.length).toBeLessThanOrEqual(40); + } + }), + ); + + it.effect("exec routes by the handle it is given, not by the execution", () => + Effect.gen(function* () { + currentBox = makeFakeBox({ proc: null }); + currentBox.exec = vi.fn(async () => ({ exitCode: 0, duration: 1, stdout: "", stderr: "" })); + + yield* Effect.flatMap(SandboxTag, (s) => + Effect.gen(function* () { + const own = yield* s.acquire({}); + const keyed = yield* s.acquire({ key: "features" }); + requestedSandboxIds.length = 0; + yield* s.exec({ command: "one", container: own }); + yield* s.exec({ command: "two", container: keyed }); + yield* s.exec({ command: "three" }); + return { own, keyed }; + }), + ).pipe( + Effect.provide(routingLayer()), + Effect.flatMap(({ own, keyed }) => + Effect.sync(() => { + // The whole bug in one assertion: the second exec must reach the + // keyed container, and an exec with no handle still reaches the + // execution's own. + expect(requestedSandboxIds).toEqual([own.id, keyed.id, own.id]); + }), + ), + ); + }), + ); +}); + describe("makeSandboxCloudflareLive — exec result folding (D)", () => { const execLayer = () => makeSandboxCloudflareLive(ns, makeBucket().bucket, "exec-1"); diff --git a/packages/runtime-cf/src/sandbox-cf.ts b/packages/runtime-cf/src/sandbox-cf.ts index 2666d89..ae6e378 100644 --- a/packages/runtime-cf/src/sandbox-cf.ts +++ b/packages/runtime-cf/src/sandbox-cf.ts @@ -319,10 +319,24 @@ export const makeSandboxCloudflareLive = ( // keys keep the raw `executionId` for traceability. See preview-sandbox-id.ts. const sandboxId = previewSafeSandboxId(executionId); - // The per-execution sandbox client. `getSandbox` is cheap — the container is - // provisioned lazily on first use — so resolving it once per Layer build is - // correct (one container per execution). - const box = getSandbox(ns, sandboxId); + // The client for a given handle — the execution's own container when a caller + // names none. + // + // This used to be one client resolved at Layer build, on the reasoning that + // there is one container per execution. That reasoning was circular: `exec` + // ignored the `container` handle it was passed, so a run COULD not have two + // containers, so one client was enough. A run that acquired twice got the + // same id back and its two acquisitions raced for one filesystem — + // `git clone` wipes its target directory first, so five "isolated" stages + // wiped each other's checkout and all five died `CheckoutFailed` in seconds. + // + // `getSandbox` is cheap (the container is provisioned lazily on first use), + // so resolving per call costs nothing and makes the handle mean what every + // signature in `SandboxService` already said it meant. The cache and artifact + // layers have always routed by `container.id`; this brings the sandbox layer + // in line with them. + const boxFor = (container?: Container): Sandbox => getSandbox(ns, container?.id ?? sandboxId); + // `exec` log keys are unique within a run: the first exec is `exec.ndjson` // (the name the plan's acceptance pins), subsequent execs `exec-2.ndjson`, … @@ -366,10 +380,13 @@ export const makeSandboxCloudflareLive = ( * already vanished) — a capture failure must never mask the original error, * so every step is swallowed. */ - const captureDetachedLog = (handleId: string): Effect.Effect => + const captureDetachedLog = ( + handleId: string, + container?: Container, + ): Effect.Effect => Effect.promise(async () => { try { - const proc = await box.getProcess(handleId); + const proc = await boxFor(container).getProcess(handleId); if (proc === null) return undefined; const logs = await proc.getLogs(); const logPath = nextLogKey(); @@ -412,11 +429,19 @@ export const makeSandboxCloudflareLive = ( * being absent IS the desired end state; and the result is then VERIFIED * in-shell, without the URL ever being emitted (see below). */ - const scrubCloneCredential = async (targetDir: string, originUrl: string): Promise => { + const scrubCloneCredential = async ( + targetDir: string, + originUrl: string, + // The container holding the checkout being scrubbed. A scrub that ran + // against the execution's own container while the clone landed in a keyed + // one would report success having removed nothing. + container?: Container, + ): Promise => { const dir = shellQuote(targetDir); const url = shellQuote(originUrl); + const target = boxFor(container); - const setUrl = await box.exec(`git -C ${dir} remote set-url origin ${url}`); + const setUrl = await target.exec(`git -C ${dir} remote set-url origin ${url}`); if (setUrl.exitCode !== 0) { throw new Error( `clone-credential scrub of ${targetDir} failed: 'git remote set-url' exited ${setUrl.exitCode} — refusing to hand the workload a checkout still holding an installation token: ${setUrl.stderr}`, @@ -424,7 +449,7 @@ export const makeSandboxCloudflareLive = ( } // Tolerant by design: these no-op when the section/key was never written. - await box.exec( + await target.exec( [ `git -C ${dir} config --local --remove-section credential || true`, `git -C ${dir} config --local --unset-all http.https://github.com/.extraheader || true`, @@ -442,7 +467,7 @@ export const makeSandboxCloudflareLive = ( // credential must not be emitted to prove it was removed. So the URL is // captured by a shell assignment, matched by `case`, and only an exit code // crosses back: 3 = could not read the remote, 4 = a credential survived. - const verify = await box.exec( + const verify = await target.exec( `url=$(git -C ${dir} config --local --get remote.origin.url) || exit 3; case "$url" in *@*) exit 4;; esac`, ); if (verify.exitCode !== 0) { @@ -458,12 +483,44 @@ export const makeSandboxCloudflareLive = ( const service: SandboxService = { // No explicit acquire in the SDK — the container is provisioned lazily. - // V0 = one container per execution; the handle is the normalised sandbox - // id (NOT the raw executionId) so the cache + artifact layers, which call - // `getSandbox(ns, container.id)`, route to the same DO as `box` above. - acquire: () => Effect.succeed({ id: sandboxId } satisfies Container), + // + // Unkeyed, this is the execution's own container: the normalised sandbox id + // (NOT the raw executionId) so the cache + artifact layers, which call + // `getSandbox(ns, container.id)`, route to the same DO. + // + // Keyed, it is a SECOND container for the same execution. The id runs + // through the same normalisation, over `:`, so it + // inherits the whole budget: DNS-safe, ≤ 40 chars, and digested rather than + // truncated when it does not fit — which is what keeps two keys of one + // execution, and the same key of two executions, from colliding onto one + // filesystem. See preview-sandbox-id.ts for what tail-truncation cost when + // that was got wrong. + // + // Nothing here provisions or leases: a keyed container costs nothing until + // something execs in it, and it is the caller's to `destroy` — the + // dispatcher's end-of-run teardown knows the execution's own id and cannot + // know what a run named. + acquire: (opts) => + Effect.succeed({ + id: + opts.key === undefined + ? sandboxId + : previewSafeSandboxId(`${executionId}:${opts.key}`), + } satisfies Container), + + // Best-effort and idempotent — destroying a container that was never + // provisioned, or is already gone, is a success. A teardown failure must + // never become the run's verdict; `sleepAfter` is the backstop. + destroy: ({ container }) => + Effect.promise(async () => { + try { + await boxFor(container).destroy(); + } catch { + /* already gone, or never provisioned */ + } + }), - gitClone: ({ repo, sha }) => + gitClone: ({ repo, sha, container }) => Effect.tryPromise({ try: async () => { const targetDir = `/workspace/${repo.split("/").pop() ?? "repo"}`; @@ -521,7 +578,7 @@ export const makeSandboxCloudflareLive = ( // `git clone` into a non-empty directory fails rather than merging, // so this is also what keeps a reused container from erroring on // checkout instead of running. - const clear = await box.exec(`rm -rf ${targetDir}`); + const clear = await boxFor(container).exec(`rm -rf ${targetDir}`); if (clear.exitCode !== 0) { throw new Error(`rm -rf ${targetDir} exited ${clear.exitCode}: ${clear.stderr}`); } @@ -532,7 +589,7 @@ export const makeSandboxCloudflareLive = ( // `cloneCommand`). The URL git is given is the credential-free one; // the token rides in `env` and is read back by the credential // helper, so the command string holds only the variable's name. - const cloned = await box.exec(cloneCommand(originUrl, targetDir, token !== undefined), { + const cloned = await boxFor(container).exec(cloneCommand(originUrl, targetDir, token !== undefined), { timeout: CLONE_TIMEOUT_SEC * 1000, ...(token !== undefined ? { env: { [CLONE_TOKEN_ENV]: token } } : {}), }); @@ -541,7 +598,7 @@ export const makeSandboxCloudflareLive = ( } // The clone lands on the default branch; pin the exact SHA so the // run is reproducible. - const checkout = await box.exec(`git checkout ${sha}`, { + const checkout = await boxFor(container).exec(`git checkout ${sha}`, { cwd: targetDir, }); if (checkout.exitCode !== 0) { @@ -559,7 +616,7 @@ export const makeSandboxCloudflareLive = ( // that silently is the same class of quiet failure this PR exists to // remove, so it is appended to the (redacted) error instead. if (token !== undefined) { - const scrubFailure = await scrubCloneCredential(targetDir, originUrl).then( + const scrubFailure = await scrubCloneCredential(targetDir, originUrl, container).then( () => undefined, (e: unknown) => (e instanceof Error ? e.message : String(e)), ); @@ -581,7 +638,7 @@ export const makeSandboxCloudflareLive = ( // throw becomes `CheckoutFailed.cause`, which Workflows persists. if (token !== undefined) { try { - await scrubCloneCredential(targetDir, originUrl); + await scrubCloneCredential(targetDir, originUrl, container); } catch (cause) { throw redactCloneFailure(cause, token); } @@ -591,7 +648,7 @@ export const makeSandboxCloudflareLive = ( catch: (cause) => new CheckoutFailed({ repo, sha, cause }), }), - exec: ({ command, cwd, env, timeoutSec, redactValues }) => { + exec: ({ command, cwd, env, timeoutSec, redactValues, container }) => { const cmd = asCommand(command); return Effect.tryPromise({ // `tryPromise` failure path is `ExecFailed | ExecTimeout` — a command @@ -600,7 +657,7 @@ export const makeSandboxCloudflareLive = ( // folded back from the SDK's CommandError/SessionTerminatedError by // `execToResult` so a failing demo still uploads its report + log. try: async () => { - const result = await execToResult(box, cmd, { cwd, env, timeoutSec }); + const result = await execToResult(boxFor(container), cmd, { cwd, env, timeoutSec }); // Scrub any injected secret VALUES before either persisted form // (the full R2 log below, and the inline tail further down) is // written — see `ExecOpts.redactValues`. @@ -662,10 +719,10 @@ export const makeSandboxCloudflareLive = ( // text (a big `git diff --output`) arrives intact. The result is NOT // checkpointed here — bounding what flows into a Workflow checkpoint is // the CALLER's job (e.g. pr-review caps the diff inside its step). - readFile: ({ path }) => + readFile: ({ path, container }) => Effect.tryPromise({ try: async () => { - const result = await box.readFile(path); + const result = await boxFor(container).readFile(path); if (!result.success) { throw new Error(`readFile ${path} reported success=false`); } @@ -681,11 +738,11 @@ export const makeSandboxCloudflareLive = ( // Detached execution (PR9) — `bootApp`'s "start the app, return at once" // path. `startProcess` launches a long-running process; the run later // recovers it by id via `getProcess` to wait on its port / its exit. - runDetached: ({ command, cwd, env, timeoutSec }) => { + runDetached: ({ command, cwd, env, timeoutSec, container }) => { const cmd = asCommand(command); return Effect.tryPromise({ try: async () => { - const proc = await box.startProcess(cmd, { + const proc = await boxFor(container).startProcess(cmd, { cwd, env, timeout: timeoutSec === undefined ? undefined : timeoutSec * 1000, @@ -705,7 +762,7 @@ export const makeSandboxCloudflareLive = ( Effect.tryPromise({ try: async (): Promise => { const startedAt = Date.now(); - const proc = await box.getProcess(handle.id); + const proc = await boxFor(handle.container).getProcess(handle.id); if (proc === null) { throw new Error(`detached process ${handle.id} not found`); } @@ -740,7 +797,7 @@ export const makeSandboxCloudflareLive = ( // regardless of SDK behavior. const sdkWait = Effect.tryPromise({ try: async () => { - const proc = await box.getProcess(handle.id); + const proc = await boxFor(handle.container).getProcess(handle.id); if (proc === null) { throw new Error(`detached process ${handle.id} not found`); } @@ -769,7 +826,7 @@ export const makeSandboxCloudflareLive = ( // attached — the only diagnostic a failed detached boot leaves behind. return bounded.pipe( Effect.catchTag("PortNeverOpened", (err) => - captureDetachedLog(handle.id).pipe( + captureDetachedLog(handle.id, handle.container).pipe( Effect.flatMap((logPath) => Effect.fail( new PortNeverOpened({ @@ -784,7 +841,7 @@ export const makeSandboxCloudflareLive = ( ); }, - exposePort: ({ port, name }) => + exposePort: ({ port, name, container }) => previewHostname === undefined ? Effect.fail( new ExposePortFailed({ @@ -797,7 +854,7 @@ export const makeSandboxCloudflareLive = ( // The SDK builds the preview URL from the Worker's domain // (`hostname`) + the port; the process bound to the container's // `localhost:` becomes reachable at the returned URL. - const { url } = await box.exposePort(port, { + const { url } = await boxFor(container).exposePort(port, { hostname: previewHostname, name, }); diff --git a/packages/runtime-cf/src/sandbox-facade.ts b/packages/runtime-cf/src/sandbox-facade.ts index a2c6d33..e4064bf 100644 --- a/packages/runtime-cf/src/sandbox-facade.ts +++ b/packages/runtime-cf/src/sandbox-facade.ts @@ -246,14 +246,30 @@ export const makeSandboxFacadeLive = (opts: SandboxFacadeOptions): Layer.Layer + // + // A `key` is REFUSED rather than ignored. The substrate namespaces one + // sandbox per consumer execution, so there is no second container to hand + // back — and silently returning the first would recreate exactly the defect + // this option exists to fix: two acquisitions sharing one filesystem, with + // `git clone` wiping its target directory out from under the other. A run + // that needs isolated containers has to know it is not getting them. + acquire: (acquireOpts) => Effect.tryPromise({ try: async () => { + if (acquireOpts.key !== undefined) { + throw new ContainerLaunchFailed({ + image: "substrate", + cause: `the substrate backend has one sandbox per execution — \`acquire({ key: "${acquireOpts.key}" })\` cannot be honoured here`, + }); + } const outcome = await opts.facade.ensureSandbox(key, recipe, { mode: "refuse" }); if (!outcome.ok) throw outcome.refusal; return { id: key } satisfies Container; }, catch: (cause): ContainerLaunchFailed | ContainerBusy => { + // The keyed-acquire refusal above is already the typed error; pass it + // through rather than re-describing it as a substrate refusal. + if (cause instanceof ContainerLaunchFailed) return cause; const refusal = cause as SubstrateRefusal; // A full pool is a wait, not a broken container — `ContainerBusy` is // the error the Workflow already renders as an infra wait rather @@ -271,6 +287,13 @@ export const makeSandboxFacadeLive = (opts: SandboxFacadeOptions): Layer.Layer Effect.void, + // No clone command is sent: `ensureSandbox` restores or rebuilds the tree // the recipe describes, and the recipe already names the repo and the sha. gitClone: ({ repo, sha }) => diff --git a/runs/README.md b/runs/README.md index a1fbe95..07b935e 100644 --- a/runs/README.md +++ b/runs/README.md @@ -294,7 +294,21 @@ wrangler kv key put --binding=CONFIG_KV \ ``` Absent or `1` is the shared-container sequential mode above, byte for byte. -Above 1, each stage acquires its **own** workspace and up to N run at once. +Above 1, each stage acquires its **own** container — `acquire({ key: