Skip to content

8 min read

Cheap checks first, and a change earns its way onto the expensive rung

On this page

The proposal comes from a VP who has watched three teams build three slightly different validation stories and wants one: run the CI pipeline, unmodified, inside the agent loop. Forty packages, checked-in generated clients, fourteen minutes end to end. Whatever the agent validates against is by definition what gates the merge, so an agent-authored patch that goes green has already passed the only bar the organisation recognises, and there is no second definition of correct for anyone to argue about.

The proposal is more right than the eye-roll it usually receives. Drift between the loop's checks and the merge gate is a real and expensive failure: a loop that runs tsc with the package's local tsconfig while CI runs it with a stricter root configuration produces patches that are green in the loop and red in the pipeline, and every one of those is a wasted task plus a confused human. Environment parity kills the entire "works in the sandbox" class — different Node version, different resolved transitive dependency, a native module built against a different libc. The pipeline is also the only thing that checks a set of properties nothing cheaper can see: that the checked-in generated clients still match their schemas, that the lockfile resolves — that the file pinning an exact version of every dependency can still be satisfied by the packages the registry is serving today — that the forty packages build against each other rather than against the versions each was last tested with. And having exactly one definition of green means the acceptance criterion for an agent patch is not something a team invents in a config file that nobody reviews.

What it gets wrong is the price. Fourteen minutes per attempt at a median of 3.2 attempts is 45 minutes of pure validation per task, and the tasks the agent is best at — a field added to a response, a null guard, a renamed method, a missing await — are ones a person does in six minutes. An agent that takes an hour to do them is not a slower engineer, it is an unused tool, and the loop's founding premise was cheap feedback the agent can act on immediately. Buying consistency with iteration time spends the one resource the loop has none of.

Rungs, and what earns a change onto the next one

The staircase gets consistency and speed at once by keeping the pipeline single-sourced while running only as much of it as a change has justified.

RungWhat runsScopep50When it runs
0Lint and type check on the pre-edit treeChanged package8 sOnce per task, before the agent edits anything
1Lint, then type checkChanged files; changed package6 sEvery attempt
2Type check of dependent packages, impacted unit testsReverse dependencies95 sWhen rung 1 is clean
3Codegen diff, lockfile resolution, cross-package buildWhole workspace4 minWhen rung 2 is clean, or immediately by path trigger
4Full CI: integration, smoke, migrations, container buildEverything14 minOutside the loop, before merge

Rung 1 is lint and a type check scoped to the changed package, and it is first because chapter 6's table put it there: 1.25 and 1.35 marginal points per loop-second, both naming a file, a line and a column the agent can open. Rung 0 exists because attribution needs a pre-edit run, and it doubles as the check that the repository was clean before the agent started — a tree that already fails rung 1 is a harness error rather than a task, and the loop should say so instead of handing the agent someone else's broken build.

Two rules govern movement between rungs. Escalation is monotonic: a rung runs only when everything below it is clean, so a patch that does not compile never spends 95 seconds discovering that the tests also fail. Demotion is total: any failure anywhere sends the next attempt back to rung 1, because a patch written against a rung-3 failure has changed the code and the cheap checks are cheap enough to re-establish from scratch.

The third rule is the one that keeps the staircase honest, and it is not about earning at all. Some rungs are triggered by what the change touches rather than by what it has cleared. A patch that edits anything under proto/ or schema/, or that modifies the lockfile, goes to rung 3 on its first clean pass through rung 1, because for those changes the codegen diff — regenerating the checked-in clients from the schema and asking whether the result differs from what is committed — is the most likely failure rather than an expensive confirmation of an unlikely one. Path triggers are how a staircase avoids the pathology where the riskiest changes climb the same slow ladder as the trivial ones.

Rung 4 sitting outside the loop does not mean it sits outside the agent's reach. When CI fails on a patch the loop already accepted, the failure comes back as a new task against the same branch, with the rung-4 output attached and the rungs below it re-run from scratch — which is the asynchronous version of the same loop, running at CI's cadence rather than the agent's. Roughly one agent patch in seventeen lands there, matching the integration build's marginal contribution from the previous chapter, and treating those as ordinary incoming work rather than as an exceptional failure is what keeps the staircase's economics intact. The alternative, which is to make the loop slow enough that rung 4 never fails, pays fourteen minutes on every task to avoid a second pass on one in seventeen.

State diagram: start → R0 (capture baseline); R0 → R1; R1 → Patch (diagnostics); Patch → R1 (re-check); R1 → R2 (clean); R2 → Patch (failures); R2 → R3 (clean or path trigger); R3 → Patch (failures); R3 → Merge (clean, hand to CI rung 4); Patch → Stop (budget spent or repeat fingerprint); Stop → start; Merge → start.

One definition of the commands, two callers

Drift is prevented by where the commands are written down, not by how many of them run. Both the loop and CI read the same file and invoke the same runner with the same image; the only difference is which rungs each asks for.

yaml
# checks.yaml - the single definition. The loop reads it, CI reads it.
version: 1
image: ghcr.io/acme/ci-base:2026.03.1     # same image in both callers
rungs:
  - id: 1
    budget_s: 30
    checks:
      - id: lint
        run: pnpm eslint --format json {changed_files}
        parser: eslint-json
      - id: typecheck
        run: node tools/collect-tsc.js --project {changed_package}
        parser: records-jsonl
  - id: 2
    budget_s: 300
    checks:
      - id: typecheck-dependents
        run: node tools/collect-tsc.js --projects {reverse_deps}
        parser: records-jsonl
      - id: unit-impacted
        run: pnpm vitest related {changed_files} --reporter=json
        parser: vitest-json
  - id: 3
    budget_s: 900
    checks:
      - id: codegen-fresh
        run: make gen && git diff --exit-code -- gen/
        parser: exit-status
      - id: resolve
        run: pnpm install --frozen-lockfile
        parser: exit-status
triggers:
  - when_paths: ["proto/**", "schema/**", "pnpm-lock.yaml"]
    require_rung: 3
yaml
# .github/workflows/pr.yml - CI is the same runner asking for everything
jobs:
  validate:
    runs-on: ubuntu-latest
    container: ghcr.io/acme/ci-base:2026.03.1
    steps:
      - uses: actions/checkout@v4
      - run: checks run --rungs 1,2,3,4 --scope all --format github

Nothing in the loop's configuration is allowed to define a check; it only selects rungs and supplies the scope placeholders. When someone tightens a compiler flag, they tighten it in the one place both callers read, and the loop starts enforcing it on the next task. This is what the VP wanted — a single definition of correct — obtained for the cost of a YAML file rather than 45 minutes a task. Rung 4 is defined in the same file and simply never selected by the loop, which makes the gap between what the agent validates and what gates the merge explicit and reviewable rather than emergent.

When the compiler makes an agent thrash

Deterministic feedback is what makes this loop tractable, and there are specific conditions under which it makes the agent worse rather than better. Each has a mechanical remedy, and none of them is a prompt.

The first is a diagnostic whose location is far from its cause. A deeply nested generic instantiation error in TypeScript can be sixty lines of type expansion pointing at a line inside a library's .d.ts; a Rust trait resolution failure can name a blanket impl the agent has never seen. The agent patches at the location it was given, which is the wrong place, and the error moves rather than clearing. Watching for that specific shape — the error count stays constant while the fingerprints change — is a reliable signal, and the response is to widen the payload to include the diagnostic's related spans and the declaration site rather than the use site.

The second is that a diagnostic can always be silenced. as any, @ts-expect-error, # type: ignore, #[allow], an inline rule disable, and a widened return type are all one-token paths from red to green, and an agent with two attempts left will find them. The harness reads the diff for those tokens and fails the attempt when it sees one introduced, which converts the shortest path from a reward into a penalty. This is the same guard 13.6 applied to edited assertions, and it needs to exist independently for each verifier, because the exploit is per-verifier.

The third is oscillation between two checks that disagree. A formatter that wants a line broken and a lint rule that wants it joined will produce an infinite alternation, with the agent dutifully satisfying whichever check spoke last. Fingerprint the diagnostics from every attempt — chapter 4's fingerprints do exactly this — and stop when a fingerprint set repeats. Two identical sets two attempts apart is a cycle and no further attempt will break it.

The fourth is an uneditable root, which chapter 3 covered for generated code and which recurs here in a form the staircase creates: a rung-3 codegen diff whose correct fix is a schema change in another repository. The loop cannot do it, and the honest behaviour is to stop with a report naming the blocking change rather than to spend the remaining budget producing a hand-edit that the next make gen will erase.

Underneath all four is one rule: a loop needs a stopping condition that is not exhaustion. Budget spent, fingerprint repeated, suppression token introduced, or diagnostic count rising across two consecutive attempts — any of them should end the task with the partial work and a description of where it got stuck, which is a far more useful artefact than the fifth speculative patch.

Where to start on Monday

The order to build this in is roughly the order of the chapters. Switch the loop's feedback to machine-readable diagnostics first, because everything else is a set operation over records and none of it is expressible over a log. Add the rung-0 baseline next, since attribution is the single largest reduction in noise available and it costs one extra run per task. Then order the diagnostics by dependency and cap the payload, which is the change that converts cascading failures from budget-burning into routine. Only then measure: seed a corpus, run the ladder in order, read the marginal column and the two latency percentiles, and let the numbers decide which checks sit on which rung, because the answer depends on your repository and the checks you happen to have.

What the staircase does not do is worth saying plainly at the end of a topic that has spent seven chapters on machine feedback. Every rung here constrains a machine-checkable property: shape, resolution, freshness, and the behaviour that existing assertions happen to describe. A patch that clears all four rungs has satisfied every grader in the repository and can still implement the wrong requirement, degrade a p99 — the latency that only one request in a hundred exceeds, and the one users notice — or violate something nobody wrote a rule for. The compiler is a fast, deterministic, precisely located grader that nobody on the team had to build, and it grades exactly what it grades.

Build, lint and type errors as a feedback signal — read the current version at https://daviddominguez.dev/learn/en/build-lint-and-type-errors-as-a-feedback-signal/cheap-checks-first-and-a-change-earns-its-way-onto-the-expensive-rung

Back to top