diff --git a/deploy/cloudflare-memory/backgroundTasks.test.ts b/deploy/cloudflare-memory/backgroundTasks.test.ts new file mode 100644 index 000000000..acca9e763 --- /dev/null +++ b/deploy/cloudflare-memory/backgroundTasks.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; +import { assertNoPendingBackgroundTasks, holdBackgroundTask } from "./backgroundTasks.ts"; + +describe("memory Worker pending task guard", () => { + it("fails on a fixture promise that is still pending when the test ends", async () => { + let release!: () => void; + const fixture = new Promise((resolve) => { + release = resolve; + }); + const waitUntil = vi.fn<(task: Promise) => void>(); + + holdBackgroundTask({ waitUntil }, "fixture pending promise", fixture); + + expect(waitUntil).toHaveBeenCalledOnce(); + expect(() => assertNoPendingBackgroundTasks()).toThrow(/fixture pending promise/); + + release(); + await fixture; + await Promise.resolve(); + expect(() => assertNoPendingBackgroundTasks()).not.toThrow(); + }); +}); diff --git a/deploy/cloudflare-memory/backgroundTasks.ts b/deploy/cloudflare-memory/backgroundTasks.ts new file mode 100644 index 000000000..82fbdcd77 --- /dev/null +++ b/deploy/cloudflare-memory/backgroundTasks.ts @@ -0,0 +1,25 @@ +type WaitUntilContext = Pick; + +const TASKS = Symbol.for("switchboard.memory-worker.pending-background-tasks"); +type PendingTasks = Map, string>; + +const pendingTasks = (): PendingTasks => { + const root = globalThis as typeof globalThis & { [TASKS]?: PendingTasks }; + return (root[TASKS] ??= new Map()); +}; + +/** Keep deliberately detached Worker work in the runtime's request lifetime. + * The process-wide label set is also the test seam: a case cannot finish while + * work it started is still able to contend with the next case. */ +export function holdBackgroundTask(context: WaitUntilContext, label: string, task: Promise): void { + const pending = pendingTasks(); + const held = task.finally(() => pending.delete(held)); + pending.set(held, label); + context.waitUntil(held); +} + +export function assertNoPendingBackgroundTasks(): void { + const labels = [...pendingTasks().values()]; + if (labels.length === 0) return; + throw new Error(`test ended with ${labels.length} pending background task(s): ${labels.join(", ")}`); +} diff --git a/deploy/cloudflare-memory/runLedger.test.ts b/deploy/cloudflare-memory/runLedger.test.ts index 73cc84f99..25a6fbded 100644 --- a/deploy/cloudflare-memory/runLedger.test.ts +++ b/deploy/cloudflare-memory/runLedger.test.ts @@ -4,6 +4,7 @@ import type { RunRecord } from "../../src/core/runRecord.ts"; import { FRICTION_CATEGORIES } from "../../src/core/runFriction.ts"; import { LEASE_MS } from "../../src/core/runLedger/types.ts"; import type { CoordinatorInstance, CoordinatorUnit } from "../../src/core/coordinator/contract.ts"; +import { assertNoPendingBackgroundTasks } from "./backgroundTasks.ts"; import type { RunHistoryDO, SessionLogDO } from "./worker.ts"; // Feature: docs/reference/specs/run-history.md items 28–34 — the live-run ledger on the @@ -1429,6 +1430,14 @@ describe("the plane's admission stage — /plane/admit, reservations, the seal's await post("/runs/claim", claimBody(key, "r1", t)); const q = (await admit(key, t, "two")).data.id as string; const pushes: { url: string; auth: string | null }[] = []; + let startedPush!: () => void; + let finishPush!: () => void; + const pushStarted = new Promise((resolve) => { + startedPush = resolve; + }); + const pushCanFinish = new Promise((resolve) => { + finishPush = resolve; + }); await runInDurableObject(env.RUNS.get(env.RUNS.idFromName(key)), async (inst: RunHistoryDO) => { // A BOT binding whose push dead-ends (the container down, an older bot // without the route): the fetch answers 404 and delivers nothing. @@ -1438,14 +1447,21 @@ describe("the plane's admission stage — /plane/admit, reservations, the seal's BOT: { fetch: async (url: string, init: { headers: Record }) => { pushes.push({ url: String(url), auth: init.headers.authorization ?? null }); + startedPush(); + await pushCanFinish; return new Response("not found", { status: 404 }); }, }, }; }); - // The seal walks the queue and pushes the admit; the push fails. - await post("/runs/finish", { storeKey: key, runId: "r1", gen: "g1", record: record("r1", t) }); - await new Promise((r) => setTimeout(r, 10)); // the push is fire-and-forget + // The seal walks the queue and pushes the admit. Its response stays + // independent, while waitUntil and the test guard own the unfinished I/O. + const finishing = post("/runs/finish", { storeKey: key, runId: "r1", gen: "g1", record: record("r1", t) }); + await pushStarted; + expect(() => assertNoPendingBackgroundTasks()).toThrow(/plane effect push \(admit:/); + finishPush(); + await finishing; + await vi.waitFor(() => expect(() => assertNoPendingBackgroundTasks()).not.toThrow()); expect(pushes).toEqual([{ url: "https://bot/plane/effects", auth: "Bearer test-token" }]); // Nothing was acked: the offer stands and rides the next heartbeat answer. await runInDurableObject(env.RUNS.get(env.RUNS.idFromName(key)), async (inst: RunHistoryDO) => { @@ -1953,15 +1969,24 @@ describe("the plane's checkpoint steers and the provider condition — the heart const key = storeKey(); await post("/runs/claim", claimBody(key, "r5", "slack:C20:5.0")); await post("/plane/park", { storeKey: key, runId: "r5", provider: "anthropic" }); + let pushed!: () => void; + const pushStarted = new Promise((resolve) => { + pushed = resolve; + }); await runInDurableObject(env.RUNS.get(env.RUNS.idFromName(key)), async (inst: RunHistoryDO) => { const withBot = inst as unknown as { env: Record }; withBot.env = { ...withBot.env, - BOT: { fetch: async () => new Response("down", { status: 503 }) }, + BOT: { + fetch: async () => { + pushed(); + return new Response("down", { status: 503 }); + }, + }, }; }); await post("/plane/level", { storeKey: key, name: "provider", provider: "anthropic", side: "up" }); - await new Promise((resolve) => setTimeout(resolve, 10)); + await pushStarted; expect(await inbox(key, "r5")).toHaveLength(1); const beat = await post("/runs/heartbeat", { storeKey: key, runId: "r5", gen: "g1", leaseMs: LEASE_MS }); expect(beat.data.effects).toMatchObject([{ id: "steer:r5:1", kind: "steer", seq: 1 }]); diff --git a/deploy/cloudflare-memory/testSetup.ts b/deploy/cloudflare-memory/testSetup.ts index 07a10e11e..4f13b57bf 100644 --- a/deploy/cloudflare-memory/testSetup.ts +++ b/deploy/cloudflare-memory/testSetup.ts @@ -1,6 +1,14 @@ +import { afterEach } from "vitest"; +import { assertNoPendingBackgroundTasks } from "./backgroundTasks.ts"; + // Routine Worker logs are not test output. With console interception disabled // they would flood the runner; keep warnings and errors visible, while tests // that need a logger inject or spy on one explicitly. console.log = () => {}; console.info = () => {}; console.debug = () => {}; + +// Every intentionally detached Worker operation must be registered through +// holdBackgroundTask. Fail its own case rather than letting workerd charge its +// unfinished work to a later Durable Object request in the shared isolate. +afterEach(() => assertNoPendingBackgroundTasks()); diff --git a/deploy/cloudflare-memory/vitest.config.ts b/deploy/cloudflare-memory/vitest.config.ts index 8c2905f21..b25df61da 100644 --- a/deploy/cloudflare-memory/vitest.config.ts +++ b/deploy/cloudflare-memory/vitest.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ // not correctness, was the only failure shape. fileParallelism: false, include: [ + "backgroundTasks.test.ts", "worker.test.ts", "schedules.test.ts", "runs.test.ts", diff --git a/deploy/cloudflare-memory/worker.ts b/deploy/cloudflare-memory/worker.ts index d7428ba75..eedc9688a 100644 --- a/deploy/cloudflare-memory/worker.ts +++ b/deploy/cloudflare-memory/worker.ts @@ -78,6 +78,7 @@ import { selectReclaim, } from "../../src/core/runLedger/decisions.ts"; import { intakeReceiptRetentionMs, minutesToMs, PLANE } from "../../src/core/budgets.ts"; +import { holdBackgroundTask } from "./backgroundTasks.ts"; import { causeOfClose, causeOfReclaim, @@ -2387,21 +2388,24 @@ export class RunHistoryDO extends DurableObject { /** The transport's push (record 0064, "Where it lives"): committed effects * are pushed to the bot Worker over the service binding, which forwards to - * the container. Fire and forget — a push that fails is not retried by a - * timer; the effect rides the next heartbeat or reclaim-sweep answer. */ + * the container. The response does not wait for this best-effort push, but + * the actor does: waitUntil keeps its I/O inside this request's lifetime so + * it cannot contend with an unrelated request after the caller moves on. */ private pushPlaneEffects(effects: PlaneEffect[]): void { if (effects.length === 0) return; const bot = this.env.BOT; if (!bot) return; - void bot - .fetch("https://bot/plane/effects", { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${this.env.MEMORY_TOKEN ?? ""}`, - }, - body: JSON.stringify({ effects }), - }) + const delivery = Promise.resolve() + .then(() => + bot.fetch("https://bot/plane/effects", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${this.env.MEMORY_TOKEN ?? ""}`, + }, + body: JSON.stringify({ effects }), + }), + ) .then((r) => { if (!r.ok) console.warn( @@ -2413,6 +2417,7 @@ export class RunHistoryDO extends DurableObject { `[plane/push] ${effects.length} effect(s) not delivered: ${err instanceof Error ? err.message : String(err)} — they ride the next heartbeat`, ); }); + holdBackgroundTask(this.ctx, `plane effect push (${effects.map((effect) => effect.id).join(", ")})`, delivery); } /** The unacknowledged effects, oldest first, at most `PLANE_EFFECTS_PER_ANSWER` diff --git a/docs/reference/specs/release-and-deploy.md b/docs/reference/specs/release-and-deploy.md index 5635f0901..92fee1796 100644 --- a/docs/reference/specs/release-and-deploy.md +++ b/docs/reference/specs/release-and-deploy.md @@ -4,7 +4,7 @@ Every merge to `main` accumulates into one release PR (release-please, conventio Which Workers a release touches is **derived from the tree, never declared**. A PR body saying "bot deploy only" is a claim; the diff between what a Worker is serving and the release commit, mapped onto that Worker's real inputs (its bundle's import closure, its image's `COPY` sources, its production dependencies), is a fact. The release PR shows the derived plan before anyone merges it, and every PR's CI shows what its own diff would deploy. When the derivation cannot be sure — a path no rule classifies, a Worker whose live commit cannot be read and no release tag to fall back on — the answer is the whole fleet, said out loud, never a silent skip. -- **Code**: [`.depot/workflows/pr-title.yml`](../../../.depot/workflows/pr-title.yml) (the required title check), [`vitest.config.ts`](../../../vitest.config.ts) + [`src/core/testing/tempRoot.ts`](../../../src/core/testing/tempRoot.ts) (item 29: the suite's one global setup, the run's temp root), [`deploy/cloudflare-memory/vitest.config.ts`](../../../deploy/cloudflare-memory/vitest.config.ts) + [`deploy/cloudflare-memory/wrangler.test.jsonc`](../../../deploy/cloudflare-memory/wrangler.test.jsonc) + [`deploy/cloudflare-memory/testSetup.ts`](../../../deploy/cloudflare-memory/testSetup.ts) (the state Worker's workerd pool: production Durable Objects with cross-script service and Workflow bindings omitted, routine logs kept out of the runner RPC), [`.github/workflows/scorecard.yml`](../../../.github/workflows/scorecard.yml) (Scorecard through GitHub Actions on Depot runners), [`.github/workflows/codeql.yml`](../../../.github/workflows/codeql.yml) (CodeQL through GitHub Actions on Depot runners), [`src/deploy/affected.ts`](../../../src/deploy/affected.ts) (the selection: inputs, inert rules, import closure, lockfile production-dependency diff, per-Worker base, markdown summary — pure), [`src/deploy/plan.ts`](../../../src/deploy/plan.ts) (`WORKER_SPECS` carry their inputs; `workersFor(profile)` binds script names and `/healthz`; a plan built from an `AffectedReport`; the account decision), [`src/deploy/profile.ts`](../../../src/deploy/profile.ts) (the deployment profile: parse, validate, derive URLs) with [`deploy/profile.example.json`](../../../deploy/profile.example.json) (an installation's own profile file is gitignored), [`src/deploy/configSource.ts`](../../../src/deploy/configSource.ts) (path / `github://` / vault-reference loaders behind one seam), [`src/deploy/secrets.ts`](../../../src/deploy/secrets.ts) (the secrets manifest's shape, the `secretsSource` shape, the put plan — pure) with [`src/deploy/secretsHost.ts`](../../../src/deploy/secretsHost.ts) (files or `op`, and `wrangler secret put` on stdin) and [`deploy/secrets.manifest.json`](../../../deploy/secrets.manifest.json), [`src/deploy/wranglerTemplate.ts`](../../../src/deploy/wranglerTemplate.ts) (each Worker's `wrangler.jsonc` rendered from the `wrangler.template.jsonc` beside it — [`deploy/cloudflare/wrangler.template.jsonc`](../../../deploy/cloudflare/wrangler.template.jsonc) and its siblings) behind `deploy init`, [`src/deploy/images.ts`](../../../src/deploy/images.ts) (the three images: the published names from `project.json`, each Worker's `image` under the profile's mode, the copy plan over the account registry's listing — pure) with [`src/deploy/accountRegistry.ts`](../../../src/deploy/accountRegistry.ts) (the registry's name and listing shape — the leaf both halves share), [`src/deploy/registryTransfer.ts`](../../../src/deploy/registryTransfer.ts) (the registry-to-registry copy's shapes: references, the credential request, manifests and indexes, the linux/amd64 selection, upload part boundaries — pure), [`src/deploy/registryTransferHost.ts`](../../../src/deploy/registryTransferHost.ts) (the copy over HTTPS: the credential mint, the account registry's catalog, the source's token and manifests, chunked blob uploads verified by sha256, the manifest push and its digest check) and [`src/deploy/imagesHost.ts`](../../../src/deploy/imagesHost.ts) (the credential from `CLOUDFLARE_API_TOKEN`, minted once per account and spent on every read and copy; no process), [`src/configDocument.ts`](../../../src/configDocument.ts) (the base config document, its client and the `state://` location — pure client), [`src/configBoot.ts`](../../../src/configBoot.ts) (the production config refusal probe), [`src/deploy/liveGate.ts`](../../../src/deploy/liveGate.ts) (the bot's live decision — pure), [`src/deploy/sandboxLiveGate.ts`](../../../src/deploy/sandboxLiveGate.ts) (the sandbox's live decision: Worker + rollout + probe, and the parsers for wrangler's `--json` and the streamed `/exec` body — pure), [`src/deploy/run.ts`](../../../src/deploy/run.ts) (the host probe: git, `/healthz`, the token-verify fallback; the profile loader; the config read, validation and push to the state Worker; the per-step env and wake; the live-gate loops, the sandbox's with injectable deps), [`src/deploy/operatorRoot.ts`](../../../src/deploy/operatorRoot.ts) (where a deploy's files live: the checkout, or the operator's directory and the package's assets — pure), [`src/deploy/workArea.ts`](../../../src/deploy/workArea.ts) (the Worker directories materialised from the package under `.switchboard/`: the copy, the per-Worker `npm ci --workspace`, the stamp — the plan pure, `npm ci` injected), [`src/deploy/host.ts`](../../../src/deploy/host.ts) (this process's root, the package's source stamp, the real `npm ci`), [`deploy/bin/build-stamp.mjs`](../../../deploy/bin/build-stamp.mjs) and [`deploy/cloudflare/write-build.mjs`](../../../deploy/cloudflare/write-build.mjs) (the commit from the environment when there is no tree), [`src/core/commands/deploy.ts`](../../../src/core/commands/deploy.ts) (`--affected`, `--base`; the root in every output path), [`.github/workflows/deploy-production.yml`](../../../.github/workflows/deploy-production.yml) (the reusable deploy), [`.github/workflows/release-please.yml`](../../../.github/workflows/release-please.yml) (release → deploy, and the image publish), [`docker-compose.yml`](../../../docker-compose.yml) (the local loop runs the published image) with [`project.json`](../../../project.json) (`image`, the one statement of its name) and [`scripts/check-project-facts.mjs`](../../../scripts/check-project-facts.mjs), [`scripts/deploy-targets.mjs`](../../../scripts/deploy-targets.mjs) (`npm run deploy:targets`: the per-PR summary, and in release-PR mode the sticky comment), [`.depot/workflows/ci.yml`](../../../.depot/workflows/ci.yml) (`deploy targets` on every PR), [`scripts/check-pr-title.mjs`](../../../scripts/check-pr-title.mjs) (the title gate's `main`: reads the tree, reports) over [`src/core/prTitle.mjs`](../../../src/core/prTitle.mjs) (the predicate: grammar, the type list from the release config, the scope list from the code map's Areas, the cap, the migration section behind `!`, the vocabulary render — pure functions the `submit_pr_description` schema shares), the generated [`src/core/prTitleVocabulary.json`](../../../src/core/prTitleVocabulary.json) and its writer [`scripts/pr-title-vocabulary.mjs`](../../../scripts/pr-title-vocabulary.mjs) (`pr-title:gen` / `pr-title:check`), [`scripts/check-lockfile.mjs`](../../../scripts/check-lockfile.mjs) (the lockfile gate: platform variants, manifest mirroring, edge resolution and pinning — pure over the lock JSON, one `main`). + `src/deploy/residentDrain.ts` (item 31) + `src/deploy/supersede.ts` (item 32: the supersede guard — pure, reads injected) +- **Code**: [`.depot/workflows/pr-title.yml`](../../../.depot/workflows/pr-title.yml) (the required title check), [`vitest.config.ts`](../../../vitest.config.ts) + [`src/core/testing/tempRoot.ts`](../../../src/core/testing/tempRoot.ts) (item 29: the suite's one global setup, the run's temp root), [`deploy/cloudflare-memory/vitest.config.ts`](../../../deploy/cloudflare-memory/vitest.config.ts) + [`deploy/cloudflare-memory/wrangler.test.jsonc`](../../../deploy/cloudflare-memory/wrangler.test.jsonc) + [`deploy/cloudflare-memory/testSetup.ts`](../../../deploy/cloudflare-memory/testSetup.ts) + [`deploy/cloudflare-memory/backgroundTasks.ts`](../../../deploy/cloudflare-memory/backgroundTasks.ts) (the state Worker's workerd pool: production Durable Objects with cross-script service and Workflow bindings omitted, routine logs kept out of the runner RPC, deliberately detached work held by the runtime and refused at a test boundary while pending), [`.github/workflows/scorecard.yml`](../../../.github/workflows/scorecard.yml) (Scorecard through GitHub Actions on Depot runners), [`.github/workflows/codeql.yml`](../../../.github/workflows/codeql.yml) (CodeQL through GitHub Actions on Depot runners), [`src/deploy/affected.ts`](../../../src/deploy/affected.ts) (the selection: inputs, inert rules, import closure, lockfile production-dependency diff, per-Worker base, markdown summary — pure), [`src/deploy/plan.ts`](../../../src/deploy/plan.ts) (`WORKER_SPECS` carry their inputs; `workersFor(profile)` binds script names and `/healthz`; a plan built from an `AffectedReport`; the account decision), [`src/deploy/profile.ts`](../../../src/deploy/profile.ts) (the deployment profile: parse, validate, derive URLs) with [`deploy/profile.example.json`](../../../deploy/profile.example.json) (an installation's own profile file is gitignored), [`src/deploy/configSource.ts`](../../../src/deploy/configSource.ts) (path / `github://` / vault-reference loaders behind one seam), [`src/deploy/secrets.ts`](../../../src/deploy/secrets.ts) (the secrets manifest's shape, the `secretsSource` shape, the put plan — pure) with [`src/deploy/secretsHost.ts`](../../../src/deploy/secretsHost.ts) (files or `op`, and `wrangler secret put` on stdin) and [`deploy/secrets.manifest.json`](../../../deploy/secrets.manifest.json), [`src/deploy/wranglerTemplate.ts`](../../../src/deploy/wranglerTemplate.ts) (each Worker's `wrangler.jsonc` rendered from the `wrangler.template.jsonc` beside it — [`deploy/cloudflare/wrangler.template.jsonc`](../../../deploy/cloudflare/wrangler.template.jsonc) and its siblings) behind `deploy init`, [`src/deploy/images.ts`](../../../src/deploy/images.ts) (the three images: the published names from `project.json`, each Worker's `image` under the profile's mode, the copy plan over the account registry's listing — pure) with [`src/deploy/accountRegistry.ts`](../../../src/deploy/accountRegistry.ts) (the registry's name and listing shape — the leaf both halves share), [`src/deploy/registryTransfer.ts`](../../../src/deploy/registryTransfer.ts) (the registry-to-registry copy's shapes: references, the credential request, manifests and indexes, the linux/amd64 selection, upload part boundaries — pure), [`src/deploy/registryTransferHost.ts`](../../../src/deploy/registryTransferHost.ts) (the copy over HTTPS: the credential mint, the account registry's catalog, the source's token and manifests, chunked blob uploads verified by sha256, the manifest push and its digest check) and [`src/deploy/imagesHost.ts`](../../../src/deploy/imagesHost.ts) (the credential from `CLOUDFLARE_API_TOKEN`, minted once per account and spent on every read and copy; no process), [`src/configDocument.ts`](../../../src/configDocument.ts) (the base config document, its client and the `state://` location — pure client), [`src/configBoot.ts`](../../../src/configBoot.ts) (the production config refusal probe), [`src/deploy/liveGate.ts`](../../../src/deploy/liveGate.ts) (the bot's live decision — pure), [`src/deploy/sandboxLiveGate.ts`](../../../src/deploy/sandboxLiveGate.ts) (the sandbox's live decision: Worker + rollout + probe, and the parsers for wrangler's `--json` and the streamed `/exec` body — pure), [`src/deploy/run.ts`](../../../src/deploy/run.ts) (the host probe: git, `/healthz`, the token-verify fallback; the profile loader; the config read, validation and push to the state Worker; the per-step env and wake; the live-gate loops, the sandbox's with injectable deps), [`src/deploy/operatorRoot.ts`](../../../src/deploy/operatorRoot.ts) (where a deploy's files live: the checkout, or the operator's directory and the package's assets — pure), [`src/deploy/workArea.ts`](../../../src/deploy/workArea.ts) (the Worker directories materialised from the package under `.switchboard/`: the copy, the per-Worker `npm ci --workspace`, the stamp — the plan pure, `npm ci` injected), [`src/deploy/host.ts`](../../../src/deploy/host.ts) (this process's root, the package's source stamp, the real `npm ci`), [`deploy/bin/build-stamp.mjs`](../../../deploy/bin/build-stamp.mjs) and [`deploy/cloudflare/write-build.mjs`](../../../deploy/cloudflare/write-build.mjs) (the commit from the environment when there is no tree), [`src/core/commands/deploy.ts`](../../../src/core/commands/deploy.ts) (`--affected`, `--base`; the root in every output path), [`.github/workflows/deploy-production.yml`](../../../.github/workflows/deploy-production.yml) (the reusable deploy), [`.github/workflows/release-please.yml`](../../../.github/workflows/release-please.yml) (release → deploy, and the image publish), [`docker-compose.yml`](../../../docker-compose.yml) (the local loop runs the published image) with [`project.json`](../../../project.json) (`image`, the one statement of its name) and [`scripts/check-project-facts.mjs`](../../../scripts/check-project-facts.mjs), [`scripts/deploy-targets.mjs`](../../../scripts/deploy-targets.mjs) (`npm run deploy:targets`: the per-PR summary, and in release-PR mode the sticky comment), [`.depot/workflows/ci.yml`](../../../.depot/workflows/ci.yml) (`deploy targets` on every PR), [`scripts/check-pr-title.mjs`](../../../scripts/check-pr-title.mjs) (the title gate's `main`: reads the tree, reports) over [`src/core/prTitle.mjs`](../../../src/core/prTitle.mjs) (the predicate: grammar, the type list from the release config, the scope list from the code map's Areas, the cap, the migration section behind `!`, the vocabulary render — pure functions the `submit_pr_description` schema shares), the generated [`src/core/prTitleVocabulary.json`](../../../src/core/prTitleVocabulary.json) and its writer [`scripts/pr-title-vocabulary.mjs`](../../../scripts/pr-title-vocabulary.mjs) (`pr-title:gen` / `pr-title:check`), [`scripts/check-lockfile.mjs`](../../../scripts/check-lockfile.mjs) (the lockfile gate: platform variants, manifest mirroring, edge resolution and pinning — pure over the lock JSON, one `main`). + `src/deploy/residentDrain.ts` (item 31) + `src/deploy/supersede.ts` (item 32: the supersede guard — pure, reads injected) - **Tests**: [`src/ciWorkflow.test.ts`](../../../src/ciWorkflow.test.ts) (items 20–21, 23, 27), [`src/core/testing/tempRoot.test.ts`](../../../src/core/testing/tempRoot.test.ts) (item 29), [`src/projectFacts.test.ts`](../../../src/projectFacts.test.ts) (item 21: the compose file runs the published image), [`src/deploy/affected.test.ts`](../../../src/deploy/affected.test.ts), [`src/deploy/plan.test.ts`](../../../src/deploy/plan.test.ts), [`src/deploy/liveGate.test.ts`](../../../src/deploy/liveGate.test.ts), [`src/deploy/sandboxLiveGate.test.ts`](../../../src/deploy/sandboxLiveGate.test.ts), [`src/deploy/sandboxGateRun.test.ts`](../../../src/deploy/sandboxGateRun.test.ts), [`src/deploy/profile.test.ts`](../../../src/deploy/profile.test.ts), [`src/deploy/configSource.test.ts`](../../../src/deploy/configSource.test.ts), [`src/configDocument.test.ts`](../../../src/configDocument.test.ts), [`src/configBoot.test.ts`](../../../src/configBoot.test.ts) and [`src/index.boot.test.ts`](../../../src/index.boot.test.ts) (the refusal-only boot), [`src/deploy/wranglerTemplate.test.ts`](../../../src/deploy/wranglerTemplate.test.ts), [`src/deploy/images.test.ts`](../../../src/deploy/images.test.ts), [`src/deploy/host.test.ts`](../../../src/deploy/host.test.ts), [`src/deploy/imagesHost.test.ts`](../../../src/deploy/imagesHost.test.ts), [`src/deploy/registryTransfer.test.ts`](../../../src/deploy/registryTransfer.test.ts), [`src/deploy/registryTransferHost.test.ts`](../../../src/deploy/registryTransferHost.test.ts) (items 25–26), [`src/deploy/secrets.test.ts`](../../../src/deploy/secrets.test.ts), [`src/core/secretsManifest.test.ts`](../../../src/core/secretsManifest.test.ts), [`src/deploy/restart.test.ts`](../../../src/deploy/restart.test.ts), [`src/core/commands/deploy.test.ts`](../../../src/core/commands/deploy.test.ts), [`src/cli.test.ts`](../../../src/cli.test.ts), [`src/prTitleCheck.test.ts`](../../../src/prTitleCheck.test.ts) (item 22), [`src/deploy/operatorRoot.test.ts`](../../../src/deploy/operatorRoot.test.ts), [`src/deploy/workArea.test.ts`](../../../src/deploy/workArea.test.ts), [`src/deploy/buildStamp.test.ts`](../../../src/deploy/buildStamp.test.ts), [`packages/switchboard/smoke.test.mts`](../../../packages/switchboard/smoke.test.mts) (item 24), [`src/lockfileCheck.test.ts`](../../../src/lockfileCheck.test.ts) (item 28). + `src/deploy/residentDrainRun.test.ts` (item 31) + `src/deploy/supersede.test.ts` (item 32) - **Docs**: [docs/how-to/ship-a-release.md](../../how-to/ship-a-release.md), [docs/how-to/operate-production.md](../../how-to/operate-production.md), [docs/how-to/rotate-a-secret.md](../../how-to/rotate-a-secret.md), [docs/explanation/worker-topology.md](../../explanation/worker-topology.md), [Deploy](../../how-to/deploy.md), [AGENTS.md](../../../AGENTS.md), [CONTRIBUTING.md](../../../CONTRIBUTING.md#the-pr-title-is-the-changelog-line) (the title rule), [docs/reference/migrations.md](../migrations.md) (the migration notes), [docs/reference/code-map.md](../code-map.md#areas) (the scope vocabulary). @@ -94,7 +94,7 @@ Runner validation `[agent]`: after a PR push, run `gh run list --branch | The docs site's config renders from `project.json` and the profile's account alone — `-docs` on the host of `docs`; a missing or non-JSON facts file, a missing or malformed fact, or a missing template is a problem naming its source — and `deploy init` writes it beside the Workers', refusing `unavailable` without the facts file (items 16, 17) | `[unit]` `src/deploy/wranglerTemplate.test.ts::siteView / renderSiteConfig::*`, `src/core/commands/deploy.test.ts::deploy.init::renders every Worker's wrangler.jsonc from its template and the profile, and the site's from project.json…`, `::deploy.init::the site's config needs project.json…` | | The rendered `wrangler.jsonc` files are generated (gitignored; `deploy:gen` runs before `test`, every Worker's `verify` and `deploy all`) and ARE the render of their templates with the profile in force, byte for byte; the templates render against the committed example with nothing unfilled (item 17) | `[unit]` `src/deploy/wranglerTemplate.test.ts::the rendered wrangler.jsonc files::*`; `[agent]` append a comment line to `deploy/cloudflare-memory/wrangler.jsonc` → `npm run deploy:check` exits 1 naming that file `(stale)` and `npm run deploy:gen`; `npm run deploy:gen` restores it and `deploy:check` exits 0. | | `deploy init` writes each render and is a no-op the second time; `--check` writes nothing and fails `conflict` on a stale or absent file; a missing template or unfillable placeholder is `unavailable` naming the template; CLI-only, `deploy:write` (item 16) | `[unit]` `src/core/commands/deploy.test.ts::deploy.init::*` | -| The state Worker's workerd pool keeps the real SQLite Durable Objects but leaves the production Workflow engine unbound; coordinator-delivery cases install their own synchronous double on the live object; every queued or re-entered plane request settles its Durable Object alarm write before its RPC response returns, and a failed alarm write preserves the already-committed queued or re-entry answer rather than inviting the caller to proceed or retry; the re-ask proof uses a non-firing cadence and explicitly awaits each alarm handler, then deletes the re-armed slot, so no Workflow promise or alarm handler crosses a test boundary to hold an unrelated case; the test-only setup and wrangler config are inert deploy inputs and absent from the published package | `[worker]` `deploy/cloudflare-memory/runLedger.test.ts::run ledger — the coordinator's event and the key (items 47–48)::the test pool leaves the real Workflow engine unbound…`, `deploy/cloudflare-memory/runLedger.test.ts::the plane's resident stage — /plane/level, /plane/observe, the re-ask alarm (orchestration-plane item 9; record 0064)::a queued admission awaits its alarm scheduling before the RPC response returns`, `::a queued admission returns its committed answer when alarm scheduling fails`, `::an observation after the admit's ack re-enters the row…`, `::an observation returns its committed re-entry when alarm scheduling fails`, `::the re-ask alarm probes a silent resident within the cadence…`; `[unit]` `src/deploy/affected.test.ts::classifyPath::tests, docs, specs, CI, scripts, the deploy tooling and repo metadata are inert…`; `packages/switchboard/build.test.mts::shippedDeployAssets::keeps every tracked deploy path but tests, vitest configs, test typings and the agent-env tooling, sorted` | +| The state Worker's workerd pool keeps the real SQLite Durable Objects but leaves the production Workflow engine unbound; coordinator-delivery cases install their own synchronous double on the live object; every queued or re-entered plane request settles its Durable Object alarm write before its RPC response returns, and a failed alarm write preserves the already-committed queued or re-entry answer rather than inviting the caller to proceed or retry; the re-ask proof explicitly awaits each alarm handler and deletes the re-armed slot; best-effort plane pushes are held by the Durable Object's `waitUntil`, and the per-test guard names and fails the originating case if registered work is still pending at its end, so no Workflow promise, alarm handler or effect push crosses a test boundary to hold an unrelated case; the test-only setup and wrangler config are inert deploy inputs and absent from the published package | `[worker]` `deploy/cloudflare-memory/backgroundTasks.test.ts::memory Worker pending task guard::fails on a fixture promise that is still pending when the test ends`, `deploy/cloudflare-memory/runLedger.test.ts::run ledger — the coordinator's event and the key (items 47–48)::the test pool leaves the real Workflow engine unbound…`, `deploy/cloudflare-memory/runLedger.test.ts::the plane's admission stage — /plane/admit, reservations, the seal's walk (orchestration-plane; record 0064)::a push that fails leaves the effect on the next heartbeat answer…`, `deploy/cloudflare-memory/runLedger.test.ts::the plane's resident stage — /plane/level, /plane/observe, the re-ask alarm (orchestration-plane item 9; record 0064)::a queued admission awaits its alarm scheduling before the RPC response returns`, `::a queued admission returns its committed answer when alarm scheduling fails`, `::an observation after the admit's ack re-enters the row…`, `::an observation returns its committed re-entry when alarm scheduling fails`, `::the re-ask alarm probes a silent resident within the cadence…`; `[unit]` `src/deploy/affected.test.ts::classifyPath::tests, docs, specs, CI, scripts, the deploy tooling and repo metadata are inert…`; `packages/switchboard/build.test.mts::shippedDeployAssets::keeps every tracked deploy path but tests, vitest configs, test typings and the agent-env tooling, sorted` | | A preflighted step is handed its Worker's origin from the profile (`SWITCHBOARD_BASE_URL` / `RESIDENT_BASE_URL`) alongside its force env; unguarded public-health steps carry a `wakeUrl`, the bot and sandbox do not; each preflight exits 2 naming its variable and `deploy all` when the origin is missing (item 16) | `[unit]` `src/deploy/plan.test.ts::planDeploy::each step spawns…`, `deploy/cloudflare/preflight.test.mjs::bot deploy preflight — main()::*`, `deploy/cloudflare-resident/preflight.test.mjs::resident deploy preflight — main()::*` | | `deploy config` pushes the profile's `configSource` (or `--source`) to the `base` document on the profile's state Worker and points at `deploy restart`; a host problem is `unavailable` verbatim; the plan carries `config: { source, document: "base", stateWorkerUrl }` and prints it; `config/` is inert to `--affected` (item 15) | `[unit]` `src/core/commands/deploy.test.ts::deploy.config::*`, `src/deploy/plan.test.ts::WORKER_SPECS / workersFor / DEPLOY_ORDER::the profile binds each Worker…`, `src/deploy/affected.test.ts::classifyPath::*`; `[agent]` `npm run cli -- deploy config --source config/config.example.yaml` against the fixture-profile state Worker → `pushed config from config/config.example.yaml → document "base" v …` then `deploy restart` → the bot's log opens with `[config] base document "base" v from config/config.example.yaml`; with no document pushed the container exits at startup with `no "base" document … push one with \`deploy config\``. | | The profile and its example are inert to `--affected` — a deploy-tooling input, never an image's (item 14) | `[unit]` `src/deploy/affected.test.ts::classifyPath::*` |