CLAUDE.md ยท diff
git:20260927.2643b01 to git:20261005.01316b4
6 added, 2 removed. Audit A to A.
# Working on pi-dispatch
Notes for an AI agent working in this repository. Short on purpose: `specs/` is the authority, and this file
exists to stop you from having to discover the load-bearing parts by breaking them.
## What this project is
pi-dispatch runs the [pi](https://github.com/earendil-works/pi) coding agent as a service. It **lives in the
background** and, on a cron schedule or a forge event (an issue, a comment, a pull request), opens a
container that runs a flow against a repository, does the work, and shuts the container down. A panel shows
the triggers, the history of every past run and the spend, and can turn the whole thing off.
pi has no queue, no concurrency control, no spend limit and, by its own README, no permission system.
**This project is exactly that missing operational layer and nothing else.** When a change would make
pi-dispatch smarter about *what the agent does*, it probably belongs in a skill or a flow, not here.
Two consequences worth internalising before you design anything:
- **The container is the boundary.** Isolation is built by the worker's own `docker run` argv, so nothing an
image contains can weaken it.
- **Money is the other boundary.** Every gate that costs nothing runs before every gate that costs
something, and a paid container starts only after all of them pass.
## The shape
| Path | What it is |
|---|---|
| `worker/` | the queue consumer, the CLI (`pi-dispatch`), the forge hosts, doctor, service installer |
| `receiver/` | the always-on trigger edge (`pi-dispatch-receiver`): webhook routes and the poller |
| `admin/` | the operator console, a pi extension (`/dispatch`), TypeScript, bundled to `dist/` |
| `image/` | the job image and the in-container runner that implements the exit-code protocol |
| `deploy/` | service units, wrappers and the compose file; `worker/deploy/` is the published mirror |
| `specs/` | constitution, requirements, design, interfaces, open questions. The source of truth |
| `docs/` | operator-facing reference, one file per feature or forge |
| `launch/` | launch copy and the demo recording recipe. Maintainer material, deliberately not in `docs/` |
## Read these before changing behaviour
1. `specs/constitution.md` โ the non-negotiables. A change that violates one must justify **the
constraint**, not the code, and amend that file in the same PR.
2. `specs/design.md` โ decisions that could have gone another way, and what was rejected.
3. `specs/interfaces.md` โ the file and container contracts, including the run-record shape.
4. The revision history at the end of each spec file. It records corrections, not just additions, and
several entries exist because a previous claim was refuted.
## Rules that bite
- **Specs change in the same PR as the code**, with a revision-history row. When a spec entry is unaffected,
say so explicitly ("UNCHANGED, checked") rather than silently leaving it. Cite spec IDs
(`CONST-*`, `REQ-*`, `DES-*`, `INT-*`) in commit bodies; they are permanent addresses.
- **Verify against the pinned artifact, not against HEAD.** pi is pinned to an exact npm version. A sha is
not a version, and this rule is in the constitution because ignoring it once nearly shipped a runner that
imported an export the pinned release did not have. Docs are a hint; source at the pin is evidence.
- **`CONST-MERGE-NEVER-AUTOMATIC`.** Nothing in this project merges anything, ever. CI greps
`worker/src`, `receiver/src` and `image/runner` for `pulls.merge`, `gh pr merge`, `autoMerge` and friends,
so even a comment mentioning one fails the build.
- **`CONST-BUDGET-BEFORE-TOKENS`.** Free, determinate refusals go before anything that spends: before the
token mint, the clone, the token-cap read and the budget reservation.
- **`CONST-RETRY-INFRA-ONLY`.** A determinate policy refusal **returns** a result; only infrastructure
failure **throws** so the queue retries. Getting this backwards means either paying to retry something
that can never succeed, or dropping real work behind a silent success.
- **Exact pins only** (`CONST-PI-VERSION-PINNED`). No `^`, `~`, `latest` or a floating tag for pi, for
staged pi packages, or for image bases. A floating range turns an upstream release into every queued job
- quietly losing a tool while the queue still reports success.
+ quietly losing a tool while the queue still reports success. pi depends on its own sibling packages by a
+ range and ships no shrinkwrap since 1.0.1, so the root `package.json` pins every `@earendil-works` package in
+ `overrides`; `.github/scripts/pi-pin-check.mjs` holds those, the lockfile and every hand-written copy of the
+ pin to the runner's. A copy of a pi file's logic is pinned by that file's content hash, not by pi's version.
- **No secrets or PII in logs.** Log key *names*, never values, and never payload text. The run record is
PII-free by construction: it holds no attacker-chosen string.
- **Fail loudly, or fail open and say which.** A silent no-op is the worst outcome available here. If a
feature cannot do what it was asked, it refuses with a reason an operator can act on. Where it fails open,
the reason is named in the record.
## Style
- **Tabs** in `worker/`, `receiver/`, `image/`. **Two spaces** in `admin/`. Double quotes, semicolons.
There is no linter, so match the file you are in.
- Node 22.19 or newer.
- Tests are `node:test` with hand-rolled, dependency-injected fakes. No mocking framework, no network, no
Docker in unit tests. Inject a seam rather than reaching for a global.
- Comments explain **why**, and especially why the obvious alternative is wrong. This codebase is dense with
them on purpose; a comment that merely restates the code is noise, one that records a rejected approach is
the most valuable line in the file.
- **Both READMEs avoid dashes as punctuation** (no em dash, no ` - `). Use commas, colons or parentheses.
Keep the images: they carry more than the prose does.
## Tests and CI
- `npm test` at the root runs every workspace. Run the whole suite before committing, not just the file you
touched: the workspaces share the triggers schema and the queue.
- CI runs the same suite with `PI_DISPATCH_REQUIRE_{LOADER,WORKER,RECEIVER}_TESTS=1` and a live Valkey, which
is where the integration tests that skip locally actually execute.
- **Six checks are REQUIRED on `main`**. Five come from `pi-upgrade-check.yml` (issue #105): `pins are exact
(CONST-PI-VERSION-PINNED)`, `no automatic merge (CONST-MERGE-NEVER-AUTOMATIC)`, `pinned assumptions still
hold (offline, no API key)`, `the job image holds its contract`, `the admin extension survives latest pi
(canary)`. The sixth is `the rootless podman venue holds its declarations` from `podman-conformance.yml`
(issue #432; it ran advisory under #354 until it had passed on every PR of a round). `enforce_admins` is
**on**, so a red build is unmergeable by the owner too.
- Both workflows' **`pull_request` triggers are deliberately unfiltered**. A required check that never
reports blocks a merge forever, and the old path filter meant a docs-only PR reported nothing at all.
Do not add `paths:` to either. The `push:` filters are unaffected and stay.
- A job's `name:` IS its required context string: renaming either workflow's job renames the check, and
the branch protection must be updated in the same breath or every PR waits forever on the old name.
- Two PR-reporting checks are deliberately **not** required. `host-pi mirrors survive latest pi (canary)`
is green-on-drift by design, so its red means pi failed to install (upstream flake, not a defect); and
`deploy/ artifacts are syntactically valid` (with the Quadlet generator job beside it) is still
path-filtered to `deploy/**`, so requiring it would deadlock every PR that does not touch `deploy/`.
- If Actions is down or a workflow file breaks, `main` is frozen. Escape hatch: `gh api -X DELETE
repos/edgehero/pi-dispatch/branches/main/protection/enforce_admins`, merge, then `gh api -X POST` the
same path to put it back.
- **A test that touches retention, a TTL or a window takes an injected clock, always.** A fixed instant
in the test beside a subject built on the default `Date.now` is a fuse: it passes until the wall clock
drifts past that subject's own window, then fails in CI on a tree nobody touched (issue #284 blocked
every merge that way, from a file that had carried the fuse since the day it was written). Two guards
run in `contract-tests`: `.github/scripts/dated-fixture-check.mjs` flags the pairing in a second, and
the suite is re-run with `Date` shifted 399 days forward, which is the oracle. They catch different
things on purpose and neither subsumes the other. Pass `now`; never move the fixture date forward.
- **A test directory comes from the workspace helper, always** (issue #351):
`import { tempDir } from "./helpers/temp-dir.mjs"`, which removes every directory the file made in an
- `after()` hook. A bare `mkdtempSync(join(tmpdir(), ...))` in a test file is refused by
+ `after()` hook, and again at process exit for any made after it ran (node:test runs root hooks early when a
+ file declares tests before a top-level await). A bare `mkdtempSync(join(tmpdir(), ...))` in a test file is refused by
`.github/scripts/temp-dir-check.mjs`, and the same job runs the suite under a `TMPDIR` of its own and
fails if anything is left in it (two fixed-name tooling caches, `jiti` and `node-compile-cache`, are
excluded by exact name; `mkdtemp` always appends random characters, so no test directory can match one). Same fast-hint-plus-oracle pairing as the clock guards above, and for
the same reason: the grep sees a call shape, only the count sees a missing cleanup. A directory rooted
somewhere the helper already made (`join(jobsDir, "job-")`) is fine and is not matched.
- `admin/dist/` is gitignored and built by `node admin/build.mjs`. Never commit it.
- Two mirrors must stay **byte-identical**, pinned by tests: `worker/.env.example` to the root
`.env.example`, and `worker/deploy/*` to `deploy/*`. Edit both.
- The wizard's `RUNTIME_VERSION` and `RECEIVER_VERSION` are bolted to the workspace versions by anti-drift
tests. A release bump moves all of them together.
- **A hand-written table that restates a derivable source is either derived or pinned, never trusted.**
Every instance found so far had drifted, and every one was load-bearing. Current bolts: the
model-callable tool list in `REQ-`/`DES-ADMIN-VIA-PI-EXTENSION` against the registrations, and
`INT-TRIGGERS-FILE-CONTRACT`'s forge vocabularies against `PR_ACTIONS` (the two tests that read a spec
file); the admin's forge, action and settings vocabularies against `worker/src/triggers.mjs` and
`runtime-settings.mjs`; `EXIT_POLICY`'s copies in the runner and the `deploy/` units; the receiver
filters' action sets as the loader's set minus their own named exclusions; the git hardening flags,
which live in `worker/src/git-hardening.mjs` because seven files carried them by hand and one had
quietly lost a flag; `docs/egress.md`'s canary rows, whose first column is rebuilt from
`CANARY_LINES` by a test and required to match between markers (the count-the-source test it replaced
could not see four spellings and could go false red on a comment); and every spec revision row, which
must split into exactly two cells on unescaped pipes, because such a row is CUT on the rendered page
while looking whole in the diff, and four had silently lost between 1,100 and 2,800 characters each.
Add a table, add its bolt. Where the relation is NOT real, say so in the test
rather than manufacturing one: `PR_ACTION_VOCAB.dflt` is deliberately unpinned, with a line explaining
that gitlab's default is not its set's first member.
## Commits and PRs
- Conventional subjects citing the issue number. The body explains *why*, names the spec IDs touched, and
states what was checked and left unchanged.
- DCO sign-off (`git commit -s`). Branch names are `type/short-slug`.
- One PR per issue where possible; stacked PRs are merged one at a time, parent first, never with
`--delete-branch` until the whole chain has landed.
- **`gh pr merge --admin` is no longer the merge path.** It was, while `main` required an approving review
that a solo author cannot give themselves. `main` now requires a PR and green checks instead of an
approval, so a green PR merges with a plain `gh pr merge`. Reach for `--admin` and you are bypassing the
contract tests, not a paperwork rule.
## Things that look like bugs and are not
- **`resolveSession` returning `null` when no store is configured** is a DI-seam backstop; the processor
refuses such a job before it spends, so the store's own branch is unreachable in a wired worker.
- **The overlay's `extensions/` load by default** while staged packages are withheld per trigger. Two
different switches, deliberately: see `docs/global-pi-overlay.md`.
- **A serviced repo's `.pi/extensions` really does load in a job.** `/workspace` is the base repo's
default-branch sha, so it is merge-gated content, never a fork's branch.
- **The staged-package manifest is read at each job start**, not once at boot. It *was* a boot read, and
that was right while the staged set only changed when an operator edited a reviewed file; issue #102 made
`pi install` then re-stage routine, and under a boot read a re-stage that drops a package makes every
later job refuse at container start with the budget already reserved. A failed read keeps last-known-good
rather than degrading to none, because an empty set runs the job toolless on a clean exit 0.
- **`worker/src/host-pi.mjs` mirrors private pi internals on purpose**, and pi exports no public
alternative. It is pinned twice: `worker/test/host-pi.pinned.test.mjs` gates the pinned version, and
`.github/scripts/host-pi-canary.mjs` warns against `latest`. Both share one needle list. If a pi bump
fails either, fix the mirror, never the assertion. The residual is `OQ-018`.
## One note on this file
`CLAUDE.md` is read by the agent working on **this repository**. It is not `AGENTS.md`: pi discovers
`AGENTS.md` natively, so a root `AGENTS.md` here would also be loaded into every job that services this
repo. Keep instructions meant for maintenance out of the job containers.