Repository navigation
fix(land): make the landing loop converge, and stop reading non-answers as answers - #372
Conversation
… branch `verify` is the authorization the whole workflow contract rests on, and on a stock macOS it could not pass: `timeout(1)` does not ship at all, and BSD `sed -i` reads the next argument as a backup suffix. CI is ubuntu, so CI is structurally blind to the entire class — an agent on a Mac either stopped on a red `verify` no branch caused, or landed on CI's word alone. This takes only the LANDING-LOOP half of that work, from `wenzowski/cloud-282-verify-cannot-run-on-macos`: `tests/helpers.bash` and its own suite, plus the call sites in the five suites this series is about to rewrite. The rest of that branch — `crates/**`, `doctor`, `run-shape-guard`, `checksums` — stays with #330, which keeps its own PR. Absorbed here rather than rebased later because the next commits rewrite every wall-clock assertion in `tests/land.bats`, and resolving that collision once, up front, is cheaper than resolving it per hunk afterwards. Two hunks needed a hand resolution rather than the patch's own, both in `tests/land.bats`, and both because `main` moved after that branch forked: * setup() — CLOUD-434's fd-3-closing wrapper around the program under test is kept, with `load helpers` added beside it. The branch's version predates the wrapper and would have dropped it, reopening the leak that wedged the whole gate green. * the silent-bot case — CLOUD-464's `124 OR 137` assertion is kept, with the call switched to `run_timeout`. The branch reverts to 124 alone, which is the flake CLOUD-464 diagnosed: which code `timeout` returns is machine load, not behaviour. Neither side alone was correct, which is the shape this whole series is about. Refs: CLOUD-282 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
CLOUD-399 `land` cannot converge at current fleet cadence: 16 laps, 5 `/fast-forward` comments, every one lost the race to a moving `main`
Why Measured 2026-08-11 landing #306 (a one-file The cause was measured, and it is not latency. Over every
The bot answered all 248 attempts within 23 seconds. 248 attempts in 30 minutes producing 5 merges is a thundering herd, ~2% success per attempt — not a slow bot. That retires candidate #2 (instrument bot latency and wait on the merge): the quantity it proposed to measure is 12s. Candidate #1 shipped. The landing queue is CLOUD-393's rolling lease, landed in #340. Re-sampled 2026-08-12: the baseline holds (245 failure : 6 success over the same window), and lease turns are honoured by every session that takes one. What remains is candidate #3, plus a cost inversion the lease exposed.
Refinement — Ready
Test obligation
Commit / bump (§6): Blockers (§8): none. CLOUD-393 landed in #340 and supplied the queue this issue's diagnosis called for. Acceptance
CLOUD-393 Landing collisions cost ~2.9 CI runs per landed PR, and the recorded reason for not queueing answers a different question
Why
The measured cost of not queueing. A lap dies whenever 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:
The honest test, and why this is Backlog rather than Todo. Refinement — Ready
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 Commit / bump (§6): Blockers (§8): none formally, but this should not be pulled before CLOUD-386, CLOUD-390 and CLOUD-392 have landed and Acceptance
Raised from Low to Urgent: the number this issue said it lacked now existsThis 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
The bot is not the bottleneck — it answered every one of 248 attempts inside 23 seconds. 243 of them were genuine So the recorded position in 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 Refinement — Ready
Test obligationTwo 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 Commit / bump (§6): Blockers (§8): none. Acceptance
Implementation record — four things the Ready block got wrongAll 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
The lease is 2. Renewal is CAS, but not the CAS the plan claimed. I asserted 3. Delete goes through the REST API, not a push. The proxy 403s a delete-push too — the same reason 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 A defect in
|
| 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-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 island-lock/holder:/expires:/nonce:— read server-side by the workflow that is about to spend the runner. This issue adds a fifth line,branch:, andland-lock-checkgains 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 skippinglandnever writes. -
Mechanism (§3).
land-lockgains a read-only verb,authorises <branch>— a pure function of (lease state, branch) reusing the existingobserve/expired/releasedpredicates. 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". Exit0run /3stop /2could not look.Each expensive
pull_requestjob gains a first step, before the real checkout: a one-file sparse checkout ofmise-tasks/land-lockatmain(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
preflightjob (§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-levelif:can, which is the preflight job ruled out above. So the reachable conclusions are success, failure and cancelled, andchecks-greenreads 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
mainadvances to exactly the SHA that passed. Disqualifying. - Failure re-drafts healthy PRs fleet-wide, since
landre-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: writeon that job alone). Readingcancelledas 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 intoskipped's not-an-answer bucket inchecks-greenand inland'sgraded_runstogether. 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_runsreads a cancelled set as ungraded, the stopped head's nextlandlap 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:landacquires the lease before it pushes, so a land-driven push is never the one stopped.
- Success or neutral grades a head green that compiled nothing, and
-
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.ymlis not touched. -
Below it, for free (§3). Extend
ready-guard— which already deniesgh pr readywithout verify and linear-check receipts — to require alease.<branch>receipt, written byland-lock acquireinto 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-parityproperty asserting that everypull_requestjob 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 noactions/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-parityinstead of landing inbatten.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 missingbranch: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-bearingpull_requestjob without the precondition is refused, and a job with no checkout is not.tests/land-lock-check.bats— a lease body with nobranch: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
landis 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_requestjob cannot omit the precondition without failingci-local-parity. land-lock-checkfails a lease body missingbranch:.
CLOUD-467 `graded_runs` converts "could not compute the required set" into "nothing is graded", which is the answer that spends a matrix
Why
mise-tasks/land's graded_runs reads the roster and, on any failure, answers zero:
n=$(gh api … | awk -F'\t' -v req="${CI_REQUIRED_CHECKS:?}" ' … ') || n=0
echo "${n:-0}"${CI_REQUIRED_CHECKS:?} aborts the command substitution when the roster is unset, and || n=0 then converts that abort into 0. Both call sites read 0 as "this head carries no graded run", which is the branch that fires the ready that starts CI (:592, :622). So the one input land cannot compute silently becomes the answer that spends a full matrix.
Observed 2026-08-12 while running tests/land.bats outside mise (which is what supplies [env]):
mise-tasks/land: line 361: CI_REQUIRED_CHECKS: parameter null or not set
land: lap 1 — readied #150 before pushing, so the push's own event is the one confirming run
The error is printed and the lap proceeds to ready the PR anyway.
Its own sibling already gets this right. mise-tasks/checks-green:86-88 reads the same variable and guards it explicitly, failing closed with a message that names the fix:
CI_REQUIRED_CHECKS is unset — run this through `mise run checks-green`, which is where the required set is declared.
These two are deliberately paired — land's comment at :300 says "Both read $CI_REQUIRED_CHECKS from mise.toml [env]; a second copy that…" — so having opposite behaviour on a missing roster is a drift the pairing exists to prevent. It is also the exact shape .claude/rules/toolchain.md legislates against: "a gate step that cannot run must exit non-zero and leave no receipt."
Severity is low in practice and that is the point. land always runs under mise run land, so the roster is always set; nothing is broken today. What is wrong is that the failure direction is unsafe by construction, and the guard that would make it safe already exists eight lines away in the file it is paired with.
Refinement — Ready
- Source of truth (§1).
$CI_REQUIRED_CHECKSfrommise.toml [env], the single declaration both readers share. - Mechanism (§3). Guard the roster before the
ghcall, the waychecks-greendoes — unset or empty exits non-zero namingmise run land, rather than falling through ton=0. Keep|| n=0for thegh/awkfailure it was written for: a transient API failure answering "ungraded" is the choice that makes progress, and stopping the loop on it would be worse. - Deliberately not in scope (§2). Changing what
0means at either call site, and changingchecks-green. The defect is one reader's handling of a missing input, not the protocol between them. - Not a rewrite (§2). Two lines: a guard, and leaving the existing fallback to cover only what it was for.
Test obligation
Mutation-checked per CLOUD-418: with the guard removed and CI_REQUIRED_CHECKS unset, the case must fail — i.e. the suite must exercise the unset roster explicitly rather than relying on mise always supplying it. tests/land.bats currently cannot see this at all, which is why it surfaced only when the suite was run the wrong way.
Commit / bump (§6): fix(land) — patch until 0.1.0 regardless of type.
Blockers (§8): none.
Acceptance
landwithCI_REQUIRED_CHECKSunset exits non-zero and readies nothing.- A failing
gh apistill answers ungraded, so the loop still makes progress. - A case pins the unset roster, and it fails when the guard is removed.
CLOUD-376 The conclusion list is hand-maintained in two tasks, kept in agreement by comment — the shape that caused CLOUD-363
Why
CLOUD-363 was caused by two awk programs disagreeing about one word. mise-tasks/land's graded_runs counted cancelled as an answer; mise-tasks/checks-green read it as red. The two composed into a branch wedge with no exit, and escaping it took a hand-minted SHA.
PR #302 fixes the values — correctly, and with the fan-in ordering the measured set needed. It does not fix the shape. After it lands there are still two hand-maintained conclusion lists, one per task:
mise-tasks/checks-green $2 == "skipped" || $2 == "cancelled" -> no answer
$2 == "success" || $2 == "neutral" -> green
catch-all -> red
mise-tasks/land $1 == "success" || $1 == "neutral" || $1 == "failure" ||
$1 == "timed_out" || $1 == "action_required" -> answered
What keeps them in agreement is a paragraph of comment in each file explaining that the other exists. That is precisely the guarantee that failed: neutral was missing from land's list until #302 added it, and nothing detected the gap — it was found by a human reading both. The next conclusion GitHub adds, or the next edit to one file, restores the defect. The repo already has the answer for this exact problem: CI_REQUIRED_CHECKS is one string in mise.toml [env] because two copies of the required set drifted (CLOUD-327). The conclusion list has the same property and none of the protection.
Refinement — Ready (§1, §2, §5, §6, §7, §8)
- Source of truth (§1).
CI_ANSWERED_CONCLUSIONSinmise.toml [env], besideCI_REQUIRED_CHECKSand for the same reason: the set of conclusions that constitute an answer, written once, read by both tasks, restated by neither.cancelledandskippedare absent from it, and their absence is then a single visible fact rather than an inference across two files. - Mechanism (§2).
CI_ANSWERED_CONCLUSIONS = "success,neutral,failure,timed_out,action_required".mise-tasks/checks-green— membership decides answered vs "no verdict"; green stays thesuccess|neutralsubset, red stays answered-and-not-green. The catch-all disappears, which is what makes the next unknown conclusion fail safe: not in the set means "no answer" and the poll continues, instead of a value nobody has seen being reported red.mise-tasks/land—graded_runsreads the variable instead of its inline list.- Unset is fatal in both, exactly as an unset
CI_REQUIRED_CHECKSalready is: an empty set makes every conclusion an answer, which is the false green in a new spelling.
- Output & exit contract (§5). No change.
checks-greenkeeps0/1/2/3and its pointer line; this moves where the list lives, not what the tasks say. - Commit / bump (§6). Lands as
refactor→ no bump. - Test obligation (§7).
tests/checks-green.bats— an unknown conclusion (stale, say) is exit 3, not red. This is the case neither task can express today, and the one that proves the catch-all is gone.- A case asserting no conclusion name appears in
mise-tasks/outside the manifest — the sensor on the property, in the shapeci-local-parityalready uses forCI_REQUIRED_CHECKS. Without it this is a refactor that silently un-refactors. tests/land.batsand the rest oftests/checks-green.batsmust pass unchanged: the behaviour is identical, which is the whole claim.
- Blockers (§8): none. It edits the lines PR fix(tasks): read a cancelled required check as no verdict, not as red #302 is changing, so it wants to land after it — a sequencing preference, not a dependency the board should carry.
Acceptance
- A conclusion name appears in exactly one tracked file.
- An unknown conclusion holds the poll open rather than reporting red.
- Every existing case in
tests/checks-green.bats,tests/ci-wait.batsandtests/land.batspasses with no edit.
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 hereAnd that wedges the branch, which is the part that makes this urgent. The two rules compose into a trap with no exit:
ci-waitreads the cancelled set as red →landre-drafts the PR and stops (correct, given "red").- Re-running
landcannot recover. HEAD is unchanged, so the push isEverything up-to-date; the verify receipt still holds, so nothing re-proves; andland's ready block only fires when the SHA carries no graded run (CLOUD-255) — the cancelled runs are graded bygraded_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.
mise-tasks/checks-green— the catch-all keepsfailure/timed_out/action_required;cancelledjoinsskippedin 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.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 readCI_REQUIRED_CHECKSfrom one place already; they must readcancelledthe same way too.- 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 checkcancelledis exit 3 and named; a set mixingcancelledandsuccessis still exit 3, not green;failurestays exit 1.tests/land.bats— a SHA whose required checks are allcancelledre-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
cancelledis driven to green by re-runninglandalone, with no hand-minted SHA and nogh run rerun. failureis still red — a cancellation being recoverable must not make a real failure recoverable.
CLOUD-470 A lease-stopped run reaches the agent as "verify and CI disagree", so a stale agent is told to debug instead of to rebase
Why
CLOUD-420's precondition stops an unauthorised run by cancelling it. final runs under always() and fails its needs: assertion, so the head reads red — which is correct for branch protection (a declined run must not present as green) but wrong for the agent reading land's output.
land then prints its generic red-CI stop:
::error:: land: CI is red on <sha>. A red run on a verified branch means verify and
CI disagree — fix the mismatch locally, then run land again.
Nothing was mismatched and there is nothing to fix locally. The actual remedy is a rebase, and it is stated only in an ::error:: annotation on the run — a surface the agent is not reading, because it is reading land's output in its own terminal.
This lands on precisely the population that cannot afford a wrong instruction. Measured 2026-08-12, 11 of 13 open PRs carry a mise-tasks/land predating the lease, so every one of those agents, on restart, hits a cancelled run and is told to go debug a disagreement that does not exist. The rule is deliberate — a stale agent takes no lease and must rebase onto current main before it can be serialised against the fleet, and each agent resolves its own conflicts — but the instruction it receives points away from that.
The design already anticipated the diagnosis problem and solved half of it: the annotation exists, at column 0, mutation-checked, precisely so a cancelled run says why. What it did not do is carry the same answer back through the channel land speaks on.
Refinement — Ready
- Source of truth (§1).
land-lock authorises "$branch"— the same predicate the runner consults, run locally wherelandalready hasland-lockand a remote. It answers0run /3stop /2could not look, so "was this run declined rather than failed" is a question the existing verb already answers. - Mechanism (§3). On the red-CI stop path only, ask
authorisesbefore choosing the message. Exit3— or a head whose ownlanddoes not acquire the lease — prints the rebase remedy (git fetch origin main && git rebase origin/main && mise run land) instead of the verify/CI-disagree text. Anything else keeps today's message unchanged. One local read on a path that is already stopping, so it costs nothing on the hot loop. - Deliberately not in scope (§2). Re-drafting behaviour, which is correct either way — the tap should close. Also not in scope: making the stopped run anything other than red.
successis the only conclusion GitHub lets a job report as fine, so a declined run must report something else, and red is the only something else a job that ran can produce. - Not a second predicate (§2). If this grows its own opinion about what "declined" means, there are two authorities for one fact and only the newer one decides — the CLOUD-351 shape. It calls
land-lock authorisesor it is wrong. - The weaker alternative, and why not (§2). Reading the run's annotations back over the API would work, but it makes
land's message depend on a network read of text the runner formatted, rather than on the predicate both ends already share.
Test obligation
Mutation-checked per CLOUD-418, in tests/land.bats with the existing gh stub: a red head whose lease says stop must print the rebase remedy and not the disagreement text; a red head whose lease authorises it must print the disagreement text and not the remedy. Removing the authorises call must red the first. The pair is the point — a change that always printed the remedy would pass a single-case test and would misdirect every genuine CI failure.
Commit / bump (§6): fix(land) — patch until 0.1.0 regardless of type.
Blockers (§8): none. CLOUD-420's precondition and land-lock authorises are both on main.
Acceptance
- A stale agent whose run was cancelled by the lease is told to rebase, in
land's own output. - An agent whose CI genuinely failed still gets today's message, unchanged.
- Neither message requires reading the GitHub run page to act on.
CLOUD-456 `land` reads the fast-forward verdict from an unfiltered, ~90s-deep run list, so a sibling's refusal can be read as its own
Why
Recorded during CLOUD-399's measurement and never filed — that issue's body said "filed separately rather than folded in here", and no such issue existed. Filing it now so the finding survives 399's re-scoping.
mise-tasks/land reads the fast-forward verdict from:
actions/workflows/fast-forward.yml/runs?event=issue_comment&per_page=20
| select(.created_at >= env.SINCE)
| select(.status == "completed")
| select(.conclusion != "success" and .conclusion != "skipped")
There is no PR filter, and there cannot easily be one: an issue_comment run attaches to the default-branch tip and carries no branch of ours, which is the same fact CLOUD-235 established when it fixed the check-run lookup. The task's own comment acknowledges this and prices it as "a concurrent refusal on the same PR would be read as ours" — true when the fleet was quiet, and false at the cadence measured on 2026-08-11: 243 refusals in 30 minutes, any of which a lap could read as its verdict.
The per_page=20 bound has the same shape. At the measured ~13 runs/minute it is ~90 seconds of history, and ~38% of those slots are skipped runs the filter then discards — a generous window at the old cadence, a marginal one at the new.
Both are conservative in the intended direction: a falsely-read refusal makes a lap re-rebase and retry, it never lands anything. So this is a diagnosis hazard rather than a correctness one — and plausibly how CLOUD-399's original "the bot is silent/slow" reading arose, since a lap attributing a stranger's refusal to itself reports a refusal nobody gave it. CLOUD-414 is the sibling shape (a 403 body read as a refusal).
Blast radius shrank but did not close. With CLOUD-393's lease landed, agent landings serialise, so concurrent issue_comment runs are rarer. fast-forward.yml still serves humans and the release-PR automation, neither of which takes the lease, so the ambiguity remains on exactly the paths the lease deliberately exempts.
Open questions blocking Ready
- What makes a verdict PR-scoped? Candidates, none costed: read the bot's reply on the PR (PR-scoped by construction, but depends on the bot commenting rather than only concluding); correlate the run's
head_shaagainstmain's tip at comment time (weak — everyissue_commentrun shares it); or havefast-forward.ymlstamp the PR number somewhere a run listing exposes, which changes a workflow this repo deliberately does not modify (CLOUD-393's header). - Is a window bound the right second half, or does a PR-scoped read make
per_pagemoot? If the verdict becomes PR-scoped, depth stops mattering; if it cannot, the bound needs deriving from the measured run rate rather than a literal 20.
Commit / bump (§6): fix(land) — patch until 0.1.0 regardless of type.
Blockers (§8): none.
Acceptance
- A lap's refusal verdict is attributable to its own PR, or the issue is closed with a recorded reason why it cannot be.
- The history depth is derived from the measured run rate, not a literal.
- A concurrent refusal on another PR does not end this lap.
CLOUD-409 `land` reads the fast-forward verdict from an unfiltered, 20-run window, so at fleet cadence a sibling's refusal is indistinguishable from its own
Why
mise-tasks/land:404-409 decides "was my /fast-forward refused" from:
actions/workflows/fast-forward.yml/runs?event=issue_comment&per_page=20
| select(.created_at >= env.SINCE)
| select(.status == "completed")
| select(.conclusion != "success" and .conclusion != "skipped")
| .conclusion] | first // empty
There is no PR filter. An issue_comment run carries no branch of ours — its head_branch is the default branch — so the only discriminator is the SINCE timestamp, and the code comment says so explicitly, pricing the residual risk as "a concurrent refusal on the same PR would be read as ours; that is the conservative direction."
That pricing was correct when the fleet was quiet. It is not the current world.
Measured 2026-08-11, 21:31:27Z → 22:01:34Z (400 fast-forward.yml runs, the full 4 pages the API would return — at this rate 400 runs is only 30 minutes):
| quantity | value |
|---|---|
| runs created | 400 in 30 min (~13/min) |
| executed (non-skipped) | 248 |
failure conclusions |
243 |
success |
5 |
skipped by the author_association gate |
152 (38%) |
So the window is not "a concurrent refusal on the same PR" — it is 243 refusals across every PR in the fleet. Any lap that polls after commenting will, with near-certainty, find a stranger's refusal inside its SINCE window and read it as its own verdict.
The per_page=20 bound has the same shape. At ~13 runs/minute, 20 runs is ~90 seconds of history, and ~38% of those slots are consumed by skipped runs the filter then discards — so the effective window is nearer 12 usable runs, under a minute. A bound that was generous at the old cadence is marginal now, and it degrades silently: the symptom is a wrong verdict, not an error.
What this does and does not break. Both defects are conservative in the direction the comment intends — a false refusal makes land lap, it never makes it misland, and main is never advanced to an ungraded SHA. So this is not a correctness hazard for the trunk.
It is a diagnosis hazard, and an expensive one. The logs stop being trustworthy about which lap lost and why, which is how a lap that was refused normally can read as something else entirely. CLOUD-399 inferred a slow-or-silent bot from exactly this class of evidence; the bot was in fact answering every attempt within 23 seconds. A loop that cannot correctly attribute its own verdict cannot be debugged from its own output, and every future contention investigation pays that cost again.
Refinement — Ready
- Source of truth (§1). The
fast-forward.ymlrun history, filtered to the run the lap's own comment triggered. The comment's own id is the discriminator that exists and is unused:ghreturns it when the comment is posted (landalready captures the comment URL), and a run'striggeringpayload identifies the issue it fired for. - Mechanism (§3). Two independent fixes, both small:
- filter candidate runs to this PR — the run's issue number, not merely its timestamp;
- raise
per_pageso the window is bounded bySINCErather than silently by page size, or page until the oldest run in hand predatesSINCE. The second is strictly correct and is the one to prefer: it makes the bound the thing the filter already claims it is.
- Deliberately not in scope (§2). The contention itself — that is CLOUD-393's land lock. This issue makes the verdict attributable; it does not make the lap win. Worth landing regardless of the lock, because the lock's own verification depends on trustworthy refusal logs.
- Output (§7). Pointer-only, unchanged: a conclusion string, never run contents.
Test obligation
tests/land.bats already covers the refusal path. It gains the case this defect is: a fixture whose run list contains a foreign refusal newer than SINCE alongside no verdict for this PR must not be read as a refusal. That is the assertion that fails today.
Commit / bump (§6): fix(land) — patch until 0.1.0 regardless of type.
Blockers (§8): none. relatedTo CLOUD-399 (whose inferred cause this helps explain) and CLOUD-393 (whose verification depends on it).
Acceptance
- A foreign refusal inside the
SINCEwindow is not read as this lap's verdict, proven by a test that fails before the change. - The candidate window is bounded by
SINCE, not by page size — demonstrated at a run rate above one page. land's refusal path still ends the lap promptly when the refusal is its own: this must not reintroduce the CLOUD-235 hang.
CLOUD-408 `land` reports a `/fast-forward` it never posted: the addComment rate-limit failure is swallowed
Why
Measured on PR #330 (CLOUD-282), 2026-08-11 ~22:05Z. land printed its success line:
GraphQL: was submitted too quickly (addComment)
land: lap 1 — commented /fast-forward on #330 (496c574a); waiting for the merge
The error is GitHub's secondary rate limit on comment creation, and the comment was never created — gh pr view 330 --json comments shows /fast-forward at 21:18, 21:33 and 21:39 and nothing after, while the run above posted at ~22:05. land then blocked waiting for a merge that nothing had been asked to perform.
This is the exact shape the repo already guards against everywhere else. run-shape-guard exists because a verdict-bearing command's exit status must be read, never inferred; .claude/rules/toolchain.md's "Task bodies do not run under set -e — fail closed by hand" names three prior instances (linear-check's fetch, lock-check's mise lock, verify's own body). This is the fourth, in the one task whose whole job is to drive the lifecycle: the gh pr comment result is not checked, so a failed post is indistinguishable from a successful one.
Why it is not merely slow. The lap is now waiting on ci-wait × main-watch for a merge that cannot happen. It self-heals only by accident — when main moves, main-watch ends the lap and the next lap re-comments. So the cost is one wasted lap per occurrence, and under the contention CLOUD-282 measured (a lap already near or past the ceiling) a wasted lap is expensive. Worse, if main were quiet the lap would block indefinitely on a directive nobody received.
Not the same as the bot being silent. mem:workflow/agent-fanout records a distinct shape — comment posted, bot slower than main. This one is upstream of that: the comment does not exist. The two are told apart by reading the PR's comments, which is what confirmed this instance.
Refinement — Ready (a /fast-forward land did not post is a failed lap, not a wait)
- Source of truth (§1).
mise-tasks/land, at thegh pr commentcall site. No caller changes. - The mechanism is a computable predicate (§2).
tests/land.bats, already in the hkgateviatest:batsand carrying the count assertion that keeps every lap-ending path exercised. - Output & exit contract (§5). A failed post must not print the "commented … waiting for the merge" line. It ends the lap the way a refusal does —
landlaps rather than stopping, since a rate limit is transient by construction and the next lap's push is free. Retrying the post inside one lap is the wrong shape: it is bespoke retry logic against a secondary limit whose whole remedy is to back off. - Commit / bump (§6).
fix→ patch until 0.1.0. - Test obligation (§7). One case: a stubbed
ghwhosepr commentexits non-zero produces no success line and ends the lap, versus the existing green path. The stub already exists in the suite. - Blockers (§8). None.
relatedToCLOUD-383, which is the other open change to this file — land them in either order, but expect a rebase.
Done
- A
gh pr commentthat fails is never reported as a posted/fast-forward. - That lap ends and the loop laps, rather than blocking on a directive nobody received.
tests/land.batscovers the failed-post path, and its lap-path count assertion is updated.mise run verifygreen; landed onmainby fast-forward, CI-confirmed green.
CLOUD-413 land treats a rate-limited fast-forward bot as "main moved" and laps, spending a CI run per lap against a bot that cannot merge
Measured, not hypothesised. Landing CLOUD-378 (PR #323) took 24 laps across three mise run land invocations and never merged. No lap failed for any of the three reasons land is documented to stop on — not one rebase conflict, not one failed verify, not one red CI. Every lap that reached the fast-forward ended one of two ways:
land: lap 7 — main moved under 04d8ef9e while the bot was still silent, so the
fast-forward can only be refused. Lapping: rebase, re-verify, retry.
land: lap 5 — the fast-forward bot refused ({
"message": "API rate limit exceeded for user ID 597920. ...",
"documentation_url": ".../rate-limiting",
"status": "403"
}); main moved under the branch. Lapping: rebase, re-verify, retry.
The second shape is the defect. A 403 rate-limit refusal is not "main moved", and land cannot tell them apart — it prints both, then laps identically. Each lap then costs a full verify plus a CI run plus another /fast-forward comment, against a bot that is structurally incapable of merging until its limit resets.
The session's own comments were being throttled too, which is the same failure one level up:
GraphQL: was submitted too quickly (addComment)
So the loop's response to being rate-limited is to generate more of exactly the request that is rate-limited.
This is the one thing mem:github-rest-etiquette names in as many words. That memory's status table says 4xx/5xx → "fix the interaction, do not retry blindly", and its rate-limit section says repeated secondary-limit failures get exponential backoff and must "fail after a bounded number of retries rather than looping forever", explicitly calling that "the one place a retry cap belongs". land is that place and does not do it.
Refinement — Ready (scoped to land's fast-forward outcome classification)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- Source of truth (§1).
mem:github-rest-etiquette— already the repo's authority on how to behave on a 403/429. Nothing new is decided here;landis brought into line with it. - Computable predicate (§2). The bot's response is classified before the lap decision, and a rate-limited refusal is its own outcome, distinct from
mainhaving moved:retry-afterpresent → wait exactly that many seconds, then retry the fast-forward without re-runningverifyor spending a CI run (the SHA is unchanged and already carries a receipt —landalready has this "HEAD already carries a verify receipt; not re-proving it" path, observed on laps 6 and 7).x-ratelimit-remaining: 0→ wait untilx-ratelimit-reset.- neither header → wait at least 60s.
- bounded retries, then stop — a fourth stop condition beside rebase conflict, failed
verifyand red CI, with a message naming the limit and the reset time rather than blamingmain.
- Effect (§3). Unchanged:
landis a write task. - Generated artifacts (§4). None.
- Output & exit (§5). Pointer-only, as today: the status code and the reset timestamp, never the response body's prose.
- Commit / bump (§6).
fix→ patch. - Test obligation (§7).
tests/land.bats, whose existing harness already stubs the bot: (a) a403rate-limit refusal does not consume a lap and does not re-runverify; (b) it waits theretry-after/x-ratelimit-resetthe response names; (c) after the bounded retry count it exits non-zero with a message naming the limit, not "main is moving faster than a lap takes"; (d) a genuinemain-moved refusal still laps exactly as it does today, with the existing count assertion unchanged. - Blockers (§8). None.
relatedToCLOUD-378 (the change whose landing produced this measurement).
Second, smaller finding from the same run, deliberately not folded in. land's exhaustion message is still not linear after 8 laps; main is moving faster than a lap takes. Over these 24 laps that diagnosis was wrong twice over: 7 of 8 laps in run two reached green CI, and several refusals were the rate limit rather than main at all. The message should report which outcome actually ended each lap. It is one line and belongs with the classification above rather than as its own issue.
Refs: CLOUD-378
CLOUD-414 `land` reads a 403 rate-limit body as the fast-forward bot's refusal, burning a lap on an answer nobody gave
Why
Measured on PR #302 (CLOUD-363), 2026-08-11 23:34:55 UTC, laps 3 and 4 of a
land run. The task announced a refusal whose "conclusion" was an HTTP error
document:
land: lap 3 — the fast-forward bot refused ({
"message": "API rate limit exceeded for user ID 597920. …",
"documentation_url": "https://docs.github.com/en/rest/using-the-rest-api/…#rate-limiting",
"status": "403"
}); main moved under the branch. Lapping: rebase, re-verify, retry.
The refusal poll is:
refused=$(gh api "repos/{owner}/{repo}/actions/workflows/$workflow/runs?event=issue_comment&per_page=20" \
--jq '[.workflow_runs[]? | … | .conclusion] | first // empty' 2>/dev/null)
[ -z "$refused" ] || breakOn a 403 there are no workflow_runs, so the filter should yield empty and the
poll should continue. It does not: gh writes the error body to stdout, the
--jq program fails on it, and the unfiltered body reaches $refused. The test
is [ -z "$refused" ] — any non-empty string is a refusal — so a transport**
**error is indistinguishable from a verdict, and the one place that distinction
is load-bearing is the one place it is not made.
2>/dev/null is what hides it. The intent was "a transient gh failure costs
one iteration rather than aborting the landing" (the task's own header), but
discarding stderr while trusting stdout inverts that: the failure costs a whole
LAP — a rebase, a verify, and a CI run — rather than one poll iteration.
This is the CLOUD-235 shape with the sign flipped. That defect was a refusal
condition that could never fire; this is one that fires when nothing was refused.
Both come from reading a verdict out of a shape that cannot carry one.
Cost, measured. Laps 3 and 4 both ended on this, ~6 minutes each. Lap 4 is
the instructive one: it re-fired the ready on an unchanged head (correctly —
that is CLOUD-363's fix working) and bought a CI run to answer a question the
bot had never been asked, because lap 3 ended on a rate-limit body rather than
on the bot's silence.
The 403 itself was a secondary limit, transient and recovered (core showed
4624/5000 remaining twenty minutes later). Nothing needs throttling; the poll
needs to tell "no answer" from "an answer I could not read".
Ready
gh api's exit status is the discriminator, and it is already available —
it is simply not read, because the assignment's status is discarded by the
command substitution's success.
- Capture the status explicitly and treat a non-zero
ghas "no answer yet":
continue the poll rather than breaking out of it. A failed call must leave
$refusedempty, whatever landed on stdout. - Belt and braces for the case where
ghexits 0 over a body the filter cannot
read: constrain$refusedto the conclusions this poll can act on
(failure,cancelled,timed_out,action_required,neutral) rather
than accepting any non-empty string. A conclusion is a closed vocabulary and
the code should say so. - Keep
2>/dev/null— the stderr noise is genuinely not wanted. It is the
silent trust of stdout that is the defect, not the silencing of stderr.
Test obligation
tests/land.bats — the gh stub already routes by endpoint. Add: a
workflow-runs call that exits non-zero while printing a JSON error body does not
end the lap; the poll continues and the PR still merges on a later state. And: a
body whose conclusion is not one of the acted-on set is not a refusal. The
existing die-count and continue-count coverage assertions must still hold.
Commit type / bump: fix(tasks). Patch until 0.1.0.
Blockers: none. Independent of CLOUD-363 — that one is about how a check-run
conclusion is graded, this one about whether a workflow-run conclusion was read
at all.
Acceptance
- A
gh apifailure during the fast-forward poll costs one poll iteration, not
a lap. - A genuine refusal (
failure,cancelled) still ends the lap and still names
its conclusion.
Files: mise-tasks/land, tests/land.bats.
Filed from the CLOUD-363 session, which owns these files but not this scope.
CLOUD-450 `land-lock.bats` case 183 fails under the parallel bats runner and passes alone — a flaky gate is a gate that gets bypassed
Why
Recurrence measured: 2 of 4 verify/land runs on one unrelated branch, ~30 minutes apart. This is not a rare flake to note and move past — at that rate it is the modal outcome for any branch whose lap happens to run the suite under load, and it blocked CLOUD-274's landing directly: land refused to push on a verify whose only failure was this case, which is land behaving exactly as designed over a signal that was wrong.
Observed 2026-08-12 while running mise run verify on an unrelated branch (CLOUD-274, which touches no lock code).
not ok 443 THE DEFECT: a lease sighted before it expired is taken on the first check after
tests/land-lock.bats:183. The same suite run alone passes all 35 cases, twice in a row:
$ mise exec -- bats tests/land-lock.bats
... 35 ok, 0 not ok
So the failure is a property of how the suite is run, not of the committed lock logic. test:bats runs under rush with --jobs (CLOUD-386, which cut the shell suite from 247s of a 299s job), and this case is timing-sensitive by construction: it asserts about a lease observed on one tick and taken on the next. Under load, wall-clock between two ticks stops being the quantity the test controls.
Why this matters more than one red run
verify is the gate that decides whether CI minutes get spent, and a gate that fails ~sometimes teaches its callers to re-run it until green. That is the exact reflex AGENTS.md names as a defect — "no reflexive drive-to-green pushing: a red run means verify was skipped" — and it only holds while red means something. One flaky case turns every genuine failure in the same suite into noise a caller has already learned to dismiss, and it is a slow poison rather than an outage: the suite stays 34/35 green and nobody reads the one line.
It also costs directly. This instance forced a full re-run of verify (~110s plus the cargo chain) on a branch whose own tests were green.
What is NOT the fix
- Retrying the case, or the suite. A retry makes the signal permanently unreadable and encodes the flake as expected behaviour.
- Serialising
test:batsagain. CLOUD-386 measured the parallel runner as the difference between a 299s job and a 166s one; paying that back for one case is the wrong trade. - Widening the timing tolerance until it passes here. That is tuning against one machine's load, and the next contended runner moves the number again.
Non-goals
- Changing what
land-lockdoes. The lock logic itself is not implicated — the isolated run proves the committed behaviour is what the case asserts. - Anything about
land's lap loop or the landing queue.
Open questions blocking Ready:
- Can the case be made to control its own clock? The honest shape for a timing assertion is to inject the clock rather than sleep against it — the lease's notion of "now" supplied by the test rather than read from the system — which would make it deterministic under any load. Whether
land-lockcan take an injected clock without contorting the production path is the design question, and it belongs to whoever owns that task. - What is the actual bound? The case asserts
elapsed -lt 8, with the header recording the defect at ~9-12s and the fix at ~3-4s — a gap of about 5s that has to absorb whatever the parallel runner does to a 3-4s operation. Both observed failures came from that margin, not from a behaviour change. The case's own comment already names the tension ("The row above is the flake-proof half; this one states the user-visible promise"), so the question is whether the duration promise can be stated without a wall clock at all. - Is case 183 the only one? It is the only one observed failing, but the suite has several cases in the same family (
an expired lease is not stolen on the first sighting,a hold whose land died releases within a beat), and a single observation does not establish that the others are safe. A bounded repeat run of the whole suite under--jobswould answer it, and is cheap.
Blockers (§8). None.
CLOUD-457 `land.bats`'s detached-descendant test flakes under the parallel suite: a 3s wall-clock deadline
What happened
mise run verify failed one bats test out of 1115 while landing CLOUD-56:
not ok 512 a detached descendant cannot hold bats' output stream — fd 3 is closed beneath the program under test
# (in test file tests/land.bats, line 1091)
# `[ -s "$BATS_TEST_TMPDIR/detached.pid" ]' failed
It does not reproduce in isolation — bats --filter "detached descendant" tests/land.bats passes — and the branch under test touches no shell task. The discriminating variable is load: test:bats runs the suite in parallel (CLOUD-419 made that the gate's pole), and this run had a real mise run land executing concurrently.
The mechanism
tests/land.bats:1087:
deadline=$((SECONDS + 3))
while [ ! -s "$BATS_TEST_TMPDIR/detached.pid" ] && [ "$SECONDS" -lt "$deadline" ]; do
sleep 0.1
done
[ -s "$BATS_TEST_TMPDIR/detached.pid" ]The assertion is land detaches a descendant that does not hold bats' fd 3 — a real property worth gating. What it actually waits on is a wall-clock deadline: three seconds for a detached child to be scheduled and flush its pid file. On a loaded box that is a race, and the failure mode is the wrong one — it reads as "the descendant was never detached" when the truth is "it had not run yet".
The test then asserts the real property ([ ! -e "/proc/$pid/fd/3" ]), which is the part that cannot flake, since it is a fact about a process that demonstrably exists.
Ready
The wait gets an exit condition rather than a clock: poll until the pid file is non-empty or the spawning land has exited, whichever comes first, and fail only on the second. A land that exited without writing the file genuinely did not detach; one that has not written it yet has not been observed. That is the same distinction ci-wait draws between "not an answer yet" and "an answer".
Raising the constant is not the fix — it makes the window wider and the failure rarer, which is how a flake survives long enough to be re-diagnosed by the next person.
Done
The test passes under mise run verify with the box loaded (reproduce by running a second mise run verify or mise run land concurrently), and still fails when the detach is removed from mise-tasks/land — the mutant its header already describes.
Provenance
Observed once, during CLOUD-56's landing. Filed rather than fixed there: tests/land.bats and mise-tasks/land belong to the lifecycle work, and CLOUD-56 changes neither. Sibling of CLOUD-412, which is the same shape (a test whose premise is timing) in the Rust suite.
CLOUD-390 Two test suites model a wait with a guessed sleep, and one of them is 12% of the shell suite
Why
mise-tasks/land's header condemns exactly this shape — a comment plus a guessed margin standing in for a mechanism — and the suite that tests it contains the largest instance in the repo.
Measured (per-file timings, 2026-08-11, 4-core box; suite total 248s serial):
| file | wall clock | of which is deliberate sleep |
|---|---|---|
tests/land.bats |
54.6s | one test is 30.4s |
tests/main-watch.bats |
16.8s | ~15s across 5 tests |
tests/target-race.bats |
7.8s | modelled race windows |
tests/land.bats, "main moving mid-wait starts the next lap instead of paying out the run", is 30.4s by itself — 12% of the whole shell suite. Its mechanism is a stub:
ci-wait) [ ! -f "$BATS_TEST_TMPDIR/ci-wait.slow" ] || sleep 30; exit 0 ;;The 30 is a guessed margin, chosen to be comfortably longer than the racing main-watch. The honest model is the one the main-watch stub two lines below already uses for its own loser: while :; do sleep 1; done. A ci-wait that never answers is a strictly more faithful model of "CI is still running" than one that answers after an arbitrary 30 seconds, and land already reaps it — the test is asserting that wait -n plus kill -- -PID resolves the race, which does not need the loser to ever terminate on its own.
tests/main-watch.bats is the same shape one layer down: it sets MAIN_WATCH_INTERVAL=1 and five tests each burn ~3s waiting out poll cycles. mise-tasks/main-watch reads ${MAIN_WATCH_INTERVAL:-5} and passes it to sleep, which accepts fractions — 0.2 cuts each of those to ~0.6s. tests/target-ensure.bats already uses sleep 0.02 for the same reason, so the precedent is in the suite.
Why it is worth doing even after CLOUD-386. Parallelising the suite (CLOUD-386) took it from 248s to 72.73s, and land.bats is now the makespan floor: it is the longest single file, so no amount of extra parallelism goes below it. Removing the guessed sleep drops that floor to roughly the next-longest file. It also shortens the suite for anyone running one file during development, which parallelism does nothing for.
Refinement — Ready
- Source of truth (§1). Per-file wall clock from
bats <file>, before and after. Not a number a human types. - Mechanism (§3). Replace the
sleep 30stub with a never-answering loop, matching the sibling stub in the same file. SetMAIN_WATCH_INTERVALto a fractional value intests/main-watch.bats. - Deliberately not in scope (§2). The
landtask itself. This is a defect in how two suites model a wait, not in the waiting. - The invariant that must not move. Both tests exist to prove a race resolves a specific way. A faster stub that no longer exercises the race is worse than a slow one — the assertion is that
main-watchwins and the lap restarts, and that must still be what fails ifland'swait -n/kill -- -PIDhandling regresses. Verify by breaking the reaping deliberately and confirming red.
Test obligation
The suites are the test. The obligation is the negative one above: prove each still fails when the behaviour it guards is broken, rather than passing faster because it stopped looking.
Commit / bump (§6): test(land) — patch until 0.1.0 regardless of type.
Blockers (§8): none. relatedTo CLOUD-386 (which makes this file the makespan floor) and CLOUD-352.
Acceptance
bats tests/land.batsandbats tests/main-watch.batsare materially faster, with the same case count.- Each still goes red when the race-resolution it asserts is deliberately broken.
- No
sleep <integer>remains in either file as a stand-in for "long enough".
CLOUD-385 A lap now takes longer than the mean gap between commits on `main`, so `land` can cap out indefinitely and a docs-only change cannot land
Why
mem:workflow/agent-fanout models the ceiling as N ≈ time-between-lands ÷ verify-duration, and measured it holding on 2026-08-11: a mean gap of 487s against a verify of ~170s. That measurement is now stale in the direction that matters.
Measured later the same day, over the twelve most recent commits on main: gaps of 104, 163, 189, 302, 359, 503, 642, 870s — a median near 300s. A lap is verify (~170s) + CI (~300s) + the fast-forward wait, so 500–600s.
A lap is now roughly twice the mean gap. That is a different regime from the one the memory describes, not a worse point within it: when lap duration exceeds the gap, the probability that main holds still long enough for a lap to pay out is small, and land correctly voids each run rather than paying out a verdict main has already invalidated. The observable result on CLOUD-289 — a single-file markdown edit to a memory — was 16 laps across two land invocations, both hitting the 8-lap cap, without landing. Each of those laps spent a full verify and a full CI run.
Nothing is malfunctioning. land is doing exactly what it should, main-watch polls conditionally, and cancel-in-progress kills the doomed runs cheaply. The gap is that the loop has no mechanism that converges under this regime — it has a cap that stops, and the documented response to the cap ("look before lapping again") has no action behind it other than lapping again.
This also isolates the cause. CLOUD-319 is Done, so the release-PR amplifier that was producing ~2 commits per landed change is gone; what remains is genuine landing throughput.
The lever the memory names is now load-bearing. "Shorten verify, do not add sessions" was advice when the ratio was 487:170. At 300:550 it is the only term anyone controls, and one observation makes it concrete: CLOUD-289 changed one markdown file under .serena/memories/ and paid the full cargo build, clippy, the 674-case bats suite, cross-check and darwin-link on every one of 16 laps. A verify proportional to what the diff actually touches would have made each lap a fraction of the gap instead of double it.
Acceptance
- A lap's cost is proportional to what the change can actually break: a diff touching no crate source does not pay the full cargo/cross/darwin matrix on every lap.
- The proportionality is a computable predicate over the diff — a path-set test, never a judgement about what a change "seems to" affect.
- No weakening of the contract: whatever
verifyskips for a given diff, CI skips too, so a green local verdict still means the same thing as a green CI one. A cheaperverifythat CI then disagrees with is strictly worse than today. - Anti-vacuity: a diff that touches crate source still runs everything, and the suite proves that a source-touching change cannot take the cheap path.
- The fan-out memory's caps section carries the re-measurement, so the next reader sees the current ratio rather than the 487:170 one.
Deliberately not proposed: raising the lap cap, adding retry logic around land, or serialising landings behind a lock. The first two spend more on the same losing race — AGENTS.md forbids wrapping the lifecycle tasks in bespoke retry anyway — and the third reintroduces the queue that trunk-based development exists to avoid.
CLOUD-337 ci-wait calls a set terminal before the required runs register, so land fast-forwards into a branch-protection rejection
Why
CLOUD-327 narrowed ci-wait to decide terminality over $CI_REQUIRED_CHECKS: green means at least one required check graded, none skipped, none pending. It deliberately does not require every required name to be present, and says why:
Absent is not skipped either:
zizmoris path-filtered and emits no run at all on a PR touching no workflow, so requiring every name to be PRESENT would hang on the ordinary PR.
That is the right call for a path-filtered check and the wrong one for a check that simply has not registered yet. pending == 0 is trivially satisfied when the jobs do not exist, so a poll that lands in the window between the push event and GitHub creating the check runs sees a small, fully-graded set and calls it green.
Measured — CLOUD-199 / PR #280, 2026-08-11 15:23–15:25.
land pushed 09d3b691 and readied. At poll time the head carried exactly two runs, both graded:
| check | state at poll |
|---|---|
commit-lint |
success |
SonarCloud Code Analysis |
success |
ci-wait printed every required check terminal and green on 09d3b691 and land posted /fast-forward. Two minutes later the same head carried three more runs, all in_progress: ci, cross, darwin-link (aarch64-apple-darwin) — plus final, which had not registered at all.
The fast-forward bot was then rejected:
remote: - Required status check "final" is expected.
! [remote rejected] 09d3b691 -> main (push declined due to repository rule violations)
No unconfirmed commit landed — branch protection held the line. That is the compensating control working, and it is also the tell: the repo's own gate and GitHub's required-check set disagreed about whether this head had an answer, and GitHub was right.
Why this is not CLOUD-327 again
CLOUD-327 closed the case where the required runs exist and are skipped. This is the case where they do not exist yet. Both present as "the set looks answered"; only the second is a race, and it is the one a fast push-then-poll makes likely. ci-wait's own comment already names both halves — "those are the draft-era runs, or the real ones not yet registered" — and the current guard covers the first.
What a mechanism would have to answer
How to distinguish not yet created from legitimately absent (path-filtered) without hanging on the ordinary PR — the exact tension CLOUD-327 recorded. Candidates worth ruling out in order:
- A settle window before the first verdict. Require the run set to be stable across two consecutive non-304 reads, or that a minimum age has elapsed since the push, before any green is emitted. Cheap, and it costs nothing on a head whose runs already exist. Does not need a wall-clock timeout on the poll itself, so the no-cap rule is untouched.
- Ask GitHub which checks are required rather than inferring from what appeared — the branch-protection/ruleset API is the authority that rejected the push, and
$CI_REQUIRED_CHECKSis a hand-maintained mirror of it (CLOUD-327 says as much, pending CLOUD-54). - Treat the workflow-run set rather than the check-run set as the unit, since a queued workflow is visible before its jobs are.
Acceptance
- A head whose required runs have not yet been created is not terminal; the poll continues rather than emitting green.
- A head whose required run is genuinely absent because it is path-filtered still reaches a verdict and does not hang — the CLOUD-247 stall must not return.
tests/ci-wait.batscovers the measured window: a first read carrying onlycommit-lint+ a third-party success, a later read carryingci/cross/final.- The poll stays conditional and carries no wall-clock timeout.
landdoes not comment/fast-forwardon a head the bot will reject for a missing required check.
Not refined: no predicate is settled; the settle-window shape above is the leading candidate, not the answer.
Found while landing CLOUD-199. Not fixed there — it is outside that issue's scope and lives in ci-wait/land, which the session was instructed not to modify.
CLOUD-334 A third-party check satisfies ci-wait's graded-conclusion guard, so a fully draft-skipped CI set lands as green
Why
ci-wait knows that draft-era skips are not an answer, and says so:
A check set that is entirely
skippedis not an answer: those are the draft-era runs, or the real ones not yet registered. Require at least one graded conclusion before treating the set as terminal.
The guard it built for that is graded > 0, counting any check run whose conclusion is success/failure/timed_out/cancelled/action_required. That set is not scoped to this repository's own workflows, so a single third-party check grading the head satisfies it while every Batten job is still a draft-era skip. land carries the same property in its graded_runs "$sha" re-ready guard, which is what decides whether to re-fire the ready that would start a real run.
Measured — CLOUD-185 / PR #273, 2026-08-11. Check runs on 3b73923a:
| check | conclusion | completed |
|---|---|---|
ci |
skipped | 14:59:00 |
cross |
skipped | 14:59:00 |
commit-lint |
skipped | 14:58:55 |
final |
skipped | 14:59:01 |
darwin-link |
skipped | 14:59:06 |
SonarCloud Code Analysis |
success | 15:03:11 |
graded = 1, from SonarCloud alone. pending = 0. ci-wait printed all checks terminal and green on 3b73923a, land posted /fast-forward at 15:02, and the PR merged. Every Batten job that actually tests the change was skipped and none re-ran. land's re-ready branch never fired, because graded_runs was non-zero for the same reason.
The change itself was sound — local mise run verify ran the full gate (ci, cross, commit-lint, 651 bats) and was green, which is the workflow contract working. That is exactly what makes this hard to notice: the landing is correct and the verdict is false. Nothing in the log reads as a warning.
Why this is the CLOUD-240 shape one layer up
CLOUD-240 closed "a draft still spends a runner". CLOUD-247 and CLOUD-255 tuned when the ready fires so a head is not left carrying only skips. This is the reader rather than the emitter: however carefully the ready is sequenced, ci-wait's definition of "somebody graded this" is wide enough that a check Batten does not own can vouch for a head Batten never tested. It is also CLOUD-193's thesis at the CI boundary — a verdict inferred from an adjacent signal rather than read from the thing that produced it.
What a mechanism would have to answer
Whether the graded-conclusion guard should be scoped to a named set of required checks — the repo's own workflow job names, which ci-local-parity already enumerates for a different purpose — rather than to any check run present on the head. A named set makes "the set is terminal" mean "the jobs that test this change are terminal", and makes a fully-skipped Batten set indistinguishable from no answer at all, which is what the comment already says it wants.
The obvious cheaper form — "require at least one graded conclusion that is not from an app" — is worth ruling out first: it is a negative over an open set, and a second third-party check would reopen the hole.
Acceptance
- A head whose Batten jobs are all
skippedand whose only graded run is a third-party check is not terminal —ci-waitkeeps waiting, andland's re-ready guard fires. - A head whose Batten jobs are genuinely graded is terminal exactly as today.
tests/ci-wait.batscovers the measured shape above, with the third-party check present and graded.- No wall-clock timeout is introduced; the poll stays conditional and uncapped.
Not refined: no predicate is settled here, and the named-required-set shape above is the candidate rather than the answer.
Found while landing CLOUD-185. Not fixed in that PR: it is outside that issue's scope and lives in land/ci-wait, which the session was instructed not to modify.
CLOUD-345 `land` deadlocks on a reused branch name after its PR merges: the deleted remote branch leaves a stale tracking ref, and `--force-with-lease` refuses forever
Partially overtaken by CLOUD-349, and deliberately not closed. That change makes
landdelete the remote branch after a merge and cites this issue as the deadlock it avoids. It does avoid it on the path wherelandperforms the deletion, because pushing a delete also drops the local remote-tracking ref.The residual is the configuration this was measured in. When the remote branch is deleted by GitHub's own auto-delete-on-merge rather than by
land,land'sgit push --deletefinds nothing to delete, takes its "already gone" warning branch — and that path does not prune the local tracking ref. The phantom survives, and reusing the name still deadlocks exactly as recorded below.So the acceptance below stands, narrowed: prune where
landfetches, and split the rejection message by cause. The branch-deletion step is a good thing that happens to cover the common path, not a substitute for either.
Why
Measured 2026-08-11, on the second issue landed from one session's branch.
land pushes with git push --force-with-lease -u origin "$branch" (mise-tasks/land:254). With no explicit expectation, --force-with-lease compares the remote against the local remote-tracking ref. When a PR merges, GitHub deletes the head branch — but git fetch does not prune by default, so refs/remotes/origin/<branch> survives, still pointing at the SHA that landed.
Reusing that branch name for the next issue then hits a push the lease can never satisfy: the tracking ref claims the remote is at c0eef1e, and the remote has no such ref at all. Git reports:
! [rejected] claude/cloud-sequential-prs-r9xr6w -> claude/cloud-sequential-prs-r9xr6w (stale info)
::error:: land: push rejected. Someone else moved the branch; look before forcing.
Three properties make this worse than an ordinary rejection.
- It is not self-clearing.
landre-fetches on each lap, and a plaingit fetchnever removes the phantom. The task was re-run and failed identically. No number of laps recovers, so this is a deadlock rather than a retry. - The diagnosis is wrong, and wrong toward the dangerous action. "Someone else moved the branch; look before forcing" describes a concurrent writer, and the correct response to that is caution. Here nobody moved anything — the branch was deleted by our own merge — and an agent that believes the message either stalls or reaches for a bare
--force, which is the one thing the lease exists to prevent. A message that misnames the cause pushes toward the unsafe remedy. git log HEAD..origin/<branch>is empty, so every "look before forcing" check an operator would run says the remote holds nothing local lacks — the evidence available at the stop point actively suggests forcing is safe, for the wrong reason.
The fix is one word at the fetch: git fetch --prune drops the phantom, after which the lease has no expectation for a ref that does not exist and the push creates the branch cleanly. Confirmed on this session: pruned, re-ran land unchanged, and it proceeded.
This is not exotic. It fires whenever a session lands more than one issue from the same branch name, which is the ordinary shape when a session is handed a queue.
Acceptance
landrecovers on its own when the remote branch was deleted by its own merge: the lifecycle task is re-runnable and needs no handgitin between.- Wherever
landfetches, the fetch prunes, so a deleted upstream cannot leave an expectation behind. - The rejection message distinguishes the two causes. A branch that a concurrent writer moved keeps today's caution; a branch that is absent from the remote says so and names pruning, never forcing.
- Anti-vacuity: a genuine concurrent move still stops
landwith the existing message, and--force-with-leaseis still what pushes — the lease is not weakened to a bare force, which would trade this bug for a worse one. tests/land.batsgains cases over the stubbed remote: deleted-upstream is recovered and pushed; a concurrent move is still refused with the concurrent-writer message; and after recovery the lap continues to the ready and the CI wait rather than exiting.
Refinement — Ready (the lifecycle task recovers from the branch its own merge deleted)
- Source of truth (§1). One authority:
mise-tasks/land, which owns the push and already owns the three stops. No new task and no second place a branch's remote state is decided. - Computable predicate (§2).
mise run test:batsovertests/land.bats, already globbed into the sharedhkgate and thereforemise run ci. The predicate is the task's own exit code over a stubbed remote, the shape that suite already uses. The recovery condition is mechanical —git ls-remote --heads origin <branch>returning nothing is "absent", which is a different state from "present at an unexpected SHA" — so no judgement is involved. - Effect (§3). No command-surface change;
landis amisetask, not a verb. - Output & exit (§5). Unchanged in shape: pointer-only on stderr, exit 1 on a stop. One message splits into two, each naming its own remedy.
- Commit / bump (§6).
fix→ patch, orci→ no bump if the task is treated as tooling; it touches no crate source either way. - Blockers (§8). None.
relatedToCLOUD-254, which established the push/ready ordering this sits inside.
Three consumers misled by the same stale ref in one session, 2026-08-11. This was filed as a land deadlock. It is wider than that: the stale remote-tracking ref left behind when a merged branch is deleted is read by everything that asks "is my work pushed", and each reader draws a different wrong conclusion.
land— the filed symptom.--force-with-leasecompared against a ref that no longer exists and refused:land: push rejected. Someone else moved the branch; look before forcing.Nobody had moved it. The message names the one cause that was not true, so the operator's first instinct is to look for a colleague who does not exist.- The stop hook.
~/.claude/stop-hook-git-check.shreported "There are 20 unpushed commit(s)", then 5, then 3, on a branch whose true unlanded set against the trunk was 1 every time. It compares against the tracking ref; with the ref stale, every commit reads as unpushed. A guardrail that cries wolf on every turn is one an operator learns to scroll past — and this one fired four times in a session where it was wrong four times. land's own post-merge delete (CLOUD-349). It reportedalready gone, or the remote refused, which reads as success-by-other-means. Both halves of that message were live: the ref was gone, andgit push origin --deletereturns 403 in this environment regardless (measured: nine branches, all 403; the same deletions viagh api -X DELETEall succeeded). The stale ref made a permanently-failing code path look like a no-op.
What that changes about the fix. A git fetch --prune before the lease check clears all three, and it belongs in land rather than in each reader: the readers are not all ours (the stop hook is harness-side), and only the landing loop knows when the ref went stale. Pruning is also the cheap half — it is one flag on a fetch land already performs every lap.
Raised to High: the deadlock is recoverable by hand once you know the cause, but the second and third faces are silent, and one of them made a shipped mechanism look like it worked.
CLOUD-383 `land` needs bash 4 for `wait -n`, and its bare `wait` hangs when the group kill misses
Why
Split out of CLOUD-282, which fixed every other macOS blocker but could not touch mise-tasks/land — another session held it. It is the last bash-4 construct in the tree: macOS ships bash 3.2 as /bin/bash and mise registry carries no bash, so this is a rewrite and not a pin.
mise-tasks/land:299 uses wait -n to race ci-wait ("is this SHA green") against main-watch ("is this SHA still landable"). On bash 3.2 the whole lifecycle task dies there, on the one machine class that has no other way to land.
Two defects, and the second is the one that bites under a naive fix
-
wait -nis bash 4. Its exit status is already discarded — the winner is read from which rc file is non-empty — so the status is not what has to be preserved. What does: a killed loser must leave its rc file EMPTY, or the race resolves to whichever process the shell happened to reap. -
Both racers are unbounded, by design (
ci-waithas no timeout andmain-watchblocks on a quiet main, and a wall-clock cap on either is explicitly forbidden by AGENTS.md). So a naive downgrade to a barewaitdeadlocks: it waits for BOTH, and the loser never returns.A FIFO rendezvous preserves every property —
exec 3<>fifo, each racer writes its name on finishing, one blockingreadpicks the winner, and the loser is killed with its rc file still empty.
While there: the bare wait at land:256 hangs if the group kill did not land. reap_watch at land:318-323 already has the correct shape — a targeted wait that tolerates an already-reaped child — and is the model to copy.
(Line numbers are from 2026-08-10 and land has changed since; re-derive them.)
Refinement — Ready (land runs on bash 3.2, and the race still resolves to the true winner)
- Source of truth (§1).
mise-tasks/land— the racer rendezvous and the twowaitcalls. No caller changes, and no new task. - The mechanism is a computable predicate (§2).
tests/land.bats, already in the hkgateviatest:bats, which covers every way a lap can end and carries a count assertion so an unexercised path cannot go dead. The standing half is aforbidrow onwait -nscoped tomise-tasks/*inbatten.toml, joining the five portability rows CLOUD-282 landed there. - Output & exit contract (§5). Unchanged. A lap must still stop for exactly three things — a rebase conflict, a failed
verify, red CI — andLAND_MAX_LAPSstays a lap COUNT, never a wall clock on a wait. - Commit / bump (§6).
fix→ patch until 0.1.0. - Test obligation (§7). One new case per defect: the loser's rc file is empty after the winner resolves (which is what makes the race readable at all), and a
waitwhose subject is already gone returns rather than hanging. Plus the existing suite green. - Blockers (§8). None.
relatedToCLOUD-282, which landed the rest of the class.
Done
mise-tasks/landnames no bash-4 construct, and aforbidrow onwait -novermise-tasks/*keeps it that way.- The racer rendezvous resolves to the true winner with both racers unbounded, and the loser leaves an empty rc file.
- Neither
waitcan hang when its subject is already reaped. mise run verifygreen; landed onmainby fast-forward, CI-confirmed green.
Not proven by any of the above: that it works on a Mac — the same residual gap CLOUD-282 records. Someone with a Mac has to run mise run land once.
CLOUD-282 `verify` cannot run on macOS: `doctor` needs `flock`, two gates are GNU-only `sed -zE`, and three test suites assume git's default branch is not `main`
Why
Local verify is the authorization gate the whole workflow contract rests on — "verify exhaustively before CI", "the gates ARE your authorization". On macOS it cannot pass, for reasons no branch causes. A macOS agent therefore either stops on a red verify it did not break, or lands on CI's word alone, which is the failure mode the contract exists to prevent. CI runs ubuntu, so CI is structurally blind to every class below — the same "verdict is a property of the machine" shape as CLOUD-227.
Surfaced on PR #205 (wenzowski, 2026-08-10), where mise run land stopped at verify; the author reproduced every failure identically on origin/main from a detached worktree. Confirmed by reading the source on main @ 5adcaef.
Two independent defect classes
1. GNU-only sed -zE. BSD sed has no -z; it exits sed: illegal option -- z. This is not only a test failure — the gates themselves cannot run on macOS:
mise-tasks/run-shape-guard:64—sed -zE "s/'[^']*'/QUOTED/g; …"mise-tasks/stop-posture-check:67-70— four chainedsed -zE, includings/(^|\n)…/which depends on NUL-slurping to see\nin the pattern space at all
Both are whole-input transforms that need the buffer unsplit by newlines. -z is one way; perl -0777, or an awk with RS='\0', are portable ones. perl -0777 -pe is byte-identical to sed -zE for all five substitutions, differentially verified against the real bats fixtures and the \n-at-start and blank-line-before-> edges; only \1 becomes $1, at line 70. The one divergence, embedded NUL, is unreachable here. Not awk: it would have to inline its regex literally to satisfy awk-regex-check, and this repo's dev boxes are mawk while the runner is gawk — the exact divergence that made ready-lint silently vacuous.
Related but distinct, same file class: tests/ci-tools-check.bats:85,94,105 use sed -i with no backup suffix, which BSD sed rejects (it reads the next argument as the suffix).
2. git branch -f main under init.defaultBranch=main. Suites build a fixture repo with git init, commit, then force-create main. git refuses branch -f on the branch that is currently checked out. It works in CI only because the runner's git still defaults to master, so main is a different branch — an accident of the runner's configuration, not a property of the test. Any developer whose global gitconfig sets init.defaultBranch=main (the common modern setting, recommended by git since 2.28) fails these suites on any OS. It is a setup() failure, so every test in the suite errors, not just one.
Re-measured: three suites break, not four. tests/verified.bats:14-19, tests/landed-check.bats:16-21, tests/issue-guard.bats:19-24. tests/linear-check.bats:114 is a false positive — branch -M main renames the current branch, and renaming main to main takes git's lenient validation path and succeeds. Two in-repo models are already correct: tests/released.bats:16 (git init -q -b main .) and the Rust builders at crates/batten/tests/common/mod.rs:249-266, which use branch -M main. Because common/mod.rs:177 forces init.defaultBranch=main, the Rust suite already runs -M main on an already-main branch every invocation and passes — in-repo proof that -M is safe and -f is not.
The wider class survives that fix: no bats suite neutralizes the developer's global git config, while common/mod.rs:184-185 sets GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_SYSTEM=/dev/null. init.defaultBranch is one leak; commit.gpgsign is the same shape and is disabled in only one suite.
Note the PR comment also listed memories-check under this class; tests/memories-check.bats:10 only calls git init -q and carries no git branch -f, so that suite fails for some third reason. See the xargs -r bullet below for the candidate.
What ShellCheck can and cannot do here (measured 2026-08-10, ShellCheck 0.11.0)
It cannot see this class at all. Over 17 GNU-only constructs — sed -i, sed -E, date -d, grep -P, stat -c, readlink -f, base64 -w0, mktemp --suffix, head -n -1, sort -V, xargs -r, find -printf, timeout, sha256sum, tac, nproc, cut --complement — it reports 0 findings under --shell=bash and 0 under --shell=sh. It models the shell language, not the utilities a script calls; there is no GNU-vs-BSD flag database, and --list-optional carries no check in that family. -s sh adds only SC3xxx bashism warnings, so a sh shebang is not the lever: it would flag legitimate bash and break bats, which requires bash.
And it has never read the bats suite, which is why these classes accumulate unseen. hk.pkl overrides Builtins.shellcheck, whose selector is (glob **/*.sh, **/*.bash) OR (types: sh, bash). hk check --all --step shellcheck -v batches 45 files — every mise-tasks/*, .claude/hooks/session-start.sh, two fixture .in files — and none of the 42 tests/*.bats, because #!/usr/bin/env bats is neither type. .shellcheckrc has carried an SC2030/SC2031 suppression reasoning about bats run subshells the whole time; a suppression reads as evidence a file is being read, and it is not. One selector entry fixes it — ShellCheck derives the bats dialect from that shebang unaided, so no --shell flag and no per-file directives.
Selecting them costs 99 findings at this repo's --severity=style posture (SC2016×55, SC2314×43, SC1007×1), of which 10 are error severity and are real defects: an !-led assertion does not fail a bats test, so ten assertions currently assert nothing — tests/issue-guard.bats:136,260,266, tests/land.bats:451, tests/run-shape-guard.bats:99, tests/stop-guard.bats:56,65,73,80, tests/target-race.bats:140. bats is v1.14.0, so run ! is available. Expect some to fail once converted; an assertion that never asserted may have been masking a defect, and that is the real yield.
Three further classes, distinct from the two above
timeout— nine executed sites.tests/main-watch.bats:73,89,100,110,121,131,tests/ci-wait.bats:57,69,tests/land.bats:642(including-kand-s KILL). It is GNU coreutils and absent from a stock macOS, so these fail on a missing binary rather than on a flag. Measured: it is the only coreutils-only utility this tree invokes, and there are zero GNU-only flags onstat/base64/readlink/date/sort/head/cutanywhere — so the answer is a portable helper in a shared test file, not a coreutils pin supplying one binary used only in tests.tests/gh-guard.bats:141carriestimeoutinside a fixture string being parsed, not an invocation; leave it.- bash 3.2. macOS ships 3.2 as
/bin/bashandmise registrycarries no bash, so these are rewrites and not pins.mapfileatmise-tasks/gh-guard-check:26— the rewrite exists twice already atmise-tasks/issue-guard:79andmise-tasks/ready-guard:36, and the load-bearing detail is thatprintf '%s'must becomeprintf '%s\n': without the trailing newlinereadreturns false on the final segment, and for a single-segment command likegh pr mergethe guard then silently allows everything.wait -natmise-tasks/land:254— its exit status is discarded, the winner is read from which rc file is non-empty, and a killed loser must leave its rc empty; both racers are unbounded so a naive downgrade to barewaitdeadlocks, and a wall-clock cap is forbidden. A FIFO rendezvous (exec 3<>fifo, one blockingread) preserves every property. While there, fix the barewaitatland:256, which hangs if the group kill did not land —reap_watchatland:318-323already has the correct shape. xargs -ratmise-tasks/memories-check:65— the candidate for the unexplainedmemories-check.batsfailure. BSDxargsdid not accept-runtil recent macOS. Test it first; it is version-dependent and unverified on a Mac.
Refinement — Ready (verify passes on macOS, and a gate keeps it that way, expressed as rules rather than a 50th mise task)
- Source of truth (§1).
batten.toml's[[rule]]rows for the banned invocations, andhk.pklfor ShellCheck's file selection. The construct set is config, not prose, and not a second copy inside a shell script. - The mechanism is a computable predicate (§2). Engine-first, per the 2026-08-07 amendment:
kind = "forbid",scope = "tree"rows inbatten.toml—sed -zE,sed -i(trailing space, sosed -i.bakis not a hit),mapfile,wait -n,xargs -r,flock,branch -f main— evaluated bybatten check, already in the shared hkgateathk.pkl:281-289with no glob, so it runs on every commit and in CI with no new wiring. This supersedes the earlier note proposing a sibling ofawk-regex-check: that amendment exists precisely because the prior wording made a bash task the cheapest satisfying artifact, and CLOUD-202 is in flight porting the existing guards out of that layer. Anchor every glob at two or more segments so it cannot match the rootbatten.tomland self-fire — the file walk isfs::read_dir, notgit ls-files, and skips only.git. The half a literalpatterncannot express — flag clusters judged by their letters, and excluding comments,case-pattern token lists and fixture strings — is CLOUD-283. - Output & exit contract (§5). Findings stay pointer-only (
path:lineplus rule id). Note the inversion when the gate moves into the engine: abatten checkviolation is exit 2, the opposite of themise-tasks/*-checkconvention where 2 means unreadable input. - Commit / bump (§6).
fix→ patch until 0.1.0. - Test obligation (§7). The committed rows are pinned in
crates/batten/tests/cli.rs, not in the fixture corpus — that corpus tests the engine, andcli.rs:1562-1570records the standing obligation that "any rule landing later owes one of its own". Model onthe_committed_repo_agnosticism_rules_fire_on_every_banned_shape(cli.rs:1600-1689): dirty tree → exit 2 with byte-exact stdout, clean tree → exit 0 with empty stdout. Those tests copy the committedbatten.tomlinto the scratch repo, so a glob matching it would break byte equality. Plus the existing bats suites green under the portable forms. - Blockers (§8).
blockedByCLOUD-286 —doctorrefuses to run withoutflock, so this cannot reach its own Done until that lands.relatedToCLOUD-283, the engine gap the gate's harder half needs; the literal-expressible rows land without it.
Done
- Both gates run under BSD sed; the three
sed -icall sites carry a portable form. - The three fixture setups do not depend on git's default branch name, verified by re-running them under
git -c init.defaultBranch=main. gh-guard-checkstill denies a single-segmentgh pr mergeafter themapfilerewrite.hk check --all --step shellcheck -vlists all 42tests/*.bats, and the step passes at--severity=style.- The ten
!-led assertions userun !and fail when their subject succeeds — verified by inverting each subject once, not by a green run. - The nine
timeoutcall sites use a portable test helper. - The banned invocations are
batten.tomlrows, not a newmise-tasks/*-checkprogram, and each fires when its literal is seeded under its glob. - Landed on
mainby fast-forward, CI-confirmed green.
Not proven by any of the above: that it works on a Mac. Every check here runs on Linux — the same residual gap mise-tasks/macos-link-check has against mise-tasks/darwin-link. Someone with a Mac has to run mise run verify once.
Not blocking anything today — filed because it is real and reproduces on main, not because it is in flight. Discovered while adopting PR #205 (CLOUD-281); it is not that change's to fix.
Implementation notes (2026-08-11) — four places the issue's own analysis was wrong, and the scope split
Everything below was measured on main @ ec3639a while building this, not inferred.
1. branch -M main is the WRONG replacement, and the issue recommends it. -M renames the current branch; these three fixtures need a second branch marking the trunk while HEAD stays on the feature branch, which is the topology tests/verified.bats's moved-main case checks out (git checkout -q -). Rewriting them to -M first made that case fail — there was no other branch left to check out. The in-repo precedent the issue cites (common/mod.rs:249-266) is doing a genuinely different thing: making the current branch be called main.
What landed instead: git init -q -b work "$REPO" plus a plain git branch main. Naming the working branch makes the topology explicit rather than inherited, and main is then a fresh name needing no force at all. Measured under init.defaultBranch=main: git branch -f main exits 128 (cannot force update the branch 'main' used by worktree at …), git branch main off work succeeds.
2. Two call sites the issue does not name. sed -zE is in three files, not two — mise-tasks/memory-guard-check:95 carries the identical scrubber. mapfile is at two sites, not one — mise-tasks/run-shape-guard:237 as well as gh-guard-check:26. Both are now fixed; both would have been missed by working from the issue's list.
3. sed -i is at 11 sites, not three. tests/ci-local-parity.bats (7) and tests/release-assets-check.bats (1) carry it too, beyond the three in ci-tools-check.bats.
4. GNU timeout returns 137, not 124, when a -k escalation actually fires. Measured against real coreutils rather than assumed, because the portable helper had to agree with the tool it replaces in the one case the two could differ. The helper's mapping is: the command's own status when it finished in time; 137 when the child died of SIGKILL; 124 otherwise. tests/helpers.bats pins all three branches plus the case that distinguishes a self-raised SIGTERM (143) from a TERM-timeout — an implementation reading only the exit status would report a genuine crash as a timeout.
What landed
perl -0777 -pein all three gates. Byte-identity differentially verified over every substitution against the realtests/*.batsbodies plus both edges the issue names (\nat start, blank line before>) — 18 cases, zero divergence.mapfile→ thereadloopissue-guard/ready-guardalready use, withprintf '%s\n'. Confirmed againsttests/gh-guard.bats: a single-segmentgh pr mergeis still denied, which is the case the missing newline would have silently allowed.xargs -r→ barexargs; the/dev/nullsentinel already bought what-rdid.- New
tests/helpers.bash(sed_i,run_timeout) +tests/helpers.bats; 22 call sites moved onto it. - The three fixture setups also neutralise
GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM, closing the wider class rather than the one leak. - Six
forbidrows inbatten.toml(no-gnu-sed-z,no-gnu-sed-in-place,no-bash4-mapfile,no-gnu-xargs-r,no-util-linux-flock,no-branch-f-main), pinned bythe_committed_portability_rules_fire_on_every_banned_shapeincli.rs.
Verified: all 166 cases across the ten touched suites pass under GIT_CONFIG_GLOBAL carrying init.defaultBranch=main and commit.gpgsign=true — the configuration that previously failed three whole suites at setup().
A finding about the rows themselves. Every rationale comment explaining a banned construct fires the row that bans it — forbid is a literal substring with no exclude, and the guarded files live under the guarded globs. The comments are therefore worded to name the flag without spelling the invocation, which is the same dodge no-consumer-account-literal already documents. This is CLOUD-283's gap showing up one layer earlier than expected: it is not only fixture strings and case token lists, it is a rule's own explanation.
Split out, not dropped
Two pieces sit in files another session held, and each shipped as its own issue rather than as a silent omission:
- CLOUD-382 —
hk.pkl's ShellCheck selector fortests/*.bats, and the ten!-led assertions it exposes. The fixes belong with the gate that keeps them (non-negotiable rule 2). - CLOUD-383 —
mise-tasks/land'swait -nand the barewait, the last bash-4 construct in the tree.
Still gated by CLOUD-283: the tests/*.bats half of the sed -i and flock rows. tests/memory-guard.bats:106,107,135,145,164 carry the in-place form as fixture strings — it is the suite proving memory-guard denies that shape — and tests/target-ensure.bats:162 names the util-linux lock in a test title. Neither row can be written today without firing on the tests that prove the guards work. The mise-tasks/* half, which is the layer that actually could not run, landed.
And still not proven: that it works on a Mac. Every check here ran on Linux. Someone with a Mac has to run mise run verify once — the same residual gap macos-link-check has against darwin-link.
…e fleet
The CI-side landing-lease check (CLOUD-420) is built so that a gate which cannot
look never becomes a gate that blocks everything. Its header promises it "NEVER
EXITS NON-ZERO" and its suite calls that the property that outranks every row.
The promise is made by the fetched program. The CALLER decided whether a body
that will not PARSE is fatal, and in all eight copies it was.
body=$(gh api ... contents/mise-tasks/ci-lease-precondition?ref=main) || exit 0
bash -c "$body"
The fetch failing was handled. The fetch SUCCEEDING with something that is not
shell was not: a truncated response, a proxy error page returned 200, or a bad
edit to the trunk copy makes this a syntax error in the FIRST step of every
`pull_request` job. The step reds under the runner's default `bash -e {0}`,
`final` fails its `needs:` assertion under `always()`, and `land` re-drafts on
red — so one bad response on `main` re-drafts every open PR, through the exact
mechanism built to protect them.
Three changes, and the third is what keeps the first two:
* `|| exit 0` on the invocation in all eight jobs (ci.yml x5, commit-lint,
test, zizmor).
* `set -euo pipefail` -> `set -uo pipefail` in the precondition itself. Under
`-e` the workspace setup — mkdir, printf, chmod, git init, git remote add,
git config — each exits the script non-zero on an unwritable RUNNER_TEMP or
a full disk, contradicting the same promise from inside. Nothing downstream
depends on those succeeding: the existing `*)` arm already routes an
unanswerable land-lock to `run_anyway`, so setup failure now reaches the
fail-open path it was always supposed to. `-u` and `pipefail` are kept.
* `ci-local-parity` grows the assertion. Its existing property matches the
step's NAME, so a copy that reds its own job read as present and correct.
Counted rather than grepped for absence, because the guarded and unguarded
forms differ only by a suffix and a `grep -v` would pass a file carrying
both.
Refs: CLOUD-420
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
The third reader of check-run conclusions still carried the pre-CLOUD-363 rank. Its own header claims "Same shape and same rules as `checks-green`, deliberately"; it was not. `cancelled` fell through the catch-all and reported red, exit 1. That is worse here than the defect CLOUD-363 originally fixed, because of where this gate runs. `sonar-gate` is invoked inside `final`, and `final` IS in the required roster while the analyzer deliberately is not. So a cancelled analysis reds `final`; `checks-green` then sees an independent `final failure` with an EMPTY no-verdict bucket, and the fan-in-over-cancelled-upstreams guard that CLOUD-363 added cannot fire on a run it cannot see. `land` reads red and re-drafts a healthy PR. Two lines, mirroring a4b34e1 exactly: `cancelled` joins `skipped` at rank 3, and the case carries the word so a stall can be spelled rather than merely felt. Refs: CLOUD-363 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
…n roster
Two defects that both end in `land` giving an agent a wrong instruction.
CLOUD-467 — `graded_runs` answered `0` on an unset roster. The command
substitution carrying `${CI_REQUIRED_CHECKS:?}` aborts, and the `|| n=0` written
for a transient API failure converted that abort into a count. Both call sites
read `0` as "this head carries no graded run", which is the branch that FIRES
THE READY THAT STARTS CI — so the one input this task cannot compute became the
answer that spends a full matrix. `checks-green` guards the same variable
explicitly eight lines into the file it is deliberately paired with, so opposite
behaviour on a missing roster is exactly the drift that pairing exists to
prevent. The `|| n=0` is kept for what it was written for: a transient `gh` or
`awk` failure answering "ungraded" is the choice that makes progress.
CLOUD-470 — three different things arrived at the red-CI stop and all three were
called red. A run the lease precondition DECLINED is cancelled, `final` reds
under `always()`, and the wait returns non-zero with nothing about the branch
broken; a `ci-wait` exit 2 is "could not look" and judged nothing at all. Both
were told that verify and CI disagree and to fix the mismatch locally — naming
the one cause that is not true, and pointing away from the remedy. Measured: 11
of 13 open PRs carried a `land` predating the lease, so every one of those
agents, on restart, was sent to debug a disagreement that did not exist.
`stopped_run` reads the cancellation off the endpoint `cancel_own_run` already
uses, and fails OPEN to the red message on any non-answer. The asymmetry is
deliberate: a wrong "nothing is broken" costs a landing, a wrong "go debug this"
costs a session.
Four cases, and the fourth is the one that matters — a GENUINE red must keep
today's message. A change that printed the rebase remedy unconditionally would
pass the other three and misdirect every real CI failure.
`land` now carries 19 stopping conditions rather than 16, and the coverage
assertion moves with it: that row failing is the mechanism working.
Refs: CLOUD-467, CLOUD-470
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
…non-answers Four defects in one poll, landed together because they are one control flow. CLOUD-409 / CLOUD-456 — THE VERDICT HAD NO KEY. An `issue_comment` run attaches to the default-branch tip, so `head_branch` and `head_sha` name `main` on every one of these and no field on the run object says which PR asked. The filter was therefore a `SINCE` stamp over a 20-run page — about 90 seconds — and the task's own comment priced the residual risk as "a concurrent refusal on the same PR would be read as ours". Measured 2026-08-11: 400 runs in 30 minutes, 243 of them refusals. The window was not one concurrent refusal on our PR, it was 243 strangers' refusals across the fleet, so a lap polling after commenting would near-certainly find one and report a refusal nobody gave it. That is how "the bot is silent or slow" was inferred while the bot was answering every one of 248 attempts inside 23 seconds. `fast-forward.yml` now mints `run-name: fast-forward #<issue> @<comment-id>`, which surfaces as `display_title`, and the filter matches it exactly. Both interpolations are integers from the event payload — never `comment.body`, which is attacker-controlled. The comment id is the stronger half: it names this lap's request on this head, so it subsumes the `SINCE` fence against an earlier lap of the same PR. `created=>=` moves the window server-side so page size stops being a silent second bound. CLOUD-408 — THE SUCCESS LINE WAS UNCONDITIONAL. Measured on PR #330: GitHub answered the secondary rate limit on comment creation, `gh` exited non-zero, nothing read it, and `land` printed "commented /fast-forward … waiting for the merge" over a comment that was never created — then blocked waiting for a merge nothing had been asked to perform. The POST goes through `gh api` now, which returns the created object, so the message is a consequence of an id existing and the id is the key the filter above needs. CLOUD-414 — THE EXIT STATUS WAS NOT READ. On a 403 `gh` writes the error body to STDOUT; the filter failed on it and the unfiltered body reached `$refused`, where the test was `[ -z ]`. Any non-empty string was a refusal, so a transport error was indistinguishable from a verdict — CLOUD-235's shape with the sign flipped. `2>/dev/null` stays: silencing stderr was never the defect, silently trusting stdout was. CLOUD-413 — TWO OUTCOMES WHERE THERE ARE THREE. Every non-success conclusion, a rate-limited 403 among them, was narrated as "main moved under the branch" and fell into a full lap. Across 24 laps of one landing that diagnosis was wrong twice over: 7 of 8 laps in one run reached green CI, and several refusals were the limit rather than `main`. So the loop's answer to being rate-limited was to generate more of exactly the request that was rate-limited. Now: `refused` requires a KEYED failure; `moved` may be set only by `main-watch`, because that is a fact about a ref and not an inference from a conclusion; everything else is `unknown` and re-asks. The re-ask is free by construction on an unmoved `main` — the verify receipt still keys to this HEAD, `graded_runs` is non-zero so neither the ready nor the `--undo` re-fire can fire, and the force-push moves nothing so there is no `synchronize` event and no run. It is bounded by `LAND_ANSWER_MAX_UNKNOWNS`, a COUNT of unreadable answers in the sense `mem:github-rest-etiquette` calls the one place a retry cap belongs, never a clock on the poll. One existing case is REWRITTEN rather than added to: "a cancelled run is a refusal too" pinned exactly the behaviour CLOUD-413 says is wrong. Counters move to 20 stops and 13 lap-ending continues. Refs: CLOUD-408, CLOUD-409, CLOUD-413, CLOUD-414, CLOUD-456 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
`tests/main-watch.bats` sets the poll interval to a fraction (CLOUD-390), and
the file's own floor case went red immediately:
if [ -n "$server_floor" ] && [ "$server_floor" -gt "$interval" ] 2>/dev/null
`-gt` is an integer comparison, and the `2>/dev/null` beside it converts "this
interval is not an integer" into "the server did not ask for a floor". So any
fractional MAIN_WATCH_INTERVAL silently discarded the `X-Poll-Interval` the
endpoint is entitled to set — a protocol obligation dropped because of how the
local interval happens to be spelled, with nothing reported anywhere. `sleep`
already takes fractions, which is what makes such an interval legal at all; the
comparison was the only thing assuming otherwise.
Numeric comparison in awk, `+0` on both sides, a non-numeric floor reading as 0
— no floor, which is the fail-open direction.
MEASURED, AND THE SPEEDUP CLOUD-390 PREDICTED IS NOT THERE: this file runs 34.9s
at interval 1 and 35.0s at 0.2, back to back on one box. The poll interval is not
its pole, so the issue's "five tests each burn ~3s waiting out poll cycles" does
not survive measurement. Recorded on CLOUD-390 with the numbers, recommending
that issue narrow to its `tests/land.bats` half — where the `sleep 30` stubs are
real and the never-answering loop is both faster and a more faithful model.
The fractional interval is kept even though it buys no time: reverting it would
re-hide the defect above, which is the only reason it was found.
Refs: CLOUD-390
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
An agent on a clone predating this series reads three of its shapes as defects —
a lease held by a live process rather than a file the tree owns, a raced wait
with no wall clock anywhere, and verdicts that refuse to answer instead of
guessing. Repairing any of them back is the regression, and prose is feedforward
only, so the memory names the four gates that refuse the repair and gives each
one's bypass — a refusal has to be distinguishable from a defect.
It records what the design was pressure-tested into rather than what it looks
like, because the reasons are not recoverable from the code: the lease is a
BRANCH because the proxy 403s any push outside `refs/heads` and GitHub does not
enforce the fast-forward rule off it either; the body carries a nonce because
git addresses by content, so two mints agreeing on holder and expiry produce the
same sha and pushing it is an "up to date" no-op that reports success — a
rejected claim reading as a win; the TTL rolls because a static one has to bound
how long a hold might legitimately take, which is a guess wrong in both
directions.
And the vacuous-pass failure mode, which is worse than the flake it looks like:
a guessed `sleep` standing in for "the background thing has happened" makes a
case pass because the process never ran, so the regression is invisible on
exactly the loaded runs where the suite is slowest.
AGENTS.md is loaded every turn and was at 199 lines against a 199 limit, so the
row is paid for rather than added: the `land` step's arrow diagram is dropped,
which `.claude/rules/toolchain.md` already carries verbatim. Net 199 — the
repo's own "link, never restate" rule paying for its own index row. The
anti-clock warning stays, because that is the half a stale agent undoes.
Also here, both found by the tooling rather than by reading:
* shfmt rewrote a backticked word in a `tests/land.bats` comment into command
substitution — that heredoc is unquoted, so backticks are not markup there.
Reworded, with the reason, since the next author will reach for them too.
* `fuzz/Cargo.lock` was stale at 0.0.61 against a 0.0.62 workspace and
`cargo run` regenerated it. Kept rather than reverted: it is real drift on
`main` that would otherwise surface in an unrelated PR.
Refs: CLOUD-399
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
Ten sites in this suite decided their outcome on wall clock. CLOUD-450 measured
the recurrence at 2 of 4 verify/land runs on an unrelated branch, and one of them
blocked CLOUD-274's landing outright: `land` refused to push on a `verify` whose
only failure was this file, which is `land` behaving exactly as designed over a
signal that was wrong.
The pattern is CLOUD-448's, already in this file: retry the SETUP, never the
measurement, and skip with a diagnostic when the environment could not create the
precondition. What the setups now wait on is an OBSERVABLE the setup itself
produces, not an interval:
* the duration promise reads the number `land-lock` already prints — the delta
from the PREVIOUS HOLDER'S EXPIRY — which excludes bash startup, two fetches,
a commit-tree and a push. Same discrimination, none of the fork cost. The
residual is stated rather than hidden: still wall clock between two instants,
a strictly smaller window, not a proof.
* every beat MOVES THE LEASE SHA, because `swap` mints a fresh nonce. So "the
sha moved" is proof a beat executed, and it stays true under the mutant each
case exists to catch — the tether deleted still renews, and a renew still
moves the sha — which is what makes skipping on it erase no coverage.
TWO ROWS WERE NOT FLAKY, THEY WERE VACUOUS, and one is worse than this issue
recorded. The healthy-hold cases asserted `status` says "held by" — but a
tombstoned lease renders as "released — last held by <id>", and "held by" is a
SUBSTRING of that. So those rows could not distinguish a healthy renew from a
wrongly-fired tether on ANY run, loaded or not. They now assert the absence of
"released", which is the thing that actually differs.
The dead-lease case was also green through the wrong branch: its first sighting
was meant to refuse, but `acquire` checks its deadline AFTER the steal test, so a
1s wait let probe 2 corroborate and steal — the rival then held a fresh 120s
lease and the rows below passed through `already held by this clone`, never
through corroboration. The `|| true` hid it. The refusal is now unmissable
(a beat that cannot be corroborated inside the wait) and is asserted.
Correction to the issue: that wrong branch is DOMINANT but not deterministic —
`acquire` computes its deadline before the first probe, so which branch runs
depends on whether the first ls-remote crosses a second boundary. Recorded so the
next reader does not treat it as certain.
Verified: 67 passed, 0 failed — identical to the baseline on the committed file,
so no pre-existing row broke. Run under load from two sibling agents, and the
four new proof-of-beat preconditions produced zero skips.
NOT FIXED, and left as found: shellcheck SC2314 fires on two pre-existing
`! kill -0 "$pid"` lines, on the committed file as well as this one. Out of
scope for a CLOUD-450 change and it belongs with CLOUD-382, which owns the
suite's shellcheck findings.
Refs: CLOUD-450
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
Measured on PR #280: `land` pushed and readied, the head carried exactly two graded runs — `commit-lint` and a third-party analyzer — and `ci-wait` printed "every required check terminal and green". Two minutes later the same head carried `ci`, `cross` and `darwin-link` all in_progress, plus `final`, which had not registered at all. The fast-forward bot was then rejected: `Required status check "final" is expected`. Branch protection held the line, and that is also the tell — this repo's gate and GitHub's required set disagreed about whether the head had an answer, and GitHub was right. Reproducible in one line against the committed roster: CHECKS_GREEN_RUNS=$'completed\tsuccess\tcommit-lint' ./mise-tasks/checks-green => EXIT 0 `pending == 0` is trivially satisfied when the jobs do not exist, so a poll landing between the push and GitHub creating the check-runs saw a small, fully graded set and called it green. ABSENCE IS LEGITIMATE FOR EXACTLY TWO NAMES, and each earns it twice over: `zizmor` and `action` are `paths:`-filtered AND their workflows declare no `types:`, so they take the default [opened, synchronize, reopened] — which omits `ready_for_review`, precisely the event `land`'s `--undo` re-fire emits. The second reason was written down nowhere. `CI_ABSENT_OK_CHECKS` carries the list, and unset or empty is the STRICT direction: everything must be present, because a stall is recoverable and a false green is not. THE ORDERING IS THE SUBTLE PART, and it is asserted rather than assumed. A real failure OUTRANKS a missing name, which is the opposite of how the cancelled bucket works — and the asymmetry has a reason. A cancelled sibling can MANUFACTURE a fan-in failure (CLOUD-363's #293 set), so no-verdict must precede red there. An absent name manufactures nothing; it is a run that has not started, so a failure beside it is an independent verdict and must still re-draft the PR. Not mirrored into `land`'s `graded_runs`: that asks "does any name carry an answer" to decide whether to fire the ready, and requiring presence there would read a healthy partial set as 0 and buy a second run. `tests/checks-green.bats`'s "an absent path-filtered check is not a skipped one" is REWRITTEN — it asserted this false green with a single-name reading. Two things worth knowing for the next reader of this suite: * IN `ci-wait.bats` THE BREAKAGE IS A HANG, NOT A FAILED ASSERTION. The poll is unbounded by design, so a `resp.last` missing a mandatory name never terminates and the rows using a bare `run "$WAIT"` stall the whole suite rather than reporting. That is why the fixture helper emits the full mandatory set rather than the one name each row cares about. * A LATENT ORDERING WART decided the helper's shape: a 3-field TSV row always outranks a 5-field row of the same name, because the empty `started_at` makes the sort key start with `|` (0x7C), which sorts above any ISO-8601 digit. It is fail-closed in effect today, so it is recorded rather than changed here. `ci-wait`'s check-runs request also gains `?per_page=100`, matching every other reader. After this, `land`'s `graded_runs` is the only one left on the default 30. Verified: 33 rows green across both suites; shfmt, shellcheck and taplo clean. Refs: CLOUD-337 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
Two changes, both replacing a margin with a mechanism. CLOUD-457 — the detached-descendant case gave a detached child three wall-clock seconds to be scheduled and flush its pid file. On a loaded box that is a race, and it fails in the wrong direction: it reads as "the descendant was never detached" when the truth is "it had not run yet". THE TICKET'S OWN PRESCRIBED FIX NO LONGER WORKS, and that is recorded on it. It says poll until the pid file fills OR the spawning `land` has exited — but CLOUD-434 moved the spawn into the `verify` stub, so `run "$LAND"` has already returned before the loop starts. The condition is true on entry, every time, whether or not the child was ever scheduled. The corrected mechanism puts the precondition where the two states are still distinguishable: the SPAWNER waits for the pid file and leaves a marker if it never fills, and the body skips on that marker rather than on a clock. Skipping there erases no coverage, which is what makes it honest rather than a dodge: the mutant this case exists to catch is the launcher's fd-closing being deleted, and that mutant still spawns the child and still publishes the pid. It fails on the fd assertion, which is never skipped. CLOUD-390 — two `sleep 30` stubs were guessed margins chosen to outlast a racing `main-watch`. A `ci-wait` that never answers is a strictly more faithful model of "CI is still running" than one that answers after an arbitrary 30 seconds, and it is the model the `main-watch` stub a few lines below already uses for its own loser. The old form also let a broken reap pass SLOWLY instead of failing, since the loser terminated on its own. The detach stand-in's `sleep 30` is deliberately left: that is a bounded lifetime for an intentionally unreapable process, not a modelled wait, and the file already argues for it. REQUIRED COMPANION, because the old form was self-limiting and this one is not: a `teardown` for this file, which had none. A regressed reap used to leak a 30-second loop and would now leak an immortal one. Matched on the per-test stub path, so it cannot reach a sibling's processes under the parallel runner — `tests/land-lock.bats` makes the same argument for its holds. Verification of this file was still in flight when this was committed; the result follows. Refs: CLOUD-457, CLOUD-390 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
…ase, do not repair The threat this series carries is not malice, it is an agent on a clone that predates it reading its shape as breakage and repairing it back. A wait with no clock, a verdict that answers "no answer yet", a lease held by a live process: each looks like a defect from an older tree, and each is the change. Prose is feedforward only, so the rule ships with the gate that refuses the repair. This is the predicate that fires BEFORE the rebase whose conflicts in `mise-tasks/land*` and `tests/land*.bats` are exactly where "take ours" undoes the work — caught at the ready rather than at review, which is after the CI minutes are spent. THE LANDING COMMIT IS DERIVED, NOT STORED, and the chicken-and-egg is the reason. Its sha is minted by the commit that introduces this predicate, so no file that commit adds can name it; and a rebase or a cherry-pick mints a different sha anyway, so a hardcoded value would be wrong in every clone that did not receive the exact original object. What is stable in every clone is a fact of history — the commit that first ADDED `.serena/memories/workflow/landing-loop.md`, the memory this same series ships. A structural row asserts no 40-hex literal ever appears in the task, so the next author cannot "simplify" it into one. Read along `origin/main`, never HEAD: on the stale branch this exists to catch, the marker is absent from HEAD's history entirely, so deriving from HEAD would answer "no landing commit" and fail open on precisely the case that matters. `tail -1` takes the OLDEST addition, so a delete-and-re-add cannot move the epoch forward and quietly re-admit a stale base. FAILS OPEN while a clone's `origin/main` has never carried the landing commit. During rollout that is not an edge case, it is every clone — a guard that refused there would stop landing everywhere the moment it shipped. The message points at a rebase and at the memory, and names no file to edit, because "fix mise-tasks/land" is the instruction that produces the repair. A row asserts that. Mutation-checked per CLOUD-418: with the predicate deleted, exactly the two discriminating rows go red while the negative control and the fail-open row stay green. 24 rows, all passing. Honest limit, since this guard runs from the tree: a clone that never fetches runs the OLD guard and this never fires. It catches the agent who rebased and is about to repair, not the one who never looked. `branch-age-check` is the backstop there — a branch old enough to be dangerous is already failing a gate for age. Refs: CLOUD-420 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
CLOUD-467's guard was landed in the wrong place twice, and both were only
visible by running it.
First it sat inside `graded_runs`. Both call sites invoke that as
`$(graded_runs "$sha")`, so `die` exited the SUBSHELL and the lap carried on with
an empty reading — then fell into the answer poll, which is unbounded by design.
A guard written to fail closed became a hang: `tests/land.bats` stopped
terminating at all, and the row asserting the guard was the row that hung.
Then it sat above `die()`'s own definition, so it called a function that did not
exist yet and fell through silently — `land: line 133: die: command not found`,
with the lap continuing as though nothing had been checked.
It now sits immediately after `die()`, at top level, where an exit exits the
task. Verified directly rather than inferred:
CI_REQUIRED_CHECKS= ./mise-tasks/land
::error:: land: CI_REQUIRED_CHECKS is unset — run this through `mise run land` …
The general shape is worth keeping in view when reading this file: a precondition
evaluated inside a command substitution cannot stop the caller, and this task
wraps several of its readers that way.
Refs: CLOUD-467
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
…est claims Two defects I introduced with the keyed fast-forward verdict, both found by rows that were already asserting the right thing. THE WINDOW MOVED SERVER-SIDE AND THE FENCE WENT WITH IT. Keying the verdict to `display_title` made the comment id the discriminator, so the `created` query parameter looked sufficient and the client-side `select(.created_at >= env.SINCE)` came out. A query parameter is an OPTIMISATION: mistype it, or meet an endpoint that ignores it, and the fence disappears with nothing failing. The client-side select is the half that actually holds the line, and it is what stops an earlier lap's own run being re-read as this lap's verdict — the livelock the stamp exists to prevent, and the property `a run that predates this lap is not read as a verdict on it` has asserted since CLOUD-235. Both fences now, and the comment says which one is load-bearing. THE FREE-LAP ROW ASSERTED A PROPERTY ITS FIXTURE COULD NOT EXHIBIT. `an unreadable answer re-asks without buying a CI run` rests on the head already carrying a graded run, which is what makes `graded_runs` non-zero and keeps both readies from firing. The default fixture leaves check-runs EMPTY, so `graded_runs` answered 0 — the branch that fires the ready — and the row was measuring the opposite of its own claim. It now sets a graded head explicitly and asserts zero readies rather than "at most one", which is the real property. Verified by reading the TAP file rather than a pipe: 4 of 4 green, exit 0, including the two rows above and the keyed-refusal negative control. Refs: CLOUD-409, CLOUD-414, CLOUD-235 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzqP3AFxFqCm3F4DfCdS92
The verdict-bearing table omitted the command every shell gate in this repo is decided by. `mise run test:bats` was covered by the `mise` row, so the same suite was guarded through one spelling and open through the other two — `mise exec -- bats …`, which is the web sandbox's working form, and a path-invoked `tests/bats/bin/bats`. Measured in this session: a suite piped into `tail` reported green over three failures, one of them a hang, and the pipeline's exit status was tail's throughout. This file's own suite had asserted the hole: `mise exec -- bats tests/foo.bats | tail -3` was the ALLOWED example in the wrapper test, which is why the omission survived a review that read the table. That row now demonstrates the wrapper skip with a read-only `git log`, and four rows cover bats directly — the measured pipe, both wrapper spellings, the argument-less forms that answer nothing and stay allowed, and the compliant redirect. Refs: CLOUD-473
The CLOUD-420 tolerance assertion greps the workflow files for the text `bash -c "$body"`. Inside single quotes that is the YAML's own variable, not this script's — SC2016 is right that it does not expand and wrong about what that means here, so it is disabled per line with the reason. Caught by `mise run verify`, not by the pre-commit gate. Refs: CLOUD-420
|
|
/fast-forward |



One land cycle for the outstanding landing-loop work. Draft for the whole series —
CI does not run on drafts,
verifyruns locally as often as it takes, and the PR isreadied exactly once, by
land, at the end. Target: one CI run for the wholeseries, against the 2.9 runs per landed PR measured on CLOUD-393.
Why one PR
Thirteen tickets, six shared files, two of them over 800 lines. They are independent
to diagnose and overlapping to edit, so a chain of dependent PRs would be a day of
laps — which is the disease this PR treats. Anything that does not fit stays a
ticket rather than becoming a second branch.
Scope
Deliberately out: the board-state gates (CLOUD-469, 192, and 468, whose
done-checkis already open as #370),
ship(CLOUD-188), least-conflict ordering (CLOUD-459).Each is a different object, not a smaller one.
Absorbed from sibling branches
Only the landing-loop hunks, so the rest of each branch stays with its owner:
tests/helpers.bashand the loop suites' call sites. Twohunks needed a hand resolution because
mainmoved after that branch forked:CLOUD-434's fd-3 wrapper and CLOUD-464's
124 OR 137assertion both had tosurvive alongside the portability shims. Neither side alone was correct.
CI_REQUIRED_CHECKSadditions are not cherry-picked at all: once the set is derived from the committed
workflows, neither branch needs to touch
mise.toml. Deriving it also surfaces afinding feat(action): assert every exit code the Action can return, not just the one that means yes (CLOUD-110) #341 would otherwise land silently — its three-OS
actionmatrix mintsthree check-runs sharing one name, and
checks-greenjudges a name by its latestrun, so a red macOS leg finishing before a green Windows leg reads green.
Cost budget this PR is accountable to
CLAUDE.mdtable row rather than aparagraph, the new memory read on demand at its trigger,
contract-drift'swidened watch keyed so it fires once per drift rather than once per session.
Verification
mise run verifyon a branch rebased on currentorigin/main, the shell suites runrepeatedly under the parallel runner since the flake class only appears there, then
mise run land. Landing this PR through the loop it repairs is the acceptance test.Refs: CLOUD-399, CLOUD-393, CLOUD-420, CLOUD-467, CLOUD-376, CLOUD-363, CLOUD-470, CLOUD-456, CLOUD-409, CLOUD-408, CLOUD-413, CLOUD-414, CLOUD-450, CLOUD-457, CLOUD-390, CLOUD-385, CLOUD-337, CLOUD-334, CLOUD-345, CLOUD-383, CLOUD-282
Generated by Claude Code