Skip to content

feat(ci): stop an unauthorised run before it spends a matrix (CLOUD-420) - #366

Merged
wenzowski merged 2 commits into
mainfrom
claude/landing-lease-optimization-hkmacd
Aug 12, 2026
Merged

wenzowski merged 2 commits into
mainfrom
claude/landing-lease-optimization-hkmacd

Conversation

@wenzowski

@wenzowski wenzowski commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

CLOUD-393's rolling lease serialises landing, but it was enforced entirely inside mise-tasks/land: anything else pushing to an already-ready PR bought a full matrix without ever touching the lock. Measured 2026-08-12 05:17–05:19Z, four concurrent pull_request matrices ran while the lease changed hands three times — every session holding the lease honouring it.

This adds the runner's half. mise-tasks/ci-lease-precondition runs as the first step of every pull_request job that can start immediately (8 jobs across 4 workflows), fetched from main so a stale clone cannot dodge the predicate by carrying a stale copy of it. It asks land-lock authorises <branch>, and whether this head's own mise-tasks/land takes the lease at all — the row the lease table cannot cover, since a clone that takes no lease reads as absent and would be waved through.

Blast radius, measured before landing

11 of 15 open PRs carry a mise-tasks/land that never acquires the lease; 8 of those are non-draft. That is the population, not an edge case. On landing, the next push to each is stopped and told to rebase — work those branches already owe, since main has moved past all of them.

Five corrections to the refinement, each found by measurement

  • final does not conclude cancelled — it runs and fails, and the obvious remedy is worse. It declares always(), and an always() job executes even when the run is cancelled: observed on run 31566043914. So cancelling an unauthorised run reds the one check the host requires, which reads as a reason to skip the job instead. !cancelled() does skip it — and GitHub documents that a job skipped by its own if: "will report its status as 'Success'. It will not prevent a pull request from merging, even if it is a required check." Every lease-stopped run would then show a green required check over a head where nothing was compiled, tested, linted or analysed. A false red costs a rebase; a false green lands untested code on main. always() stays, now carrying the reason it cannot change.
  • The exemption is needs:, not "has no checkout". final checks out too; a fan-in cannot start before its dependencies, so it can never spend a runner ahead of the cancel.
  • Step-vs-gate-job break-even is 4:3, not 6:1.
  • The repository is private and several checkouts set persist-credentials: false, so the precondition carries its own credential via http.extraheader — never a userinfo URL, since land-lock prints its remote when unreachable. Probed end-to-end against the real remote.
  • dependabot/* and release-plz-* are exempt. A cancelled run is completed, so their /fast-forward landers fire, find no green, and stop with nothing retrying — cancelling would defer a matrix, not save one.

The ::error:: annotations are load-bearing rather than decorative, since a stopped run is a cancelled run with a red final and no failed step of its own. They were being swallowed: the runner only reads a line as a workflow command when it begins with :: after trimming whitespace (actions/runner, ActionCommand.TryParseV2, line-anchored), and the stop message went through a helper that prefixes lease-precondition: , putting the token at column 20. Both stop paths now emit at column 0, pinned by cases that check every occurrence rather than the first.

It never exits non-zero: a job that reds before its cancellation lands concludes the run failure rather than cancelled, which reds final and re-drafts the PR — the same failure mode arriving through its own remedy.

Verification

  • tests/ci-lease-precondition.bats, 16 cases. Mutants: neutering the staleness row reds 3 cases and no others; reading exit 3 as run reds the acceptance case; exiting non-zero on the stop path reds every stop row; removing the prefix exemption reds only its case.
  • mise-tasks/ci-local-parity gains property 7 as the sensor, 4 new cases. Its fixture helper now emits the precondition by default — without that, every pre-existing case would have reddened for the wrong reason.
  • Property 7 earned itself during this branch's own rebase: a semver job landed on main mid-flight and the gate refused the merge until that job carried the step too. Verified by mutation against the real workflows.
  • This PR's own CI is unaffected by its own change: the step reads the script from main, where it does not yet exist, and a 404 fails open.

Refs: CLOUD-420, CLOUD-393, CLOUD-363


Generated by Claude Code

@linear-code

linear-code Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
CLOUD-420 The landing lease is enforced only by the code path that honours it, so an agent that skips `land` still spends a full matrix

Why

CLOUD-393 serialises landing behind a lease and cuts the discarded-CI-run rate. It is enforced entirely inside mise-tasks/land. An agent that runs gh pr ready by hand, pushes to an already-ready PR, or wraps landing in its own logic spends a full four-job matrix without ever touching the lock.

That is the failure this repository names on its front page: "A new rule without a runnable gate is half a change. Prose is feedforward only." The lease is a convention honoured by the cooperating path, and the threat model is the honest agent that does the wrong thing — CLOUD-200 records a session that satisfied gh-guard and then wrapped mise run land in a bespoke retry loop it invented on the spot. Nothing stops the same shape here.

The dominant case is residue, not defiance. Measured 05:17–05:19Z on 2026-08-12: four concurrent pull_request matrices (#356, #354, #332, #302) while the lease changed hands three times, so the lease was being honoured by every session that held it and all four matrices ran anyway. land re-drafts only on red, so a landing interrupted any other way — a container reclaim, a stopped task, a lost lease, a die on a rebase conflict — leaves the PR ready permanently, and every later push to it buys a full matrix with no landing attempt in progress at all. All three of the other PRs were draft=false. The implementation is scoped to that case; an agent that skips land deliberately is the same mechanism and the smaller share.

The enabling gap. The lease identifies a clone (hostname-pid-random), which CI cannot check anything against. Adding the branch it authorises to the lease body is what makes it verifiable by the thing spending the money.

Refinement — Ready

  • Source of truth (§1). The lease ref refs/heads/batten-land-lock — a parentless commit over the empty tree whose body is land-lock / holder: / expires: / nonce: — read server-side by the workflow that is about to spend the runner. This issue adds a fifth line, branch:, and land-lock-check gains its well-formedness assertion rather than a second gate growing beside it. Not a receipt in a clone, which is exactly the artefact an agent skipping land never writes.

  • Mechanism (§3). land-lock gains a read-only verb, authorises <branch> — a pure function of (lease state, branch) reusing the existing observe / expired / released predicates. The alternative, re-parsing the body in workflow YAML, is a second authority for the ref and silently drops the read semantics the lease was pressure-tested into: a per-process observation ref, an unparseable body reading as held, and an unreachable remote that is not "free". Exit 0 run / 3 stop / 2 could not look.

    Each expensive pull_request job gains a first step, before the real checkout: a one-file sparse checkout of mise-tasks/land-lock at main (fetch-depth: 1, ~2s) and one invocation of it. The predicate comes from trunk, never from the head under test — that head has not been graded yet.

lease state action
absent, released, or expired-and-corroborated run — nobody is landing
branch: is this branch run — this is the authorised attempt
branch: names another branch stop, having spent only the spin-up
unreachable, unparseable, or carrying no branch: run, with a warning — fail open

Nothing in the table asks why a push happened, which is what makes it cover the residue case: the precondition is per job rather than per landing, so a push to a PR left ready by an interrupted land is stopped by the same row that stops a hand-typed gh pr ready.

The last row is the design and not a fallback: failing open costs one matrix, while failing closed on an unreadable ref stops every PR in the fleet, and a body minted before this change ages out within one TTL (120s).

  • A step, not a preflight job (§3), and the arithmetic that decides it. In billed minutes the two are closer than a wall-clock reading suggests: a gate job costs 1 job-minute per stopped run, because the expensive jobs never start and a skipped job bills nothing, while a first step in each job costs 7. Against that, the gate taxes every legitimate run one extra job-minute plus ~15-20s of serial spin-up. The step is therefore cheaper exactly while legitimate runs outnumber stopped ones better than 6:1, and lap length sits in the exponent of the collision model this whole line of work exists to shrink, which decides it at parity. That ratio is measurable rather than assumed; it is the number to re-check if the residue rate stays high.

  • THE HAZARD — a stop is a third answer, and the verdict layer has two (§2). A first step cannot make a job skipped; only a job-level if: can, which is the preflight job ruled out above. So the reachable conclusions are success, failure and cancelled, and checks-green reads success or neutral as green, failure and cancelled as red, and a latest-run skip as "not an answer".

    • Success or neutral grades a head green that compiled nothing, and main advances to exactly the SHA that passed. Disqualifying.
    • Failure re-drafts healthy PRs fleet-wide, since land re-drafts on red. Disqualifying.
    • Cancelled is the one that can be made correct. "This run declined to answer" is a third thing the verdict vocabulary lacks, and CLOUD-436's latest-run-per-name grouping is what makes a per-name declination readable at all.

    So: the step self-cancels the run (POST /actions/runs/{id}/cancel, actions: write on that job alone). Reading cancelled as a declination rather than as red is CLOUD-363's change, in flight on fix(tasks): read a cancelled required check as no verdict, not as red #302, which moves it into skipped's not-an-answer bucket in checks-green and in land's graded_runs together. This issue consumes that predicate rather than restating it, and that is why it is a blocker rather than a note: a stop landing first would re-draft healthy PRs fleet-wide, which is the hazard this bullet exists to avoid.

    Two consequences, both load-bearing:

    • Branch protection requires only final, which concludes cancelled too, so an unauthorised head cannot be merged while it waits.
    • Because graded_runs reads a cancelled set as ungraded, the stopped head's next land lap re-fires its ready and mints a real run, so the re-dispatch this would otherwise have to specify arrives with CLOUD-363's second half. It cannot loop: land acquires the lease before it pushes, so a land-driven push is never the one stopped.
  • Humans are unaffected by construction (§2). The "absent or released" row is what preserves them: a person pushing while no agent holds the lease sees no change. They wait only in the case where their run would have been voided anyway, which is a saving rather than a tax. fast-forward.yml is not touched.

  • Below it, for free (§3). Extend ready-guard — which already denies gh pr ready without verify and linear-check receipts — to require a lease.<branch> receipt, written by land-lock acquire into the same receipts directory. Catches the honest mistake at zero CI cost. A fail-fast convenience, not the guarantee: a hook can be unloaded (CLOUD-187) or bypassed, which is the whole reason the server-side check is the load-bearing half.

  • Above it (§3). A ci-local-parity property asserting that every pull_request job which checks the repository out carries the precondition as its first step, so a new job cannot silently omit it. The exemption is computable rather than enumerated: a job with no actions/checkout — final, the fan-in — has no work to protect.

  • Engine gap, per §2 (§3). That property is a predicate over workflow structure, which no rule kind can address, so it extends ci-local-parity instead of landing in batten.toml. The gap is CLOUD-452, linked rather than left as an exception.

Cost (§1), in the unit the invoice uses. This repository is private and every pull_request job is a GitHub-hosted ubuntu-latest runner, so the metered unit is a job-minute rounded up per job, not a second of wall clock. Measured p95 per job, 2026-08-12: ci 429s, darwin-link 97s, cross 82s, commit-lint 65s, msrv 60s, final 14s, action 12s — ~759s of runner time, which bills as 17 job-minutes per full run.

  • The tax on a legitimate run is ~2s in each of 7 jobs, which per-job rounding absorbs: 0 billed minutes, unless a job already sits within 2s of a minute boundary.
  • A stopped run still bills its floor — 7 jobs x 1 minute = 7 job-minutes against the 17 it replaces. That is ~59% of the invoice and ~86% of the wall clock: real, and smaller than a seconds-based reading implies.
  • The cheapest option for the residue case spends nothing at all, because a push to a PR left draft triggers no job: CLOUD-458. This issue is the backstop for what still leaks past that, so it must not be sized as though it carried the volume.

Local execution is the unmetered tier and nothing here moves work onto the metered one. The CI-side check exists because the local one is the half an interrupted session never reaches.

Test obligation

  • tests/land-lock.bats — the four rows above as a pure function of (lease body, branch), plus the two that must not be conflated: unreachable is not absent, and a missing branch: is not a foreign holder.
  • tests/land.bats — the composition rather than the predicate, which CLOUD-363 owns and tests: a head whose required runs were stopped by the precondition reads as ungraded, so the next lap re-fires the ready instead of re-drafting. Mutation-checked per CLOUD-418: with the stop step removed from the fixture workflow, that head grades normally.
  • tests/ci-local-parity.bats — both directions: a checkout-bearing pull_request job without the precondition is refused, and a job with no checkout is not.
  • tests/land-lock-check.bats — a lease body with no branch: line is reported.

Commit / bump (§6): feat(ci) — patch until 0.1.0 regardless of type.

Blockers (§8): blockedBy CLOUD-363 — the stop conclusion is safe only once a cancelled required check reads as no verdict rather than as red, which is in flight on #302. CLOUD-393 landed in #340, so the lease exists, and adding branch: to its body is this issue's own first step; CLOUD-436 landed in #347, supplying the latest-run-per-name grouping the declination sits on.

Acceptance

  • A PR pushed while another branch holds the lease stops within seconds instead of running a matrix.
  • A PR pushed while the lease is free, or while its own branch holds it, runs unchanged.
  • A stopped run neither reds the PR nor leaves its head unrecoverable: the branch's own next lap mints a real run without a hand-minted SHA.
  • A push to a PR left ready by an interrupted land is stopped by the same row as a hand-typed ready, with no landing attempt in progress.
  • An unreachable lease, or one carrying no branch:, runs the matrix rather than stopping the fleet.
  • A new checkout-bearing pull_request job cannot omit the precondition without failing ci-local-parity.
  • land-lock-check fails a lease body missing branch:.

CLOUD-393 Landing collisions cost ~2.9 CI runs per landed PR, and the recorded reason for not queueing answers a different question

Why

mem:workflow/agent-fanout records the position: "There is deliberately no cap on In Review: in a fast-forward trunk nothing queues there." That sentence is true and it is about the board column — how many landed-but-unreleased issues may sit in In Review. It is routinely read as also settling a different question: whether landing itself should be serialised. It does not settle that, and this issue exists so the two are not conflated again.

The measured cost of not queueing. A lap dies whenever main moves under it. With mean inter-land gap G = 487s and lap length L, a lap survives with probability ≈ e^(-L/G), so expected CI runs per landed PR is e^(L/G) — ~2.9 at today's L, matching the observed 8, 3, 4 and 2 laps. Roughly two of every three CI runs on the landing path are discarded work.

The same memory argues this is correct — "collisions are designed for rather than prevented, in the CSMA/CD sense", and "a rising re-verify rate is NOT the stop signal" — and that argument is sound as far as it goes. What it does not do is compare against the alternative, because no alternative was costed.

Two alternatives, both preserving exactly-tested-SHA fast-forward:

  • A land lock. An advisory lease — a ref like refs/land-lock, or a label — taken before ready+push and released on merge or on a timeout. The holder gets an uncontended lap; everyone else waits, and waiting is free. This converts a collision (one wasted CI run) into a queue (no CI run at all). It changes nothing about CI, nothing about the fast-forward, and needs no host feature. The risk is the classic one: a lease whose holder dies must expire, or landing stops for everyone — so the expiry is the whole design, not a detail.
  • GitHub merge queue. It builds a merge_group ref, runs CI on it, and lands that exact tested SHA linearly — so the "main takes the PR's exact, already-passed commits" invariant survives. It also batches N PRs into one CI run, which at this land rate would cut the bill further rather than raise it. The real costs are honest ones: a second required-check surface (merge_group must be a CI trigger), and a genuine departure from /fast-forward-by-comment as repository discipline — which batten.toml deliberately does not encode as a host constraint.

The honest test, and why this is Backlog rather than Todo. L is in the exponent, so shortening the lap raises p on its own. CLOUD-386 has already taken the shell suite from 248s to 72.73s; CLOUD-390 and CLOUD-392 would take more out of L. Re-measure p after those land before spending anything here. If p rises far enough, the recorded position is vindicated by measurement rather than by assertion, and this issue should be closed as declined — with the number written down. If it does not, the comparison above is what the decision needs.

Refinement — Ready

  • Source of truth (§1). Observed laps-per-landed-PR from land's own runs and the workflow run history, before and after the L-shortening issues land. Not a number a human types, and not a model's estimate.
  • Mechanism (§3). None proposed yet — deliberately. This issue's deliverable at Ready time is the re-measured p, and a decision recorded against it. Promoting it to Todo requires that number to exist.
  • Deliberately not in scope (§2). Anything that changes what CI proves or how main advances. Both options above are about who goes first, never about what is verified.
  • The invariant that must not move. main advances only to a SHA that CI graded green, by fast-forward. Any queueing design that re-writes commits after testing them — or that lands a merge commit — fails this and is out of scope regardless of its speed.

Test obligation

If a land lock ships: a lease that expires must be reclaimable, and a holder that dies must not wedge landing. Those two are the gate, and they are exactly the shape mise-tasks/target-ensure already solved for the rustup lock (CLOUD-220) — reuse that reasoning rather than reinventing it.

Commit / bump (§6): feat(land) — patch until 0.1.0 regardless of type.

Blockers (§8): none formally, but this should not be pulled before CLOUD-386, CLOUD-390 and CLOUD-392 have landed and p has been re-measured.

Acceptance

  • Laps-per-landed-PR is re-measured after the L-shortening work, and recorded.
  • The decision — queue, land-lock, or decline — is written against that number rather than against either the original CSMA/CD argument or this issue's counter-argument.
  • If declined, mem:workflow/agent-fanout gains the measured p so the question is settled by data next time it is raised.

Raised from Low to Urgent: the number this issue said it lacked now exists

This issue argued the case for queueing and recorded that the honest test was to shorten the lap first and see whether the case survived. It did not survive.

Measured 2026-08-11, 21:31–22:01Z (400 fast-forward.yml runs, 248 executed — full method and caveats in CLOUD-399):

quantity value
landing attempts 248 in 30 minutes
merges 5
per-attempt success rate ~2%
bot answer time median 12s, max 23s

The bot is not the bottleneck — it answered every one of 248 attempts inside 23 seconds. 243 of them were genuine sequoia-pgp/fast-forward refusals: the branch was behind by the time it asked. This is a thundering herd, and it is the exact cost of not queueing, paid in CI minutes instead of in waiting.

So the recorded position in mem:workflow/agent-fanout — "in a fast-forward trunk nothing queues there" — is falsified, not merely arguable. The queue exists today; it is just implemented as 243 wasted CI matrices per half hour instead of as a lease. Amending the memory in place is part of this change rather than a follow-up.

Mechanism chosen: the land lock, the cheap half of the two options in the body. GitHub merge queue stays out of scope — it is a larger departure from /fast-forward-by-comment as repository discipline, and the lease answers the measured problem without a host feature.

Refinement — Ready

  • Source of truth (§1). fast-forward.yml run history: the refusal:success ratio over a window, before and after. Not an estimate, and re-sampled rather than recalled.
  • Mechanism (§3). A lease on refs/land-lock at the origin. Claim is an atomic server-side test-and-set — a create-only push is rejected as non-fast-forward if the ref exists, so two sessions cannot both hold it. Release is a lease-guarded delete, so it cannot delete a lease someone else has since taken. Held from just before the push that starts CI through the merge, so the only CI matrix running for a landing attempt is the holder's. Deliberately not held across local verify — that is ~150s touching no shared resource.
  • Liveness (§3). Expiry as p95 × 3 in the grammar timeout-check already defines, refreshed by a heartbeat while the holder genuinely progresses, so the TTL can be short without expiring healthy holds. A holder re-checks the lease immediately before commenting /fast-forward — the cheap stand-in for a fencing token, and what makes a stolen lease safe rather than a double-comment.
  • Deliberately not in scope (§2). FIFO ordering — v1 is lease-plus-jittered-backoff, the CSMA/CD posture agent-fanout already argues for. Also excluded, and each for a stated reason: synthesising a red check on non-holders (red must mean broken, and land re-drafts on red, so it would re-draft healthy PRs fleet-wide); cancelling other PRs' in-flight runs (can cancel the legitimate holder, needs cross-PR permissions, and reverses CLOUD-240's deliberate "supersede your own runs, never cancel someone else's"); auto-redding a timed-out holder (it was slow, not broken — re-draft is the honest signal and already exists); and penalising a PR that goes red while holding (every mature queue evicts the failing change and proceeds rather than carrying a grudge; the lost turn is the penalty).
  • Output (§7). Pointer-only: holder ref and lease age, never a payload.

Test obligation

Two assertions the change rests on, both against a real remote rather than a fixture: two concurrent acquires — exactly one exits 0; and a holder whose lease was stolen reports not-held from its pre-comment re-check rather than proceeding. Plus a land case per exit path proving the lock is never leaked, including the rebase-conflict stop and the red-CI re-draft.

Commit / bump (§6): feat(land) — patch until 0.1.0 regardless of type.

Blockers (§8): none. relatedTo CLOUD-399, whose candidate #1 this implements.

Acceptance

  • Two concurrent acquires: exactly one wins, proven against the real origin.
  • The lock is released on every exit path, proven per path rather than by inspection.
  • Re-sampled refusal:success ratio is materially better than the 243:5 baseline — and reported, not assumed.
  • CI runs per landed PR trends toward 1.
  • checks-green and the final fan-in are untouched: the green predicate does not change.

Implementation record — four things the Ready block got wrong

All four were found by testing against the real remote rather than by reasoning, and each is recorded because the wrong version is written down above.

1. The lease is a BRANCH, not refs/land-lock. Two independent reasons, both measured:

  • The agent proxy answers 403 to a push at any ref outside refs/heads — so a custom namespace is unwritable from a session at all.
  • GitHub does not enforce the fast-forward rule off refs/heads. A PATCH of a parentless orphan with force: false was accepted on refs/batten/land-lock; the same request against a branch returned 422 Update is not a fast forward. So the atomicity the design depends on only exists on refs/heads.

The lease is refs/heads/batten-land-lock. branch-age-check needs no exemption: a lease lives minutes and heads no merged PR, and a leaked one showing up as stale is the gate working.

2. Renewal is CAS, but not the CAS the plan claimed. I asserted PATCH ... force:false would give compare-and-swap via the fast-forward rule. Measured: it does not, per (1). What does work is git push --force-with-lease=<ref>:<observed> — the correct expected value wins, a stale one is rejected with stale info. That is a true CAS and it is what renew, the heartbeat and the steal all use. Create stays a plain push (rejected as non-fast-forward if the ref exists), so acquire is still an atomic test-and-set.

3. Delete goes through the REST API, not a push. The proxy 403s a delete-push too — the same reason land's post-merge branch delete already fails and is tolerated. So release calls gh api -X DELETE. That asymmetry is environmental, not a design choice, and it is why every caller checks mine first: there is no lease guard available on that path.

4. The lease body needs a nonce. Git addresses objects by content, so two mints agreeing on holder and expiry — the same clone renewing twice inside one second — produce the same sha, and pushing a sha the ref already points at is an "up to date" no-op that reports success. That turns a rejected claim into an apparent win. Measured before the fix: a second acquire from the holder reported "acquired" rather than recognising its own lease, and a renew left the ref unmoved.

A defect in land that the lease exposed

land's CI race used a bare wait -n, which returns on the first job of any kind to exit. The lease heartbeat is also a job of that shell, so a heartbeat that ends — its lease lost, or a stub returning at once — was read as the race concluding, leaving both result files empty and every lap reporting "no verdict". It turned all of tests/land.bats into no-verdict laps. Fixed by naming the two contenders: wait -n "$ci_pid" "$main_pid". The bug was latent before this change and would have fired the first time any future background job was added.

Rolling TTL, and a second backstop

The TTL is 120s with a 30s heartbeat, not the static p95 x3 = 1030s the Ready block above specifies. A static TTL has to bound how long a hold might legitimately take — a guess, wrong in both directions: too low and a slow CI run is stolen from mid-flight, too high and a reclaimed VM blocks the fleet for the whole window. A rolling lease only has to bound how long until we notice a holder stopped beating, which is small, stable and independent of CI duration. Three missed beats before declaring death is the usual Raft/etcd margin. A dead holder now blocks landing for ~2 minutes rather than ~17.

A lap that loses the lease spends no CI, so it does not count against LAND_MAX_LAPS — that backstop exists to catch "main is moving faster than a lap takes", and counting waits would make a busy fleet exhaust it without ever attempting. But "never counted" is how a loop becomes unbounded with nothing that can fire, so waits get their own: LAND_LOCK_MAX_WAITS (8) consecutive whole waits lost reports the fleet saturated. Not a wall clock on a poll — each wait already ran its full duration — a count of turns lost, which is a real and diagnosable state.


Landed — #340, 0552c41, in one lap

Seven commits. Four of them fix defects in the lease itself, all found after it was first "done", and three of those were found by a gate refusing rather than by re-reading the design:

the lease CAS-only, rolling TTL, no delete, no REST API
land-lock-check the gate, because "stays well-formed" was prose
four reliability defects FETCH_HEAD cross-read, heartbeat fragility, fence margin, clock skew
identity + delete refspec commit-tree needs an identity; an empty mint was a delete
the test fix label the two waits instead of deducing which is which
the bare wait the one that mattered — see below

The last one is the finding worth carrying forward. land's CI race ended with a bare wait, which blocks on every background job of the shell. This change adds one that by design never exits: the lease heartbeat. So from the moment the lease shipped, land would reach green CI and then block forever. It sat there for five minutes with all eight checks green and the SHA landable, logging nothing.

CLOUD-383 had already named the shape as an intermittent hang when a group kill misses. The heartbeat made it certain. The lease, as first written, could never have landed itself — and that was invisible to every test, because reproducing it requires a never-exiting child, which is precisely what would hang the suite. It is gated structurally now: no bare wait may appear in the task.

It also explains attempts I had attributed to contention and to a moving main. The last several were not that.

What is enforced, and what is not. The lease serialises landing across clones. It does not serialise within one — acquire is re-entrant per clone by design, so two land processes in one checkout both hold it (CLOUD-428, filed after exactly that happened here: three concurrent lands, two heartbeats). And it is honoured only by land itself; an agent that readies by hand still spends a matrix (CLOUD-420). Neither is a defect in what shipped; both are the boundary of it.

Not yet measured: the refusal:success ratio against the 243:5 baseline. The fleet went quiet at 22:01Z, so there is no comparable window yet. That measurement is this issue's acceptance criterion and remains outstanding — it should be taken before this moves to Done, not inferred from the fact that #340 landed in one lap.

CLOUD-363 A `cancelled` required check is read as red, so a run superseded by `land`'s own ready/push race wedges the branch permanently

Why

Measured on PR #293 (CLOUD-346), 2026-08-11. land lap 1 readied the PR and then force-pushed, which is the deliberate order from CLOUD-254 — ready first, so the push's own event is the confirming run. Both events reached the same concurrency: ci-${{ github.ref }} group two seconds apart:

17:53:15  run 31519869739  head 3eba36a (pre-push SHA, the ready_for_review event)  -> kept running
17:53:17  run 31519873369  head a3589cc (the force-push event)                      -> CANCELLED

The run on the SHA that would land was the one cancelled; the run on the stale SHA survived. final then failed on a3589cc because its upstreams were cancelled, and ci-wait reported red:

::error:: CI is not green on a3589cc — final failure, msrv cancelled, ci cancelled,
          cross cancelled, darwin-link (aarch64-apple-darwin) cancelled, commit-lint cancelled.

The red is not a verdict. A cancelled run did not judge anything — it is the absence of an answer, exactly like the draft-era skipped that CLOUD-327 taught this repo not to read as a conclusion. But the predicate's catch-all buckets it with failure:

$2 == "skipped" { skipped = ...; next }
$2 == "success" || $2 == "neutral" { graded++; next }
{ graded++; bad = bad sep4 $3 " " $2; sep4 = ", " }   # <- cancelled lands here

And that wedges the branch, which is the part that makes this urgent. The two rules compose into a trap with no exit:

  1. ci-wait reads the cancelled set as red → land re-drafts the PR and stops (correct, given "red").
  2. Re-running land cannot recover. HEAD is unchanged, so the push is Everything up-to-date; the verify receipt still holds, so nothing re-proves; and land's ready block only fires when the SHA carries no graded run (CLOUD-255) — the cancelled runs are graded by graded_runs' reckoning, so the ready is skipped. Lap 1 goes straight back to the same stale reading and re-drafts again.

Observed exactly that: two consecutive land invocations, both terminating on the identical cancelled set, neither able to create a single new check-run. The only recovery available to an agent is to hand-mint a new SHA (git commit --amend), which is a manual step outside the loop land exists to drive.

Ready

cancelled is not an answer. Move it to the same bucket skipped occupies — one place, since CLOUD-346 collapsed the predicate to a single definition.

  1. mise-tasks/checks-green — the catch-all keeps failure/timed_out/action_required; cancelled joins skipped in the "not an answer" set (exit 3). Name it in the same pointer line, distinguishing the two words so a stall is diagnosable: required check(s) with no verdict: ci cancelled, cross skipped.
  2. mise-tasks/land — graded_runs (line ~129) must agree, or the compose-trap survives the first fix: a cancelled set must read as "no graded run", so the lap re-fires the ready and a real run replaces it. The two read CI_REQUIRED_CHECKS from one place already; they must read cancelled the same way too.
  3. Consider whether the race itself is worth closing separately — the ready and the push both landing in one concurrency group is what produces the cancellation, and CLOUD-254/CLOUD-255 have iterated on that ordering twice. This issue does not propose re-ordering them: making a cancellation recoverable is the smaller, more general fix, and it holds whatever cancels a run (a concurrent push, a manual cancel, a runner reclaim). Filed as a note, not a task.

Test obligation

  • tests/checks-green.bats — a required check cancelled is exit 3 and named; a set mixing cancelled and success is still exit 3, not green; failure stays exit 1.
  • tests/land.bats — a SHA whose required checks are all cancelled re-fires the ready on the next lap rather than re-drafting; the existing lap-count assertions must still hold.

Commit type / bump: fix(tasks). Patch until 0.1.0.

Blockers: none. It sits on top of CLOUD-346's checks-green, which is in flight on PR #293; if that has not landed, this edits ci-wait's awk in the same shape.

Acceptance

  • A branch whose required checks are all cancelled is driven to green by re-running land alone, with no hand-minted SHA and no gh run rerun.
  • failure is still red — a cancellation being recoverable must not make a real failure recoverable.

Review in Linear

@wenzowski
wenzowski marked this pull request as ready for review August 12, 2026 07:52
@wenzowski
wenzowski force-pushed the claude/landing-lease-optimization-hkmacd branch from 0969db8 to 251090a Compare August 12, 2026 07:52
@wenzowski
wenzowski marked this pull request as draft August 12, 2026 08:00
@wenzowski
wenzowski force-pushed the claude/landing-lease-optimization-hkmacd branch from 251090a to 088b471 Compare August 12, 2026 08:04
@wenzowski
wenzowski marked this pull request as ready for review August 12, 2026 08:06
claude added 2 commits August 12, 2026 08:19
CLOUD-393's rolling lease serialises landing, but it was enforced entirely
inside mise-tasks/land: anything else pushing to an already-ready PR bought a
full matrix without ever touching the lock. Measured 2026-08-12 05:17-05:19Z,
four concurrent pull_request matrices ran while the lease changed hands three
times, every session holding the lease honouring it. The dominant case is
residue rather than defiance — land re-drafts only on red, so a landing
interrupted any other way leaves the PR ready permanently.

So the runner asks too. A first step in every pull_request job that can start
immediately fetches mise-tasks/ci-lease-precondition from main, which fetches
land-lock from main and asks the one question a runner can ask: does the lease
authorise this branch? If not it cancels the run it is standing in. Read from
trunk rather than from the head being judged, which is what makes a stale clone
unable to dodge the predicate by carrying a stale copy of it.

The lease table cannot cover the case that motivated this. An agent running
tooling older than 6d3d534 takes no lease, so the ref reads absent and every row
authorises it — the lease is evidence of a free queue only among participants
that can see the queue. So the precondition also asks whether this head's own
mise-tasks/land acquires the lease, content-wise from one API read rather than
by ancestry, which needs a deep fetch and answers wrongly after a rebase or a
cherry-pick. A head that does not is stopped, and the message names the remedy:
fetch, rebase, land. That narrows 420's "humans are unaffected by construction"
promise to "humans on a branch newer than the lease" — the remedy is the one
rebase they need in order to land anyway, but it is a real change to that
guarantee rather than a side effect.

Three things the refinement did not have, each found by measurement rather than
reading, all detailed on CLOUD-420:

final does NOT conclude cancelled, and the obvious remedy for that is worse than
the problem. It declares always(), and an always() job runs even when the run is
cancelled — observed on run 31566043914, where ci was cancelled and final
executed anyway and failed its needs: assertion. So cancelling an unauthorised
run reds the one check the host requires, which reads as a reason to skip the
job instead. !cancelled() does skip it, and that is a false green: GitHub
documents that a job skipped by its own if: "will report its status as
'Success'. It will not prevent a pull request from merging, even if it is a
required check." Every lease-stopped run would then present a green required
check over a head where nothing was compiled, tested, linted or analysed, and a
maintainer's /fast-forward — whose stated safety property is that the exact SHA
already has the required checks green — would land it.

A false red costs a rebase; a false green lands untested code on main. success
is the only conclusion GitHub lets a job report as fine, so a run that was
DECLINED has to report something else, and red is the only something else a job
that ran can produce. always() therefore stays, now carrying 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. The misleading half of that red is answered
where it belongs: the precondition emits ::error:: annotations naming the lease
and the remedy.

Those annotations are load-bearing rather than decorative, and they were being
swallowed. The runner only reads a line as a workflow command when it begins
with :: after leading whitespace is trimmed (actions/runner,
ActionCommand.TryParseV2, line-anchored), and the stop message went through a
helper that prefixes "lease-precondition: " — putting the token at column 20,
where it is ordinary output. A stopped run would have been a cancelled run with
a red final, no failed step, and nothing anywhere saying why. Both stop paths
now emit at column 0 with the greppable prefix inside the annotation body, and
both are pinned by cases that check every occurrence rather than the first.

The exemption is needs:, not "has no checkout". final checks out too, so the
refinement's rule would have demanded a precondition it cannot use. A fan-in
cannot start before its dependencies are terminal, so it can never spend a
runner ahead of the cancellation — which is the reason, and reasons survive a
job being added where enumerations do not.

The repository is private and several checkouts set persist-credentials: false,
so the precondition carries its own credential through an http.extraheader —
never a userinfo URL, since land-lock prints its remote when it cannot reach it.
That is also what lets it run before any checkout exists, which is where it is
cheapest.

It never exits non-zero. A job that reds before its cancellation lands concludes
the run failure rather than cancelled, which reds final and re-drafts the PR:
the same failure mode arriving through its own remedy. Stopping is a cancel plus
a bounded wait to be killed, and every fail-open row is a plain exit 0 — an
unreadable script, an unreachable remote, a refused cancellation, an answer that
is neither run nor stop. The asymmetry is the design: a predicate that cannot
read itself would stop the whole fleet, where waving one matrix through costs
one matrix.

Two branch families are exempt, and not as a carve-out for bots. dependabot/*
and release-plz-* land through a /fast-forward comment posted by a workflow that
fires on workflow_run completed — which a CANCELLED run satisfies. It fires,
finds the checks not green, and stops; nothing retries. So cancelling those runs
would not save a matrix, it would defer one to the next rebase and add a stall
to a landing path that is unattended by design. They are also the population
least worth stopping: the release PR is a draft until the debounce readies it,
and the debounce fires on 30 minutes of quiet main, which is exactly when the
lease is free.

ci-local-parity gains property 7 as the sensor, so a job cannot be added without
one. Its fixture helper now emits the precondition by default — every existing
case rested on a job whose first step was its work, so without that the new
property would have reddened all of them for the wrong reason. Mutation-checked
in both files: neutering the staleness row reds three cases and no others,
reading exit 3 as run reds the acceptance case, exiting non-zero on the stop
path reds every stop row, and removing the step from the fixture reds property 7
alone.

The suite scrubs the ambient GITHUB_* environment, and that is not housekeeping.
Every LEASE_* input has a GITHUB_* fallback, CI sets those and a developer box
does not — so a case reaching "unset" by unsetting only the LEASE_ name behaves
differently under Actions. One did: `no run id means there is nothing to cancel`
went green locally and red in CI, where it fell through to GITHUB_RUN_ID and
asked to cancel the very run it was executing in. Caught on a draft, at no CI
cost beyond the run that found it, which is what drafts are for. The fallback is
real behaviour rather than a mistake — inside a job GITHUB_RUN_ID names exactly
the run a stop should cancel — so it is now pinned by its own case instead of
merely avoided. Swept the suites for the shape: this was the only one exposed;
tests/step-receipt.bats already scrubbed.

Property 7 earned itself during this change's own rebase. A `semver` job landed
on main while this was in flight, and the gate refused the merge until that job
carried the step too — which is the difference between a gate and a paragraph,
observed rather than argued.

The step vs gate-job arithmetic is also re-derived: a gate job per workflow taxes
a legitimate run 4 job-minutes and a stopped one 4, while the step taxes a
legitimate run ~0 and a stopped one 7, so the step wins while stopped runs are
fewer than 1.33x legitimate ones — not the 6:1 the refinement claimed. The gate
job needs no actions: write and no cancel API at all, so the switch stays cheap
if that ratio ever inverts.

Refs: CLOUD-420, CLOUD-393, CLOUD-363

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4tzyT3Q3hXo5QFEESENYP
…signal

CLOUD-426 fixed one half of this pair and its scope note named the rest: any
other case asserting an outcome via a real race has the same defect, and a sweep
for the shape belongs with the fix. This is that shape, in the sibling case, by
a different mechanism.

The case runs `timeout -k 1 5 "$LAND"` and asserted exit 124. The property it is
about is that land was STILL POLLING at 5s, and 124 is not that property. GNU
timeout returns 124 when the process dies from the TERM it sent, and 137 when -k
had to escalate to KILL because TERM was not serviced within the grace second.
land runs `set -m`, installs an EXIT trap and reaps two watcher process groups,
so how long that takes is a function of machine load — and the window was one
second. Observed inside `mise run verify` with four subagents contending for the
box: red there, then 5 of 5 green in isolation, which is exactly the evidence
that misleads.

Both codes are timeout saying the command did not finish, so both are the
property. This is not the widening CLOUD-426 refuses: a land that ends the lap
on its own exits with its own status, so neither code can appear, and the case
still reds when the poll is broken. Mutation-checked by making the poll break
instead of waiting — the assertion fails, naming itself.

Swept the suites for the same shape: this is the only instance. main-watch.bats
also asserts 137, but it sends the SIGKILL itself, so that outcome is decided by
the fixture rather than by scheduling.

It rides along with CLOUD-420 rather than taking its own branch because it is
what was blocking that branch's gate, and a separate landing for a four-line
test change would buy a full matrix to fix a test that costs nothing to run.

Refs: CLOUD-464, CLOUD-426, CLOUD-418

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4tzyT3Q3hXo5QFEESENYP
@wenzowski
wenzowski force-pushed the claude/landing-lease-optimization-hkmacd branch from 088b471 to 6dcd03b Compare August 12, 2026 08:19
@sonarqubecloud

Copy link
Copy Markdown

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 6dcd03b into main Aug 12, 2026
10 checks passed
@wenzowski
wenzowski deleted the claude/landing-lease-optimization-hkmacd branch August 12, 2026 08:25
wenzowski pushed a commit that referenced this pull request Aug 12, 2026
…once had

A bare `gh pr view` answers with the PR associated with this branch in ANY
state, so once a branch name has carried a merged PR every later landing on that
name binds the merged one. That is the default shape here rather than an edge
case: trunk-based development deletes the branch on merge (CLOUD-349) while the
session harness pins an agent to one branch name for its whole engagement, so
the second landing of any session recycles a merged name. This branch carries
eight PRs, seven of them merged.

Observed rather than theorised: after #366 merged, a new commit and a new PR
#368 on the same name produced `could not re-draft #366`. What kept that from
being worse is incidental and worth naming, because it is the reason this is a
fix and not a nicety — `redraft` runs before the wait loop and GitHub refuses to
re-draft a merged PR, so the run died before reaching the terminal-state read,
which treats MERGED as landed and exits 0. A bound-merged PR is one refactor
away from reporting a landing that never happened, and a false completion signal
is the one thing this repository exists to refuse.

`gh pr list --head "$branch" --state open` instead, so "no open PR" falls through
to the guard that already existed and names the fix. `branch` moves above the
resolution because the query needs it. `// empty` rather than a bare field read:
`--jq` prints the string "null" for a missing field, which is not empty and
would sail past the guard as a PR number — `land` would then drive a pull
request called "null" and block in the wait loop, which is a wedge rather than a
failure.

The stub had to learn to FILTER before either case could prove anything. First
attempt returned the same body whether or not `--state open` was passed, so
removing the flag left both cases green — a test that cannot see the defect it
names. The real endpoint filters, so the stub now keeps two bodies: what an
open-only query returns, and what an unfiltered one returns for a branch whose
older PRs merged. Mutation-checked afterwards, which is the only reason this
is stated as fact: dropping `--state open` reds both cases, and dropping
`// empty` reds the no-open-PR case alone (with a non-empty list the fallback is
not reachable, so the other case correctly stays green).

Both resolution cases are bounded with `timeout`. The `// empty` mutant does not
fail, it HANGS — `land` accepts "null" and waits forever — and an unbounded case
wedges the whole file the way CLOUD-434's leaked stubs did. The bound turns a
wedge into an ordinary failure the assertion can see.

Refs: CLOUD-465, CLOUD-349, CLOUD-418

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4tzyT3Q3hXo5QFEESENYP
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants