Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .claude/rules/toolchain.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,55 @@ need?" by probing the read endpoints and reporting each 403's
it in a fresh environment before concluding a task is broken — an under-scoped
token otherwise surfaces as an unrelated 403 in whichever task runs first.

## Invocation latency: the number, the series, and the regression gate

`perf` measures batten's own invocation cost (hyperfine over a release build:
`noop`, `check`, `hook`) and `perf-assert` holds it to the ceiling README
publishes — an ABSOLUTE budget, 100ms, clig's floor. `perf-pair` asks the other
question: it builds this branch's binary AND its merge base's, measures them back
to back on one machine, and `perf-compare` decides the RATIO. `perf-gate`
composes those two and is the one name `verify` calls. Do not confuse this with
`hook-latency-drift`, which measures the hk gate's own pre-commit tier (CLOUD-509)
rather than the binary's startup.

**Wall clock, paired — instruction counts are not available.** CLOUD-172
specified callgrind with wall clock as a fallback. `mise registry valgrind`
reports "tool not found in registry", so it cannot be pinned, and
`no-source-built-tool` forbids compiling one; the fallback is the only branch.
What rescues wall clock is not a better clock but a better experiment — machine
noise is common-mode across a pair measured seconds apart, so it divides out.
Hence a ratio, never an absolute, and `perf-record` stamps `metric=wall-clock`
into every series entry so a later instruction-count series can never be diffed
against this one and read as a step change. The threshold is derived, not chosen:
a null comparison (identical binary as both arms, n=30) spread 0.966–1.102, and
`perf-compare`'s 1.30 clears that measured maximum. Re-measure with `mise run
perf-pair --null`; a bats case fails if the constant is tightened below the
recorded floor.

**In `verify` AND as a CI job, and the second is not redundant.** `verify` is
where it catches a regression earliest: `ready-guard` refuses `gh pr ready`
without a verify receipt for this exact HEAD, so the branch stops before it can
spend a matrix. But `verify` is what an author runs, not a check — `[tasks.ci]`
is `depends = ["hooks", "deny"]`, so for its whole life `perf-gate` ran on no
runner at all, and CLOUD-172's "a PR that regresses fails a check that names the
regressed measurement" was unmet. The `perf` job in `ci.yml` is that check, in
`final`'s `needs:` and in `CI_REQUIRED_CHECKS`. Both placements cost the common
change nothing: `perf-gate` exits clean without building when the diff against
the merge base touches no crate source, manifest or lockfile, because a binary
that cannot have changed cannot have got slower.

**The series records on a clock, not on a push to `main`.** A per-commit series
implies a push-to-`main` workflow, which AGENTS.md forbids — and the reason holds
rather than merely applying: `main` advances only by fast-forward to a SHA CI
already judged, so a push trigger buys a runner per merge for a measurement a
clock can take. `perf.yml` records daily from `main` into `refs/notes/perf`.
The cost is resolution, affordable because the series is not the attribution
mechanism — `perf-gate` catches a regression at the commit that caused it.
`perf-record` refuses to run off the trunk, because a series mixing a branch's
numbers with the trunk's cannot be read at all. (That decision belongs in
AGENTS.md beside the rule it honours; AGENTS.md is at its budgeted line ceiling,
so it lives here with the rest of the workshop detail.)

## The gate

`mise-tasks/` scripts are real programs: `shfmt`, `shellcheck` and `test:bats`
Expand Down
83 changes: 82 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,87 @@ jobs:
key: semver
- run: mise run semver

# The latency regression gate on the surface that reaches a person (CLOUD-172).
#
# WHY THIS JOB EXISTS AT ALL, since `perf-gate` is already a step in `verify`.
# The issue's Done clause is "a PR that regresses past the threshold FAILS A
# CHECK that names the regressed measurement", and `verify` is not a check —
# it is what an author runs locally. `[tasks.ci]` is `depends = ["hooks",
# "deny"]`, so nothing in CI ran `perf-gate`: the gate existed and no runner
# ever invoked it, which is a gate that decides nothing about a pull request.
# A regression would have reached `main` past a green required check.
#
# It is affordable for the reason `perf-gate`'s own header argues: the gate
# exits clean without building anything when the diff against the merge base
# touches no crate source, manifest or lockfile. Most of this repository's
# traffic is task-layer, workflow and documentation changes, which pay a
# `git diff --name-only` here and nothing more. Only a change that can have
# moved the binary pays for two release builds.
#
# Deliberately NOT the recording half. `perf-record` writes the series and
# refuses to run off `main`; it runs from `perf.yml` on a schedule, because a
# push-to-`main` trigger is forbidden (AGENTS.md) and `main` only ever advances
# by fast-forward to a SHA this job already judged.
perf:
name: perf
if: ${{ github.event.pull_request.draft == false }}
runs-on: ubuntu-latest
# DERIVED, not guessed, and stated because a single sample is not a p95.
# `mise run perf-gate` forced down its full path — both release binaries built
# and both arms sampled, in a worktree with a crate-source change so the skip
# could not fire — measured 109s wall on a warm cache. The declared 200s adds
# the job's own preamble (checkout, mise install, cache restore) and headroom
# for a colder, busier runner; x3 on top of that is the multiplier every job
# here carries. Re-measure from real runs once this job has a history: an
# over-declared budget is a weaker backstop, which is the safe direction to be
# wrong in but still the wrong number.
timeout-minutes: 10 # budget: p95=200s x3 measured=2026-08-14
# `actions: write` is here for one call and one only: cancelling THIS run
# when the landing lease does not authorise this branch (CLOUD-420). A job
# that could not cancel itself would have to fail instead, and a failed job
# makes the run conclude `failure` rather than `cancelled` — which reds
# `final` and makes `land` re-draft a healthy PR.
permissions:
contents: read
actions: write
steps:
# CLOUD-420: THE LANDING LEASE, ENFORCED WHERE THE MONEY IS SPENT. First
# step, before any checkout or toolchain install, for the reason the `ci`
# job states at length: a run this branch is not authorised to make should
# cost the rounding rather than a matrix. `|| exit 0` on both lines.
- name: Landing lease precondition
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
LEASE_HEAD_REF: ${{ github.head_ref }}
LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
LEASE_RUN_ID: ${{ github.run_id }}
run: |
body=$(gh api -H "Accept: application/vnd.github.raw" \
"repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition?ref=main") || exit 0
bash -c "$body" || exit 0

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# `perf-pair` builds BOTH arms — the merge base and this head — so the
# baseline commit has to be in the clone. A shallow checkout has no
# merge base, and an unresolvable base is exit 2 by design rather than
# a pass, so this would fail loudly rather than measure nothing.
fetch-depth: 0
- uses: jdx/mise-action@9dda3952d607125725deac9ec10a5f0e245d266b # v4.2.4+9dda395 retry fix, pre-release (CLOUD-404)
with:
# rust builds the two binaries under measurement, hyperfine samples
# them, jq reads the ratio out. See the ci job for why these lists are
# narrow.
install_args: rust aqua:sharkdp/hyperfine aqua:jqlang/jq
# A distinct key: this job builds `--release`, where every other cargo job
# here builds debug, so sharing an entry would thrash it — the same reason
# msrv and semver carry their own.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: perf
- run: mise run perf-gate

# Single fan-in status. Point branch protection at `final` instead of
# enumerating every leg, so adding a job never requires a ruleset change.
#
Expand Down Expand Up @@ -537,7 +618,7 @@ jobs:
# carries the reason it cannot change, so the next reader who notices that a
# stopped run reds `final` does not re-derive the skip and ship it.
if: ${{ always() && github.event.pull_request.draft == false }}
needs: [ci, cross, darwin-link, msrv, semver]
needs: [ci, cross, darwin-link, msrv, semver, perf]
runs-on: ubuntu-latest
# `checks: read` is the whole reason this job now needs a permission at all:
# the analyzer's verdict is a check-run on the head commit.
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/land-divergence.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ name: land-divergence

on:
schedule:
# Daily, 04:00 UTC — an hour before bench.yml (05:00), which is the only
# Daily, 04:00 UTC — an hour before perf.yml (05:00), which is the only
# other daily job, and clear of the Monday 06:00-08:15 weekly ladder.
- cron: "0 4 * * *"
workflow_dispatch:
Expand Down Expand Up @@ -59,7 +59,7 @@ jobs:
# Measure to a FILE, then read it back — never `measure | assert`. A
# pipeline hands the step's exit status to its last stage, so a measurement
# that died would be judged as a clean empty window rather than reported
# (bench.yml:84-92, and `run-shape-guard` refuses the shape locally).
# (perf.yml:84-92, and `run-shape-guard` refuses the shape locally).
#
# GH_TOKEN through env, never interpolated into the run block: a `${{ }}`
# expansion is textual substitution into the shell script, which zizmor
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/nonverdict-rate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ jobs:
- uses: jdx/mise-action@9dda3952d607125725deac9ec10a5f0e245d266b # v4.2.4+9dda395 retry fix, pre-release (CLOUD-404)
# Measure to a FILE, then read it back — never `scan | assert`. A pipeline
# hands the step's exit status to its last stage, so a scan that died would
# be judged as an empty window rather than reported (bench.yml:84-92).
# be judged as an empty window rather than reported (perf.yml:84-92).
#
# GH_TOKEN through env, never interpolated into the run block: a `${{ }}`
# expansion is textual substitution into the shell script, which zizmor gates
Expand Down
62 changes: 47 additions & 15 deletions .github/workflows/bench.yml → .github/workflows/perf.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: bench
name: perf

# The measurement half of CLOUD-207, on a clock rather than on the landing path.
#
Expand All @@ -14,7 +14,7 @@ name: bench
# minutes are metered; `ci-local-parity` exists to keep the landing path to
# things a local `mise run verify` already proved.
#
# WHAT DEFENDS THE NUMBER PER-COMMIT, then, is `tests/bench-assert.bats`: the
# WHAT DEFENDS THE NUMBER PER-COMMIT, then, is `tests/perf-assert.bats`: the
# gate is a pure function of records on stdin, so its decision runs in the hk
# gate on every commit in milliseconds, with no build and no hyperfine. This
# workflow supplies the one thing that suite cannot — a real measurement of the
Expand Down Expand Up @@ -43,7 +43,7 @@ on:
workflow_dispatch:

concurrency:
group: bench
group: perf
cancel-in-progress: false

permissions: {}
Expand All @@ -56,24 +56,38 @@ env:
MISE_LOCKFILE: "true"

jobs:
bench:
name: bench
perf:
name: perf
runs-on: ubuntu-latest
# `grandfathered` rather than a derived budget, and deliberately so: this job
# has no run history, so there is no p95 to multiply and any number written
# here would be the asserted-instead-of-measured claim this very ticket
# exists to delete. Locally a full `mise run bench` — cold release build,
# exists to delete. Locally a full `mise run perf` — cold release build,
# three paths, 110 invocations each — measured 48.7s on four cores; the
# runner adds checkout and install on top, and pays the full dependency
# build whenever the cache misses. `mise run timeout-drift` is the mechanism
# that replaces this with a measured budget once runs exist to measure.
timeout-minutes: 20 # budget: grandfathered measured=2026-08-11
permissions:
contents: read
# `write`, and only here (CLOUD-172): the recording step pushes the series
# to `refs/notes/perf`. Every other scheduled workflow reads; this one
# writes exactly one ref that no branch protection covers and no build
# consumes — a note is not a commit, so nothing this pushes can change
# what `main` builds.
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
# Credentials ARE persisted here, unlike every other scheduled job,
# because the recording step pushes. Scoped by the `contents: write`
# above and by the single explicit refspec that push names.
fetch-depth: 0
# `git notes append` needs the existing note to append TO; without this
# every run would start a fresh note and the series would be a list of
# singletons. `|| true` because on the first run the ref does not exist,
# and a missing ref is the empty series rather than a failure.
- name: Fetch the existing series
run: git fetch origin "refs/notes/perf:refs/notes/perf" || true
- uses: jdx/mise-action@9dda3952d607125725deac9ec10a5f0e245d266b # v4.2.4+9dda395 retry fix, pre-release (CLOUD-404)
with:
# rust builds the binary under measurement, hyperfine samples it, jq
Expand All @@ -84,16 +98,34 @@ jobs:
with:
# A release profile, which no other job builds, so a shared cache entry
# would thrash.
key: bench
key: perf
# Redirected into a file and read back, never piped into the gate. A
# pipeline hands its exit status to the LAST stage, so `bench |
# bench-assert` would discard exactly the measurement failure that must
# pipeline hands its exit status to the LAST stage, so `perf |
# perf-assert` would discard exactly the measurement failure that must
# stop this job — the false-green shape `mise run run-shape-guard` refuses
# locally, and no less wrong in a workflow.
- name: Measure
run: mise run bench >"$RUNNER_TEMP/bench.txt"
run: mise run perf >"$RUNNER_TEMP/perf.txt"
- name: Assert the budget
run: mise run bench-assert <"$RUNNER_TEMP/bench.txt"
run: mise run perf-assert <"$RUNNER_TEMP/perf.txt"
# The series (CLOUD-172). AFTER the assertion, deliberately: a measurement
# that broke its budget is a defect to fix, not a datum to enshrine, and a
# series carrying known-bad readings is one nobody can read a trend from.
#
# `perf-record` refuses to run off the trunk by itself, so the schedule's
# main-only guarantee is belt and braces rather than the only guard.
- name: Record the series
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
mise run perf-record <"$RUNNER_TEMP/perf.txt"
env:
BENCH_RUNNER: ubuntu-latest
# Its own step, so a push that fails is distinguishable from a measurement
# that failed. The refspec is explicit and singular: this job may write the
# series and nothing else.
- name: Push the series
run: git push origin "refs/notes/perf:refs/notes/perf"
# Always, including on a budget violation: the records are the evidence
# for the failure, and a run that fails without publishing what it measured
# sends the reader to re-run it by hand.
Expand All @@ -107,9 +139,9 @@ jobs:
# backtick pair inside single quotes reads as legacy command
# substitution to shellcheck (SC2016), which actionlint runs over
# every `run:` block.
echo "Budgets are enforced by \`mise run bench-assert\` — see README, Performance."
echo "Budgets are enforced by \`mise run perf-assert\` — see README, Performance."
echo
echo '```'
cat "$RUNNER_TEMP/bench.txt"
cat "$RUNNER_TEMP/perf.txt"
echo '```'
} >>"$GITHUB_STEP_SUMMARY"
26 changes: 26 additions & 0 deletions .serena/memories/toolchain-and-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,32 @@ programs the engine will replace, and churning their contract now would rewrite
suites that are about to be deleted. The hazard is recorded rather than fixed
because the fix is the port itself.

## Declare the break when you write it, not when `semver` asks

`mise run semver` is **verify-time**, and a break declared late is not free: the
`!` has to land on the commit that makes the break, which is routinely not HEAD
by the time verify runs. Fixing it then means unwinding to that commit
(`reset --soft`, un-stage the later work, `--amend`, re-commit) — interactive
rebase is unavailable here. Declaring it on whichever commit happens to be HEAD
**satisfies the gate**, because it reads the whole `base..head` range for any `!`
or `BREAKING CHANGE:` — and leaves a `test(...)!` or `docs(...)!` in permanent
history claiming an API break it did not make.

Measured twice in one bundle (CLOUD-59), both by an author who knew:

- `Provision` gained a field and its `url`/`sha256` became `Option` —
`constructible_struct_adds_field`, committed as a plain `feat(provision):`.
- `rules::run_static`/`run_all` gained a parameter —
`function_parameter_count_changed`, committed as a plain `feat(rules):` **whose
own body explained the threading**. Knowing a change is breaking and marking it
breaking are independent acts; the second one is the one that gets skipped.

The cheap check is at authoring time, not at verify time. A public struct that
callers construct, a public fn's parameter list, an enum variant's position —
touch one and the subject needs `!` before the commit exists. Recorded rather
than mechanised: a commit-time semver check would need a baseline rustdoc build
per commit, which is the cost `verify` exists to amortise.

## Never hand awk a regex through `-v`

The value is escape-processed by the assignment before awk sees it as a pattern,
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ per-clone setup and the task tour.

Batten runs as a `PreToolUse` hook, so its cost is paid on **every** mediated
tool call rather than once per commit. These are measured numbers, not targets —
`mise run bench` reproduces them, and `mise run bench-assert` fails when a
`mise run perf` reproduces them, and `mise run perf-assert` fails when a
measured p95 leaves its budget.

`wired` is the number an agent actually waits on: the entry point
Expand Down Expand Up @@ -184,7 +184,7 @@ ceiling here could tell a large tree apart from a regression.
> Measured 2026-08-12 on a 4-core x86_64 Linux container: release build, 10
> warmup runs discarded, 100 timed runs per path, p95 from the sorted run times.
> Your machine will differ; the budget is what the gate holds, and the schedule
> in [`.github/workflows/bench.yml`](.github/workflows/bench.yml) is what keeps
> in [`.github/workflows/perf.yml`](.github/workflows/perf.yml) is what keeps
> holding it.

<!-- prettier-ignore -->
Expand Down
Loading
Loading