diff --git a/.github/workflows/branch-hygiene.yml b/.github/workflows/branch-hygiene.yml index fe360ebd2..982d6f594 100644 --- a/.github/workflows/branch-hygiene.yml +++ b/.github/workflows/branch-hygiene.yml @@ -61,4 +61,26 @@ jobs: # LAND_LOCK_MAX_WAITS and stops with the saturation message inside ~16 # minutes. What this adds is the DIAGNOSIS — busy versus wedged — for the # one state nothing else can distinguish. - - run: mise run land-lock-check + # + # THE SUCCESSOR IS THE ENGINE'S (CLOUD-1148). `mise-tasks/land-lock-check.sh` + # is retired; `batten lease check` decides the same six states through + # `lease::health`, which is the one implementation of that predicate rather + # than a second reader of the same ref. + # + # THE EXIT NUMBERS MOVED AND THIS CALLER CANNOT TELL. The predecessor + # answered `0` healthy, `1` wedged-or-garbage, `2` could not look; the engine + # has one table with no per-verb exception, so a verdict is `2` and a + # could-not-look is `3`. A step fails on any non-zero either way, so no shim + # is needed here — which is the whole reason this repointing is one line + # rather than a translation task. + # + # `./install.sh` RATHER THAN A BUILD, for `auto-bot-land.yml`'s reason: the + # job installs `gh` and `jq` deliberately and has no Rust toolchain, and the + # committed script is POSIX sh that verifies every asset against the digest + # the release API reports. Run from the checkout, so `install-check` still + # governs what this executes. + - name: Install the released batten, which the lease gate is now a verb of + env: + GH_TOKEN: ${{ github.token }} + run: ./install.sh + - run: batten lease check diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abc02eb7c..2eecdf6ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,35 +122,77 @@ jobs: # CLOUD-420: THE LANDING LEASE, ENFORCED WHERE THE MONEY IS SPENT. # First step, before any checkout or toolchain install, because a run this # branch is not authorised to make should cost the rounding rather than a - # matrix. The body is `mise-tasks/ci-lease-precondition.sh` and every - # justification lives there; it is fetched from `main` rather than from - # this head, so a clone carrying stale rules cannot dodge the predicate by - # carrying a stale copy of it. An unreadable body runs — fail open, which - # is the whole posture: waving one matrix through costs one matrix, while - # a predicate that cannot read itself would stop the fleet. + # matrix. The body runs `batten lease guard`, installed from `main` and + # pinned to trunk's version rather than to this head's, so a clone + # carrying stale rules cannot dodge the predicate by carrying a stale copy + # of it. Every justification lives on `run_lease_guard`. THREE DIFFERENT + # FAILURES FAIL OPEN below, one `|| exit 0` each: a binary that will not + # download, a policy that will not fetch, and a guard that will not run. + # That is the whole posture — waving one matrix through costs one matrix, + # while a precondition that cannot reach its own inputs would stop the + # fleet. What keeps those three from reading as *ran and allowed* is + # trunk's installer refusing a binary that does not carry the verb + # (`BATTEN_REQUIRE`); until that installer IS trunk's, this step is + # vacuous by construction rather than by accident. + # + # AND IT WAS VACUOUS FOR A SECOND REASON THE PARAGRAPH ABOVE HID (review of + # #848). `$RUNNER_TEMP/batten-bin` is not on PATH, and `install.sh`'s + # off-PATH refusal is a `die 1` that fires BEFORE the `BATTEN_REQUIRE` + # check — so the `|| exit 0` ended the step at the install and the guard + # never ran, at any of the sites, whatever the installer's provenance. The + # bootstrap above is real and was not the reason; it just described the + # same silence convincingly enough that nobody looked further. + # + # `BATTEN_ALLOW_OFF_PATH=1` is the opt-out for exactly this shape and the + # binary is invoked by absolute path two lines down, so nothing here + # resolves `batten` by name and the refusal is protecting a caller that + # does not exist. Installing onto PATH instead would put a trunk-pinned + # binary ahead of the checkout's own for every later step in the job. - name: Landing lease precondition env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} LEASE_HEAD_REF: ${{ github.head_ref }} # The HEAD sha, never `github.sha`: on a pull_request event that is the - # merge commit, which carries trunk's `mise-tasks/land.sh` whenever this - # head did not touch it — and the staleness row reads exactly that file. + # merge commit, whose tree is trunk's wherever this head did not touch + # it — and the staleness read asks about the head's own landing paths. LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - # `|| exit 0` on BOTH lines, and the second is the one that was missing - # (CLOUD-420). A fetch that fails is handled; a fetch that SUCCEEDS with - # something that is not shell was not — a truncated body, 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 job. The step reds under the runner's - # default `bash -e {0}`, `final` fails its `needs:` assertion under - # `always()`, and `land` re-drafts a healthy PR — fleet-wide, from one bad - # response. The precondition's own header promises it never exits - # non-zero; this is what makes that true at the call site too. - bash -c "$body" || exit 0 + # `|| exit 0` ON EVERY LINE, and the reason outlives the shell it was + # written for (CLOUD-420). A step that reds makes the RUN's conclusion + # `failure` rather than `cancelled`; `final` then runs under + # `!cancelled()`, fails its `needs:` assertion, and `land` re-drafts a + # healthy PR — fleet-wide, from one bad response. `batten lease guard` + # promises it never exits non-zero; these are what make that true at the + # call site too, for the cases the promise cannot cover: a binary that + # will not download and one that will not run are different failures + # from one that ran and decided. + # + # THE VERSION IS TRUNK'S, NEVER THIS HEAD'S. The installer comes from + # trunk and resolves the version from trunk's own manifest, which is the + # property the fetched-script design protected by reading its logic from + # trunk — a head cannot pin an older guard for itself. + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 # RECLAIM RUNNER DISK, BEFORE ANYTHING WRITES ITS OWN GIGABYTES. # @@ -641,9 +683,26 @@ jobs: LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - bash -c "$body" || exit 0 + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -774,9 +833,26 @@ jobs: LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - bash -c "$body" || exit 0 + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -927,9 +1003,26 @@ jobs: LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - bash -c "$body" || exit 0 + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -1077,7 +1170,7 @@ jobs: # `success` is the only conclusion GitHub lets a job report as "fine", so a # run that was DECLINED must report something else, and red is the only # something else a job that ran can produce. The misleading part of that red - # is answered where it belongs — `ci-lease-precondition` emits an `::error::` + # is answered where it belongs — `batten lease guard` emits an `::error::` # annotation naming the lease and the remedy, so the run says why. # # This is also why `always()` is not merely the old value restored: it now diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml index 4cbac120d..d30cb9c5a 100644 --- a/.github/workflows/commit-lint.yml +++ b/.github/workflows/commit-lint.yml @@ -67,35 +67,77 @@ jobs: # CLOUD-420: THE LANDING LEASE, ENFORCED WHERE THE MONEY IS SPENT. # First step, before any checkout or toolchain install, because a run this # branch is not authorised to make should cost the rounding rather than a - # matrix. The body is `mise-tasks/ci-lease-precondition.sh` and every - # justification lives there; it is fetched from `main` rather than from - # this head, so a clone carrying stale rules cannot dodge the predicate by - # carrying a stale copy of it. An unreadable body runs — fail open, which - # is the whole posture: waving one matrix through costs one matrix, while - # a predicate that cannot read itself would stop the fleet. + # matrix. The body runs `batten lease guard`, installed from `main` and + # pinned to trunk's version rather than to this head's, so a clone + # carrying stale rules cannot dodge the predicate by carrying a stale copy + # of it. Every justification lives on `run_lease_guard`. THREE DIFFERENT + # FAILURES FAIL OPEN below, one `|| exit 0` each: a binary that will not + # download, a policy that will not fetch, and a guard that will not run. + # That is the whole posture — waving one matrix through costs one matrix, + # while a precondition that cannot reach its own inputs would stop the + # fleet. What keeps those three from reading as *ran and allowed* is + # trunk's installer refusing a binary that does not carry the verb + # (`BATTEN_REQUIRE`); until that installer IS trunk's, this step is + # vacuous by construction rather than by accident. + # + # AND IT WAS VACUOUS FOR A SECOND REASON THE PARAGRAPH ABOVE HID (review of + # #848). `$RUNNER_TEMP/batten-bin` is not on PATH, and `install.sh`'s + # off-PATH refusal is a `die 1` that fires BEFORE the `BATTEN_REQUIRE` + # check — so the `|| exit 0` ended the step at the install and the guard + # never ran, at any of the sites, whatever the installer's provenance. The + # bootstrap above is real and was not the reason; it just described the + # same silence convincingly enough that nobody looked further. + # + # `BATTEN_ALLOW_OFF_PATH=1` is the opt-out for exactly this shape and the + # binary is invoked by absolute path two lines down, so nothing here + # resolves `batten` by name and the refusal is protecting a caller that + # does not exist. Installing onto PATH instead would put a trunk-pinned + # binary ahead of the checkout's own for every later step in the job. - name: Landing lease precondition env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} LEASE_HEAD_REF: ${{ github.head_ref }} # The HEAD sha, never `github.sha`: on a pull_request event that is the - # merge commit, which carries trunk's `mise-tasks/land.sh` whenever this - # head did not touch it — and the staleness row reads exactly that file. + # merge commit, whose tree is trunk's wherever this head did not touch + # it — and the staleness read asks about the head's own landing paths. LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - # `|| exit 0` on BOTH lines, and the second is the one that was missing - # (CLOUD-420). A fetch that fails is handled; a fetch that SUCCEEDS with - # something that is not shell was not — a truncated body, 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 job. The step reds under the runner's - # default `bash -e {0}`, `final` fails its `needs:` assertion under - # `always()`, and `land` re-drafts a healthy PR — fleet-wide, from one bad - # response. The precondition's own header promises it never exits - # non-zero; this is what makes that true at the call site too. - bash -c "$body" || exit 0 + # `|| exit 0` ON EVERY LINE, and the reason outlives the shell it was + # written for (CLOUD-420). A step that reds makes the RUN's conclusion + # `failure` rather than `cancelled`; `final` then runs under + # `!cancelled()`, fails its `needs:` assertion, and `land` re-drafts a + # healthy PR — fleet-wide, from one bad response. `batten lease guard` + # promises it never exits non-zero; these are what make that true at the + # call site too, for the cases the promise cannot cover: a binary that + # will not download and one that will not run are different failures + # from one that ran and decided. + # + # THE VERSION IS TRUNK'S, NEVER THIS HEAD'S. The installer comes from + # trunk and resolves the version from trunk's own manifest, which is the + # property the fetched-script design protected by reading its logic from + # trunk — a head cannot pin an older guard for itself. + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 87470f2f6..80c2f9ebf 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -86,35 +86,77 @@ jobs: # CLOUD-420: THE LANDING LEASE, ENFORCED WHERE THE MONEY IS SPENT. # First step, before any checkout or toolchain install, because a run this # branch is not authorised to make should cost the rounding rather than a - # matrix. The body is `mise-tasks/ci-lease-precondition.sh` and every - # justification lives there; it is fetched from `main` rather than from - # this head, so a clone carrying stale rules cannot dodge the predicate by - # carrying a stale copy of it. An unreadable body runs — fail open, which - # is the whole posture: waving one matrix through costs one matrix, while - # a predicate that cannot read itself would stop the fleet. + # matrix. The body runs `batten lease guard`, installed from `main` and + # pinned to trunk's version rather than to this head's, so a clone + # carrying stale rules cannot dodge the predicate by carrying a stale copy + # of it. Every justification lives on `run_lease_guard`. THREE DIFFERENT + # FAILURES FAIL OPEN below, one `|| exit 0` each: a binary that will not + # download, a policy that will not fetch, and a guard that will not run. + # That is the whole posture — waving one matrix through costs one matrix, + # while a precondition that cannot reach its own inputs would stop the + # fleet. What keeps those three from reading as *ran and allowed* is + # trunk's installer refusing a binary that does not carry the verb + # (`BATTEN_REQUIRE`); until that installer IS trunk's, this step is + # vacuous by construction rather than by accident. + # + # AND IT WAS VACUOUS FOR A SECOND REASON THE PARAGRAPH ABOVE HID (review of + # #848). `$RUNNER_TEMP/batten-bin` is not on PATH, and `install.sh`'s + # off-PATH refusal is a `die 1` that fires BEFORE the `BATTEN_REQUIRE` + # check — so the `|| exit 0` ended the step at the install and the guard + # never ran, at any of the sites, whatever the installer's provenance. The + # bootstrap above is real and was not the reason; it just described the + # same silence convincingly enough that nobody looked further. + # + # `BATTEN_ALLOW_OFF_PATH=1` is the opt-out for exactly this shape and the + # binary is invoked by absolute path two lines down, so nothing here + # resolves `batten` by name and the refusal is protecting a caller that + # does not exist. Installing onto PATH instead would put a trunk-pinned + # binary ahead of the checkout's own for every later step in the job. - name: Landing lease precondition env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} LEASE_HEAD_REF: ${{ github.head_ref }} # The HEAD sha, never `github.sha`: on a pull_request event that is the - # merge commit, which carries trunk's `mise-tasks/land.sh` whenever this - # head did not touch it — and the staleness row reads exactly that file. + # merge commit, whose tree is trunk's wherever this head did not touch + # it — and the staleness read asks about the head's own landing paths. LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - # `|| exit 0` on BOTH lines, and the second is the one that was missing - # (CLOUD-420). A fetch that fails is handled; a fetch that SUCCEEDS with - # something that is not shell was not — a truncated body, 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 job. The step reds under the runner's - # default `bash -e {0}`, `final` fails its `needs:` assertion under - # `always()`, and `land` re-drafts a healthy PR — fleet-wide, from one bad - # response. The precondition's own header promises it never exits - # non-zero; this is what makes that true at the call site too. - bash -c "$body" || exit 0 + # `|| exit 0` ON EVERY LINE, and the reason outlives the shell it was + # written for (CLOUD-420). A step that reds makes the RUN's conclusion + # `failure` rather than `cancelled`; `final` then runs under + # `!cancelled()`, fails its `needs:` assertion, and `land` re-drafts a + # healthy PR — fleet-wide, from one bad response. `batten lease guard` + # promises it never exits non-zero; these are what make that true at the + # call site too, for the cases the promise cannot cover: a binary that + # will not download and one that will not run are different failures + # from one that ran and decided. + # + # THE VERSION IS TRUNK'S, NEVER THIS HEAD'S. The installer comes from + # trunk and resolves the version from trunk's own manifest, which is the + # property the fetched-script design protected by reading its logic from + # trunk — a head cannot pin an older guard for itself. + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -171,35 +213,77 @@ jobs: # CLOUD-420: THE LANDING LEASE, ENFORCED WHERE THE MONEY IS SPENT. # First step, before any checkout or toolchain install, because a run this # branch is not authorised to make should cost the rounding rather than a - # matrix. The body is `mise-tasks/ci-lease-precondition.sh` and every - # justification lives there; it is fetched from `main` rather than from - # this head, so a clone carrying stale rules cannot dodge the predicate by - # carrying a stale copy of it. An unreadable body runs — fail open, which - # is the whole posture: waving one matrix through costs one matrix, while - # a predicate that cannot read itself would stop the fleet. + # matrix. The body runs `batten lease guard`, installed from `main` and + # pinned to trunk's version rather than to this head's, so a clone + # carrying stale rules cannot dodge the predicate by carrying a stale copy + # of it. Every justification lives on `run_lease_guard`. THREE DIFFERENT + # FAILURES FAIL OPEN below, one `|| exit 0` each: a binary that will not + # download, a policy that will not fetch, and a guard that will not run. + # That is the whole posture — waving one matrix through costs one matrix, + # while a precondition that cannot reach its own inputs would stop the + # fleet. What keeps those three from reading as *ran and allowed* is + # trunk's installer refusing a binary that does not carry the verb + # (`BATTEN_REQUIRE`); until that installer IS trunk's, this step is + # vacuous by construction rather than by accident. + # + # AND IT WAS VACUOUS FOR A SECOND REASON THE PARAGRAPH ABOVE HID (review of + # #848). `$RUNNER_TEMP/batten-bin` is not on PATH, and `install.sh`'s + # off-PATH refusal is a `die 1` that fires BEFORE the `BATTEN_REQUIRE` + # check — so the `|| exit 0` ended the step at the install and the guard + # never ran, at any of the sites, whatever the installer's provenance. The + # bootstrap above is real and was not the reason; it just described the + # same silence convincingly enough that nobody looked further. + # + # `BATTEN_ALLOW_OFF_PATH=1` is the opt-out for exactly this shape and the + # binary is invoked by absolute path two lines down, so nothing here + # resolves `batten` by name and the refusal is protecting a caller that + # does not exist. Installing onto PATH instead would put a trunk-pinned + # binary ahead of the checkout's own for every later step in the job. - name: Landing lease precondition env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} LEASE_HEAD_REF: ${{ github.head_ref }} # The HEAD sha, never `github.sha`: on a pull_request event that is the - # merge commit, which carries trunk's `mise-tasks/land.sh` whenever this - # head did not touch it — and the staleness row reads exactly that file. + # merge commit, whose tree is trunk's wherever this head did not touch + # it — and the staleness read asks about the head's own landing paths. LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - # `|| exit 0` on BOTH lines, and the second is the one that was missing - # (CLOUD-420). A fetch that fails is handled; a fetch that SUCCEEDS with - # something that is not shell was not — a truncated body, 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 job. The step reds under the runner's - # default `bash -e {0}`, `final` fails its `needs:` assertion under - # `always()`, and `land` re-drafts a healthy PR — fleet-wide, from one bad - # response. The precondition's own header promises it never exits - # non-zero; this is what makes that true at the call site too. - bash -c "$body" || exit 0 + # `|| exit 0` ON EVERY LINE, and the reason outlives the shell it was + # written for (CLOUD-420). A step that reds makes the RUN's conclusion + # `failure` rather than `cancelled`; `final` then runs under + # `!cancelled()`, fails its `needs:` assertion, and `land` re-drafts a + # healthy PR — fleet-wide, from one bad response. `batten lease guard` + # promises it never exits non-zero; these are what make that true at the + # call site too, for the cases the promise cannot cover: a binary that + # will not download and one that will not run are different failures + # from one that ran and decided. + # + # THE VERSION IS TRUNK'S, NEVER THIS HEAD'S. The installer comes from + # trunk and resolves the version from trunk's own manifest, which is the + # property the fetched-script design protected by reading its logic from + # trunk — a head cannot pin an older guard for itself. + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) @@ -238,12 +322,32 @@ jobs: # CLOUD-420: THE LANDING LEASE, ENFORCED WHERE THE MONEY IS SPENT. # First step, before any checkout or toolchain install, because a run this # branch is not authorised to make should cost the rounding rather than a - # matrix. The body is `mise-tasks/ci-lease-precondition.sh` and every - # justification lives there; it is fetched from `main` rather than from - # this head, so a clone carrying stale rules cannot dodge the predicate by - # carrying a stale copy of it. An unreadable body runs — fail open, which - # is the whole posture: waving one matrix through costs one matrix, while - # a predicate that cannot read itself would stop the fleet. + # matrix. The body runs `batten lease guard`, installed from `main` and + # pinned to trunk's version rather than to this head's, so a clone + # carrying stale rules cannot dodge the predicate by carrying a stale copy + # of it. Every justification lives on `run_lease_guard`. THREE DIFFERENT + # FAILURES FAIL OPEN below, one `|| exit 0` each: a binary that will not + # download, a policy that will not fetch, and a guard that will not run. + # That is the whole posture — waving one matrix through costs one matrix, + # while a precondition that cannot reach its own inputs would stop the + # fleet. What keeps those three from reading as *ran and allowed* is + # trunk's installer refusing a binary that does not carry the verb + # (`BATTEN_REQUIRE`); until that installer IS trunk's, this step is + # vacuous by construction rather than by accident. + # + # AND IT WAS VACUOUS FOR A SECOND REASON THE PARAGRAPH ABOVE HID (review of + # #848). `$RUNNER_TEMP/batten-bin` is not on PATH, and `install.sh`'s + # off-PATH refusal is a `die 1` that fires BEFORE the `BATTEN_REQUIRE` + # check — so the `|| exit 0` ended the step at the install and the guard + # never ran, at any of the sites, whatever the installer's provenance. The + # bootstrap above is real and was not the reason; it just described the + # same silence convincingly enough that nobody looked further. + # + # `BATTEN_ALLOW_OFF_PATH=1` is the opt-out for exactly this shape and the + # binary is invoked by absolute path two lines down, so nothing here + # resolves `batten` by name and the refusal is protecting a caller that + # does not exist. Installing onto PATH instead would put a trunk-pinned + # binary ahead of the checkout's own for every later step in the job. # # This job in particular is the one the sensor caught: `semver` landed on # `main` while this change was in flight, and property 7 refused the merge @@ -255,23 +359,45 @@ jobs: GH_REPO: ${{ github.repository }} LEASE_HEAD_REF: ${{ github.head_ref }} # The HEAD sha, never `github.sha`: on a pull_request event that is the - # merge commit, which carries trunk's `mise-tasks/land.sh` whenever this - # head did not touch it — and the staleness row reads exactly that file. + # merge commit, whose tree is trunk's wherever this head did not touch + # it — and the staleness read asks about the head's own landing paths. LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - # `|| exit 0` on BOTH lines, and the second is the one that was missing - # (CLOUD-420). A fetch that fails is handled; a fetch that SUCCEEDS with - # something that is not shell was not — a truncated body, 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 job. The step reds under the runner's - # default `bash -e {0}`, `final` fails its `needs:` assertion under - # `always()`, and `land` re-drafts a healthy PR — fleet-wide, from one bad - # response. The precondition's own header promises it never exits - # non-zero; this is what makes that true at the call site too. - bash -c "$body" || exit 0 + # `|| exit 0` ON EVERY LINE, and the reason outlives the shell it was + # written for (CLOUD-420). A step that reds makes the RUN's conclusion + # `failure` rather than `cancelled`; `final` then runs under + # `!cancelled()`, fails its `needs:` assertion, and `land` re-drafts a + # healthy PR — fleet-wide, from one bad response. `batten lease guard` + # promises it never exits non-zero; these are what make that true at the + # call site too, for the cases the promise cannot cover: a binary that + # will not download and one that will not run are different failures + # from one that ran and decided. + # + # THE VERSION IS TRUNK'S, NEVER THIS HEAD'S. The installer comes from + # trunk and resolves the version from trunk's own manifest, which is the + # property the fetched-script design protected by reading its logic from + # trunk — a head cannot pin an older guard for itself. + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -391,7 +517,7 @@ jobs: # branch is not authorised to make should cost the rounding rather than a # matrix. Body fetched from `main`, never from this head, and `|| exit 0` # on both lines so a body that will not parse cannot red the first step of - # every job. Every justification lives in `mise-tasks/ci-lease-precondition.sh`. + # every job. Every justification lives on `run_lease_guard`. - name: Landing lease precondition # EXPLICIT, for the reason test.yml states at its own Windows legs: a # `run:` step with no `shell:` takes the runner's default, which is @@ -405,14 +531,31 @@ jobs: GH_REPO: ${{ github.repository }} LEASE_HEAD_REF: ${{ github.head_ref }} # The HEAD sha, never `github.sha`: on a pull_request event that is the - # merge commit, which carries trunk's `mise-tasks/land.sh` whenever this - # head did not touch it — and the staleness row reads exactly that file. + # merge commit, whose tree is trunk's wherever this head did not touch + # it — and the staleness read asks about the head's own landing paths. LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - bash -c "$body" || exit 0 + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a50b878e3..e104f9da7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,42 +87,100 @@ jobs: # CLOUD-420: THE LANDING LEASE, ENFORCED WHERE THE MONEY IS SPENT. # First step, before any checkout or toolchain install, because a run this # branch is not authorised to make should cost the rounding rather than a - # matrix. The body is `mise-tasks/ci-lease-precondition.sh` and every - # justification lives there; it is fetched from `main` rather than from - # this head, so a clone carrying stale rules cannot dodge the predicate by - # carrying a stale copy of it. An unreadable body runs — fail open, which - # is the whole posture: waving one matrix through costs one matrix, while - # a predicate that cannot read itself would stop the fleet. + # matrix. The body runs `batten lease guard`, installed from `main` and + # pinned to trunk's version rather than to this head's, so a clone + # carrying stale rules cannot dodge the predicate by carrying a stale copy + # of it. Every justification lives on `run_lease_guard`. THREE DIFFERENT + # FAILURES FAIL OPEN below, one `|| exit 0` each: a binary that will not + # download, a policy that will not fetch, and a guard that will not run. + # That is the whole posture — waving one matrix through costs one matrix, + # while a precondition that cannot reach its own inputs would stop the + # fleet. What keeps those three from reading as *ran and allowed* is + # trunk's installer refusing a binary that does not carry the verb + # (`BATTEN_REQUIRE`); until that installer IS trunk's, this step is + # vacuous by construction rather than by accident. + # + # AND IT WAS VACUOUS FOR A SECOND REASON THE PARAGRAPH ABOVE HID (review of + # #848). `$RUNNER_TEMP/batten-bin` is not on PATH, and `install.sh`'s + # off-PATH refusal is a `die 1` that fires BEFORE the `BATTEN_REQUIRE` + # check — so the `|| exit 0` ended the step at the install and the guard + # never ran, at any of the sites, whatever the installer's provenance. The + # bootstrap above is real and was not the reason; it just described the + # same silence convincingly enough that nobody looked further. + # + # `BATTEN_ALLOW_OFF_PATH=1` is the opt-out for exactly this shape and the + # binary is invoked by absolute path two lines down, so nothing here + # resolves `batten` by name and the refusal is protecting a caller that + # does not exist. Installing onto PATH instead would put a trunk-pinned + # binary ahead of the checkout's own for every later step in the job. - name: Landing lease precondition + # NOT ON WINDOWS, AND THE SKIP IS THE POINT (CLOUD-1460). `install.sh`'s + # `detect_target` handles `Linux` and `Darwin` and returns 1 for anything + # else, so on this matrix's Windows leg the installer dies with "no + # release target" — and the `|| exit 0` below, which exists so a guard + # that cannot run never reds a job, swallowed it. The step reported green + # having installed nothing and asked the lease nothing, which is + # byte-identical on the decision surface to a guard that ran and allowed. + # + # The condition does not close the hole: this leg is still unserialised + # against the fleet, and closing it needs a Windows target in the + # installer, which is CLOUD-1460's. What it buys is that the hole is now + # a VISIBLE skipped step rather than a green one nobody can tell from a + # real pass. Found in review of PR #848. + if: runner.os != 'Windows' # EXPLICIT, because this workflow is the one that runs on Windows. A # `run:` step with no `shell:` takes the runner's default, which is # PowerShell there — so the bash body below is handed to `pwsh`, which # reports `body=$(gh api …)` is not a cmdlet and exits 1 in the FIRST - # step of the job. Every other caller of this block is ubuntu-only and - # never had to say so; `action` is a three-OS matrix and does. + # step of the job. The condition above means this no longer fires on + # Windows, and the line stays: the two say different things, and a matrix + # that grows another non-default-bash runner needs it. shell: bash env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} LEASE_HEAD_REF: ${{ github.head_ref }} # The HEAD sha, never `github.sha`: on a pull_request event that is the - # merge commit, which carries trunk's `mise-tasks/land.sh` whenever this - # head did not touch it — and the staleness row reads exactly that file. + # merge commit, whose tree is trunk's wherever this head did not touch + # it — and the staleness read asks about the head's own landing paths. LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - # `|| exit 0` on BOTH lines, and the second is the one that was missing - # (CLOUD-420). A fetch that fails is handled; a fetch that SUCCEEDS with - # something that is not shell was not — a truncated body, 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 job. The step reds under the runner's - # default `bash -e {0}`, `final` fails its `needs:` assertion under - # `always()`, and `land` re-drafts a healthy PR — fleet-wide, from one bad - # response. The precondition's own header promises it never exits - # non-zero; this is what makes that true at the call site too. - bash -c "$body" || exit 0 + # `|| exit 0` ON EVERY LINE, and the reason outlives the shell it was + # written for (CLOUD-420). A step that reds makes the RUN's conclusion + # `failure` rather than `cancelled`; `final` then runs under + # `!cancelled()`, fails its `needs:` assertion, and `land` re-drafts a + # healthy PR — fleet-wide, from one bad response. `batten lease guard` + # promises it never exits non-zero; these are what make that true at the + # call site too, for the three cases the promise cannot cover: a binary + # that will not download, a policy that will not fetch, and a guard that + # will not run. Each is a different failure from one that ran and + # decided, and all three run rather than stopping the fleet. + # + # THE VERSION IS TRUNK'S, NEVER THIS HEAD'S. The installer comes from + # trunk and resolves the version from trunk's own manifest, which is the + # property the fetched-script design protected by reading its logic from + # trunk — a head cannot pin an older guard for itself. + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 # `path: action` frees the workspace root for the fixture. The action then # lives at ./action, and `$GITHUB_ACTION_PATH/Cargo.toml` — which is where @@ -241,9 +299,26 @@ jobs: LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - bash -c "$body" || exit 0 + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -351,9 +426,26 @@ jobs: LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - bash -c "$body" || exit 0 + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -476,9 +568,26 @@ jobs: LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - bash -c "$body" || exit 0 + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -588,9 +697,26 @@ jobs: LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - bash -c "$body" || exit 0 + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -730,9 +856,26 @@ jobs: LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - bash -c "$body" || exit 0 + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 # OVER `needs.*`, NOT A LIST OF NAMES. Branch protection points at this one # job so that adding a leg never needs a ruleset change — which only holds diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 8db31f421..e3a1e11fc 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -82,35 +82,77 @@ jobs: # CLOUD-420: THE LANDING LEASE, ENFORCED WHERE THE MONEY IS SPENT. # First step, before any checkout or toolchain install, because a run this # branch is not authorised to make should cost the rounding rather than a - # matrix. The body is `mise-tasks/ci-lease-precondition.sh` and every - # justification lives there; it is fetched from `main` rather than from - # this head, so a clone carrying stale rules cannot dodge the predicate by - # carrying a stale copy of it. An unreadable body runs — fail open, which - # is the whole posture: waving one matrix through costs one matrix, while - # a predicate that cannot read itself would stop the fleet. + # matrix. The body runs `batten lease guard`, installed from `main` and + # pinned to trunk's version rather than to this head's, so a clone + # carrying stale rules cannot dodge the predicate by carrying a stale copy + # of it. Every justification lives on `run_lease_guard`. THREE DIFFERENT + # FAILURES FAIL OPEN below, one `|| exit 0` each: a binary that will not + # download, a policy that will not fetch, and a guard that will not run. + # That is the whole posture — waving one matrix through costs one matrix, + # while a precondition that cannot reach its own inputs would stop the + # fleet. What keeps those three from reading as *ran and allowed* is + # trunk's installer refusing a binary that does not carry the verb + # (`BATTEN_REQUIRE`); until that installer IS trunk's, this step is + # vacuous by construction rather than by accident. + # + # AND IT WAS VACUOUS FOR A SECOND REASON THE PARAGRAPH ABOVE HID (review of + # #848). `$RUNNER_TEMP/batten-bin` is not on PATH, and `install.sh`'s + # off-PATH refusal is a `die 1` that fires BEFORE the `BATTEN_REQUIRE` + # check — so the `|| exit 0` ended the step at the install and the guard + # never ran, at any of the sites, whatever the installer's provenance. The + # bootstrap above is real and was not the reason; it just described the + # same silence convincingly enough that nobody looked further. + # + # `BATTEN_ALLOW_OFF_PATH=1` is the opt-out for exactly this shape and the + # binary is invoked by absolute path two lines down, so nothing here + # resolves `batten` by name and the refusal is protecting a caller that + # does not exist. Installing onto PATH instead would put a trunk-pinned + # binary ahead of the checkout's own for every later step in the job. - name: Landing lease precondition env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} LEASE_HEAD_REF: ${{ github.head_ref }} # The HEAD sha, never `github.sha`: on a pull_request event that is the - # merge commit, which carries trunk's `mise-tasks/land.sh` whenever this - # head did not touch it — and the staleness row reads exactly that file. + # merge commit, whose tree is trunk's wherever this head did not touch + # it — and the staleness read asks about the head's own landing paths. LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LEASE_RUN_ID: ${{ github.run_id }} run: | - body=$(gh api -H "Accept: application/vnd.github.raw" \ - "repos/$GH_REPO/contents/mise-tasks/ci-lease-precondition.sh?ref=main") || exit 0 - # `|| exit 0` on BOTH lines, and the second is the one that was missing - # (CLOUD-420). A fetch that fails is handled; a fetch that SUCCEEDS with - # something that is not shell was not — a truncated body, 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 job. The step reds under the runner's - # default `bash -e {0}`, `final` fails its `needs:` assertion under - # `always()`, and `land` re-drafts a healthy PR — fleet-wide, from one bad - # response. The precondition's own header promises it never exits - # non-zero; this is what makes that true at the call site too. - bash -c "$body" || exit 0 + # `|| exit 0` ON EVERY LINE, and the reason outlives the shell it was + # written for (CLOUD-420). A step that reds makes the RUN's conclusion + # `failure` rather than `cancelled`; `final` then runs under + # `!cancelled()`, fails its `needs:` assertion, and `land` re-drafts a + # healthy PR — fleet-wide, from one bad response. `batten lease guard` + # promises it never exits non-zero; these are what make that true at the + # call site too, for the cases the promise cannot cover: a binary that + # will not download and one that will not run are different failures + # from one that ran and decided. + # + # THE VERSION IS TRUNK'S, NEVER THIS HEAD'S. The installer comes from + # trunk and resolves the version from trunk's own manifest, which is the + # property the fetched-script design protected by reading its logic from + # trunk — a head cannot pin an older guard for itself. + installer=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/install.sh?ref=main") || exit 0 + printf '%s\n' "$installer" | \ + BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \ + BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0 + # THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs + # BEFORE any checkout, so the directory the guard stands in is empty and + # `config::load` found nothing — `[lease] landing_paths` read as *no + # paths declared* and the staleness half failed open on every run, which + # is the exact silence the row was written to end. Fetched from `main` + # for the installer's own reason, one line up: a head must not pin the + # policy it is judged by, and the only tree a checkout would offer here + # is the pull request's own. + mkdir -p "$RUNNER_TEMP/batten-config" || exit 0 + gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/batten.toml?ref=main" \ + >"$RUNNER_TEMP/batten-config/batten.toml" || exit 0 + "$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \ + lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 931a300db..41d690ac6 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -442,6 +442,61 @@ budget` and **enforced on `check`**. `[budget.]` is a MAP, not a struct wi than guessed: it runs two programs the caller named. "Not yet" never reaches the caller — that is the state the loop exists to sit in, and it is the whole difference between this verb and `checks green`. +- `speculation.rs` — betting on the base that is about to exist (CLOUD-748, + CLOUD-862, CLOUD-369). A waiter behind the lease holder linearizes onto the + holder's head NOW rather than rebasing after it lands. **A CONSERVING PORT + CARRYING ONE KNOWN DEFECT:** `settle` has three outcomes — landed, pending, + lost — and no arm for a POISONED base, one whose tree will never pass `verify`. + That reads as `pending` every lap and the waiter re-bets on the same holder, + stalling every waiter behind it. CLOUD-1306 owns the fix and it is deliberately + not made here, because a port that improved behaviour could not be shown to + conserve it; `a_poisoned_base_is_conserved_as_pending_because_cloud_1306_owns_the_fix` + pins the ported reading so the fix cannot arrive by accident. The settle table + is a PURE function of readings the caller already took, which is what makes + "does this do what the bash did" answerable without a remote. It opens NO + backend of its own: the one ancestry read it needs is `gitwrite::carries`, + because `gix_is_confined_to_the_git_modules` refuses a fourth module reaching + `gix` and caught this file's first draft doing exactly that. Every failure is + a fallback except one: `Live::decide` fails CLOSED, because failing open on an + unreadable lease would make a network blip the thing that lands somebody else's + work. +- `main_watch.rs` — the STALENESS half of a lap's wait: has the trunk moved past + the base this branch was replayed onto (CLOUD-390, ported off + `mise-tasks/main-watch.sh`)? A CONDITIONAL forge read of + `git/ref/heads/` — the smallest body that answers the question — + carrying the previous `ETag` as `If-None-Match` and honouring + `X-Poll-Interval` as a floor. **The floor comparison is NUMERIC**, which is + the whole of CLOUD-390: the predecessor compared with `-gt`, integer-only, so + a fractional interval read as "no floor asked for", and the first Rust port + reproduced it exactly by typing the field `Option`. Beside `pr_watch.rs` + rather than inside it because the two ask about different objects — a head's + check runs versus a ref — while sharing its response parser rather than + growing a second one. **An earlier revision of `land::wait` answered this arm + with `lease::advertise` instead and argued the conditional poll was a + regression; it was not.** Conditionality buys the PACE, not the meter — a ref + advertisement has no `304`, so it must be slow or wasteful — a git + advertisement carries no server-directed backoff at all, and the green arm's + `ETag` conditionality is over check-runs and is not shared with this arm. +- `fast_forward.rs` — asking the bot to land a head, and reading the answer keyed + to THAT request (CLOUD-1338). Beside `pr_watch.rs` rather than inside it: both + spawn the forge client and both are read by a lap, but `pr_watch` asks whether a + SHA is green and this asks whether the bot answered US. **The join key is the + whole correctness argument**: an `issue_comment` run attaches to the DEFAULT + BRANCH's tip, so `head_branch` and `head_sha` name trunk on every one of them and + no field records which PR asked — measured at ~400 runs in thirty minutes, 243 of + them refusals, which makes finding a stranger's inside any lap's window a + near-certainty. The comment id comes back from the POST that created it, the + workflow mints the same string as its `run-name`, and `display_title` carries it. + **Two fences, and the client-side one is the correctness half**: `created>=since` + bounds the page server-side so paging terminates, but a query parameter is an + optimisation and an endpoint ignoring it would drop the fence silently, so the + `created_at >= since` comparison is what holds the line — and what stops an + EARLIER lap of this same PR being re-read as this one's verdict. Reaches + `pr_watch` for `parse_response` alone, the sanctioned edge onto a parser rather + than onto a decider. The conclusion vocabulary is closed and only `failure` is a + verdict about the branch; `cancelled`, `timed_out`, `startup_failure` and `stale` + are the bot not deciding, and none of them is ever read as "main moved" — that is + a fact about a ref and only the staleness arm may assert it. - `ci.rs` — the merge contract derived from the host ruleset (CLOUD-54). The HOST is the authority; `[ci]` in `batten.toml` is a projection a gate polices, never the reverse. Committed rather than fetched per run because a gate that can fail @@ -845,6 +900,51 @@ repo config > default`, declared as data in `SETTINGS` (per-key env var/flag), asked. A root commit or remote URL is identity-bearing and auto-adopts; a matching common dir ALONE is not (a path can be reused by a stranger) and yields `Candidate`, bound only by `batten state adopt`. +- `pipeline.rs` — the landing composition as a DECLARED list, with a + compensation per step (CLOUD-1338, PR #848's review). Replaces the driver's + array literal and its compile-time step-to-function match, which a consumer + could not add to, reorder or re-implement — so the successor still described + the "Button-specific landing policy a consumer inherits and cannot tailor" + that the whole retirement exists to falsify. `StepRow` carries the step, an + `effectful` flag, a `compensate` and an optional `precheck`; that last is + where the driver's `step == Verify` staleness exception goes, which had + leaked into the loop sixteen lines below a comment promising policy "cannot + land in four `if`s out of five". **A COMPENSATION IS A DURABLE EXTERNAL + WRITE**: a saga stack unwound in-process does not run when the container is + killed (`land.sh:353`, "a trap runs on the container kill too"), so every + `Compensation` arm names a forge or ref write and `is_durable` asks a later + arm by compiler rather than by review. It is deliberately NOT a `Progress` + variant — `Progress` says whether the lap continues, while whether an effect + needs undoing is answered by which steps were ENTERED, and that applies to + `Lap` as much as `Stop`: a lap that readies, spends and then laps has a live + matrix for a SHA about to be replaced. `unwind` walks the entered set newest + first, because releasing the lease before re-drafting hands the next branch a + slot while this one still spends. `validate` refuses three shapes at LOAD — + an effectful step before the commit point with no undo, a step positioned + after it, and a composition with no commit point at all — and returns every + finding rather than the first. `Step::FastForward` is the commit point and + needs no undo, which is what makes everything before it need one. + **THE PRODUCTION ENTRY POINTS ARE NAMED, because a composition nothing calls + is a test fixture** (PR #848's review): `run_land_lap` walks the declared rows + rather than an array literal, dispatches `Precheck::BetSettled` through + `settle_the_bet`/`place_the_bet`, and every path that leaves the lap early + goes through `unwind_lap` — the one caller of `Pipeline::unwind`, so a step + that entered cannot exit uncompensated down a branch nobody wired. +- `rest.rs` — the forge's REST tier, IN PROCESS, over `fetch.rs` (CLOUD-1338). + One client, one credential reader (`GH_TOKEN` then `GITHUB_TOKEN`, the forge + CLI's own precedence), and a typed `Answer` carrying the status, the `ETag` + and the `X-Poll-Interval` floor as an `f64` — so no caller re-parses a status + line out of `gh api -i` bytes. **It exists because four spawns claimed a + client that was already in the crate**: each carried + `#[expect(clippy::disallowed_types)]` reading _"this crate carries no HTTP + client that resolves a forge credential"_, and one of the four was written in + `lease.rs` eighty lines from the credential reader promoted here. `main_watch`, + `fast_forward` and `lease`'s cancel and staleness reads go through it, and + `spawn-adapters` lost the two placements those spawns had needed. Named `rest` + rather than `forge` because `forge.rs` is a different subject — a verdict + RECORD read off disk — and drafting this under that name overwrote it. What is + NOT here is git smart-HTTP: `lease.rs` speaks that directly, and folding the + two would put a ref-advertisement parser behind a REST helper. - `fetch.rs` — one HTTPS request, in process (CLOUD-745). The client is **hyper plus hyper-rustls, not `reqwest`**, and that substitution is a measurement rather than a preference: every reqwest configuration hits one of the two @@ -2058,6 +2158,22 @@ judge_fingerprint`, its own domain tag), so a caller can reference content it dual-HMAC). Behavioural churn fixtures live in `crates/batten/tests/identity_churn.rs` (CLOUD-169); they compose the matcher with this module because a `Finding` carries no fingerprint yet (CLOUD-164). +- `scratch.rs` — out-of-tree TEST scratch, owned in one place and reaped by + liveness (CLOUD-1148). Test support rather than product surface, and `pub` + only because the call sites live in three scopes that cannot share a + `#[cfg(test)]` helper. **The defect it closes is the pid's POSITION**: ~76 + sites spelled `temp_dir().join(format!("batten-x-{pid}-{name}"))`, and + nextest gives every case its own process, so each case in each run minted a + path no successor ever computes again — 269 leaked directories, and the + `remove_dir_all` those sites opened with was wiping a path already empty. The + control group is why that is the cause and not a correlate: the sites that + leave the pid out sit at a fixed 12 and 2 forever. So the pid stays, as a + path SEGMENT (`/tmp/batten-scratch//`) — something a reaper can + decide about, where a pid spliced into a leaf name is recoverable only by + guessing at 76 name shapes. Reaped on ACQUIRE by liveness, never mtime: a run + here is killed constantly so a `Drop` is tidiness rather than the mechanism, + which is `task::singleton_acquire`'s reasoning, and an age bound would collect + a long-running suite's own corpora. EPERM is life; only ESRCH is death. - `secret.rs` — the credential type, and the PUREST LEAF in the layer table: it reaches nothing in this crate, not even `error`. One newtype, a hand-written `Debug` that renders a fixed marker, no `Display`/`Serialize`/`AsRef`, and one diff --git a/.serena/memories/serena-setup.md b/.serena/memories/serena-setup.md index b9fafff35..e0e65a9aa 100644 --- a/.serena/memories/serena-setup.md +++ b/.serena/memories/serena-setup.md @@ -121,11 +121,26 @@ mcp-timeout-budget`, which carries the floor, the measurement behind it, and the for this**, and nothing should be: the repository's file is already correct, and a second copy of a correct grant is a second authority that drifts. - The durable question this leaves open is whether the client re-reads - permissions when a server attaches late. If it does, this was something else; - if it does not, the fix belongs upstream and every cold container that loses - the serena race pays a session of hand-approvals. Unmeasured — do not assume - either. + **THE DURABLE QUESTION IS ANSWERED: THE CLIENT DOES RE-READ PERMISSIONS + MID-SESSION** (2026-09-07). Measured here. The session started with the + committed `.claude/settings.json` already enumerating all 21 correctly and + `"ToolSearch"` granted, and the serena tools listed as **deferred**; + `read_memory` and `list_memories` prompted anyway. A settings write MID-SESSION + — `~/.claude/settings.json` and `.claude/settings.local.json`, same 21 entries + plus `ToolSearch` and `mcp__serena` — then let `get_symbols_overview`, a tool + never called and never approved in that session, run with **no prompt**. + + So a startup-present grant is not sufficient and a mid-session one is, which + inverts what the paragraph above assumed. The two files were written together + and the two variables cannot be separated from this run: whether what mattered + was the LOCATION (user-level or `settings.local.json`) or the TIMING (a write + the watcher observed) is still open. Separate them by writing one file only, + and test with a tool that has no prior approval — approval is remembered per + session, so re-calling an approved tool proves nothing here either. + + Either way the one-session repair above is confirmed working, and nothing is + committed for it: `.claude/settings.local.json` is gitignored + (`.gitignore:22`) and `$HOME` is disposable. **AND A FOURTH GATE SITS ABOVE ALL THREE: THE TOOLS MAY BE DEFERRED, AND THE ONE CALL THAT LOADS THEM IS A SEPARATE GRANT** (2026-09-07). In some sessions diff --git a/batten.toml b/batten.toml index e33edbb97..566016d32 100644 --- a/batten.toml +++ b/batten.toml @@ -726,6 +726,60 @@ it rather than holding a foreground poll open.""" # deny is refused rather than shipped: committed-and-pushed is what survives a # container reclaim, so denying it outright would make the reclaim advice # unfollowable. Named as a known limit so the silence is not read as coverage. +# +# THE DENY'S REMEDY IS UNREACHABLE ON THE CONFLICT PATH, AND THAT IS A NAMED +# LIMIT RATHER THAN A REASON TO LOWER THIS COLUMN (measured 2026-09-06, one lap +# after this row landed). The reason below used to end "resolve it and +# `git rebase --continue`, which this row does not touch" — and that remedy is +# UNREACHABLE against this engine, so on a conflict the deny has no route left. +# +# `gitwrite.rs`'s header states the design it collides with, and states it as a +# deliberate one: "**Nothing moves on a conflict.** The ref is written and the +# worktree touched only after every commit in the range has replayed, so a +# refusal leaves the clone exactly as it was — no detached HEAD, no `rebase +# --abort` to remember, no half-replayed state for the next lap to discover." +# That is right for a loop left running unattended, and it means `land` NEVER +# leaves a rebase to continue. So on a conflict there is nothing to resolve and +# nothing to `--continue`, and the one command that produces the resolvable state +# is the one this row denied. Measured on this branch: `land` reported +# `install.sh` conflicted, the tree was clean, and the branch had no landable next +# step in any spelling. +# +# The distinguishing fact — is a conflict outstanding — is real and RECORDED, in +# the lap record `rebase-conflict-stops-the-lap` reads. It is not reachable from +# a `mediated_call` shape row, which compares words in a command line. So this +# row cannot tell the race it measured from the resolution it blocks, and a deny +# that cannot tell them apart must not be the one that stops the resolution. +# +# AND `warn` IS NOT THE ANSWER TO THAT, WHICH IS MEASURED RATHER THAN ARGUED. +# Lowering this column was tried on this branch and is withdrawn. A warn row +# makes `hook::blocks` false, `adjudicate` returns `Decision::Allow`, and the +# decision document is EMPTY — the same bytes a repository with no such row +# emits. Over the compiled binary, `adjudicate --harness claude-code` on +# `git rebase origin/main`: no output, exit 0. Under `--fail-on-warning`, the +# same call: `"permissionDecision":"deny"`, reason `call name refused +# rebase-not-hand-stepped`. +# +# So `warn` here is silence, not advice, and the trade it offered was a refusal +# that is wrong on ONE path for a row that decides nothing on EVERY path. The +# paragraph that argued for it claimed "the nudge still fires, still names the +# loop, and still reaches the agent at the call"; every clause of that is false, +# and the correction is recorded rather than quietly deleted, because a comment +# overstating its own gate is the one direction a severity column must never +# fail in, and it shipped here once. +# +# THE CONFLICT COST IS NAMED RATHER THAN PAID. On a conflict the stop is a +# human's, which `AGENTS.md` already sanctions ("a rebase conflict needs a human +# decision"). That is the `git push` paragraph's "named as a known limit so the +# silence is not read as coverage", applied to a refusal rather than to a gap. +# +# Narrowing this to a predicate that can see the lap record — the fact that +# separates the race from the resolution — is what would remove the cost, and it +# is a follow-up rather than this branch's work. +# +# `crates/batten/tests/it/land_hand_stepping.rs` carries the whole predicate at +# DEFAULT strictness, including an arm asserting this row refuses without +# `--fail-on-warning`, so both a deleted row and a silently lowered one redden. [[rule]] id = "rebase-not-hand-stepped" kind = "shape" @@ -737,9 +791,10 @@ reason = """ Rebasing onto `main` by hand is a lap of the loop `mise run land` drives, and \ taking it off the task is the race the contract refuses rather than a repair. \ Background `mise run land`; it fetches, rebases, verifies, pushes and waits, \ -and stops only for a rebase conflict, a failed `verify`, or red CI. A conflict \ -is the one step that is yours: resolve it and `git rebase --continue`, which \ -this row does not touch.""" +and stops only for a rebase conflict, a failed `verify`, or red CI. A CONFLICT \ +is the one step that is yours, and it is why this warns rather than denies: the \ +engine replays without moving anything, so a conflict leaves no rebase to \ +`--continue`, and re-creating it by hand is the only route to resolving it.""" # The toolchain pin, given a mechanism (CLOUD-271). AGENTS.md has said "never a # bare `cargo`" since long before anything enforced it, which non-negotiable @@ -1513,28 +1568,32 @@ args = ["--list"] # # WHAT THAT COSTS, STATED RATHER THAN ABSORBED: `status` grades the lease against # this CLONE (`mine`) where `authorises` grades it against the BRANCH, and it does -# not honour the admitted successor. `land-lock-peek` below is what closes that -# gap — the module compares `peek`'s answer against `Value::Branch`, which is the -# whole reason that variant exists. -[program.land-lock-status] -path = "mise-tasks/land-lock.sh" -args = ["status"] +# not honour the admitted successor. The `lease-successor` column below is what +# closes that gap — the module compares its answer against `Value::Branch`, which +# is the whole reason that variant exists. +# +# THE TWO ROWS THAT USED TO LIVE HERE ARE GONE, AND THAT IS THE REPOINTING RATHER +# THAN A DELETION (CLOUD-1148). They were `[program.land-lock-status]` and +# `[program.land-lock-peek]`, both `path = "mise-tasks/land-lock.sh"`. A +# `[program]` row names a path resolved against the repository root, so neither +# could ever have named the compiled verb — and the file they named is retired. +# The columns ask `authority = { ask = "lease-status" | "lease-successor" }` +# instead, which is CLOUD-1100's landed move for `ready-lint.sh` applied to the +# same shape: same `read` contract, same recorded tokens, no spawn. # THE ADMITTED SUCCESSOR, AS A MACHINE FIELD (CLOUD-369). `status` renders prose # for a human; `peek` prints one named field alone or nothing, which is exactly why # it exists — "a caller parsing that sentence would turn a message into an # interface, and the next edit to the wording would be a silent breakage." # -# THE FIELD ARGUMENT IS REQUIRED AND ITS ABSENCE IS SILENT HERE. `peek` with no -# field is exit 2 with a usage line on STDOUT, and `render_column` folds whitespace -# — so the record's SHAPE survives and the successor column quietly carries the -# usage text. The module then compares that against the branch, finds them unequal, -# and refuses: a fail-CLOSED deviation on the one row `Value::Branch` was added to -# keep open. Probed rather than assumed: `peek` alone is exit 2 / 51 bytes, `peek -# next` is exit 0 / 0 bytes when no reservation stands. -[program.land-lock-peek] -path = "mise-tasks/land-lock.sh" -args = ["peek", "next"] +# NO FIELD ARGUMENT TO GET WRONG, WHICH IS WHAT THE COMPILED ARM BUYS HERE. The +# predecessor took the field on argv, so `peek` with none was exit 2 with a usage +# line on STDOUT — `render_column` folds whitespace, the record's shape survived, +# and the successor column quietly carried the usage text, which the module then +# compared against the branch and refused on. A fail-CLOSED deviation on the one +# row `Value::Branch` was added to keep open, reachable by editing one string. +# `ask = "lease-successor"` names the field in the variant, so there is no +# spelling to get wrong. # ─── THE OUTPUT-POSTURE SCRUB AND ITS TELL (CLOUD-97, ported under CLOUD-1051) ─ # @@ -2220,6 +2279,124 @@ regex = 'gh[[:space:]]+pr[[:space:]]+view\b[^|;&]*--jq[[:space:]]+\.body\b' id = "shell-script-directory" regex = '^\$\(dirname([[:space:]]+--)?[[:space:]]+"[^"]*"\)$|^\$\(cd([[:space:]]+--)?[[:space:]]+"\$\(dirname([[:space:]]+--)?[[:space:]]+"[^"]*"\)"[[:space:]]*&&[[:space:]]*pwd\)$|^\$\{(BASH_SOURCE\[0\]|0)%/\*\}$' +# HOW A BATS SUITE SPELLS THE SAME CONCEPT, and it is a second row rather than a +# fourth alternation in the one above because the two decide different questions. +# +# The row above is a SIBLING directory — a shell program resolving the file next +# to it, which is why its own comment excludes the parent form by construction. A +# `.bats` suite does not live next to its subject: every suite in `tests/` names a +# program in `mise-tasks/`, so the traversal is inherent to the shape rather than +# a widening of it. And `$(dirname "$0")` is unavailable there at all — `$0` is +# the bats runner, so bats hands the suite its own directory as +# `$BATS_TEST_DIRNAME` instead. +# +# THE GAP THIS CLOSES WAS NAMED BEFORE IT WAS MEASURED. `shell-retirement`'s +# `case_earns_removal` already records that "a suite spells it relative to its own +# directory (`$BATS_TEST_DIRNAME/../.claude/hooks/…`)" and routes the REMOVAL side +# around the missing spelling — which left the REPOINTING side unreachable, so a +# suite that BINDS a retired program's path and spends the variable later had no +# landable edit in either direction. Measured on `tests/tree-clean.bats`: the +# binding line is removable (the bare stem matches `naming_forms`) while the spend +# line `run "$VERIFIED"` is neither an admitted removal nor an admitted +# repointing, so retiring `mise-tasks/verified.sh` broke a surviving suite with no +# way to repair it. CLOUD-843 retires ~130 programs and most carry such a suite. +# +# ANCHORED AT BOTH ENDS for the reason the row above is, and the traversal is +# bounded to path segments: no command substitution, no quote, no space, so the +# span this admits can only ever be a literal path rooted at the suite's own +# directory. The module removes the `/` tail before matching, exactly as it +# does for the row above. +# +# AT MOST ONE `..`, AND NO TRAVERSAL AFTER IT. The segment class was +# `[A-Za-z0-9._-]+`, which contains `.` — so `.` and `..` were ordinary segments +# and `$BATS_TEST_DIRNAME/../../../elsewhere/x.sh` matched. That is not the shape +# the comment above describes: a suite sits in `tests/` and its subject in a +# sibling directory, which is exactly one level up, and every further `..` walks +# out of the repository layout this row exists to recognise. +# +# The tail class now requires at least one NON-dot character per segment, which +# is what refuses `.` and `..` while still admitting `.claude` and `a.b` — the +# spellings the paragraph above names. RE2 has no lookahead, so the exclusion is +# spelled positively rather than as a negative assertion. +[[pattern]] +id = "bats-suite-directory" +regex = '^\$\{?BATS_TEST_DIRNAME\}?(/\.\.)?(/[A-Za-z0-9._-]*[A-Za-z0-9_-][A-Za-z0-9._-]*)*$' + +# A CLIPPY LINT ESCAPE, in either spelling (CLOUD-1338). `spawn-widening` reads +# it over the lines a change ADDED to engine source. +# +# ANCHORED ON THE ATTRIBUTE OR THE BARE LINT PATH, and both arms are needed +# because this crate writes it both ways: `#[expect(clippy::disallowed_types)]` +# on one line, and the multi-line form whose second line is the lint alone. A +# pattern matching only one of them misses every site written the other way. +# +# A DOC COMMENT NAMING A LINT IS NOT AN ESCAPE, which the leading anchor gets for +# free: a `///` or `//` line does not begin with the attribute or the path, and +# the modules that discuss `clippy::disallowed_types` at length — including the +# ones this row was written for — would otherwise refuse every commit that +# explains itself. +[[pattern]] +id = "clippy-lint-escape" +regex = '^\s*(?:#!?\[\s*(?:expect|allow)\(\s*)?clippy::[a-z_]+' + +# THE THREE LINTS A `mod tests` WAIVES, and the exemption is narrow on purpose. +# +# Every `#[cfg(test)] mod tests` in this crate opens with +# `#[allow(clippy::expect_used)]` or its siblings, because the workspace forbids +# `unwrap`/`expect`/`panic` on REACHABLE paths and panicking loudly is how a test +# fails. Those modules live inside `crates/batten/src/**`, so the path exclusion +# that keeps `crates/batten/tests/**` out cannot reach them. +# +# Measured: without this row `spawn-widening` refused three modules for using the +# idiom the whole codebase uses. A rule that fires on the universal case is one +# somebody switches off, which is the failure mode that ends gates. +# +# A CLOSED LIST OF THREE, never a general "test lints" carve-out. Everything else +# — `too_many_arguments`, `cast_precision_loss`, and `disallowed_types` above all +# — stays refused, because those are claims about the code rather than about how +# a test reports failure. +# +# EVERY LINT ON THE LINE MUST BE EXEMPT, AND THIS MATCHED ONLY THE FIRST (review +# of #848). The row read `…clippy::(?:expect_used|unwrap_used|panic)\b` and +# stopped there, so `#[allow(clippy::expect_used, clippy::disallowed_types)]` +# satisfied it wholesale — the exemption was one lint name wide, and the lint it +# waived second is the spawn escape `spawn-widening` exists to refuse. Multi-lint +# attributes are this crate's own idiom (ten `#[allow(clippy::unwrap_used, +# clippy::expect_used)]` sites), so the bypass was one keystroke from the +# universal case. +# +# The list may repeat, and what follows it decides: a `reason =`, the closing +# paren, or the end of the line. A NON-exempt lint after the comma reaches none +# of those, so the alternation fails and the line is refused. +# +# `,\s*$` is the multi-line attribute, and it is not slack: a trailing comma +# means the list continues on the next line, which carries its own lint name and +# is judged on its own by the same pair of rows. +[[pattern]] +id = "clippy-test-idiom" +regex = '^\s*(?:#!?\[\s*(?:expect|allow)\(\s*)?clippy::(?:expect_used|unwrap_used|panic)\b(?:\s*,\s*clippy::(?:expect_used|unwrap_used|panic)\b)*\s*(?:,\s*reason\s*=|,\s*$|\)|$)' + +# ONE MEMBER OF THE SPAWN PLACEMENT SET (CLOUD-1338). +# +# `policy/spawn-adapters.rego`'s `adapters` is a Rego set of bare module names, +# tab-indented. The row is deliberately narrow: it matches a line made ENTIRELY +# of quoted lower-snake words with commas, which is that set's shape and nothing +# else's in the file. Widening it to any quoted string would catch the module's +# own test fixtures and its prose, and a rule refusing those is a rule somebody +# switches off. +# +# **IT SAID "ONE PER LINE" AND THE FILE IS NOT WRITTEN THAT WAY** (review of +# #848). The pattern was `^\s*"[a-z_]+",\s*$` — one name ALONE on its line — and +# `spawn-adapters.rego` packs four to six per line, which is its dominant style. +# So the whole gate was evaded by appending to an existing line rather than +# adding one: the added line failed the anchor, clause B never fired, and +# widening the placement table cost a keystroke instead of the argument +# `spawn-adapters` says it should cost. A premise about formatting, asserted in a +# comment and true of nothing. +[[pattern]] +id = "spawn-placement-entry" +regex = '^\s*(?:"[a-z_]+",\s*)+$' + # THE SUBCOMMAND TAIL of a task-name invocation (CLOUD-1299). # # `shell-retirement`'s repointing arm could only recognise a span that named its @@ -2440,20 +2617,29 @@ requires-input-matching = { command = "landing-lifecycle-call" } name = "kind" value = { literal = "lease" } -# THE FRESHNESS GRADE, RESOLVED BY THE PRODUCER'S OWN CLOCK. `status` exits 0 for -# unheld, released, expired or mine; 1 for held by another clone; 2 where it could -# not observe the lease at all. -# -# 2 IS DELIBERATELY UNMAPPED, AND THAT OMISSION IS THE FAIL-OPEN HALF. An unmapped -# status records `-`, which equals neither token, so the module's refusal cannot -# hold. `status` fails CLOSED on an unreachable remote where `authorises` fails -# OPEN — "a lease it cannot read stops EVERY job in the fleet, where waving one -# matrix through costs one matrix" — and this table is what restores that -# asymmetry at the boundary rather than asserting it in prose. Adding a `"2"` row -# here would invert the one behaviour the port exists to conserve. +# THE FRESHNESS GRADE, RESOLVED BY THE PRODUCER'S OWN CLOCK — which since +# CLOUD-1148 is the recorder boundary's single instant rather than a spawned +# script's. `lease-status` exits 0 for unheld, released, expired or mine; 2 for +# held by another clone; 3 where it could not observe the lease, read this +# clone's identity, or find a remote at all. +# +# 3 IS DELIBERATELY UNMAPPED, AND THAT OMISSION IS THE FAIL-OPEN HALF. An +# unmapped status records `-`, which equals neither token, so the module's +# refusal cannot hold. The producer fails CLOSED on an unreachable remote where +# `authorises` fails OPEN — "a lease it cannot read stops EVERY job in the fleet, +# where waving one matrix through costs one matrix" — and this table is what +# restores that asymmetry at the boundary rather than asserting it in prose. +# Adding a `"3"` row here would invert the one behaviour the port exists to +# conserve. +# +# THE NUMBERS MOVED WITH THE PRODUCER AND THE MEANINGS DID NOT. The shell +# answered 1 for held-elsewhere and 2 for could-not-look, because its own 1 +# already meant "held by someone else"; the engine has one table with no +# per-verb exception (non-negotiable rule 5), so 2 is the verdict and 3 is +# could-not-look. This map is the one place the two vocabularies meet. [[recorder.columns]] name = "verdict" -value = { program = { run = "land-lock-status", read = { status = { "0" = "authorised", "1" = "held-elsewhere" } }, stdin = { literal = "" } } } +value = { authority = { ask = "lease-status", read = { status = { "0" = "authorised", "2" = "held-elsewhere" } }, stdin = { literal = "" } } } # THE ADMITTED SUCCESSOR (CLOUD-369). Silent and `-` when no reservation stands, # which the module reads as *not this branch* — correct here and only here, because @@ -2461,7 +2647,7 @@ value = { program = { run = "land-lock-status", read = { status = { "0" = "autho # somebody else. [[recorder.columns]] name = "successor" -value = { program = { run = "land-lock-peek", read = "stdout", stdin = { literal = "" } } } +value = { authority = { ask = "lease-successor", read = "stdout", stdin = { literal = "" } } } # THE BRANCH THE COMPARISON NEEDS, and the reason `Value::Branch` exists at all. # `status` grades the lease against the CLONE; only this column lets the module ask @@ -4744,6 +4930,55 @@ severity = "deny" scope = "tree" no_fix_reason = "migrate the predicate onto a rule kind, or waive the increase deliberately with a reason and an expiry; which of the two is the whole question" +# A BAN YOU CAN WAIVE AT WILL IS NOT A BAN (CLOUD-1148, over CLOUD-1177's +# inventory). +# +# Measured at this row's baseline: 11 `#[expect(clippy::disallowed_methods, …)]` +# annotations standing over 13 `std::thread::sleep` sites in `crates/batten/src`. +# The delay ban is therefore waived at essentially every site it governs, which +# is the same as not having it. +# +# `crates/batten/tests/it/sleep_ban.rs` is the gate that was supposed to stop +# this, and its own comment says so — "what stops the ban being satisfied by +# thirteen waivers". It does not, and the reason is worth stating because it is a +# non-negotiable rule 3 defect in a gate rather than a missing one. What it +# decides is the FORM of the justification: `expect` rather than `allow`, a +# `reason` present, a backticked token in it, and that token appearing elsewhere +# in the same file. Every clause is a property of the SENTENCE. None asks whether +# the delay was necessary, because clippy cannot ask that and neither can a text +# scan — so the gate estimates whether an author thought about it, and an author +# who copies the neighbouring annotation's shape passes. +# +# Measured 2026-09-06, by this agent, against itself: a grace loop was written +# into `exec::terminate_group` with a blocking sleep, annotated in about thirty +# seconds by copying the shape of the annotation three hundred lines above it +# (`group_is_empty` and `GROUP_GRACE` both resolve in that file, so the bound +# clause was satisfied), and it passed. The delay was then found to be +# unnecessary — the group's leader is already dead, so nothing is mid-exit — and +# deleted outright. The gate had certified a delay that should never have +# existed. +# +# So the decidable property is the COUNT, and this row is it: the waivers may +# fall and may never rise. That is a real object with a real exit code and no +# judgement in it, which is what rule 3 asks for. It does not migrate the 11 — +# that is its own change, and it touches the runtime posture `.claude/rules/rust.md` +# records — it makes the migration MONOTONIC, so no twelfth can arrive while it +# is pending. +# +# `disallowed_methods` rather than the sleep paths specifically: the same +# argument applies to every ban in that list, and a row that named only the sleep +# would leave the multi-thread-runtime ban waivable at will for the same reason. +[[rule]] +id = "delay-waivers-not-growing" +kind = "ratchet" +glob = "crates/batten/src/**/*.rs" +pattern = "clippy::disallowed_methods" +direction = "non_increasing" +base = "origin/main" +severity = "deny" +scope = "tree" +no_fix_reason = "a waiver is not a fix: either the delay has a bound it can exit on and needs no sleep, or it is a backoff and belongs on the async path where it can be cancelled. Adding a twelfth exemption is the thing this row refuses" + # The other spelling of the same concept — see the block above for why it is a # second row rather than a second pattern on the first. [[rule]] @@ -5623,6 +5858,28 @@ delta_sources = ["**"] module = "policy/test-targets.rego" severity = "deny" +# CLOUD-1148's mechanism half. The doctrine — a platform split inside a `#[test]` +# is spelled `cfg!` so both arms compile on every target — landed as two doc +# comments and no gate, which is non-negotiable rule 2 violated by the commit +# that closed the class. +# +# `delta_sources` AND `line_sources` DIFFER HERE ON PURPOSE, and the asymmetry is +# the interesting half. The delta is the whole tree because the module decides on +# a path's own content and a pre-filtered delta cannot tell "nothing was added" +# from "the filter removed it" — the class `test-targets` above states for its +# own reason. The lines are `crates/**/*.rs` because that is where a `#[test]` +# can live, and handing the module every path in the repository would make it +# read `batten.toml` looking for Rust attributes. +[[rule]] +id = "cfg-gated-test" +kind = "policy" +scope = "tree" +base = "origin/main" +delta_sources = ["**"] +line_sources = ["crates/**/*.rs"] +module = "policy/cfg-gated-test.rego" +severity = "deny" + [[rule]] id = "stop-posture" kind = "policy" @@ -6689,8 +6946,13 @@ sources = [ ] line_sources = [ ".github/workflows/*.yml", - "mise-tasks/abandon-matrix.sh", - "mise-tasks/land.sh", + # THE ENGINE, NOT THE RETIRED SHELL (CLOUD-1148). The fan-in clauses used to + # read `mise-tasks/abandon-matrix.sh` and `mise-tasks/land.sh`; both are + # retired, and a deleted path makes `input.tree.lines[…]` UNDEFINED rather + # than empty — so both helpers go false and both violations fire. Repointed at + # the compensation's own site, which is where the declaration is now read and + # where the abandon is now reached. + "crates/batten/src/lib.rs", ] module = "policy/ci-parity.rego" severity = "deny" @@ -6959,6 +7221,65 @@ symbols = true module = "policy/spawn-adapters.rego" severity = "deny" +# THE INVENTORY MAY NOT BE SELF-SERVICE (CLOUD-1338), and this row is the reader +# its two siblings never had. `.claude/rules/rust.md` makes a spawn an inventory +# row; `spawn-adapters` above says which modules may hold one. Both are answered +# by the author, in the same commit, with nothing reading the answer: the +# annotation's `reason` is an unchecked string and a placement is a word added to +# a Rego set. +# +# Measured, and on the worst possible branch: one whose entire subject was +# RETIRING SHELL added five `#[expect(clippy::disallowed_types)]` spawns and +# widened the placement table twice, with every sensor green and every reason +# repeating a claim — *"this crate carries no HTTP client that resolves a forge +# credential"* — that `crates/batten/src/fetch.rs` had already falsified. +# +# `base = "origin/main"` because the subject is a CHANGE rather than a state +# (CLOUD-1059): a tree holding N escapes is the inventory, and this decides +# whether N grew. `sources` names the two surfaces it reads lines from, because +# the module compares the working side against the delta's base side and needs +# both files acquired. +# +# NO `bypass_env`, and its verdicts declare no override route. That follows +# `shell edit refused`'s precedent rather than inventing a posture: the answer to +# this refusal is not a token to spend, it is that the change has the wrong +# shape. A spawn that genuinely belongs is a decision for a groomed row and a +# human, never an annotation an agent writes about its own work. +[[rule]] +id = "spawn-widening" +kind = "policy" +scope = "tree" +module = "policy/spawn-widening.rego" +base = "origin/main" +severity = "deny" +# `delta_sources`, NOT `sources`, and the difference is the whole reading. It is +# what makes the engine acquire the BASE side of each glob, which is where +# `base-lines` comes from; `sources` alone builds the working side and leaves the +# delta null, so the module reports could-not-look on every run and decides +# nothing. Measured here against a seeded escape it did not catch, which is why +# the seeding was the acceptance step rather than reading a clean exit as proof. +# +# RECURSIVE, because a single-level glob is a hole that arms itself. There is no +# nested Rust module under `src/` today, so `crates/batten/src/*.rs` matches +# every file that exists — and the day somebody adds one, this row stops seeing +# it, both clauses run over a map that never held its lines, and the gate reports +# clean. That is the same silence the two paragraphs above record being caught by +# seeding rather than by reading. Found in review of PR #848. +delta_sources = ["crates/batten/src/**/*.rs", "policy/spawn-adapters.rego"] +# AND `line_sources` FOR THE WORKING SIDE, because the two keys fill different +# halves and the module subtracts one from the other. `delta_sources` alone gave +# a base with nothing to compare against it: `input.tree.lines` was filled by +# nothing, both refusing clauses ran over an empty map, and the gate reported +# clean. That is the second time in ten minutes this row read as passing while +# deciding nothing — which is why the acceptance step is a SEEDED escape and not +# a clean exit. +# +# Recursive for `delta_sources`' reason, one paragraph up: the two keys fill the +# two halves the module subtracts, so a glob that reached one and not the other +# would leave a nested module's lines on one side only — which is the same read +# as not seeing it at all. +line_sources = ["crates/batten/src/**/*.rs", "policy/spawn-adapters.rego"] + # CLOUD-316's gate, ported off `mise-tasks/mise-pin-agreement.sh` under CLOUD-910 # — the wave's first gate, and the one whose end-to-end cost sizes the rest. # @@ -7205,6 +7526,45 @@ rule = "inline-task-bodies-not-growing" reason = "CLOUD-1265's `[tasks.record-verdicts]` is a producer's EFFECT, not a predicate: the predicate it replaces migrated to `policy/validator-verdict-clean.rego`, and section 5 leaves no rule kind for a body that must spawn a validator. `mise-tasks/pkl-check.sh` and its suite leave the tree in the same commit, so the bash surface falls while this row's `mise.toml`-only count rises by one." expires = "2026-10-31" +# `cfg-gated-test`'s own first legitimate refusal, taken through the route the +# class declares rather than the one it declared first. The long form of why an +# override is not that route sits on `verdict[test cover partial]` above; the +# short form is that an admission is keyed to the checkout and CI has a different +# one (CLOUD-1674). +# +# NARROWED TO THE ONE PATH, which is possible here and was not possible for the +# two ratchet waivers above. Their findings' subjects are a glob plus a pair of +# counts, so no `path =` selects them; this rule reports the FILE it counted, so +# the literal is exact and a second file gaining a gated test is still refused. +# +# WHY THE ATTRIBUTE IS RIGHT, since a waiver over one's own new gate is the shape +# that deserves the most suspicion. The case is +# `a_relink_replaces_the_target_rather_than_writing_through_it` and it asserts +# over `std::os::unix::fs::MetadataExt::ino` — an inode number, which is what +# distinguishes a relink from a write-through and which the Windows target has no +# symbol for. A `cfg!` arm keeps both legs compiled, which is the whole reason +# this rule prefers it, and a leg that does not TYPE-CHECK cannot be kept: this is +# the one case where the attribute is required rather than chosen. The two other +# findings on the gate's first run were not this, and they lost their attributes. +# +# EXPECTED TO LAPSE UNUSED. `base = "origin/main"`, so `provision.rs`'s gated +# count becomes the floor the moment this lands and the row suppresses nothing +# after that. It exists to get one commit past a ratchet it legitimately trips. +# +# `rule` IS THE MODULE'S RULE ID, NOT THE `[[rule]]` ROW'S. `Waiver::covers` +# compares against `finding.rule`, which a policy module sets itself, so a row +# keyed `cfg-gated-test` parses, lints, reads as declared, and covers nothing. +# Measured here: the row went in spelled that way and the gate still exited 2 +# with the finding in violation rather than waived form. This is the SAME id trap +# that cost an admission spend on this branch — `override request` resolves its +# finding anchor by the module id too — and nothing gates the mismatch, which is +# the third surface keyed off a different id from the one `--rule` selects. +[[waiver]] +rule = "platform-gated-test-added" +path = "crates/batten/src/provision.rs" +reason = "the relink case asserts over `std::os::unix::fs::MetadataExt::ino`, a symbol absent on the Windows target, so the `cfg!` arm this rule prefers would not type-check and the attribute is required rather than chosen to silence a leg. `base = \"origin/main\"` makes this floor itself on landing, so the row is expected to lapse unused." +expires = "2026-10-31" + # CLOUD-1387's shadow, waived for as long as its fix takes to land and no longer. # # `claim-before-code` reads `node = "project"` off the capture keyed to one row, @@ -7280,6 +7640,41 @@ rule = "filed-over-own-diff" reason = "CLOUD-1547 is implemented and closed by this PR, so `closes` is the exemption that applies; it cannot fire because `pr-closes` is minted from a `gh pr view` call and there is no `gh` on this host (CLOUD-1126, residual on CLOUD-1481). The class's own `path admit first` override route does not consume — eight admissions were spent and the findings did not move, because the anchor resolver falls back to a `call:` anchor on zero stored-finding matches and nothing queries one for a tree finding (CLOUD-1551). Remove this with CLOUD-1551." expires = "2026-10-11" +# THE SIBLING ARM OF THE SAME DEFECT, and the arm above was scoped to half of it. +# `filed-over-own-diff` and `filed-and-left-open` are two predicates in one module +# (`policy/filed-here.rego`) and CLOUD-1551 defeats the override route for both; +# only the first was waived, because only the first had fired yet. +# +# MEASURED HERE, and it is a sharper mechanism than "the override suppresses +# nothing". It suppresses until HEAD moves. Two consecutive laps of `mise run +# land` over one tree, one finding, one spent admission (`06b322be`, anchored +# `call:550939ab`): lap 1 replayed nothing and reported 0; lap 2 replayed onto a +# trunk that had moved and reported 1. The rebase rewrote `550939ab` as +# `7b705e64` — `git cat-file` still finds the object and +# `git merge-base --is-ancestor` exits 1 — so the anchor addressed a commit the +# branch no longer contains. +# +# WHICH MAKES THE NARROW ROUTE STRUCTURALLY UNAVAILABLE RATHER THAN FRAGILE, and +# that is why this is a waiver rather than another admission. `override request` +# anchors at the current HEAD, and the commit carrying the resulting `Admits:` +# block BECOMES a new HEAD, so a re-mint is stale the moment it lands; a commit +# on top has the same shape. And this waiver cannot be narrowed by `path` either: +# `Waiver::path` is a path glob and this rule's subject is `{"artifact": id}` with +# no path in it. Rule-wide is the only shape available, which is exactly the cost +# CLOUD-1551 imposes and exactly why it should be paid down rather than lived in. +# +# The articulation the admission was meant to carry is not lost — it is in +# `13c0dd5e`'s message, hash-bound, where a reviewer reads it. What CLOUD-1635 is +# and why it is deferred: `mcp-allow-check.sh` is a governed shell gate, the fix +# was attempted as an edit, `shell-rule-retired` refused it correctly, and the row +# is re-scoped to the retirement `rules/toolchain.md` says it always was. +# +# Remove this with CLOUD-1551, alongside its sibling above. +[[waiver]] +rule = "filed-and-left-open" +reason = "CLOUD-1551 defeats this rule's override route the same way it defeats `filed-over-own-diff` above, and measurably worse: an admission binds `call:`, so the replay every landing lap performs when trunk moves orphans the anchor and the spent admission stops suppressing. Measured on two consecutive laps of one tree — lap 1 replayed nothing and reported 0, lap 2 replayed and reported 1, with `06b322be` spent throughout. Re-minting cannot escape it, since the commit carrying the `Admits:` block becomes the new HEAD; and `Waiver::path` cannot narrow this rule, whose subject is an artifact id rather than a path. The deferral's articulation is hash-bound in `13c0dd5e` regardless. Remove this with CLOUD-1551." +expires = "2026-10-11" + [[waiver]] rule = "claim-before-code" reason = "CLOUD-1387: `captured::reduce` selects the first capture whose bytes MENTION the key rather than the one it is the subject of, so a response quoting the row shadows its real payload and `present` answers false over a row that is on a project. Fix is open in PR #842; remove this waiver with it." @@ -7380,7 +7775,7 @@ measured = "2026-09-08" mb = 21455 worst_mb = 21455 multiplier = 1 -measured = "2026-09-06" +measured = "2026-09-07" # THE BASIS EACH FLOOR WAS MEASURED AGAINST (CLOUD-1158), because `measured` is a # pointer to a basis and not the basis itself. @@ -7626,6 +8021,24 @@ measured = "2026-09-06" # the first time the caller's fabricated explanation has a plausible-looking number # sitting next to it. It is still a stem count that refused. Read the callee. +# AND A SIXTH, ON THIS BRANCH, WHICH IS THE FIRST ONE THE CALLER'S OWN NUMBERS +# CONTRADICT IN THE SAME BREATH (CLOUD-1586). `verify` again rendered a stem-count +# refusal as "not enough disk to run the gate, and pruning did not recover it". +# Measured 2026-09-07 on `bcf1ec3e`: 16470 MB free against a LEARNED warm floor of +# 15028 MB, printed by `target-prune` two lines above the refusal — so the caller +# claimed the disk was short while the callee printed the margin it had. Nothing +# was short. The basis was 230 against a live 241, tolerance 10. +# +# The entries above ask the reader to read the callee. This one asks something +# narrower of whoever moves next: the misreport is now the FIFTH-most-likely +# reading of that sentence and still the only one the caller emits, so the sentence +# is the defect rather than the evidence. It is `land::Refusal`'s +# `Environment` arm that fixes it — a declared `[[verify_environment_pattern]]` +# matching the stem-count refusal would carry the consumer's own remedy instead of +# "reproduce and fix locally", which is what this lap was told about a tree with +# nothing wrong in it. That row is not written here because the pattern belongs +# with the measurement that justifies it, and this entry is the measurement. + # ONE READING WORTH LEAVING BEHIND, because it is the first time the derived cold # floor has exceeded what this container can offer: 21455 MB against 21065 MB free # at the time of the move. That is latent rather than blocking — cold is judged @@ -7698,6 +8111,37 @@ measured = "2026-09-06" # warm's basis refreshed to 220 and cold's left at 208, the very next lap refused # on `[prune.cold]`'s staleness arm with warm never breached. The two arms are # judged at different times and only one of them waits. + +# THE 2026-09-06 REFRESH, 220 -> 230, AND IT IS THE REBASE'S rather than a new +# claim. Lapping this branch onto trunk brought both journals together: trunk had +# moved the basis to 220 and this branch to 225, and the live walk of +# `crates/batten/tests/**/*.rs` at the merged tree is 230. One count, measured +# here, replacing two that were each right about a tree that no longer exists. +# +# NEITHER FLOOR MOVES, for the reason every entry above it gives and the reason +# the entry below it measured: a basis refresh is a trend counter, not a byte +# budget, and moving a floor needs the independent measurement CLOUD-1158 owns. +# THE 2026-09-05 MOVE, and only ONE half of it is taken. CLOUD-1148's retirement +# added five compiled tiers, taking the live count 15 past the basis — this gate +# working, exactly as the 2026-08-30 move records. +# +# **The count is refreshed and the floors are NOT.** The block above says a count +# refreshed without a new measurement is the same staleness wearing a newer +# number, so a measurement was taken: `target/debug` built from an EMPTY tree, +# full suite run, **2644 MB at 212 stems** — 12.5 MB per stem against this +# basis's own 108.9. That does not say the floors are 8% low; it says they are +# roughly 8x HIGH, which is the opposite finding and a much larger claim than a +# stem refresh. +# +# It is filed rather than acted on. Lowering a safety floor eightfold on one +# reading is the change this block already warns against in the other direction — +# a floor too high refuses laps that would have been fine, and a floor too low is +# CLOUD-861's rustc IO error inside a test run. The likeliest reconciliation is +# that the declared numbers were taken over an ACCUMULATED tree rather than a +# single cold build, which is the same apples-to-oranges error this block caught +# once already; confirming that needs its own measurement discipline and its own +# row. The number is recorded here so the next reader has it rather than +# retaking it. # THE 2026-09-08 MOVE, 220 -> 232, BOTH BASES, AND WARM RE-MEASURED (CLOUD-205). # # WHAT MOVED THE BASIS is ordinary growth plus one file from this branch: @@ -7733,14 +8177,42 @@ measured = "2026-09-06" # 2026-09-06 entry records what happens otherwise — refreshing one basis and not # the other made the very next lap refuse on the other arm, with warm never # breached. The two arms are judged at different times and only one of them waits. +# THE 2026-09-08 REBASE MOVE, 232 -> 244, BOTH BASES, AND NEITHER FLOOR MOVES. +# +# WHAT MOVED THE COUNT is the rebase rather than new work: replaying this branch +# onto the current trunk brought `crates/batten/tests/it/nextest_slow.rs` — the +# entry above is the commit that added it — together with this branch's own +# `cfg_gated_test.rs`. `target-prune` refused at `declared 232, live 244, +# tolerance 10`, which is the staleness arm and not the disk: 18901 MB were free. +# +# THE FLOORS DO NOT MOVE, AND THIS ENTRY IS ABOUT WHY THE MEASUREMENT IS OWED +# RATHER THAN TAKEN. The method the entry above prescribes is `du -sm target` +# immediately after a successful prune. That reads **6384 MB** here and it is not +# comparable: this prune ESCALATED below the warm floor and dropped 26926 MB of +# regrowable cache, so 6384 is the post-escalation figure rather than the ordinary +# retained state 11140 was measured against. Adopting it would lower a safety +# floor by 43% on a reading of a different thing, which is CLOUD-861's rustc IO +# error arriving inside a test run. +# +# Scaling instead — 244 x 48.02 — is what the entry above calls the same staleness +# wearing a newer number, so it is not taken either. The precedent is the +# 2026-09-06 entry's: a basis refresh is a trend counter, not a byte budget, and +# moving a floor needs the independent measurement CLOUD-1158 owns. The floor +# stays where main re-measured it, the count follows the tree, and the honest +# statement is that an ordinary post-prune `du` was not takeable on this container +# at this moment — which is different from being skipped. +# +# COLD'S BASIS MOVES WITH WARM'S, for the 2026-09-06 entry's measured reason: +# refreshing one and not the other made the very next lap refuse on the other arm +# with warm never breached. [prune.warm.basis] glob = "crates/batten/tests/**/*.rs" -count = 232 +count = 244 tolerance = 10 [prune.cold.basis] glob = "crates/batten/tests/**/*.rs" -count = 232 +count = 244 tolerance = 10 # THE REGROWABLE ROOTS THE ESCALATION MAY DROP (CLOUD-1157), in the order it drops @@ -8049,6 +8521,117 @@ email = "alec@wenzowski.com" # creation order, which this tracker gives and a slug- or UUID-keyed one does # not — and there it would fail silently rather than loudly. Every tracker stamps # a creation time. +# ─── THE LANDING MECHANISM'S OWN PATHS (CLOUD-1148 §2) ──────────────────────── +# +# What `batten lease carries` asks the forge about: has this head's history got +# trunk's newest commit touching any of these? A head that has not cannot be +# serialised against the fleet, so it must rebase before it spends a matrix. +# +# A PATH SET RATHER THAN A GREP STRING, and that is the whole change. The +# predecessor asked this by grepping the head's `mise-tasks/land.sh` for +# `land-lock acquire` (`ci-lease-precondition.sh:157`). Both halves of that die +# with the retirement: the file goes, so the read fails, so the script takes its +# own fail-open path — "not judging this head's age" — and every stale head +# passes SILENTLY, which is worse than a wrong answer. What changes when the +# landing mechanism moves is WHICH PATHS, and that is a row a retirement edits +# rather than a literal a retirement invalidates. +# +# THE ENGINE PATHS ARE HERE TOO, AND THEY ARE THE POINT AFTER SLICE 7. Today the +# mechanism is bash; when it is `crates/batten/src/land.rs`, this row is what +# keeps the question answerable without being rewritten — the bash paths simply +# stop appearing in trunk's history and the engine ones carry it. +# +# EMPTY IS COULD-NOT-LOOK, never a clean answer: this gate fails open at every +# unknown, because a reading it cannot take would cancel every job in the fleet +# where waving one matrix through costs one matrix. +# ─── WHICH GATE FAILURES ARE THE ENVIRONMENT'S (CLOUD-861) ────────────────── +# +# A `verify` refusal is normally about this tree, and "reproduce and fix locally" +# is the right advice for it. These rows name the failures for which that advice +# is ACTIVELY WRONG — the branch did not cause them, and reproducing costs a +# cycle to learn so. +# +# MEASURED 2026-08-21: `target-prune` passed a lap with 6242MB against its 4096MB +# floor, the `cargo test` link step then consumed all of it, and the lap stopped +# with "Reproduce and fix locally" over a tree with nothing wrong in it. That is +# the misattribution CLOUD-811 records in `linear-check`, one layer over. +# +# THE LITERAL AND THE REMEDY ARE BOTH THIS CONSUMER'S. The wording is a property +# of the toolchain this repository runs, and the remedy names a task — either one +# inside `crates/batten` is non-negotiable rule 1's plainest violation, which +# `document_facts.rs` would refuse. The engine learns only that a declared row +# matched, and reads the remedy back out. +# +# A SEPARATE TABLE FROM `[[exec_pattern]]`, which asks the opposite question: +# that one promotes a lying exit `0` to a violation, and this one explains a +# failure that already happened. `report_bundle` scans only on a `0` and says so +# — "Only `0` is promotable" — so one table serving both readings would produce +# opposite verdicts over identical bytes. +[[verify_environment_pattern]] +id = "disk-full" +pattern = "No space left on device" +stream = "both" +reason = "the disk filled, so this is the environment rather than your branch. Run `mise run target-prune`, and consider building with CARGO_INCREMENTAL=0." + +[lease] +landing_paths = [ + "mise-tasks/land.sh", + "mise-tasks/land-lock.sh", + "crates/batten/src/land.rs", + "crates/batten/src/lease.rs", + # `fast_forward.rs` carries the half of the landing protocol that ASKS for the + # merge and recognises the answer — the `/fast-forward` comment, its keyed + # verdict and the anti-livelock fence. A head that predates a change there is + # exactly as stale as one that predates a change to `land.rs`, and without this + # row it read as current. `pipeline.rs` is deliberately NOT here: it decides + # whether a composition is walkable and performs nothing, so a head can differ + # on it without differing on what a lap DOES. + "crates/batten/src/fast_forward.rs", +] + +# THE BRANCHES A LANDER FAST-FORWARDS, so no agent ever holds the lease for them +# and the runner-side precondition must not judge them. Read off the two landers' +# own `branches:` filters rather than guessed: +# +# auto-bot-land.yml renovate/**, sbom-actions/** +# auto-release-land.yml release-plz-** +# +# `sbom-actions/` IS THE ONE THE PREDECESSOR MISSED, and it is a live gap rather +# than a tidy-up. `ci-lease-precondition.sh`'s `case` lists `renovate/*` and +# `release-plz-*` only, so an `sbom-actions/**` branch — landed by the same +# workflow, on the same trigger, with nobody holding a lease on its behalf — is +# judged like an ordinary branch and its run is cancelled whenever some agent +# holds the lease. Found by reading the carve-out against the filters it is +# supposed to mirror; the shell has carried it since the lane was added. +# +# The economics, which are the predecessor's and unchanged: a cancelled run is +# `completed`, so those landers DO fire, find the checks not green, and stop. +# Nothing retries, and the head then waits for its next push to mint a fresh +# matrix. Cancelling here does not save a matrix — it DEFERS one and adds a +# stall. +# +# NOT `[bot_lane] bots`, which keys on a forge login. What decides this question +# is which workflow lands the branch, and a workflow selects on the name. +fast_forward_branches = ["renovate/", "sbom-actions/", "release-plz-"] + +# WHICH RECEIPTS A HEAD MUST CARRY TO BE CALLED VERIFIED (CLOUD-1338). +# +# These two names were `const VERIFIED_BY: [&str; 2]` inside `crates/batten`, +# which is non-negotiable rule 1's plainest shape: they are THIS repository's +# task names, and an adopter whose gates are called something else had no way to +# say so. The set is the consumer's now and the engine reads it. +# +# `verify` says the tree passed its gate. `linear-check` says the branch was +# linear on the trunk it was measured against, and records WHICH trunk, so a +# moved `origin/main` expires it — a head carrying only the first has been +# proven against a base that may no longer exist. +# +# AN EMPTY SET REFUSES rather than passing: nothing is unverified when nothing is +# required, so an undeclared row would make `verified` report clean over every +# head having asked about nothing. +[receipt] +verified_by = ["verify", "linear-check"] + [ready] prose_dialect_required_from = "2026-09-02T00:00:00.000Z" @@ -9322,20 +9905,6 @@ gloss = "declared and spent nowhere" word = "wrong" gloss = "does not match the declared shape" -[[verdict]] -id = "task declare dropped" -gloss = "mise.toml's [env] carries no no-proxy key, so nothing is fenced out of the agent proxy" -class = """ -A deleted fence is not a narrower fence. `mise install` then resolves every tool -release through a proxy that injects a repo-scoped token, and api.github.com -answers 403 for third-party tool repos — naming the tool rather than the proxy. -""" - -[[verdict.route]] -id = "task read first" -kind = "document" -target = "mise.toml" - [[verdict]] id = "provision declare dropped" gloss = "no [[provision.env]] row carries a no-proxy key, so the provisioned mise wrapper fences nothing" @@ -9378,6 +9947,20 @@ id = "provision read first" kind = "document" target = "batten.toml" +[[verdict]] +id = "task declare dropped" +gloss = "mise.toml's [env] carries no no-proxy key, so nothing is fenced out of the agent proxy" +class = """ +A deleted fence is not a narrower fence. `mise install` then resolves every tool +release through a proxy that injects a repo-scoped token, and api.github.com +answers 403 for third-party tool repos — naming the tool rather than the proxy. +""" + +[[verdict.route]] +id = "task read first" +kind = "document" +target = "mise.toml" + [[verdict]] id = "task declare partial" gloss = "the no-proxy value no longer names the host mise's release resolver calls" @@ -10933,7 +11516,7 @@ would not. [[verdict.route]] id = "task read first" kind = "document" -target = "mise-tasks/abandon-matrix.sh" +target = "crates/batten/src/lib.rs" [[verdict]] id = "job reach dead" @@ -10948,7 +11531,7 @@ fan-in is declared, and the matrix still bills out in full after the first red. [[verdict.route]] id = "task read first" kind = "document" -target = "mise-tasks/land.sh" +target = "crates/batten/src/lib.rs" [[verdict]] id = "cargo spelling other" @@ -12133,6 +12716,79 @@ id = "module read first" kind = "document" target = "policy/spawn-adapters.rego" +# CLOUD-1338's four. Separate tokens rather than one, for the reason the three +# above are separate: a reader meeting an added escape and a reader meeting a +# widened placement table have nothing to learn from each other. +# +# NONE OF THE FOUR DECLARES AN OVERRIDE ROUTE, and that omission is the rule's +# whole posture. `shell edit refused` is the precedent — one route, no override, +# no `bypass_env` — and the argument carries: an agent articulating why its own +# new spawn is fine is the mechanism that already failed, five times in one +# branch. The routes below point at what to READ, never at what to spend. +[[verdict]] +id = "spawn write refused" +gloss = "this change adds a clippy escape in engine source" +class = """ +A spawn is an inventory row and the inventory may not grow by annotation. Reach \ +for the in-process form first — `crate::rest` for the forge's REST tier, \ +`crate::fetch` for an ordinary HTTPS call, `crate::exec::piped_through` for a \ +child process that genuinely has to be one. Where a new spawn really is the \ +answer, it is a decision for a groomed row and a human rather than a `reason` \ +string the author writes about their own change. +""" + +[[verdict.route]] +id = "source read first" +kind = "document" +target = "crates/batten/src/rest.rs" + +[[verdict.route]] +id = "prose read first" +kind = "document" +target = ".claude/rules/rust.md" + +[[verdict]] +id = "adapter add refused" +gloss = "this change adds an entry to the spawn placement table" +class = """ +The placement table is deny-by-omission, so widening it is a two-word edit whose \ +justification lives in a comment nothing reads. That is how two placements landed \ +on a branch whose subject was removing shell. Remove the spawn instead, or take \ +the widening to a groomed row where a human decides it. +""" + +[[verdict.route]] +id = "module read first" +kind = "document" +target = "policy/spawn-adapters.rego" + +[[verdict]] +id = "diff read absent" +gloss = "the base rev did not resolve, so no change could be read" +class = """ +Could-not-look is not clean. With no delta every clause of this rule is undefined, \ +which is byte-identical to a tree that added no spawn — the dead-gate class this \ +repository exists to refuse. Fetch the base ref and run it again. +""" + +[[verdict.route]] +id = "patch run first" +kind = "command" +target = "git fetch origin main, so the declared base rev resolves and the change can be read" + +[[verdict]] +id = "source parse dead" +gloss = "a declared engine source would not parse, so it was never judged" +class = """ +A module that iterates only the documents it could read reports green over a file \ +it never opened. This names the file instead. +""" + +[[verdict.route]] +id = "source read first" +kind = "document" +target = "crates/batten/src/rest.rs" + [[verdict]] id = "plan read missing" gloss = "a declared plan the boundary could not acquire from the runner" @@ -12599,6 +13255,49 @@ id = "module read first" kind = "document" target = "policy/ci-cache-declared.rego" +[[verdict]] +id = "test cover partial" +gloss = "this branch put a #[cfg] platform attribute on a #[test], so one arm is never compiled where it is authored" +class = """ +The remedy is `cfg!` inside the case, not an attribute over it. An attribute makes the whole case vanish on the other target, so `cross-check` type-checks only the arm the local host admits and the next edit to the other one is discovered by CI; a `cfg!` keeps both arms compiled everywhere and states the off-platform contract where a reader can see it. Measured on CLOUD-1148: `scratch.rs`'s reaper case asserted collection unconditionally, the `windows` job reddened alone, and the first fix put `#[cfg(unix)]` over it -- which is this verdict. A case whose SUBJECT genuinely does not exist off the platform is a different thing and is not what this refuses: the rule is a ratchet over the diff, so the ~40 pairs already in the tree stay. The could-not-look side is `diff read absent`, which this module raises rather than restating. +""" + +[[verdict.route]] +id = "module read first" +kind = "document" +target = "policy/cfg-gated-test.rego" + +# THE LEGITIMATE CASE EXISTS AND ITS ROUTE IS A `[[waiver]]`, NOT AN OVERRIDE. +# `provision.rs`'s relink case asserts over `std::os::unix::fs::MetadataExt::ino`, +# a symbol the Windows target does not have, so a `cfg!` arm would not TYPE-CHECK +# — `cross-check` is what caught that spelling. Without any route at all, a gate +# whose refusal is sometimes right becomes one an author switches off rather than +# answers, which is the shape this rule's own header refuses. +# +# AN OVERRIDE ROUTE WAS DECLARED HERE FIRST AND WITHDRAWN ON MEASUREMENT, and the +# measurement is what this comment is for. An admission's record lives in the +# CHECKOUT's state directory — `admission::store_dir` resolves +# `state::repo_state_dir`, which keys the path by a digest of the canonical +# absolute root, and whose own header names "a CI checkout beside a local one" as +# the case that keying exists to separate. `apply_admissions` runs inside `check`, +# so it runs on the runner too, over a store that cannot be there. A spend +# therefore suppresses on the host that minted it and nowhere else: the finding +# below was admitted locally, `batten check --rule cfg-gated-test` read exit 0, +# and CI's `batten-check` raised +# `crates/batten/src/provision.rs platform-gated-test-added` on the same commit. +# +# So an override on a TREE-scope class is not a hatch, it is a local/CI parity +# trap, and it is WORSE than no route because it reads green to the author who +# spends it. Six of the seven such routes in this file are on tree-scope classes; +# CLOUD-1674 carries the general row and the gate that will refuse them. +# +# A waiver is the exemption a runner can read: committed, carrying a reason, and +# it LAPSES. It is also strictly narrower than the override was — one path, rather +# than a standing answer anyone can re-give — and it is expected to lapse unused, +# for the reason `inline-task-bodies-not-growing`'s waiver already records: +# `base = "origin/main"`, so the moment this lands the floor rises on its own and +# the row suppresses nothing. + [[verdict]] id = "suite bind missing" gloss = "the runner config declares no slow-timeout this gate can read, so no slow-test ban is in force" diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index 95a57c584..fb113fb66 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,114 +6,107 @@ runner measured it; the suite runs `--no-parallelize-within-files`, so a file's number is its own serial cost and is what an author adding a case to it pays. -- suites: 106 -- serial total: 445.5s +- suites: 99 +- serial total: 158.9s | seconds | share | suite | | ---: | ---: | --- | -| 141.5 | 31.8% | `tests/land-lock.bats` | -| 82.0 | 18.4% | `tests/land.bats` | -| 34.6 | 7.8% | `tests/main-watch.bats` | -| 21.2 | 4.8% | `tests/graph-check.bats` | -| 12.5 | 2.8% | `tests/board-diff-overlap.bats` | -| 9.0 | 2.0% | `tests/token-bench.bats` | -| 7.5 | 1.7% | `tests/target-race.bats` | -| 7.0 | 1.6% | `tests/ready-lint.bats` | -| 6.7 | 1.5% | `tests/board-sweep.bats` | -| 6.5 | 1.5% | `tests/released.bats` | -| 5.7 | 1.3% | `tests/in-progress-drain.bats` | -| 5.4 | 1.2% | `tests/release-tracking-check.bats` | -| 4.8 | 1.1% | `tests/release-assets-check.bats` | -| 4.7 | 1.1% | `tests/sbom.bats` | -| 4.2 | 0.9% | `tests/step-receipt.bats` | -| 3.9 | 0.9% | `tests/land-divergence.bats` | -| 3.9 | 0.9% | `tests/mcp-allow-check.bats` | -| 3.6 | 0.8% | `tests/doctor-race.bats` | -| 3.6 | 0.8% | `tests/with-lock.bats` | -| 3.4 | 0.8% | `tests/hk-selection.bats` | -| 3.4 | 0.8% | `tests/ntia-check.bats` | -| 3.3 | 0.7% | `tests/ready-cites-check.bats` | -| 3.1 | 0.7% | `tests/target-ensure.bats` | -| 2.7 | 0.6% | `tests/landed-check.bats` | -| 2.5 | 0.6% | `tests/install.bats` | -| 2.4 | 0.5% | `tests/closing-key-check.bats` | -| 2.3 | 0.5% | `tests/finding-sink-check.bats` | -| 1.8 | 0.4% | `tests/suite-select.bats` | -| 1.6 | 0.4% | `tests/spec-ref-check.bats` | -| 1.6 | 0.4% | `tests/claimed-keys.bats` | -| 1.6 | 0.4% | `tests/reclaim-census.bats` | -| 1.5 | 0.3% | `tests/ci-tools-check.bats` | -| 1.5 | 0.3% | `tests/tree-clean.bats` | -| 1.5 | 0.3% | `tests/signing-posture.bats` | -| 1.4 | 0.3% | `tests/verify.bats` | -| 1.4 | 0.3% | `tests/install-check.bats` | -| 1.4 | 0.3% | `tests/ready-lint-deferral.bats` | -| 1.4 | 0.3% | `tests/ci-slow-needed.bats` | -| 1.3 | 0.3% | `tests/linear-check.bats` | -| 1.3 | 0.3% | `tests/ci-lease-precondition.bats` | -| 1.2 | 0.3% | `tests/land-divergence-assert.bats` | -| 1.1 | 0.3% | `tests/done-check.bats` | -| 1.1 | 0.3% | `tests/deferral-check.bats` | -| 1.1 | 0.2% | `tests/perf-record.bats` | -| 1.1 | 0.2% | `tests/lint-rego.bats` | -| 1.0 | 0.2% | `tests/spawn-census.bats` | -| 1.0 | 0.2% | `tests/nonverdict-scan.bats` | -| 1.0 | 0.2% | `tests/awk-regex-check.bats` | -| 0.9 | 0.2% | `tests/module-map-check.bats` | -| 0.9 | 0.2% | `tests/release-backfill.bats` | -| 0.9 | 0.2% | `tests/render-cli.bats` | -| 0.8 | 0.2% | `tests/attestation-check.bats` | -| 0.8 | 0.2% | `tests/done-pr-check.bats` | -| 0.8 | 0.2% | `tests/doctor.bats` | -| 0.8 | 0.2% | `tests/lint-deno.bats` | -| 0.8 | 0.2% | `tests/pr-unsubscribed.bats` | -| 0.7 | 0.2% | `tests/commit-attribution.bats` | -| 0.7 | 0.2% | `tests/timeout-drift.bats` | -| 0.7 | 0.2% | `tests/sbom-binary.bats` | -| 0.7 | 0.2% | `tests/merged-pr-keys.bats` | -| 0.7 | 0.2% | `tests/duplicate-close-check.bats` | -| 0.6 | 0.1% | `tests/mcp-timeout-budget.bats` | -| 0.6 | 0.1% | `tests/verified.bats` | -| 0.6 | 0.1% | `tests/evaluator-closure-check.bats` | -| 0.6 | 0.1% | `tests/mcp-attach-check.bats` | -| 0.6 | 0.1% | `tests/suite-bench-check.bats` | -| 0.6 | 0.1% | `tests/checksums.bats` | -| 0.5 | 0.1% | `tests/hook-pin-check.bats` | -| 0.5 | 0.1% | `tests/stop-posture-check.bats` | -| 0.5 | 0.1% | `tests/macos-link-check.bats` | -| 0.5 | 0.1% | `tests/connector-allow-guard.bats` | -| 0.5 | 0.1% | `tests/publish-credential-check.bats` | -| 0.5 | 0.1% | `tests/board-payloads.bats` | -| 0.5 | 0.1% | `tests/digest-major-agreement.bats` | -| 0.5 | 0.1% | `tests/land-lock-check.bats` | -| 0.4 | 0.1% | `tests/pipefail-grep-check.bats` | -| 0.4 | 0.1% | `tests/branch-age-check.bats` | -| 0.4 | 0.1% | `tests/msrv-pin-agreement.bats` | -| 0.4 | 0.1% | `tests/abandon-matrix.bats` | -| 0.4 | 0.1% | `tests/commit-convention.bats` | -| 0.4 | 0.1% | `tests/sonar-gate.bats` | -| 0.4 | 0.1% | `tests/transcript-corpus-check.bats` | -| 0.4 | 0.1% | `tests/timeout-check.bats` | -| 0.4 | 0.1% | `tests/no-doctests.bats` | -| 0.3 | 0.1% | `tests/serena-mcp.bats` | -| 0.3 | 0.1% | `tests/license-table-check.bats` | -| 0.3 | 0.1% | `tests/report-only-check.bats` | -| 0.3 | 0.1% | `tests/nonverdict-assert.bats` | -| 0.3 | 0.1% | `tests/release-due.bats` | -| 0.3 | 0.1% | `tests/cap-drift.bats` | -| 0.3 | 0.1% | `tests/batten-glob-check.bats` | -| 0.3 | 0.1% | `tests/connector-allow-resolve.bats` | -| 0.3 | 0.1% | `tests/coderabbit-config-check.bats` | -| 0.3 | 0.1% | `tests/container-preflight.bats` | -| 0.2 | 0.1% | `tests/git-hook.bats` | +| 17.9 | 11.3% | `tests/graph-check.bats` | +| 10.2 | 6.4% | `tests/board-diff-overlap.bats` | +| 7.3 | 4.6% | `tests/target-race.bats` | +| 7.1 | 4.5% | `tests/ready-lint.bats` | +| 6.5 | 4.1% | `tests/token-bench.bats` | +| 6.1 | 3.8% | `tests/released.bats` | +| 5.3 | 3.3% | `tests/board-sweep.bats` | +| 4.9 | 3.1% | `tests/release-tracking-check.bats` | +| 4.8 | 3.0% | `tests/release-assets-check.bats` | +| 4.1 | 2.6% | `tests/sbom.bats` | +| 4.0 | 2.5% | `tests/in-progress-drain.bats` | +| 3.6 | 2.3% | `tests/step-receipt.bats` | +| 3.5 | 2.2% | `tests/mcp-allow-check.bats` | +| 3.4 | 2.1% | `tests/doctor-race.bats` | +| 3.3 | 2.1% | `tests/ready-cites-check.bats` | +| 3.3 | 2.0% | `tests/land-divergence.bats` | +| 3.2 | 2.0% | `tests/hk-selection.bats` | +| 3.0 | 1.9% | `tests/ntia-check.bats` | +| 2.9 | 1.8% | `tests/target-ensure.bats` | +| 2.8 | 1.8% | `tests/with-lock.bats` | +| 2.2 | 1.4% | `tests/landed-check.bats` | +| 2.1 | 1.3% | `tests/install.bats` | +| 1.8 | 1.1% | `tests/closing-key-check.bats` | +| 1.8 | 1.1% | `tests/reclaim-census.bats` | +| 1.7 | 1.0% | `tests/finding-sink-check.bats` | +| 1.6 | 1.0% | `tests/suite-select.bats` | +| 1.5 | 0.9% | `tests/spec-ref-check.bats` | +| 1.4 | 0.9% | `tests/signing-posture.bats` | +| 1.3 | 0.8% | `tests/claimed-keys.bats` | +| 1.2 | 0.8% | `tests/tree-clean.bats` | +| 1.2 | 0.8% | `tests/ci-slow-needed.bats` | +| 1.2 | 0.7% | `tests/ready-lint-deferral.bats` | +| 1.1 | 0.7% | `tests/ci-tools-check.bats` | +| 1.0 | 0.7% | `tests/verify.bats` | +| 1.0 | 0.6% | `tests/perf-record.bats` | +| 1.0 | 0.6% | `tests/install-check.bats` | +| 1.0 | 0.6% | `tests/linear-check.bats` | +| 1.0 | 0.6% | `tests/lint-rego.bats` | +| 0.9 | 0.6% | `tests/land-divergence-assert.bats` | +| 0.9 | 0.6% | `tests/nonverdict-scan.bats` | +| 0.9 | 0.6% | `tests/deferral-check.bats` | +| 0.9 | 0.6% | `tests/spawn-census.bats` | +| 0.9 | 0.5% | `tests/done-check.bats` | +| 0.8 | 0.5% | `tests/module-map-check.bats` | +| 0.8 | 0.5% | `tests/release-backfill.bats` | +| 0.8 | 0.5% | `tests/lint-deno.bats` | +| 0.7 | 0.4% | `tests/render-cli.bats` | +| 0.7 | 0.4% | `tests/pr-unsubscribed.bats` | +| 0.7 | 0.4% | `tests/awk-regex-check.bats` | +| 0.7 | 0.4% | `tests/doctor.bats` | +| 0.6 | 0.4% | `tests/commit-attribution.bats` | +| 0.6 | 0.4% | `tests/done-pr-check.bats` | +| 0.6 | 0.4% | `tests/attestation-check.bats` | +| 0.6 | 0.4% | `tests/merged-pr-keys.bats` | +| 0.6 | 0.4% | `tests/timeout-drift.bats` | +| 0.6 | 0.4% | `tests/evaluator-closure-check.bats` | +| 0.6 | 0.4% | `tests/sbom-binary.bats` | +| 0.6 | 0.4% | `tests/mcp-timeout-budget.bats` | +| 0.6 | 0.4% | `tests/mcp-attach-check.bats` | +| 0.5 | 0.3% | `tests/duplicate-close-check.bats` | +| 0.5 | 0.3% | `tests/suite-bench-check.bats` | +| 0.5 | 0.3% | `tests/macos-link-check.bats` | +| 0.5 | 0.3% | `tests/stop-posture-check.bats` | +| 0.4 | 0.3% | `tests/checksums.bats` | +| 0.4 | 0.3% | `tests/publish-credential-check.bats` | +| 0.4 | 0.3% | `tests/msrv-pin-agreement.bats` | +| 0.4 | 0.2% | `tests/pipefail-grep-check.bats` | +| 0.4 | 0.2% | `tests/digest-major-agreement.bats` | +| 0.4 | 0.2% | `tests/connector-allow-guard.bats` | +| 0.4 | 0.2% | `tests/hook-pin-check.bats` | +| 0.4 | 0.2% | `tests/commit-convention.bats` | +| 0.3 | 0.2% | `tests/sonar-gate.bats` | +| 0.3 | 0.2% | `tests/no-doctests.bats` | +| 0.3 | 0.2% | `tests/report-only-check.bats` | +| 0.3 | 0.2% | `tests/branch-age-check.bats` | +| 0.3 | 0.2% | `tests/license-table-check.bats` | +| 0.3 | 0.2% | `tests/board-payloads.bats` | +| 0.3 | 0.2% | `tests/nonverdict-assert.bats` | +| 0.3 | 0.2% | `tests/timeout-check.bats` | +| 0.3 | 0.2% | `tests/release-due.bats` | +| 0.3 | 0.2% | `tests/transcript-corpus-check.bats` | +| 0.3 | 0.2% | `tests/serena-mcp.bats` | +| 0.2 | 0.2% | `tests/connector-allow-resolve.bats` | +| 0.2 | 0.2% | `tests/batten-glob-check.bats` | +| 0.2 | 0.2% | `tests/cap-drift.bats` | | 0.2 | 0.1% | `tests/mise-action-floor.bats` | -| 0.2 | 0.0% | `tests/rust-paths-check.bats` | -| 0.2 | 0.0% | `tests/token-bench-check.bats` | -| 0.2 | 0.0% | `tests/remedy-payload-source.bats` | -| 0.2 | 0.0% | `tests/dist.bats` | -| 0.1 | 0.0% | `tests/task-fail-closed.bats` | -| 0.1 | 0.0% | `tests/egress-check.bats` | -| 0.1 | 0.0% | `tests/evaluator-io-check.bats` | -| 0.1 | 0.0% | `tests/darwin-link.bats` | -| 0.1 | 0.0% | `tests/cross-check.bats` | +| 0.2 | 0.1% | `tests/coderabbit-config-check.bats` | +| 0.2 | 0.1% | `tests/rust-paths-check.bats` | +| 0.2 | 0.1% | `tests/container-preflight.bats` | +| 0.2 | 0.1% | `tests/git-hook.bats` | +| 0.1 | 0.1% | `tests/token-bench-check.bats` | +| 0.1 | 0.1% | `tests/remedy-payload-source.bats` | +| 0.1 | 0.1% | `tests/task-fail-closed.bats` | +| 0.1 | 0.1% | `tests/dist.bats` | +| 0.1 | 0.1% | `tests/egress-check.bats` | +| 0.1 | 0.1% | `tests/evaluator-io-check.bats` | +| 0.0 | 0.0% | `tests/darwin-link.bats` | +| 0.0 | 0.0% | `tests/cross-check.bats` | | 0.0 | 0.0% | `tests/zizmor-split.bats` | diff --git a/completions/batten.bash b/completions/batten.bash index b980f3e15..d18f5ad07 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -595,6 +595,12 @@ _batten() { batten__subcmd__help__subcmd__hk,observe) cmd="batten__subcmd__help__subcmd__hk__subcmd__observe" ;; + batten__subcmd__help__subcmd__land,fast-forward) + cmd="batten__subcmd__help__subcmd__land__subcmd__fast__subcmd__forward" + ;; + batten__subcmd__help__subcmd__land,lap) + cmd="batten__subcmd__help__subcmd__land__subcmd__lap" + ;; batten__subcmd__help__subcmd__land,push) cmd="batten__subcmd__help__subcmd__land__subcmd__push" ;; @@ -619,9 +625,15 @@ _batten() { batten__subcmd__help__subcmd__lease,authorises) cmd="batten__subcmd__help__subcmd__lease__subcmd__authorises" ;; + batten__subcmd__help__subcmd__lease,carries) + cmd="batten__subcmd__help__subcmd__lease__subcmd__carries" + ;; batten__subcmd__help__subcmd__lease,check) cmd="batten__subcmd__help__subcmd__lease__subcmd__check" ;; + batten__subcmd__help__subcmd__lease,guard) + cmd="batten__subcmd__help__subcmd__lease__subcmd__guard" + ;; batten__subcmd__help__subcmd__lease,held) cmd="batten__subcmd__help__subcmd__lease__subcmd__held" ;; @@ -721,6 +733,9 @@ _batten() { batten__subcmd__help__subcmd__receipt,status) cmd="batten__subcmd__help__subcmd__receipt__subcmd__status" ;; + batten__subcmd__help__subcmd__receipt,verified) + cmd="batten__subcmd__help__subcmd__receipt__subcmd__verified" + ;; batten__subcmd__help__subcmd__record,closes) cmd="batten__subcmd__help__subcmd__record__subcmd__closes" ;; @@ -814,9 +829,15 @@ _batten() { batten__subcmd__hk__subcmd__help,observe) cmd="batten__subcmd__hk__subcmd__help__subcmd__observe" ;; + batten__subcmd__land,fast-forward) + cmd="batten__subcmd__land__subcmd__fast__subcmd__forward" + ;; batten__subcmd__land,help) cmd="batten__subcmd__land__subcmd__help" ;; + batten__subcmd__land,lap) + cmd="batten__subcmd__land__subcmd__lap" + ;; batten__subcmd__land,push) cmd="batten__subcmd__land__subcmd__push" ;; @@ -829,9 +850,15 @@ _batten() { batten__subcmd__land,wait) cmd="batten__subcmd__land__subcmd__wait" ;; + batten__subcmd__land__subcmd__help,fast-forward) + cmd="batten__subcmd__land__subcmd__help__subcmd__fast__subcmd__forward" + ;; batten__subcmd__land__subcmd__help,help) cmd="batten__subcmd__land__subcmd__help__subcmd__help" ;; + batten__subcmd__land__subcmd__help,lap) + cmd="batten__subcmd__land__subcmd__help__subcmd__lap" + ;; batten__subcmd__land__subcmd__help,push) cmd="batten__subcmd__land__subcmd__help__subcmd__push" ;; @@ -868,9 +895,15 @@ _batten() { batten__subcmd__lease,authorises) cmd="batten__subcmd__lease__subcmd__authorises" ;; + batten__subcmd__lease,carries) + cmd="batten__subcmd__lease__subcmd__carries" + ;; batten__subcmd__lease,check) cmd="batten__subcmd__lease__subcmd__check" ;; + batten__subcmd__lease,guard) + cmd="batten__subcmd__lease__subcmd__guard" + ;; batten__subcmd__lease,held) cmd="batten__subcmd__lease__subcmd__held" ;; @@ -901,9 +934,15 @@ _batten() { batten__subcmd__lease__subcmd__help,authorises) cmd="batten__subcmd__lease__subcmd__help__subcmd__authorises" ;; + batten__subcmd__lease__subcmd__help,carries) + cmd="batten__subcmd__lease__subcmd__help__subcmd__carries" + ;; batten__subcmd__lease__subcmd__help,check) cmd="batten__subcmd__lease__subcmd__help__subcmd__check" ;; + batten__subcmd__lease__subcmd__help,guard) + cmd="batten__subcmd__lease__subcmd__help__subcmd__guard" + ;; batten__subcmd__lease__subcmd__help,held) cmd="batten__subcmd__lease__subcmd__help__subcmd__held" ;; @@ -1141,6 +1180,9 @@ _batten() { batten__subcmd__receipt,status) cmd="batten__subcmd__receipt__subcmd__status" ;; + batten__subcmd__receipt,verified) + cmd="batten__subcmd__receipt__subcmd__verified" + ;; batten__subcmd__receipt__subcmd__help,help) cmd="batten__subcmd__receipt__subcmd__help__subcmd__help" ;; @@ -1150,6 +1192,9 @@ _batten() { batten__subcmd__receipt__subcmd__help,status) cmd="batten__subcmd__receipt__subcmd__help__subcmd__status" ;; + batten__subcmd__receipt__subcmd__help,verified) + cmd="batten__subcmd__receipt__subcmd__help__subcmd__verified" + ;; batten__subcmd__record,closes) cmd="batten__subcmd__record__subcmd__closes" ;; @@ -4157,7 +4202,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__land) - opts="replay wait push verify" + opts="replay wait push verify fast-forward lap" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -4170,6 +4215,34 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__land__subcmd__fast__subcmd__forward) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__help__subcmd__land__subcmd__lap) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__land__subcmd__push) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -4269,7 +4342,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__lease) - opts="authorises check status peek held acquire renew hold release reserve" + opts="authorises carries guard check status peek held acquire renew hold release reserve" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -4310,6 +4383,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__lease__subcmd__carries) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__lease__subcmd__check) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -4324,6 +4411,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__lease__subcmd__guard) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__lease__subcmd__held) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -4899,7 +5000,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__receipt) - opts="record status" + opts="record status verified" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -4940,6 +5041,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__receipt__subcmd__verified) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__record) opts="tool forge plan closes" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -5641,7 +5756,7 @@ _batten() { return 0 ;; batten__subcmd__land) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help replay wait push verify help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help replay wait push verify fast-forward lap help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5670,8 +5785,38 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__land__subcmd__fast__subcmd__forward) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__land__subcmd__help) - opts="replay wait push verify help" + opts="replay wait push verify fast-forward lap help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5684,6 +5829,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__land__subcmd__help__subcmd__fast__subcmd__forward) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__land__subcmd__help__subcmd__help) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -5698,6 +5857,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__land__subcmd__help__subcmd__lap) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__land__subcmd__help__subcmd__push) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -5754,6 +5927,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__land__subcmd__lap) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__land__subcmd__push) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -5785,12 +5988,16 @@ _batten() { return 0 ;; batten__subcmd__land__subcmd__replay) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + opts="-q -v -y -h --resolve --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi case "${prev}" in + --resolve) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --strictness) COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) return 0 @@ -6061,7 +6268,7 @@ _batten() { return 0 ;; batten__subcmd__lease) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help authorises check status peek held acquire renew hold release reserve help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help authorises carries guard check status peek held acquire renew hold release reserve help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -6150,6 +6357,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__lease__subcmd__carries) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__lease__subcmd__check) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -6180,6 +6417,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__lease__subcmd__guard) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__lease__subcmd__held) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -6211,7 +6478,7 @@ _batten() { return 0 ;; batten__subcmd__lease__subcmd__help) - opts="authorises check status peek held acquire renew hold release reserve help" + opts="authorises carries guard check status peek held acquire renew hold release reserve help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -6252,6 +6519,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__lease__subcmd__help__subcmd__carries) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__lease__subcmd__help__subcmd__check) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -6266,6 +6547,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__lease__subcmd__help__subcmd__guard) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__lease__subcmd__help__subcmd__held) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -8271,7 +8566,7 @@ _batten() { return 0 ;; batten__subcmd__receipt) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help record status help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help record status verified help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -8301,7 +8596,7 @@ _batten() { return 0 ;; batten__subcmd__receipt__subcmd__help) - opts="record status help" + opts="record status verified help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -8356,6 +8651,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__receipt__subcmd__help__subcmd__verified) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__receipt__subcmd__record) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -8420,6 +8729,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__receipt__subcmd__verified) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help tool forge plan closes help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 6a7d0c2cf..b4bd2e392 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -2510,30 +2510,31 @@ complete -c batten -n "__fish_batten_using_subcommand payload; and __fish_seen_s complete -c batten -n "__fish_batten_using_subcommand payload; and __fish_seen_subcommand_from field" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand payload; and __fish_seen_subcommand_from help" -f -a "field" -d 'Print one field of a hook payload read from stdin, for a shell hook that must not depend on jq' complete -c batten -n "__fish_batten_using_subcommand payload; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -f -a "record" -d 'Record that the named check concluded pass against the current HEAD' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -f -a "status" -d 'Judge the named check\'s recorded receipt against HEAD and origin/main' -complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -f -a "record" -d 'Record that the named check concluded pass against the current HEAD' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -f -a "status" -d 'Judge the named check\'s recorded receipt against HEAD and origin/main' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -f -a "verified" -d 'Is HEAD verified — every declared check\'s receipt valid against this commit?' +complete -c batten -n "__fish_batten_using_subcommand receipt; and not __fish_seen_subcommand_from record status verified help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from record" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -2581,8 +2582,30 @@ complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_s complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from status" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from status" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from status" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from verified" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from help" -f -a "record" -d 'Record that the named check concluded pass against the current HEAD' complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from help" -f -a "status" -d 'Judge the named check\'s recorded receipt against HEAD and origin/main' +complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from help" -f -a "verified" -d 'Is HEAD verified — every declared check\'s receipt valid against this commit?' complete -c batten -n "__fish_batten_using_subcommand receipt; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand defects; and not __fish_seen_subcommand_from query add help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' @@ -3054,38 +3077,40 @@ complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from help" -f -a "reclaim" -d 'Remove non-batten hook registrations from this host\'s merged surfaces' complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' -standard\t'The default: a finding is a violation' -strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' -quiet\t'Suppress ordinary progress; keep warnings' -normal\t'The default' -verbose\t'Explain what is being checked' -debug\t'Add resolution detail' -trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -f -a "authorises" -d 'May this branch spend a matrix right now?' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -f -a "check" -d 'Gate: the lease is free or a live, well-formed hold — never a wedge and never garbage' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -f -a "status" -d 'Report who holds the lease, for how much longer, and who is admitted behind them' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -f -a "peek" -d 'Print one advisory field of the held lease, or nothing' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -f -a "held" -d 'Is this clone\'s lease still held, with a beat of margin to act on?' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -f -a "acquire" -d 'Take the lease, waiting out a live holder and reaping a dead one' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -f -a "renew" -d 'Extend this clone\'s lease by one term' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -f -a "hold" -d 'Renew this clone\'s lease every beat until it is lost or the hold ends' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -f -a "release" -d 'Hand the lease back, leaving a tombstone rather than deleting the ref' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -f -a "reserve" -d 'Take the one slot behind the current holder' -complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises check status peek held acquire renew hold release reserve help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "authorises" -d 'May this branch spend a matrix right now?' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "carries" -d 'Gate: this head carries the landing mechanism trunk has, so it can be serialised' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "guard" -d 'The runner\'s step-0 guard: may this branch spend a matrix right now?' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "check" -d 'Gate: the lease is free or a live, well-formed hold — never a wedge and never garbage' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "status" -d 'Report who holds the lease, for how much longer, and who is admitted behind them' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "peek" -d 'Print one advisory field of the held lease, or nothing' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "held" -d 'Is this clone\'s lease still held, with a beat of margin to act on?' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "acquire" -d 'Take the lease, waiting out a live holder and reaping a dead one' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "renew" -d 'Extend this clone\'s lease by one term' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "hold" -d 'Renew this clone\'s lease every beat until it is lost or the hold ends' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "release" -d 'Hand the lease back, leaving a tombstone rather than deleting the ref' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "reserve" -d 'Take the one slot behind the current holder' +complete -c batten -n "__fish_batten_using_subcommand lease; and not __fish_seen_subcommand_from authorises carries guard check status peek held acquire renew hold release reserve help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from authorises" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -3107,6 +3132,48 @@ complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from authorises" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from authorises" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from authorises" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from carries" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from guard" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from check" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -3298,6 +3365,8 @@ complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from reserve" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from reserve" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from help" -f -a "authorises" -d 'May this branch spend a matrix right now?' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from help" -f -a "carries" -d 'Gate: this head carries the landing mechanism trunk has, so it can be serialised' +complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from help" -f -a "guard" -d 'The runner\'s step-0 guard: may this branch spend a matrix right now?' complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from help" -f -a "check" -d 'Gate: the lease is free or a live, well-formed hold — never a wedge and never garbage' complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from help" -f -a "status" -d 'Report who holds the lease, for how much longer, and who is admitted behind them' complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from help" -f -a "peek" -d 'Print one advisory field of the held lease, or nothing' @@ -3308,32 +3377,35 @@ complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from help" -f -a "release" -d 'Hand the lease back, leaving a tombstone rather than deleting the ref' complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from help" -f -a "reserve" -d 'Take the one slot behind the current holder' complete -c batten -n "__fish_batten_using_subcommand lease; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -f -a "replay" -d 'Advance the base and replay this branch onto it, recording the outcome' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -f -a "wait" -d 'Ask whether this head is green and whether its base still holds; the first answer decides' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -f -a "push" -d 'Push this branch to its own ref, under receive-pack\'s compare-and-swap' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -f -a "verify" -d 'Run the configured gate over this head and record what it answered' -complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -f -a "replay" -d 'Advance the base and replay this branch onto it, recording the outcome' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -f -a "wait" -d 'Ask whether this head is green and whether its base still holds; the first answer decides' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -f -a "push" -d 'Push this branch to its own ref, under receive-pack\'s compare-and-swap' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -f -a "verify" -d 'Run the configured gate over this head and record what it answered' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -f -a "fast-forward" -d 'Ask this head\'s pull request to fast-forward, and read the answer that request got' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -f -a "lap" -d 'Drive the whole lap and lap again on any refusal a rebase would clear' +complete -c batten -n "__fish_batten_using_subcommand land; and not __fish_seen_subcommand_from replay wait push verify fast-forward lap help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from replay" -l resolve -d 'A path whose conflict is resolved in the worktree (repeatable)' -r complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from replay" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -3418,10 +3490,54 @@ complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from verify" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from verify" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from verify" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from fast-forward" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from lap" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from help" -f -a "replay" -d 'Advance the base and replay this branch onto it, recording the outcome' complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from help" -f -a "wait" -d 'Ask whether this head is green and whether its base still holds; the first answer decides' complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from help" -f -a "push" -d 'Push this branch to its own ref, under receive-pack\'s compare-and-swap' complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from help" -f -a "verify" -d 'Run the configured gate over this head and record what it answered' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from help" -f -a "fast-forward" -d 'Ask this head\'s pull request to fast-forward, and read the answer that request got' +complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from help" -f -a "lap" -d 'Drive the whole lap and lap again on any refusal a rebase would clear' complete -c batten -n "__fish_batten_using_subcommand land; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready landed hk checks pr task singleton claim semver attribution worktree override provision startup adjudicate payload receipt defects design state record show wiring lease land help" -f -a "check" -d 'Run the applicable read-only gates against the repository' complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready landed hk checks pr task singleton claim semver attribution worktree override provision startup adjudicate payload receipt defects design state record show wiring lease land help" -f -a "enforce" -d 'Run every configured rule, including kinds that execute a configured command' @@ -3534,6 +3650,7 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from payload" -f -a "field" -d 'Print one field of a hook payload read from stdin, for a shell hook that must not depend on jq' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from receipt" -f -a "record" -d 'Record that the named check concluded pass against the current HEAD' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from receipt" -f -a "status" -d 'Judge the named check\'s recorded receipt against HEAD and origin/main' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from receipt" -f -a "verified" -d 'Is HEAD verified — every declared check\'s receipt valid against this commit?' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from defects" -f -a "query" -d 'List recorded defects, as pointers' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from defects" -f -a "add" -d 'Append defect records read as JSONL on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from design" -f -a "audit" -d 'Audit a JSONL design-evidence claim stream on stdin for record integrity' @@ -3549,6 +3666,8 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from show" -f -a "agent" -d 'What an agent may do in this repository: the read-only verbs, the exit contract, and the declared gates' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from wiring" -f -a "reclaim" -d 'Remove non-batten hook registrations from this host\'s merged surfaces' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from lease" -f -a "authorises" -d 'May this branch spend a matrix right now?' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from lease" -f -a "carries" -d 'Gate: this head carries the landing mechanism trunk has, so it can be serialised' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from lease" -f -a "guard" -d 'The runner\'s step-0 guard: may this branch spend a matrix right now?' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from lease" -f -a "check" -d 'Gate: the lease is free or a live, well-formed hold — never a wedge and never garbage' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from lease" -f -a "status" -d 'Report who holds the lease, for how much longer, and who is admitted behind them' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from lease" -f -a "peek" -d 'Print one advisory field of the held lease, or nothing' @@ -3562,3 +3681,5 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from land" -f -a "wait" -d 'Ask whether this head is green and whether its base still holds; the first answer decides' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from land" -f -a "push" -d 'Push this branch to its own ref, under receive-pack\'s compare-and-swap' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from land" -f -a "verify" -d 'Run the configured gate over this head and record what it answered' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from land" -f -a "fast-forward" -d 'Ask this head\'s pull request to fast-forward, and read the answer that request got' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from land" -f -a "lap" -d 'Drive the whole lap and lap again on any refusal a rebase would clear' diff --git a/completions/batten.zsh b/completions/batten.zsh index 582643736..d3cc52dd6 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -4312,6 +4312,35 @@ trace\:"Add everything"))' \ ':check -- The check whose receipt is judged:_default' \ && ret=0 ;; +(verified) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ ":: :_batten__subcmd__receipt__subcmd__help_commands" \ @@ -4332,6 +4361,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(verified) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -5260,6 +5293,68 @@ trace\:"Add everything"))' \ ':branch -- The branch being asked about:_default' \ && ret=0 ;; +(carries) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':head -- The head commit being judged:_default' \ +&& ret=0 +;; +(guard) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':head -- The head commit being judged:_default' \ +':branch -- The branch being asked about:_default' \ +':run -- The run to cancel on a stop:_default' \ +&& ret=0 +;; (check) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -5542,6 +5637,14 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(carries) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(guard) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (check) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -5629,6 +5732,7 @@ trace\:"Add everything"))' \ case $line[1] in (replay) _arguments "${_arguments_options[@]}" : \ +'*--resolve=[A path whose conflict is resolved in the worktree (repeatable)]: :_default' \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" standard\:"The default\: a finding is a violation" strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ @@ -5745,6 +5849,65 @@ trace\:"Add everything"))' \ '--help[Print help (see more with '\''--help'\'')]' \ && ret=0 ;; +(fast-forward) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; +(lap) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':reference -- The remote reference to replay onto:_default' \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ ":: :_batten__subcmd__land__subcmd__help_commands" \ @@ -5773,6 +5936,14 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(fast-forward) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(lap) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -6512,6 +6683,10 @@ _arguments "${_arguments_options[@]}" : \ (status) _arguments "${_arguments_options[@]}" : \ && ret=0 +;; +(verified) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 ;; esac ;; @@ -6685,6 +6860,14 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(carries) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(guard) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (check) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -6752,6 +6935,14 @@ _arguments "${_arguments_options[@]}" : \ (verify) _arguments "${_arguments_options[@]}" : \ && ret=0 +;; +(fast-forward) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(lap) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 ;; esac ;; @@ -7713,9 +7904,21 @@ _batten__subcmd__help__subcmd__land_commands() { 'wait:Ask whether this head is green and whether its base still holds; the first answer decides' \ 'push:Push this branch to its own ref, under receive-pack'\''s compare-and-swap' \ 'verify:Run the configured gate over this head and record what it answered' \ +'fast-forward:Ask this head'\''s pull request to fast-forward, and read the answer that request got' \ +'lap:Drive the whole lap and lap again on any refusal a rebase would clear' \ ) _describe -t commands 'batten help land commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__land__subcmd__fast-forward_commands] )) || +_batten__subcmd__help__subcmd__land__subcmd__fast-forward_commands() { + local commands; commands=() + _describe -t commands 'batten help land fast-forward commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__land__subcmd__lap_commands] )) || +_batten__subcmd__help__subcmd__land__subcmd__lap_commands() { + local commands; commands=() + _describe -t commands 'batten help land lap commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__land__subcmd__push_commands] )) || _batten__subcmd__help__subcmd__land__subcmd__push_commands() { local commands; commands=() @@ -7758,6 +7961,8 @@ _batten__subcmd__help__subcmd__landed__subcmd__check_commands() { _batten__subcmd__help__subcmd__lease_commands() { local commands; commands=( 'authorises:May this branch spend a matrix right now?' \ +'carries:Gate\: this head carries the landing mechanism trunk has, so it can be serialised' \ +'guard:The runner'\''s step-0 guard\: may this branch spend a matrix right now?' \ 'check:Gate\: the lease is free or a live, well-formed hold — never a wedge and never garbage' \ 'status:Report who holds the lease, for how much longer, and who is admitted behind them' \ 'peek:Print one advisory field of the held lease, or nothing' \ @@ -7780,11 +7985,21 @@ _batten__subcmd__help__subcmd__lease__subcmd__authorises_commands() { local commands; commands=() _describe -t commands 'batten help lease authorises commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__lease__subcmd__carries_commands] )) || +_batten__subcmd__help__subcmd__lease__subcmd__carries_commands() { + local commands; commands=() + _describe -t commands 'batten help lease carries commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__lease__subcmd__check_commands] )) || _batten__subcmd__help__subcmd__lease__subcmd__check_commands() { local commands; commands=() _describe -t commands 'batten help lease check commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__lease__subcmd__guard_commands] )) || +_batten__subcmd__help__subcmd__lease__subcmd__guard_commands() { + local commands; commands=() + _describe -t commands 'batten help lease guard commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__lease__subcmd__held_commands] )) || _batten__subcmd__help__subcmd__lease__subcmd__held_commands() { local commands; commands=() @@ -8029,6 +8244,7 @@ _batten__subcmd__help__subcmd__receipt_commands() { local commands; commands=( 'record:Record that the named check concluded pass against the current HEAD' \ 'status:Judge the named check'\''s recorded receipt against HEAD and origin/main' \ +'verified:Is HEAD verified — every declared check'\''s receipt valid against this commit?' \ ) _describe -t commands 'batten help receipt commands' commands "$@" } @@ -8042,6 +8258,11 @@ _batten__subcmd__help__subcmd__receipt__subcmd__status_commands() { local commands; commands=() _describe -t commands 'batten help receipt status commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__receipt__subcmd__verified_commands] )) || +_batten__subcmd__help__subcmd__receipt__subcmd__verified_commands() { + local commands; commands=() + _describe -t commands 'batten help receipt verified commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__record_commands] )) || _batten__subcmd__help__subcmd__record_commands() { local commands; commands=( @@ -8311,10 +8532,17 @@ _batten__subcmd__land_commands() { 'wait:Ask whether this head is green and whether its base still holds; the first answer decides' \ 'push:Push this branch to its own ref, under receive-pack'\''s compare-and-swap' \ 'verify:Run the configured gate over this head and record what it answered' \ +'fast-forward:Ask this head'\''s pull request to fast-forward, and read the answer that request got' \ +'lap:Drive the whole lap and lap again on any refusal a rebase would clear' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten land commands' commands "$@" } +(( $+functions[_batten__subcmd__land__subcmd__fast-forward_commands] )) || +_batten__subcmd__land__subcmd__fast-forward_commands() { + local commands; commands=() + _describe -t commands 'batten land fast-forward commands' commands "$@" +} (( $+functions[_batten__subcmd__land__subcmd__help_commands] )) || _batten__subcmd__land__subcmd__help_commands() { local commands; commands=( @@ -8322,15 +8550,27 @@ _batten__subcmd__land__subcmd__help_commands() { 'wait:Ask whether this head is green and whether its base still holds; the first answer decides' \ 'push:Push this branch to its own ref, under receive-pack'\''s compare-and-swap' \ 'verify:Run the configured gate over this head and record what it answered' \ +'fast-forward:Ask this head'\''s pull request to fast-forward, and read the answer that request got' \ +'lap:Drive the whole lap and lap again on any refusal a rebase would clear' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten land help commands' commands "$@" } +(( $+functions[_batten__subcmd__land__subcmd__help__subcmd__fast-forward_commands] )) || +_batten__subcmd__land__subcmd__help__subcmd__fast-forward_commands() { + local commands; commands=() + _describe -t commands 'batten land help fast-forward commands' commands "$@" +} (( $+functions[_batten__subcmd__land__subcmd__help__subcmd__help_commands] )) || _batten__subcmd__land__subcmd__help__subcmd__help_commands() { local commands; commands=() _describe -t commands 'batten land help help commands' commands "$@" } +(( $+functions[_batten__subcmd__land__subcmd__help__subcmd__lap_commands] )) || +_batten__subcmd__land__subcmd__help__subcmd__lap_commands() { + local commands; commands=() + _describe -t commands 'batten land help lap commands' commands "$@" +} (( $+functions[_batten__subcmd__land__subcmd__help__subcmd__push_commands] )) || _batten__subcmd__land__subcmd__help__subcmd__push_commands() { local commands; commands=() @@ -8351,6 +8591,11 @@ _batten__subcmd__land__subcmd__help__subcmd__wait_commands() { local commands; commands=() _describe -t commands 'batten land help wait commands' commands "$@" } +(( $+functions[_batten__subcmd__land__subcmd__lap_commands] )) || +_batten__subcmd__land__subcmd__lap_commands() { + local commands; commands=() + _describe -t commands 'batten land lap commands' commands "$@" +} (( $+functions[_batten__subcmd__land__subcmd__push_commands] )) || _batten__subcmd__land__subcmd__push_commands() { local commands; commands=() @@ -8418,6 +8663,8 @@ _batten__subcmd__landed__subcmd__help__subcmd__help_commands() { _batten__subcmd__lease_commands() { local commands; commands=( 'authorises:May this branch spend a matrix right now?' \ +'carries:Gate\: this head carries the landing mechanism trunk has, so it can be serialised' \ +'guard:The runner'\''s step-0 guard\: may this branch spend a matrix right now?' \ 'check:Gate\: the lease is free or a live, well-formed hold — never a wedge and never garbage' \ 'status:Report who holds the lease, for how much longer, and who is admitted behind them' \ 'peek:Print one advisory field of the held lease, or nothing' \ @@ -8441,11 +8688,21 @@ _batten__subcmd__lease__subcmd__authorises_commands() { local commands; commands=() _describe -t commands 'batten lease authorises commands' commands "$@" } +(( $+functions[_batten__subcmd__lease__subcmd__carries_commands] )) || +_batten__subcmd__lease__subcmd__carries_commands() { + local commands; commands=() + _describe -t commands 'batten lease carries commands' commands "$@" +} (( $+functions[_batten__subcmd__lease__subcmd__check_commands] )) || _batten__subcmd__lease__subcmd__check_commands() { local commands; commands=() _describe -t commands 'batten lease check commands' commands "$@" } +(( $+functions[_batten__subcmd__lease__subcmd__guard_commands] )) || +_batten__subcmd__lease__subcmd__guard_commands() { + local commands; commands=() + _describe -t commands 'batten lease guard commands' commands "$@" +} (( $+functions[_batten__subcmd__lease__subcmd__held_commands] )) || _batten__subcmd__lease__subcmd__held_commands() { local commands; commands=() @@ -8455,6 +8712,8 @@ _batten__subcmd__lease__subcmd__held_commands() { _batten__subcmd__lease__subcmd__help_commands() { local commands; commands=( 'authorises:May this branch spend a matrix right now?' \ +'carries:Gate\: this head carries the landing mechanism trunk has, so it can be serialised' \ +'guard:The runner'\''s step-0 guard\: may this branch spend a matrix right now?' \ 'check:Gate\: the lease is free or a live, well-formed hold — never a wedge and never garbage' \ 'status:Report who holds the lease, for how much longer, and who is admitted behind them' \ 'peek:Print one advisory field of the held lease, or nothing' \ @@ -8478,11 +8737,21 @@ _batten__subcmd__lease__subcmd__help__subcmd__authorises_commands() { local commands; commands=() _describe -t commands 'batten lease help authorises commands' commands "$@" } +(( $+functions[_batten__subcmd__lease__subcmd__help__subcmd__carries_commands] )) || +_batten__subcmd__lease__subcmd__help__subcmd__carries_commands() { + local commands; commands=() + _describe -t commands 'batten lease help carries commands' commands "$@" +} (( $+functions[_batten__subcmd__lease__subcmd__help__subcmd__check_commands] )) || _batten__subcmd__lease__subcmd__help__subcmd__check_commands() { local commands; commands=() _describe -t commands 'batten lease help check commands' commands "$@" } +(( $+functions[_batten__subcmd__lease__subcmd__help__subcmd__guard_commands] )) || +_batten__subcmd__lease__subcmd__help__subcmd__guard_commands() { + local commands; commands=() + _describe -t commands 'batten lease help guard commands' commands "$@" +} (( $+functions[_batten__subcmd__lease__subcmd__help__subcmd__held_commands] )) || _batten__subcmd__lease__subcmd__help__subcmd__held_commands() { local commands; commands=() @@ -9036,6 +9305,7 @@ _batten__subcmd__receipt_commands() { local commands; commands=( 'record:Record that the named check concluded pass against the current HEAD' \ 'status:Judge the named check'\''s recorded receipt against HEAD and origin/main' \ +'verified:Is HEAD verified — every declared check'\''s receipt valid against this commit?' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten receipt commands' commands "$@" @@ -9045,6 +9315,7 @@ _batten__subcmd__receipt__subcmd__help_commands() { local commands; commands=( 'record:Record that the named check concluded pass against the current HEAD' \ 'status:Judge the named check'\''s recorded receipt against HEAD and origin/main' \ +'verified:Is HEAD verified — every declared check'\''s receipt valid against this commit?' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten receipt help commands' commands "$@" @@ -9064,6 +9335,11 @@ _batten__subcmd__receipt__subcmd__help__subcmd__status_commands() { local commands; commands=() _describe -t commands 'batten receipt help status commands' commands "$@" } +(( $+functions[_batten__subcmd__receipt__subcmd__help__subcmd__verified_commands] )) || +_batten__subcmd__receipt__subcmd__help__subcmd__verified_commands() { + local commands; commands=() + _describe -t commands 'batten receipt help verified commands' commands "$@" +} (( $+functions[_batten__subcmd__receipt__subcmd__record_commands] )) || _batten__subcmd__receipt__subcmd__record_commands() { local commands; commands=() @@ -9074,6 +9350,11 @@ _batten__subcmd__receipt__subcmd__status_commands() { local commands; commands=() _describe -t commands 'batten receipt status commands' commands "$@" } +(( $+functions[_batten__subcmd__receipt__subcmd__verified_commands] )) || +_batten__subcmd__receipt__subcmd__verified_commands() { + local commands; commands=() + _describe -t commands 'batten receipt verified commands' commands "$@" +} (( $+functions[_batten__subcmd__record_commands] )) || _batten__subcmd__record_commands() { local commands; commands=( diff --git a/crates/batten/src/ci.rs b/crates/batten/src/ci.rs index dc41e3df7..92c7e4302 100644 --- a/crates/batten/src/ci.rs +++ b/crates/batten/src/ci.rs @@ -335,12 +335,33 @@ pub fn derive_host(payload: &str) -> Result { /// separates a real comparison from a non-zero exit on any non-200. /// /// A key the config leaves unclaimed is skipped: this polices what the tree -/// says, and says nothing about what it does not. +/// says, and says nothing about what it does not. A key the config DOES claim +/// and the host did not report is a [`Drift`] carrying `+unreported`, never a +/// skip — see the comment on the comparison for the credential-scope shape that +/// makes it live. #[must_use] pub fn host_drift(committed: &Host, host: &Host) -> Vec { let mut found = Vec::new(); let mut compare = |key: &str, mine: Option, theirs: Option| { - let (Some(mine), Some(theirs)) = (mine, theirs) else { + // AN UNCLAIMED KEY IS SKIPPED; AN UNREPORTED ONE IS NOT. The two were one + // `else { return }` (review of #848), so a key the tree DOES claim and + // the host did not report read as agreement — the answer this comparison + // exists never to give. `derive_host` guards only the all-absent payload; + // a partial one is the live shape, because + // `security_and_analysis.secret_scanning_push_protection` is absent + // whenever the credential lacks the scope to see it, which is a + // could-not-look about the strongest control in the table. + let Some(mine) = mine else { + return; + }; + let Some(theirs) = theirs else { + found.push(Drift { + id: HOST_SETTING_DRIFT, + key: format!("host.{key}"), + // The same two-token shape, with could-not-look on the host's + // side spelled as a TOKEN rather than as a value it never sent. + tokens: vec![format!("-{mine}"), String::from("+unreported")], + }); return; }; if mine != theirs { @@ -589,6 +610,34 @@ mod tests { assert!(ci(&["final"], Some(&["squash"])).validate().is_ok()); } + /// AN UNREPORTED KEY IS NOT AGREEMENT, and it read as agreement. + /// + /// The live shape is `security_and_analysis`: it is absent whenever the + /// credential lacks the scope to see it, so the strongest control in the + /// table was the one whose could-not-look passed silently. + #[test] + fn a_key_the_tree_claims_and_the_host_does_not_report_is_drift() { + let claimed = Host { + delete_branch_on_merge: Some(true), + web_commit_signoff_required: None, + secret_scanning_push_protection: Some(true), + }; + let partial = Host { + delete_branch_on_merge: Some(true), + web_commit_signoff_required: None, + secret_scanning_push_protection: None, + }; + let found = host_drift(&claimed, &partial); + assert_eq!(found.len(), 1, "the unreported claim is the one finding"); + assert_eq!(found[0].key, "host.secret_scanning_push_protection"); + assert_eq!(found[0].rendered(), "-true,+unreported"); + + // ANTI-VACUITY, both halves. A key the tree does NOT claim stays skipped + // — this polices what the tree says — and a key both sides report and + // agree on is still clean. + assert!(host_drift(&partial, &partial).is_empty()); + } + #[test] fn every_declared_method_token_validates() { // The vocabulary is a census, so a token added to `MERGE_METHODS` diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index e9f15043c..633447a7e 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -483,6 +483,14 @@ pub enum LandCommand { Replay { /// The remote reference to replay onto, e.g. `refs/heads/main`. reference: String, + /// Paths whose conflict is resolved in the WORKTREE (CLOUD-1586). + /// + /// **On this verb and never on `lap`.** A lap runs unattended, so a + /// resolution it could apply would be one nobody looked at — the + /// auto-resolution `gitwrite`'s header refuses. Naming a path here is a + /// person saying *I merged this by hand*, which is the loop's one human + /// stop being taken rather than skipped. + resolve: Vec, }, /// Ask whether this head is green and whether its base still holds, and act /// on whichever answers first. @@ -504,6 +512,34 @@ pub enum LandCommand { /// A flag would put a second spelling of one name on the surface, and the /// two would drift. Verify, + /// Ask the fast-forward bot to land this head, and read the answer keyed to + /// that request. + /// + /// NO POSITIONAL, for `verify`'s reason one step further out: the pull + /// request is a fact about this branch that the forge already holds, and the + /// workflow whose runs carry the verdict is the CONSUMER's — so both are + /// resolved rather than typed. A number on argv would let a lap ask one pull + /// request to land while every other step is looking at another. + FastForward, + /// Drive the whole lap — replay, verify, push, wait, fast-forward — and lap + /// again on every refusal that a rebase would clear. + /// + /// **THE LOOP IS A SUB-VERB RATHER THAN THE BARE NOUN**, on the reason the + /// enum's own header gives: a bare `batten land` would have to be widened + /// into this shape later, and that is a surface break for a change that adds + /// a capability. It is also the honest spelling — the five steps above are + /// reachable individually on purpose, and the loop is one more thing you can + /// ask for rather than the only thing. + /// + /// **A CALLER WHO HAND-STEPS THIS BURNS LAPS EVERY GATE PASSES.** The loop + /// re-verifies and re-waits per lap because a rebase mints a new SHA and the + /// receipts keyed to the old one are gone; stepping it yourself pays that + /// cost without the rebase that earns it. Measured on the predecessor, trunk + /// advanced 27 commits during one hand-run `verify`. + Lap { + /// The remote reference this lap lands onto, e.g. `refs/heads/main`. + reference: String, + }, } /// Subcommands of `mutate`. @@ -944,6 +980,35 @@ pub enum LeaseCommand { /// The branch asking. branch: String, }, + /// Does this head carry the landing mechanism trunk has (CLOUD-1148 §2)? + /// + /// The staleness half of the CI-side precondition, and a READ: it asks the + /// forge two questions and answers, writing nothing. + Carries { + /// The head being judged. On a `pull_request` event this must be + /// `github.event.pull_request.head.sha` and NEVER `GITHUB_SHA` — that is + /// the merge commit, which carries trunk's landing mechanism whenever the + /// head did not touch it, so every stale head would read as current. + head: String, + }, + /// The runner's step-0 guard: may this branch spend a matrix right now + /// (CLOUD-420, CLOUD-1148)? + /// + /// A WRITE, and the only reason it is: on a stop it CANCELS the run it is + /// standing in. Composing `carries` and `authorises` rather than letting a + /// workflow do it is what keeps the cancel-and-wait one authority instead of + /// sixteen copies of the subtlest failure mode in the loop. + Guard { + /// The head being judged. `github.event.pull_request.head.sha`, NEVER + /// `GITHUB_SHA` — on a `pull_request` event that is the merge commit, + /// which carries trunk's landing mechanism whenever the head did not + /// touch it, so every stale head would read as current. + head: String, + /// The branch asking for the lease. + branch: String, + /// The run to cancel on a stop. + run: String, + }, /// Is the lease ref free, or a live and well-formed hold? Check, /// Who holds it, for how long, and who is admitted behind them. @@ -1309,6 +1374,17 @@ pub enum ReceiptCommand { /// Emit the verdict as byte-stable JSON instead of a pointer line. json: bool, }, + /// Is HEAD verified — every declared check's receipt valid against this + /// exact commit and the trunk it was taken against? + /// + /// **The composition is the point, and it is why this is a verb rather than + /// two calls.** A caller asking `status` twice has to remember both names + /// and to AND the answers, and the failure that shape produces is silent: + /// one call, one green, and a head reported verified on half the evidence. + /// The predecessor existed because that had happened — a `verify` piped into + /// `tail` exits with the pipe's status, so a branch `linear-check` had + /// rejected reported success and the zero was acted on. + Verified, } /// Subcommands of `config`. @@ -1761,12 +1837,20 @@ fn land_of(matches: &ArgMatches) -> Option { match matches.subcommand()? { ("replay", matches) => Some(LandCommand::Replay { reference: reference_of(matches), + resolve: matches + .get_many::("resolve") + .map(|found| found.cloned().collect()) + .unwrap_or_default(), }), ("wait", matches) => Some(LandCommand::Wait { reference: reference_of(matches), }), ("push", _) => Some(LandCommand::Push), ("verify", _) => Some(LandCommand::Verify), + ("fast-forward", _) => Some(LandCommand::FastForward), + ("lap", matches) => Some(LandCommand::Lap { + reference: reference_of(matches), + }), _ => None, } } @@ -1788,6 +1872,32 @@ fn lease_of(matches: &ArgMatches) -> Option { ("authorises", matches) => Some(LeaseCommand::Authorises { branch: branch_of(matches), }), + ("carries", matches) => Some(LeaseCommand::Carries { + // EMPTY RATHER THAN ABSENT, for `authorises`' reason one arm up: the + // verb must be able to say it cannot answer, and a missing head is + // exactly that rather than a usage error — this gate fails open. + head: matches + .get_one::("head") + .cloned() + .unwrap_or_default(), + }), + ("guard", matches) => Some(LeaseCommand::Guard { + // EMPTY RATHER THAN ABSENT on all three, for `authorises`' reason: + // this verb must be able to say it could not look, and a missing + // operand is exactly that rather than a usage error. It fails open. + head: matches + .get_one::("head") + .cloned() + .unwrap_or_default(), + branch: matches + .get_one::("branch") + .cloned() + .unwrap_or_default(), + run: matches + .get_one::("run") + .cloned() + .unwrap_or_default(), + }), ("check", _) => Some(LeaseCommand::Check), ("status", matches) => Some(LeaseCommand::Status { json: matches.get_flag("json"), @@ -1882,19 +1992,25 @@ fn generate_of(matches: &ArgMatches) -> Option { fn receipt_of(matches: &ArgMatches) -> Option { let (name, matches) = matches.subcommand()?; - let check = matches.get_one::("check")?.clone(); + // THE CHECK IS READ PER ARM, not once above the match. `verified` names no + // check — it asks about the declared SET — so hoisting the positional would + // make this whole function answer `None` for it, and a sub-verb that parses + // to nothing is a verb that silently does not exist. match name { - "record" => Some(ReceiptCommand::Record { check }), + "record" => Some(ReceiptCommand::Record { + check: matches.get_one::("check")?.clone(), + }), // `unwrap_or_default` rather than `?`: the flag is declared with a // default, so an absent value is the ordinary case, not a parse failure. "status" => Some(ReceiptCommand::Status { - check, + check: matches.get_one::("check")?.clone(), key: matches .get_one::("key") .copied() .unwrap_or_default(), json: flag(matches, "json"), }), + "verified" => Some(ReceiptCommand::Verified), _ => None, } } diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 3865c165d..8583d3036 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -190,6 +190,22 @@ pub struct Config { /// could-not-look rather than as a default. #[serde(default, skip_serializing_if = "Option::is_none")] pub ready: Option, + /// The landing mechanism's own paths, for the CI-side staleness read + /// (CLOUD-1148 §2). + /// + /// Absent is could-not-look and exempts everything, which matches the + /// precondition's whole posture: it fails open at every unknown, because a + /// reading it cannot take would stop every job in the fleet where waving one + /// matrix through costs one matrix. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lease: Option, + /// Which receipts a head must carry to be called verified (CLOUD-1338). + /// + /// Absent REFUSES rather than exempting, which is the opposite direction to + /// `[lease]` above and deliberately so: this is a gate about the tree in + /// hand, where that one is an economy about somebody else's runner. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receipt: Option, /// Accepted invocation-latency regressions (CLOUD-1163 unit 10). Absent /// means this file accepts none, which is the safe direction — an absent /// table cannot exempt a path. @@ -353,6 +369,32 @@ pub struct Config { skip_serializing_if = "Vec::is_empty" )] pub exec_patterns: Vec, + /// Output predicates that classify a landing gate's REFUSAL as the + /// environment's rather than this tree's (CLOUD-861). + /// + /// **A separate table from [`Config::exec_patterns`], because the two answer + /// opposite questions from the same shape.** That one asks *is this green run + /// lying* and promotes a `0`; this one asks *a run already failed, and what + /// KIND of failure was it* and promotes nothing. One table serving both would + /// have to decide per reader whether a hit means promote-this-success or + /// explain-this-failure, and a row written for one reading would silently + /// change the other's verdict. + /// + /// Consumer-specific for [`Config::exec_patterns`]'s reason and then some: + /// the literal is a toolchain's wording, and the `reason` is the remedy — + /// *which* reclaim task to run is this repository's vocabulary, so putting + /// either in the crate is non-negotiable rule 1's plainest violation. + /// `crates/batten/tests/it/document_facts.rs` is the gate that would catch it. + /// + /// The measured case: `target-prune` passed a lap with 6242MB against its + /// 4096MB floor, the link step then consumed all of it, and the stop said + /// *"Reproduce and fix locally"* over a tree with nothing wrong in it. + #[serde( + default, + rename = "verify_environment_pattern", + skip_serializing_if = "Vec::is_empty" + )] + pub verify_environment_patterns: Vec, /// How `batten exec` owns what it dispatched (CLOUD-427). Absent means the /// defaults, and the default is today's behaviour: Batten makes no process /// group. Authority-only by omission from [`OverrideConfig`] — an uncommitted @@ -734,6 +776,101 @@ impl Perf { } } +/// The landing lease's own configuration (CLOUD-1148 §2). +#[derive( + Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema, +)] +#[serde(deny_unknown_fields)] +pub struct Lease { + /// The paths that CONSTITUTE the landing mechanism, so a head can be asked + /// whether it carries what trunk has. + /// + /// # A PATH SET RATHER THAN A GREP STRING, and that is the whole point + /// + /// The predecessor asked this by grepping the head's `mise-tasks/land.sh` + /// for `land-lock acquire`. Both halves of that die with the retirement: the + /// file is deleted, so the read fails, so the script takes its own fail-open + /// path and reports "not judging this head's age" — and every stale head + /// passes, silently, which is worse than a wrong answer. + /// + /// A declared path set survives, because the thing that changes when the + /// mechanism moves is WHICH PATHS, and that is config a retirement edits + /// rather than a literal a retirement invalidates. + /// + /// Empty is could-not-look: nothing to compare means no verdict, never a + /// clean one. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub landing_paths: Vec, + + /// Branch-name prefixes that land WITHOUT taking the lease, so the runner-side + /// precondition must not judge them. + /// + /// # THE POPULATION IS THE LANDER'S, NOT THE AUTHOR'S + /// + /// These are branches some other workflow fast-forwards on a `workflow_run` + /// completion, so no agent ever holds the lease on their behalf and judging + /// them would refuse the run the gate exists to let through. The predecessor + /// spelled the same set as a `case` in shell, and its comment carries the + /// economics: a cancelled run is `completed`, so those landers DO fire, find + /// the checks not green, and stop; nothing retries. Cancelling here would not + /// save a matrix, it would DEFER one and add a stall. + /// + /// **Not the bot-author set** (`[bot_lane] bots`), and the two must not be + /// collapsed: that keys on a forge LOGIN and this keys on a branch NAME, + /// because what decides the question is which workflow lands the branch. A + /// human who names a branch with one of these prefixes gets the same + /// treatment, correctly — the lander fires on the name. + /// + /// **Prefixes on the branch, never a substring anywhere in the ref**, which + /// the predecessor's suite pinned as its own case: a `case` arm matching + /// mid-ref would exempt a branch that merely mentions one. + /// + /// Empty means every branch is judged, which is the gate's default and the + /// reason this is a short named list rather than a pattern. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fast_forward_branches: Vec, +} + +/// The `[receipt]` table: which receipts a head must carry to be called verified. +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Receipt { + /// The checks `receipt verified` requires a valid receipt for. + /// + /// # NON-NEGOTIABLE RULE 1, AND THE ARRAY THIS REPLACES + /// + /// `receipt.rs` carried `const VERIFIED_BY: [&str; 2] = ["verify", + /// "linear-check"]` — two of THIS consumer's task names, compiled into the + /// core, which is the rule's plainest shape. A different adopter's gates are + /// not called those things, and nothing in the engine could tell them so. + /// + /// # UNDECLARED FALLS BACK, and this doc said it refused + /// + /// The hazard is real and unchanged: an empty set would make + /// `receipt verified` pass over every head, since *nothing is unverified* + /// when nothing is required — a gate that answers clean because it was never + /// told what to ask. What closes it is a fallback rather than a refusal. + /// [`crate::receipt::verified_by`] resolves an undeclared or empty table to + /// [`crate::receipt::VERIFIED_BY`], and its own header records why the first + /// draft's usage error could not stand: it refused over a fixture repository + /// with no `batten.toml` and no interest in receipts, and that suite's + /// subject survives, so the only way to land the refusal was to edit around + /// a gate that was right. + /// + /// The fallback is strictly more general than the `const` it replaced and + /// costs an adopter nothing — our names over their receipts resolve to + /// `Missing`, so `verified` refuses loudly on their first run rather than + /// passing quietly. A wrong answer that announces itself is the acceptable + /// failure; a silent one is not. + /// + /// **Stated here because a field's doc is where a reader looks for what an + /// absent key does** (review of #848), and this one asserted a refusal the + /// code does not make — with `trust.rs`'s `VerifiedCheckRemoved` repeating + /// it, which is how one false premise became two. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub verified_by: Vec, +} + /// The `[ready]` table: the refinement gate's consumer-set thresholds. /// /// # Why a value and not a `[[pattern]]` row (CLOUD-472) @@ -1463,6 +1600,50 @@ fn under(native: crate::verdict::Native, result: Result) -> Result { /// /// Returns a [`UsageError`] (→ exit `1`) under the class of whichever table /// refused; see [`under`]. +/// The remedies `[[redirect]]` and `[[verb]]` carry, resolved against the command +/// surface and the rule table (CLOUD-1189). +/// +/// Here rather than in `redirect::validate` because it is the one clause needing +/// a THIRD table — the `[[rule]]` ids — and a validator reaching past its own +/// argument for them is how one table's checker quietly becomes the config's. +/// +/// Both remedy tables in ONE call, because they answer one question and two +/// spellings of "does this remedy name a real command" is the drift a shared +/// question does not survive — `verdict-routes-resolve`'s note about the two +/// sources of "what tasks exist" is the same reasoning one table over. +/// +/// Split out of [`validate_tables`] rather than inlined: that function crossed +/// the 100-line lint when this branch and `main` each landed a table into it, +/// and this is the block with a rationale of its own rather than one more +/// `under(...)` call in the list. +fn validate_remedy_tables(config: &Config) -> Result<()> { + let rule_ids: Vec = config.rules.iter().map(|rule| rule.id.clone()).collect(); + let remedies = config + .redirects + .iter() + .flat_map(|entry| { + std::iter::once(( + format!("redirect[{}].mutation", entry.glob), + entry.mutation.as_str(), + )) + .chain( + entry + .read + .as_deref() + .map(|read| (format!("redirect[{}].read", entry.glob), read)), + ) + }) + .chain(config.verbs.iter().filter_map(|verb| { + verb.redirect + .as_deref() + .map(|text| (format!("verb[{}].redirect", verb.verb), text)) + })); + under( + Native::RemedyUnresolved, + crate::redirect::validate_remedies(remedies, &rule_ids), + ) +} + fn validate_tables(config: &Config, text: &str, source: &str) -> Result<()> { // The verb table is validated here, at load, because nothing else validates // it anywhere: `verbs::validate` had no caller outside its own tests, so a @@ -1518,43 +1699,7 @@ fn validate_tables(config: &Config, text: &str, source: &str) -> Result<()> { Native::RedirectTableRefused, crate::redirect::validate(&config.redirects), )?; - // The remedies those two tables carry, resolved against the command surface - // and the rule table (CLOUD-1189). Here rather than in `redirect::validate` - // because it is the one clause needing a THIRD table — the `[[rule]]` ids — - // and a validator reaching past its own argument for them is how one table's - // checker quietly becomes the config's. - // - // Both remedy tables in one call, because they answer one question and two - // spellings of "does this remedy name a real command" is the drift a shared - // question does not survive — `verdict-routes-resolve`'s note about the two - // sources of "what tasks exist" is the same reasoning one table over. - { - let rule_ids: Vec = config.rules.iter().map(|rule| rule.id.clone()).collect(); - let remedies = config - .redirects - .iter() - .flat_map(|entry| { - std::iter::once(( - format!("redirect[{}].mutation", entry.glob), - entry.mutation.as_str(), - )) - .chain( - entry - .read - .as_deref() - .map(|read| (format!("redirect[{}].read", entry.glob), read)), - ) - }) - .chain(config.verbs.iter().filter_map(|verb| { - verb.redirect - .as_deref() - .map(|text| (format!("verb[{}].redirect", verb.verb), text)) - })); - under( - Native::RemedyUnresolved, - crate::redirect::validate_remedies(remedies, &rule_ids), - )?; - } + validate_remedy_tables(config)?; // And the MCP table, at load for the identical reason (CLOUD-1260). Every // clause is a property of the TABLE — a duplicated id, a path that would // leave its root, a reduction over no fields at all — so it is knowable @@ -1563,6 +1708,16 @@ fn validate_tables(config: &Config, text: &str, source: &str) -> Result<()> { if let Some(mcp) = &config.mcp { crate::mcp::validate(mcp)?; } + // And `[receipt] verified_by`, which names checks that must be WRITABLE + // (review of #848). `receipt record` and `receipt status` already refuse a + // name that is not an identifier, and this table was held to no such rule — + // so a row naming one loaded clean, no receipt for it could ever be written, + // and `receipt verified` reported it missing forever, pointing at the + // absence rather than at the name that guaranteed it. The inert-typo class + // every table above is refused at load for. + if let Some(receipt) = &config.receipt { + crate::receipt::validate_verified_by(&receipt.verified_by)?; + } // And the marker table, for the identical reason in the identical shape // (CLOUD-253). Both tables arrived in one commit; CLOUD-242 wired one of // them up and nobody checked the sibling, so an empty `token` — which @@ -1595,6 +1750,16 @@ fn validate_tables(config: &Config, text: &str, source: &str) -> Result<()> { Native::OutputTableRefused, crate::outputs::validate(&config.exec_patterns), )?; + // The same validator over the second pattern table, and deliberately the + // same class: both are `OutputPattern`, so an empty id, an empty literal, an + // empty reason or a duplicate id is malformed in exactly the same way + // whichever question the table answers. A malformed row here would classify + // nothing and read as a consumer who declared no classifier at all — the + // inert-typo shape this call site exists to refuse. + under( + Native::VerifyEnvironmentTableRefused, + crate::outputs::validate_environment(&config.verify_environment_patterns), + )?; // And the waiver table, where the stakes are inverted from every other row // here: a malformed rule fails to gate, but a malformed *waiver* is a hatch // whose expiry nobody could read. Refusing at load is what makes "every @@ -2990,6 +3155,11 @@ impl Config { deferrals: Vec::new(), host: None, min_batten_version: None, + // Declaring nothing declares no landing path set, which the reader + // takes as could-not-look — the same direction every other absent + // table here takes. + lease: None, + receipt: None, strictness: None, fail_on_warning: None, rules: Vec::new(), @@ -3023,6 +3193,11 @@ impl Config { mcp: None, capture: None, exec_patterns: Vec::new(), + // No declared classifier means every gate refusal reads as being + // about the tree, which is the advice that was always given and is + // right in the common case — the safe direction for an authority + // that could not be read. + verify_environment_patterns: Vec::new(), waivers: Vec::new(), // An authority that declares no budget grants no exemption from one // either — there is simply no threshold, which is what `None` says. @@ -3456,6 +3631,17 @@ mod tests { "crate::outputs::validate(", Native::OutputTableRefused, ), + // The second `OutputPattern` table, sharing the validator and the class + // with the row above. Listed separately because the census is per FIELD: + // two fields validated by one call site are two rows here, and collapsing + // them would let one of the two go unwired unnoticed — which is CLOUD-242 + // and CLOUD-253's own shape, where both tables shipped together and the + // fix wired up only one. + ( + "verify_environment_patterns", + "crate::outputs::validate_environment(", + Native::VerifyEnvironmentTableRefused, + ), ( "provisions", "crate::provision::validate(", @@ -3532,17 +3718,21 @@ mod tests { // about the loader. Reading only the first would report every section // the split moved as unwrapped — the false positive that gets a gate // switched off. - let parse_body = ["fn validate_tables", "fn validate_sections"] - .iter() - .map(|name| { - let start = source - .find(name) - .unwrap_or_else(|| panic!("`{name}` is declared here")); - let rest = &source[start..]; - &rest[..rest.find("\n}").expect("the function closes")] - }) - .collect::>() - .join("\n"); + let parse_body = [ + "fn validate_tables", + "fn validate_sections", + "fn validate_remedy_tables", + ] + .iter() + .map(|name| { + let start = source + .find(name) + .unwrap_or_else(|| panic!("`{name}` is declared here")); + let rest = &source[start..]; + &rest[..rest.find("\n}").expect("the function closes")] + }) + .collect::>() + .join("\n"); let parse_body = parse_body.as_str(); let mut seen = Vec::new(); @@ -3618,17 +3808,21 @@ mod tests { // about the loader. Reading only the first would report every section // the split moved as unwrapped — the false positive that gets a gate // switched off. - let parse_body = ["fn validate_tables", "fn validate_sections"] - .iter() - .map(|name| { - let start = source - .find(name) - .unwrap_or_else(|| panic!("`{name}` is declared here")); - let rest = &source[start..]; - &rest[..rest.find("\n}").expect("the function closes")] - }) - .collect::>() - .join("\n"); + let parse_body = [ + "fn validate_tables", + "fn validate_sections", + "fn validate_remedy_tables", + ] + .iter() + .map(|name| { + let start = source + .find(name) + .unwrap_or_else(|| panic!("`{name}` is declared here")); + let rest = &source[start..]; + &rest[..rest.find("\n}").expect("the function closes")] + }) + .collect::>() + .join("\n"); let parse_body = parse_body.as_str(); let listed: Vec<(&str, &str, Native)> = VALIDATED_AT_LOAD diff --git a/crates/batten/src/deferral.rs b/crates/batten/src/deferral.rs index 700a48884..143a6862c 100644 --- a/crates/batten/src/deferral.rs +++ b/crates/batten/src/deferral.rs @@ -88,7 +88,13 @@ pub enum Fact { /// [`crate::UsageError`] naming the row and the key. pub fn validate(deferrals: &[Deferral]) -> crate::Result<()> { for deferral in deferrals { - if semver::Version::parse(&deferral.reaches).is_err() { + // ONE PARSER, AND THERE WERE TWO (review of #848). This called + // `semver::Version::parse` directly while [`satisfied`] fills a missing + // patch — so `reaches = "1.98"`, which is exactly the spelling + // `rust-version` uses and the one `satisfied` exists to accept, was + // refused at load. The two readings of one string could disagree in both + // directions; they are [`version`] now. + if version(&deferral.reaches).is_none() { return Err(crate::UsageError::raise(format!( "deferral {}: `reaches = \"{}\"` is not a version, so its condition could \ never be compared", @@ -105,6 +111,22 @@ pub fn validate(deferrals: &[Deferral]) -> crate::Result<()> { Ok(()) } +/// The ONE reading of a version string this module has. +/// +/// `rust-version = "1.98"` is a valid manifest value and not valid semver, so a +/// missing patch is filled rather than refused. Shared by [`validate`] and +/// [`satisfied`] because a load-time refusal and a runtime comparison over the +/// same string must not be able to disagree — the class +/// `.claude/rules/policy-modules.md` records for parsers, arriving here. +fn version(text: &str) -> Option { + let filled = if text.split('.').count() == 2 { + format!("{text}.0") + } else { + text.to_owned() + }; + semver::Version::parse(&filled).ok() +} + /// Whether `reaches` has been met by `pin`. /// /// Both are parsed as semver; either failing to parse is **not satisfied**, @@ -112,17 +134,7 @@ pub fn validate(deferrals: &[Deferral]) -> crate::Result<()> { /// deferral is reported only when the tree can show the condition holds. #[must_use] pub fn satisfied(reaches: &str, pin: &str) -> bool { - let parse = |text: &str| { - // `rust-version = "1.98"` is a valid manifest value and not valid - // semver, so a missing patch is filled rather than refused. - let filled = if text.split('.').count() == 2 { - format!("{text}.0") - } else { - text.to_owned() - }; - semver::Version::parse(&filled).ok() - }; - match (parse(reaches), parse(pin)) { + match (version(reaches), version(pin)) { (Some(reaches), Some(pin)) => pin >= reaches, _ => false, } @@ -148,4 +160,25 @@ mod tests { assert!(!satisfied("nightly", "1.98")); assert!(!satisfied("1.88.0", "stable")); } + + /// ONE PARSER, AND THE TWO USED TO DISAGREE. `validate` called + /// `semver::Version::parse` outright while `satisfied` fills a missing patch, + /// so a `reaches` written the way `rust-version` is written — the spelling + /// `satisfied` exists to accept — was refused at load and never reached the + /// comparison at all. + #[test] + fn a_two_component_reaches_validates_exactly_where_satisfied_accepts_it() { + let row = |reaches: &str| Deferral { + issue: String::from("CLOUD-647"), + fact: Fact::RustVersion, + reaches: reaches.to_owned(), + reason: String::from("upstream has not raised its pin"), + }; + assert!(satisfied("1.98", "1.98")); + assert!(validate(std::slice::from_ref(&row("1.98"))).is_ok()); + // And the refusal still reaches what `satisfied` cannot compare, which is + // what says the shared parser did not become a rubber stamp. + assert!(!satisfied("nightly", "1.98")); + assert!(validate(std::slice::from_ref(&row("nightly"))).is_err()); + } } diff --git a/crates/batten/src/exec.rs b/crates/batten/src/exec.rs index f3c52f25d..07e9d9511 100644 --- a/crates/batten/src/exec.rs +++ b/crates/batten/src/exec.rs @@ -510,6 +510,169 @@ fn signal_group(pgid: rustix::process::Pid, signal: rustix::process::Signal) { let _ = rustix::process::kill_process_group(pgid, signal); } +/// Terminate the process group led by `pgid`, reporting whether it was there. +/// +/// **The reaper's one primitive, and it belongs here rather than beside the +/// registry that supplies the number** (CLOUD-1148). `task` owns a record store +/// and its declared edges are `error` and `exit`; a `kill` there would make it a +/// second authority over process lifetime. This module already owns the group +/// protocol — [`group_is_empty`] and [`signal_group`] are its own — so the reaper +/// borrows the primitive and the caller composes the two. +/// +/// **A GROUP TERM IS A REQUEST, NOT A FACT** — so this verifies and escalates. +/// +/// The first version of this function sent TERM and returned, on the reasoning +/// that a `SIGKILL` would deny a cargo or git leaf the chance to remove its +/// lockfile. That reasoning is plausible and it is WRONG, and the correction is +/// recorded rather than quietly applied because it is the second time on this +/// branch that a first-principles argument was made where a measured decision +/// already existed. `land.sh`'s `reap_residue` is the measured one (CLOUD-434): +/// a group TERM *"demonstrably missed grandchildren twice in one loaded gate +/// run, and the survivors held bats' output fd and wedged the whole gate."* +/// A survivor is not a leaf tidying up; it is the leak, and the escalation is +/// what closes it. +/// +/// **Verified with `kill -0 -- -pgid` rather than with a `wait`**, and the +/// difference matters here in a way it does not for [`Forwarding`]: that path is +/// waiting on its OWN child and can observe it exit, while this group's leader is +/// already dead and this process was never its parent, so there is nothing to +/// reap and no status to collect. Group emptiness is the only observation +/// available, which is exactly what the shell used. +/// +/// **AND NO GRACE PERIOD, BECAUSE THERE IS NOTHING TO BE GRACIOUS TO.** The +/// supervised teardown above polls to a [`GROUP_GRACE`] deadline before +/// escalating, and copying that here was the second wrong turn on this function: +/// it bought the delay with a blocking `std::thread::sleep` behind an +/// `#[expect]`, and a blocking sleep has no sanctioned site in this crate. +/// +/// The grace exists there to avoid escalating a group that is *mid-exit* — a +/// child this process just signalled and is actively waiting on. Neither premise +/// holds here. The group's leader is already dead, this process was never its +/// parent, and the members have by definition been running unattended since +/// their parent died. A survivor of that is not tidying up. +/// +/// So the verification the shell insisted on is kept and the timer is dropped: +/// [`escalate_group`] is the second half, and the CALLER runs it over the whole +/// set after signalling all of them. That ordering is what gives a group its +/// chance to act on the TERM — the walk itself — without a delay standing in for +/// an exit condition (CLOUD-1177). +/// +/// Returns `false` for a group that is already empty, an unparseable pgid, or a +/// platform with no process groups, so a caller counting reaps never counts a +/// no-op. That count is the whole of what a caller may report (non-negotiable +/// rule 4): a number and a pgid are pointers, and the command line of whatever +/// was reaped is not. +#[cfg(unix)] +pub(crate) fn terminate_group(pgid: &str) -> bool { + let Some(group) = resolve_group(pgid) else { + return false; + }; + if group_is_empty(group) { + return false; + } + signal_group(group, rustix::process::Signal::TERM); + true +} + +/// The second half: `SIGKILL` a group that did not act on its `SIGTERM`. +/// +/// **A GROUP TERM IS A REQUEST, NOT A FACT** (CLOUD-434, measured in +/// `land.sh`'s `reap_residue`): a group TERM *"demonstrably missed grandchildren +/// twice in one loaded gate run, and the survivors held bats' output fd and +/// wedged the whole gate."* `kill -0 -- -pgid` is the observation that tells a +/// request from a fact, and escalation is what closes the gap. +/// +/// Separate from [`terminate_group`] so a caller signals the whole set before +/// re-observing any of it. Reporting `true` means a group was still there AFTER +/// being asked to leave, which is a different fact from the reap itself and is +/// worth a caller counting separately. +#[cfg(unix)] +pub(crate) fn escalate_group(pgid: &str) -> bool { + let Some(group) = resolve_group(pgid) else { + return false; + }; + if group_is_empty(group) { + return false; + } + signal_group(group, rustix::process::Signal::KILL); + true +} + +/// Cancel the group THIS process is currently supervising, if it owns one. +/// +/// # Why a caller cannot simply pass a pgid +/// +/// The child is behind a blocking [`classify_in_env`], so the thread that wants +/// it stopped is not the thread that spawned it and never saw its pid. What both +/// threads DO share is this process's own pid, and [`GroupRecord::write`] keys the +/// note by exactly that — *"the reader's question is 'which supervisor died', and +/// a second `exec` in the same checkout must not overwrite the first one's note."* +/// The same key answers a second question it was not written for: which group am +/// *I* supervising right now. +/// +/// # The pair, not just the TERM +/// +/// [`terminate_group`] then [`escalate_group`], because CLOUD-434 measured that a +/// group TERM *"demonstrably missed grandchildren twice in one loaded gate run, +/// and the survivors held bats' output fd and wedged the whole gate."* A gate is +/// precisely that shape — `mise` running `hk` running `cargo` — so the arm that +/// cancels one must not stop at asking. +/// +/// **No grace period between them, and that is deliberate.** The caller is a +/// watcher that has already established the gate's verdict is worthless, so there +/// is nothing to wait for a clean exit to produce. `escalate_group` re-observes +/// with `kill -0` before signalling, so a group that did leave on the TERM is not +/// signalled twice. +/// +/// Returns whether anything was still there to signal, which is a pointer-only +/// answer (non-negotiable rule 4): a boolean, never what was running. +#[must_use] +pub(crate) fn cancel_owned_group(repo_root: &Path) -> bool { + let Ok(dir) = crate::state::repo_state_dir(repo_root) else { + return false; + }; + let path = dir + .join("exec") + .join(format!("group.{}", std::process::id())); + // ABSENT IS THE COMMON CASE AND IT IS NOT A FAILURE: a gate run without the + // grouping opt-in records no note, so there is no group to cancel and the + // watcher simply loses whatever it was racing. + let Ok(body) = std::fs::read_to_string(&path) else { + return false; + }; + let pgid = body.trim(); + let asked = terminate_group(pgid); + let survived = escalate_group(pgid); + asked || survived +} + +/// A recorded `pgid` as a group this process may signal. +/// +/// `0` is refused explicitly rather than by accident: `kill(0, …)` addresses the +/// CALLER's own group, so reaping a record that carries it would signal the +/// process doing the reaping. +#[cfg(unix)] +fn resolve_group(pgid: &str) -> Option { + let raw = pgid.parse::().ok()?; + if raw <= 0 { + return None; + } + rustix::process::Pid::from_raw(raw) +} + +/// Windows has no process groups, so there is nothing to reap and nothing to +/// report — the same stance every other `#[cfg(unix)]` half of this module takes. +#[cfg(not(unix))] +pub(crate) fn terminate_group(_pgid: &str) -> bool { + false +} + +/// As [`terminate_group`]: no process groups, so nothing to escalate. +#[cfg(not(unix))] +pub(crate) fn escalate_group(_pgid: &str) -> bool { + false +} + /// Drain `pipe` into `sink`, accumulating everything that passed through. /// /// The tee. Chunked rather than read-to-end-then-write so a long-running child's @@ -1359,11 +1522,34 @@ pub fn run_with( /// /// As [`run`]. pub fn run_in(repo_root: &Path, command: &[String]) -> Result { - run_in_with( + run_in_env(repo_root, command, &[]) +} + +/// [`run_in`], with variables published into the child's environment. +/// +/// **A PARAMETER RATHER THAN `std::env::set_var`, and the workspace forbids the +/// alternative outright.** Setting a variable on this process would be `unsafe`, +/// would outlive the call, and would reach every later child whether or not the +/// fact is still true — a bet unwound two laps ago would still be published. +/// +/// It is also not an [`ExecConfig`] field: that struct is deserialized from +/// committed configuration, and these pairs are resolved per call from the state +/// of the run. A consumer must not be able to declare one. +/// +/// # Errors +/// +/// As [`run`]. +pub fn run_in_env( + repo_root: &Path, + command: &[String], + published: &[(String, String)], +) -> Result { + run_in_with_env( repo_root, command, &[], &ExecConfig::DEFAULT, + published, &mut std::io::sink(), ) } @@ -1379,12 +1565,146 @@ pub fn run_in_with( patterns: &[OutputPattern], settings: &ExecConfig, report: &mut dyn Write, +) -> Result { + run_in_with_env(repo_root, command, patterns, settings, &[], report) +} + +/// [`run_in_with`], with variables published into the child's environment. +/// +/// # Errors +/// +/// As [`run`]. +pub fn run_in_with_env( + repo_root: &Path, + command: &[String], + patterns: &[OutputPattern], + settings: &ExecConfig, + published: &[(String, String)], + report: &mut dyn Write, ) -> Result { let bundle = split_bundle(command)?; - let outcomes = dispatch(repo_root, &bundle, settings, next_run())?; + let outcomes = dispatch(repo_root, &bundle, settings, published, next_run())?; report_bundle(&bundle, &outcomes, patterns, settings, report) } +/// Run a command and report WHICH declared patterns its output matched, +/// whatever it exited. +/// +/// # This asks a different question from [`run_in_with_env`], and the difference +/// is the exit code +/// +/// [`report_bundle`] scans for patterns only on a `0`, and says so in as many +/// words: *"Only `0` is promotable, and only a declared pattern promotes it."* +/// That is right for CLOUD-117, whose question is **is this green run lying** — +/// a child that already failed needs no promotion, and re-deciding a failure +/// Batten did not diagnose would make the wrapper's verdict unreadable. +/// +/// The question here is the other one: **a run already failed, and what KIND of +/// failure was it.** CLOUD-861 is the measured case — a `verify` that died on a +/// full disk is not a defect in the tree, and reporting it as one sends an +/// author to reproduce a condition their branch did not create. Reusing the +/// promotion path could not answer it, because the promotion path returns before +/// it scans. +/// +/// So this is a sibling rather than a flag on that one: one function answering +/// both questions would have to decide, per caller, whether a hit means *promote +/// this success* or *explain this failure*, and those produce opposite verdicts +/// from identical inputs. +/// +/// # It returns hits, never bytes +/// +/// [`Hit`] is `stream:line id` and carries no matched text, so a caller learns +/// which class of failure it was without the output passing through its hands — +/// non-negotiable rule 4 held in the return TYPE rather than by each caller +/// remembering. A wrapped command's output is the likeliest place in this whole +/// engine for a secret to appear, which is what makes that load-bearing here. +/// +/// The exit code is returned rather than raised: a gate that ran and refused is +/// an ANSWER to its caller, and wrapping it in [`Passthrough`] would make the +/// caller unwrap an error to read a verdict it asked for. +/// +/// # Errors +/// +/// A malformed bundle, or a boundary that could not start the child. A child +/// that ran and failed is `Ok`, with its code. +pub(crate) fn classify_in_env( + repo_root: &Path, + command: &[String], + patterns: &[OutputPattern], + settings: &ExecConfig, + published: &[(String, String)], +) -> Result<(i32, Vec)> { + let bundle = split_bundle(command)?; + let outcomes = dispatch(repo_root, &bundle, settings, published, next_run())?; + reraise(&outcomes)?; + let code = bundle_code(&outcomes); + let mut found: Vec = Vec::new(); + for outcome in &outcomes { + found.extend(outputs::hits(patterns, Stream::Stdout, &outcome.out_bytes)); + found.extend(outputs::hits(patterns, Stream::Stderr, &outcome.err_bytes)); + } + Ok((code, found)) +} + +/// Die of the signal a child was killed by, where one was. +/// +/// **AN INTERRUPT IS NOT A VERDICT ABOUT THE TREE** (review of #848). Only +/// `report_bundle` acted on `Outcome::received`, and [`classify_in_env`] goes +/// `dispatch` → `bundle_code` → return, so it bypassed the re-raise entirely. +/// Measured shape: Ctrl-C while `mise run verify` is `land`'s child returns +/// `130`, `land::verify` falls past its `3 | 126 | 127` arm into the pattern +/// scan, and the lap records `verify refused ` in the landing log and +/// prints "reproduce and fix locally" — for an interrupt the tree had nothing to +/// do with. Batten also exited normally, so a shell `for` loop around +/// `batten land` did not abort, which is the `WIFSIGNALED` property CLOUD-746 S2 +/// exists for. +/// +/// Extracted rather than copied so the two entry points cannot drift about when +/// a signal is honoured; `report_bundle` states the ordering argument at its own +/// site, and the same one holds here — there is no record to seal on this path, +/// so nothing is lost by raising as soon as the outcomes are in hand. +/// +/// **A PER-TARGET PAIR RATHER THAN AN INLINE `#[cfg(unix)]`**, because the whole +/// body is unix-only and the parameter would then be unused on Windows — +/// `cross-check` denies warnings on `x86_64-pc-windows-gnu` (CLOUD-397), so that +/// spelling does not type-check there. `report_bundle` gets away with the inline +/// form only because it reads `outcomes` elsewhere in the same function. +#[cfg(unix)] +fn reraise(outcomes: &[Outcome]) -> Result<()> { + if let Some(signal) = outcomes.iter().find_map(|outcome| outcome.received) { + // Restores the default disposition and raises on self, so this does not + // return. + signal_hook::low_level::emulate_default_handler(signal) + .context("re-raise the signal Batten was sent")?; + } + Ok(()) +} + +/// The other half of [`reraise`]: a target with no POSIX signal to re-raise. +/// +/// `Ok(())` rather than an error — the caller's next statement is the classify +/// path, and a platform that cannot be interrupted this way has nothing to say +/// about it. +#[cfg(not(unix))] +fn reraise(_outcomes: &[Outcome]) -> Result<()> { + Ok(()) +} + +/// A bundle's exit code: the FIRST non-zero in declaration order. +/// +/// Extracted rather than written twice (CLOUD-430). A bundle where command 2 of +/// 3 failed and command 3 succeeded must not report `0`; and reading the FIRST +/// failure rather than the last keeps the answer a property of the bundle rather +/// than of the scheduler, which `--jobs` would otherwise make non-deterministic. +/// Two spellings of that reduction is two authorities over one number. +fn bundle_code(outcomes: &[Outcome]) -> i32 { + outcomes + .iter() + .map(|outcome| outcome.exit) + .find(|exit| *exit != 0) + .unwrap_or(0) +} + /// Run a program at `root/` with `stdin` piped in, and read back its /// exit code and stdout. /// @@ -1408,10 +1728,6 @@ pub fn run_in_with( /// pipe and a child that died without a code are all "no answer", and no caller /// can act differently on which. A caller that needs clean-versus-unreadable /// apart must not infer it from here. -#[expect( - clippy::disallowed_types, - reason = "stays: this is the placed adapter's spawn, and the two callers that used to hold their own now hold none (CLOUD-1051)" -)] pub(crate) fn piped( root: &Path, program: &Path, @@ -1422,34 +1738,108 @@ pub(crate) fn piped( if !path.is_file() { return None; } - // THROUGH THE RESOLUTION LADDER, not `Command::new` directly, and Windows CI - // is what said so: a `#!/usr/bin/env bash` program has no extension and no - // executable image, so `CreateProcess` refuses it and the caller read the - // refusal as could-not-look — a recorder column that said `-` where it should - // have said `ready`. `spawn_resolving`'s third rung reads the shebang and - // runs the interpreter, which is the same ladder this module's own verb uses - // and the reason it exists. - // // `None` for the resolve root: the program is already absolute here, so a // relative name cannot arise and handing a directory would only be a guess at // one. - let mut child = crate::rules::spawn_resolving(None, path.to_str()?, |program, extra| { - Command::new(OsString::from(program)) + // `Drop`: both callers of this entry point parse the string it returns. + piped_through(root, None, path.to_str()?, args, stdin, Diagnostics::Drop) +} + +/// The one spawn both piped entry points share. +/// +/// **Extracted rather than duplicated, and the reason is the gate this branch +/// owes** (CLOUD-1338). [`piped_argv`] was written with its own `Command::new` +/// and its own `#[expect(clippy::disallowed_types)]`, which is the inventory +/// growing by one for a body byte-identical to this one — an annotation is meant +/// to record a spawn somebody decided on, not to be the cheap way past the lint. +/// `policy/spawn-widening.rego` refuses an added escape now, and this is what +/// that refusal asks for: one site, two callers. +/// +/// The two callers differ in exactly one thing and it is the argument they pass: +/// how the first word RESOLVES. See [`piped_argv`]'s header for why that +/// difference may not be folded away. +/// +/// THROUGH THE RESOLUTION LADDER, never `Command::new` directly, and Windows CI +/// is what said so: a `#!/usr/bin/env bash` program has no extension and no +/// executable image, so `CreateProcess` refuses it and the caller read the +/// refusal as could-not-look — a recorder column that said `-` where it should +/// have said `ready`. `spawn_resolving`'s third rung reads the shebang and runs +/// the interpreter. +#[expect( + clippy::disallowed_types, + reason = "stays: this is the placed adapter's ONE spawn, shared by both piped entry points so \ + the inventory does not grow per calling shape (CLOUD-1051, CLOUD-1338)" +)] +fn piped_through( + root: &Path, + resolve_root: Option<&Path>, + program: &str, + args: &[String], + stdin: &str, + diagnostics: Diagnostics, +) -> Option<(i32, String)> { + let mut child = crate::rules::spawn_resolving(resolve_root, program, |resolved, extra| { + Command::new(OsString::from(resolved)) .args(extra.iter().map(OsString::from)) .args(args) .current_dir(root) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()) + .stderr(diagnostics.redirection()) .spawn() }) .ok()?; - child.stdin.take()?.write_all(stdin.as_bytes()).ok()?; + // TAKEN AND DROPPED EVEN WHEN EMPTY, because a gate that reads stdin blocks + // until it closes. A caller with nothing to say still has to say nothing and + // hang up, which is what an owned handle going out of scope here does. + { + // **A GATE THAT DID NOT READ STDIN STILL ANSWERED** (review of #848). This + // was `.ok()?`, which turns an ordinary `EPIPE` into `None` — and `None` + // reaches `land::ready` as `Readied::Unrunnable`, a could-not-look, over a + // gate that ran and produced a real verdict. Rust ignores `SIGPIPE`, so a + // gate that ignores its stdin and exits before the write completes is not + // a crash; it is a program that had already decided. + // + // The early `?` also abandoned a live child without waiting, leaking a + // zombie per occurrence. Dropping the handle closes the pipe either way, + // and `wait_with_output` below is what actually decides. + // + // **ON A THREAD, because `Diagnostics::Keep` made a blocking write here a + // DEADLOCK** (review of #848). Nothing drains stdout or stderr until + // `wait_with_output` below, and this write completes first — so with + // stderr piped, a gate that emits more than one pipe buffer (~64 KiB) of + // diagnostics before consuming all of stdin blocks writing stderr while + // this blocks writing stdin, and the lap hangs with no timeout. With + // `Stdio::null()` that was unreachable: the child could only ever block on + // stdout, which the caller was not filling. `land::ready` feeds a pull + // request BODY on stdin to a gate that takes `Keep`, which is exactly the + // shape. + // + // Detached rather than joined: the handle closes when the thread's owned + // pipe drops, and `wait_with_output` is what decides. A caller must never + // wait on the writer, because a gate that ignores its stdin is the case + // above that legitimately never drains it. + if let Some(mut pipe) = child.stdin.take() { + let body = stdin.to_owned(); + drop(std::thread::spawn(move || { + let _ = pipe.write_all(body.as_bytes()); + })); + } + } let finished = child.wait_with_output().ok()?; - Some(( - finished.status.code()?, - String::from_utf8_lossy(&finished.stdout).into_owned(), - )) + let mut output = String::from_utf8_lossy(&finished.stdout).into_owned(); + if diagnostics == Diagnostics::Keep { + // STDOUT FIRST, because that is where a verdict is written and a caller + // parsing one must not skip a diagnostic to find it. + let reason = String::from_utf8_lossy(&finished.stderr); + if !reason.trim().is_empty() { + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str(reason.trim_end()); + } + } + Some((finished.status.code()?, output)) } /// Start `program` with `args` and **do not wait** (CLOUD-1480). @@ -1498,6 +1888,102 @@ pub(crate) fn detached(program: &Path, args: &[String], env: &[(&str, &str)]) { drop(builder.spawn()); } +/// Whether a spawn's stderr joins its stdout, and it is per CALL SITE. +/// +/// **A shared spawn may not decide this, which is what the first attempt got +/// wrong** (review of #848). Capturing everywhere fixed `land::ready`'s empty +/// `detail` and broke two callers that PARSE the string it returns: +/// `review::ready` runs `serde_json::from_str` over it, so a runner emitting any +/// diagnostic — a deprecation warning, a TLS notice — would fail to parse and +/// read as *never dispatched*, which is the one arm a predicate over +/// `input.tree.review` may refuse on; and `recorder::run_program`'s own doc +/// states the contract verbatim, *"stderr is discarded, deliberately … a +/// recorder that surfaced another gate's findings would be a second, unasked-for +/// channel for them"*, so a gate writing `path:line` to stderr would have written +/// it into a recorded column. +/// +/// So the question is the caller's: a gate whose REASON is the payload keeps it, +/// and a program whose stdout is a parsed value does not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Diagnostics { + /// Discard stderr. For a caller that parses the returned string. + Drop, + /// Fold stderr in after stdout. For a caller reporting a gate's own reason. + Keep, +} + +impl Diagnostics { + fn redirection(self) -> Stdio { + match self { + Self::Drop => Stdio::null(), + Self::Keep => Stdio::piped(), + } + } +} + +/// [`piped`] over an ARGV rather than a program path. +/// +/// # Why a sibling rather than a widened `piped` +/// +/// The two resolve differently and the difference is not cosmetic. [`piped`] +/// takes a program AT A PATH — `root.join(program)`, guarded by `is_file`, with +/// no resolve root because the result is already absolute. This takes a command +/// whose first word is a NAME the ladder resolves on `PATH`, which is what a task +/// runner's invocation is. Folding them would give one function whose first +/// argument means two things depending on whether it happens to exist as a file, +/// and the guard that makes `piped` safe is exactly wrong here: a task name is +/// not a file, so `is_file` would answer could-not-look for every one of them — +/// a gate that reads as unreachable rather than as refusing, which is the dead +/// class this engine exists to refuse. +/// +/// # Why it exists +/// +/// The landing lap's ready phase runs gates that take the pull request's BODY on +/// stdin, and nothing here could spell that shape: [`run_in`] has no stdin +/// channel and [`piped`] has no argv. The alternative was a fifth `Command::new` +/// in whichever module needed it, which is the second-spawn-in-an-unplaced-module +/// shape `piped`'s own header records being added to remove. +/// +/// **It never spells an argv.** Both the runner and the task names arrive from +/// the consumer's own configuration, because a task name inside `crates/batten` +/// is non-negotiable rule 1's plainest violation. +/// +/// `None` is could-not-look, collapsed for [`piped`]'s reason: unresolvable, a +/// broken pipe, and a child that died without a code are all "no answer", and no +/// caller can act differently on which. +/// +/// # [`Diagnostics`] IS THE CALLER'S, and hardcoding it here was the same defect +/// one layer up +/// +/// This entry point pinned [`Diagnostics::Keep`] because the LANDING GATES need +/// their refusal reason: their verdict is the exit code and the coordinate is on +/// stderr, so dropping it left `Readied::Refused { detail }` empty and the +/// operator reading a refusal with nowhere to look. +/// +/// **But the same entry point also fetches the pull request's BODY** (review of +/// #848), and there the returned string is PARSED rather than shown. A forge +/// client's notice on stderr — an auth warning, a deprecation, a proxy line — +/// was folded into the body, which defeats `land::ready`'s empty-body pass and +/// runs the body gates over text nobody wrote. [`Diagnostics`]' own doc forbids +/// `Keep` for a caller that parses. +/// +/// That is [`piped`]'s history repeated: one shared spawn changed to fix one +/// caller's symptom, breaking the callers that read the stream. The answer is the +/// same both times — the call site says which it wants. +pub(crate) fn piped_argv( + root: &Path, + argv: &[String], + stdin: &str, + diagnostics: Diagnostics, +) -> Option<(i32, String)> { + let (program, operands) = argv.split_first()?; + // `Some(root)`, where [`piped`] passes `None`: the first word here is a NAME + // the ladder resolves, so rung 3 needs a directory to read a shebang out of. + // That one argument IS the difference between the two entry points, which is + // why they share [`piped_through`] and not a signature. + piped_through(root, Some(root), program, operands, stdin, diagnostics) +} + /// This process's next dispatch number, for the live-capture key. /// /// The key has to name a *run*, not just a command: through the CLI there is @@ -1556,6 +2042,7 @@ fn dispatch( repo_root: &Path, bundle: &[&[String]], settings: &ExecConfig, + published: &[(String, String)], run: u64, ) -> Result> { let jobs = settings.jobs.max(1); @@ -1570,7 +2057,7 @@ fn dispatch( // The single-command path stays a plain call, so a bare `batten exec` // spawns no thread it does not need — and so the ordinary case is not // paying for the bundle case. - vec![run_one(repo_root, run, first, wave[0], settings)] + vec![run_one(repo_root, run, first, wave[0], settings, published)] } else { std::thread::scope(|scope| { let handles: Vec<_> = wave @@ -1578,7 +2065,7 @@ fn dispatch( .enumerate() .map(|(offset, command)| { scope.spawn(move || { - run_one(repo_root, run, first + offset, command, settings) + run_one(repo_root, run, first + offset, command, settings, published) }) }) .collect(); @@ -1616,6 +2103,7 @@ fn run_one( index: usize, command: &[String], settings: &ExecConfig, + published: &[(String, String)], ) -> Result { let Some((program, args)) = command.split_first() else { return Err(UsageError::raise( @@ -1655,7 +2143,8 @@ fn run_one( .args(extra.iter().map(OsString::from)) .args(args.iter().map(OsString::from)) .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + .stderr(Stdio::piped()) + .envs(published.iter().map(|(name, value)| (name, value))); group_at_spawn(&mut builder, decision); builder.spawn() }); @@ -1797,11 +2286,7 @@ fn report_bundle( // FIRST failure rather than the last keeps the answer a property of the // bundle rather than of the scheduler, which `--jobs` would otherwise make // non-deterministic. - let code = outcomes - .iter() - .map(|outcome| outcome.exit) - .find(|exit| *exit != 0) - .unwrap_or(0); + let code = bundle_code(outcomes); // CLOUD-429: the record IS the default answer, and it is emitted before the // passthrough below because a non-zero child must not lose it. On `report`, @@ -2164,6 +2649,132 @@ mod tests { ); } + /// THE CANCEL READS A RECORD, AND EVERY WAY IT CAN FAIL TO NAME A GROUP IS A + /// REFUSAL (CLOUD-1586). + /// + /// `cancel_owned_group` exists so a watcher thread can stop a gate whose pid + /// it never saw, and it gets there through a file. Three of the four arms + /// below are could-not-look — no state dir, no record, an unreadable one — + /// and all three must answer `false`: the caller records that answer as + /// *"the base moved and the gate was NOT reclaimed"*, which is the honest + /// reading and the one that says this mechanism did nothing. + /// + /// **The `0` arm is the one that matters**, and it is here rather than left + /// to `resolve_group`'s own cases because this function reaches `kill` + /// through a *different* route than [`terminate_group`]'s callers do. A + /// record carrying `0` names the CALLER's own group, so a cancel that + /// resolved it would signal the lap doing the cancelling — and on this path + /// that is the test runner. Pinned at this entry point, not only at the leaf. + /// + /// **NO `#[cfg(unix)]`** — `cfg-gated-test` found this case carrying one, and + /// [`cancel_owned_group`] is compiled on every target: it reaches `kill` only + /// through [`terminate_group`] and [`escalate_group`], whose + /// `#[cfg(not(unix))]` twins return `false`. So all four refusals hold off + /// unix as well, where they are the only thing pinning that path at all. + #[test] + fn a_cancel_that_cannot_resolve_its_record_signals_nothing() { + let root = crate::scratch::scratch("exec-cancel-record"); + + // No record at all: the common case, and not a failure. A gate run + // without the grouping opt-in writes none. + assert!( + !cancel_owned_group(&root), + "an absent record is could-not-look, never a reclaimed group" + ); + + let Ok(dir) = crate::state::repo_state_dir(&root) else { + return; + }; + let exec_dir = dir.join("exec"); + std::fs::create_dir_all(&exec_dir).expect("seed the record directory"); + let path = exec_dir.join(format!("group.{}", std::process::id())); + + // THE ARM THAT WOULD KILL THIS PROCESS. `kill(0, …)` addresses the + // caller's own group, so this is the one case where a missing guard is + // not a false report but a suicide. + std::fs::write(&path, "0\n").expect("seed a self-addressing record"); + assert!( + !cancel_owned_group(&root), + "0 names this process's own group and must never be signalled" + ); + + std::fs::write(&path, "not-a-number\n").expect("seed an unparseable record"); + assert!( + !cancel_owned_group(&root), + "a record that will not parse names no group" + ); + + // Above every assignable pid on Linux, so the group is provably empty + // rather than merely unlikely. + std::fs::write(&path, "4194305\n").expect("seed an empty group"); + assert!( + !cancel_owned_group(&root), + "an empty group is nothing to cancel and must not be counted as one" + ); + } + + /// THE REAPER SIGNALS NOTHING IT CANNOT RESOLVE, and every arm here is a + /// refusal rather than a kill (CLOUD-1148). + /// + /// `terminate_group` is handed a `pgid` read out of a record another process + /// wrote, possibly on a previous boot. Each way that string can fail to name + /// a live group must return `false` and send no signal — a caller counts the + /// `true`s and reports them, so a no-op counted as a reap is a false claim + /// that the machine was cleaned. + /// + /// The `true` arm is deliberately not here: it needs a real group with a real + /// member, which is a spawn, and a unit test that spawned one would be + /// asserting over `Command` rather than over this function. The compiled tier + /// drives it instead. + /// + /// **NO `#[cfg(unix)]`, AND THE ABSENCE IS THE ASSERTION** — `cfg-gated-test` + /// found this case carrying one. Every arm here is a REFUSAL, and + /// `terminate_group` and `escalate_group` both have `#[cfg(not(unix))]` twins + /// that return `false` unconditionally, so every assertion holds on Windows + /// too. The attribute bought nothing and cost the one thing that matters: it + /// took the case out of the off-unix build, where the twins are the only + /// implementation there is and nothing else pins them at all. + #[test] + fn a_group_that_cannot_be_resolved_is_not_reaped() { + assert!( + !terminate_group("not-a-number"), + "a pgid that will not parse names no group" + ); + assert!( + !terminate_group(""), + "an entry written before the field existed carries an empty pgid" + ); + assert!( + !terminate_group("0"), + "0 is the caller's OWN group to `kill`, and reaping it would signal this process" + ); + assert!( + !terminate_group("-1"), + "a negative pgid is `kill`'s every-process form and must never be resolved" + ); + // Above every assignable pid on Linux, so the group is provably empty + // rather than merely unlikely — `ESRCH`, which `group_is_empty` reads. + assert!( + !terminate_group("4194305"), + "an empty group is nothing to reap and must not be counted as one" + ); + // THE ESCALATION HALF TAKES THE SAME REFUSALS, and asserting it + // separately is the point: it is a second entry point over the same + // `kill`, so a guard added to one and not the other is a live defect + // that the first function's cases cannot see. + assert!(!escalate_group("not-a-number")); + assert!(!escalate_group("")); + assert!( + !escalate_group("0"), + "escalation would `SIGKILL` this process's own group" + ); + assert!(!escalate_group("-1")); + assert!( + !escalate_group("4194305"), + "an empty group did not ignore a term; it is simply gone" + ); + } + #[cfg(unix)] #[test] fn only_the_four_job_control_signals_are_forwarded() { diff --git a/crates/batten/src/fast_forward.rs b/crates/batten/src/fast_forward.rs new file mode 100644 index 000000000..1bed2956b --- /dev/null +++ b/crates/batten/src/fast_forward.rs @@ -0,0 +1,608 @@ +//! Asking the fast-forward bot to land a head, and reading the answer it gave +//! THIS request. +//! +//! # Why this is a module and not three lines in the lap +//! +//! The lap's other four steps decide from something local — a rebase outcome, a +//! gate's exit code, a push's CAS report. This one asks a THIRD PARTY and then +//! has to work out which of that party's answers was addressed to it, and the +//! whole difficulty is in the second half. So the protocol lives here, beside its +//! own join key, and [`crate::land`] orchestrates. +//! +//! # The join key, which is the entire correctness argument +//! +//! An `issue_comment` workflow run attaches to the DEFAULT BRANCH's tip. Its +//! `head_branch` and `head_sha` both name trunk on every one of these runs, and +//! no other field records which pull request asked. So a lap that polled by +//! timestamp alone would read strangers' refusals as its own — measured on the +//! predecessor at ~400 runs in thirty minutes, 243 of them refusals, which is a +//! near-certainty of finding somebody else's within any lap's window. That is how +//! "the bot is silent or slow" was concluded while the bot was in fact answering +//! every attempt within 23 seconds. +//! +//! [`key`] is what closes it: the comment id comes back from the POST that +//! created it, the workflow mints the same string as its `run-name`, and +//! `display_title` carries it. One request, one answer. +//! +//! # Two fences, and the client-side one is the correctness half +//! +//! `created=>=` bounds the page server-side, which is what makes paging +//! terminate over a finite window rather than over all history. But a query +//! parameter is an OPTIMISATION: mistype it, or meet an endpoint that ignores it, +//! and the fence vanishes with nothing failing. The `created_at >= since` +//! comparison here is what actually holds the line, and it is also what stops an +//! EARLIER LAP of this same pull request being re-read as this lap's verdict — +//! the livelock the stamp exists to prevent, and the reason the stamp is taken +//! before the comment is posted rather than after. +//! +//! # Pointer-only +//! +//! A refusal names the status, the endpoint's last segment, or a conclusion +//! token. Never a response body: a forge error can echo a header dump back, and a +//! token with it (non-negotiable rule 4, and `bot::forge`'s own posture). + +use anyhow::Result; + +use crate::error::UsageError; + +/// Runs per page when reading for the answer. +/// +/// The maximum the endpoint offers, because the page size is not a fence — the +/// `created` window is — and a smaller page only costs more round trips inside +/// the same window. +const PER_PAGE: u32 = 100; + +/// How many pages one read will walk before giving up. +/// +/// A RUNAWAY BACKSTOP AND NOTHING ELSE. Paging stops when a page comes back +/// short, which terminates because `created>=since` bounds the set server-side. +/// Reaching this cap means that fence stopped being honoured, not that the window +/// is genuinely this deep: at the predecessor's measured 13 runs/minute, 2000 +/// runs is ~2.5 hours, far outside any lap's stamp. +const MAX_PAGES: u32 = 20; + +/// The open pull request for `branch`, where the forge names one. +/// +/// HERE RATHER THAN IN THE LAP, for the reason [`crate::pr_watch::read`] states +/// about its own read: a lap that built the request itself would be a second +/// authority over the endpoint and the failure posture. `land` reaches this +/// module and no other, which is also what keeps `land -> bot` off the layering +/// table for one number. +/// +/// **The `--jq` filter is gone with the spawn, and the guard it needed with it.** +/// The predecessor wrote `.[0].number // empty` because the client prints the +/// STRING `null` for a missing field — not empty, and it would sail past a +/// caller's guard as a pull request number. Reading the document here, an absent +/// entry is `None` by construction and there is no rendering to be fooled by. +/// # THE HEAD FILTER IS `owner:branch`, NEVER A BARE BRANCH +/// +/// The forge documents this parameter as *"head user or head organization and +/// branch name in the format of `user:ref-name`"*. A bare ref is not that shape, +/// and an unmatched filter returns an empty array rather than an error — so the +/// failure is `None`, which reads exactly like *this branch has no open pull +/// request* and sends the lap down the has-nothing-to-land path. +/// +/// The owner is taken from `repo` rather than from a second argument, which is +/// correct for a branch in the repository being landed and is the only case this +/// loop has: `land` drives THIS repository's own branches, trunk-based, and a +/// fork's head would carry the fork owner instead. Stated rather than left +/// implied — a consumer landing fork branches needs the head owner threaded +/// through, and this function would answer `None` for every one of them. +#[must_use] +pub fn open_pull_request(repo: &str, branch: &str) -> Option { + match look_up_pull_request(repo, branch) { + Lookup::Found(number) => Some(number), + Lookup::None | Lookup::Unreadable(_) => None, + } +} + +/// Whether the forge says this pull request actually MERGED. +/// +/// **The workflow's conclusion is not this, and reading it as this deleted +/// branches** (review of #848). `Answer::Accepted` says the bot's run finished +/// without refusing; it does not say a merge happened, and `answer`'s own module +/// header says so — *"the merge shows up as the pull request's own terminal state +/// rather than here"*. Nothing read that state, so `Progress::Landed` retired the +/// branch on the bot's say-so: `refs/heads/` deleted on the remote, the +/// tracking ref gone, the receipts swept, under a pull request that could still +/// be open. +/// +/// The predecessor asked exactly this question at exactly this point and died on +/// anything but a merge. This restores that gate. +/// +/// Three-valued for [`Lookup`]'s reason: a forge that would not answer must not +/// read as *not merged*, because the caller's action on that is to keep lapping, +/// which is the safe direction — where reading it as merged deletes things. +#[must_use] +pub fn merged(repo: &str, pr: &str) -> Merged { + let Some(answer) = crate::rest::get(&format!("repos/{repo}/pulls/{pr}"), None) else { + return Merged::Unreadable(0); + }; + if !answer.is_reading() { + return Merged::Unreadable(answer.status); + } + let Ok(document) = serde_json::from_str::(&answer.body) else { + return Merged::Unreadable(answer.status); + }; + // `merged` is the forge's own boolean and is the only field that answers + // this. `state == "closed"` does NOT: a pull request closed without merging + // is closed, and treating that as landed is the predecessor's own `die` + // case. + match document.get("merged").and_then(serde_json::Value::as_bool) { + Some(true) => Merged::Yes, + Some(false) => Merged::No, + None => Merged::Unreadable(answer.status), + } +} + +/// The forge's answer to "did this pull request merge". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Merged { + /// It merged. The branch has done its job. + Yes, + /// The forge answered and it has not merged. + No, + /// The forge did not answer. **Never a fact about the pull request.** + Unreadable(u16), +} + +/// What a pull-request lookup actually found, with could-not-look kept apart. +/// +/// **[`open_pull_request`]'s `None` COLLAPSED TWO ANSWERS and every caller +/// printed the wrong one** (review of #848). `rest::get` hands back an `Answer` +/// for a `404` as readily as for a `200` — the body is the error document — so an +/// unauthenticated read of a private repository parsed as *not an array* and came +/// back `None`, identical to a branch that genuinely has no pull request. The +/// callers then wrote *"no open pull request for ``"*, which states a fact +/// about the branch on evidence that says only that nobody looked. +/// +/// The distinction is the whole type. A missing credential, a rate limit and a +/// repository slug that did not resolve are all [`Lookup::Unreadable`], carrying +/// the status so the operator is pointed at their environment rather than at +/// their branch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Lookup { + /// The forge named an open pull request for this branch. + Found(String), + /// The forge answered, and there is none. A fact about the branch. + None, + /// The forge did not answer usefully, carrying the status it did answer. + /// **Never a fact about the branch.** + Unreadable(u16), +} + +/// The pull request for `branch` WHATEVER its state. +/// +/// **[`look_up_pull_request`] filters `state=open`, and a merged pull request is +/// CLOSED** — so asking it to confirm a merge asks a question whose `Yes` is +/// unreachable by construction. `landed_for_real` did exactly that: the +/// fast-forward succeeded, the lookup found nothing, the merge read as +/// could-not-look, and the lap went round until the budget was spent and exited +/// `3` without ever retiring the branch (review of #848). The gate against +/// deleting a branch on an unmerged pull request became a gate against ever +/// landing. +/// +/// `state=all`, and it is a different question from the open-only one rather +/// than a widening of it: that lookup asks *is there work in flight for this +/// branch*, and this asks *what became of the work there was*. +#[must_use] +pub fn pull_request_in_any_state(repo: &str, branch: &str) -> Lookup { + look_up(&format!( + "repos/{repo}/pulls?head={}:{branch}&state=all&per_page=1", + repo.split('/').next().unwrap_or(repo) + )) +} + +/// The three-valued lookup [`open_pull_request`] flattens. +#[must_use] +pub fn look_up_pull_request(repo: &str, branch: &str) -> Lookup { + let owner = repo.split('/').next().unwrap_or(repo); + look_up(&format!( + "repos/{repo}/pulls?head={owner}:{branch}&state=open&per_page=1" + )) +} + +/// The reading both lookups share. One parser, two questions. +/// +/// The path is the whole of the question: both callers bake the repository and +/// the branch into it, so carrying them again as arguments was two more ways for +/// a caller to name a different pull request than the one it asks for. +fn look_up(path: &str) -> Lookup { + let Some(answer) = crate::rest::get(path, None) else { + // The request did not complete at all — no host, no route, no answer. + // `0` rather than a status, because there was none to carry. + return Lookup::Unreadable(0); + }; + if !answer.is_reading() { + return Lookup::Unreadable(answer.status); + } + // Past here the forge answered `200`, so an absent entry IS the answer: this + // branch has no open pull request. A body that will not parse as the array + // this endpoint documents is the forge disagreeing with itself, which is a + // could-not-look rather than an empty result. + let Ok(document) = serde_json::from_str::(&answer.body) else { + return Lookup::Unreadable(answer.status); + }; + let Some(entries) = document.as_array() else { + return Lookup::Unreadable(answer.status); + }; + let Some(number) = entries.first().and_then(|entry| entry.get("number")) else { + return Lookup::None; + }; + // A NUMBER OR A STRING, for `comment_id`'s reason one function down: the + // forge sends a number, and a string-only read would answer `None` over a + // good response. + number + .as_u64() + .map(|found| found.to_string()) + .or_else(|| number.as_str().map(str::to_owned)) + .map_or(Lookup::Unreadable(answer.status), Lookup::Found) +} + +/// What the lap needs to ask, and to recognise the answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ask { + /// The repository, in the forge's own `owner/name` spelling. + pub repo: String, + /// The pull request number, as written. + pub pr: String, + /// The workflow file whose runs carry the verdict. + pub workflow: String, +} + +/// The join key the workflow mints as its `run-name`. +/// +/// Built from the comment id the POST returned, so it cannot be guessed ahead of +/// the request and cannot collide with another lap's. +#[must_use] +pub fn key(pr: &str, comment: &str) -> String { + format!("fast-forward #{pr} @{comment}") +} + +/// What asking produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Asked { + /// The comment exists, and this is the id the answer will be keyed to. + Commented(String), + /// The forge did not create it. Pointer-only: the status, never the body. + /// + /// **THE SUCCESS LINE IS A CONSEQUENCE OF THE COMMENT EXISTING**, not of + /// control reaching the next statement. Measured on the predecessor at PR + /// #330: the forge answered a secondary rate limit, the client exited + /// non-zero, nobody read it, and the lap reported "commented /fast-forward … + /// waiting for the merge" over a comment that was never created — then waited + /// for a merge nothing had been asked to perform. + Refused(u16), +} + +/// Post the `/fast-forward` directive and read back the comment's own id. +/// +/// # Errors +/// +/// Only for an [`Ask`] naming no pull request. Every failure to REACH the forge +/// is an [`Asked::Refused`], because a lap must survive one. +pub fn ask(ask: &Ask) -> Result { + if ask.pr.is_empty() { + return Err(UsageError::raise(String::from( + "land: no pull request to ask for a fast-forward", + ))); + } + // `-i` so the RESPONSE HEADERS come back with the body: when this is refused, + // the reason and the delay are stated there, and asking a second endpoint for + // them would be one more request against the limit that just refused this one. + // + // The API rather than the client's own `pr comment` porcelain, and not for + // style: this returns the created comment OBJECT, so `.id` — the key the read + // below needs — comes back on stdout, and a non-2xx gives both a real + // non-zero exit and a status worth naming. + // THE API RATHER THAN THE CLIENT'S COMMENT PORCELAIN, and the predecessor's + // reason survives the transport change: this returns the created comment + // OBJECT, so `.id` — the key the read below needs — comes back in the body. + // + // The response headers used to need `-i` so a refusal's reason and delay + // arrived with the body. They arrive typed now: `crate::rest::Answer` + // carries the status the transport read, so there is no header block to + // parse and no second request to ask for one. + let endpoint = format!("repos/{}/issues/{}/comments", ask.repo, ask.pr); + let body = serde_json::json!({ "body": "/fast-forward" }); + let Some(answer) = crate::rest::post_json(&endpoint, &body) else { + return Ok(Asked::Refused(0)); + }; + let Some(id) = comment_id(&answer.body) else { + return Ok(Asked::Refused(answer.status)); + }; + Ok(Asked::Commented(id)) +} + +/// The `id` field of a created comment, as a string whatever its JSON type. +/// +/// The forge sends a NUMBER here, so a string-only read answers `None` over a +/// perfectly good response and the lap reports a comment it did create as +/// refused — which is the false-negative direction, and the one that costs a lap. +fn comment_id(body: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(body).ok()?; + let id = value.get("id")?; + if let Some(number) = id.as_u64() { + return Some(number.to_string()); + } + id.as_str().map(str::to_owned) +} + +/// What the bot said about THIS request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Answer { + /// No keyed run yet. The ordinary state, and not a verdict. + Pending, + /// It ran and did not refuse. The merge shows up as the pull request's own + /// terminal state rather than here. + Accepted, + /// It ran and refused: the branch is no longer a direct descendant. + Refused, + /// It ran and decided nothing, or the answer could not be read. Carries the + /// conclusion token, which is a closed vocabulary and never prose. + /// + /// **NEVER "main moved"** — that is a fact about a ref, and only the + /// staleness arm may assert it. Reading every non-success conclusion as + /// staleness is what the predecessor did, and 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 rate limit rather than trunk moving. The + /// loop's response to being rate-limited was to generate more of exactly the + /// request that had been refused. + Unknown(String), +} + +/// The API-relative path one page of the answer read asks for. +/// +/// Built rather than formatted at the call site so the window, the page size and +/// the event filter are one object a test can read. +/// +/// **A PATH RATHER THAN AN ARGV**, which is the shape change the in-process +/// transport buys: there is no `api` subcommand word to prepend and no client to +/// resolve, so what a case reads here is the endpoint itself. +#[must_use] +pub fn answer_request(ask: &Ask, since: &str, page: u32) -> String { + format!( + "repos/{}/actions/workflows/{}/runs?event=issue_comment&per_page={PER_PAGE}&page={page}&created=%3E%3D{since}", + ask.repo, ask.workflow + ) +} + +/// Read whether the run keyed to `comment` has concluded. +/// +/// Pages until a page comes back short, which is what makes the depth a property +/// of the WINDOW rather than of a page size. The two limits are independent: the +/// key says which run is this lap's, the depth says whether this lap's run is in +/// the page at all. A keyed filter over a window that has already rolled past the +/// run returns empty, which reads as [`Answer::Pending`] — byte-identical to a +/// silent bot, and the reading that cost the predecessor its diagnosis. +/// Seconds between two reads of the same lap's fast-forward answer. +/// +/// The bot takes roughly twenty seconds to create its run at all, so the first +/// read of any lap is `Pending` by construction. Two seconds is short enough that +/// the answer is reported promptly once it exists and long enough that the wait +/// is a handful of requests rather than a spin — and this endpoint is not the +/// conditional one, so each ask is a real request against the rate limit. +/// +/// **The loop is the caller's, never this module's**, for the reason +/// [`crate::main_watch`]'s header gives about its own: a module holding an +/// unbounded loop becomes a second authority over when to stop asking. +/// +/// Spelled `f64` because the one sleep in this crate is +/// [`crate::pr_watch::pause_until`] and that is what it takes. A second timer +/// here would need a second `disallowed_methods` escape, which is the widening +/// `spawn-widening` refuses and which `land.rs`'s stale arm already declines for +/// the same reason. +pub const ANSWER_POLL_SECONDS: f64 = 2.0; + +#[must_use] +pub fn answer(ask: &Ask, since: &str, comment: &str) -> Answer { + let wanted = key(&ask.pr, comment); + let mut page = 1; + while page <= MAX_PAGES { + let Some(raw) = run(&answer_request(ask, since, page)) else { + return Answer::Unknown(String::from("unreadable")); + }; + let Ok(value) = serde_json::from_str::(&raw) else { + return Answer::Unknown(String::from("unreadable")); + }; + // A body that is not a runs list can never read as an answer. Absent and + // present-but-wrong-shape are one reading here on purpose: both mean the + // question was not answered, and neither is a verdict about the branch. + let Some(runs) = value.get("workflow_runs").and_then(|r| r.as_array()) else { + return Answer::Unknown(String::from("unreadable")); + }; + let seen = runs.len(); + if let Some(conclusion) = concluded(runs, since, &wanted) { + return grade(&conclusion); + } + if seen < PER_PAGE as usize { + break; + } + page += 1; + } + Answer::Pending +} + +/// The conclusion of the first completed run carrying `wanted`, inside the window. +fn concluded(runs: &[serde_json::Value], since: &str, wanted: &str) -> Option { + runs.iter().find_map(|run| { + let created = run.get("created_at").and_then(serde_json::Value::as_str)?; + if created < since { + return None; + } + let title = run + .get("display_title") + .and_then(serde_json::Value::as_str)?; + if title != wanted { + return None; + } + if run.get("status").and_then(serde_json::Value::as_str)? != "completed" { + return None; + } + Some( + run.get("conclusion") + .and_then(serde_json::Value::as_str) + .unwrap_or("-") + .to_owned(), + ) + }) +} + +/// A conclusion token, as the lap's three-valued reading of it. +/// +/// A CLOSED VOCABULARY, and `failure` is the only one that is a verdict about the +/// branch. Everything that is not `success` or `failure` — `cancelled`, +/// `timed_out`, `startup_failure`, `stale`, `action_required` — is the bot not +/// deciding, which is not the branch's fault and must not stop the landing. +/// +/// **`skipped` USED TO JOIN `success` AND THAT WAS A BRANCH-DESTROYING READ** +/// (review of #848). The reasoning was *"the bot ran and did not refuse"*, which +/// is true and is not the question: a workflow skipped by a job-level `if:`, a +/// path filter or a concurrency rule NEVER MERGED ANYTHING. `Accepted` reaches +/// `Progress::Landed`, which retires the branch — deleting `refs/heads/` +/// on the remote, its tracking ref and its receipts — so a skip deleted the head +/// branch out from under a pull request that was still open. +/// +/// It is `Unknown` now, which laps rather than lands. A caller that wants to know +/// whether the merge actually happened must read the pull request's own terminal +/// state, which is what the predecessor did and what this answer deliberately +/// does not claim to be. +fn grade(conclusion: &str) -> Answer { + match conclusion { + "success" => Answer::Accepted, + "failure" => Answer::Refused, + other => Answer::Unknown(other.to_owned()), + } +} + +/// One REST call, or `None` where the forge could not be reached. +/// +/// **IN PROCESS, over [`crate::rest`].** This was a `gh` spawn annotated +/// `#[expect(clippy::disallowed_types)]` on the claim that the crate carries no +/// HTTP client that resolves a forge credential. It does — `fetch.rs`, vendored +/// under CLOUD-745 — and `lease.rs` was already using it with a bearer header. +/// Every one of the four spawns CLOUD-1338 removed carried that same sentence. +/// +/// `None` rather than an error, for [`crate::main_watch::read`]'s reason: every +/// failure to reach the forge is a could-not-look the caller's own loop must +/// survive. +fn run(path: &str) -> Option { + crate::rest::get(path, None).map(|answer| answer.body) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_key_names_the_comment_rather_than_the_pull_request_alone() { + // Two laps of ONE pull request must not share a key, which is the whole + // reason the comment id is in it. + assert_ne!(key("42", "1001"), key("42", "1002")); + assert_eq!(key("42", "1001"), "fast-forward #42 @1001"); + } + + #[test] + fn a_numeric_comment_id_is_read_as_readily_as_a_string_one() { + // The forge sends a number. A string-only read answers `None` over a good + // response, and the lap then reports a comment it DID create as refused. + assert_eq!(comment_id(r#"{"id": 1001}"#), Some(String::from("1001"))); + assert_eq!(comment_id(r#"{"id": "1001"}"#), Some(String::from("1001"))); + assert_eq!(comment_id(r#"{"nothing": true}"#), None); + } + + #[test] + fn the_window_is_enforced_client_side_and_not_only_by_the_query() { + // The server-side `created` bound is an optimisation; this is the fence. + // A run carrying the right key from BEFORE the stamp is an earlier lap of + // this same pull request, and reading it is the livelock. + let runs = vec![serde_json::json!({ + "created_at": "2026-09-04T00:00:00Z", + "display_title": "fast-forward #42 @1001", + "status": "completed", + "conclusion": "failure", + })]; + assert_eq!( + concluded(&runs, "2026-09-04T01:00:00Z", "fast-forward #42 @1001"), + None, + "a run predating the stamp is an earlier lap, never this one's verdict" + ); + assert_eq!( + concluded(&runs, "2026-09-03T23:00:00Z", "fast-forward #42 @1001"), + Some(String::from("failure")), + "the same run inside the window is this lap's answer" + ); + } + + #[test] + fn a_stranger_s_run_in_the_same_window_is_not_this_lap_s_answer() { + // The measured failure: ~400 runs in thirty minutes, 243 refusals, all + // attached to trunk's tip. Only the key tells them apart. + let runs = vec![serde_json::json!({ + "created_at": "2026-09-04T02:00:00Z", + "display_title": "fast-forward #99 @2002", + "status": "completed", + "conclusion": "failure", + })]; + assert_eq!( + concluded(&runs, "2026-09-04T01:00:00Z", "fast-forward #42 @1001"), + None + ); + } + + #[test] + fn an_incomplete_run_is_not_yet_an_answer() { + let runs = vec![serde_json::json!({ + "created_at": "2026-09-04T02:00:00Z", + "display_title": "fast-forward #42 @1001", + "status": "in_progress", + "conclusion": serde_json::Value::Null, + })]; + assert_eq!( + concluded(&runs, "2026-09-04T01:00:00Z", "fast-forward #42 @1001"), + None + ); + } + + #[test] + fn only_failure_is_a_verdict_about_the_branch() { + assert_eq!(grade("success"), Answer::Accepted); + assert_eq!(grade("failure"), Answer::Refused); + // **`skipped` IS NOT AN ACCEPTANCE, and this case asserted that it was** + // for as long as `grade` said so. A skip merges nothing, and `Accepted` + // reaches `Progress::Landed`, which retires the branch — so the pair + // agreed with each other about a reading that deleted branches. Listed + // with the other not-a-verdict tokens now, where it belongs. + for token in [ + "cancelled", + "timed_out", + "startup_failure", + "stale", + "skipped", + ] { + assert_eq!( + grade(token), + Answer::Unknown(token.to_owned()), + "{token} is the bot not deciding, which is not the branch's fault" + ); + } + } + + #[test] + fn the_answer_request_carries_both_the_window_and_the_event_filter() { + let ask = Ask { + repo: String::from("o/r"), + pr: String::from("42"), + workflow: String::from("land.yml"), + }; + let url = answer_request(&ask, "2026-09-04T01:00:00Z", 3); + assert!( + !url.starts_with('/'), + "API-relative, never rooted — a leading slash is a 404 that reads as \ + could-not-look: {url}" + ); + assert!(url.contains("event=issue_comment"), "got {url}"); + assert!(url.contains("page=3"), "got {url}"); + assert!(url.contains("created=%3E%3D2026-09-04T01"), "got {url}"); + } +} diff --git a/crates/batten/src/fetch.rs b/crates/batten/src/fetch.rs index bc5b43c87..21e8353d9 100644 --- a/crates/batten/src/fetch.rs +++ b/crates/batten/src/fetch.rs @@ -622,12 +622,27 @@ const PROXY_HEAD_LIMIT: usize = 8192; /// URL it is HANDED, and a 302 hands it a new one. async fn exchange(call: &Call<'_>) -> Result { let mut target = call.url.to_owned(); + // **THE HEADERS NARROW WHEN THE HOST CHANGES** (review of #848). This replayed + // `call.headers` verbatim on every hop, and `redirect_target` constrains only + // the SCHEME — so a `Location` naming another host received whatever the + // caller attached, `Authorization: Bearer ` included. `curl`, the + // client this transport replaced, strips credentials on a cross-host redirect + // unless `--location-trusted` is passed; the port kept the redirect-following + // and dropped that protection silently. + // + // Latent rather than live today — no current call site reaches a redirecting + // endpoint — which is exactly why it is worth closing now: the next asset or + // download endpoint added here would leak the credential to a CDN host with + // nothing in the code saying it could. + let mut carried: Vec<(String, String)> = call.headers.to_vec(); for _hop in 0..=MAX_REDIRECTS { - let (answer, location) = - one_exchange(&target, call.headers, call.body, call.direct).await?; + let (answer, location) = one_exchange(&target, &carried, call.body, call.direct).await?; let Some(next) = redirect_target(&target, answer.status, location.as_deref())? else { return Ok(answer); }; + if !same_host(&target, &next) { + carried.retain(|(name, _)| !name.eq_ignore_ascii_case("authorization")); + } target = next; } Err(anyhow::anyhow!( @@ -635,6 +650,25 @@ async fn exchange(call: &Call<'_>) -> Result { )) } +/// Do two URLs name the same host, so a credential may follow? +/// +/// **A HOST COMPARISON, NOT AN ORIGIN ONE, and the difference is deliberate.** +/// The scheme is already pinned to `https` by [`redirect_target`] and the port is +/// not what a credential is scoped to in practice; the host is. A URL that will +/// not parse, or that names no host, answers `false` — a redirect this cannot +/// reason about is one the credential does not follow. +fn same_host(from: &str, to: &str) -> bool { + let host_of = |raw: &str| { + raw.parse::() + .ok() + .and_then(|uri| uri.host().map(str::to_ascii_lowercase)) + }; + match (host_of(from), host_of(to)) { + (Some(from), Some(to)) => from == to, + _ => false, + } +} + /// Where a redirect points, or `None` when the answer is the answer. /// /// # Errors diff --git a/crates/batten/src/git.rs b/crates/batten/src/git.rs index 313f63d35..d7f2f697a 100644 --- a/crates/batten/src/git.rs +++ b/crates/batten/src/git.rs @@ -434,6 +434,61 @@ fn plain(path: PathBuf) -> PathBuf { PathBuf::from(rest) } +/// Resolve the root of THIS WORKING TREE — the checkout `start` is actually in. +/// +/// # THE SIBLING TO [`repo_root`], AND CHOOSING BETWEEN THEM IS THE WHOLE POINT +/// +/// `repo_root` answers with the MAIN checkout on purpose, because per-repository +/// state must be one store across every linked worktree (CLOUD-164). That makes +/// it the wrong anchor for anything a BRANCH decides, and the difference is +/// invisible until somebody works in a linked worktree — which is where agents +/// work. +/// +/// Measured (review of #848): `receipt verified` anchored `[receipt] +/// verified_by` on `repo_root`, so a worktree whose branch TIGHTENED the check +/// set was judged against the main checkout's looser one and a head carrying +/// half the receipts exited `0`. The reverse is as bad — a main config naming a +/// check the branch retired makes the worktree permanently unverifiable. +/// +/// **The rule: committed config is the WORKING TREE's, state is the +/// REPOSITORY's.** A `batten.toml` is a file this branch may change, so it is +/// read from here; a receipt store is shared, so it is rooted on `repo_root`. +/// +/// A relative `start` resolves against the process working directory, and the +/// returned path is absolute. Unlike a bare cwd read this WALKS UP, so a call +/// from a subdirectory finds the checkout's own authority rather than none — +/// which is the defect on the other side of this one. +/// +/// # Errors +/// +/// As [`repo_root`]: not a directory, not inside a repository, or a bare +/// repository with no working tree to root. +pub fn worktree_root(start: &Path) -> Result { + if !start.is_dir() { + return Err(UsageError::raise(format!( + "{} is not a directory", + start.display() + ))); + } + let repo = open_upwards(start, Vec::new()).map_err(|_| { + UsageError::raise(format!( + "{} is not inside a git repository", + start.display() + )) + })?; + let Some(workdir) = repo.workdir() else { + return Err(UsageError::raise(format!( + "{} is inside a bare repository, which has no working tree to root", + start.display() + ))); + }; + Ok(plain( + workdir + .canonicalize() + .unwrap_or_else(|_| workdir.to_path_buf()), + )) +} + /// Resolve the root of the repository containing `start`: the working-tree /// directory whose `.git` is the repository's *common* directory. /// @@ -3263,6 +3318,52 @@ fn patch_id_index(dir: &Path, window: Window, range: &str) -> Result Option { + let mut changes = commit_changes(repo, id).ok()?; + crate::patch::identity(&mut changes) +} + +/// The patch identities present on `range`, as a set. +/// +/// [`patch_id_index`] without the commit it came from: the replay asks only +/// whether a change is ALREADY THERE, and carrying which commit carries it would +/// be an answer nothing reads. +/// +/// # Errors +/// +/// As [`rev_list`]: a range that will not resolve or a commit that will not read. +pub(crate) fn patch_identities( + dir: &Path, + window: Window, + range: &str, +) -> Result> { + let repo = open(dir)?; + let mut found = std::collections::BTreeSet::new(); + for commit in rev_list(dir, window, range)? { + let id = gix::ObjectId::from_hex(commit.as_bytes()) + .map_err(|_| UsageError::raise(format!("cannot read commits for {range:?}")))?; + if let Some(hex) = commit_identity(&repo, &id) { + found.insert(hex); + } + } + Ok(found) +} + /// Where this branch and `base_ref` diverged, for RANGE SELECTION. /// /// The same line [`cumulative_patch_id`] draws, and the reason this is here at diff --git a/crates/batten/src/gitwrite.rs b/crates/batten/src/gitwrite.rs index 2cc17f0df..825660c13 100644 --- a/crates/batten/src/gitwrite.rs +++ b/crates/batten/src/gitwrite.rs @@ -185,6 +185,70 @@ pub enum Rebase { }, } +/// Remove `reference`, whatever it points at. +/// +/// **`Any` for the expected value, matching [`set_ref`]'s reasoning**: the only +/// caller deletes a remote-TRACKING ref, which is this clone's record of what a +/// remote said rather than a claim anyone races for. The compare-and-swap that +/// matters is the remote one, and that is [`crate::lease::swap`]'s. +/// +/// # Errors +/// +/// A repository that will not open, a name that will not parse, or a backend +/// that refuses the edit. +pub fn delete_ref(dir: &Path, reference: &str) -> Result<()> { + let repo = crate::git::open_for_write(dir)?; + let name: gix::refs::FullName = reference + .try_into() + .map_err(|err| anyhow::anyhow!("gitwrite: {reference} is not a ref name: {err}"))?; + repo.edit_reference(gix::refs::transaction::RefEdit { + change: gix::refs::transaction::Change::Delete { + expected: gix::refs::transaction::PreviousValue::Any, + log: gix::refs::transaction::RefLog::AndReference, + }, + name, + deref: false, + }) + .map_err(|err| anyhow::anyhow!("gitwrite: {reference} will not delete: {err}"))?; + Ok(()) +} + +/// Does `tip` already carry `candidate`? +/// +/// # Why it lives here rather than in `git.rs` +/// +/// [`rebase`] below asks this same question inline and says why: **CLOUD-36 +/// refuses ancestry as a MERGED-NESS answer**, because landing rebases and a +/// branch that landed is not an ancestor of anything. That refusal stands. What +/// is asked here is the other question — whether a tree really is built on a +/// commit — and for that, ancestry is exactly the predicate. +/// +/// So the primitive lives beside the one caller that had already justified it, +/// where the misuse CLOUD-36 names cannot spread by looking like a general +/// utility in the module a reader browses for reads. +/// +/// It is public because `speculation` needs the same question and must not open +/// the backend itself: `gix_is_confined_to_the_git_modules` refuses a fourth +/// module reaching `gix`, and it caught that module's first draft doing so. The +/// alternative — widening the confinement list for a predicate this file already +/// contains — would have bought a second place to get ancestry wrong. +/// +/// `false` for anything that will not resolve, which is the fail-closed +/// direction every caller of this wants: a bet whose base cannot be read is not +/// a bet whose base is present. +#[must_use] +pub fn carries(dir: &Path, candidate: &str, tip: &str) -> bool { + let Ok(repo) = crate::git::open_for_write(dir) else { + return false; + }; + let resolve = |rev: &str| repo.rev_parse_single(rev).ok().map(gix::Id::detach); + let (Some(base), Some(head)) = (resolve(candidate), resolve(tip)) else { + return false; + }; + repo.merge_base(base, head) + .is_ok_and(|found| found.detach() == base) +} + /// Replay `branch` onto `onto`, and update the worktree to match. /// /// The commits in `onto..branch` are replayed oldest first, each as a three-way @@ -210,6 +274,129 @@ pub enum Rebase { /// the engine cannot compute, or a worktree write that fails. A CONFLICT is not /// an error — it is [`Rebase::Conflicted`]. pub fn rebase(dir: &Path, branch: &str, onto: &str) -> Result { + // The range and the graft point are the same rev, which is what an ordinary + // rebase means: replay everything this branch has that `onto` does not. + replay_onto(dir, branch, onto, onto) +} + +/// [`rebase`], with the conflict at named paths taken from the WORKTREE. +/// +/// # The loop's one human stop had no route to take it (CLOUD-1586) +/// +/// `mem:workflow/landing-loop` gives the loop exactly one human stop — a rebase +/// that conflicts — and the module header above states the design that makes the +/// stop safe: **nothing moves on a conflict**, so there is no detached HEAD and +/// no half-replayed state for the next lap to find. That is right, and it left +/// the human nothing to resolve. The predecessor shell lander left an ordinary +/// rebase in progress, where `git add` and `git rebase --continue` are the route; +/// this engine leaves a clean tree and a refusal, and the only way back to a +/// resolvable state is re-creating the rebase by hand — which this repository's +/// own `rebase-not-hand-stepped` denies, with no `bypass_env` and a general hatch +/// that is only readable in the adjudicating process's environment. Measured on +/// #848 twice: the loop stopped, and no route it named was open. +/// +/// # Why the resolution comes from the worktree, and why that needs no state +/// +/// The alternative is `git rebase`'s: materialise the conflict, remember where +/// the replay got to, and continue. That needs the remainder of the range +/// persisted across invocations, and a half-replayed branch on disk — the exact +/// state the header refuses. +/// +/// This is stateless instead. The whole replay re-runs from its base on every +/// invocation, so the caller supplies the merged content UP FRONT: edit the file +/// in the worktree, name it here, and the replay uses those bytes for that path +/// instead of refusing. Nothing is remembered, nothing is left half-done, and a +/// run with no `resolutions` is byte-identical to [`rebase`]. +/// +/// **It resolves a PATH, never a side.** There is no `--ours`/`--theirs`: those +/// would be the auto-resolution the module header refuses, one strategy pick +/// wearing a flag. What the caller supplies is content they wrote, which is a +/// decision a person made rather than one the engine took for them. +/// +/// **EACH RESOLUTION IS SPENT ONCE, AND SINCE CLOUD-1670 THAT IS PER ENTRY +/// RATHER THAN PER PATH.** The rule it enforces is unchanged: one file carries +/// one version of its content, so using it for a second merge OF THAT PATH would +/// be replaying a resolution for a merge nobody looked at. What changed is that +/// the caller can now WRITE a second version. An entry is `` — content +/// from the worktree, spent on the first conflict of that path — or +/// `=`, content from a file authored for one merge of it; entries +/// naming the same path are spent in the order given, one per conflict. +/// +/// **The per-path spelling was a permanent stop for a path that conflicts +/// twice, and this branch is where it was measured.** One declared document +/// conflicted at FOUR of #848's commits, because four of them edit a single +/// line the base branch had also moved; a second document conflicted at four +/// more. Every run resolved the first and refused at the second, moved nothing, +/// and the next run re-derived the identical range — which is precisely the +/// shape the paragraph below says the patch-identity drop exists to remove, +/// arrived at through the path rather than through the offer. Naming the path +/// four times did not help, by construction: the offer was a SET of paths. +/// +/// The documents are not named here, and that is rule 1 rather than reticence: +/// they are a consumer's own config, `document_facts`'s +/// `no_artifact_name_reaches_the_core` refuses one in this crate, and it caught +/// the first draft of this very paragraph. +/// +/// Naming a path more than once is therefore an assertion, not a loophole: the +/// caller is saying they read each of those merges and wrote an answer for each. +/// A different path later in the range is still a different question and may be +/// named in the same run. +/// +/// Spending the whole offer at the first conflicting commit enforced the same +/// rule and made a CHAIN unresolvable, which is worse than the case it guarded: +/// nothing moves on a conflict, so a range conflicting at two paths could never +/// complete — every run resolved the first, refused at the second, moved +/// nothing, and the next run re-derived the identical range. That is the +/// permanent-stop shape [`rebase`]'s patch-identity drop exists to remove, +/// reintroduced by its own remedy. +/// +/// # Errors +/// +/// As [`rebase`], plus a named path that cannot be read from the worktree — that +/// is a usage error rather than a conflict, because the caller asserted a +/// resolution exists there. +pub fn rebase_resolving( + dir: &Path, + branch: &str, + onto: &str, + resolutions: &[String], +) -> Result { + replay_range(dir, branch, onto, onto, resolutions) +} + +/// Replay `upstream..branch` onto `onto`, and update the worktree to match. +/// +/// **`git rebase --onto`, and the third argument is the whole of it.** [`rebase`] +/// bounds the range by the place it grafts to, which is right when they are the +/// same rev and wrong when they are not — and the case that needs them apart is +/// unwinding a bet this process ADOPTED (CLOUD-862). Such a bet has no undo +/// point, because the process that recorded one is gone; what it has is the +/// borrowed base, and `origin/main..HEAD` minus the borrowed range is precisely +/// this branch's own commits. Bounding by `onto` there would replay the borrowed +/// commits too, which is the tree the unwind exists to get rid of. +/// +/// Everything else is [`rebase`]'s and is documented there: merge commits +/// refused, signatures dropped, a conflict returned rather than raised. +/// +/// # Errors +/// +/// As [`rebase`]. +pub fn replay_onto(dir: &Path, branch: &str, upstream: &str, onto: &str) -> Result { + replay_range(dir, branch, upstream, onto, &[]) +} + +/// The replay every entry point above funnels into. +/// +/// `resolutions` is [`rebase_resolving`]'s and is empty for every other caller, +/// which is what makes the added parameter behaviour-preserving rather than a +/// second replay to keep in step. +fn replay_range( + dir: &Path, + branch: &str, + upstream: &str, + onto: &str, + resolutions: &[String], +) -> Result { let repo = crate::git::open_for_write(dir)?; let resolve = |rev: &str| { repo.rev_parse_single(rev) @@ -217,6 +404,7 @@ pub fn rebase(dir: &Path, branch: &str, onto: &str) -> Result { .map(gix::Id::detach) }; let base = resolve(onto)?; + let bound = resolve(upstream)?; let tip = resolve(branch)?; // "Does this branch already sit on the base" is an ancestry question, and it @@ -226,22 +414,29 @@ pub fn rebase(dir: &Path, branch: &str, onto: &str) -> Result { // is asked here is whether there is anything to replay, and for that ancestry // is exactly the predicate — the base is an ancestor of the tip iff the tip // already carries it. - if repo - .merge_base(base, tip) - .is_ok_and(|found| found.detach() == base) + // ASKED OF THE GRAFT POINT AND THE BOUND ALIKE, and both are needed once + // they can differ: there is nothing to replay when the tip already carries + // `onto` AND the range is empty. An adopted bet's unwind has a tip that + // carries its bound and NOT its graft point, which is exactly the case that + // must not short-circuit. + if bound == base + && repo + .merge_base(base, tip) + .is_ok_and(|found| found.detach() == base) { return Ok(Rebase::Current); } let walk = repo .rev_walk([tip]) - .with_hidden([base]) + .with_hidden([bound]) .all() - .map_err(|err| anyhow::anyhow!("gitwrite: {onto}..{branch} will not walk: {err}"))?; + .map_err(|err| anyhow::anyhow!("gitwrite: {upstream}..{branch} will not walk: {err}"))?; let mut range = Vec::new(); for step in walk { - let info = - step.map_err(|err| anyhow::anyhow!("gitwrite: {onto}..{branch} will not walk: {err}"))?; + let info = step.map_err(|err| { + anyhow::anyhow!("gitwrite: {upstream}..{branch} will not walk: {err}") + })?; if info.parent_ids().count() > 1 { return Err(anyhow::anyhow!( "gitwrite: {} is a merge, and this replay does not model one", @@ -265,11 +460,103 @@ pub fn rebase(dir: &Path, branch: &str, onto: &str) -> Result { anyhow::anyhow!("gitwrite: the configured committer will not parse: {err}") })?; + // **A COMMIT WHOSE CHANGE IS ALREADY ON THE BASE IS DROPPED, NOT REPLAYED** + // (CLOUD-1586). This is `git rebase`'s own behaviour — it builds the list + // through `git cherry`, which compares patch identities and omits the + // commits the upstream already carries — and its absence here was a + // PERMANENT stop rather than a slow path: a change that reached `main` by + // any route other than this branch's own merge (a cherry-pick, an + // independent re-implementation, a fix ported ahead of the PR) stays in + // `upstream..branch` because it is not REACHABLE from the base, gets + // three-way merged against a base that already contains it, and conflicts. + // Every later lap re-derives the same range and conflicts identically, so + // the loop cannot make progress no matter how many times it runs. + // + // Measured on this branch: `3f308039` and `main`'s `a7935a7b` share patch + // identity `f185159e…`, and the replay stopped on it every lap. The + // resolution needed a hand rebase, which `rebase-not-hand-stepped` denies — + // so the engine's missing drop presented as a policy deadlock and cost a + // human override to get past. + // + // ANCHORED ON `branch..onto`, which is `git cherry`'s comparison side: the + // commits the base has and this branch does not. `upstream..onto` is the + // wrong set and is empty in the common case where the graft point IS the + // bound, which would make this whole guard a no-op. + // + // A range that will not read is an EMPTY set, never a refusal: could-not-look + // here means replay everything, which is the behaviour that existed before + // and can only conflict, never silently drop work. + let already = crate::git::patch_identities( + dir, + crate::git::Window::DEFAULT, + &format!("{branch}..{onto}"), + ) + .unwrap_or_default(); + let empty = gix::ObjectId::empty_tree(repo.object_hash()); + let context = Replay { + dir, + options: &options, + committer: &committer, + empty, + }; let mut cursor = base; + let mut replayed = 0usize; + // THE OFFER, PARSED ONCE AND SPENT PER OCCURRENCE (CLOUD-1670). + // + // An entry is `` — content from the worktree, as it always was — or + // `=`, content from a file the caller wrote for ONE merge of that + // path. The pair is what makes a path conflicting at several commits + // resolvable at all, and it is the honest shape rather than reuse: the second + // occurrence gets bytes somebody authored for it, not the first occurrence's + // answer applied again to a merge nobody looked at. + let offer = parse_offer(resolutions); + let mut used: std::collections::BTreeMap = std::collections::BTreeMap::new(); for original in &range { - match replay(&repo, cursor, *original, &options, &committer, empty)? { - Step::Landed(id) => cursor = id, + // `commit_identity` is `None` for a commit that changes nothing, and an + // absent identity must not match another absent one — so an empty commit + // is replayed rather than dropped, and stays the caller's to reason about. + if crate::git::commit_identity(&repo, original) + .is_some_and(|identity| already.contains(&identity)) + { + continue; + } + // **EACH PATH IS SPENT ONCE, rather than the whole offer at the first + // conflicting commit.** One worktree file holds one version of its + // content, so reusing it for a SECOND merge of the same path would be + // inventing a resolution nobody looked at — that is the rule, and it is + // about the path rather than about the commit. + // + // Spending the whole offer at the first conflict enforced the same rule + // and made a CHAIN unresolvable, which is worse than the case it + // guarded. Nothing moves on a conflict, so a range whose commits + // conflict at two different paths could never complete: every run + // resolved the first, refused at the second, moved nothing, and the next + // run re-derived the identical range. Measured on #848 — `fetch.rs` at + // one commit and `egress-fencing.rego` at another — where it is the + // permanent-stop shape the patch-identity drop above exists to remove, + // reintroduced by its own remedy. + // + // SPENT PER OCCURRENCE SINCE CLOUD-1670, and the rule above is unchanged + // by it: what a path may not do is reuse ONE resolution twice. The offer + // for this commit is, per path, the NEXT unspent entry naming it — so a + // caller who wrote four files for four merges of one path gets each + // applied to the merge they wrote it for, and a caller who named the path + // once still spends it once. Nothing is remembered across invocations; + // the whole range re-runs and the offer is re-read from argv. + let offered = next_offer(&offer, &used); + match replay(&context, &repo, cursor, *original, &offered)? { + Step::Landed(id) => { + cursor = id; + replayed += 1; + } + Step::Resolved(id, spent) => { + cursor = id; + replayed += 1; + for path in spent { + *used.entry(path).or_insert(0) += 1; + } + } Step::Conflicted(paths) => { return Ok(Rebase::Conflicted { commit: original.to_hex().to_string(), @@ -282,16 +569,58 @@ pub fn rebase(dir: &Path, branch: &str, onto: &str) -> Result { let now = cursor.to_hex().to_string(); set_ref(dir, branch, &now)?; update_worktree(&repo, tip, cursor)?; + // The count is what was REPLAYED, not what was walked: a dropped commit + // contributes no commit to the new head, and reporting the range's length + // would claim a head carries commits it does not. Ok(Rebase::Replayed { head: now, - commits: range.len(), + commits: replayed, }) } +/// Move `branch` to `to` and make the worktree match — `git reset --hard`. +/// +/// **The EXACT unwind, and it is a different operation from a replay.** A bet +/// this process placed recorded the branch's own last non-speculative HEAD, so +/// undoing it is not "recompute what this branch should be" but "go back to what +/// it was" — no merge, no new commits, nothing to conflict. Where that undo point +/// is absent, [`replay_onto`] is the other unwind and its header says why. +/// +/// The worktree update is [`rebase`]'s, so a reset writes the same bytes a replay +/// would and leaves an index with the stat data git needs to read the tree as +/// clean. Without that this is a reset that leaves every tracked file looking +/// modified, which the next lap's `tree-clean` would refuse. +/// +/// # Errors +/// +/// A repository that will not open, a rev that will not resolve, or a worktree +/// write that fails. +pub fn reset_hard(dir: &Path, branch: &str, to: &str) -> Result { + let repo = crate::git::open_for_write(dir)?; + let resolve = |rev: &str| { + repo.rev_parse_single(rev) + .map_err(|err| anyhow::anyhow!("gitwrite: {rev} will not resolve: {err}")) + .map(gix::Id::detach) + }; + let was = resolve(branch)?; + let now = resolve(to)?; + let id = now.to_hex().to_string(); + set_ref(dir, branch, &id)?; + update_worktree(&repo, was, now)?; + Ok(id) +} + /// What replaying ONE commit produced. enum Step { /// The rewritten commit's id. Landed(gix::ObjectId), + /// The rewritten commit's id, and the paths whose resolution was spent on it. + /// + /// A separate arm rather than a flag on [`Step::Landed`] because the caller + /// must know WHICH paths were spent: each is withdrawn from the offer for + /// every later commit in the range, and a walk that could not tell would hand + /// the same worktree bytes to a second merge of that path nobody inspected. + Resolved(gix::ObjectId, Vec), /// The paths it conflicts at. Conflicted(Vec), } @@ -301,14 +630,84 @@ enum Step { /// Split out of [`rebase`] because the loop and the merge fail for entirely /// different reasons, and because a lap's whole decision — take the conflict or /// resolve it — lives in six lines here rather than buried in a walk. +/// One resolution entry read as `(path, source file)` (CLOUD-1670). +/// +/// `` alone reads the content from the path itself, which is every caller +/// before this row and stays byte-identical. `=` names a file the +/// caller authored for ONE merge of that path, which is what makes a path +/// conflicting at several commits resolvable without reusing an answer. +/// +/// The split is on the FIRST `=`, so a source file whose own name contains one +/// still resolves; a path containing `=` is not expressible and is not a shape +/// this repository has. +fn parse_offer(resolutions: &[String]) -> Vec<(String, std::path::PathBuf)> { + resolutions + .iter() + .map(|entry| match entry.split_once('=') { + Some((path, source)) => (path.to_owned(), std::path::PathBuf::from(source)), + None => (entry.clone(), std::path::PathBuf::from(entry)), + }) + .collect() +} + +/// The resolution on offer for the NEXT conflict, one per path (CLOUD-1670). +/// +/// `offer` is the caller's entries in the order given and `used` counts how many +/// of each path's entries earlier commits already spent, so a path's offer here +/// is its `used[path]`-th entry and a path whose entries are exhausted offers +/// nothing — which is what makes the commit after them conflict rather than +/// silently reuse the last answer. +fn next_offer( + offer: &[(String, std::path::PathBuf)], + used: &std::collections::BTreeMap, +) -> std::collections::BTreeMap { + let mut offered: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for (path, _) in offer { + if offered.contains_key(path) { + continue; + } + let already = used.get(path).copied().unwrap_or(0); + let mut queue = offer + .iter() + .filter(|(candidate, _)| candidate == path) + .map(|(_, source)| source); + if let Some(source) = queue.nth(already) { + offered.insert(path.clone(), source.clone()); + } + } + offered +} + +/// What every commit in one replay shares. +/// +/// A struct rather than six parameters because the walk computes all of it once +/// and hands the same values to each commit — and because the alternative is a +/// signature clippy refuses at seven. +struct Replay<'a> { + /// The worktree root, read only to pick up a caller's resolution. + dir: &'a Path, + /// The merge options the whole range is judged by. + options: &'a gix::merge::tree::Options, + /// The committer every rewritten commit takes. + committer: &'a gix::actor::Signature, + /// The empty tree, for a root commit's absent parent. + empty: gix::ObjectId, +} + fn replay( + ctx: &Replay<'_>, repo: &gix::Repository, cursor: gix::ObjectId, original: gix::ObjectId, - options: &gix::merge::tree::Options, - committer: &gix::actor::Signature, - empty: gix::ObjectId, + resolutions: &std::collections::BTreeMap, ) -> Result { + let Replay { + dir, + options, + committer, + empty, + } = *ctx; let commit = repo .find_commit(original) .map_err(|err| anyhow::anyhow!("gitwrite: {original} will not read: {err}"))?; @@ -337,6 +736,7 @@ fn replay( // let a resolution strategy quietly pick a side, which deletes the loop's // only human stop. let strict = gix::merge::tree::TreatAsUnresolved::forced_resolution(); + let mut resolved: Vec = Vec::new(); if outcome.has_unresolved_conflicts(strict) { let mut paths: Vec = outcome .conflicts @@ -346,7 +746,45 @@ fn replay( .collect(); paths.sort_unstable(); paths.dedup(); - return Ok(Step::Conflicted(paths)); + + // EVERY conflicting path must be named, never some of them. A partial + // resolution would write a tree carrying the engine's own pick for the + // paths the caller did not mention — the auto-resolution this module + // exists to refuse, arrived at by omission rather than by a flag. + if paths.is_empty() || !paths.iter().all(|path| resolutions.contains_key(path)) { + return Ok(Step::Conflicted(paths)); + } + + for path in &paths { + // Read the WORKTREE, which is where the caller did the work — at the + // SOURCE this offer names, which is the path itself unless the caller + // wrote `=` for this particular merge of it. A path that + // will not read is an error rather than a conflict: the caller + // asserted a resolution is there, and replaying their assertion as + // "still conflicted" would hide the typo in a verdict. + let source = resolutions + .get(path) + .map_or_else(|| std::path::PathBuf::from(path), Clone::clone); + let bytes = std::fs::read(dir.join(&source)).map_err(|err| { + anyhow::anyhow!( + "gitwrite: {path} carries no resolution to read at {}: {err}", + source.display() + ) + })?; + let blob = repo + .write_blob(bytes) + .map_err(|err| { + anyhow::anyhow!("gitwrite: {path}'s resolution will not write: {err}") + })? + .detach(); + outcome + .tree + .upsert(path.as_str(), gix::objs::tree::EntryKind::Blob, blob) + .map_err(|err| { + anyhow::anyhow!("gitwrite: {path}'s resolution will not place: {err}") + })?; + } + resolved = paths; } let tree = outcome @@ -365,11 +803,15 @@ fn replay( replayed .extra_headers .retain(|(name, _)| name.as_slice() != b"gpgsig"); - Ok(Step::Landed( - repo.write_object(&replayed) - .map_err(|err| anyhow::anyhow!("gitwrite: {original} will not rewrite: {err}"))? - .detach(), - )) + let minted = repo + .write_object(&replayed) + .map_err(|err| anyhow::anyhow!("gitwrite: {original} will not rewrite: {err}"))? + .detach(); + Ok(if resolved.is_empty() { + Step::Landed(minted) + } else { + Step::Resolved(minted, resolved) + }) } /// Bring the worktree from the tree of `was` to the tree of `now`. diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 44414ac27..c3d855651 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -7325,9 +7325,22 @@ fn protected_mutation(policy: &Policy, command: &str) -> Decision { // program alone, so a program that mutates under one subcommand or // behind one flag could only be declared as mutating under all of // them — which is why five write shapes could not be expressed. - if let Some(matched) = - crate::verbs::qualify(&policy.verbs, program, &tokens[index + 1..]) - { + // NORMALISED FOR THE MATCH TOO, and only the operands were + // (review of #848). `qualify` compares a row's `subcommand` + // against the first argument and runs `flag_matches` over the + // rest, and `flag_matches` rejects any suffix that is not `.` or + // `=` — so with `requires_flag = ["--in-place"]`, the call + // `(sed -e 's/a/b/' batten.toml --in-place)` tokenises the flag + // as `--in-place)`, the row does not apply, and the write to a + // protected path is allowed. The identical command without the + // parentheses refuses. That is CLOUD-1382's one-keystroke bypass + // surviving on the half of the walk that was not normalised. + let arguments: Vec<&str> = tokens[index + 1..] + .iter() + .copied() + .map(program_token) + .collect(); + if let Some(matched) = crate::verbs::qualify(&policy.verbs, program, &arguments) { // The OPERANDS as the program was handed them, with a // group's closing punctuation off (CLOUD-1382): measured, // `(rm batten.toml)` was allowed because the operand read @@ -8996,13 +9009,35 @@ fn is_shell_grammar(token: &str) -> bool { /// `VAR=value` env prefixes, then look through known wrapper programs so the /// wrapped program is judged, not the wrapper. Known wrappers only; anything /// unrecognised keeps the fail-open posture. +/// +/// # EVERY COMPARISON IS ON THE NORMALISED TOKEN, and only the last one was +/// +/// **CLOUD-1382's one-keystroke bypass survived on the prefix skips** (review of +/// #848). [`program_token`] was applied to the FINAL program alone, while the +/// env-assignment, look-through-wrapper and `mise exec` arms each compared the +/// raw token — so a single leading `(` defeated all three. Measured over the +/// compiled binary, each bare form denying and its grouped form allowed: +/// +/// | command | bare | grouped | +/// | ---------------------------------------- | ---- | ------- | +/// | `FOO=1 git push --force origin main` | deny | allowed | +/// | `nohup mise run ci &` | deny | allowed | +/// | `mise exec -- rm batten.toml` | deny | allowed | +/// +/// `(git push --force origin main)` denied correctly the whole time, which is +/// what hid this: the hole is only where a PREFIX token carries the paren, so +/// every case anyone thought to try was the one that worked. +/// +/// The index returned is still an index into the RAW `tokens`, because callers +/// normalise what they read off it themselves — normalising here is about which +/// arm is taken, never about what the answer points at. fn effective_program(tokens: &[&str]) -> Option { let mut i = 0; - while i < tokens.len() && is_env_assignment(tokens[i]) { + while i < tokens.len() && is_env_assignment(program_token(tokens[i])) { i += 1; } loop { - match *tokens.get(i)? { + match program_token(tokens.get(i)?) { // SHELL GRAMMAR BEFORE THE PROGRAM (CLOUD-1382), and the guard is // half the arm: stepping past requires a further token, so a // segment that is only grammar keeps the answer it already had. @@ -9010,7 +9045,7 @@ fn effective_program(tokens: &[&str]) -> Option { // so the prefix skip is repeated rather than done once at the top. grammar if is_shell_grammar(grammar) && i + 1 < tokens.len() => { i += 1; - while i < tokens.len() && is_env_assignment(tokens[i]) { + while i < tokens.len() && is_env_assignment(program_token(tokens[i])) { i += 1; } } @@ -9026,9 +9061,9 @@ fn effective_program(tokens: &[&str]) -> Option { // The wrapper's own flags, env assignments, and bare numeric // arguments (timeout's duration) precede the wrapped program. while i < tokens.len() - && (tokens[i].starts_with('-') - || is_env_assignment(tokens[i]) - || tokens[i].starts_with(|c: char| c.is_ascii_digit())) + && (program_token(tokens[i]).starts_with('-') + || is_env_assignment(program_token(tokens[i])) + || program_token(tokens[i]).starts_with(|c: char| c.is_ascii_digit())) { i += 1; } @@ -9036,8 +9071,8 @@ fn effective_program(tokens: &[&str]) -> Option { "mise" => { // Only `mise exec` / `mise x` run another program; `mise run` // names a task, which is the sanctioned surface. - match tokens.get(i + 1) { - Some(&("exec" | "x")) => { + match tokens.get(i + 1).map(|token| program_token(token)) { + Some("exec" | "x") => { i += 2; // Tool pins (node@22), flags, and the `--` separator // precede the program. @@ -10596,6 +10631,90 @@ mod tests { )); } + /// The six line-splitting behaviours CLOUD-1287 proved, re-asserted over the + /// PARSER's own split after `main` landed the same fix independently. + /// + /// This branch carried a hand-rolled `joined_lines`/`quote_after` pair that + /// re-split a segment's `raw` and tracked quote spans by character. `main` + /// replaced the tokenizer with [`rable`] and made the per-command split a + /// parse product — [`Segment::lines`], whose own doc names the re-splitting + /// walk as the thing it supersedes. Keeping both would be the second + /// AUTHORITY over one argv reading that `.claude/rules/policy-modules.md` + /// refuses, and the hand-rolled one is the weaker: a character scanner + /// against a bash grammar. + /// + /// So the implementation is deleted and the CASES move here, because the + /// behaviours are what was proved and they must not leave with it. Each + /// arm below is one of the six, in the order they were originally measured. + #[test] + fn the_parser_splits_lines_where_the_shell_does() { + fn lines(command: &str) -> Vec> { + match segments(command) { + crate::facts::Look::Is(parsed) => parsed + .iter() + .flat_map(|segment| segment.lines.iter()) + .map(|line| line.words.clone()) + .collect(), + other => panic!("{command:?} did not parse: {other:?}"), + } + } + + // A NEWLINE INSIDE A QUOTED SPAN IS NOT A LINE BOUNDARY. A commit + // message body is prose, and splitting there hands the walk prose as + // argv — measured as a `grep ` line in a message body + // refused as a tool substitution over a call that runs no `grep`. + let quoted = lines("git commit -m \"fix: thing\n\ngrep src/lib.rs\""); + assert_eq!(quoted.len(), 1, "a quoted body is one command: {quoted:?}"); + + // THE MIRROR, because a split that never splits is not a split. An + // unquoted newline IS a boundary — that is the whole of CLOUD-1287, and + // without this the arm above is satisfied by never splitting at all. + assert_eq!( + lines("cd /tmp\nrm batten.toml"), + vec![ + vec!["cd".to_owned(), "/tmp".to_owned()], + vec!["rm".to_owned(), "batten.toml".to_owned()], + ] + ); + + // A SPAN CLOSED ON A LATER LINE ENDS THERE, so the shell following a + // multi-line argument is judged rather than swallowed. + let closes = lines("git commit -m \"one\ntwo\"\nrm batten.toml"); + assert_eq!(closes.len(), 2, "{closes:?}"); + assert_eq!(closes[1], vec!["rm".to_owned(), "batten.toml".to_owned()]); + + // A BACKSLASH IS LITERAL INSIDE `'…'`, which is where `'` and `"` differ + // in every shell — so a trailing backslash must not swallow the closing + // quote and join the next line. + let single = lines("echo 'a\\'\nrm batten.toml"); + assert_eq!(single.len(), 2, "the span closed: {single:?}"); + + // AN APOSTROPHE IN A `#` COMMENT IS NOT AN OPEN SPAN. `#` in word + // position ends the shell for the rest of the line, so bash runs line + // two as its own command; reading it as an open quote joined line two's + // operands onto `echo` and `protected_mutation` saw prose where a + // protected path should have been. + let comment = lines("echo hi # don't do it\nrm batten.toml"); + assert_eq!(comment.len(), 2, "the comment is not a span: {comment:?}"); + assert_eq!(comment[1], vec!["rm".to_owned(), "batten.toml".to_owned()]); + + // AND A `#` MID-WORD IS AN ORDINARY CHARACTER, or the arm above is + // satisfied by treating every `#` as a comment. + let midword = lines("echo 'a#b\nc'\nrm batten.toml"); + assert_eq!( + midword.len(), + 2, + "`#` inside a word opens nothing: {midword:?}" + ); + + // `$'…'` IS ANSI-C QUOTING, WHERE A BACKSLASH ESCAPES. Read as a plain + // single-quoted span, `$'it\'s'` closed on the `\'` and RE-OPENED on the + // final quote, joining every following line. + let ansi = lines("echo $'it\\'s'\nrm batten.toml"); + assert_eq!(ansi.len(), 2, "the ANSI-C span closed: {ansi:?}"); + assert_eq!(ansi[1], vec!["rm".to_owned(), "batten.toml".to_owned()]); + } + /// The program-only row again, this time carrying the mediator requirement /// (CLOUD-271). Same row shape as `program_only_shape_policy`, one key more, /// so the pair of policies isolates what the key changes. diff --git a/crates/batten/src/land.rs b/crates/batten/src/land.rs index dec0a2a5c..6ca11c3d7 100644 --- a/crates/batten/src/land.rs +++ b/crates/batten/src/land.rs @@ -100,7 +100,23 @@ impl Replay { // list would need a separator this format does not have. The // count is not lost: the caller reports it, and the module's job // is to say WHERE to look first rather than to enumerate. - let path = paths.first().map_or("-", String::as_str); + // **WHITESPACE IS COLLAPSED, because the format is columnar and + // a path may carry a space** (review of #848). The module reading + // this requires `count(columns) == 4`, so `docs/my notes.md` made + // FIVE and the line was dropped from `replays` entirely — + // `last_replay` fell back to the previous lap's clean line and + // `rebase-conflict-stops-the-lap` reported clean over the lap's + // one human stop. A dropped line and a clean tree are + // byte-identical on the decision surface, which is the shape this + // repository refuses everywhere. + // + // Collapsed rather than quoted: the reader splits on spaces and + // has no unquoting, so a quote would move the defect rather than + // remove it. The path is a POINTER — what a reader opens — and + // `_` keeps it one column and still legible. + let path = paths + .first() + .map_or_else(|| String::from("-"), |path| columnar(path)); format!("rebase conflicted {commit} {path}") } Self::Current => String::from("rebase current - -"), @@ -109,6 +125,16 @@ impl Replay { } } +/// One column's worth of `value`: whitespace collapsed so it cannot become two. +/// +/// The record is space-separated with a fixed column count, and its readers +/// enforce that count precisely so a re-columned line is not read through a +/// shifted lens. A value carrying a space breaks that silently — the line is +/// skipped, and a skipped line reads as no finding. +fn columnar(value: &str) -> String { + value.split_whitespace().collect::>().join("_") +} + /// Bring `reference` forward from `remote` into this clone, and answer where it /// now points. /// @@ -117,11 +143,22 @@ impl Replay { /// move. A ref moved before its objects landed names a commit this clone cannot /// read, which is a corrupt clone rather than a failed fetch. /// +/// **`pub(crate)` for that ordering rather than for reuse.** The bet's liveness +/// reading needs the holder's current tip in this clone's odb, which is the same +/// three steps in the same order; a second spelling of them would be a second +/// place to get the ordering wrong, over the one ref whose corruption nobody +/// would notice until an ancestry answer came back false. +/// /// # Errors /// /// A transport failure, a reference the remote does not advertise, or an odb that /// will not take the objects. -fn advance(root: &Path, remote: &str, reference: &str, tracking: &str) -> Result { +pub(crate) fn advance( + root: &Path, + remote: &str, + reference: &str, + tracking: &str, +) -> Result { let fetched = crate::lease::fetch(remote, root, reference) .with_context(|| format!("land: fetch {reference} from the remote"))?; // EMPTY IS NOT A FAILURE — `Fetched::objects` is empty when the odb already @@ -132,9 +169,95 @@ fn advance(root: &Path, remote: &str, reference: &str, tracking: &str) -> Result .with_context(|| format!("land: write the objects {reference} brought"))?; gitwrite::set_ref(root, tracking, &fetched.head) .with_context(|| format!("land: move {tracking} to the fetched head"))?; + prune(root, &fetched.advertised); Ok(fetched.head) } +/// The tracking prefix this clone keeps the landing remote's refs under. +/// +/// One spelling, because [`tracking_ref`] writes into it and [`prune`] deletes +/// out of it: a prune keyed to a different prefix than the writer uses either +/// deletes nothing or deletes the writer's own refs. +/// +/// **THE PREFIX AND THE ADVERTISEMENT MUST NAME THE SAME REMOTE** (review of +/// #848). This was a `const` reading `origin` while `advance` fetched from +/// `$LAND_LOCK_REMOTE`, so a consumer whose landing remote is not `origin` had +/// its FIRST lap delete every `origin/*` tracking ref that other remote does not +/// advertise — a prune is destructive, so the mismatch is not a no-op in the +/// forgiving direction. [`crate::lease::remote_name`] is the one authority on +/// which remote that is, and every sibling already reads it. +fn tracking_prefix() -> String { + format!("refs/remotes/{}/", crate::lease::remote_name()) +} + +/// Delete the tracking refs the remote no longer advertises. +/// +/// **THE PRODUCTION CALLER `stale_tracking` DID NOT HAVE** (review of #848). The +/// predicate was ported, tested and left unreached, so the predecessor's +/// `fetch --prune` had no successor at all — and the failure it prevents is +/// permanent rather than transient: a stale `origin/` makes +/// `--force-with-lease` reject forever, because the lease compares against a ref +/// naming a commit the remote deleted. +/// +/// Here rather than in the driver because this is where the advertisement is, +/// and reading a second one to prune against would let the prune act on a staler +/// answer than the fetch it belongs to. +/// +/// **NOTHING HERE IS FATAL, and that is the same posture `unwind_lap` takes.** +/// The caller is mid-replay with a moved base; a tracking ref that would not +/// list or would not delete must not replace that with an error. The next lap +/// takes another advertisement and tries again. +fn prune(root: &Path, advertised: &[String]) { + let Ok(local) = crate::git::refs(root) else { + return; + }; + for reference in stale_tracking(&local, advertised, &tracking_prefix()) { + let _ = gitwrite::delete_ref(root, &reference); + } +} + +/// The remote-tracking refs this clone holds that the remote no longer +/// advertises. +/// +/// **THE PRUNE, and it is load-bearing for a reason the predecessor measured +/// rather than assumed** (CLOUD-1338, conserving `fetch_main`'s `--prune`). A +/// stale `origin/` left behind after a merge makes `--force-with-lease` +/// reject **permanently**: the lease compares against this clone's tracking ref, +/// and a ref naming a commit the remote deleted can never match what the remote +/// now has. +/// +/// Measured 2026-08-11, and it misled three readers at once: the push ("someone +/// else moved the branch" — nobody had), the harness stop hook ("20 unpushed +/// commits" on a branch whose true unlanded set was 1), and the lander's own +/// post-merge delete ("already gone, or the remote refused" — both halves were +/// live). +/// +/// It belongs on the landing path rather than in each reader, and the +/// predecessor says why: the readers are not all ours, and only the loop knows +/// when the ref went stale. +/// +/// **Pure, over two listings the caller already took.** The decision is a set +/// difference; taking it here means it tests without a remote, which is the same +/// split [`worthless`] and [`closes_the_tap`] make. [`prune`] is the caller — +/// purity is what makes this testable, never a reason to leave it unreached. +#[must_use] +pub fn stale_tracking(local: &[String], advertised: &[String], prefix: &str) -> Vec { + local + .iter() + .filter(|reference| reference.starts_with(prefix)) + .filter(|reference| { + // THE BRANCH NAME, not the tracking name: the remote advertises + // `refs/heads/x` where this clone holds `refs/remotes/origin/x`, and + // comparing the two spellings directly prunes everything. + let branch = reference.trim_start_matches(prefix); + !advertised + .iter() + .any(|head| head.trim_start_matches("refs/heads/") == branch) + }) + .cloned() + .collect() +} + /// One lap's replay: advance the base, replay the branch onto it, record it. /// /// # Errors @@ -143,12 +266,26 @@ fn advance(root: &Path, remote: &str, reference: &str, tracking: &str) -> Result /// a replay that could not be attempted. **A conflict is not an error** — it is /// [`Replay::Conflicted`], and reporting it as a failure is what would let a /// caller's `?` turn the loop's one human stop into a stack trace. -pub fn replay(root: &Path, remote: &str, reference: &str, branch: &str) -> Result { +/// `resolutions` names the paths a PERSON merged in the worktree, and is empty +/// for every lap: the driver passes `&[]` so an unattended loop can never apply +/// one, which is [`crate::gitwrite::rebase_resolving`]'s whole precondition. +pub fn replay( + root: &Path, + remote: &str, + reference: &str, + branch: &str, + resolutions: &[String], +) -> Result { let tracking = tracking_ref(reference); advance(root, remote, reference, &tracking)?; - let outcome = gitwrite::rebase(root, &format!("refs/heads/{branch}"), &tracking) - .with_context(|| format!("land: replay {branch} onto {tracking}"))?; + let outcome = gitwrite::rebase_resolving( + root, + &format!("refs/heads/{branch}"), + &tracking, + resolutions, + ) + .with_context(|| format!("land: replay {branch} onto {tracking}"))?; let replayed = match outcome { Rebase::Conflicted { commit, paths } => Replay::Conflicted { commit, paths }, Rebase::Current => Replay::Current, @@ -169,9 +306,34 @@ pub fn replay(root: &Path, remote: &str, reference: &str, branch: &str) -> Resul /// a function rather than formatted at the call site so the one place that /// decides this is greppable, and so a caller cannot pass a tracking ref where a /// remote one belongs. -fn tracking_ref(reference: &str) -> String { - let leaf = reference.rsplit('/').next().unwrap_or(reference); - format!("refs/remotes/origin/{leaf}") +/// **THE `refs/heads/` PREFIX, NEVER THE LAST SEGMENT** (review of #848). This +/// read `reference.rsplit('/').next()`, which is the leaf — so a consumer whose +/// trunk is `release/1.x` got `refs/remotes/origin/1.x`, and `advance` then wrote +/// the trunk's head into the tracking ref belonging to an unrelated branch named +/// `1.x` while `stale` compared this branch's freshness against it. A slashed +/// branch name is ordinary, not exotic. +/// +/// `lease::lands_by_fast_forward` states the rule in this same crate — "ONE +/// PREFIX, NEVER EVERY LEADING ONE" — and uses `strip_prefix` for it. A short +/// name passes through unchanged, which is what the driver hands in. +pub(crate) fn tracking_ref(reference: &str) -> String { + format!("{}{}", tracking_prefix(), short_ref(reference)) +} + +/// A branch's SHORT name — the whole of it, slashes included. +/// +/// Extracted rather than repeated because it has two readers and they must not +/// disagree about which trunk they name: [`tracking_ref`] builds the local ref, +/// and the driver's staleness config builds the endpoint path +/// (`git/ref/heads/`). Both were `rsplit('/')` once and the fix reached +/// only one of them, so a consumer on `release/1.x` had a tracking ref pointing +/// at an unrelated `1.x` AND a staleness poll asking the forge for a ref that +/// does not exist — one defect, two spellings, and only the first was found. +pub(crate) fn short_ref(reference: &str) -> &str { + reference + .strip_prefix("refs/heads/") + .unwrap_or(reference) + .trim_start_matches('/') } /// Append this lap's outcome to the branch's record. @@ -316,6 +478,21 @@ pub enum Waited { /// The forge's verdict, as a token. verdict: String, }, + /// The green question answered, and the answer was no. + /// + /// **THE ARM THAT WAS MISSING, and its absence was not a gap in the type but + /// an hour of runner time per lap.** The green arm broke only on + /// `Verdict::Green`, so a red required check was indistinguishable from a + /// pending one: the loop asked its whole count — 3600 by default — and then + /// reported [`Waited::Unanswered`], which laps. A branch with one failing + /// test therefore bought a fresh matrix per lap and waited out an hour on + /// each. The predecessor stopped on red, and `tests/land.bats` says so in as + /// many words: *"red CI stops the lap without asking for the merge"*. + Red { + /// Which required checks failed, as pointers. Never a log line — + /// [`crate::checks_green::Finding`] has nowhere to put one. + findings: Vec, + }, /// The staleness question answered first: the base moved, so the run in /// flight is already spend for a verdict nobody will read. Stale { @@ -330,50 +507,52 @@ pub enum Waited { Unanswered, } -/// Ask both questions until one answers. +/// Ask both questions concurrently and take whichever answers first. /// -/// # The race, and why this alternates rather than forking +/// # The race is a race /// -/// The two questions are asked in ONE loop, one after the other, and the first -/// to answer returns. That is not a weaker form of the concurrent race it -/// replaces — it is a stronger form of the property that race exists for. +/// Two arms, each its own conditional poll, run in a [`std::thread::scope`] and +/// report through one channel; the first message decides the lap. `ci-wait ∥ +/// main-watch` is what the predecessor did and the reason is economic: the +/// moment `main` advances, the run in flight is already waste — its verdict +/// cannot be used, the fast-forward bot will refuse, and every remaining second +/// of that run is billed. Learning that only after the green arm's round trip +/// costs a lap. /// -/// **The loser's answer is voided by construction here.** Two pollers running -/// concurrently both produce answers, and voiding the loser's is then something -/// the caller has to remember to do; alternating means the loser is simply never -/// asked again once the winner has spoken. A lap physically cannot read both. +/// **AN EARLIER REVISION OF THIS FUNCTION ALTERNATED THE ARMS IN ONE LOOP AND +/// ARGUED THAT WAS STRICTLY BETTER. IT WAS NOT.** The argument ran: a scoped join +/// would hang on the loser and a detached one would keep spending rate limit, so +/// alternating is the only shape available without a second authority. Both +/// halves are answered by the shape below rather than by taste. The loser is not +/// joined-while-blocked: it checks [`std::sync::atomic::AtomicBool`] at its own +/// interval boundary, which is a bounded wait it was already taking, so the scope +/// closes without hanging. And it spends nothing after the winner speaks, +/// because it stops asking — the flag is read before the request, never after. /// -/// It is also the only shape available without a second authority. `pr_watch`'s -/// own loop polls until ITS question answers, so racing it in a thread would -/// leave the loser running with nobody able to stop it — a scoped join would -/// hang on it, and a detached one would keep spending the forge's rate limit -/// after the lap had moved on. -/// -/// The cost is one extra round trip per cycle, and the staleness arm is free of -/// the metered tier entirely: it is ref discovery over the engine's own client, -/// so it spawns nothing and asks the forge's API for nothing. +/// The alternating loop also serialised the two round trips, so the staleness +/// answer was always at least one green-arm round trip late. That is the latency +/// the race exists to remove. /// /// # The bound is a COUNT /// -/// `asks` is how many times the pair is asked, never a deadline. A wall clock -/// would reintroduce the VM-reap gap `mem:workflow/landing-loop` records, and -/// `clippy.toml`'s timer ban is the mechanism that refuses one in this crate. -/// The delay between asks is the server's own interval — a derived delay bounded -/// by a real exit condition, which is the shape `run-shape-guard` admits. +/// `asks` is how many times each arm asks, never a deadline. A wall clock would +/// reintroduce the VM-reap gap `mem:workflow/landing-loop` records, and +/// `clippy.toml`'s timer ban is the mechanism that refuses one in this crate. The +/// delay between asks is the server's own interval — a derived delay bounded by a +/// real exit condition, which is the shape `run-shape-guard` admits. /// /// # Errors /// -/// Only for a stream that will not accept output. **Every failure to reach -/// either the forge or the remote is a could-not-look**: the arm reports nothing -/// that cycle and the pair is asked again, because a lap that concluded from an -/// unreachable forge would decide about the network rather than about the work. +/// Only for a stream that will not accept output. **Every failure to reach the +/// forge is a could-not-look**: the arm reports nothing that cycle and asks +/// again, because a lap that concluded from an unreachable forge would decide +/// about the network rather than about the work. pub fn wait( config: &crate::pr_watch::Config, roster: &crate::checks_green::Roster, - remote: &str, - reference: &str, - base: &str, + trunk: &crate::main_watch::Config, asks: u32, + heartbeat: &(dyn Fn() + Sync), out: &mut dyn std::io::Write, ) -> Result { writeln!( @@ -381,38 +560,120 @@ pub fn wait( "land: waiting on {} — green or stale, whichever answers first", config.sha )?; - let mut poll = crate::pr_watch::Poll::default(); - for _ in 0..asks { - // ARM ONE: is this commit green? The conditional read is `pr_watch`'s, - // so the argv, the client and the empty-string-on-failure posture stay - // its business and this loop only decides when to ask. - let raw = crate::pr_watch::read(config, poll.etag()); - let interval = poll.absorb(&raw, config.interval); - if let Ok(crate::checks_green::Verdict::Green) = - crate::checks_green::decide(poll.runs(), roster) - { - return Ok(Waited::Green { - verdict: String::from("green"), - }); - } - // ARM TWO: has the base moved out from under it? Ref discovery over the - // engine's own client — no forge API, no `gh`, no `git`, and nothing - // against the metered tier. - if let Ok(advertisement) = - crate::lease::advertise(remote, crate::lease::Service::UploadPack) - { - let now = advertisement.head_of(reference); - if now != base { - return Ok(Waited::Stale { - base: now.to_owned(), - }); + // ONE CHANNEL, NOT TWO RETURN VALUES. The first message IS the verdict, so + // the loser's answer is voided by construction rather than by the caller + // remembering to drop it — the property the alternating loop was defending, + // kept. + let (tx, rx) = std::sync::mpsc::channel::(); + let decided = std::sync::atomic::AtomicBool::new(false); + + let waited = std::thread::scope(|scope| { + let green = tx.clone(); + let stop = &decided; + drop(scope.spawn(move || { + let mut poll = crate::pr_watch::Poll::default(); + for _ in 0..asks { + if stop.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + // The conditional read is `pr_watch`'s, so the argv, the client + // and the empty-string-on-failure posture stay its business and + // this arm only decides when to ask. + let raw = crate::pr_watch::read(config, poll.etag()); + let interval = poll.absorb(raw.as_ref(), config.interval); + // THE HOLDER'S HEARTBEAT, AND THIS LOOP IS THE ONLY PLACE THAT + // SPENDS REAL TIME (review of #848). A lap acquires its lease + // once and then waits out a whole matrix here, so without a beat + // the TTL lapses mid-wait and a rival takes a lease this process + // still believes it holds. The predecessor backgrounded a + // heartbeat process; this is that obligation, in the one loop + // whose iterations are seconds rather than microseconds. + // + // A CALLBACK RATHER THAN A `lease` CALL, because `land` must not + // depend on `lease` — `module-layering` refuses the edge, and + // the caller is where the terms and this clone's identity + // already live. It decides its own cadence; this only says WHEN + // there is time to spend. + heartbeat(); + // AND THE SAME NEVER-ANSWERED BOUND `pr_watch::watch` TAKES + // (review of #848). A credential the forge refuses answers a + // could-not-look to every request, so this arm would spend its + // whole ask count — 3600 by default — learning nothing, while + // the lap holds the lease and the operator sees one line. Left + // to the `Unanswered` verdict, which is what the lap already + // laps on, so the shape of the refusal is unchanged. + if poll.unanswered_from_the_start() >= crate::pr_watch::UNANSWERED_BEFORE_REFUSING { + stop.store(true, std::sync::atomic::Ordering::Relaxed); + drop(green.send(Waited::Unanswered)); + return; + } + // BOTH TERMINAL ANSWERS END THE WAIT, not only the one the lap + // hopes for. Breaking on `Green` alone made a red head + // byte-identical to a pending one for the whole ask count. + match crate::checks_green::decide(poll.runs(), roster) { + Ok(crate::checks_green::Verdict::Green) => { + stop.store(true, std::sync::atomic::Ordering::Relaxed); + drop(green.send(Waited::Green { + verdict: String::from("green"), + })); + return; + } + Ok(crate::checks_green::Verdict::Red(findings)) => { + stop.store(true, std::sync::atomic::Ordering::Relaxed); + drop(green.send(Waited::Red { findings })); + return; + } + // Pending is the state this loop exists to sit in, and a + // roster that cannot decide was refused before the loop. + Ok(crate::checks_green::Verdict::Pending(_)) | Err(_) => {} + } + // INTERRUPTIBLE, because the OTHER arm may answer during this + // wait and `thread::scope` joins this one before the verdict can + // be acted on. Checking the flag only at the loop head held a + // finished landing for a whole interval — survivable at the + // poll's one second, and not once `wait_for` began honouring a + // rate-limit backoff measured in minutes (review of #848). + crate::pr_watch::pause_until(interval, stop); } - } + })); - crate::pr_watch::pause(interval); - } - Ok(Waited::Unanswered) + let stale = tx.clone(); + drop(scope.spawn(move || { + let mut poll = crate::main_watch::Poll::default(); + for _ in 0..asks { + if stop.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + let raw = crate::main_watch::read(trunk, poll.etag()); + let interval = poll.absorb(raw.as_ref(), trunk.interval); + if let Some(moved) = poll.moved(&trunk.base) { + stop.store(true, std::sync::atomic::Ordering::Relaxed); + drop(stale.send(Waited::Stale { + base: moved.to_owned(), + })); + return; + } + // `pr_watch`'s pause deliberately, not a second one: there is one + // sleep in this crate and it carries the one `disallowed_methods` + // exemption, so a second arm cannot grow a timer of its own. + // + // Interruptible for the reason the sibling arm states: this arm + // loses the race most of the time, and holding the scope open + // through its last interval delays every verdict the other one + // reaches. + crate::pr_watch::pause_until(interval, stop); + } + })); + + // THE LAST LIVE SENDER MUST BE DROPPED OR THE RECV BELOW NEVER RETURNS. + // Both arms exhausting `asks` without answering closes their clones; this + // one is the outer handle and would keep the channel open forever. + drop(tx); + rx.recv().ok() + }); + + Ok(waited.unwrap_or(Waited::Unanswered)) } /// Both arms of one wait, from whichever of them answered. @@ -554,7 +815,64 @@ pub enum Verified { Clean(String), /// The gate refused. The lap stops here — this is a failed `verify`, which /// the design names as one of the three things that end a lap. - Refused(String), + Refused { + /// The head the gate was run over. + sha: String, + /// What KIND of refusal it was, which decides the advice. + cause: Refusal, + }, +} + +/// What kind of refusal a gate returned. +/// +/// # A refusal is not always about the tree, and reporting it as one misattributes +/// +/// The lap's stop used to be one unconditional line. Two of the sentences it +/// carried are wrong for a refusal the branch did not cause, and both were +/// measured rather than imagined (CLOUD-861): a `verify` killed by a full disk +/// was reported as a defect to reproduce, over a tree with nothing wrong in it. +/// *"Reproduce and fix locally"* is correct advice for [`Self::Tree`] and a wasted +/// cycle for [`Self::Environment`]. +/// +/// # It does NOT reach the record +/// +/// [`Verified::line`] is byte-identical across both arms. The record is a +/// predicate's input and stays pointer-only; the cause is advice to an operator, +/// which is a different channel with a different reader. Widening the record to +/// carry it would put a test runner's diagnosis where a module looks for a +/// verdict — and `crates/batten/tests/it/land_verify_advice.rs` pins the two +/// lines equal so the next reader cannot helpfully merge them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// The gate refused about this tree. The ordinary case, and the one whose + /// advice was always right. + Tree, + /// **`main` MOVED UNDER THE RUN, WHICH LAPS RATHER THAN STOPPING** + /// (CLOUD-318, and the port did not carry it). + /// + /// `verify` reserves exit `2` for exactly this and says so on its own error + /// line — *"main moved under this branch — rebase and verify again, there is + /// nothing here to fix"* — and the consumer's task manifest states the other + /// half in as many words: *"`land` reads a 2 from `verify` as 'main moved + /// under the run, lap'"*. The engine read it as [`Self::Tree`], because every + /// non-zero fell through to the pattern scan and an empty scan is `Tree`. + /// + /// So the one refusal class the predecessor measured as SELF-HEALING became + /// the loop's hardest stop, and it stopped with the advice for a defect the + /// branch does not have. Measured on #240 before the port: run 1 died here, + /// run 2 landed in three laps with zero edits between them. + /// + /// It carries no remedy because there is nothing for an operator to do — the + /// next lap's replay IS the remedy, which is what makes this a lap rather + /// than a stop. + Moved, + /// The gate died of something that is not this branch's doing, matching a + /// declared `[[verify_environment_pattern]]`. + Environment { + /// The consumer's own remedy, from the matching row's `reason`. Never + /// composed here: which reclaim to run is that repository's vocabulary. + remedy: String, + }, } impl Verified { @@ -567,11 +885,17 @@ impl Verified { /// POINTER-ONLY. The gate's own output is not carried at any width — it went /// to the caller's terminal where it belongs, and a record read by a /// predicate is no place for a test runner's stdout. + /// + /// **AND THE CAUSE DOES NOT REACH IT EITHER.** Both [`Refusal`] arms render + /// the same four columns, deliberately: a module deciding over this store + /// asks whether the head verified, and a second verdict column would be a + /// diagnosis it has no way to act on. The advice goes to the operator, on + /// stderr, where a person reads it. #[must_use] pub fn line(&self) -> String { match self { Self::Clean(sha) => format!("verify clean {sha} -"), - Self::Refused(sha) => format!("verify refused {sha} -"), + Self::Refused { sha, .. } => format!("verify refused {sha} -"), } } } @@ -582,184 +906,2055 @@ impl Verified { /// An empty one is a usage error rather than a default, because guessing a /// consumer's gate would put that consumer's vocabulary in this crate. /// +/// `published` reaches the gate's environment and is the caller's too. The +/// landed use is `speculation::PUBLISHED_AS`: a gate cannot otherwise tell a +/// commit this branch authored from one the lap speculatively adopted, and +/// CLOUD-748 measured the consequence twice in one session — the consumer's own +/// race check reported the waiter as racing the very PR the bet was placed on. +/// The NAME is the consumer's and nothing in this crate reads it back. +/// /// # Errors /// /// An empty command, a HEAD this clone cannot resolve, or a boundary that cannot /// start the program. A gate that RAN and refused is [`Verified::Refused`], not /// an error: that is an answer about the tree. -pub fn verify(root: &Path, branch: &str, command: &[String]) -> Result { +pub fn verify( + root: &Path, + branch: &str, + command: &[String], + published: &[(String, String)], + environment: &[crate::outputs::OutputPattern], +) -> Result { if command.is_empty() { return Err(crate::error::UsageError::raise(String::from( "land: no verify command is configured, and this engine does not know what verifying means here", ))); } let head = crate::git::head_commit(root).context("land: read this clone's HEAD")?; + // THE RESOLVED ROOT, NEVER THE ANCHOR — and this is the only call + // `exec::run_in` has, so getting it wrong made the verb unreachable rather + // than merely awkward. `exec`'s capture store is keyed by the repository's + // own directory NAME, which `state::derive_repo_name` cannot read off `.`; + // every caller above anchors at `.`, so handing that straight through raised + // "cannot derive a repository name from ." on EVERY invocation in every + // clone. `exec::run_with` resolves at its own site for exactly this reason, + // as do `admission::store_dir` and `lib::run_board`. This was the fourth + // site and the only one that did not. + // + // IT WAS INVISIBLE TWICE OVER, which is why the fix is a resolution here + // rather than a message: the refusal is a `UsageError`, so `main`'s reporter + // prints one clean line and drops the chain, and the `None` arm below then + // wrapped it in a context naming the gate — so a boundary that never started + // the program read as the program having run and failed. `LAND_VERIFY=true` + // and `LAND_VERIFY=false` were byte-identical. + let started = crate::git::repo_root(root).context("land: resolve this clone's root")?; // A REFUSAL TRAVELS AS AN ERROR THROUGH THIS BOUNDARY, because `exec` exists // to pass a child's status through to the caller. Here it is an ANSWER, so // the two are told apart rather than collapsed: a code that came back at all // is the gate speaking, and only a failure to START is this lap's problem. - let verified = match crate::exec::run_in(root, command) { - Ok(crate::exit::ExitCode::Success) => Verified::Clean(head), - Ok(_) => Verified::Refused(head), - Err(problem) => match problem.downcast_ref::() { - Some(_) => Verified::Refused(head), - None => return Err(problem.context("land: run the configured verify command")), + // TEE, AND THIS LINE IS A FIX RATHER THAN A SETTING. `Verified`'s own header + // says the gate's output "went to the caller's terminal where it belongs" — + // which was FALSE for this call's whole life. `run_in_env` takes + // `ExecConfig::DEFAULT`, whose `tee` is `false` by CLOUD-429's design, so the + // child's bytes went to the capture store and nowhere a person could see + // them. An operator whose lap stopped on a refused gate was told the gate + // refused and shown nothing about why. + // + // CLOUD-429's default is right for `batten exec`, where the bytes are + // addressable and the caller can go and read them. It is wrong here: the lap + // is interactive, it has just stopped, and the reason is the next thing its + // caller needs. + // AND THE GROUP IS MANAGED, WITHOUT WHICH THE RACE CANNOT CANCEL ANYTHING + // (CLOUD-1586). `ExecConfig::DEFAULT` leaves `manage_process_group` false, so + // `GroupDecision::observe` answered false, `GroupRecord::write` wrote no + // `group.` note, and `exec::cancel_owned_group` therefore returned + // `false` on every call — `verify_raced`'s watcher won its race and reclaimed + // NOTHING, which is the pre-CLOUD-423 behaviour wearing the new mechanism's + // name. The second lap-record line was there to make that visible and would + // have read `false` forever. + // + // A gate is `mise` running `hk` running `cargo`, so the group is exactly the + // right unit to cancel: the whole tree or none of it. This is the one caller + // that asks for it, which is why the flag is set here rather than moved into + // `DEFAULT` — every other `exec` caller's topology is unchanged. + let settings = crate::exec::ExecConfig { + tee: true, + manage_process_group: true, + ..crate::exec::ExecConfig::DEFAULT + }; + // CLASSIFIED, NEVER PROMOTED. `run_in_with_env` scans patterns only on a + // `0`, because its question is whether a green run is lying; the question + // here is what kind of failure a red one was, and `classify_in_env` is the + // sibling that answers it. Both readings from one function would produce + // opposite verdicts over identical bytes. + let verified = match crate::exec::classify_in_env( + &started, + command, + environment, + &settings, + published, + ) { + Ok((0, _)) => Verified::Clean(head), + // **THE EXIT CODE IS READ BEFORE THE PATTERNS, and it was not read at + // all** (review of #848). Every non-zero became `Refusal::Tree` + // unless a declared `[[verify_environment_pattern]]` happened to + // match, so a renamed task, a missing runner or a gate that answered + // could-not-look were all recorded as `verify refused ` and the + // driver printed the speculative-base advice as though the gate had + // judged this tree. A consumer cannot be asked to write a pattern for + // "the program was not there": there is no output to match on. + // + // Three codes say the gate did not judge, and none of them is a + // verdict. `3` is this engine's own could-not-look (house-style §7), + // and `126`/`127` are the shell's — not executable, and not found. + // Everything else reaches the pattern scan exactly as before, so a + // declared row still classifies a `1` or a `2` that names a + // disk-full or a rate limit. + // EXIT 2 IS "MAIN MOVED", AND IT IS READ BEFORE THE PATTERNS FOR THE SAME + // REASON THE THREE BELOW ARE (CLOUD-318). `verify` reserves this code for + // that one verdict and the consumer's task manifest declares both halves; + // reaching the pattern scan meant an empty scan classified it `Tree`, so + // the self-healing class became the loop's hardest stop. A consumer + // cannot be asked to write a pattern for it either — the remedy is a lap, + // not a message. + Ok((2, _)) => Verified::Refused { + sha: head, + cause: Refusal::Moved, }, + Ok((code, _)) if matches!(code, 3 | 126 | 127) => Verified::Refused { + sha: head, + cause: Refusal::Environment { + remedy: format!( + "the verify command exited {code}, which is not a verdict about this tree — check that it names a task this checkout declares and that its runner is on PATH" + ), + }, + }, + Ok((_, found)) => Verified::Refused { + sha: head, + // THE FIRST DECLARED ROW THAT MATCHED, in declaration order, and one + // remedy rather than a concatenation: `outputs::reasons` already + // dedupes per pattern for the reason it states — a tool that emitted + // the same warning forty times should say what to do about it once. + // An empty set is `Tree`, which is the common case and the one whose + // advice was always right. + cause: crate::outputs::reasons(environment, &found).first().map_or( + Refusal::Tree, + |remedy| Refusal::Environment { + remedy: remedy.clone(), + }, + ), + }, + Err(problem) => return Err(problem.context("land: run the configured verify command")), }; append(root, branch, std::slice::from_ref(&verified.line()))?; Ok(verified) } -#[cfg(test)] -mod tests { - use super::*; +/// The gate, raced against the base it was asked about (CLOUD-423's other half). +/// +/// # What this reclaims, and why it is not [`stale`] +/// +/// [`stale`] saves the METERED half — the matrix and the fast-forward behind it — +/// by discarding a gate's verdict after the fact. The gate's own minutes were +/// still spent, and this repository measured that at **~45% of laps paying a full +/// gate to discover trunk had moved**. Where a gate ran ~220s that was an +/// annoyance; measured on this container it is ~25 minutes, and ten consecutive +/// laps of it is a session that lands nothing. **The cost model that called local +/// execution free is what made the partial port look complete**, and it is wrong +/// here rather than wrong in principle. +/// +/// # THE GATE IS A SPAWN, NOT A POLL, which is the sentence that kept this open +/// +/// [`stale`]'s doc named the blocker exactly: *"applying it to the GATE is a +/// different problem — the gate is a spawn, not a poll."* The missing piece was +/// never the race, it was a handle on the child. [`crate::exec`] records the +/// process group it owns under the state dir, keyed by THIS process's pid, so a +/// sibling thread can read the pgid its own gate is running under and signal it. +/// `terminate_group` then `escalate_group` is the pair `land.sh`'s +/// `kill -- -$v_pid -$vm_pid` spelled with the grace period CLOUD-434 added. +/// +/// # NO NEW TIMER, WHICH IS A CONSTRAINT RATHER THAN A CONVENIENCE +/// +/// This is [`wait`]'s shape reused, deliberately: one `thread::scope`, one +/// channel, `recv` taking whichever arm answers first, and one `stop` flag both +/// arms read. The watcher's pause is [`crate::pr_watch::pause_until`] — *"there +/// is one sleep in this crate and it carries the one `disallowed_methods` +/// exemption, so a second arm cannot grow a timer of its own."* Growing one here +/// would also trip `delay-waivers-not-growing`, whose own `no_fix_reason` states +/// the alternative this takes: a delay with a bound it can exit on needs no +/// waiver. +/// +/// **The watcher's loop is bounded by the gate, not by an ask count**, and that +/// is the difference from [`wait`]'s arms. A watcher outliving its gate would be +/// the unbounded loop CLOUD-1338 refuses; here the sibling's completion sets +/// `stop`, which is a real terminal state rather than a guess at one. +/// +/// # Errors +/// +/// The gate's own errors, unchanged — a boundary that could not start the program +/// is still this lap's problem and a code that came back is still the gate +/// speaking. A watcher that cannot reach the forge answers could-not-look and +/// simply never wins, which leaves the gate's verdict standing: the fail-open +/// direction, because a forge this lap cannot read is not evidence the base moved. +pub fn verify_raced( + root: &Path, + branch: &str, + command: &[String], + published: &[(String, String)], + environment: &[crate::outputs::OutputPattern], + trunk: &crate::main_watch::Config, + reference: &str, +) -> Result { + /// Which arm answered. The gate's `Result` travels whole so a boundary + /// failure stays a boundary failure rather than becoming a refusal. + enum Raced { + Gate(Result), + Moved(String), + } - /// The verify family writes the same four columns every other family does. - #[test] - fn every_verify_outcome_writes_four_columns_led_by_the_kind() { - for outcome in [ - Verified::Clean(String::from("abc1234")), - Verified::Refused(String::from("abc1234")), - ] { - let line = outcome.line(); - let columns: Vec<&str> = line.split(' ').collect(); - assert_eq!(columns.len(), 4, "four columns exactly, got {line:?}"); - assert_eq!(columns[0], "verify", "the kind column leads: {line:?}"); + let head = crate::git::head_commit(root).context("land: read this clone's HEAD")?; + let tracking = tracking_ref(reference); + let replayed_onto = crate::git::resolve_ref(root, &tracking) + .ok() + .flatten() + .unwrap_or_else(|| trunk.base.clone()); + + let (tx, rx) = std::sync::mpsc::channel(); + // A SECOND CHANNEL FOR THE CANCEL'S OWN ANSWER, because it is a different + // fact from who won. The race says "the base moved"; this says whether the + // gate was actually stopped, and a lap that reclaimed nothing spent the + // minutes anyway. Reported rather than inferred from the verdict. + let (cancelled, reclaimed_rx) = std::sync::mpsc::channel(); + let stop = std::sync::atomic::AtomicBool::new(false); + let stop = &stop; + + let raced = std::thread::scope(|scope| { + let gate = tx.clone(); + drop(scope.spawn(move || { + let answer = verify(root, branch, command, published, environment); + // SET BEFORE THE SEND, so the watcher's next flag read ends it even + // if the receive below has already taken this arm's answer. + stop.store(true, std::sync::atomic::Ordering::Relaxed); + drop(gate.send(Raced::Gate(answer))); + })); + + let moved = tx.clone(); + drop(scope.spawn(move || { + let mut poll = crate::main_watch::Poll::default(); + while !stop.load(std::sync::atomic::Ordering::Relaxed) { + let answer = crate::main_watch::read(trunk, poll.etag()); + let interval = poll.absorb(answer.as_ref(), trunk.interval); + if let Some(base) = poll.moved(&replayed_onto) { + let base = base.to_owned(); + stop.store(true, std::sync::atomic::Ordering::Relaxed); + // THE KILL IS THE POINT. Without it this arm wins the race + // and the gate keeps running to completion anyway, which is + // the state `stale` already reaches more cheaply. + // + // THE ANSWER IS RECORDED RATHER THAN DISCARDED, and the + // distinction it draws is the one worth having: `false` means + // there was no group to signal, so this arm won the race and + // reclaimed nothing — the gate is still running and will + // finish into a channel nobody reads. That is exactly the + // pre-CLOUD-423 behaviour, and a lap that silently fell back + // to it would report this half as working. + let reclaimed = crate::exec::cancel_owned_group(root); + // `let _` rather than `drop`: the send's own result is a + // `Result<(), SendError>`, which is `Copy`, so dropping + // it does nothing and clippy is right to say so. A receiver + // gone before this lands is the race resolving without this + // arm, which the verdict below already handles. + let _ = cancelled.send(reclaimed); + drop(moved.send(Raced::Moved(base))); + return; + } + crate::pr_watch::pause_until(interval, stop); + } + })); + + drop(tx); + rx.recv().ok() + }); + + match raced { + Some(Raced::Gate(answer)) => answer, + // RECORDED LIKE ANY OTHER REFUSAL, through the one arm that already maps + // to a LAP rather than a stop (CLOUD-318). The lap does not need to learn + // a new outcome to act on this — it needs the one it already laps on, and + // `progress` maps `Refusal::Moved` there. + Some(Raced::Moved(base)) => { + let verified = Verified::Refused { + sha: head, + cause: Refusal::Moved, + }; + // TWO LINES, BOTH ARMS, for the reason `record_wait`'s signature + // exists: a lap that raced and reclaimed the gate and a lap that + // raced and reclaimed nothing produce identical records otherwise, + // so a module over them has nothing to decide. The base it moved to + // is the pointer; whether the kill landed is the half that says this + // mechanism is live rather than merely present. + let reclaimed = reclaimed_rx.try_recv().unwrap_or(false); + append( + root, + branch, + &[ + verified.line(), + format!("{LAP_RECORD} gate-cancelled {base} {reclaimed}"), + ], + )?; + Ok(verified) } + // BOTH ARMS CLOSED WITHOUT ANSWERING is could-not-look about the RACE, + // never a clean tree: the gate is the arm that cannot abstain, so this is + // reachable only if its thread died without sending. + None => Err(crate::error::UsageError::raise(String::from( + "land: the raced gate answered nothing — neither the gate nor the base watcher reported, so this lap has no verdict to act on", + ))), } +} - /// NO DEFAULT COMMAND, asserted rather than described. A default would be a - /// consumer's task name compiled into this crate, which non-negotiable rule - /// 1 forbids — and the failure mode of guessing one is worse than refusing, - /// because a lap would report a gate as clean having run something else. - #[test] - fn an_unconfigured_verify_command_refuses_rather_than_guessing() { - let Err(problem) = verify(Path::new("."), "work", &[]) else { - panic!("an empty command must refuse rather than run something"); - }; - assert!( - format!("{problem}").contains("does not know what verifying means"), - "the refusal names the missing configuration: {problem}" - ); - } +/// Has the base moved since this lap replayed onto it? +/// +/// # Why this exists between the gate and the push +/// +/// A gate runs for minutes, and on a busy trunk that is long enough for the base +/// this lap replayed onto to move. The lap then pushes a head that can no longer +/// fast-forward, CI grades it, and the whole matrix is spent learning what one +/// ref read already knew. The predecessor measured the steady state at ~45% of +/// laps paying a full gate to discover trunk had moved (CLOUD-423), and answered +/// it by RACING the gate against a watcher so the gate could be aborted early. +/// +/// **This is the cheaper half of that, and the difference is stated rather than +/// absorbed.** It does not abort the gate — the gate runs to completion and its +/// result is then discarded if the base moved. So the gate's minutes are still +/// spent where the predecessor could reclaim them; what is saved is the CI matrix +/// and the fast-forward round trip behind it, which is the metered half. Aborting +/// early needs a poller that can be stopped mid-wait; [`wait`] now has that shape +/// (a scoped pair over a stop flag), and applying it to the GATE is a different +/// problem — the gate is a spawn, not a poll — so CLOUD-423's other half stays +/// open rather than being claimed here. +/// +/// # ONE ASK, THROUGH THE CONDITIONAL ENDPOINT +/// +/// This is [`crate::main_watch`]'s poll asked exactly once, not ref discovery. A +/// single unconditional ref advertisement is affordable in isolation — that is +/// what made an earlier revision of this function look fine — but it is the same +/// question [`wait`] asks in a loop, and answering one question two ways is how +/// the two readings drift. The first ask carries no validator and costs a full +/// body; every later lap's does, because the [`crate::main_watch::Poll`] is the +/// lap's. +/// +/// # It fails OPEN, unlike every gate in this module +/// +/// A read that did not answer is not evidence the base moved, and this is an +/// ECONOMY rather than a gate: refusing to push because the forge hiccuped would +/// stop a landing to save a matrix, which is the wrong trade in the wrong +/// direction. `None` therefore means "carry on" for both *unmoved* and *could not +/// look*, and the two are deliberately one reading here. +/// **THE POLL IS THE CALLER'S, AND IT HAS TO BE.** The paragraph above says +/// *"every later lap's [ask] does [carry a validator], because the +/// `main_watch::Poll` is the lap's"* — and this function used to build a fresh +/// one on every call and pass `None`, so the validator was discarded between +/// laps and every probe was unconditional. The prose described the design and +/// the code did not implement it. A `&mut` parameter is what makes the sentence +/// true rather than aspirational: the driver holds one poll across the whole +/// landing, so lap 2 onward send `If-None-Match` and a quiet trunk answers `304` +/// at no rate-limit cost, which is the entire reason the interval is affordable. +#[must_use] +pub fn stale( + root: &Path, + poll: &mut crate::main_watch::Poll, + trunk: &crate::main_watch::Config, + reference: &str, +) -> Option { + let tracking = tracking_ref(reference); + // The base this lap actually replayed onto, read from the ref `advance` set + // rather than passed down through five signatures. Local, so it costs nothing + // and cannot itself fail to reach anybody. + let replayed_onto = crate::git::resolve_ref(root, &tracking).ok()??; + let answer = crate::main_watch::read(trunk, poll.etag()); + let _pace = poll.absorb(answer.as_ref(), trunk.interval); + poll.moved(&replayed_onto).map(ToOwned::to_owned) +} - /// The push family writes the same four columns every other family does. +/// One step of the lap, named so the table below reads as a table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Step { + /// Advance the base and replay this branch onto it. + Replay, + /// Run the consumer's gate over this head. + Verify, + /// Take the landing lease, so one branch at a time spends a matrix. /// - /// Asserted here rather than trusted, for `Replay`'s reason one family over: - /// a module narrows on column zero and splits on four, so a family that - /// shipped three columns would be read through the wrong lens by every - /// predicate over this store rather than by its own. - #[test] - fn every_push_outcome_writes_four_columns_led_by_the_kind() { - for outcome in [Pushed::Landed(String::from("abc1234")), Pushed::Raced] { - let line = outcome.line(); - let columns: Vec<&str> = line.split(' ').collect(); - assert_eq!(columns.len(), 4, "four columns exactly, got {line:?}"); - assert_eq!(columns[0], "push", "the kind column leads: {line:?}"); - } - } + /// **NOTHING TOOK IT, AND THE WHOLE COMPENSATION CLUSTER ASSUMED SOMETHING + /// DID** (review of #848). `Compensation::ReleaseLease` was declared on + /// `Push`, `unwind_lap` computed `mine` from `lease::observe`, and + /// `closes_the_tap` refused to re-draft unless `singleton_held` — but the + /// only callers of `run_lease_acquire` were the `batten lease` CLI and the + /// hand-back. So `mine` was always false, the tap never closed, and after a + /// red wait the pull request stayed READY: every later push bought another + /// matrix on a failure nobody had fixed, which is the exact leak + /// `closes_the_tap`'s own header says it exists to plug. + /// + /// **BEFORE `Ready`, which is the predecessor's placement.** `land.sh` ran + /// `land-lock acquire` before readying, for the reason `Ready`'s own doc + /// gives: readying is the site that buys the matrix, so the serialisation + /// has to be upstream of it or it serialises nothing that costs money. + Lease, + /// Ask the gates that read the pull request's BODY, then commit to review. + /// + /// **A step of its own rather than a clause inside the push**, because it is + /// where a lap stops being free: readying is what starts CI, so it is the one + /// site that buys a matrix and the last place a refusal costs nothing. The + /// bash ran these three gates BEFORE its own conditional ready block, for + /// the same reason stated the other way round: a lap that happened not to + /// re-ready must not be a way through. + Ready, + /// Push under receive-pack's compare-and-swap. + Push, + /// Race green against stale and act on whichever answers. + Wait, + /// Ask the bot to land this head, and read the keyed answer. + FastForward, +} - /// The four columns the vendored module reads, pinned on this side too. +/// What the lap does once a step has answered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Progress { + /// Carry on to the next step of this lap. + Proceed, + /// Lap again. The base moved, or the answer is not in yet. + Lap, + /// Stop, carrying the step's own code. A human owns this one. + Stop, + /// The head is on the landing target. + Landed, +} + +/// What a lap does with `code` from `step`. +/// +/// # This is the whole design, as one table +/// +/// The split is **whether a rebase would clear the refusal**, and nothing else: +/// +/// * A **conflict** and a **refused gate** stop. Both are decisions a human +/// owns, and lapping re-runs them against the same commits to reach the same +/// answer — paying a full gate each time. Lapping OFTEN is what keeps the +/// conflict small; lapping over one is what makes it pointless. +/// * A **raced push**, a **stale base**, an **unanswered wait**, and a +/// **refused or unreadable fast-forward** all lap. Every one means the base +/// moved or nobody has answered yet, and the next lap's replay is the remedy. +/// +/// # Why a refused fast-forward laps rather than stopping +/// +/// It looks like a verdict and is not. The bot refuses when the head stopped +/// being a direct descendant — which is a fact about the BASE having moved, so +/// it is staleness wearing a refusal's clothes. Reading it as a stop would halt +/// a branch whose only problem is that trunk advanced. +/// +/// The mirror mistake is the one CLOUD-413 measured: every non-success +/// conclusion was narrated as "main moved" across 24 laps of one landing, and +/// that diagnosis was wrong twice over — 7 of 8 laps in one run reached green CI, +/// and several refusals were the rate limit rather than trunk. So the two +/// READINGS stay apart (only the staleness arm may assert trunk moved) while +/// their REMEDY is the same lap. +/// +/// # A step's own usage or internal code is never the lap's +/// +/// An unconfigured gate is a usage error about this clone, and a forge that +/// cannot be read is a could-not-look. Neither is a verdict about the branch, so +/// both stop carrying their own code rather than being laundered into a lap that +/// would ask the same unanswerable question again. +/// # Usage always stops, and could-not-look depends on the step +/// +/// `Usage` is a misconfiguration of this clone — an unnamed gate, an unnamed +/// workflow — so every step stops on it. Lapping would ask the same +/// unanswerable question again, which is the CLOUD-235 hang with a tidier +/// cause. +/// +/// `Internal` is could-not-look, and it means two different things depending on +/// who said it. From `replay`, `verify` or `push` it is a clone or a remote this +/// lap cannot read, and there is nothing to lap toward. From `wait` and +/// `fast-forward` it is the loop's ORDINARY state — nobody has answered yet — +/// so it laps, which is the whole reason exit `3` is a first-class outcome on +/// those two rather than an error. +/// The table, given the reading the exit code cannot carry. +/// +/// # WHY THIS TAKES A SECOND ARGUMENT AND [`progress`] DOES NOT +/// +/// The wait has two refusals and they are both a policy verdict, so both are +/// exit `2` — non-negotiable rule 5 is one table with no per-verb exception, and +/// inventing a code for one of them is exactly the exception it forbids. But a +/// base that moved LAPS (the next replay is the remedy) and a red required check +/// STOPS (no rebase clears a failing test). The code cannot tell them apart; the +/// reading can, and [`run wait`](wait) already took it. +/// +/// So the discrimination lives here, in the one table, beside the row it +/// qualifies — rather than as an `if` in the driver, which is where the last +/// thing needing per-step room ended up and what the declared pipeline exists to +/// stop happening again. +impl Step { + /// The phase token this step registers under. /// - /// The writer and the reader are deliberately not each other's authority — - /// the module ships into every consumer's binary and this is one consumer — - /// so the layout is asserted at both ends rather than derived at one. - #[test] - fn every_outcome_writes_four_columns_led_by_the_kind() { - for outcome in [ - Replay::Conflicted { - commit: String::from("abc1234"), - paths: vec![String::from("shared.txt")], - }, - Replay::Current, - Replay::Replayed { - head: String::from("def5678"), - commits: 1, - }, - ] { - let line = outcome.line(); - let columns: Vec<&str> = line.split(' ').collect(); - assert_eq!(columns.len(), 4, "four columns exactly, got {line:?}"); - assert_eq!(columns[0], "rebase", "the kind column leads: {line:?}"); + /// **Written out rather than derived from `Debug`, because the registry is a + /// STORE and a rename must not silently move what is in it** (CLOUD-425). A + /// reader — `mise run alive`, or `lease::progress_of` comparing a phase to + /// the one before it — is matching text another process wrote, possibly by an + /// older build. Lower case for the same reason: it is a token, not a label. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Replay => "replay", + Self::Verify => "verify", + Self::Lease => "lease", + Self::Ready => "ready", + Self::Push => "push", + Self::Wait => "wait", + Self::FastForward => "fast-forward", } } +} - /// A conflict names the commit and the first path, and nothing else. - #[test] - fn a_conflict_records_a_pointer_and_never_a_hunk() { - let line = Replay::Conflicted { - commit: String::from("abc1234"), - paths: vec![String::from("shared.txt"), String::from("other.txt")], - } - .line(); - assert_eq!(line, "rebase conflicted abc1234 shared.txt"); +#[must_use] +pub const fn progress_of( + step: Step, + code: crate::exit::ExitCode, + seen: Option, +) -> Progress { + // THE REFUSAL, NOT THE STEP. Keying on `(Wait, Red)` alone reads a wait that + // SUCCEEDED as a stop whenever a red reading is in hand — which the driver + // can produce, since `seen` is whatever the last wait saw. The qualifier + // reaches exactly one cell: the wait's own `2`. + if let (Step::Wait, crate::exit::ExitCode::Violation, Some(TapVerdict::Red)) = + (step, code, seen) + { + return Progress::Stop; } + progress(step, code) +} - /// A CONFLICT WITH NO PATH still writes four columns, with `-` where the - /// pointer would be. An empty column would shift every column after it, and - /// the reader's own length check would then skip the line entirely — turning - /// the loop's one human stop into silence. - #[test] - fn a_conflict_with_no_path_keeps_the_column_count() { - let line = Replay::Conflicted { - commit: String::from("abc1234"), - paths: Vec::new(), - } - .line(); - assert_eq!(line, "rebase conflicted abc1234 -"); - } +#[must_use] +pub const fn progress(step: Step, code: crate::exit::ExitCode) -> Progress { + use crate::exit::ExitCode::{Internal, Success, Usage, Violation}; + match (step, code) { + // Five of the six steps answering cleanly only means the lap may go on; + // the sixth is the one that ends it. + ( + Step::Replay | Step::Verify | Step::Lease | Step::Ready | Step::Push | Step::Wait, + Success, + ) => Progress::Proceed, + (Step::FastForward, Success) => Progress::Landed, - /// The two clean outcomes are distinguishable from each other and from a - /// conflict — a replay that minted a sha, and one that had nothing to mint. - #[test] - fn the_clean_outcomes_are_told_apart() { - assert_eq!(Replay::Current.line(), "rebase current - -"); - assert_eq!( - Replay::Replayed { - head: String::from("def5678"), - commits: 3, - } - .line(), - "rebase replayed def5678 -" - ); - } + // LAPS. A raced push is the first place the base can move under a lap + // that had already replayed — receive-pack's CAS is what noticed, and the + // next replay is what fixes it. A stale base and an unanswered wait are + // the same lap for different reasons, and a refused fast-forward joins + // them because the bot refuses on a head that stopped descending, which + // is a fact about the base. + // A LEASE ANOTHER BRANCH HOLDS IS A LAP, NEVER A STOP, and it is the + // arm that makes the whole mechanism a queue rather than a race. The + // holder is landing; this branch waits, replays onto whatever they land, + // and asks again. Stopping instead would make every waiter a human + // decision, and lapping is what lets the speculation path find a holder + // to bet on at all. + // + // **A READY THAT COULD NOT LOOK JOINS THEM** (review of #848). `Ready`'s + // two exit codes were one arm, so a single transient 403 or 5xx on the + // pulls list — or on the draft-state read — ended the landing at exit `3` + // where every sibling arm laps. The split is by exit code and the step's + // own arms carry it: a refused body gate, a gate declared and unrunnable, + // and a branch with no pull request are all `Violation` (the tree or the + // forge state is wrong and no lap changes it), and `Internal` is reserved + // for a forge read that did not answer. + // AND THE GATE'S COULD-NOT-LOOK, WHICH IS "MAIN MOVED" AND NOTHING ELSE + // (CLOUD-1586). `run_land_verify` codes `Refusal::Moved` `Internal` and + // every other refusal — `Environment`, `Tree` — `Violation`, so this cell + // is reached by exactly one cause and lapping it cannot swallow a real + // failure. `verify` reserves exit 2 for that one verdict, whose own text + // is "there is nothing here to fix". + // + // IT STOPPED, AND THREE COMMENTS SAID IT LAPPED. This cell read + // `Progress::Stop` while `run_land_verify` asserted "`land` reads this as + // lap, and the next replay is the whole remedy", `verify_raced`'s header + // claimed the same, and `land.rs`'s own `Refusal::Moved` doc said "WHICH + // LAPS RATHER THAN STOPPING". So the one self-healing refusal class was + // the loop's hardest stop: exit 3 on lap 1, after printing that a replay + // was coming. `charge_the_lap`'s reclaim arm, `Ledger::reclaimed`, + // `Bound::GateReclaims` and `$LAND_MAX_GATE_RECLAIMS` were all + // unreachable behind it — a mechanism whose every part existed and whose + // entry cell voided it, which is the dead-gate class this repository + // gates for elsewhere and shipped here. + (Step::Push | Step::Lease, Violation) + | (Step::Ready | Step::Verify, Internal) + | (Step::Wait | Step::FastForward, Violation | Internal) => Progress::Lap, - #[test] - fn a_remote_reference_resolves_to_its_tracking_ref() { - assert_eq!(tracking_ref("refs/heads/main"), "refs/remotes/origin/main"); - assert_eq!(tracking_ref("main"), "refs/remotes/origin/main"); + // STOPS. The replay and the gate both answer about THIS tree, so a + // refusal from either is a decision no rebase clears. A push that could + // not look reached a remote it cannot read, which is a clone problem + // rather than a race. And `Usage` stops everywhere: a gate or a workflow + // this clone never named is not a question another lap can answer. + // The ready gates read the pull request's BODY, so a refusal is a + // statement about what the author wrote — a deferral with no ticket, a + // key the merge will not close. No rebase clears prose, which puts it + // beside the replay and the gate rather than beside the push. + // A LEASE THAT WILL NOT READ STOPS, where a lease another branch HOLDS + // laps. The two are not the same answer: a holder is a queue this branch + // joins, and an unreadable lease is a clone that cannot serialise at all. + // Lapping on it would spend the whole budget re-asking a question the + // environment cannot answer and then exit `3` anyway, which is the same + // outcome later and less legibly — so it joins `Push`'s could-not-look + // rather than `Wait`'s. + // A READY REFUSAL IS THE AUTHOR'S, which is why it stays here while + // `Ready`'s could-not-look moved to the lap above. + // `Verify`'s VIOLATION stops and its could-not-look laps, which is the + // split the arm above explains: a refused tree is a decision no rebase + // clears, and a raced base is one a replay fixes. + (Step::Replay, Violation | Internal) + | (Step::Verify | Step::Ready, Violation) + | (Step::Push | Step::Lease, Internal) + | (_, Usage) => Progress::Stop, } +} - /// A wait line carries four columns led by its kind, like a replay line. - #[test] - fn every_wait_answer_writes_four_columns_led_by_the_kind() { - for answer in [ - Answered { - arm: Arm::Green, - verdict: Some(String::from("success")), - sha: String::from("abc1234"), - }, - Answered { - arm: Arm::Stale, - verdict: None, - sha: String::from("abc1234"), - }, - ] { - let line = answer.line(); - let columns: Vec<&str> = line.split(' ').collect(); - assert_eq!(columns.len(), 4, "four columns exactly, got {line:?}"); - assert_eq!(columns[0], "wait", "the kind column leads: {line:?}"); - } - } +/// The gate invocations a ready phase runs, decoded from one declared string. +/// +/// `|`-separated argvs, each space-separated words. **Both the runner and the +/// task names are the CONSUMER's** — a task name inside `crates/batten` is +/// non-negotiable rule 1's plainest violation, and a compiled-in default would +/// be one with extra steps. The separator is `|` rather than `,` because an argv +/// contains spaces and a comma would make one argv per word. +/// +/// Empty entries are dropped rather than becoming an empty argv: a trailing +/// separator is a typo, not a gate, and [`ready`] treats an unrunnable gate as a +/// refusal — so an empty argv would stop every lap on a stray character. +#[must_use] +pub fn body_gates(declared: &str) -> Vec> { + declared + .split('|') + .map(|entry| { + entry + .split_whitespace() + .map(ToOwned::to_owned) + .collect::>() + }) + .filter(|argv| !argv.is_empty()) + .collect() +} - /// **THE LOSER IS WRITTEN AS COULD-NOT-LOOK, and that is what makes the race - /// legible.** An arm abandoned unread records `-` rather than being omitted: - /// omitting it would make a lap that raced properly and a lap that read both - /// sides produce records nothing can tell apart. - #[test] - fn a_voided_loser_records_could_not_look_rather_than_vanishing() { - assert_eq!( - Answered { +/// What a ready phase decided. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Readied { + /// Every declared gate passed, or there was no body to judge. + Clear, + /// A gate refused, naming itself. + Refused { + /// The gate's own first word, which is the pointer a reader follows. + gate: String, + /// What the gate printed. Its own report, forwarded rather than + /// summarised — the gate is the authority on its own finding. + detail: String, + }, + /// A declared gate could not be run at all. + /// + /// **A REFUSAL, NOT A PASS**, and that is the whole reason this variant is + /// distinct from [`Readied::Clear`]. A declared gate that cannot run is the + /// dead-gate class this engine exists to refuse, and the bash agreed by + /// construction: `mise run ` for a task that does not exist exits + /// non-zero, which was a stop. + Unrunnable { + /// The gate that would not run. + gate: String, + }, +} + +/// Run the declared body gates over `body`. +/// +/// # An empty body is a PASS, and that is the predecessor's posture +/// +/// The body is fetched from the forge, and a fetch that failed is not evidence +/// about what the author wrote: these gates are about what a body SAYS, and one +/// they never saw says nothing. The bash spelled it `[[ -n "$body" ]] && ! gate` +/// — the gate simply does not run. Reading a failed fetch as a refusal would +/// stop every lap on a network blip. +/// +/// # An unrunnable gate is a REFUSAL +/// +/// The opposite direction, and both are the predecessor's. `mise run ` for +/// a task that does not exist exits non-zero, so the bash stopped — and it is the +/// right way round: a gate that cannot run has not passed, and treating it as +/// clean is exactly how a retired or renamed gate goes silently dead. +#[must_use] +pub fn ready(root: &Path, gates: &[Vec], body: &str) -> Readied { + if body.trim().is_empty() { + return Readied::Clear; + } + for argv in gates { + if argv.is_empty() { + continue; + } + // THE WHOLE ARGV, NOT ITS FIRST WORD. `argv.first()` is the RUNNER — + // `mise` for every gate this consumer declares — so every refusal named + // the same program and none named the gate that refused. A pointer that + // is identical across every possible finding is not a pointer (review of + // #848). + let gate = argv.join(" "); + let Some((code, output)) = + crate::exec::piped_argv(root, argv, body, crate::exec::Diagnostics::Keep) + else { + return Readied::Unrunnable { gate }; + }; + if code != 0 { + return Readied::Refused { + gate, + detail: output.trim().to_owned(), + }; + } + } + Readied::Clear +} + +/// The prefix marking an entry gate whose verdict does not stop the landing. +/// +/// One character on the gate's own first word, so the marker travels with the +/// gate it qualifies rather than in a second list somebody has to keep in step. +/// [`admits_the_landing`] carries the measurement it conserves. +pub const ADVISORY: char = '?'; + +/// What retiring a landed branch actually removed. +/// +/// **COUNTS AND BOOLEANS, never a name or a path** (non-negotiable rule 4). The +/// caller already knows which branch it landed, and the receipt store's contents +/// are the findings a branch filed — the one thing this family may not echo. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Retired { + /// The branch is gone from the remote — deleted here, or already absent. + pub remote: bool, + /// This clone's remote-tracking ref for it is gone. + pub tracking: bool, + /// How many branch-keyed receipts were dropped. + pub receipts: usize, +} + +/// The receipt families keyed by BRANCH NAME rather than by sha. +/// +/// **A sha-keyed receipt dies with its sha and needs no sweep; these do not.** +/// Each of these records something about a branch's whole filing history, so the +/// next piece of work to reuse the name would be judged against rows that belong +/// to the last one (CLOUD-774). +/// +/// `filed-set-nudged` is here and was NOT in the predecessor's pair, which is a +/// correction rather than a port: `Suppression::PerSet` writes a third store +/// under the same key shape, and it landed after the bash cleanup was written. A +/// port that copied the two literals would have left one family accumulating +/// forever, which is the drift a named list exists to stop. +const BRANCH_KEYED_RECEIPTS: &[&str] = &["board-writes", "filed-here-nudged", "filed-set-nudged"]; + +/// Retire a branch whose pull request has merged. +/// +/// # This is only ever reached from the LANDED path, and that is the whole guard +/// +/// `mise-tasks/land.sh` states it: *"ONLY here, never on a `die` path — an +/// abandoned branch is evidence and has to survive."* Trunk-based development is +/// explicit that a short-lived branch should not outlive its pull request +/// (CLOUD-349), and a name left behind is how one becomes long-lived; but a +/// branch that did NOT land is the record of why it did not. +/// +/// # Every failure is silent, because the landing already succeeded +/// +/// Reporting a cleanup failure as the landing's exit code would make a successful +/// landing look broken. So this returns what it managed rather than a `Result`, +/// and the caller prints the counts — which is also what makes the difference +/// between *deleted* and *could not* visible without it changing a verdict. +/// +/// # The tracking ref goes too, and that half is not cosmetic +/// +/// [`stale_tracking`]'s header carries the measurement: a stale +/// `origin/` left after a merge makes a later `--force-with-lease` +/// reject **permanently**, because the lease compares against a ref naming a +/// commit the remote deleted. Deleting the remote branch without pruning the +/// tracking ref manufactures exactly that state. +#[must_use] +pub fn retire_branch(root: &Path, remote: &str, branch: &str) -> Retired { + let reference = format!("refs/heads/{branch}"); + let remote_gone = matches!( + crate::lease::delete_ref(remote, &reference), + Ok(crate::lease::Outcome::Applied) + ); + // `tracking_ref`, never a second spelling of it (review of #848). This file + // derives the prefix through `tracking_prefix` so a consumer whose + // `LAND_LOCK_REMOTE` is not `origin` gets the refs `advance` actually wrote; + // this site was left on the literal, so the retirement either deleted + // nothing and reported `Retired.tracking` false, or deleted a ref under + // another remote entirely. + let tracking = tracking_ref(&reference); + let tracking_gone = crate::gitwrite::delete_ref(root, &tracking).is_ok(); + + // THE SLUG IS THE STORE'S OWN SPELLING, not a second one. Every writer under + // `.git/batten-receipts` keys a branch as `branch.replace('/', "-")`, so a + // sweep spelling it differently would delete nothing and report a clean + // count — the silent-empty-answer shape this repository refuses everywhere. + // **AND THE CLAIM PARTITION, which the sweep did not carry** (review of + // #848). `recorder::record_path` names a branch-keyed record + // `{record}.{branch}.{claim}` whenever a claim receipt answers — and + // `board-writes` is written through exactly that path — so on any CLAIMED + // branch this unlinked `board-writes.`, got ENOENT, and reported + // `receipts: 0` while the real record survived. The next branch reusing that + // name is then judged against the previous one's filings, which is verbatim + // the CLOUD-774 failure the constant's own doc says this sweep exists to + // prevent. + // + // A PREFIX SWEEP rather than a second spelling of the claim: the sweep must + // not need to know which claim was live when each record was written, and + // asking would be a second authority over a name `record_path` already owns. + let slug = branch.replace('/', "-"); + let store = crate::git::git_dir(root).map(|dir| dir.join("batten-receipts")); + let receipts = store.map_or(0, |dir| { + let Ok(listing) = std::fs::read_dir(&dir) else { + return 0; + }; + listing + .filter_map(std::result::Result::ok) + .filter(|entry| { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + return false; + }; + BRANCH_KEYED_RECEIPTS.iter().any(|family| { + // `{family}.{slug}` exactly, or `{family}.{slug}.{claim}` — + // never `{family}.{slug}-2`, which is a DIFFERENT branch. + let stem = format!("{family}.{slug}"); + name == stem + || name + .strip_prefix(&stem) + .is_some_and(|rest| rest.starts_with('.')) + }) + }) + .filter(|entry| std::fs::remove_file(entry.path()).is_ok()) + .count() + }); + + Retired { + remote: remote_gone, + tracking: tracking_gone, + receipts, + } +} + +/// What an entry gate decided, before the lap spends anything. +/// +/// A separate type from [`Readied`] rather than a reuse of it, because the two +/// answer about different subjects: `Readied` judges the pull request's BODY and +/// takes it on stdin, and this judges a precondition of the SESSION and takes the +/// pull request's number on argv. One type over both would have to carry a body +/// that half its callers do not have. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Admitted { + /// Every declared gate passed, or none was declared. + Clear, + /// A gate refused, naming itself. + Refused { + /// The gate's own first word, which is the pointer a reader follows. + gate: String, + /// What the gate printed — its own report, forwarded rather than + /// summarised, for [`Readied::Refused`]'s reason. + detail: String, + }, + /// A declared gate could not be run at all. + /// + /// **A REFUSAL, NOT A PASS**, for exactly [`Readied::Unrunnable`]'s reason: a + /// declared gate that cannot run is the dead-gate class this engine exists to + /// refuse, and an undeclared set is the only legitimate way to have none. + Unrunnable { + /// The gate that would not run. + gate: String, + }, +} + +/// Run the consumer's entry gates over this landing's pull request. +/// +/// # What this is for, and why it is not an ordinary body gate +/// +/// `mise-tasks/land.sh` opened with a pair of calls the lap could not proceed +/// past — a drop and then a check — over a precondition that is about the SESSION +/// rather than about the branch: this repository's contract forbids PR-webhook +/// babysitting, the harness arms a subscription on every pull request it opens +/// anyway, and the lander is the one place that runs at the moment it matters +/// (CLOUD-518, CLOUD-790). Nothing in the engine carried it, so the retirement +/// would have dropped a live gate on the floor (CLOUD-1471). +/// +/// # The pull request's number is the engine's fact and the command is not +/// +/// Each declared argv is run with the pull request number appended as its LAST +/// argument. That split is non-negotiable rule 1 as a mechanism: which task drops +/// a subscription is this consumer's vocabulary and is never compiled in, while +/// *which pull request this landing is about* is a fact the engine already +/// resolved and must not ask a consumer to spell a second time. A gate reading it +/// from its own environment would be a second authority over which pull request a +/// lap is landing, and the two could name different ones. +/// +/// # It runs ONCE, before the first lap +/// +/// The predecessor ran it before the singleton and the lease, so a refusal cost +/// nothing at all. Per lap it would re-ask a question whose answer cannot change +/// under a rebase, and each ask spends a process on the critical path. +/// +/// **An UNDECLARED set is a pass**, on [`ready`]'s asymmetry: naming no entry +/// gates is a legitimate configuration, and naming one that cannot run is a dead +/// gate. +#[must_use] +pub fn admits_the_landing(root: &Path, gates: &[Vec], pr: &str) -> Admitted { + for argv in gates { + let Some(first) = argv.first() else { + continue; + }; + let advisory = first.starts_with(ADVISORY); + let program = first.trim_start_matches(ADVISORY).to_owned(); + if program.is_empty() { + continue; + } + let mut with_pr: Vec = std::iter::once(program.clone()) + .chain(argv.iter().skip(1).cloned()) + .collect(); + // THE WHOLE ARGV, NOT ITS FIRST WORD, and this is the sibling of the same + // fix in `ready` one function up. The first word is the RUNNER, so with + // this consumer's declared pair every possible refusal reported `mise` — + // a pointer identical across every finding, which is no pointer at all. + // Built from `with_pr` BEFORE the number is appended, and from the + // advisory-stripped program, so it reads as the command an operator would + // run rather than as this engine's own spelling (review of #848). + let gate = with_pr.join(" "); + with_pr.push(pr.to_owned()); + let Some((code, output)) = + crate::exec::piped_argv(root, &with_pr, "", crate::exec::Diagnostics::Keep) + else { + // AN ADVISORY GATE THAT WILL NOT RUN IS NOT A REFUSAL EITHER, which + // is the same reading one line down rather than a separate decision: + // `|| true` swallowed an unrunnable command exactly as it swallowed a + // refusing one, and a marker that covered only one of the two would + // be a promotion the predecessor never made. + if advisory { + continue; + } + return Admitted::Unrunnable { gate }; + }; + if code != 0 && !advisory { + return Admitted::Refused { + gate, + detail: output.trim().to_owned(), + }; + } + } + Admitted::Clear +} + +/// Which bound a charge ran into. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bound { + /// Never won the lease. The fleet is saturated — and this is the ONE + /// exhaustion that has spent no CI at all, which is why a caller reports it + /// differently: a `land-lock-check` tells a saturated fleet apart from a + /// wedged lease, and they look identical from inside the loop. + LeaseWaits, + /// The fast-forward bot gave no readable answer. Nothing about the branch is + /// wrong and `main` has not moved under it. + Unknowns, + /// CI failed before reaching a verdict, repeatedly. Past this bound it is not + /// a flake any more: the provisioning path is broken, and re-running would + /// spend jobs to learn the same thing. + Transients, + /// The base kept moving out from under the gate. Like [`Bound::LeaseWaits`] + /// this has spent NO CI — the gate was reclaimed before it finished, so a + /// caller reports it as a trunk moving faster than a gate takes rather than + /// as anything wrong with the branch. The remedy is a shorter gate or a + /// quieter trunk, never a re-run. + GateReclaims, +} + +/// What a charge decided. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Charge { + /// Inside the bound; the lap goes round again. + Lap, + /// The bound is spent. + Stop(Bound), +} + +/// A lap's accounting. +/// +/// # SPEND IS COUNTED, NEVER INFERRED FROM THE ATTEMPT COUNTER +/// +/// [`Ledger::laps`] and [`Ledger::paid`] look interchangeable and are not, and +/// the difference was measured rather than reasoned. The inference — "every lap +/// that bought no CI is refunded, so the lap counter IS the spend" — fails in +/// both directions. +/// +/// There are FIVE refund sites, not the three CLOUD-904 named: the two in the +/// lease wait, bot silence, an absorbed transient, and the admitted-successor +/// push. And they still miss the ordinary case: a lap where `main` moves while +/// the gate runs aborts before the ready, buys nothing, and is charged anyway. +/// +/// Measured on PR #651 while landing the change that fixed it — two laps, both +/// lost to `main` moving under the gate, the ready never reached, ZERO check-runs +/// on the head — and the refusal announced "having spent 2 CI matrices". So +/// `laps` is an attempt counter and nothing more; `paid` is the spend, +/// incremented at the ONE site that buys one. +/// +/// # Counts, never clocks +/// +/// Every bound here is a count. `clippy.toml` bans the sleeps that would let one +/// become a deadline, and `tests/sleep_ban.rs` holds each `reason` to a named +/// bound — a wall clock would reintroduce the VM-reap gap +/// `mem:workflow/landing-loop` records. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Ledger { + /// Attempts. Bounded by the caller's lap maximum as a runaway backstop. + pub laps: u32, + /// CI matrices actually bought. + pub paid: u32, + /// Passes that never won the lease. + pub lease_waits: u32, + /// Passes that got no readable answer from the bot. + pub unknowns: u32, + /// Runs that failed before reaching a verdict. + pub transients: u32, + /// Passes whose gate was reclaimed because the base moved under it. + pub gate_reclaims: u32, +} + +impl Ledger { + /// Open a lap. + pub const fn attempt(&mut self) { + self.laps = self.laps.saturating_add(1); + } + + /// A matrix was bought. **The one site that increments this.** + pub const fn bought_a_matrix(&mut self) { + self.paid = self.paid.saturating_add(1); + } + + /// A pass that never won the lease: refund the lap, charge the wait. + /// + /// The refund is what keeps a saturated fleet from exhausting a budget that + /// exists to catch "main moves faster than a lap takes" — and from reporting + /// THAT diagnosis, which CLOUD-413 measured being wrong twice over across 24 + /// laps. + pub const fn waited(&mut self, max: u32) -> Charge { + self.laps = self.laps.saturating_sub(1); + self.lease_waits = self.lease_waits.saturating_add(1); + if self.lease_waits > max { + Charge::Stop(Bound::LeaseWaits) + } else { + Charge::Lap + } + } + + /// A pass the bot gave no readable answer to. + /// + /// The same shape as [`Ledger::waited`] for the same reason: the pass spent + /// nothing. An unknown re-ask laps, and on an unmoved `main` that lap is free + /// by construction — the receipt short-circuits on the unchanged HEAD, the + /// head already graded so neither re-fire can fire, and a force-push that + /// moves nothing emits no event and buys no run. + pub const fn unknown(&mut self, max: u32) -> Charge { + self.laps = self.laps.saturating_sub(1); + self.unknowns = self.unknowns.saturating_add(1); + if self.unknowns > max { + Charge::Stop(Bound::Unknowns) + } else { + Charge::Lap + } + } + + /// A pass whose gate was reclaimed because the base moved under it. + /// + /// **[`Ledger::waited`]'s shape, and now for the same reason it was written** + /// (CLOUD-1586). This module's own header named the gap: *"they still miss + /// the ordinary case: a lap where `main` moves while the gate runs aborts + /// before the ready, buys nothing, and is charged anyway."* + /// + /// It was survivable while nothing aborted the gate, because the condition + /// was discovered only after `verify` had finished and the lap was rare. + /// [`verify_raced`] makes it deliberate, and this repository measured the + /// condition holding on **~45% of laps** — so the runaway backstop, which + /// defaults to TWO, now exhausts on a busy trunk having bought nothing. The + /// diagnosis it printed would be "main moves faster than a lap takes", which + /// is true and useless: the lap was not the thing that was slow. + /// + /// **Generous for [`lease_wait_bound`]'s reason, stated in its own words:** + /// the pass spent nothing — no matrix, no gate to completion, no push — and + /// the thing being waited out is other branches landing. The cost of too many + /// is conditional requests against a ref; the cost of too few is giving up on + /// a trunk that was moving, which is the queue working. + pub const fn reclaimed(&mut self, max: u32) -> Charge { + self.laps = self.laps.saturating_sub(1); + self.gate_reclaims = self.gate_reclaims.saturating_add(1); + if self.gate_reclaims > max { + Charge::Stop(Bound::GateReclaims) + } else { + Charge::Lap + } + } + + /// A run that failed before reaching a verdict. + pub const fn transient(&mut self, max: u32) -> Charge { + self.laps = self.laps.saturating_sub(1); + self.transients = self.transients.saturating_add(1); + if self.transients > max { + Charge::Stop(Bound::Transients) + } else { + Charge::Lap + } + } + + /// What a refusal may honestly say was spent. + #[must_use] + pub const fn spent(&self) -> u32 { + self.paid + } +} + +/// Was this head's CI failure a provisioning transient rather than a verdict? +/// +/// `records` is one line per failed run, as the non-verdict scanner reported them. +/// **A run is absorbed only if EVERY record is a non-verdict**: one line naming a +/// verdict means the branch was judged, and re-running would spend jobs to +/// re-learn a real refusal. +/// +/// `None` for could-not-look, and the causes are deliberately one reading: no +/// failed runs, a scan that produced nothing, a scan that answered with a +/// verdict, and a record this reader does not recognise. A caller cannot act +/// differently on which, and inventing a distinction would invite one to. +/// +/// **AN UNRECOGNISED RECORD IS COULD-NOT-LOOK, NOT AN ABSENT VERDICT.** The +/// filter used to keep the `nonverdict` lines and DROP everything else, so a +/// scanner error, a truncated record or a shape added later read as *every +/// record is a non-verdict* — the permissive answer, which re-runs the matrix on +/// a head that may well have been judged. Absorbing is the expensive direction, +/// so the reading that cannot be justified must not reach it. +#[must_use] +pub fn absorbed(records: &[String]) -> Option> { + let lines: Vec<&str> = records + .iter() + .flat_map(|record| record.lines()) + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect(); + if lines.is_empty() { + return None; + } + if lines.iter().any(|line| line.starts_with("verdict")) { + return None; + } + // EVERY line must be one this reader knows. `nonverdict` does not begin with + // `verdict`, so the two prefixes partition cleanly and anything outside the + // pair is a record nobody here can classify. + if !lines.iter().all(|line| line.starts_with("nonverdict")) { + return None; + } + Some(lines.iter().map(|line| (*line).to_owned()).collect()) +} + +// --------------------------------------------------------------------------- +// CLOUD-900 / CLOUD-1338: abandoning the matrix a red check made worthless. +// --------------------------------------------------------------------------- + +/// One run still spending on a head, as the pair the decision needs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Spending { + /// The run id, for the cancel endpoint. + pub id: String, + /// The workflow file it came from — what the fan-in is recognised by. + pub path: String, +} + +/// What one abandon pass did. Counts, never a log line from a cancelled run. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Abandoned { + /// Runs this pass asked the forge to stop. + pub cancelled: u32, + /// Runs deliberately left alone. + pub spared: u32, + /// Cancellations the forge refused. Not a stop: those minutes bill out. + pub refused: u32, +} + +/// The fan-in's WORKFLOW PATH, which is not its check name. +/// +/// **A NEWTYPE BECAUSE THE TWO SPELLINGS WERE INTERCHANGEABLE AND ONE OF THEM +/// WAS WRONG FOR THE WHOLE OF THIS BRANCH.** `CI_FANIN_CHECK` names a check and +/// belongs to [`crate::checks_green`]'s roster; `CI_FANIN_WORKFLOW` names the +/// path a run carries, which is what [`worthless`] compares against. Both are +/// `String`, so reading the wrong one type-checked, every suite in this crate was +/// green over it, and `spared` was silently always 0 — cancelling the fan-in's own +/// run, whose `cancelled` context is not an answer and wedges the branch. +/// +/// The constructor is the whole mechanism: a caller reaching for the check name +/// has to write [`FanIn::from_workflow_path`] over it, which is a lie a reader +/// can see rather than an argument position that accepts anything. `ci-parity`'s +/// `fan-in-is-wired` binds on that constructor appearing on the same line as the +/// declaration read, so the module and the compiler hold the same join — the +/// module alone could not, because two independent line matches are satisfied by +/// an unrelated read plus a wrong argument (found in review of #848). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FanIn(String); + +impl FanIn { + /// The declared workflow PATH. Empty where the consumer declared nothing. + #[must_use] + pub fn from_workflow_path(path: impl Into) -> Self { + Self(path.into()) + } + + /// Did the consumer declare one? An undeclared fan-in cancels NOTHING. + #[must_use] + pub fn declared(&self) -> bool { + !self.0.is_empty() + } + + /// Does this run carry the fan-in's own workflow? + #[must_use] + fn spares(&self, run: &Spending) -> bool { + run.path == self.0 + } +} + +/// Which runs on a head a red verdict makes worthless, sparing the fan-in's. +/// +/// **THE FAN-IN'S RUN IS NEVER CANCELLED, and that is the whole safety +/// property.** `final` is the one context branch protection requires; it is +/// `always()` over a `needs:` assertion, so cancelling its run leaves that +/// context `cancelled` — which is not an answer, and buys a branch that can +/// never grade and never land. The predecessor states it at length and this +/// conserves it exactly. +/// +/// Split from the forge calls so the decision is testable without a network, +/// which is the same split [`crate::lease::decide`] makes for the staleness +/// read. +#[must_use] +pub fn worthless(spending: &[Spending], fanin: &FanIn) -> (Vec, u32) { + let mut spared = 0; + let doomed = spending + .iter() + .filter(|run| { + if fanin.spares(run) { + spared += 1; + return false; + } + true + }) + .cloned() + .collect(); + (doomed, spared) +} + +/// The runs still in flight on `sha`, read from the forge. +/// +/// **`status != "completed"` is the whole filter**, and it is the +/// predecessor's: a run that has already finished bills nothing further, so +/// asking to cancel it is a call that buys nothing. +/// +/// `None` is could-not-look. Every failure here is best-effort by contract — +/// the caller is on its way to reporting the real failure, and a cleanup step +/// that could not reach the forge must not replace that message with its own. +#[must_use] +pub fn spending(repo: &str, sha: &str) -> Option> { + let answer = crate::rest::get( + &format!("repos/{repo}/actions/runs?head_sha={sha}&per_page=100"), + None, + )?; + let document = serde_json::from_str::(&answer.body).ok()?; + let runs = document.get("workflow_runs")?.as_array()?; + Some( + runs.iter() + .filter(|run| { + run.get("status").and_then(serde_json::Value::as_str) != Some("completed") + }) + .filter_map(|run| { + let id = run.get("id")?; + let id = id + .as_u64() + .map(|found| found.to_string()) + .or_else(|| id.as_str().map(str::to_owned))?; + let path = run.get("path")?.as_str()?.to_owned(); + Some(Spending { id, path }) + }) + .collect(), + ) +} + +/// Cancel every run a red verdict made worthless, sparing the fan-in's. +/// +/// **BEST-EFFORT THROUGHOUT AND NEVER A VERDICT.** A refused cancellation costs +/// the minutes it would have saved and changes no conclusion, so nothing here +/// stops and the count is reported rather than raised. +/// +/// **NOT "cancelling somebody else's runs".** A head sha is one no other branch +/// has, so the blast radius is one push's worth of runs by construction rather +/// than by filtering — the same argument the lease guard's own cancel carries. +#[must_use] +pub fn abandon(repo: &str, sha: &str, fanin: &FanIn) -> Abandoned { + // AN UNSET FAN-IN CANCELS NOTHING RATHER THAN GUESSING, and this is the + // guard whose absence would have been worst: with no name to spare, EVERY + // run is doomed — including the one carrying the fan-in, whose cancelled + // context is not an answer and wedges the branch. `abandon-matrix.bats` + // refuses to run at all without the declaration for exactly this reason, and + // the first port of this dropped the arm. + if !fanin.declared() { + return Abandoned::default(); + } + let Some(in_flight) = spending(repo, sha) else { + return Abandoned::default(); + }; + let (doomed, spared) = worthless(&in_flight, fanin); + let mut report = Abandoned { + spared, + ..Abandoned::default() + }; + for run in doomed { + if crate::rest::post(&format!("repos/{repo}/actions/runs/{}/cancel", run.id)) { + report.cancelled += 1; + } else { + report.refused += 1; + } + } + report +} + +// --------------------------------------------------------------------------- +// CLOUD-1338: the tap. `mise-tasks/land.sh`'s `redraft` / `close_the_tap`. +// --------------------------------------------------------------------------- + +/// Whether a lap that stopped without merging should re-draft its pull request. +/// +/// **THE TAP, AND IT IS THE PROPERTY THAT MAKES THE DELETION SAFE.** AGENTS.md +/// states it: *"a red run re-drafts the PR — CI skips drafts, so that is the +/// only thing that stops the next push buying another run while you fix it +/// locally."* The predecessor's own header is blunter — *"stopping on a red run +/// without closing the tap is a leak this exists to plug."* Retiring `land.sh` +/// without this removes the tap and leaves every later push spending a runner +/// on a failure nobody has fixed. +/// +/// Split from the forge calls so the decision is testable without a network, +/// the same split [`worthless`] and [`crate::lease::decide`] make. +/// +/// **The verdict mapping is the predecessor's, arm for arm**, and the two arms +/// that do NOT re-draft are the load-bearing ones: +/// +/// * `Green` — leave it ready. A resume costs nothing from here. +/// * a reading that could not be taken — **never strand a head on a failure to +/// look**. This is the arm a collapsed three-valued read would lose. +/// * `Red` and `Pending` — both mean the resume needs a fresh run whatever +/// happens, so the draft costs nothing and stops every push until one starts. +#[must_use] +pub fn closes_the_tap(state: &Tap) -> bool { + // A LAND THAT MERGED OWNS NOTHING TO CLOSE, and one that never took the + // singleton owns neither the lease nor the pull request — which is why a + // REFUSED second land must not touch the live one's work. + if state.landed || !state.singleton_held { + return false; + } + // Already a draft is already closed. Asked rather than assumed, because + // re-drafting one would fail and the failure is swallowed — a silent no-op + // reads exactly like a tap that closed. + if state.is_draft != Some(false) { + return false; + } + match state.verdict { + // COULD NOT LOOK IS NOT RED. `None` is a reading nobody took, and + // draft-ing on it would punish a network blip with a stopped branch. + None | Some(TapVerdict::Green) => false, + Some(TapVerdict::Red | TapVerdict::Pending) => true, + } +} + +/// What one wait leaves the tap to read, or `None` where nobody looked. +/// +/// **`Stale` is `None`, and that is the load-bearing arm.** The staleness arm +/// won the race, so the green arm was voided UNREAD — no checks reading was +/// taken, and [`closes_the_tap`] must not be handed a verdict nobody looked up. +/// `Unanswered` is the opposite case and must not be collapsed into it: the +/// green arm asked its full count and never saw a terminal answer, which IS a +/// reading, and is precisely what `Pending` means. +/// +/// Pure, and separate from [`wait`] for the reason [`worthless`] is separate +/// from [`abandon`]: the mapping is the decision, and a decision reachable only +/// through a network call is a decision nothing tests. +#[must_use] +pub const fn tap_verdict(waited: &Waited) -> Option { + match waited { + Waited::Green { .. } => Some(TapVerdict::Green), + Waited::Red { .. } => Some(TapVerdict::Red), + Waited::Unanswered => Some(TapVerdict::Pending), + Waited::Stale { .. } => None, + } +} + +/// What the tap decision reads. Every field is a reading the caller already +/// took, so the decision itself opens nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Tap { + /// Whether this lap merged. A merge closes nothing. + pub landed: bool, + /// Whether this lap holds the singleton, and so owns the pull request. + pub singleton_held: bool, + /// The pull request's draft state, or `None` where it could not be read. + pub is_draft: Option, + /// What the checks said, or `None` where the reading could not be taken. + pub verdict: Option, +} + +/// The checks reading, narrowed to what the tap turns on. +/// +/// **Three values rather than [`crate::checks_green::Verdict`] itself**, because +/// the tap does not care WHICH check failed or why a pending one is pending — +/// and a decision that carried the findings would invite a predicate over them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TapVerdict { + /// Every required check terminal and green. + Green, + /// A required check failed. + Red, + /// Not an answer yet. + Pending, +} + +impl TapVerdict { + /// Narrow a full verdict to the three the tap reads. + #[must_use] + pub fn of(verdict: &crate::checks_green::Verdict) -> Self { + match verdict { + crate::checks_green::Verdict::Green => Self::Green, + crate::checks_green::Verdict::Red(_) => Self::Red, + crate::checks_green::Verdict::Pending(_) => Self::Pending, + } + } +} + +/// A pull request's draft state and its node id, in one read. +/// +/// Both come from the same response deliberately: two calls could disagree about +/// which state they described, and the node id is only ever wanted in order to +/// change the state this same read reported. +#[must_use] +pub fn draft_state(repo: &str, pr: &str) -> Option { + let answer = crate::rest::get(&format!("repos/{repo}/pulls/{pr}"), None)?; + let document = serde_json::from_str::(&answer.body).ok()?; + let draft = document.get("draft")?.as_bool()?; + let node = document.get("node_id")?.as_str()?.to_owned(); + // FROM THE SAME DOCUMENT, so it costs no round trip and cannot describe a + // different pull request than the draft flag beside it. + let head = document.get("head")?.get("sha")?.as_str()?.to_owned(); + Some(Readiness { draft, node, head }) +} + +/// What one read of a pull request says about whether readying it buys a run. +/// +/// **`head` is here because the READY FIRES AGAINST IT, not against this +/// clone's HEAD** (review of #848). `Step::Ready` precedes `Step::Push`, so on +/// every lap that replayed, the local head is a sha the forge has never seen — +/// and reading the check verdict for it mints a run on the pull request's +/// SUPERSEDED head, charges it to the ledger, and leaves it uncancellable: +/// `Compensation::Abandon` reads `git::head_commit`, which is the other sha. +/// +/// Carried in the same struct as `draft` rather than fetched separately for +/// [`crate::lease::adjudicate`]'s reason one module over: two reads of one +/// subject can disagree, and here the disagreement would be between the flag +/// that decides whether to ready and the sha the ready lands on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Readiness { + /// Whether the pull request is currently a draft. + pub draft: bool, + /// Its GraphQL node id, which is what the ready and re-draft mutations take. + pub node: String, + /// The sha the pull request's head ref points at RIGHT NOW. + pub head: String, +} + +/// Convert a pull request back to a draft. +/// +/// **GraphQL rather than REST, and that is the endpoint's shape rather than a +/// preference.** The REST pulls endpoint will not move a ready pull request back +/// to draft — `convertPullRequestToDraft` is the only mutation that does, which +/// is why the predecessor shelled to a client that speaks it. It is still one +/// POST through [`crate::rest`], to the same host, with the same credential. +/// +/// `false` where it did not happen, swallowed rather than raised: the caller is +/// on an exit path, and a tap that could not close must not replace the real +/// diagnosis with its own. +#[must_use] +pub fn redraft(node: &str) -> bool { + let body = serde_json::json!({ + "query": "mutation($id:ID!){convertPullRequestToDraft(input:{pullRequestId:$id}){clientMutationId}}", + "variables": { "id": node }, + }); + let Some(answer) = crate::rest::post_json("graphql", &body) else { + return false; + }; + if !(200..300).contains(&answer.status) { + return false; + } + // A GRAPHQL ERROR IS A 200, which is the whole reason the status alone is + // not the answer here: the endpoint reports a refused mutation in an + // `errors` array with an OK status, so a caller reading the code would + // report a tap it never closed. + let Ok(document) = serde_json::from_str::(&answer.body) else { + return false; + }; + document.get("errors").is_none() +} + +/// Mark a pull request ready for review — the event that buys the matrix. +/// +/// The exact mirror of [`redraft`], and it is the same endpoint for the same +/// reason: `markPullRequestReadyForReview` is the only mutation that moves a +/// draft, so this is one POST through [`crate::rest`] rather than a second +/// client. +/// +/// `false` where it did not happen. Unlike the tap's, this failure is NOT +/// swallowed by its caller: a ready that did not fire buys no run, so pushing +/// afterwards would wait out the whole ask count on a matrix nobody started — +/// `tests/land.bats`'s *"a ready that fails stops before the push rather than +/// pushing into silence"* is the case, and the successor conserves it. +#[must_use] +pub fn mark_ready(node: &str) -> bool { + let body = serde_json::json!({ + "query": "mutation($id:ID!){markPullRequestReadyForReview(input:{pullRequestId:$id}){clientMutationId}}", + "variables": { "id": node }, + }); + let Some(answer) = crate::rest::post_json("graphql", &body) else { + return false; + }; + if !(200..300).contains(&answer.status) { + return false; + } + // A GRAPHQL ERROR IS A 200 here too, for the reason `redraft` states. + let Ok(document) = serde_json::from_str::(&answer.body) else { + return false; + }; + document.get("errors").is_none() +} + +/// What readying this head would buy. +/// +/// **`Refire` is a state, not a retry**, which is the distinction the shell's +/// eight ready cases exist to hold: a pull request already ready cannot be +/// readied again, so the only way to mint a fresh run on it is to draft it and +/// ready it back. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Spend { + /// Ready it. It is a draft, and readying is what starts CI. + Ready, + /// Draft it and ready it back. It is already ready and the runs on this head + /// will never become an answer, so without this the branch waits forever on + /// a matrix that cannot grade. + Refire, + /// Neither. An answer exists, or one is on its way, or nobody could look. + Nothing, +} + +/// Whether a ready on this head buys a run, spelled over readings already taken. +/// +/// # THE IDEMPOTENCE IS WHY THIS READS THE VERDICT RATHER THAN THE RUN COUNT +/// +/// *"A DRAFT whose push moves nothing readies once, not once and then again"* is +/// the predecessor's own case, and a predicate over *"are there runs"* fails it: +/// the lap that readied leaves a run that is **in flight**, and a second lap +/// reading only presence would draft and ready it again, cancelling the very +/// matrix it just bought. [`crate::checks_green::Pending`] is what tells the +/// three apart, so no conclusion name is spelled here — the consumer's +/// `answered` set already decided which of them is an answer, and a forge's +/// vocabulary under `crates/batten` is non-negotiable rule 1's violation. +/// +/// * `Running` — in flight. Leave it; this is the arm idempotence rests on. +/// * `NoVerdict` — a draft-era `skipped` or a cancelled set: terminal, and never +/// going to answer. Re-fire. +/// * `Unregistered` — a fresh sha carrying no run at all. Re-fire. +/// +/// # A READING NOBODY TOOK SPENDS NOTHING +/// +/// `None` on either argument is could-not-look, and the answer is [`Spend:: +/// Nothing`] — the same posture [`closes_the_tap`] takes, one direction over. +/// There it refuses to strand a head on a failure to look; here it refuses to +/// buy a matrix on one. +#[must_use] +pub fn buys_a_matrix( + is_draft: Option, + reading: Option<&crate::checks_green::Verdict>, +) -> Spend { + let Some(is_draft) = is_draft else { + return Spend::Nothing; + }; + // A DRAFT IS READIED WHATEVER ITS RUNS SAY, and the case that forces it is + // the tap's own leftover: a pull request the tap drafted carries a cancelled + // set, and reading the runs first would leave it stuck as a draft forever. + if is_draft { + return Spend::Ready; + } + match reading { + None | Some(crate::checks_green::Verdict::Green | crate::checks_green::Verdict::Red(_)) => { + Spend::Nothing + } + Some(crate::checks_green::Verdict::Pending(pending)) => match pending { + crate::checks_green::Pending::Running { .. } => Spend::Nothing, + crate::checks_green::Pending::NoVerdict(_) + | crate::checks_green::Pending::Unregistered(_) => Spend::Refire, + }, + } +} + +/// The runs on a head that failed, as ids. +/// +/// **NO PAGE SIZE, deliberately, and the predecessor says why in a sentence +/// worth carrying:** `tests/land.bats`'s keyed-verdict sensor asserts the lander +/// carries no windowed page size, because the fast-forward verdict must be found +/// by its KEY rather than by a window. This is a different endpoint that needs +/// none — a head sha's failed runs are a handful — so the sensor stays exact +/// instead of being spelled past. +/// +/// `None` is could-not-look, and the caller reads it as *not absorbed*: a +/// transient is a claim about the runs, and a claim over a list nobody could +/// read is not one. +#[must_use] +pub fn failed_runs(repo: &str, sha: &str) -> Option> { + let answer = crate::rest::get( + &format!("repos/{repo}/actions/runs?head_sha={sha}&status=failure"), + None, + )?; + let document = serde_json::from_str::(&answer.body).ok()?; + let runs = document.get("workflow_runs")?.as_array()?; + let ids: Vec = runs + .iter() + .filter_map(|run| { + let id = run.get("id")?; + id.as_u64() + .map(|found| found.to_string()) + .or_else(|| id.as_str().map(str::to_owned)) + }) + .collect(); + // AN EMPTY LIST IS NOT AN ANSWER HERE. The predecessor returns non-zero on + // one, and it is right to: "no failed runs" cannot support "the failure was + // a transient", because there is no failure to have been one. + (!ids.is_empty()).then_some(ids) +} + +/// Ask the forge to re-run one run's failed jobs. +/// +/// `false` where the forge refused. **Reported rather than swallowed**, unlike +/// the tap: the predecessor dies here with a remedy naming the exact command, +/// because a lap that believed it re-ran and did not would wait forever for a +/// run nobody started. +#[must_use] +pub fn rerun_failed(repo: &str, run: &str) -> bool { + crate::rest::post(&format!( + "repos/{repo}/actions/runs/{run}/rerun-failed-jobs" + )) +} + +#[cfg(test)] +mod lap_tests { + use super::{Progress, Step, progress}; + use crate::exit::ExitCode::{Internal, Success, Usage, Violation}; + + /// **The discriminating claim: a refusal a rebase would clear laps, and one + /// it would not stops.** + /// + /// Asserted as the whole table rather than as two cases, because the design + /// is the SPLIT rather than either side of it. A version that lapped on + /// everything and a version that stopped on everything both satisfy any + /// single case here; only the pairing rules both out. + #[test] + fn a_refusal_a_rebase_would_clear_laps_and_one_it_would_not_stops() { + // Stops: the tree's own answer. Lapping re-runs a gate against the same + // commits to reach the same verdict, paying it again each time. + assert_eq!(progress(Step::Replay, Violation), Progress::Stop); + assert_eq!(progress(Step::Verify, Violation), Progress::Stop); + + // Laps: the base moved, or nobody has answered. The next replay is the + // remedy for all three. + assert_eq!(progress(Step::Push, Violation), Progress::Lap); + assert_eq!(progress(Step::Wait, Violation), Progress::Lap); + assert_eq!(progress(Step::FastForward, Violation), Progress::Lap); + } + + /// A refused fast-forward is staleness wearing a refusal's clothes. + /// + /// The bot refuses when the head stopped being a direct descendant, which is + /// a fact about the BASE. Stopping on it would halt a branch whose only + /// problem is that trunk advanced — and the mirror error, reading every + /// non-success as "main moved", is what CLOUD-413 measured going wrong twice + /// over across 24 laps. The readings stay apart; the remedy is one lap. + #[test] + fn a_refused_fast_forward_laps_because_it_is_a_fact_about_the_base() { + assert_eq!(progress(Step::FastForward, Violation), Progress::Lap); + assert_ne!(progress(Step::FastForward, Violation), Progress::Stop); + } + + /// Could-not-look means two different things, and which step said it decides. + /// + /// From `wait` and `fast-forward` it is the loop's ordinary state — exit `3` + /// is first-class on those two. From `replay` and `push` it is a clone or a + /// remote this lap cannot read, and there is nothing to lap toward. + /// + /// **`Verify` MOVED SIDES, AND THIS CASE IS WHY THE BUG SURVIVED** + /// (CLOUD-1586). It asserted `Stop`, so the table and the suite agreed with + /// each other and disagreed with three doc comments and the whole + /// `GateReclaims` budget. A case can pin a defect as firmly as it pins a + /// property; what tells them apart is whether anything else in the tree + /// claims otherwise, and here `run_land_verify`, `verify_raced` and + /// `Refusal::Moved`'s own doc all did. + #[test] + fn could_not_look_laps_only_where_it_means_nobody_has_answered_yet() { + assert_eq!(progress(Step::Wait, Internal), Progress::Lap); + assert_eq!(progress(Step::FastForward, Internal), Progress::Lap); + // `run_land_verify` codes ONLY `Refusal::Moved` `Internal`, so this cell + // is "the base moved under the gate" and a replay is the whole remedy. + assert_eq!(progress(Step::Verify, Internal), Progress::Lap); + + assert_eq!(progress(Step::Replay, Internal), Progress::Stop); + assert_eq!(progress(Step::Push, Internal), Progress::Stop); + } + + /// AND THE GATE'S OWN VERDICT STILL STOPS, which is the anti-vacuity half. + /// + /// Without it, "`Verify` laps on `Internal`" could be satisfied by a table + /// that lapped on everything from `Verify` — and a refused tree lapping is + /// the loop spending its budget re-proving a defect. + #[test] + fn a_refused_tree_still_stops_the_lap() { + assert_eq!(progress(Step::Verify, Violation), Progress::Stop); + assert_ne!(progress(Step::Verify, Violation), Progress::Lap); + } + + /// **The freshness probe fails OPEN, which is the opposite of every gate in + /// this module and is the whole of its correctness.** + /// + /// It is an economy, not a gate: it exists to avoid spending a CI matrix on + /// a head whose base moved. A ref read that did not answer is not evidence + /// the base moved, so reading could-not-look as "stale" would stop a landing + /// to save a matrix — the wrong trade in the wrong direction, and the one a + /// fail-closed reading makes by default. + /// + /// Driven over a path that is not a repository, which is the strongest form + /// of could-not-look this suite can produce without a network: `resolve_ref` + /// cannot answer and the conditional read reaches no forge. + #[test] + fn the_freshness_probe_reads_could_not_look_as_carry_on_rather_than_as_stale() { + let dir = std::env::temp_dir().join("batten-land-stale-no-remote"); + let trunk = crate::main_watch::Config { + repo: String::from("nobody/nothing"), + branch: String::from("main"), + base: String::new(), + interval: 1, + }; + + assert_eq!( + super::stale( + &dir, + &mut crate::main_watch::Poll::default(), + &trunk, + "refs/heads/main" + ), + None, + "a probe that could not look must not report the base as moved" + ); + } + + /// **The raced wait TERMINATES when neither arm answers, and this case is + /// here because the failure it pins is a HANG rather than a wrong verdict.** + /// + /// Both arms send on clones of one channel and the outer handle stays live + /// in this function's frame. Forget to drop it and `recv()` waits on a + /// sender that will never send — for ever, inside a `thread::scope` that has + /// already joined both arms. No assertion catches that; only the suite not + /// coming back does. So the case is written to reach exactly that state: + /// `asks: 1`, a forge nothing can reach, so both arms exhaust their count + /// and close their clones without answering. + /// + /// It also pins the direction of an unanswered race. `Unanswered` is a + /// could-not-look and never a verdict — a lap that read an unreachable forge + /// as "green" would fast-forward on nothing, and one that read it as "stale" + /// would burn a lap per network hiccup. + #[test] + fn a_race_neither_arm_answers_returns_rather_than_waiting_on_a_sender() { + let config = crate::pr_watch::Config { + sha: String::from("0000000000000000000000000000000000000000"), + repo: String::from("nobody/nothing"), + interval: 1, + progress: None, + }; + let roster = crate::checks_green::Roster { + required: vec![String::from("ci")], + absent_ok: Vec::new(), + answered: vec![String::from("success")], + fanin: None, + }; + let trunk = crate::main_watch::Config { + repo: String::from("nobody/nothing"), + branch: String::from("main"), + base: String::from("1111111111111111111111111111111111111111"), + interval: 1, + }; + + let mut out = Vec::new(); + // `Ok(_)` matched rather than unwrapped: the only `Err` here is a stream + // that will not accept output, and a `Vec` always accepts, so a panic + // would be reporting the impossible case as the interesting one. + assert_eq!( + super::wait(&config, &roster, &trunk, 1, &|| (), &mut out).ok(), + Some(super::Waited::Unanswered), + "an unreachable forge is a could-not-look, never a verdict about the work" + ); + } + + /// Anti-vacuity: a misconfiguration stops everywhere, and success never does. + /// + /// Without the first half, `Usage` would lap and the loop would spend its + /// whole count asking a question no lap can answer. Without the second, a + /// table that stopped on everything would pass every case above. + #[test] + fn a_misconfiguration_stops_every_step_and_success_stops_none() { + for step in [ + Step::Replay, + Step::Verify, + Step::Push, + Step::Wait, + Step::FastForward, + ] { + assert_eq!( + progress(step, Usage), + Progress::Stop, + "{step:?} must not lap over a clone it cannot be configured to answer" + ); + assert_ne!( + progress(step, Success), + Progress::Stop, + "{step:?} answering cleanly is never a stop" + ); + } + assert_eq!(progress(Step::FastForward, Success), Progress::Landed); + } +} + +#[cfg(test)] +mod tests { + + /// EVERY STEP HAS A DISTINCT, STABLE PHASE TOKEN. + /// + /// Distinct because `task::stamp_for` only moves a stamp when the VALUE + /// changes, so two steps sharing a token would leave `phase_since` frozen + /// across the pair — and `lease::progress_of` reads that as no progress, + /// which is the wrong direction: a live holder reported stalled is one a + /// sibling may reclaim the lease from. + /// + /// Stable because the registry is a store another process reads, possibly + /// written by an older build. Deriving these from `Debug` would let a rename + /// move a stored phase silently, which is what the written-out match refuses. + #[test] + fn every_step_registers_under_its_own_token() { + let steps = [ + Step::Replay, + Step::Verify, + Step::Lease, + Step::Ready, + Step::Push, + Step::Wait, + Step::FastForward, + ]; + let tokens: std::collections::BTreeSet<&str> = + steps.iter().map(|step| step.as_str()).collect(); + assert_eq!( + tokens.len(), + steps.len(), + "two steps sharing a token freeze `phase_since` across the pair: {tokens:?}" + ); + for token in &tokens { + assert!( + !token.is_empty() && token.chars().all(|c| c.is_ascii_lowercase() || c == '-'), + "a phase is a token another process matches, not a label: {token:?}" + ); + } + } + + use super::*; + + /// The verify family writes the same four columns every other family does. + /// + /// **AND BOTH REFUSAL CAUSES WRITE THE SAME LINE**, which is the assertion + /// that stops the operator's diagnosis leaking into a predicate's input. The + /// two arms are here for that rather than for coverage. + #[test] + fn every_verify_outcome_writes_four_columns_led_by_the_kind() { + for outcome in [ + Verified::Clean(String::from("abc1234")), + Verified::Refused { + sha: String::from("abc1234"), + cause: Refusal::Tree, + }, + Verified::Refused { + sha: String::from("abc1234"), + cause: Refusal::Environment { + remedy: String::from("disk-full: reclaim something"), + }, + }, + ] { + let line = outcome.line(); + let columns: Vec<&str> = line.split(' ').collect(); + assert_eq!(columns.len(), 4, "four columns exactly, got {line:?}"); + assert_eq!(columns[0], "verify", "the kind column leads: {line:?}"); + } + // THE CAUSE IS NOT IN THE RECORD, stated as an equality rather than left + // to the shape check above — which four arbitrary columns would satisfy. + assert_eq!( + Verified::Refused { + sha: String::from("abc1234"), + cause: Refusal::Tree, + } + .line(), + Verified::Refused { + sha: String::from("abc1234"), + cause: Refusal::Environment { + remedy: String::from("disk-full: reclaim something"), + }, + } + .line(), + "the operator's diagnosis must not reach a predicate's input" + ); + } + + /// NO DEFAULT COMMAND, asserted rather than described. A default would be a + /// consumer's task name compiled into this crate, which non-negotiable rule + /// 1 forbids — and the failure mode of guessing one is worse than refusing, + /// because a lap would report a gate as clean having run something else. + #[test] + fn an_unconfigured_verify_command_refuses_rather_than_guessing() { + let Err(problem) = verify(Path::new("."), "work", &[], &[], &[]) else { + panic!("an empty command must refuse rather than run something"); + }; + assert!( + format!("{problem}").contains("does not know what verifying means"), + "the refusal names the missing configuration: {problem}" + ); + } + + /// The push family writes the same four columns every other family does. + /// + /// Asserted here rather than trusted, for `Replay`'s reason one family over: + /// a module narrows on column zero and splits on four, so a family that + /// shipped three columns would be read through the wrong lens by every + /// predicate over this store rather than by its own. + #[test] + fn every_push_outcome_writes_four_columns_led_by_the_kind() { + for outcome in [Pushed::Landed(String::from("abc1234")), Pushed::Raced] { + let line = outcome.line(); + let columns: Vec<&str> = line.split(' ').collect(); + assert_eq!(columns.len(), 4, "four columns exactly, got {line:?}"); + assert_eq!(columns[0], "push", "the kind column leads: {line:?}"); + } + } + + /// The four columns the vendored module reads, pinned on this side too. + /// + /// The writer and the reader are deliberately not each other's authority — + /// the module ships into every consumer's binary and this is one consumer — + /// so the layout is asserted at both ends rather than derived at one. + #[test] + fn every_outcome_writes_four_columns_led_by_the_kind() { + for outcome in [ + Replay::Conflicted { + commit: String::from("abc1234"), + paths: vec![String::from("shared.txt")], + }, + Replay::Current, + Replay::Replayed { + head: String::from("def5678"), + commits: 1, + }, + ] { + let line = outcome.line(); + let columns: Vec<&str> = line.split(' ').collect(); + assert_eq!(columns.len(), 4, "four columns exactly, got {line:?}"); + assert_eq!(columns[0], "rebase", "the kind column leads: {line:?}"); + } + } + + /// A conflict names the commit and the first path, and nothing else. + #[test] + fn a_conflict_records_a_pointer_and_never_a_hunk() { + let line = Replay::Conflicted { + commit: String::from("abc1234"), + paths: vec![String::from("shared.txt"), String::from("other.txt")], + } + .line(); + assert_eq!(line, "rebase conflicted abc1234 shared.txt"); + } + + /// A CONFLICT WITH NO PATH still writes four columns, with `-` where the + /// pointer would be. An empty column would shift every column after it, and + /// the reader's own length check would then skip the line entirely — turning + /// the loop's one human stop into silence. + #[test] + fn a_conflict_with_no_path_keeps_the_column_count() { + let line = Replay::Conflicted { + commit: String::from("abc1234"), + paths: Vec::new(), + } + .line(); + assert_eq!(line, "rebase conflicted abc1234 -"); + } + + /// The two clean outcomes are distinguishable from each other and from a + /// conflict — a replay that minted a sha, and one that had nothing to mint. + #[test] + fn the_clean_outcomes_are_told_apart() { + assert_eq!(Replay::Current.line(), "rebase current - -"); + assert_eq!( + Replay::Replayed { + head: String::from("def5678"), + commits: 3, + } + .line(), + "rebase replayed def5678 -" + ); + } + + #[test] + fn a_remote_reference_resolves_to_its_tracking_ref() { + assert_eq!(tracking_ref("refs/heads/main"), "refs/remotes/origin/main"); + assert_eq!(tracking_ref("main"), "refs/remotes/origin/main"); + } + + /// A wait line carries four columns led by its kind, like a replay line. + #[test] + fn every_wait_answer_writes_four_columns_led_by_the_kind() { + for answer in [ + Answered { + arm: Arm::Green, + verdict: Some(String::from("success")), + sha: String::from("abc1234"), + }, + Answered { + arm: Arm::Stale, + verdict: None, + sha: String::from("abc1234"), + }, + ] { + let line = answer.line(); + let columns: Vec<&str> = line.split(' ').collect(); + assert_eq!(columns.len(), 4, "four columns exactly, got {line:?}"); + assert_eq!(columns[0], "wait", "the kind column leads: {line:?}"); + } + } + + /// **THE LOSER IS WRITTEN AS COULD-NOT-LOOK, and that is what makes the race + /// legible.** An arm abandoned unread records `-` rather than being omitted: + /// omitting it would make a lap that raced properly and a lap that read both + /// sides produce records nothing can tell apart. + #[test] + fn a_voided_loser_records_could_not_look_rather_than_vanishing() { + assert_eq!( + Answered { arm: Arm::Stale, verdict: None, sha: String::from("abc1234"), @@ -784,4 +2979,774 @@ mod tests { fn the_two_arms_carry_different_tokens() { assert_ne!(Arm::Green.token(), Arm::Stale.token()); } + + /// `|`-separated argvs, and the separator is not a comma for a reason. + #[test] + fn a_declared_gate_list_decodes_to_one_argv_per_entry() { + assert_eq!( + super::body_gates("mise run deferral-check|mise run closing-key-check"), + vec![ + vec![ + String::from("mise"), + String::from("run"), + String::from("deferral-check") + ], + vec![ + String::from("mise"), + String::from("run"), + String::from("closing-key-check") + ], + ], + "a comma would give one argv per WORD" + ); + } + + /// An empty entry is dropped rather than becoming an empty argv. + /// + /// Load-bearing rather than tidy: `ready` treats an unrunnable gate as a + /// refusal, so an empty argv would stop every lap on a trailing separator. + #[test] + fn a_stray_separator_does_not_become_a_gate_that_cannot_run() { + assert!(super::body_gates("").is_empty()); + assert!(super::body_gates(" | ").is_empty()); + assert_eq!(super::body_gates("one|").len(), 1); + } + + /// **THE TWO DIRECTIONS, and they are opposite on purpose.** + /// + /// An empty body is a PASS — the body is fetched from a forge, and one this + /// never saw is not evidence about what the author wrote, which is the + /// predecessor's `[[ -n "$body" ]] &&`. A declared gate that cannot run is a + /// REFUSAL — `mise run ` for a task that does not exist exited + /// non-zero, and treating it as clean is how a renamed gate goes dead. + /// + /// Asserted as the pair, because a version that passed on both and a version + /// that refused on both each satisfy one half. + #[test] + fn an_unseen_body_passes_and_an_unrunnable_gate_refuses() { + let root = std::env::temp_dir(); + let gate = vec![vec![String::from( + "batten-no-such-program-for-the-ready-phase", + )]]; + + assert_eq!( + super::ready(&root, &gate, " \n "), + super::Readied::Clear, + "a body the fetch never produced says nothing, so there is nothing to judge" + ); + + assert_eq!( + super::ready(&root, &gate, "Closes CLOUD-1"), + super::Readied::Unrunnable { + gate: String::from("batten-no-such-program-for-the-ready-phase"), + }, + "a declared gate that cannot run has not passed" + ); + } + + /// **THE POINTER NAMES THE GATE, NOT THE RUNNER.** + /// + /// This read `argv.first()`, which is `mise` for every gate this consumer + /// declares — so a refusal from `deferral-check` and one from + /// `closing-key-check` produced the identical pointer and neither said which + /// had refused. A value that is constant across every possible finding + /// carries no information about which one occurred, which is the same + /// objection this crate makes to a fallback that cannot fail. + /// + /// Driven through `Unrunnable` because that arm needs no live gate: the + /// label is built before the spawn is attempted, so it is the same string + /// either arm would carry. + #[test] + fn a_refusal_names_the_whole_gate_rather_than_the_task_runner() { + let root = std::env::temp_dir(); + let gate = vec![vec![ + String::from("batten-no-such-runner-for-the-ready-phase"), + String::from("run"), + String::from("closing-key-check"), + ]]; + + assert_eq!( + super::ready(&root, &gate, "Closes CLOUD-1"), + super::Readied::Unrunnable { + gate: String::from( + "batten-no-such-runner-for-the-ready-phase run closing-key-check" + ), + }, + "the pointer must distinguish two gates the same runner invokes" + ); + } + + /// No declared gates is a clear ready, and the distinction from `Unrunnable` + /// is the optional-versus-dead one the driver's own header states. + #[test] + fn a_consumer_declaring_no_body_gates_is_clear_rather_than_unrunnable() { + assert_eq!( + super::ready(&std::env::temp_dir(), &[], "Closes CLOUD-1"), + super::Readied::Clear + ); + } + + /// The ready step stops rather than laps, and that is the table's claim. + /// + /// A refusal is about the BODY — a deferral with no ticket, a key the merge + /// will not close — and no rebase clears prose, which puts it beside the + /// replay and the gate rather than beside the push. + #[test] + fn a_body_gate_refusal_stops_the_lap_rather_than_lapping_it() { + use crate::exit::ExitCode::{Internal, Success, Violation}; + assert_eq!( + super::progress(super::Step::Ready, Violation), + super::Progress::Stop + ); + // **AND `Internal` LAPS, which is the split this case used to assert + // away** (review of #848). Both codes stopped, so one transient 403 or + // 5xx on the pulls list ended the landing at exit 3 where every sibling + // arm laps. `Violation` is the tree or the forge state being wrong — a + // refused body gate, a gate declared and unrunnable, a branch with no + // pull request — and no lap changes any of those. `Internal` is a forge + // read that did not answer, and a lap is exactly how you re-ask. + assert_eq!( + super::progress(super::Step::Ready, Internal), + super::Progress::Lap + ); + assert_eq!( + super::progress(super::Step::Ready, Success), + super::Progress::Proceed + ); + // And the contrast that makes it a claim rather than a default: the push + // laps on the same code, because a raced push IS cleared by a rebase. + assert_eq!( + super::progress(super::Step::Push, Violation), + super::Progress::Lap + ); + } + + /// **SPEND IS COUNTED, NOT INFERRED, and this is the case PR #651 produced.** + /// + /// Two laps, both lost to `main` moving under the gate, the ready never + /// reached, zero check-runs on the head — and the refusal announced "having + /// spent 2 CI matrices". The attempt counter is not the spend, and the + /// inference that they agree fails in both directions. + #[test] + fn a_lap_that_bought_nothing_is_not_reported_as_a_spend() { + let mut ledger = Ledger::default(); + ledger.attempt(); + ledger.attempt(); + assert_eq!(ledger.laps, 2, "two attempts were made"); + assert_eq!( + ledger.spent(), + 0, + "and neither bought a matrix, so nothing was spent" + ); + + ledger.attempt(); + ledger.bought_a_matrix(); + assert_eq!(ledger.spent(), 1, "the one site that buys one, counted"); + } + + /// **A pass that spent nothing is refunded, and the refund is the point.** + /// + /// Without it a saturated fleet exhausts the lap budget — a budget that + /// exists to catch "main moves faster than a lap takes" — and then reports + /// THAT diagnosis, which CLOUD-413 measured being wrong twice over across 24 + /// laps. + #[test] + fn a_pass_that_never_won_the_lease_refunds_its_lap() { + let mut ledger = Ledger::default(); + ledger.attempt(); + assert_eq!(ledger.waited(3), Charge::Lap); + assert_eq!(ledger.laps, 0, "the attempt was refunded"); + assert_eq!(ledger.lease_waits, 1, "and charged to its own bound"); + } + + /// **A RECLAIMED GATE IS THE SAME CLASS, AND THE RACE MADE IT COMMON** + /// (CLOUD-1586). + /// + /// This module's header named the gap before [`verify_raced`] existed: *"a + /// lap where `main` moves while the gate runs aborts before the ready, buys + /// nothing, and is charged anyway."* It was survivable while the condition + /// was only discovered AFTER the gate finished. Racing the gate makes the + /// abort deliberate, and this repository measured the condition holding on + /// ~45% of laps — so without the refund the runaway backstop, which defaults + /// to TWO, exhausts on a busy trunk having bought nothing. + /// + /// Fails by: dropping the `saturating_sub` in [`Ledger::reclaimed`], which + /// leaves the attempt charged and reproduces the exhaustion. + #[test] + fn a_reclaimed_gate_refunds_its_lap_and_charges_its_own_bound() { + let mut ledger = Ledger::default(); + ledger.attempt(); + assert_eq!(ledger.reclaimed(3), Charge::Lap); + assert_eq!(ledger.laps, 0, "the attempt was refunded"); + assert_eq!(ledger.gate_reclaims, 1, "and charged to its own bound"); + assert_eq!( + ledger.spent(), + 0, + "a reclaimed gate buys no matrix, which is what makes the refund honest" + ); + } + + /// THE BOUND STILL STOPS, or the refund would be an unbounded loop wearing + /// an accounting change. + /// + /// The generous default is a separate decision from whether the bound binds + /// at all: past it, a trunk moving faster than this gate takes is a real + /// answer and re-lapping cannot change it. + #[test] + fn enough_reclaimed_gates_stop_the_lap_under_their_own_bound() { + let mut ledger = Ledger::default(); + assert_eq!(ledger.reclaimed(1), Charge::Lap); + assert_eq!( + ledger.reclaimed(1), + Charge::Stop(Bound::GateReclaims), + "past the bound the lap stops, and says which bound it was" + ); + } + + /// The three bounds are separate, and exhausting one names it. + /// + /// Asserted as the whole set rather than one arm: a version with one shared + /// counter satisfies any single case here, and only the three together rule + /// it out. The bounds must stay distinct because the refusals differ — a + /// saturated fleet has spent no CI at all, which is the one exhaustion a + /// caller can honestly describe as costless. + #[test] + fn each_bound_is_charged_and_named_separately() { + let mut ledger = Ledger::default(); + assert_eq!(ledger.waited(0), Charge::Stop(Bound::LeaseWaits)); + + let mut ledger = Ledger::default(); + assert_eq!(ledger.unknown(0), Charge::Stop(Bound::Unknowns)); + + let mut ledger = Ledger::default(); + assert_eq!(ledger.transient(0), Charge::Stop(Bound::Transients)); + + // And a bound not yet reached laps rather than stopping, on each. + let mut ledger = Ledger::default(); + assert_eq!(ledger.waited(1), Charge::Lap); + assert_eq!(ledger.unknown(1), Charge::Lap); + assert_eq!(ledger.transient(1), Charge::Lap); + } + + /// A refund cannot take the attempt counter below zero. + /// + /// Reachable rather than defensive: a bot-silence refund can fire on a pass + /// that never opened a lap, and an underflow there would panic in a loop + /// whose whole purpose is to keep running. + #[test] + fn a_refund_with_no_attempt_to_refund_does_not_underflow() { + let mut ledger = Ledger::default(); + assert_eq!(ledger.unknown(5), Charge::Lap); + assert_eq!(ledger.laps, 0); + } + + /// **The discriminating pair: every record a non-verdict is absorbed, one + /// verdict is not.** + /// + /// A run that reached a verdict was a judgement on this branch, and + /// re-running it would spend jobs to re-learn a real refusal. + #[test] + fn a_failure_before_any_verdict_is_absorbed_and_one_after_is_not() { + let absorbed_runs = absorbed(&[ + String::from("nonverdict 111 provision\n"), + String::from("nonverdict 222 checkout\n"), + ]); + assert_eq!( + absorbed_runs, + Some(vec![ + String::from("nonverdict 111 provision"), + String::from("nonverdict 222 checkout"), + ]), + "neither run reached a verdict, so neither judged the branch" + ); + + assert_eq!( + absorbed(&[ + String::from("nonverdict 111 provision\n"), + String::from("verdict 222 test-failed\n"), + ]), + None, + "ONE verdict means the branch was judged; absorbing the pair would \ + re-run a real refusal" + ); + } + + /// Could-not-look is one reading, and its three causes are deliberately + /// indistinguishable: no failed runs, an empty scan, and a scan that + /// answered nothing. No caller can act differently on which. + #[test] + fn an_empty_scan_is_could_not_look_rather_than_an_absorbed_transient() { + assert_eq!(absorbed(&[]), None); + assert_eq!(absorbed(&[String::new()]), None); + assert_eq!(absorbed(&[String::from(" \n\n")]), None); + } + + /// **THE FIXTURE NAMES ARE GENERIC, and that is rule 1 rather than taste.** + /// + /// The first draft spelled these as this repository's own workflow paths and + /// `document_facts::no_artifact_name_reaches_the_core` refused it: the core + /// knows that a run carries a workflow PATH, and WHICH path carries the + /// fan-in is the consumer's — declared in its config, never here. A fixture + /// naming one puts a consumer's artifact inside `crates/batten`, which a grep + /// is supposed to return nothing for. + /// + /// **AND THE SECOND DRAFT NAMED IT IN THIS COMMENT**, to explain the first — + /// which the gate refused again, correctly. The rule is a grep, so prose + /// spelling the path is the same hit as code spelling it, and a reader copies + /// what an explanation shows them. `.claude/rules/policy-modules.md` records + /// the identical shape one domain over. + /// + /// The predicate treats the path as an opaque token, so a generic name + /// exercises it identically — which is the tell that the specific one was + /// never carrying anything. + fn run(id: &str, path: &str) -> Spending { + Spending { + id: String::from(id), + path: String::from(path), + } + } + + /// **THE ROW THAT MATTERS: the run carrying the fan-in is never cancelled.** + /// + /// `final` is the one context branch protection requires, and it is + /// `always()` over a `needs:` assertion — so cancelling its run leaves that + /// context `cancelled`, which is not an answer. The saving would buy a + /// branch that can never grade and never land, which is strictly worse than + /// paying for the matrix. + #[test] + fn the_run_carrying_the_fan_in_is_spared_and_the_rest_are_not() { + let (doomed, spared) = worthless( + &[ + run("1", "sibling-a.yml"), + run("2", "the-fan-in.yml"), + run("3", "sibling-b.yml"), + ], + &FanIn::from_workflow_path("the-fan-in.yml"), + ); + assert_eq!(spared, 1, "exactly the fan-in's run"); + assert_eq!( + doomed.iter().map(|r| r.id.as_str()).collect::>(), + vec!["1", "3"], + "and every sibling is still doomed: {doomed:?}" + ); + } + + /// A fan-in declared for a file no run carries spares nothing — and still + /// cancels the rest. + /// + /// The anti-vacuity mirror for the pair above: a predicate that spared + /// everything would satisfy the sparing half and save nothing at all. + #[test] + fn a_fan_in_no_run_carries_spares_nothing_and_still_cancels() { + let (doomed, spared) = worthless( + &[run("1", "sibling-a.yml"), run("2", "sibling-b.yml")], + &FanIn::from_workflow_path("no-run-carries-this.yml"), + ); + assert_eq!(spared, 0); + assert_eq!(doomed.len(), 2, "nothing is spared by accident"); + } + + /// **AN UNSET FAN-IN CANCELS NOTHING RATHER THAN GUESSING, and the split + /// between the two halves of that is where the first port went wrong.** + /// + /// `worthless` is a set difference and cannot refuse — with no name to + /// spare, every run is doomed, and that is the honest answer for a pure + /// function. The first draft stopped there and wrote "so the CALLER must + /// refuse", which was a note rather than a guard: `abandon` had no such + /// check, so an unset declaration would have cancelled EVERY run including + /// the fan-in's, whose cancelled context is not an answer and wedges the + /// branch. + /// + /// `abandon-matrix.bats` refuses to run at all without the declaration. + /// The refusal lives in `abandon` now; this case pins the pure half's shape + /// so the guard cannot quietly move back down here and stop refusing. + #[test] + fn an_empty_fan_in_name_matches_no_run_so_the_refusal_lives_in_abandon() { + let (doomed, spared) = worthless( + &[run("1", "the-fan-in.yml")], + &FanIn::from_workflow_path(""), + ); + assert_eq!(spared, 0, "a set difference cannot spare an unnamed file"); + assert_eq!( + doomed.len(), + 1, + "which is why `abandon` refuses before ever calling this" + ); + } + + /// Nothing in flight is a clean no-op. + #[test] + fn nothing_in_flight_cancels_nothing() { + let (doomed, spared) = worthless(&[], &FanIn::from_workflow_path("the-fan-in.yml")); + assert!(doomed.is_empty()); + assert_eq!(spared, 0); + } + + fn tap(landed: bool, held: bool, draft: Option, v: Option) -> Tap { + Tap { + landed, + singleton_held: held, + is_draft: draft, + verdict: v, + } + } + + /// **THE PAIR THE TAP EXISTS FOR: red closes it, green leaves it open.** + /// + /// The predecessor's header states the leak — "stopping on a red run without + /// closing the tap is a leak this exists to plug: CI skips drafts, so + /// re-drafting is what stops the next push, from any source, spending + /// another runner on a failure nobody has fixed yet." + #[test] + fn a_red_head_is_redrafted_and_a_green_one_is_left_ready() { + assert!( + closes_the_tap(&tap(false, true, Some(false), Some(TapVerdict::Red))), + "a red run must stop the next push" + ); + assert!( + !closes_the_tap(&tap(false, true, Some(false), Some(TapVerdict::Green))), + "AND A GREEN ONE IS LEFT READY — the resume costs nothing from here" + ); + } + + /// **COULD NOT LOOK IS NOT RED, and this is the arm a collapsed read loses.** + /// + /// Drafting on a reading nobody took punishes a network blip with a stopped + /// branch. The predecessor spells it as an exit code it declines to act on; + /// here it is `None`, which is the same three-valued reading typed. + #[test] + fn a_reading_that_could_not_be_taken_never_strands_the_head() { + assert!( + !closes_the_tap(&tap(false, true, Some(false), None)), + "no checks verdict is not a red one" + ); + assert!( + !closes_the_tap(&tap(false, true, None, Some(TapVerdict::Red))), + "and neither is a draft state that would not read" + ); + } + + /// Pending closes the tap too: the resume needs a fresh run whatever + /// happens, so the draft costs nothing and stops every push until one starts. + #[test] + fn a_pending_head_closes_the_tap_because_the_resume_needs_a_fresh_run() { + assert!(closes_the_tap(&tap( + false, + true, + Some(false), + Some(TapVerdict::Pending) + ))); + } + + /// **A LAND THAT MERGED OWNS NOTHING TO CLOSE, and one holding no singleton + /// owns neither the lease nor the pull request.** + /// + /// The second is why a REFUSED second land must not touch the live one's + /// work: without it, a session that lost the singleton would re-draft the + /// pull request the winner is actively landing. + #[test] + fn a_merged_lap_and_an_unheld_singleton_both_close_nothing() { + assert!( + !closes_the_tap(&tap(true, true, Some(false), Some(TapVerdict::Red))), + "a merge closes nothing" + ); + assert!( + !closes_the_tap(&tap(false, false, Some(false), Some(TapVerdict::Red))), + "and a lap that never took the singleton owns nothing to close" + ); + } + + /// An already-drafted pull request is already closed, and asking is what + /// keeps a silent no-op from reading like a tap that closed. + #[test] + fn an_already_drafted_pull_request_is_left_alone() { + assert!(!closes_the_tap(&tap( + false, + true, + Some(true), + Some(TapVerdict::Red) + ))); + } + + /// **THE ARM THAT DECIDES WHETHER THE TAP CAN EVER FIRE, and the pair that + /// shows it discriminates.** + /// + /// A lap leaves without landing on one of three readings, and two of them + /// must not be collapsed. `Unanswered` means the green arm asked its whole + /// count and saw nothing terminal — a reading, and the one that closes the + /// tap. `Stale` means the staleness arm won the race and the green arm was + /// voided UNREAD, so there is no reading, and `closes_the_tap` must leave a + /// pull request ready rather than draft it on a failure to look. + /// + /// Collapsing them either way is a live defect: `Stale → Pending` drafts on + /// something nobody read, and `Unanswered → None` makes `Compensation:: + /// Redraft` unreachable from every path the driver has — which is the state + /// PR #848's review found the cluster in. + /// **RED STOPS AND STALE LAPS, AND BOTH ARE EXIT 2.** + /// + /// The wait's two refusals are both a verdict about this repository, so the + /// one exit table gives them the same code — and inventing a fifth for red + /// would be the per-verb exception non-negotiable rule 5 forbids. What tells + /// them apart is the reading: no rebase clears a failing test, and the next + /// replay is exactly the remedy for a base that moved. + /// + /// Without this, a red head is `Unanswered`: the green arm asks its whole + /// count — 3600 by default — and the lap then LAPS, buying a fresh matrix and + /// waiting out another hour on a branch with one failing test. + #[test] + fn a_red_wait_stops_where_a_stale_one_laps_on_the_same_code() { + use crate::exit::ExitCode::Violation; + + assert_eq!( + progress_of(Step::Wait, Violation, Some(TapVerdict::Red)), + Progress::Stop, + "no rebase clears a failing test" + ); + assert_eq!( + progress_of(Step::Wait, Violation, None), + Progress::Lap, + "the staleness arm won the race, and the next replay is the remedy" + ); + } + + /// The qualifier reaches ONE cell and leaves the table alone otherwise — + /// without this, a validator that stopped everything would satisfy the case + /// above. + #[test] + fn the_reading_qualifies_the_wait_row_and_nothing_else() { + use crate::exit::ExitCode::{Internal, Success, Usage, Violation}; + + for step in [ + Step::Replay, + Step::Verify, + Step::Ready, + Step::Push, + Step::Wait, + Step::FastForward, + ] { + for code in [Success, Usage, Violation, Internal] { + for seen in [None, Some(TapVerdict::Green), Some(TapVerdict::Pending)] { + assert_eq!( + progress_of(step, code, seen), + progress(step, code), + "{step:?}/{code:?}/{seen:?} must read as the table does" + ); + } + // And the red reading moves only the wait's own refusal. + if !(step == Step::Wait && code == Violation) { + assert_eq!( + progress_of(step, code, Some(TapVerdict::Red)), + progress(step, code), + "{step:?}/{code:?} is not the wait's refusal" + ); + } + } + } + } + + #[test] + fn a_wait_that_read_nothing_is_not_a_pending_reading() { + assert_eq!( + tap_verdict(&Waited::Unanswered), + Some(TapVerdict::Pending), + "asking the full count and seeing nothing terminal IS a reading" + ); + assert_eq!( + tap_verdict(&Waited::Stale { + base: String::from("0000000"), + }), + None, + "the green arm was voided unread, so nobody looked" + ); + assert_eq!( + tap_verdict(&Waited::Green { + verdict: String::from("green"), + }), + Some(TapVerdict::Green) + ); + assert_eq!( + tap_verdict(&Waited::Red { + findings: Vec::new(), + }), + Some(TapVerdict::Red), + "and a red answer is an answer, which is what stops the lap" + ); + } + + /// And the mapping reaches the decision: a lap that ran out of asks over a + /// pull request it owns closes the tap, and one whose base moved does not. + #[test] + fn an_unanswered_lap_closes_the_tap_and_a_stale_one_does_not() { + let unanswered = tap_verdict(&Waited::Unanswered); + assert!(closes_the_tap(&tap(false, true, Some(false), unanswered))); + + let stale = tap_verdict(&Waited::Stale { + base: String::from("0000000"), + }); + assert!(!closes_the_tap(&tap(false, true, Some(false), stale))); + } + + /// **A DRAFT IS READIED WHATEVER ITS RUNS SAY**, and the case that forces it + /// is the tap's own leftover: a pull request the tap drafted carries a + /// cancelled set, and reading the runs first would leave it a draft forever. + /// `tests/land.bats` states it as *"the re-drafted PR a cancelled set left + /// behind is readied, not stuck"*. + #[test] + fn a_draft_is_readied_whatever_its_head_carries() { + use crate::checks_green::{Pending, Verdict}; + + for reading in [ + None, + Some(Verdict::Green), + Some(Verdict::Red(Vec::new())), + Some(Verdict::Pending(Pending::NoVerdict(Vec::new()))), + ] { + assert_eq!(buys_a_matrix(Some(true), reading.as_ref()), Spend::Ready); + } + } + + /// **THE IDEMPOTENCE ARM, and it is the one a run count gets wrong.** + /// + /// *"A DRAFT whose push moves nothing readies once, not once and then + /// again"*: the lap that readied leaves a run IN FLIGHT, so a later lap + /// reading only *are there runs* would draft and ready it again — cancelling + /// the matrix it had just bought. `Pending::Running` is what tells that from + /// a set that will never grade. + #[test] + fn a_run_still_in_flight_buys_nothing_and_a_set_that_cannot_grade_refires() { + use crate::checks_green::{Pending, Verdict}; + + let running = Verdict::Pending(Pending::Running { + pending: 1, + graded: 0, + }); + assert_eq!( + buys_a_matrix(Some(false), Some(&running)), + Spend::Nothing, + "re-firing here cancels the run this lap just paid for" + ); + + let cancelled = Verdict::Pending(Pending::NoVerdict(Vec::new())); + assert_eq!( + buys_a_matrix(Some(false), Some(&cancelled)), + Spend::Refire, + "a skipped or cancelled set is terminal and will never answer" + ); + let fresh = Verdict::Pending(Pending::Unregistered(Vec::new())); + assert_eq!( + buys_a_matrix(Some(false), Some(&fresh)), + Spend::Refire, + "and a head carrying no run at all has nothing to wait for" + ); + } + + /// An answer that exists buys nothing, and a reading nobody took buys + /// nothing either — the same could-not-look posture [`closes_the_tap`] takes + /// one direction over. + #[test] + fn an_answered_head_and_an_unread_one_both_spend_nothing() { + use crate::checks_green::Verdict; + + assert_eq!( + buys_a_matrix(Some(false), Some(&Verdict::Green)), + Spend::Nothing + ); + assert_eq!( + buys_a_matrix(Some(false), Some(&Verdict::Red(Vec::new()))), + Spend::Nothing + ); + assert_eq!(buys_a_matrix(Some(false), None), Spend::Nothing); + assert_eq!( + buys_a_matrix(None, Some(&Verdict::Green)), + Spend::Nothing, + "a draft state nobody could read is not a licence to spend" + ); + } + + /// The narrowing keeps every verdict's arm and drops only the findings, so a + /// predicate over them cannot grow here later. + #[test] + fn every_checks_verdict_narrows_to_exactly_one_tap_arm() { + use crate::checks_green::{Pending, Verdict}; + + assert_eq!(TapVerdict::of(&Verdict::Green), TapVerdict::Green); + assert_eq!(TapVerdict::of(&Verdict::Red(Vec::new())), TapVerdict::Red); + assert_eq!( + TapVerdict::of(&Verdict::Pending(Pending::Unregistered(Vec::new()))), + TapVerdict::Pending + ); + } + + /// **THE PRUNE'S DISCRIMINATING PAIR.** A tracking ref the remote no longer + /// advertises is stale; one it still advertises is not. + /// + /// The anti-vacuity half is the one that matters: a predicate pruning + /// everything would satisfy the first assertion and delete the base this + /// clone lands onto. + #[test] + fn a_tracking_ref_the_remote_dropped_is_stale_and_a_live_one_is_not() { + let local = vec![ + String::from("refs/remotes/origin/main"), + String::from("refs/remotes/origin/merged-and-gone"), + ]; + let advertised = vec![String::from("refs/heads/main")]; + assert_eq!( + stale_tracking(&local, &advertised, "refs/remotes/origin/"), + vec![String::from("refs/remotes/origin/merged-and-gone")], + "exactly the ref the remote dropped" + ); + } + + /// **THE SPELLINGS DIFFER ON THE TWO SIDES**, and comparing them raw prunes + /// everything: the remote advertises `refs/heads/x` where this clone holds + /// `refs/remotes/origin/x`. + #[test] + fn the_branch_name_is_compared_rather_than_the_ref_name() { + let local = vec![String::from("refs/remotes/origin/main")]; + assert!( + stale_tracking( + &local, + &[String::from("refs/heads/main")], + "refs/remotes/origin/" + ) + .is_empty(), + "a live branch must survive the spelling difference" + ); + } + + /// A local branch is not a tracking ref, so the prefix filter is what keeps + /// this from deleting the work it is landing. + #[test] + fn a_local_branch_is_never_pruned() { + let local = vec![ + String::from("refs/heads/my-work"), + String::from("refs/remotes/origin/main"), + ]; + assert!( + stale_tracking( + &local, + &[String::from("refs/heads/main")], + "refs/remotes/origin/" + ) + .is_empty(), + "refs/heads is out of scope whatever the remote says" + ); + } + + /// **AN EMPTY ADVERTISEMENT PRUNES EVERYTHING, which is why the caller must + /// never hand one over from a failed read.** + /// + /// Stated as a case rather than guarded here: the function is a set + /// difference and cannot tell "the remote has no branches" from "the read + /// failed". The caller owns that distinction, and this is the case that says + /// so out loud. + #[test] + fn an_empty_advertisement_prunes_every_tracking_ref() { + let local = vec![String::from("refs/remotes/origin/main")]; + assert_eq!( + stale_tracking(&local, &[], "refs/remotes/origin/").len(), + 1, + "so a caller that could not read the remote must not call this" + ); + } } diff --git a/crates/batten/src/landed.rs b/crates/batten/src/landed.rs index 64ecc4f53..6182828b4 100644 --- a/crates/batten/src/landed.rs +++ b/crates/batten/src/landed.rs @@ -235,11 +235,40 @@ pub fn decide(rows: &[Row], evidence: &Evidence) -> Report { let mut findings = Vec::new(); for row in rows { - // DIRECTION ONE: the board is behind git. Only In Progress is swept, - // because a Backlog or Todo row whose key appears on `main` is the - // ordinary case — a commit may cite a row it does not implement, which - // is the whole reason `claimed-keys` distinguishes closing from naming. - if row.is_in_progress() && evidence.landed(&row.id) { + // THE BOARD IS BEHIND GIT. Only In Progress is swept, because a Backlog + // or Todo row whose key appears on `main` is the ordinary case — a commit + // may cite a row it does not implement, which is the whole reason + // `claimed-keys` distinguishes closing from naming. + // + // Bound here rather than at its arm so the arm can be an `else if`: the + // two arms are mutually exclusive by the paragraph below, and spelling + // that as a chain is what keeps the exclusion structural. + let behind_git = row.is_in_progress() && evidence.landed(&row.id); + + // **THE DECLINE IS ASKED FIRST, because it outranks the landing and the + // two arms are mutually exclusive** (review of #848). A row can satisfy + // both — its key is in the landed union AND a pull request body declined + // it — and they carry OPPOSITE remedies: `BehindGit` says advance to In + // Review, `DeclinedButAdvanced` says put it back in Todo. Whichever ran + // first decided, and the landed arm ran first, so an explicit human + // `DO-NOT-CLOSE` was answered with "advance it" — inverting the arm this + // module's own doc calls load-bearing. + // + // The decline wins because it is the one statement here that needs no + // inference: derived evidence says a commit mentioning the key reached + // `main`, and a decline says a person looked at that and said no. + // + // A declined row still in Todo passes, so this is not a blanket refusal + // of the marker: `DO-NOT-CLOSE` on a row nothing advanced is the marker + // working. + if row.is_started() && evidence.declined.contains(&row.id) { + findings.push(Finding { + id: row.id.clone(), + holds: row.status.clone(), + reason: Reason::DeclinedButAdvanced, + asserted_by: None, + }); + } else if behind_git { findings.push(Finding { id: row.id.clone(), holds: row.status.clone(), @@ -256,24 +285,6 @@ pub fn decide(rows: &[Row], evidence: &Evidence) -> Report { None }, }); - continue; - } - - // DIRECTION TWO: the board is ahead of nothing. A key the body DECLINED - // sitting in a started column is dishonest whoever wrote the - // transition — which is what makes this arm decidable where the - // served-key arm is not (see the module doc). - // - // A declined row still in Todo passes, so this is not a blanket refusal - // of the marker: `DO-NOT-CLOSE` on a row nothing advanced is the marker - // working. - if row.is_started() && evidence.declined.contains(&row.id) { - findings.push(Finding { - id: row.id.clone(), - holds: row.status.clone(), - reason: Reason::DeclinedButAdvanced, - asserted_by: None, - }); } } diff --git a/crates/batten/src/lease.rs b/crates/batten/src/lease.rs index f304d74b2..ad8686000 100644 --- a/crates/batten/src/lease.rs +++ b/crates/batten/src/lease.rs @@ -46,6 +46,7 @@ //! what CLOUD-689's ceiling and CLOUD-747's no-runtime assertion both refuse. use std::collections::BTreeMap; +use std::path::Path; use crate::Result; use crate::fetch::{self, Call}; @@ -108,19 +109,13 @@ impl Advertisement { /// The bearer token this repository's remote needs, or `None`. /// -/// **Resolved here and returned to nobody.** It is deliberately not a field of -/// [`Terms`] or of any other value: a token in a struct is a token in that -/// struct's `Debug`, and non-negotiable rule 4 makes every report here a pointer. -/// Keeping it inside the two functions that build a request means there is no -/// value a caller could print by accident. -/// -/// `GH_TOKEN` first, matching the forge CLI's own precedence, so a session that -/// set one for that tool does not have to set a second. +/// **Delegated to [`crate::rest::credential`]**, which is this function +/// promoted rather than a second reader. The four spawns CLOUD-1338 removed all +/// justified themselves with *"this crate carries no HTTP client that resolves a +/// forge credential"*, and one of them was written in this file — so the reader +/// has one home now and the sentence has nowhere left to be true. fn credential() -> Option { - ["GH_TOKEN", "GITHUB_TOKEN"] - .into_iter() - .find_map(|name| std::env::var(name).ok()) - .filter(|token| !token.is_empty()) + crate::rest::credential() } /// The request headers for one exchange, with the credential attached when there @@ -129,17 +124,94 @@ fn credential() -> Option { /// **An absent credential is not an error.** A public remote needs none, and a /// private one answers `401`, which every caller here already reports as /// could-not-look rather than as an unheld lease. +/// +/// # GIT-OVER-HTTP TAKES `Basic`, AND THIS SENT `Bearer` +/// +/// These headers go to the SMART-HTTP endpoints — `info/refs`, +/// `git-upload-pack`, `git-receive-pack` — which are not the REST API and do not +/// share its auth scheme. Git's HTTP transport is specified on Basic +/// authentication with the token as the password, and GitHub rejects a bearer +/// token there outright. `crate::rest` is the other half and is correct as it +/// stands: `Bearer` is what `api.github.com` wants. +/// +/// Measured on this branch, one token, four arms: +/// +/// | request | scheme | status | +/// | -------------------------------- | -------- | ------ | +/// | `info/refs?service=git-upload-pack` | `Bearer` | `401` | +/// | `info/refs?service=git-upload-pack` | `Basic` | `200` | +/// | `repos/{owner}/{repo}` | `Bearer` | `200` | +/// | `repos/{owner}/{repo}` (none) | — | `403` | +/// +/// So a configured credential made the fetch FAIL where no credential at all +/// would have succeeded against a public remote — and every caller reports that +/// `401` as could-not-look, which is honest about the reading and silent about +/// the cause. `land` stopped at `fetch main from the remote` with a credential +/// that was valid the whole time. +/// +/// The username is ignored by GitHub for a token — `x-access-token` is the +/// convention its own documentation and tooling use, so it is what a reader +/// grepping for this will recognise. fn headers(accept: &str, content_type: Option<&str>) -> Vec<(String, String)> { + headers_for(accept, content_type, credential().as_deref()) +} + +/// [`headers`]'s decision, over a credential the caller already resolved. +/// +/// **Split from the environment read so the SCHEME is testable**, which is the +/// same split `carries` takes over its two forge calls: reading `GH_TOKEN` is an +/// effect and this is a pure function of what it returned. A case that had to +/// set a process variable to reach the decision would need `unsafe` — which the +/// workspace forbids — and would be asserting over whatever the runner's own +/// environment happened to hold. +fn headers_for( + accept: &str, + content_type: Option<&str>, + token: Option<&str>, +) -> Vec<(String, String)> { let mut headers = vec![(String::from("Accept"), accept.to_owned())]; if let Some(content_type) = content_type { headers.push((String::from("Content-Type"), content_type.to_owned())); } - if let Some(token) = credential() { - headers.push((String::from("Authorization"), format!("Bearer {token}"))); + if let Some(token) = token { + headers.push(( + String::from("Authorization"), + format!("Basic {}", base64_of(&format!("x-access-token:{token}"))), + )); } headers } +/// Standard base64 of `raw`, which is what a `Basic` credential is carried as. +/// +/// **Hand-rolled rather than vendored**, on the trade `query_value` states one +/// screen down: the alphabet and the padding rule are eight lines of RFC 4648, +/// and a dependency here would go through `deny.toml`, `macos-link-check`, +/// `darwin-link`, the ambient-authority bound and the SBOM inventory to buy +/// them. +fn base64_of(raw: &str) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let bytes = raw.as_bytes(); + let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let triple = chunk.iter().enumerate().fold(0_u32, |acc, (index, byte)| { + acc | (u32::from(*byte) << (16 - 8 * index)) + }); + for index in 0..=chunk.len() { + let shift = 18 - 6 * u32::try_from(index).unwrap_or(0); + let sextet = usize::try_from((triple >> shift) & 0x3f).unwrap_or(0); + encoded.push(char::from(ALPHABET[sextet])); + } + // PADDED TO A FOUR-CHARACTER GROUP, because a decoder is entitled to + // require it and a credential is not the place to find out which one + // does not. + for _ in chunk.len()..3 { + encoded.push('='); + } + } + encoded +} + /// Ask the remote what it carries, over the service that will be used next. /// /// **Discovery is service-specific and that is not a formality**: a server may @@ -883,13 +955,27 @@ fn apply_delta(base: &[u8], delta: &[u8]) -> Result> { /// One of a delta header's two little-endian varint sizes. fn delta_size(delta: &[u8], cursor: &mut usize) -> Result { let mut value = 0_usize; - let mut shift = 0; + let mut shift = 0_u32; loop { let byte = *delta .get(*cursor) .ok_or_else(|| anyhow::anyhow!("lease: a delta header runs off its end"))?; *cursor += 1; - value |= usize::from(byte & 0x7f) << shift; + // **THE SHIFT IS BOUNDED, and it was not** (review of #848). `shift += 7` + // with no bound over bytes the REMOTE supplied: ten continuation bytes + // reach 70, which is `attempt to shift left with overflow` in a debug + // build — a panic on a reachable path, which `.claude/rules/rust.md` + // forbids — and a silently masked shift in release, so the decoded size is + // wrong and surfaces as the generic length mismatch rather than as the + // malformed input it is. A truncated or corrupted pack through a flaky + // proxy is enough; no malice required. + // + // A varint wider than the machine's own word cannot describe a size this + // process could allocate, so it is could-not-look rather than a value to + // salvage — the same direction every other reader on this path takes. + value |= usize::from(byte & 0x7f).checked_shl(shift).ok_or_else(|| { + anyhow::anyhow!("lease: a delta header's size varint is out of range") + })?; shift += 7; if byte & 0x80 == 0 { return Ok(value); @@ -908,14 +994,18 @@ fn pack_header(rest: &[u8]) -> Result<(u8, usize, usize)> { .ok_or_else(|| anyhow::anyhow!("lease: the pack ends where an object header should be"))?; let kind = (first >> 4) & 0x07; let mut size = usize::from(first & 0x0f); - let mut shift = 4; + let mut shift = 4_u32; let mut index = 1; let mut byte = first; while byte & 0x80 != 0 { byte = *rest.get(index).ok_or_else(|| { anyhow::anyhow!("lease: an object header runs off the end of the pack") })?; - size |= usize::from(byte & 0x7f) << shift; + // Bounded for `delta_size`'s reason, one function up: the same unbounded + // shift over the same remote-supplied bytes. + size |= usize::from(byte & 0x7f).checked_shl(shift).ok_or_else(|| { + anyhow::anyhow!("lease: an object header's size varint is out of range") + })?; shift += 7; index += 1; } @@ -1233,15 +1323,463 @@ pub struct Terms { pub beat: i64, } +/// How many beats fit inside a TTL — the RATIO the field docs call the property. +/// +/// Written once because it is now read twice: [`Terms::default`] derives the +/// shipped beat from it, and [`terms`] restores it when an operator declares a +/// `LAND_LOCK_HEARTBEAT` that is not narrower than their `LAND_LOCK_TTL`. Two +/// spellings of one relation is exactly the drift this module records elsewhere, +/// and the pair went unchecked for its whole life because the relation lived in +/// prose (review of #848). +const BEATS_PER_TTL: i64 = 4; + +/// The shipped TTL. The beat is derived, so the two cannot be edited apart. +const DEFAULT_TTL: i64 = 120; + impl Default for Terms { fn default() -> Self { Self { remote: String::from("origin"), reference: String::from("refs/heads/batten-land-lock"), // 120s over a 30s beat. See the field docs for why the ratio rather - // than either number is the property. - ttl: 120, - beat: 30, + // than either number is the property, and `BEATS_PER_TTL` for where + // that ratio is written down. + ttl: DEFAULT_TTL, + beat: DEFAULT_TTL / BEATS_PER_TTL, + } + } +} + +/// Why a clone has no lease terms, and the two are not the same answer. +/// +/// **A clone with no remote is a FACT about the clone, not a failure to look.** +/// The could-not-look guard exists so an unreadable lease is never reported as a +/// free one; a repository with no remote has no lease ref to misread, so folding +/// it into that guard made `lease status` an error in every clone that has not +/// been pushed anywhere — including the census fixture, where every other +/// data-channel verb answers cleanly. +/// +/// The distinction is only ever RELAXED for the reporting arms. The write arms +/// refuse either way, because acquiring a lease that has nowhere to live is not +/// something a missing remote makes safe. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum TermsMissing { + /// No remote is configured, so this clone cannot participate in a lease. + NoRemote, + /// A remote exists and something about reading it failed. This is the + /// could-not-look the guard is for. + Unreadable(String), +} + +impl TermsMissing { + /// The diagnostic, for the arms that report one. + #[must_use] + pub fn say(&self, name: &str) -> String { + match self { + TermsMissing::NoRemote => format!("no remote named {name} is configured"), + TermsMissing::Unreadable(reason) => reason.clone(), + } + } +} + +/// Terms for a caller with no clone at all, built from the forge's own +/// environment. +/// +/// **THE PRE-CHECKOUT HALF OF `lease guard`, WHICH HAD NO SUCCESSOR** (review of +/// #848, CLOUD-420). [`terms`] resolves the remote through `git::remotes`, which +/// answers `Ok(vec![])` for a directory that is not a repository — so on the +/// only deployment the verb has, a workflow's step 0 "before any checkout or +/// toolchain install", it landed on [`TermsMissing::NoRemote`] and the lease was +/// never read. The guard ran with its lease half switched off and only the +/// staleness read could stop anything, so two landers could spend matrices +/// concurrently: the exact condition the guard exists to prevent. +/// +/// The predecessor solved it and said so at its own site — +/// *"A throwaway repo in `RUNNER_TEMP` is what lets this run as the genuine FIRST +/// step, before any checkout exists"* — building a `git init` clone with an +/// `extraheader` credential purely so the shell's `git ls-remote` had somewhere +/// to run. This engine needs none of that: [`advertise`] takes a URL and speaks +/// smart HTTP through [`crate::rest`]'s own credential, so the remote is the +/// only thing missing and the environment names it. +/// +/// `None` where it does not — which keeps this a strictly ADDITIONAL reading. A +/// caller that cannot build these terms is exactly where it was before, and the +/// guard's fail-open posture is unchanged. +#[must_use] +pub fn terms_from_environment() -> Option { + let server = std::env::var("GITHUB_SERVER_URL") + .ok() + .filter(|url| !url.trim().is_empty()) + .unwrap_or_else(|| String::from("https://github.com")); + // **A NON-DEFAULT `LAND_LOCK_REMOTE` IS UNANSWERABLE FROM HERE, SO IT IS + // REFUSED RATHER THAN GUESSED** (review of #848). The environment names THIS + // repository; `LAND_LOCK_REMOTE` names an ALIAS, and resolving an alias to a + // URL needs the clone this function exists to work without. Building the + // environment's slug anyway would read a lease ref on the wrong repository — + // finding none, answering `Run`, and spending the matrix while a rival holds + // the real lease. That is the two-lander condition this function was added + // to close, reintroduced for every consumer whose lease does not live on + // `origin`. + // + // `None` is the documented fail-open and leaves such a consumer exactly + // where it was, which is the honest answer: a reading nobody can take. + if remote_name() != "origin" { + return None; + } + let slug = std::env::var("GITHUB_REPOSITORY") + .ok() + .filter(|slug| !slug.trim().is_empty())?; + let mut resolved = Terms { + remote: format!("{}/{slug}", server.trim_end_matches('/')), + ..Terms::default() + }; + // The same two bounds `terms` honours, through the same reader, so a suite + // driving one path does not silently get the shipped values on the other. + if let Some(ttl) = env_secs("LAND_LOCK_TTL") { + resolved.ttl = ttl; + } + if let Some(beat) = env_secs("LAND_LOCK_HEARTBEAT") { + resolved.beat = beat; + } + bound_the_relation(&mut resolved); + if let Ok(reference) = std::env::var("LAND_LOCK_BRANCH") { + resolved.reference = format!("refs/heads/{reference}"); + } + Some(resolved) +} + +/// Restore the TTL/beat relation `BEATS_PER_TTL` declares. +/// +/// **ONE AUTHORITY, because it had two and they were already drifting** (review +/// of #848). [`terms`] and [`terms_from_environment`] each carried a copy, so a +/// correction to one silently left the other enforcing the older rule — which is +/// exactly the shape the clamp itself exists to stop. +/// +/// # Both halves move, and which one moves is the whole decision +/// +/// The BEAT moves first: the TTL is the outer bound — how long a dead holder can +/// wedge the fleet — so an operator who raised it wants it raised, and the beat +/// is an implementation detail of staying alive inside it. +/// +/// **But clamping the beat alone cannot restore the relation below +/// `BEATS_PER_TTL`.** `(ttl / BEATS_PER_TTL).max(1)` is integer division, so +/// `LAND_LOCK_TTL=1` clamped the beat to `1` and left `1 * 4 > 1` — still +/// violated, and worse than the case the clamp was written for: the lease +/// expires at the instant the heartbeat is due, [`Body::expired`] is +/// `now >= expires`, and a waiter's `expired(now) && held_for >= beat` takes it +/// from a live holder on every beat. +/// +/// So the TTL is floored first. A TTL narrower than one beat asks for something +/// the relation cannot express, and the narrowest that CAN express it is +/// `BEATS_PER_TTL` seconds. +fn bound_the_relation(resolved: &mut Terms) { + if resolved.ttl < BEATS_PER_TTL { + resolved.ttl = BEATS_PER_TTL; + } + if resolved.beat.saturating_mul(BEATS_PER_TTL) > resolved.ttl { + resolved.beat = (resolved.ttl / BEATS_PER_TTL).max(1); + } +} + +/// The remote the lease lives on, by configured name. +/// +/// Named here rather than at each reader because [`terms`] and every diagnostic +/// that reports a missing remote must agree on which name went missing. +#[must_use] +pub fn remote_name() -> String { + std::env::var("LAND_LOCK_REMOTE").unwrap_or_else(|_| String::from("origin")) +} + +/// A positive whole number of seconds from the environment, or `None`. +/// +/// **Zero and negative are `None`**, not values: every bound the lease carries is +/// a duration, and a zero TTL or beat would turn a lease into a spin rather than +/// into a tighter test. +#[must_use] +pub fn env_secs(name: &str) -> Option { + std::env::var(name) + .ok()? + .trim() + .parse::() + .ok() + .filter(|seconds| *seconds > 0) +} + +/// Resolve the lease's terms from this checkout. +/// +/// **The remote must resolve to a URL rather than a name.** The transport speaks +/// smart-HTTP over the vendored client, which has no notion of a git remote +/// alias, and a name reaching it would be an unresolvable host rather than a +/// clear refusal here. +/// +/// **It lives in this module rather than beside the verb dispatch**, and that is +/// CLOUD-1148's move rather than tidying: a `[[recorder]]` column now asks the +/// lease for a grade ([`adjudicate`]), and a resolver reachable only from `lib` +/// would have had to be written a second time to serve it — which is the second +/// authority every other duplicated reading in this repository was. +/// +/// # Errors +/// +/// [`TermsMissing`], whose two variants are a fact about the clone and a +/// could-not-look respectively. The distinction is the whole point; see its docs. +pub fn terms(root: &Path) -> std::result::Result { + let name = remote_name(); + let remotes = crate::git::remotes(root) + .map_err(|err| TermsMissing::Unreadable(format!("cannot read this repository: {err}")))?; + let url = remotes + .iter() + .find(|(configured, _)| *configured == name) + .map(|(_, url)| url.clone()); + let Some(url) = url else { + // **AN EMPTY LIST IS TWO ANSWERS, AND THE `map_err` ABOVE REACHES + // NEITHER** (review of #848). `git::remotes` answers `Ok(vec![])` for a + // directory it cannot open at all — its own comment says so, on the + // shell-era reason that a non-zero exit could not be told from an empty + // one — so that `map_err` never fires and `Unreadable`, the + // could-not-look half of this very type, was UNCONSTRUCTIBLE. A clone + // whose `.git` is corrupt or permission-denied reported the FACT "no + // remote is configured" and `lease status` exited 0 over it. + // + // Asked here rather than by widening `git::remotes`, whose empty answer + // every other caller already reads as a fact: this is the one caller + // that needs the two apart, so it takes the second reading itself. + return Err(if crate::git::worktree_root(root).is_err() { + TermsMissing::Unreadable(String::from( + "this directory could not be read as a repository, so whether a lease remote is configured is unknown", + )) + } else { + TermsMissing::NoRemote + }); + }; + let mut resolved = Terms { + remote: url, + ..Terms::default() + }; + // Overridable so a suite can drive the bounds without waiting out a real TTL. + // Each falls back to the shipped default rather than to zero: a TTL of zero + // is a lease that has already lapsed, which would report as a fleet with no + // lease at all rather than as a misconfiguration. + if let Some(ttl) = env_secs("LAND_LOCK_TTL") { + resolved.ttl = ttl; + } + if let Some(beat) = env_secs("LAND_LOCK_HEARTBEAT") { + resolved.beat = beat; + } + // **THE RELATION IS THE SAFETY PROPERTY, AND IT WAS PROSE.** `Terms`' own + // field docs say the TTL is three beats wide on purpose, and every consumer + // of `beat`/`ttl` assumes it — but the two were read INDEPENDENTLY, each + // filtered only for `> 0`, so `LAND_LOCK_HEARTBEAT=120 LAND_LOCK_TTL=30` + // loaded clean and left the lease expired for 90s of every beat. A waiter's + // `body.expired(now) && held_for >= terms.beat` then takes a lease whose + // holder is alive and two landers run concurrently, which is the one thing + // this module exists to prevent (review of #848). + // + // THE TTL IS KEPT AND THE BEAT IS DERIVED, which is not a coin toss between + // two values. The TTL is the OUTER bound — how long a dead holder can wedge + // the fleet — so an operator who raised it wants it raised, and it is the + // half that stays. The beat is an implementation detail of staying alive + // inside it, so it is the half that moves, back to the width the field docs + // already declare. Restoring the relation cannot widen the window a waiter + // sees; it can only shorten it. + // + // **AND THE GUARD IS THE RATIO, NOT THE LIMIT** (review of #848). This fired + // only at `beat >= ttl`, so it enforced "a beat shorter than the TTL" while + // the paragraph above calls the RELATION the safety property. `LAND_LOCK_TTL=31` + // against the shipped 30s beat passed both filters and loaded with a + // one-second margin between a renewal and expiry — and the renewal is a smart + // HTTP round trip, so a second of latency leaves the lease expired while its + // holder is alive. That is the same two-landers outcome the measurement above + // describes, reached by an env var rather than by two. + bound_the_relation(&mut resolved); + if let Ok(reference) = std::env::var("LAND_LOCK_BRANCH") { + resolved.reference = format!("refs/heads/{reference}"); + } + Ok(resolved) +} + +/// Whether an observed lease leaves the clone reading it free to spend. +/// +/// **The decision, extracted from both of its callers, and that is `rust.md`'s +/// rule rather than tidying**: the failing condition is a lease held by a rival +/// on a real remote, which no fixture in this sandbox can produce, so the +/// predicate is tested directly instead of asserting a conclusion over a +/// precondition nothing created. `batten lease status`'s verdict and +/// [`adjudicate`]'s `lease-status` answer are the two callers, and a second copy +/// of this comparison is exactly the drift that made the shell and the engine +/// disagree about the same lease. +/// +/// **Absent, released and expired are one answer here**: the next `acquire` +/// wins, so nothing is authorised away from this clone. +/// +/// **`Garbage` IS NOT IN THAT SET, and the sentence putting it there was false +/// about the column it feeds** (review of #848). It read *"a lease nothing can +/// parse stays held to every DECISION, and the decision this feeds is the +/// `landing-loop` preset's, which reads a could-not-look column rather than this +/// one"* — but [`adjudicate`] turned the `true` into exit `0`, and `batten.toml` +/// maps `"0"` to `authorised`, so the column recorded AUTHORISED. The preset's +/// refusal could not hold and this clone landed beside a live holder. +/// +/// It also contradicted [`Observed::Garbage`]'s own doc one screen up — *"Every +/// decision below still treats this as held"* — which is the reading that +/// survives: a ref that is there and will not parse has not shown this clone +/// owns anything, and the safe direction over a lease is the closed one. +#[must_use] +pub fn authorises_this_clone(observed: &Observed, holder: &str, now: i64) -> bool { + let body = match observed { + Observed::Absent => return true, + Observed::Garbage { .. } => return false, + Observed::Held { body, .. } => body, + }; + if body.released() || body.expired(now) { + return true; + } + body.holder == holder +} + +/// Does `holder` hold this lease RIGHT NOW? +/// +/// # THE COMPLEMENT OF [`authorises_this_clone`], AND NOT ITS NEGATION +/// +/// That predicate answers *may this clone proceed*, so it is `true` for a lease +/// that is absent, released or expired — nobody is in the way. This one answers +/// *do I still own what I took*, and every one of those states is `false`: a +/// lease I released is a lease somebody else may hold. +/// +/// The distinction is why this is a function rather than a comparison at the +/// call site. `unwind_lap` open-coded `body.holder == holder` and dropped both +/// time-varying clauses (review of #848), so a lap whose lease had lapsed still +/// read as owning the pull request and would re-draft one another lander now +/// owns. A comparison cannot go stale; a lease can, and that is the whole +/// content of the two clauses. +#[must_use] +pub fn holds_now(observed: &Observed, holder: &str, now: i64) -> bool { + let Observed::Held { body, .. } = observed else { + // Absent is nobody's. Garbage has not SHOWN this clone owns anything, + // which is the same closed direction `authorises_this_clone` takes over + // a ref that will not parse. + return false; + }; + !body.released() && !body.expired(now) && body.holder == holder +} + +/// The lease reading this run's recorder columns share. +/// +/// `None` is could-not-look, collapsed: no remote, no terms, or a lease that +/// would not read are all answers the caller turns into the same `3`. +/// +/// **A FAILED READING IS NOT CACHED**, which is what keeps this a pairing rather +/// than a latch: a transient failure on the first column must not condemn the +/// second to could-not-look for the rest of the run. Only a reading that +/// succeeded is worth pairing, because only that one has something for the +/// second column to agree with. +fn paired_reading(root: &Path) -> Option<(Terms, Observed)> { + static READING: std::sync::Mutex> = + std::sync::Mutex::new(None); + + // A poisoned lock is could-not-look like any other unreadable input; it is + // not worth a panic on a path whose whole posture is to answer `3`. + let mut held = READING.lock().ok()?; + if let Some((_, resolved, observed)) = held.as_ref().filter(|(at, ..)| at == root) { + return Some((resolved.clone(), observed.clone())); + } + let resolved = terms(root).ok()?; + let observed = observe(&resolved).ok()?; + *held = Some((root.to_path_buf(), resolved.clone(), observed.clone())); + Some((resolved, observed)) +} + +/// What a `[[recorder]]` column may ask the landing lease for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Asked { + /// Does the lease authorise THIS CLONE right now? + Status, + /// Which branch, if any, the live holder admitted behind it. + Successor, +} + +/// One recorder answer, in the exit-status-and-stdout contract a spawned program +/// would have produced (CLOUD-1148 §2). +/// +/// # It answers in the ENGINE'S table, and the consumer's `status` map reconciles +/// +/// `mise-tasks/land-lock.sh status` answered `0` authorising / `1` held +/// elsewhere / `2` could-not-look, and `[program.land-lock-status]` mapped the +/// first two and deliberately left the third unmapped — which is where the +/// lease's fail-open asymmetry is enforced, because an unmapped status records +/// could-not-look and the `landing-loop` preset's refusal cannot hold over it. +/// +/// This returns the engine's one table instead: `0` authorising, `2` held +/// elsewhere, `3` could-not-look. The consumer's map moves with it, once. Every +/// other spelling of the reconciliation — a per-verb exception here, a second +/// table in the recorder — would put the same decision in two places. +/// +/// # `None` is could-not-look and reaches the column as `-` +/// +/// A clone with no remote, an unreadable identity and an unreachable lease all +/// answer `3` rather than `None`, because each is a reading the recorder should +/// store as *could not look* rather than an evaluation that failed. `None` is +/// reserved for the shape [`crate::recorder::evaluate`] already uses it for. +/// # ONE OBSERVATION SERVES BOTH COLUMNS OF ONE RUN +/// +/// **The `verdict` and `successor` columns could describe two different lease +/// states** (review of #848). `crate::recorder` calls this once per [`Asked`], +/// and each call took its own `terms` plus its own [`observe`] — two smart-HTTP +/// round trips each — with nothing pairing them. So the status read at T1 could +/// see a rival's live lease while the successor read at T2 saw the renewal that +/// admitted this branch behind it, and the preset then compared a successor +/// drawn from a lease state that never coexisted with the verdict. +/// +/// That is [`observe`]'s own argument one level out: it takes the sha and the +/// body from ONE read precisely so they cannot describe different leases, and +/// the predecessor's measured 16-of-40 wrong bodies is what it cost when they +/// could. Two columns of one row are the same pairing. +/// +/// So the reading is taken once per root and reused. Scoped to the process — a +/// `batten check` run reads these columns moments apart and then exits — which +/// is what makes the cache a PAIRING rather than a staleness bet: the question +/// is never "is this current", it is "do these two answers describe one lease". +#[must_use] +pub fn adjudicate(asked: Asked, root: &Path, now: i64) -> Option<(i32, String)> { + let unknown = Some((crate::exit::ExitCode::Internal.code(), String::new())); + let Some((_resolved, observed)) = paired_reading(root) else { + return unknown; + }; + match asked { + // THE SUCCESSOR IS SILENT WHERE THERE IS NONE, and silent-and-`0` rather + // than could-not-look: "no reservation stands" is a reading, and the + // preset compares the empty token against a branch name and finds them + // unequal, which is the refusal standing rather than being waved. + Asked::Successor => { + let next = match &observed { + Observed::Held { body, .. } if !body.released() && !body.expired(now) => { + body.next.clone() + } + _ => String::new(), + }; + Some((0, format!("{next}\n"))) + } + Asked::Status => { + let Ok(git_dir) = crate::git::git_dir(root) else { + return unknown; + }; + let Ok(holder) = Local::under(&git_dir).holder() else { + return unknown; + }; + // A REF THAT WILL NOT PARSE IS COULD-NOT-LOOK ON THIS COLUMN, never + // a verdict. `authorises_this_clone` now fails closed on it, which is + // right for a caller deciding whether to spend — but `2` here would + // record `held-elsewhere`, asserting a holder nobody could read. `3` + // is unmapped, so the column reads `-` and the preset sees that it + // could not look. + if matches!(observed, Observed::Garbage { .. }) { + return unknown; + } + if authorises_this_clone(&observed, &holder, now) { + Some((0, String::new())) + } else { + Some((crate::exit::ExitCode::Violation.code(), String::new())) + } } } } @@ -1340,6 +1878,46 @@ pub fn claim(terms: &Terms, holder: &str, branch: &str, head: &str, now: i64) -> } } +/// Does this branch land WITHOUT ever taking the lease? +/// +/// # The population is the LANDER's, and that is the whole predicate +/// +/// Some branches are fast-forwarded by a workflow that fires on a `workflow_run` +/// completion, so no agent holds the lease on their behalf and the runner-side +/// precondition would refuse the very run it exists to let through. Which +/// branches those are is a fact about which workflow lands them, and the workflow +/// selects on the branch NAME — so this does too. +/// +/// **Not `crate::bot::is_lane_bot`, and collapsing the two would be wrong in both +/// directions.** That keys on a forge LOGIN. A human who names a branch with one +/// of these prefixes still gets fast-forwarded by the lander and still holds no +/// lease, so it must be exempt; a lane bot pushing a branch these prefixes do not +/// name is landed the ordinary way and must be judged. +/// +/// **A PREFIX ON THE BRANCH, never a substring anywhere in the ref**, which the +/// predecessor's suite pinned as its own case. Given a full ref, the branch is +/// its `refs/heads/` remainder — a caller handing one over must not have the +/// question answered about the wrong string. +/// +/// An empty prefix is ignored rather than matching everything: a blank row in +/// consumer config is a typo, and reading it as *exempt every branch* would +/// silently switch the whole gate off. +/// +/// **ONE PREFIX, NEVER EVERY LEADING ONE.** `trim_start_matches` strips the +/// pattern repeatedly, so a branch literally named `refs/heads/lane/x` — which +/// git permits under `refs/heads/` — reduced to `lane/x` and was exempted by a +/// `lane/` row it does not belong to. `strip_prefix` removes at most one and +/// falls back to the branch as given, which is the only reading that answers the +/// question about the string the caller actually named. +#[must_use] +pub fn lands_by_fast_forward(branch: &str, prefixes: &[String]) -> bool { + let branch = branch.strip_prefix("refs/heads/").unwrap_or(branch); + prefixes + .iter() + .filter(|prefix| !prefix.is_empty()) + .any(|prefix| branch.starts_with(prefix.as_str())) +} + /// The body a release leaves behind. /// /// **A tombstone, not a delete**: the expiry CASes to `0`, which leaves the lease @@ -1372,6 +1950,54 @@ pub fn renewal(terms: &Terms, body: &Body, progress: Option<&str>, now: i64) -> } } +/// One heartbeat: renew this clone's lease if it still holds it. +/// +/// # THE LAP HAD NO HEARTBEAT AT ALL, WHICH IS WHAT THIS EXISTS FOR +/// +/// The lap acquires once and then spends the whole of CI inside `Step::Wait` +/// (review of #848). The TTL is 120 seconds by default and the wait polls up to +/// an hour, so roughly two minutes into a twenty-minute matrix the lease read +/// EXPIRED, [`authorises`] handed it to the next branch, and a second lander +/// bought a matrix concurrently — the exact overlap the singleton exists to +/// prevent. Worse, the first branch's own later jobs then failed their step-0 +/// guard against the new holder and were cancelled mid-landing. +/// +/// The predecessor backgrounded a heartbeat process, which is why +/// [`crate::run_lease_hold`]'s doc and `note_release`'s both speak as though one +/// exists. The port dropped it and nothing noticed, because a lease that expires +/// under you fails by letting somebody ELSE succeed. +/// +/// # SILENT AND BEST-EFFORT, AND `false` IS NOT AN ALARM +/// +/// Every arm answers `false` without writing anything: no identity, no lease, a +/// lease held by somebody else, a rejected CAS, a push that did not go through. +/// The caller drives this from inside a poll, where a diagnostic per beat would +/// bury the wait's own output, and where a single failed push must NOT be read +/// as a lost lease — [`crate::run_lease_hold`] states the reason and this shares +/// it: the TTL is deliberately several beats wide precisely so one blip is +/// survivable. What a caller does with a run of failures is the caller's; one is +/// not news. +/// +/// Progress is carried forward rather than rewritten, for [`renewal`]'s own +/// reason: a beat that erased it would make the lease unstealable to every rival. +#[must_use] +pub fn beat(root: &Path, terms: &Terms, now: i64) -> bool { + let Ok((_, holder)) = crate::lease_identity(root) else { + return false; + }; + let Ok(observed) = observe(terms) else { + return false; + }; + if !holds_now(&observed, &holder, now) { + return false; + } + let Observed::Held { body, .. } = &observed else { + return false; + }; + let renewed = renewal(terms, body, None, now); + matches!(cas(terms, &observed, &renewed, now), Ok(Outcome::Applied)) +} + /// Fill the one successor slot, re-minting every other field verbatim. /// /// **The WAITER writes this, not the holder**, and that is forced rather than @@ -1800,22 +2426,84 @@ pub fn health(observed: &Observed, terms: &Terms, now: i64) -> Health { "a lease with no holder cannot be released by anyone", )); } + // THE ADMITTED SUCCESSOR (CLOUD-369), rendered ONCE and appended by every arm + // below. The lease bounds confirming runs at two — the holder plus one branch + // `reserve` admitted — and this report is what a human reads on a wedged + // lease. Naming only the holder shows half the occupancy, so the one view + // meant to explain who is spending CI could not name the second spender. + // + // Rendered here rather than at each arm so the four cannot drift into + // describing the same field differently, which is the predecessor's own + // reason for hoisting it. + let behind = successor_clause(body); // The release sentinel, reported as a declaration rather than as an expiry // fifty-odd years in the past. if body.released() { - return Health::Free(format!("free — released by {}", body.holder)); + return Health::Free(format!("free — released by {}{behind}", body.holder)); } - let left = body.expires - now; - if left <= 0 { - return Health::Free(format!("free — lapsed by {} {}s ago", body.holder, -left)); + // **CHECKED, because `expires` is parsed straight out of a ref body somebody + // may have written by hand** — which is the very case the `Wedged` arm below + // exists for (review of #848). `i64::MIN` parses fine, is not `released()`, + // and then this subtraction overflows: a panic under overflow checks, and in + // release a wrap to a large positive `left`, so a lease that lapsed decades + // ago reports as wedged for another nine billion seconds and blocks the fleet + // on a lease that is actually free. `expired()` and `released()` above are + // total; only this was not. + // + // A body whose arithmetic will not close is garbage rather than a duration, + // and garbage is the state this module already refuses to read as an + // occupancy. + // **EXPIRY IS ASKED BEFORE THE ARITHMETIC, AND THE ORDER IS THE FIX** (review + // of #848). The `checked_sub` below removed a panic and kept the wrong + // verdict: an underflowing `expires` took the guard arm and reported + // `Wedged` — blocking — over a lease `Body::expired`, `authorises`, `turn` + // and `authorises_this_clone` ALL already treat as free and takeable. So + // `lease check` exited 2 with "landing is blocked until it expires" while + // the next `acquire` would have won, which is the outcome the comment on the + // guard named as the defect it was closing. + // + // `expired` is the one authority on whether a lease has lapsed, and it is + // total — a comparison, never a subtraction. Reaching for it first means the + // arithmetic below only ever runs on a lease that is genuinely live. + if body.expired(now) { + return Health::Free(format!( + "free — lapsed by {} {}s ago{behind}", + body.holder, + now.saturating_sub(body.expires) + )); } + // A body whose arithmetic will not close is GARBAGE rather than a duration, + // which is what the prose here always said and what the arm now returns. It + // is unreachable for any lease that reached this line — `expired` is false, + // so `expires > now` and the difference is positive — and it is kept because + // a total function that cannot answer must say so rather than pick a side. + let Some(left) = body.expires.checked_sub(now) else { + return Health::Garbage(format!( + "held by {}{behind} with an expiry that will not compare — the body is not a lease this can read", + body.holder + )); + }; if left > terms.ttl { return Health::Wedged(format!( - "held by {} for another {left}s, beyond the {}s any lease may claim", + "held by {}{behind} for another {left}s, beyond the {}s any lease may claim", body.holder, terms.ttl )); } - Health::Held(format!("held by {}, {left}s left", body.holder)) + Health::Held(format!("held by {}{behind}, {left}s left", body.holder)) +} + +/// The admitted successor as a clause, or the empty string. +/// +/// **Advisory exactly like `branch:` and `head:`** — read for the report, never +/// for a verdict. It is absent on every lease minted before CLOUD-369 and on +/// every lease nobody has reserved behind, so an empty reading is the ORDINARY +/// case and the output stays byte-identical whenever it is empty. That +/// byte-identity is asserted by a case of its own in the suite this conserves. +fn successor_clause(body: &Body) -> String { + if body.next.is_empty() { + return String::new(); + } + format!(", {} admitted behind it", body.next) } /// Delete `reference` on `remote`, from whatever it currently reads. @@ -1940,10 +2628,29 @@ pub fn push(remote: &str, repo: &std::path::Path, reference: &str, head: &str) - /// different answers and only one of them is safe to continue from. pub fn fetch(remote: &str, repo: &std::path::Path, reference: &str) -> Result { let advertisement = advertise(remote, Service::UploadPack)?; - let want = advertisement.head_of(reference); + // **QUALIFIED, BECAUSE THE ADVERTISEMENT IS KEYED BY FULL REF NAME.** + // `Advertisement::refs` says so in its own field doc and `head_of` is an + // exact map lookup, but every driver-level caller carries `reference` SHORT + // — `main`, as the CLI positional and the tracking-ref construction both + // spell it. So `head_of("main")` missed `refs/heads/main`, answered `ZERO`, + // and this reported *"{remote} does not advertise main"* about a remote + // whose advertisement carried it twice. Measured against this repository: + // 79,973 bytes of advertisement, `refs/heads/main` present, the fetch + // refusing anyway. + // + // The `refs/` test rather than a slash test, because a branch is legitimately + // `feature/x` and prefixing by "has no slash" would leave exactly those + // unresolvable — which is the same half-right rule that made + // `gitwrite::FullName` accept a slashed short name verbatim. + let qualified = if reference.starts_with("refs/") { + reference.to_owned() + } else { + format!("refs/heads/{reference}") + }; + let want = advertisement.head_of(&qualified); if want == ZERO { return Err(anyhow::anyhow!( - "lease: {remote} does not advertise {reference}" + "lease: {remote} does not advertise {qualified}" )); } // Already in hand: the local odb has it, so there is nothing on the wire to @@ -1953,6 +2660,7 @@ pub fn fetch(remote: &str, repo: &std::path::Path, reference: &str) -> Result Result, + /// Every ref the remote advertised on THIS exchange, by full name. + /// + /// Carried out rather than dropped because the fetch already read it, and + /// the one caller that needs it — the landing path's prune — would otherwise + /// have to take a second advertisement to learn what the first one said. + /// Two readings of "what does the remote carry" is two answers, and the + /// prune is exactly the decision that must not act on the staler one. + /// + /// **Never empty on a successful fetch**, because a fetch that found nothing + /// to want has already failed by then — so a caller may read emptiness as a + /// remote carrying no refs rather than as could-not-look. + pub advertised: Vec, } /// How many local commits are offered as `have` lines. @@ -2057,6 +2778,415 @@ pub fn stop(pid: u32) { .status(); } +// --------------------------------------------------------------------------- +// CLOUD-420 / CLOUD-1148: the composite step-0 guard. +// --------------------------------------------------------------------------- + +/// What the runner's step-0 guard decided. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Guarded { + /// Spend the matrix. + Run { + /// Why, as a pointer a human reads off a green step. + why: String, + }, + /// Do not. The caller cancels the run it is standing in. + Stop { + /// Why, and it carries the REMEDY: a stopped run is a cancelled run with + /// no failed step of its own, so a reader who is not told sees a red + /// check and no cause. + why: String, + }, +} + +/// The guard's decision over the two readings it composes. +/// +/// # STALENESS FIRST, AND THE LEASE IS NOT CONSULTED WHEN IT STOPS +/// +/// The predecessor's ordering, conserved: `ci-lease-precondition.sh` sets `stop` +/// from the staleness row and enters the lease table only `if [[ -z "$stop" ]]`. +/// So `authority` is `None` where the caller never asked — one fewer forge read +/// on a head that is doomed either way — and `None` is not a third verdict. +/// +/// **AN UNREADABLE STALENESS ROW IS NOT A STOP, so it does not skip the lease** +/// (review of #848). This arm returned `Run` before the `authority` match at all, +/// which INVERTS the ordering the paragraph above claims to conserve: in the +/// shell an unreadable row left `stop` UNSET — it said *"cannot read this head's +/// landing mechanism; not judging its age"* and carried on — so the lease table +/// was still entered. Measured consequence: with the forge rate-limited or the +/// credential absent, `decide` yields `Unknown`, the lease was never observed, +/// and a rival's live lease was ignored while this job spent a matrix. +/// +/// `Unknown` still never stops on its own — it contributes no refusal — it just +/// stops being a reason not to ask the other half. +/// +/// # EVERY COULD-NOT-LOOK RUNS +/// +/// This gate is the opposite of every other refusal in this repository. A +/// reading it could not take would stop every job in the fleet, where waving one +/// matrix through costs one matrix. So [`Carries::Unknown`] runs, an absent +/// authority runs, and the only two things that stop are a head that provably +/// does not carry trunk's landing mechanism and a lease that provably names +/// somebody else. +#[must_use] +pub fn guard(carries: &Carries, authority: Option<&Authority>) -> Guarded { + match carries { + Carries::Stale { wanted } => { + return Guarded::Stop { + why: format!( + "this head does not carry {wanted}, so it cannot be serialised against the \ + fleet. Rebase onto current trunk and land with it." + ), + }; + } + Carries::Unknown { .. } | Carries::Current => {} + } + let unjudged = match carries { + Carries::Unknown { because } => format!("{because}; not judging this head's age. "), + _ => String::new(), + }; + match authority { + Some(Authority::Stop(why)) => Guarded::Stop { + why: format!("{unjudged}{why}"), + }, + Some(Authority::Run(why)) => Guarded::Run { + why: format!("{unjudged}{why}"), + }, + // The caller could not read the lease at all. `authorises` fails open by + // contract and so does this. + None => Guarded::Run { + why: format!("{unjudged}the lease could not be read, so nothing refuses this branch"), + }, + } +} + +/// Ask the forge to cancel `run`. +/// +/// `false` when the cancellation was refused, which the caller reports and then +/// runs anyway: a guard that could not stop a run must not also fail the job it +/// is standing in. +#[must_use] +pub fn cancel_run(repo: &str, run: &str) -> bool { + crate::rest::post(&format!("repos/{repo}/actions/runs/{run}/cancel")) +} + +// --------------------------------------------------------------------------- +// CLOUD-1148 §2: does this head carry the landing mechanism trunk has? +// --------------------------------------------------------------------------- + +/// What the staleness read decided. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Carries { + /// The head's history contains trunk's newest landing-mechanism commit. + Current, + /// It does not, so the head cannot be serialised against the fleet. + Stale { + /// Trunk's commit the head is missing. A pointer, never a diff. + wanted: String, + }, + /// The reading could not be taken. + /// + /// **A distinct variant BECAUSE THE CALLER MUST FAIL OPEN ON IT.** Folding + /// it into `Stale` would cancel every run in the fleet on one unreachable + /// forge, and folding it into `Current` is how the predecessor's row went + /// dead. It is neither, and the caller decides — which for this gate means + /// run the matrix. + Unknown { + /// What could not be read. A pointer for a human, never a payload. + because: String, + }, +} + +/// One REST read, as the body text. +/// +/// **IN PROCESS, over [`crate::rest`].** This was a `gh` spawn whose +/// `#[expect(clippy::disallowed_types)]` reason claimed the crate carries no +/// HTTP client that resolves a forge credential — eighty lines from +/// [`credential`]'s predecessor in this same file, which reads `GH_TOKEN` and +/// attaches a bearer header to a [`crate::fetch`] call. The claim was false where +/// it was easiest to check. +/// +/// Empty on any failure, which is the same could-not-look posture +/// [`crate::main_watch::read`] takes: every failure to reach the forge is a +/// reading nobody took, never a verdict. +/// +/// # A NON-2xx BODY IS NOT A READING, AND THE STATUS IS WHAT SAYS SO +/// +/// This returned `answer.body` for every status, so a `401`, a `403` and a +/// rate-limited `5xx` were handed to the callers as text to parse (review of +/// #848). No verdict flips today — the forge's error bodies are JSON OBJECTS, so +/// `newest_landing_commit`'s `as_array()` and `head_carries`'s status match both +/// abandon — but that is the reading being rescued by the accident of a body's +/// SHAPE rather than by a status test, and a proxy that wraps errors in an array +/// or a fifth `status` token would turn a refusal into a verdict. +/// +/// [`crate::rest::Answer::is_reading`] is the one test, and this file's own +/// siblings already take it. +#[must_use] +fn forge_read(path: &str) -> String { + crate::rest::get(path, None).map_or_else(String::new, |answer| { + if answer.is_reading() { + answer.body + } else { + String::new() + } + }) +} + +/// The newest commit at `trunk` touching any of `paths`. +/// +/// One request per path, and the newest sha across them wins. **`per_page=1` +/// rather than a window**, because the question is "what is the latest" and a +/// page of history would be bytes fetched to discard. +/// +/// `None` when no path answered, which is could-not-look rather than "nothing +/// has ever touched the mechanism" — the two are indistinguishable from here and +/// the caller fails open on both. +/// Percent-encode one query VALUE. +/// +/// **A local encoder rather than a crate**, and the trade is stated because it is +/// the kind of thing that gets waved through: the unreserved set is four lines of +/// RFC 3986 and vendoring a dependency here would go through `deny.toml`, +/// `macos-link-check`, `darwin-link`, the ambient-authority bound and the SBOM +/// inventory to buy them. Everything outside `A-Za-z0-9-._~` is escaped, which is +/// the conservative direction: over-escaping a segment the server would have +/// accepted costs nothing, and under-escaping is the defect. +/// Percent-encode one PATH component, keeping `/` as the separator it is. +/// +/// The sibling to [`query_value`], and the difference is the whole reason both +/// exist: a query value has no structure, so `/` is escaped there; a path +/// component like `owner/repo` or a ref named `release/1.x` carries its +/// separators, and escaping them would ask the forge about a repository nobody +/// named. What must NOT survive is `?`, `#` and whitespace, each of which +/// re-keys or truncates the request. +fn path_value(raw: &str) -> String { + let mut encoded = String::with_capacity(raw.len()); + for byte in raw.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') { + encoded.push(char::from(byte)); + } else { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + encoded.push('%'); + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } + encoded +} + +fn query_value(raw: &str) -> String { + let mut encoded = String::with_capacity(raw.len()); + for byte in raw.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + // The two hex digits by hand rather than through `format!`, which + // allocates per byte — and `write!` here would need `std::fmt::Write` + // in scope beside `std::io::Write`, which this module already uses. + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + encoded.push('%'); + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } + encoded +} + +/// The newest commit at `trunk` touching any of `paths`. +/// +/// One request per path, and the newest sha across them wins. **`per_page=1` +/// rather than a window**, because the question is "what is the latest" and a +/// page of history would be bytes fetched to discard. +/// +/// # ORDERED BY ANCESTRY, NEVER BY THE COMMITTER'S DATE +/// +/// This compared `commit.committer.date` across paths, and that is a MUTABLE +/// field: a rebase, a cherry-pick or a hand-set `GIT_COMMITTER_DATE` reorders it +/// freely, and the endpoint's own ordering says nothing across two separate +/// queries. An older trunk commit could therefore win, and [`carries`] would +/// report `Current` for a head missing the later landing commit — the guard +/// answering clean about exactly the staleness it exists to catch. +/// +/// Every candidate is on `trunk`, so they are totally ordered by ancestry, and +/// the newest is the one that CARRIES the others. That is one extra compare per +/// additional path — three, for a four-row declaration — against a guard that +/// already spends one request per path. +/// +/// # A PATH THAT WOULD NOT READ IS COULD-NOT-LOOK FOR THE WHOLE READING +/// +/// Not just an unorderable pair: a response that will not parse, is not an +/// array, or carries an entry with no `sha` abandons the reading too. Skipping +/// one leaves the newest-so-far holding an EARLIER path's commit, and returning +/// that as authoritative is the same too-lenient verdict — a head missing the +/// unread path's landing commit read as `Current` — that the ancestry ordering +/// above exists to prevent. An empty array is not that: it is the answer that +/// nothing on the trunk has touched the path. +/// +/// Where [`head_carries`] cannot answer, the two candidates cannot be ordered at +/// all, and there is no safe way to pick one: taking the older makes the guard +/// too lenient, which is this function's own defect, and taking the newer makes +/// it refuse a head it has no evidence against. So the reading is abandoned and +/// the caller fails open, exactly as it does for a path that never answered. +/// +/// `None` when no path answered, which is could-not-look rather than "nothing +/// has ever touched the mechanism" — the two are indistinguishable from here and +/// the caller fails open on both. +#[must_use] +pub fn newest_landing_commit( + repo: &str, + trunk: &str, + paths: &[String], +) -> Option<(String, String)> { + let mut newest: Option<(String, String)> = None; + for path in paths { + // AN EMPTY ROW ASKS ABOUT THE WHOLE REPOSITORY. `path=` with no value is + // not "no filter I meant to write" to this endpoint — it is every commit + // on the trunk, so one blank entry in a consumer's `landing_paths` makes + // the newest trunk commit the answer and every head that is not tip-of- + // trunk read as stale. Skipped rather than refused, because this reading + // fails open on everything it cannot use. + if path.is_empty() { + continue; + } + // ENCODED, because a configured path is consumer data. A `&` or a `#` in + // one silently truncated or re-keyed the query, so the request asked + // about a different path than the row declared. + let raw = forge_read(&format!( + "repos/{}/commits?sha={}&path={}&per_page=1", + path_value(repo), + query_value(trunk), + query_value(path) + )); + // A PATH THAT WOULD NOT READ ABANDONS THE WHOLE READING, exactly as an + // unorderable pair does below. `continue`ing left `newest` holding an + // EARLIER path's commit and returned it as authoritative, so the guard + // reported `Current` for a head missing whatever the unread path landed + // — the same too-lenient answer the ancestry ordering above exists to + // stop, reached by the other route. + let Ok(document) = serde_json::from_str::(&raw) else { + return None; + }; + let rows = document.as_array()?; + // An EMPTY array is an answer rather than a failure to read: no commit + // on the trunk has ever touched this path. That path contributes no + // candidate and the others still do. + let Some(entry) = rows.first() else { + continue; + }; + let sha = entry.get("sha").and_then(serde_json::Value::as_str)?; + let Some((held, _)) = newest.as_ref() else { + newest = Some((sha.to_owned(), path.clone())); + continue; + }; + if held == sha { + continue; + } + // Does the candidate carry what we hold? Then it is the later commit. + match head_carries(repo, held, sha) { + Some(true) => newest = Some((sha.to_owned(), path.clone())), + Some(false) => {} + None => return None, + } + } + newest +} + +/// Does `head` carry `wanted`? +/// +/// # Server-side ancestry, and the reason is the predecessor's own +/// +/// `ci-lease-precondition.sh` already records why this is an API question rather +/// than a `merge-base --is-ancestor`: that needs a deep fetch and "answers +/// wrongly after a rebase or a cherry-pick, both of which are the normal shape +/// of work here". The compare endpoint answers it in one request against no +/// clone at all, which is what lets the guard stay the genuine FIRST step. +/// +/// `identical` and `ahead` carry it; `behind` and `diverged` do not. +/// [`crate::gitwrite::carries`] is the LOCAL form of the same question, used +/// where a clone exists. +#[must_use] +pub fn head_carries(repo: &str, wanted: &str, head: &str) -> Option { + // ENCODED, for the reason `newest_landing_commit` states two functions up + // and this one did not carry (review of #848). `head` reaches here from a + // CLI positional or a workflow expression, so a `?`, a `#` or a space in it + // re-keys or truncates the path and the forge answers about a DIFFERENT + // comparison — or 404s, which reads back as `Carries::Unknown` and switches + // the staleness half of the guard off without saying so. + let raw = forge_read(&format!( + "repos/{}/compare/{}...{}", + path_value(repo), + path_value(wanted), + path_value(head) + )); + let document = serde_json::from_str::(&raw).ok()?; + let status = document.get("status")?.as_str()?; + match status { + "identical" | "ahead" => Some(true), + "behind" | "diverged" => Some(false), + // A status this does not know is not a guess. The forge has only ever + // sent those four, so a fifth means the contract moved and a verdict + // taken over it would be one nobody checked. + _ => None, + } +} + +/// The staleness DECISION, over readings the caller already took. +/// +/// **Split from the reads so the predicate is testable without a forge.** The +/// two forge calls are `Cost::Effect` and cannot run in a suite; this is a pure +/// function of what they returned, which is the same split +/// `crates/batten/src/speculation.rs` makes and for the same reason: "does this +/// do what the bash did" has to be answerable without a network. +#[must_use] +pub fn decide( + paths: &[String], + head: &str, + wanted: Option<&str>, + ancestral: Option, +) -> Carries { + if paths.is_empty() { + return Carries::Unknown { + because: String::from("no landing paths declared"), + }; + } + if head.trim().is_empty() { + return Carries::Unknown { + because: String::from("no head sha in the environment"), + }; + } + let Some(wanted) = wanted else { + return Carries::Unknown { + because: String::from("no landing commit readable at trunk"), + }; + }; + match ancestral { + Some(true) => Carries::Current, + Some(false) => Carries::Stale { + wanted: wanted.to_owned(), + }, + None => Carries::Unknown { + because: format!("the forge did not compare {wanted} with {head}"), + }, + } +} + +/// The whole staleness read: is this head's landing mechanism current with +/// trunk's? +/// +/// The two reads, then [`decide`]. Nothing branches here that is not in that +/// function, which is what keeps the suite's verdict and production's the same. +#[must_use] +pub fn carries(repo: &str, trunk: &str, head: &str, paths: &[String]) -> Carries { + if paths.is_empty() || head.trim().is_empty() { + return decide(paths, head, None, None); + } + let wanted = newest_landing_commit(repo, trunk, paths).map(|(sha, _)| sha); + let ancestral = wanted + .as_deref() + .and_then(|wanted| head_carries(repo, wanted, head)); + decide(paths, head, wanted.as_deref(), ancestral) +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { @@ -2074,6 +3204,87 @@ mod tests { body } + /// GIT-OVER-HTTP TAKES `Basic`, AND THIS SENT `Bearer` — so a configured + /// credential made the fetch fail where no credential would have succeeded. + /// + /// The scheme is the assertion. `headers` reaches the smart-HTTP endpoints, + /// which are not the REST API and do not share its auth: measured on this + /// branch, one token, `Bearer` answered `401` on `info/refs` and `Basic` + /// answered `200`. A case over the SCHEME rather than over a live request, + /// because the request is `Cost::Effect` and cannot run in a suite — which + /// is the same split `carries` takes one screen down. + #[test] + fn the_smart_http_credential_is_basic_rather_than_bearer() { + let auth = |token: Option<&str>| { + headers_for("application/x-git-upload-pack-advertisement", None, token) + .into_iter() + .find(|(name, _)| name == "Authorization") + .map(|(_, value)| value) + }; + + let sent = auth(Some("s3cr3t")).expect("a configured credential is attached"); + assert!( + sent.starts_with("Basic "), + "git-over-HTTP rejects a bearer token outright: {sent}" + ); + // `x-access-token:s3cr3t` encoded, so the case pins the ENCODING too — a + // base64 wrong by one character is a 401 nobody can read back. + assert_eq!(sent, "Basic eC1hY2Nlc3MtdG9rZW46czNjcjN0"); + + // AND AN ABSENT CREDENTIAL ATTACHES NOTHING, which is not a formality: + // a public remote answers a credential-free request, and this branch + // measured the inverted case — a configured token made the fetch FAIL + // where none would have succeeded. + assert!(auth(None).is_none()); + } + + /// The padding arm, which a credential's own length may never exercise. + #[test] + fn base64_pads_every_remainder() { + assert_eq!(base64_of(""), ""); + assert_eq!(base64_of("f"), "Zg=="); + assert_eq!(base64_of("fo"), "Zm8="); + assert_eq!(base64_of("foo"), "Zm9v"); + assert_eq!(base64_of("foob"), "Zm9vYg=="); + assert_eq!(base64_of("fooba"), "Zm9vYmE="); + assert_eq!(base64_of("foobar"), "Zm9vYmFy"); + } + + /// **A PREFIX ON THE BRANCH, NEVER A SUBSTRING ANYWHERE IN THE REF**, which + /// is `ci-lease-precondition.bats`'s own case: an arm matching mid-ref would + /// exempt a branch that merely mentions a lander's name, and the exemption is + /// the one thing in this gate that switches it off. + #[test] + fn the_exemption_is_a_prefix_on_the_branch_and_not_a_substring() { + let lanes = vec![String::from("lane/"), String::from("cut-")]; + + assert!(lands_by_fast_forward("lane/bump-x", &lanes)); + assert!(lands_by_fast_forward("cut-v1.2.3", &lanes)); + // The full ref resolves to the same answer as the branch name. + assert!(lands_by_fast_forward("refs/heads/lane/bump-x", &lanes)); + + assert!( + !lands_by_fast_forward("fix/not-lane/bump-x", &lanes), + "a mention mid-ref is not the lander's branch" + ); + assert!(!lands_by_fast_forward("main", &lanes)); + } + + /// **AN EMPTY SET JUDGES EVERY BRANCH, and an empty ROW exempts none.** + /// + /// The default has to be *judge it* or a consumer that declares nothing has + /// no gate; and a blank row is a typo, which read as a prefix would match + /// every branch and silently switch the whole gate off — the failure the + /// exemption is most able to cause and least likely to be noticed for. + #[test] + fn nothing_declared_exempts_nothing_and_a_blank_row_exempts_nothing_either() { + assert!(!lands_by_fast_forward("lane/bump-x", &[])); + assert!(!lands_by_fast_forward( + "lane/bump-x", + &[String::new(), String::new()] + )); + } + #[test] fn a_flush_is_a_delimiter_and_never_a_payload() { let body = framed(&["one\n", "", "two\n"]); @@ -2208,6 +3419,116 @@ mod tests { } } + fn with_successor(holder: &str, expires: i64, next: &str) -> Observed { + Observed::Held { + sha: String::from("1111111111111111111111111111111111111111"), + body: Body { + holder: holder.to_owned(), + expires, + next: next.to_owned(), + ..Body::default() + }, + } + } + + /// **CLOUD-369 CLAUSE F: every state names the successor admitted behind + /// the holder, and the first port of `health` named it in none of them.** + /// + /// The lease bounds confirming runs at two — the holder plus one branch + /// `reserve` admitted — and this report is what a human reads on a wedged + /// lease. Naming only the holder shows half the occupancy, so the one view + /// meant to explain who is spending CI could not name the second spender. + /// + /// Five cases in `tests/land-lock-check.bats` assert it, one per state. + #[test] + fn every_health_state_names_the_admitted_successor() { + let terms = Terms::default(); + let now = 1000; + + let held = health(&with_successor("mine", now + 60, "theirs"), &terms, now); + assert!( + format!("{held:?}").contains("theirs admitted behind it"), + "a held lease names who is behind it: {held:?}" + ); + + let released = health(&with_successor("mine", 0, "theirs"), &terms, now); + assert!( + format!("{released:?}").contains("theirs admitted behind it"), + "a RELEASED lease still names who was admitted: {released:?}" + ); + + let lapsed = health(&with_successor("mine", now - 5, "theirs"), &terms, now); + assert!( + format!("{lapsed:?}").contains("theirs admitted behind it"), + "a LAPSED lease names the successor it left behind: {lapsed:?}" + ); + + let wedged = health( + &with_successor("mine", now + terms.ttl + 60, "theirs"), + &terms, + now, + ); + assert!( + format!("{wedged:?}").contains("theirs admitted behind it"), + "a WEDGED lease names the successor too, and still fails: {wedged:?}" + ); + assert!( + matches!(wedged, Health::Wedged(_)), + "and naming it does not soften the verdict: {wedged:?}" + ); + } + + /// **BYTE-IDENTICAL WHEN NO SUCCESSOR IS ADMITTED**, which is the ordinary + /// case: the field is absent on every lease minted before CLOUD-369 and on + /// every lease nobody has reserved behind. + /// + /// The anti-vacuity mirror for the case above — without it, a clause that + /// rendered `, admitted behind it` over an empty name would satisfy every + /// assertion there and corrupt every report that has no successor. + #[test] + fn a_lease_with_no_successor_renders_byte_identically() { + let terms = Terms::default(); + let now = 1000; + for (expires, what) in [ + (now + 60, "held"), + (0, "released"), + (now - 5, "lapsed"), + (now + terms.ttl + 60, "wedged"), + ] { + let rendered = format!( + "{:?}", + health(&with_successor("mine", expires, ""), &terms, now) + ); + assert!( + !rendered.contains("admitted behind it"), + "{what} with no successor must read exactly as it did before: {rendered}" + ); + } + } + + /// A lease at exactly one TTL is the longest legitimate hold, not wedged. + /// + /// `>` rather than `>=`, and the boundary is the whole of it: the protocol + /// mints exactly `now + ttl`, so the maximum legitimate horizon IS one TTL + /// and refusing it would refuse every freshly-acquired lease. + #[test] + fn a_lease_at_exactly_one_ttl_is_the_longest_legitimate_hold() { + let terms = Terms::default(); + let now = 1000; + assert!(matches!( + health(&with_successor("mine", now + terms.ttl, ""), &terms, now), + Health::Held(_) + )); + assert!(matches!( + health( + &with_successor("mine", now + terms.ttl + 1, ""), + &terms, + now + ), + Health::Wedged(_) + )); + } + fn at(expires: i64, holder: &str) -> Observed { Observed::Held { sha: String::from("1111111111111111111111111111111111111111"), @@ -2353,6 +3674,85 @@ mod tests { ); } + /// **The verdict `lease status` and the `lease-status` column both carry.** + /// + /// Shown able to fail on the arm that matters: a live lease held by a rival + /// authorises nobody, and the same lease held by this clone authorises it. + /// Tested here rather than over the binary because the failing condition is + /// a lease on a real remote, which no fixture in this sandbox produces — + /// `.claude/rules/rust.md`'s rule for exactly that case. + /// + /// The regression it pins is measured rather than imagined: the first port + /// of `land-lock.sh status` returned `Success` on every answering path, so + /// `[program.land-lock-status]`'s `held-elsewhere` mapping became + /// unreachable, the recorder wrote `authorised` over a rival's lease, and + /// the `landing-loop` preset allowed the overlapping spend it exists to + /// refuse — a dead gate with a green suite, because no case drove the + /// producer. + #[test] + fn a_live_lease_authorises_its_holder_and_nobody_else() { + let now = 1000; + let live = body("mine", now + 60, ""); + assert!( + authorises_this_clone(&live, "mine", now), + "the holder may spend" + ); + assert!( + !authorises_this_clone(&live, "theirs", now), + "AND A RIVAL MAY NOT — the arm the whole verdict exists for" + ); + } + + /// Absent, released and expired are one answer, because the next `acquire` + /// wins and nothing is authorised away from anybody. + /// + /// The anti-vacuity mirror for the pair above: without it a predicate that + /// answered *not authorised* for every clone but the holder would pass, + /// which would stop a fleet standing on a free lease. + #[test] + fn a_lease_that_holds_nothing_authorises_every_clone() { + let now = 1000; + assert!(authorises_this_clone(&Observed::Absent, "anyone", now)); + assert!( + authorises_this_clone(&body("theirs", 0, ""), "anyone", now), + "a tombstone is a DECLARATION, and `0` is its sentinel rather than an instant" + ); + assert!( + authorises_this_clone(&body("theirs", now, ""), "anyone", now), + "zero seconds left is none left — the `>=` the expiry comparison uses" + ); + } + + /// **A REF THAT WILL NOT PARSE HAS NOT SHOWN THIS CLONE OWNS ANYTHING**, and + /// it lived in the case above asserting the opposite (review of #848). + /// + /// The premise there was that garbage "reaches the preset as could-not-look + /// on the COLUMN, so answering it here as a refusal would fail closed twice". + /// It did not: [`adjudicate`] turned the `true` into exit `0` and + /// `batten.toml` maps `"0"` to `authorised`, so the column recorded + /// AUTHORISED, the `landing-loop` refusal could not hold, and this clone + /// landed beside a live holder. It also contradicted [`Observed::Garbage`]'s + /// own doc — "Every decision below still treats this as held". + /// + /// Moved to its own case because it is not one of "absent, released and + /// expired": those three are provably free, and this one is unread. + /// `adjudicate` answers `3` for it now, which is the could-not-look column + /// the old premise described but did not produce. + #[test] + fn a_lease_nobody_can_parse_authorises_nobody() { + assert!( + !authorises_this_clone( + &Observed::Garbage { + sha: String::from("1111111111111111111111111111111111111111"), + why: String::from("the ref carries no lease body"), + }, + "anyone", + 1000 + ), + "an unparseable lease must not read as free" + ); + } + #[test] fn a_progress_token_is_the_pair_and_nothing_interpreted() { assert_eq!( @@ -3052,3 +4452,203 @@ mod tests { assert!(text.ends_with("0000PACKFAKE"), "got: {text}"); } } + +#[cfg(test)] +// Panicking on a failed assertion is how a test fails loudly. +#[allow(clippy::expect_used)] +mod staleness_tests { + use super::{Authority, Carries, Guarded, decide, guard}; + + /// **THE FAIL-OPEN DIRECTION IS THIS GATE'S WHOLE CORRECTNESS, and it is the + /// opposite of every other refusal in this repository.** + /// + /// A reading the guard could not take would cancel every job in the fleet, + /// where waving one matrix through costs one matrix. So the set below runs, + /// and only a PROVEN stop stops. Asserted as the whole set because a version + /// that ran on everything and one that stopped on everything each satisfy + /// any single case. + #[test] + fn every_reading_it_could_not_take_runs_and_only_a_proven_stop_stops() { + let unknown = Carries::Unknown { + because: String::from("no landing paths declared"), + }; + for (carries, authority) in [ + (&unknown, None), + ( + &unknown, + Some(Authority::Run(String::from("nobody holds it"))), + ), + (&Carries::Current, None), + ] { + let verdict = guard(carries, authority.as_ref()); + assert!( + matches!(verdict, Guarded::Run { .. }), + "a could-not-look must spend the matrix: {verdict:?}" + ); + } + + // And the two that DO stop, which is what keeps the above from being a + // gate that never fires. + assert!(matches!( + guard( + &Carries::Stale { + wanted: String::from("trunksha") + }, + None + ), + Guarded::Stop { .. } + )); + assert!(matches!( + guard( + &Carries::Current, + Some(&Authority::Stop(String::from("somebody else holds it"))) + ), + Guarded::Stop { .. } + )); + } + + /// **AN UNKNOWN STALENESS READING DOES NOT OUTRANK A LEASE THAT SAYS STOP**, + /// and this doc asserted the opposite over a predecessor that says so in the + /// other direction (review of #848). + /// + /// Read at `origin/main:mise-tasks/ci-lease-precondition.sh:163`: the + /// unreadable arm is `say "cannot read this head's mise-tasks/land.sh; not + /// judging its age"` and sets NOTHING, so `if [[ -z "${stop:-}" ]]` holds and + /// the lease table is entered. Only the STALE arm sets `stop=1`. So `Unknown` + /// never skipped the lease; the port made it do so, and the case below pinned + /// the port rather than the behaviour it claimed to conserve. + /// + /// What is true, and what this case actually shows, is the STALE half: a head + /// that provably does not carry trunk's landing mechanism stops without the + /// lease being read at all. + #[test] + fn a_stale_head_stops_without_the_lease_being_consulted() { + // A stop from staleness names the commit the head is missing, so the + // remedy is in the annotation rather than in a follow-up read. + let verdict = guard( + &Carries::Stale { + wanted: String::from("abc123"), + }, + Some(&Authority::Run(String::from("nobody holds it"))), + ); + match verdict { + Guarded::Stop { why } => { + assert!( + why.contains("abc123"), + "the refusal names the commit: {why}" + ); + assert!( + why.contains("Rebase"), + "and the remedy, because a cancelled run has no failed step to read: {why}" + ); + } + Guarded::Run { why } => panic!("a stale head must not spend a matrix: {why}"), + } + } + + /// **AND AN UNJUDGED HEAD STILL ANSWERS TO THE LEASE**, which is the arm the + /// case above used to assert away. + /// + /// With the forge rate-limited or the credential absent, `decide` yields + /// `Carries::Unknown`. Returning `Run` there without asking the lease meant a + /// rival's live hold was ignored and this job spent a matrix beside it — + /// two landers, which is the one thing this module exists to prevent. + #[test] + fn an_unjudged_head_still_stops_on_a_lease_somebody_else_holds() { + let unknown = Carries::Unknown { + because: String::from("the forge did not answer"), + }; + let verdict = guard( + &unknown, + Some(&Authority::Stop(String::from("somebody else holds it"))), + ); + match verdict { + Guarded::Stop { why } => { + assert!( + why.contains("somebody else holds it"), + "the lease's own reason reaches the reader: {why}" + ); + assert!( + why.contains("not judging this head's age"), + "and so does the reading that could not be taken: {why}" + ); + } + Guarded::Run { why } => { + panic!("an unjudged head must still answer to the lease: {why}") + } + } + } + + fn paths() -> Vec { + vec![String::from("mise-tasks/land.sh")] + } + + /// **The discriminating pair: a head carrying trunk's landing commit is + /// current, one that does not is stale.** + /// + /// This is the predicate whose predecessor is about to go SILENTLY dead. + /// `ci-lease-precondition.sh:157` greps the head's `mise-tasks/land.sh` for + /// `land-lock acquire`; once that file is retired the read fails, the script + /// takes its own fail-open path — "not judging this head's age" — and every + /// stale head passes. A path SET survives the retirement that killed a grep + /// string, because what changes when the mechanism moves is which paths, and + /// that is config a retirement edits rather than a literal it invalidates. + #[test] + fn a_head_carrying_trunks_landing_commit_is_current_and_one_behind_is_stale() { + assert_eq!( + decide(&paths(), "headsha", Some("trunksha"), Some(true)), + Carries::Current + ); + assert_eq!( + decide(&paths(), "headsha", Some("trunksha"), Some(false)), + Carries::Stale { + wanted: String::from("trunksha") + }, + "the refusal names the commit the head is missing, and nothing else" + ); + } + + /// **EVERY UNKNOWN IS ITS OWN VARIANT, AND NEVER `Stale`.** + /// + /// This gate is the opposite of every other refusal in the repository: it + /// fails OPEN, because a reading it cannot take would cancel every job in + /// the fleet where waving one matrix through costs one matrix. Reading a + /// could-not-look as stale is the expensive direction; reading it as current + /// is how the predecessor's row died. It is neither, and the caller decides. + /// + /// Asserted as the whole set, because a version that answered `Unknown` for + /// everything and one that answered `Current` for everything each satisfy a + /// single case. + #[test] + fn every_reading_that_could_not_be_taken_is_unknown_rather_than_a_verdict() { + for (paths, head, wanted, ancestral) in [ + (Vec::new(), "headsha", Some("trunksha"), Some(false)), + (paths(), "", Some("trunksha"), Some(false)), + (paths(), " ", Some("trunksha"), Some(false)), + (paths(), "headsha", None, Some(false)), + (paths(), "headsha", Some("trunksha"), None), + ] { + let verdict = decide(&paths, head, wanted, ancestral); + assert!( + matches!(verdict, Carries::Unknown { .. }), + "an unreadable input must not become a verdict: {verdict:?}" + ); + } + } + + /// The unknown NAMES what could not be read. + /// + /// Pointer-only (non-negotiable rule 4): a reason a human can act on, never + /// a byte of the response. A stopped run is a cancelled run with no failed + /// step of its own, so without this the reader sees a red check and no clue. + #[test] + fn an_unknown_names_which_reading_was_missing() { + let no_paths = decide(&[], "headsha", None, None); + let no_head = decide(&paths(), "", None, None); + assert_ne!( + format!("{no_paths:?}"), + format!("{no_head:?}"), + "two different could-not-looks must not report the same reason" + ); + } +} diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 4d4264a7c..0a0e42645 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -44,6 +44,9 @@ pub mod error; pub mod exec; pub mod exit; pub mod facts; +/// Asking the fast-forward bot to land a head, and reading the answer keyed to +/// THIS request rather than to a timestamp (CLOUD-1338). +pub mod fast_forward; pub mod fetch; pub mod findings; pub mod forge; @@ -70,6 +73,7 @@ pub mod landed; /// remote ref, spoken as git smart-HTTP over [`fetch`] (CLOUD-1274). pub mod lease; pub mod lint; +pub mod main_watch; pub mod markers; pub mod mcp; pub mod mint; @@ -84,6 +88,7 @@ mod patch; pub mod pattern; pub mod perf; pub mod pinned; +pub mod pipeline; pub mod policy; pub mod pr_watch; pub mod preset; @@ -98,8 +103,10 @@ pub mod redirect; pub mod refusal; pub mod render; pub mod resolve; +pub mod rest; pub mod review; pub mod rules; +pub mod scratch; pub mod secret; pub mod secrets; pub mod selfwrite; @@ -112,6 +119,7 @@ pub mod severity; pub mod sink; pub mod source; pub mod spec; +pub mod speculation; /// Resolved-symbol facts, from a delegated analyser's structured output /// (CLOUD-760). The first occupant of `Cost::Effect`: resolving it runs a /// program, which is the classification rather than an accident of it. @@ -301,7 +309,7 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re // is why nothing on the `check` or `hook` surface may reach this module — // `policy/module-layering.rego` forbids both edges over the resolved use // graph rather than by review. - Some(Command::Lease { command }) => run_lease(command, out, err), + Some(Command::Lease { command }) => run_lease(command, &overrides, out, err), // The landing lap (CLOUD-1335). It reaches the network through `lease` // and the worktree through `gitwrite`, so the same two edges are // forbidden — transitively, which the layering table states rather than @@ -781,7 +789,11 @@ fn run_baseline( ) -> Result { let root = anchor(); let config = resolve::resolve(&root, overrides)?; - let scan = rules::run_static( + // AN INSTANT, for `filed_here_pointers`' reason at its own site: this runs + // the WHOLE rule set, so a `[[rule.minted]]` `max_age` bound evaluated here + // against epoch 0 would record a baseline over findings `check` does not + // produce. + let scan = rules::run_static_over( &config.rules, &config.provisions, policy::Vocabulary { @@ -790,6 +802,15 @@ fn run_baseline( recorders: &config.recorders, }, &root, + rules::RunOptions { + checks: policy::ModuleChecks::Run, + scope: &rules::Scope::Tree, + // A TREE walk, so the read surface — the same answer the sibling + // site upstream gives, and for its reason: this is what `check` + // does rather than the mediated boundary. + surface: facts::Surface::Check, + now: Some(now_unix()), + }, )?; if prune { @@ -3075,6 +3096,7 @@ fn run_receipt( match command { ReceiptCommand::Record { check } => receipt::run_record(&check, mode, err), ReceiptCommand::Status { check, key, json } => receipt::run_status(&check, key, json, out), + ReceiptCommand::Verified => receipt::run_verified(out), } } @@ -3303,7 +3325,13 @@ fn run_pr( let config = pr_watch::Config { sha, - repo: repo.unwrap_or_else(|| pr_watch::REPO_PLACEHOLDER.to_owned()), + // `--repo` FIRST, THEN THE REMOTE, and the placeholder only where neither + // answers. This verb has no `root` argument, so the reading is taken from + // the working directory the caller invoked it in — which is the same + // checkout every other reading in this process comes from. See + // [`repo_slug`] for why the bare placeholder is a guaranteed 404 rather + // than a fallback. + repo: repo.unwrap_or_else(|| repo_or_placeholder(Path::new("."))), interval, progress, }; @@ -3787,7 +3815,9 @@ fn run_claim_race( ); }; let grammar = board_grammar(overrides)?; - let log = bot::forge::commit_messages(&slug, &me.number).unwrap_or_default(); + // Taken by value before the listing is rebuilt below: `me` borrows it. + let mine_number = me.number.clone(); + let log = bot::forge::commit_messages(&slug, &mine_number).unwrap_or_default(); let mine = race::claimed(&me.head_ref, &me.title, &log, &me.body, &grammar); if mine.is_empty() { return clean( @@ -3795,7 +3825,35 @@ fn run_claim_race( "claim race: this branch claims no issue — nothing to race", ); } - let races = race::races(&mine, &pulls, Some(&me.number), &grammar); + // **EVERY COMPETITOR IS JUDGED BY THE SAME SOURCES THIS BRANCH IS** (review + // of #848). `bot::forge::open_pulls` builds each competitor with an EMPTY + // log, and `race::claimed`'s third source is the `Refs:` trailer — read out + // of the log. So the comparison was asymmetric: this branch's claim resolved + // through a trailer and a rival's could not, and a rival whose only statement + // of the key is that trailer was invisible. `commit-lint` requires the + // trailer on every commit here and a closing keyword on almost none, so the + // unreachable source was the one that actually resolves claims — the gate + // reported "no races" at exit 0 on precisely the collision it exists to + // refuse. + // + // ONLY WHERE THE CHEAP SOURCES FOUND NOTHING, which is what keeps this from + // becoming a round trip per open pull request: a competitor whose branch, + // title or body already names a key has been answered, and the trailer can + // only agree. A fetch that fails leaves the log empty, which is the reading + // this had before — worse than the truth, and never better than it. + let pulls: Vec = pulls + .into_iter() + .map(|pull| { + if pull.number == mine_number + || !race::claimed(&pull.head_ref, &pull.title, "", &pull.body, &grammar).is_empty() + { + return pull; + } + let log = bot::forge::commit_messages(&slug, &pull.number).unwrap_or_default(); + race::Pull { log, ..pull } + }) + .collect(); + let races = race::races(&mine, &pulls, Some(&mine_number), &grammar); if races.is_empty() { return clean( out, @@ -4411,7 +4469,7 @@ const RECEIPT_DIR: &str = "batten-receipts"; /// Seconds since the epoch, or zero where the clock will not read — a timestamp /// nobody can produce is recorded as one rather than refusing the claim. -fn now_unix() -> u64 { +pub(crate) fn now_unix() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |since| since.as_secs()) @@ -5720,7 +5778,17 @@ fn admission_anchor( // over one subject to recover its fingerprint, which is the same // work `check` does and not the mediated boundary's. surface: facts::Surface::Check, - now: None, + // **THE CLOCK, because this is a BOUNDARY and the other three were + // given one** (review of #848). `rules::minted_facts` takes the + // instant from its caller now and reads `now.unwrap_or(0)`, so a + // `None` here makes every receipt look ancient and every + // `[[rule.minted]]` `max_age` bound refuse — and this scan is what + // resolves an admission's ANCHOR. The mint would then bind against a + // finding set `batten check` never produced, over the same tree. + // Latent only while no `[[rule.minted]]` row is declared, which is + // the same reason the sibling site was called latent and fixed + // anyway. + now: Some(now_unix()), }, ) else { return head(); @@ -6269,29 +6337,23 @@ fn report_comparison( Ok(ExitCode::Violation) } -/// `batten mutate`: does each declared gate have a mutation its declared suite -/// is proven to catch (CLOUD-418, CLOUD-1267)? -/// -/// **The report is the deliverable and the exit code is the verdict**, and the -/// two say different things on purpose. Every finding reaches stdout as a -/// pointer — gate, mutation id, case — because the workflow that runs this cats -/// the file into a step summary, and a run that fails without publishing what it -/// found sends the reader back to re-run a sweep that costs the better part of -/// an hour. The `::error::` summary on stderr carries the count and nothing else. -/// -/// Exit follows the one table: `2` where the sweep decided against the tree, `3` -/// where it could not look, and the split is the acceptance rather than a -/// nicety — a gate whose declared suite cannot be resolved or run must never be -/// reported as "every mutation caught". /// The landing lease's nine arms (CLOUD-1274), ported off `mise-tasks/land-lock.sh`. /// -/// # The exit vocabulary is not uniform across these arms, and that is the design +/// # The exit vocabulary is uniform, and this paragraph used to claim it was not /// -/// `authorises` answers `0` run / `3` stop / `2` could not look, because `1` -/// already means "held by someone else" — which there is a REASON to stop rather -/// than the instruction, so a caller keying on `3` cannot mistake a refusal for an -/// error. Every other arm keeps the ordinary pair, and `2` stays "could not look" -/// throughout. +/// It read: _"`authorises` answers `0` run / `3` stop / `2` could not look, +/// because `1` already means held by someone else."_ That is +/// `mise-tasks/land-lock.sh`'s table, transcribed rather than ported, and the +/// landed arm never spoke it — `Authority::Stop` returns +/// [`ExitCode::Violation`] and an unresolvable terms read returns +/// [`ExitCode::Internal`], which is the engine's one table with no per-verb +/// exception (non-negotiable rule 5). `surface.rs`'s `lease authorises` row +/// states the correction at the declaration; this said the opposite two +/// screens away, so a reader reaching either one first got a different answer. +/// +/// The debt that made it survive is now paid too: the predecessor's numbers had +/// sixteen CI callers, and `refactor(ci)` moved every one of them onto +/// `lease guard`, which exits `0` unconditionally and keys on nothing. /// /// # Where the fail-open asymmetry lives /// @@ -6301,11 +6363,20 @@ fn report_comparison( /// a runner's budget. fn run_lease( command: cli::LeaseCommand, + overrides: &resolve::Overrides, out: &mut dyn Write, err: &mut dyn Write, ) -> Result { let root = Path::new("."); - let terms = match lease_terms(root) { + // BEFORE THE TERMS RESOLVE, because this arm asks the FORGE rather than the + // lease remote and so has no terms to want. `land verify` and + // `land fast-forward` sit outside their own remote resolution for the same + // reason: a clone that cannot name a remote can still answer both, and + // resolving one first would refuse a question that never needed it. + if let cli::LeaseCommand::Carries { head } = &command { + return run_lease_carries(root, overrides, head, out, err); + } + let terms = match lease::terms(root) { Ok(terms) => terms, // A CLONE WITH NO REMOTE IS AN ANSWER FOR THE READ ARMS, and the type // above says why. The five arms that reach `swap` still refuse below, @@ -6322,7 +6393,7 @@ fn run_lease( // stopping the fleet because a clone has no remote is exactly the cost // that arm exists never to pay. Reaching it required this branch, because // the terms resolve BEFORE the arm does. - Err(TermsMissing::NoRemote) => match command { + Err(lease::TermsMissing::NoRemote) => match command { cli::LeaseCommand::Status { json } => { return lease_report(json, "unconfigured", &[], out); } @@ -6333,6 +6404,19 @@ fn run_lease( )?; return Ok(ExitCode::Success); } + // THE GUARD STILL ASKS ITS OTHER HALF. A clone with no lease remote + // has no lease to honour, but the STALENESS question is answered by + // the forge and does not need one — so reaching `Authorises`' arm + // here would skip a reading that was available. `guard` takes `None` + // for the authority, which it reads as fail-open. + cli::LeaseCommand::Guard { head, branch, run } => { + let asking = Standing { + head: &head, + branch: &branch, + run: &run, + }; + return run_lease_guard_unleased(root, overrides, &asking, out, err); + } cli::LeaseCommand::Check => { writeln!( out, @@ -6345,6 +6429,12 @@ fn run_lease( cli::LeaseCommand::Peek { .. } => return Ok(ExitCode::Success), // `held` asks whether THIS clone holds it. It does not. cli::LeaseCommand::Held => return Ok(ExitCode::Violation), + // UNREACHABLE — the arm returns before the terms resolve, above. + // Spelled rather than wildcarded so the next arm added here cannot + // be swallowed by a `_`, which is the property that forced this row. + cli::LeaseCommand::Carries { head } => { + return run_lease_carries(root, overrides, &head, out, err); + } cli::LeaseCommand::Acquire { .. } | cli::LeaseCommand::Renew | cli::LeaseCommand::Hold @@ -6394,7 +6484,7 @@ fn run_lease( } } cli::LeaseCommand::Check => run_lease_check(&terms, now, out, err), - cli::LeaseCommand::Status { json } => run_lease_status(&terms, json, now, out, err), + cli::LeaseCommand::Status { json } => run_lease_status(root, &terms, json, now, out, err), cli::LeaseCommand::Peek { field } => run_lease_peek(&terms, &field, now, out, err), cli::LeaseCommand::Held => run_lease_held(root, &terms, now, out, err), cli::LeaseCommand::Acquire { branch } => { @@ -6404,42 +6494,19 @@ fn run_lease( cli::LeaseCommand::Hold => run_lease_hold(root, &terms, out, err), cli::LeaseCommand::Release => run_lease_release(root, &terms, now, out, err), cli::LeaseCommand::Reserve { branch } => run_lease_reserve(&terms, &branch, now, out, err), - } -} - -/// Resolve the lease's terms from this checkout. -/// -/// **The remote must resolve to a URL rather than a name.** The transport speaks -/// smart-HTTP over the vendored client, which has no notion of a git remote alias, -/// and a name reaching it would be an unresolvable host rather than a clear -/// refusal here. -/// Why a clone has no lease terms, and the two are not the same answer. -/// -/// **A clone with no remote is a FACT about the clone, not a failure to look.** -/// The could-not-look guard exists so an unreadable lease is never reported as a -/// free one; a repository with no remote has no lease ref to misread, so folding -/// it into that guard made `lease status` an error in every clone that has not -/// been pushed anywhere — including the census fixture, where every other -/// data-channel verb answers cleanly. -/// -/// The distinction is only ever RELAXED for the reporting arms. The write arms -/// refuse either way, because acquiring a lease that has nowhere to live is not -/// something a missing remote makes safe. -enum TermsMissing { - /// No remote is configured, so this clone cannot participate in a lease. - NoRemote, - /// A remote exists and something about reading it failed. This is the - /// could-not-look the guard is for. - Unreadable(String), -} - -impl TermsMissing { - /// The diagnostic, for the arms that report one. - fn say(&self, name: &str) -> String { - match self { - TermsMissing::NoRemote => format!("no remote named {name} is configured"), - TermsMissing::Unreadable(reason) => reason.clone(), + cli::LeaseCommand::Guard { head, branch, run } => { + let asking = Standing { + head: &head, + branch: &branch, + run: &run, + }; + run_lease_guard(root, overrides, &terms, &asking, now, out, err) } + // UNREACHABLE, and stated rather than wildcarded: the arm returns above, + // before the terms this match is built on resolve. A `_` here would + // silently swallow the next arm somebody adds, which is what the + // exhaustive match exists to prevent — it is what forced this very row. + cli::LeaseCommand::Carries { head } => run_lease_carries(root, overrides, &head, out, err), } } @@ -6469,155 +6536,2876 @@ fn run_land( out: &mut dyn Write, err: &mut dyn Write, ) -> Result { - // `push` names no reference — it pushes the branch it is on — so the shared - // preamble below binds an empty one for it rather than growing a second - // preamble. Every arm needs the same three facts: a remote, its url, and a - // branch to key the record on. - let reference = match command { - cli::LandCommand::Replay { reference } | cli::LandCommand::Wait { reference } => { - reference.clone() - } - cli::LandCommand::Push | cli::LandCommand::Verify => String::new(), - }; let root = Path::new("."); + // THE BRANCH IS EVERY ARM'S, so it is resolved once and ahead of the split: + // the record is keyed by it, the push names it, and the pull request is found + // by it. A detached HEAD can answer none of those, and that is a statement + // about the clone rather than about the work. + let Ok(Some(branch)) = git::current_branch(root) else { + writeln!(err, "::error:: land: a detached HEAD has no branch to key")?; + return Ok(ExitCode::Internal); + }; - // VERIFY RUNS BEFORE THE REMOTE IS RESOLVED, and that ordering is the point - // rather than a shortcut. Verifying is a question about the WORKING TREE: - // a clone with no remote can still answer it, and making the whole verb - // depend on a remote would refuse a lap step that had everything it needed. - if matches!(command, cli::LandCommand::Verify) { - let Ok(Some(branch)) = git::current_branch(root) else { - writeln!(err, "::error:: land: a detached HEAD has no branch to key")?; - return Ok(ExitCode::Internal); - }; - return run_land_verify(root, &branch, out, err); + // ONE EXHAUSTIVE MATCH, on `run_lease`'s shape rather than on the cascade of + // `matches!` blocks this replaced. A cascade is a shape a new sub-verb has to + // EXTEND — read the guards, work out which preamble it needs, insert it in the + // right place — where a match arm is one a new variant SLOTS into and the + // compiler names the omission. `fast-forward` is the fifth arm to arrive, + // which is the point at which the difference stops being taste. + // + // THE ORDERING THE CASCADE BOUGHT IS KEPT, and kept by construction rather + // than by a comment asking the next reader to preserve it: `verify` asks about + // the working tree and `fast-forward` about a pull request, neither of which + // is a ref, so a clone with no remote still answers both. Only the three + // ref-shaped arms resolve a remote, and each resolves it for itself. + match command { + cli::LandCommand::Verify => { + // THE REF, NOT AN EMPTY BET. Run by hand, this verb has no lap + // holding one — but a bet is durable precisely because it outlives + // the process that placed it, so publishing nothing here would tell + // the gate this tree carries no borrowed range while it does. + let mut standing = speculation::Bet::default(); + let _ = speculation::recover(root, &mut standing); + run_land_verify(root, &standing, &branch, None, out, err) + } + cli::LandCommand::FastForward => run_land_fast_forward(root, &branch, out, err), + cli::LandCommand::Replay { reference, resolve } => { + let Some(url) = land_remote(root, err)? else { + return Ok(ExitCode::Internal); + }; + run_land_replay(root, &url, reference, &branch, resolve, out) + } + // NO REMOTE RESOLVED HERE ANY MORE. The staleness arm asks the FORGE + // through its conditional endpoint rather than the git remote, so this + // arm stopped being ref-shaped when CLOUD-390's poll landed — and a + // resolution nothing reads would refuse a clone that can still answer. + // The verdict is the LAP's to read; a hand-driven wait reports the code + // and nothing else, exactly as it did before the tap needed one. + cli::LandCommand::Wait { reference } => { + run_land_wait(root, reference, &branch, out, err).map(|(code, _)| code) + } + cli::LandCommand::Push => { + let Some(url) = land_remote(root, err)? else { + return Ok(ExitCode::Internal); + }; + run_land_push(root, &url, &branch, out) + } + cli::LandCommand::Lap { reference } => { + let Some(url) = land_remote(root, err)? else { + return Ok(ExitCode::Internal); + }; + run_land_lap(root, &url, reference, &branch, out, err) + } } +} - let name = std::env::var("LAND_LOCK_REMOTE").unwrap_or_else(|_| String::from("origin")); - let Ok(remotes) = git::remotes(root) else { - writeln!(err, "::error:: land: cannot read this repository's remotes")?; - return Ok(ExitCode::Internal); +/// Has the base moved while the previous step ran? `true` means lap. +/// +/// The last free moment before a step spends, extracted from the driver because +/// it is one decision with its own could-not-look and the driver is a sequencer. +/// +/// **THE SLUG OR NO PROBE, never the placeholder** (review of #848). This read +/// goes on the wire: `main_watch::read` interpolates the repo straight into the +/// endpoint and no client-side `{owner}/{repo}` substitution exists any more, so +/// the placeholder 404s every request — `is_reading()` false, `Poll::head` never +/// set, and `moved()` answers `None`, which this reads as STILL LANDABLE. The +/// probe would be permanently dead while looking exactly like a quiet trunk, and +/// each lap would buy a matrix the fast-forward then refuses. +/// +/// Skipping it on an unresolvable slug is the same fail-open reading +/// `land::stale` already takes for a forge that did not answer. The difference is +/// that it is a decision here rather than a silent consequence of a string that +/// cannot work. +/// +/// # Errors +/// +/// Only for a stream that will not accept output. +fn base_moved( + root: &Path, + trunk_poll: &mut main_watch::Poll, + reference: &str, + lap: u32, + step: land::Step, + out: &mut dyn Write, +) -> Result { + let Some(slug) = repo_slug(root) else { + return Ok(false); }; - let Some((_, url)) = remotes.iter().find(|(configured, _)| *configured == name) else { + let trunk = trunk_watch(reference, "", &slug, 1); + if let Some(moved) = land::stale(root, trunk_poll, &trunk, reference) { writeln!( - err, - "::error:: land: no remote named {name}, so this lap has no base" + out, + "land: lap {lap} — {reference} moved to {moved} before {step:?}; lapping before a matrix is spent" )?; - return Ok(ExitCode::Internal); - }; - // A DETACHED HEAD HAS NO BRANCH TO REPLAY, and that is a statement about the - // clone rather than about the work — `3`, like every other could-not-look. - let Ok(Some(branch)) = git::current_branch(root) else { + return Ok(true); + } + Ok(false) +} + +/// Charge a lap that is about to go round again, refunding the ones that spent +/// nothing. `Some(bound)` means stop. +/// +/// **`Ledger`'s refund had no production caller at all** (review of #848), so a +/// lap lost to another branch holding the lease consumed one of the two the +/// runaway backstop allows — and a contended fleet exhausted the budget and then +/// announced "a conflict, a failed gate or red CI will lose again". That is +/// precisely the mis-diagnosis `Bound::LeaseWaits` exists to prevent, and the one +/// CLOUD-413 measured being wrong twice across 24 laps. +/// +/// **Only the lease arm is wired, and the other two are deliberately not.** The +/// bot's unreadable answer no longer reaches the lap — `run_land_fast_forward`'s +/// poll absorbs it now — and the transient re-run has no producer in this engine +/// yet. A call site for a condition nothing raises is the dead code this finding +/// was about, one layer over. +fn charge_the_lap( + step: land::Step, + code: ExitCode, + ledger: &mut land::Ledger, +) -> Option { + // THE RECLAIMED GATE, and it is charged to its own bound for `waited`'s + // reason (CLOUD-1586). `verify_raced` aborts the gate when the base moves, + // so this lap bought nothing — no matrix, no completed gate, no push — and + // charging it against a runaway backstop that defaults to TWO exhausts the + // loop on a busy trunk. `Refusal::Moved` is the one verify refusal that + // codes `Internal` rather than `Violation`, which is what makes it a lap + // rather than a stop, so the pair identifies it without a second channel. + if step == land::Step::Verify && code == ExitCode::Internal { + return match ledger.reclaimed(gate_reclaim_bound()) { + land::Charge::Lap => None, + land::Charge::Stop(bound) => Some(bound), + }; + } + if step != land::Step::Lease { + return None; + } + match ledger.waited(lease_wait_bound()) { + land::Charge::Lap => None, + land::Charge::Stop(bound) => Some(bound), + } +} + +/// How many reclaimed gates a landing absorbs before it stops. +/// +/// **`lease_wait_bound`'s default, on `lease_wait_bound`'s reasoning.** A lap +/// whose gate was reclaimed has spent nothing, and what it is waiting out is +/// other branches landing — so too few gives up on a trunk that was moving, +/// which is the queue working rather than a fault. +fn gate_reclaim_bound() -> u32 { + std::env::var("LAND_MAX_GATE_RECLAIMS") + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|reclaims| *reclaims > 0) + .unwrap_or(60) +} + +/// How many lease waits a landing absorbs before it stops. +/// +/// **Separate from `$LAND_MAX_LAPS`, which is the point.** A lap lost to another +/// branch holding the lease has spent nothing — no matrix, no gate, no push — so +/// charging it against a budget that exists to catch "main moves faster than a +/// lap takes" reports the wrong diagnosis at exhaustion. `Ledger::waited` +/// refunds the lap and charges here instead. +/// +/// Generous, because waiting is free and the thing being waited for is another +/// branch finishing: the cost of too many is conditional requests, and the cost +/// of too few is giving up on a queue that was moving. +fn lease_wait_bound() -> u32 { + std::env::var("LAND_MAX_LEASE_WAITS") + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|waits| *waits > 0) + .unwrap_or(60) +} + +/// How many laps before the loop gives up, when the caller names none. +/// +/// TWO, matching the predecessor. It is a RUNAWAY BACKSTOP rather than a budget: +/// a lap that keeps losing to contention converges, and one losing to a conflict, +/// a failed gate or red CI will lose again — so the useful number is small enough +/// that a broken branch stops rather than grinding. +const LAPS: u32 = 2; + +/// Drive the whole lap and lap again on any refusal a rebase would clear. +/// +/// # Every bound here is a COUNT, and that is load-bearing +/// +/// `$LAND_MAX_LAPS` counts laps. Nothing in this loop consults a clock, and the +/// mechanism refusing one is not this doc comment: `clippy.toml` denies both +/// `std::thread::sleep` and `tokio::time::sleep`, and `tests/sleep_ban.rs` holds +/// each ban's stated reason to a bound whose name resolves. A deadline would +/// reintroduce the VM-reap gap the count exists to close, and would land as a +/// false refusal on a slow bot rather than on a broken branch. +/// +/// # Which refusals lap and which stop +/// +/// The split is whether a REBASE would clear it, and it is the whole design: +/// +/// * **Conflict, or a gate that refused** — stop. Both are decisions a human +/// owns, and lapping would re-run them against the same tree to reach the same +/// answer. This is the one step the loop cannot do for you, and lapping OFTEN +/// is what keeps it small. +/// * **A raced push, a stale base, an unanswered wait, a refused or unreadable +/// fast-forward** — lap. Every one of them means the base moved or the answer +/// is not in yet, and the next lap's rebase is exactly the remedy. +/// +/// A refusal is the design working rather than a failure: each lap rebases onto a +/// little more landed work, so conflicts arrive one small resolvable increment at +/// a time. Batching laps removes no refusal and only makes each one bigger, which +/// is the inference CLOUD-238 measured an agent making and optimising toward. +/// +/// # Exits +/// +/// `0` landed. `2` stopped for a decision — a conflict or a refused gate, which +/// is a verdict about this repository. `3` the laps ran out with no answer, which +/// is not a verdict about anything: the branch may be perfectly landable and the +/// bot merely slow. +/// The lock and the registration a lap holds, released when it is dropped. +/// +/// **`Drop` is the honest half of the contract and it is not the whole one.** It +/// covers every ordinary exit — a landing, a refused gate, a `?` — which is what +/// the shell's `trap on_exit EXIT` covered. It does NOT run on `SIGKILL`, and it +/// does not run when the container is reclaimed mid-lap, which in this execution +/// environment is the common case rather than the exotic one. +/// +/// That is why the lock is not merely released but RECLAIMABLE: the successor's +/// `singleton_acquire` decides by liveness — pid existence plus a cmdline that +/// still names the task — rather than by finding the file gone. A crash leaves a +/// lock on disk with no process behind it, and that state must resolve itself +/// without a human. Crash-only means the recovery path is the only path, so this +/// guard is an optimisation of tidiness, never the mechanism. +struct LandSingleton { + git_dir: PathBuf, + pid: String, +} + +impl LandSingleton { + /// Push what this lap is doing now, and that it went round. + /// + /// **A REGISTRATION THAT NEVER MOVES IS WORSE THAN NONE, because something + /// reads it as progress** (CLOUD-425, CLOUD-499). `land.sh` pushed a phase at + /// every transition the loop already had; the port registered once and went + /// quiet, which looks like observability lost and is not. + /// + /// [`lease::progress_of`] derives `advance` from `phase_since`/`sig_at` and + /// takes `tick_at` from this registry. With nothing pushed, `advance` is + /// frozen at the instant of registration and `tick_at` stays `0` for the + /// whole landing — so the stall detector reads a healthy lap that is eight + /// minutes into a gate as one that has stopped making progress. The wrong + /// direction, too: a live holder reported stalled is one a sibling may + /// reclaim the lease from. + /// + /// Both signals, because they answer different questions and + /// [`task::stamp_for`] only moves a stamp when the VALUE changes. The phase + /// is what a lap is doing and repeats across laps; the tick is the loop going + /// round, so it carries the lap number and therefore always differs. Pushing + /// only the phase would leave `tick_at` frozen through six steps of one lap. + fn phase(&self, phase: &str, lap: u32) { + let now = boundary_epoch(); + task::push(&self.git_dir, &self.pid, task::Signal::Phase, phase, now); + task::push( + &self.git_dir, + &self.pid, + task::Signal::Tick, + &lap.to_string(), + now, + ); + } +} + +impl Drop for LandSingleton { + fn drop(&mut self) { + task::unregister(&self.git_dir, &self.pid); + task::singleton_release(&self.git_dir, LAND_TASK); + } +} + +/// The task name the lock and the registry agree on. +/// +/// One constant rather than two literals: the acquire and the release naming +/// different strings is a lock nothing ever frees, and it would look exactly like +/// a lock working. +const LAND_TASK: &str = "land"; + +/// Reap what a dead lap left running, then take the lock, then register. +/// +/// **In that order, and the order is the design.** Reaping first is what makes +/// the lock's reclaim safe to act on: `singleton_acquire` will hand this process +/// a lock whose previous holder is gone, and a holder that is gone may still have +/// left a `verify` — with its cargo build and its test binaries — running under a +/// group nothing is waiting on. Taking the lock without reaping means the new lap +/// competes with the old lap's leaves for the same four CPUs, which is the +/// measured failure this exists to end. The lock would be correct and the machine +/// would still be wrong. +/// +/// `Ok(None)` means a live lap holds the lock, or the registry could not be read. +/// Both refuse, and the second one refuses for `Claim::CouldNotLook`'s own stated +/// reason: treating unreadable as free is how two lands start. +/// +/// # Errors +/// +/// Only for a stream that will not accept output. +fn run_land_singleton( + root: &Path, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result> { + let Ok(git_dir) = crate::git::git_dir(root) else { writeln!( err, - "::error:: land: a detached HEAD has no branch to replay" + "::error:: land: not a git repository, so there is no lock to take" )?; - return Ok(ExitCode::Internal); + return Ok(None); }; + let pid = std::process::id().to_string(); + + // THE REAP, AND IT ABSTAINS WHERE THE CONSUMER HAS NOT SAID WHERE ITS + // PROGRAMS LIVE (non-negotiable rule 1). + // + // `program_root` is what tells a live task from a recycled pid wearing its + // number, and *where a consumer keeps its programs* is a fact about that + // consumer. The first version of this defaulted the value to a literal, which + // `document_facts::no_artifact_name_reaches_the_core` refused — correctly, and + // `task.rs`'s own header names that test as the reason the root is a + // parameter there rather than a constant. + // + // Absent, this reaps NOTHING rather than guessing. A wrong root makes + // `matches_cmdline` miss every live task, so every entry reads as abandoned + // and the reaper signals the group of a lap that is running — the direction + // a miss must never fail in. + // + // ONLY THE REAP IS CONDITIONAL. The lock and the registration below run + // either way: `singleton_acquire` reclaims on `pid_exists`, which needs no + // root at all, and skipping them here would trade a reaper for the + // concurrent-lands defect the lock exists to stop — a strictly worse bargain + // than reaping nothing. + let abandoned = std::env::var("BATTEN_TASK_PROGRAM_ROOT") + .map(|root| task::abandoned(&git_dir, &root)) + .unwrap_or_default(); + // TWO PASSES OVER THE WHOLE SET, NEVER A GRACE PERIOD PER GROUP. Signalling + // every group before re-observing any of it is what gives each one its + // chance to act on the TERM — the walk itself, rather than a delay standing + // in for an exit condition (CLOUD-1177). `exec::terminate_group`'s header + // carries why a group here gets no grace at all: its leader is already dead, + // so a survivor is not a leaf mid-exit. + let mut reaped = 0_usize; + for entry in &abandoned { + if exec::terminate_group(&entry.pgid) { + reaped += 1; + } + } + // A GROUP TERM IS A REQUEST, NOT A FACT (CLOUD-434). This is the observation + // that tells the two apart, and it is the half `land.sh` had that the port + // dropped. + let mut escalated = 0_usize; + for entry in &abandoned { + if exec::escalate_group(&entry.pgid) { + escalated += 1; + } + // Unregistered only now, so a record survives long enough for both + // passes to read its group. + task::unregister(&git_dir, &entry.pid); + } + if reaped > 0 { + // COUNTS AND NOTHING ELSE (rule 4). What was reaped is somebody's command + // line; this reports how many groups went and how many had to be forced, + // never what they were running. + writeln!( + out, + "land: reaped {reaped} abandoned task group(s) left by a lap that did not exit; {escalated} ignored the term" + )?; + } - if matches!(command, cli::LandCommand::Wait { .. }) { - return run_land_wait(root, url, &reference, &branch, out, err); + // THE LOCK. `recheck` is the pause a reclaim needs between two sightings; + // production takes the default, as `singleton_acquire`'s own doc states. + match task::singleton_acquire(&git_dir, LAND_TASK, &pid, SINGLETON_RECHECK) { + task::Claim::Taken => {} + task::Claim::Reclaimed(corpse) => { + writeln!( + out, + "land: reclaimed the landing lock from {corpse}, which is gone" + )?; + } + task::Claim::Held { holder, phase } => { + let doing = phase.unwrap_or_else(|| String::from("unknown")); + writeln!( + err, + "::error:: land: a landing already runs here (pid {holder}, {doing}). One lap at a time — `mise run alive` reports it." + )?; + return Ok(None); + } + task::Claim::CouldNotLook(path) => { + writeln!( + err, + "::error:: land: the landing lock is unreadable at {}, which is not the same as free", + path.display() + )?; + return Ok(None); + } } - if matches!(command, cli::LandCommand::Push) { - return match land::push(root, url, &branch)? { - land::Pushed::Landed(head) => { - writeln!(out, "land: {branch} on the remote now reads {head}")?; - Ok(ExitCode::Success) + // THE REGISTRATION, so `alive` can answer. After the lock rather than before: + // a registration by a process that then loses the race is an entry naming a + // lap that never ran. + task::register(&git_dir, LAND_TASK, &pid, "lap", boundary_epoch()); + Ok(Some(LandSingleton { git_dir, pid })) +} + +/// The pause between the two sightings a singleton reclaim requires. +/// +/// A constant here rather than in `task`, because it is this caller's tolerance +/// for a holder that took the lock between two reads, and the only other caller +/// is a test that needs a wider margin than production. +const SINGLETON_RECHECK: std::time::Duration = std::time::Duration::from_millis(250); + +fn run_land_lap( + root: &Path, + url: &str, + reference: &str, + branch: &str, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let laps = std::env::var("LAND_MAX_LAPS") + .ok() + .and_then(|declared| declared.parse::().ok()) + // ZERO IS NOT A BOUND, IT IS A LOOP THAT NEVER RUNS. `1..=0` iterates + // never, so the lap loop fell straight through to the exhausted-laps + // message and reported "0 lap(s) bought no landing" at exit 3 — a + // could-not-look about a branch nothing looked at. The same positive + // filter `LAND_ANSWER_MAX_UNKNOWNS` already applies to its own count. + .filter(|laps| *laps > 0) + .unwrap_or(LAPS); + // THE COMPOSITION, VALIDATED BEFORE A LAP SPENDS ANYTHING. A pipeline that + // would ready and then abandon fails to LOAD rather than in production, + // which is the whole difference between a schema and a convention. + let pipeline = pipeline::Pipeline::default(); + let faults = pipeline.validate(); + if !faults.is_empty() { + for fault in &faults { + writeln!( + err, + "::error:: land: the landing composition will not load: {fault:?}" + )?; + } + return Ok(ExitCode::Usage); + } + + // THE ENTRY GATES, ONCE, BEFORE ANY LAP CAN SPEND (CLOUD-1471). The bash + // lander opened with a pair of calls no lap could proceed past, over a + // precondition about the SESSION rather than about the branch — this + // repository's ban on PR-webhook babysitting, which the harness arms anyway + // (CLOUD-518, CLOUD-790). Nothing in the engine carried it, so retiring the + // lander would have dropped a live gate on the floor. + // + // BEFORE the lease and the singleton, which is where the predecessor put it + // and for its reason: a refusal here has spent nothing at all. + if let Some(code) = run_land_entry_gates(root, branch, out, err)? { + return Ok(code); + } + + // THE SINGLETON AND THE REAPER, WHICH THE SENTENCE ABOVE NAMED AND THE PORT + // DID NOT CARRY (CLOUD-1148). `land.sh` opened by taking a lock and closed + // with `trap on_exit EXIT` / `trap 'exit 1' INT TERM`, and the retirement + // ported this driver's STEPS while dropping its LIFECYCLE. Measured on this + // branch, five laps deep: one `land lap` and four `land verify` alive at + // once, ages 3.7h/3.1h/2.5h/1.9h, four full test suites competing for four + // CPUs — a suite that runs in 174s took 6834s and reported a test failure + // that was a stopwatch rather than a defect. `mise run alive` said "nothing + // registered" throughout, because nothing had registered. + // + // Three mechanisms already existed and none was reachable from here: + // `task::singleton_acquire` (with a liveness-based reclaim), + // `task::register` (the reader `alive` answers from), and `exec`'s process + // group protocol. This is the wiring, not new machinery. + // NAMED, never `_`: the binding is what holds the lock and the registration + // for the rest of this function, and a bare `_` drops it here — releasing the + // lock at the moment it was taken, which reads as working. + // + // And named WITHOUT the underscore since it gained `phase`: clippy refuses + // using an underscore-prefixed binding, and rightly — the prefix is a promise + // to the reader that nothing touches it. + let Some(guard) = run_land_singleton(root, out, err)? else { + return Ok(ExitCode::Violation); + }; + + // ONE POLL FOR THE WHOLE LANDING, held outside the lap loop so lap 2 onward + // send the validator lap 1 was given. Rebuilt per lap it was a fresh + // unconditional ask every time — see `land::stale`'s own header, which + // described this design while the code discarded it. + let mut trunk_poll = main_watch::Poll::default(); + // THE SPEND, COUNTED RATHER THAN INFERRED. `Ledger`'s header records the + // measurement: two laps both lost to `main` moving under the gate, the ready + // never reached, zero check-runs on the head — and the refusal announced + // "having spent 2 CI matrices". `laps` is an attempt counter; `paid` moves at + // the one site that buys a run. + let mut ledger = land::Ledger::default(); + // AT MOST ONE OUTSTANDING BET, which is why it is held here rather than built + // per lap. `Bet::undo` is this branch's own last NON-speculative HEAD, so a + // bet re-placed each lap would overwrite it with a head that is itself + // speculative — and the unwind would then restore a tree still carrying + // somebody else's commits, which is the exact hazard the undo exists to + // remove. `speculation::Bet`'s own header states it. + let mut bet = speculation::Bet::default(); + // **THE REFUND IS WIRED AND THE LOOP BOUND IS NOT MOVED, WHICH LEAVES IT + // INERT — DELIBERATELY, AND THIS IS THE SECOND ATTEMPT** (review of #848). + // + // `Ledger::waited` decrements `laps` so a pass that never won the lease does + // not consume the budget. Making the loop read `ledger.laps` does activate + // that, and it was tried: the effect is that a refunded pass re-enters the + // lap at `Replay`, and `Lease` sits AFTER `Verify` in the composition — so + // every refunded wait re-runs the rebase and the full `$LAND_VERIFY` gate, + // which this file's own comments price at ~200s, with no backoff. At the + // default sixty waits that is ~61 full verify cycles where the shipped bound + // is two. "Waiting is free" is true of the lease and false at this position + // in the pipeline. + // + // The three ways out are a design decision rather than a patch: hold the wait + // INSIDE the `Lease` primitive (which then carries a verify receipt taken + // before the wait, so a trunk that moved during it is unnoticed until the + // next precheck), move `Lease` ahead of `Verify` (which holds the lease + // across every waiter's verify and serialises the fleet on gate time), or + // give the refunded pass a resume point rather than restarting the lap. + // + // So the counter is charged and read for its REPORT — the refusal can say + // the fleet was saturated rather than blaming a conflict — and the budget is + // unchanged from what shipped. `LAND_MAX_LEASE_WAITS` bounds the charge, not + // the loop. + // WHAT HAS BEEN ENTERED AND NOT YET UNDONE, which is what an undo is owed + // for. A lap that stopped at `verify` never readied, so it owes no re-draft + // — computing the owed set from the composition alone would compensate + // effects nobody caused. + // + // **DECLARED OUTSIDE THE LOOP, and that is the whole of `Landing::Unconfirmed` + // working** (review of #848). This was a per-lap binding, so `continue 'laps` + // DROPPED it — and the one arm that laps WITHOUT unwinding is the unconfirmed + // merge, whose entire argument is that the undos are deferred rather than + // forgotten. Measured against the comment that claimed exactly that: an + // unconfirmed lap discarded its ready and its live runs, so a lap stopping + // afterwards handed back no lease, re-drafted nothing and abandoned nothing. + // + // `unwind_lap` is what clears it, so every path that DOES compensate starts + // the next lap owing nothing and the deferring path starts it owing what it + // deferred. `pipeline::unwind` already dedupes, so a step entered twice + // across two laps is still compensated once. + let mut entered: Vec = Vec::new(); + 'laps: for lap in 1..=laps { + ledger.attempt(); + writeln!(out, "land: lap {lap} of {laps}")?; + // WHAT THE WAIT SAW, or `None` where no lap took a reading. The tap + // refuses to draft on `None` deliberately — see `land::closes_the_tap`. + let mut seen: Option = None; + // THE ORDER IS THE LAP, and it is DECLARED rather than an array literal + // in this function. What each answer means is `land::progress`'s — one + // table, read here rather than re-derived per step — and what each step + // leaves behind is its row's `compensate`. + for row in &pipeline.steps { + let step = row.step; + // THE ROW'S OWN QUESTION, where the driver used to carry a + // `step == Verify` exception. A pre-check runs BEFORE the primitive + // and can only lap, never land: it exists to spend nothing. + // THE BET IS SETTLED BEFORE ANYTHING IS SPENT, and before the + // replay that would otherwise build on somebody else's commits. + if let Some(pipeline::Precheck::BetSettled) = row.precheck { + if let Some(code) = + settle_the_bet(root, url, &mut bet, reference, branch, out, err)? + { + unwind_lap(root, branch, &pipeline, &mut entered, seen, out, err)?; + return Ok(code); + } + // AND ONLY THEN IS A NEW ONE PLACED. Placing before settling would + // stack a second borrowed range on an unsettled first, and the + // undo point recorded for the second would already be + // speculative. + place_the_bet(root, &mut bet, reference, branch, out)?; + } + // THE ROW'S OWN QUESTION. Only the row declaring `BaseMoved` asks it: + // running the probe before every step would be a forge read per step + // rather than per lap, and a precheck the composition never asked for. + if row.precheck == Some(pipeline::Precheck::BaseMoved) + && base_moved(root, &mut trunk_poll, reference, lap, step, out)? + { + unwind_lap(root, branch, &pipeline, &mut entered, seen, out, err)?; + continue 'laps; + } + // THE PHASE, PUSHED BEFORE THE STEP RUNS RATHER THAN AFTER IT. A step + // is what this lap is doing WHILE it blocks, and the whole reason a + // reader wants it is that a gate can hold for minutes — so announcing + // it on completion would name every phase exactly when it stopped + // being true. `land.sh` pushed at the transition for the same reason. + guard.phase(step.as_str(), lap); + let code = match step { + // NO RESOLUTIONS ON THE LAP, ever. A lap runs unattended, so a + // resolution it could apply would be one nobody looked at — + // `gitwrite`'s auto-resolution refusal, reached through the + // driver instead of through a flag. + land::Step::Replay => run_land_replay(root, url, reference, branch, &[], out)?, + land::Step::Verify => { + run_land_verify(root, &bet, branch, Some(reference), out, err)? + } + land::Step::Lease => run_land_lease(root, branch, out, err)?, + land::Step::Ready => run_land_ready(root, branch, &mut ledger, out, err)?, + land::Step::Push => run_land_push(root, url, branch, out)?, + land::Step::Wait => { + let (code, verdict) = run_land_wait(root, reference, branch, out, err)?; + seen = verdict; + code + } + land::Step::FastForward => run_land_fast_forward(root, branch, out, err)?, + }; + // ENTERED ON SUCCESS, OR ON THE ATTEMPT WHERE THE UNDO SAYS SO. The + // first half is the discrimination the undo rests on: a `Ready` that + // REFUSED bought no matrix, so re-drafting over it would draft a pull + // request the lap never made ready. The second half is what that rule + // gets wrong on its own — `Wait` answers `Success` only when it is + // GREEN, so red, stale and unanswered recorded nothing and + // `Compensation::Abandon` ran only after a green wait whose + // fast-forward then lapped. `owed_on_attempt` carries the reason and + // keeps it off this loop, where a `step == Wait` arm would be the + // `step == Verify` exception `pipeline` exists to have removed. + if row.entered(code == ExitCode::Success) { + entered.push(step); + } + note_the_push(step, code, &mut bet); + match land::progress_of(step, code, seen) { + land::Progress::Proceed => {} + // `None` is not merged, or nobody could say. Either way this is + // a lap rather than a retirement — see `landed_for_real`. + land::Progress::Landed => match landed_for_real(root, url, branch, out)? { + Landing::Retired(code) => return Ok(code), + // The forge says it did NOT merge, so this head is not trunk + // and the lap's own effects are still this lap's to undo. + Landing::NotMerged => { + unwind_lap(root, branch, &pipeline, &mut entered, seen, out, err)?; + continue 'laps; + } + // **NOBODY COULD SAY, SO NOTHING IS CANCELLED** (review of + // #848). Under fast-forward landing this branch's head IS + // trunk's new tip the moment the merge happens, and + // `Compensation::Abandon` reads `git::head_commit` and cancels + // every run carrying that sha — so unwinding on an unread + // answer cancels `main`'s own post-merge runs. The bot said + // `Accepted`; the only thing missing is confirmation. + // + // Lapping without compensating is the cheap direction: the + // next lap re-reads the merge state, and a lap that really + // did not land still has its ready and its runs, which the + // NEXT unwind owes. `entered` outlives the lap and only + // `unwind_lap` drains it, so nothing is forgotten — only + // deferred. + Landing::Unconfirmed => continue 'laps, + }, + land::Progress::Lap => { + // `refusal` rather than shadowing `code`: the STEP's code is + // what tells a reclaimed gate from a lease wait, so the + // charge needs it and a shadow would hide it (CLOUD-1586). + if let Some(refusal) = charge_or_refuse(step, code, &mut ledger, err)? { + unwind_lap(root, branch, &pipeline, &mut entered, seen, out, err)?; + return Ok(refusal); + } + writeln!( + out, + "land: lap {lap} — {step:?} says lap; rebasing and retrying" + )?; + // A LAP COMPENSATES TOO, and missing this is what a + // `Progress::Compensate` variant would have done: a lap that + // readied, spent and then laps has a ready pull request and a + // live matrix for a SHA about to be replaced. + unwind_lap(root, branch, &pipeline, &mut entered, seen, out, err)?; + continue 'laps; + } + // CARRYING THE STEP'S OWN CODE rather than a code of the loop's. + // A conflict and a refused gate are both `2`, an unnamed gate is + // `1`, and an unreadable clone is `3` — the caller reads the same + // answer it would have got running that step by hand, which is + // what keeps the loop from becoming a second exit vocabulary. + land::Progress::Stop => { + unwind_lap(root, branch, &pipeline, &mut entered, seen, out, err)?; + return Ok(code); + } } - // A LOST CAS IS A VERDICT ABOUT THE REPOSITORY, so `2` rather than a - // failure code: somebody else moved this branch, the lap has an - // answer, and the answer is to lap again. - land::Pushed::Raced => { + } + } + // **THE DEFERRED UNDOS COME DUE HERE, because there is no next lap to defer + // them to** (review of #848). `Landing::Unconfirmed` is the one arm that laps + // WITHOUT compensating, on the argument that the next lap re-reads the merge + // state — and on the LAST lap there is no next lap, so the loop fell out of + // the range and returned with `entered` still populated. The lease was held + // to its TTL and the in-flight runs kept spending, which is exactly what the + // compensation cluster exists to prevent. + // + // Unconditional rather than guarded on that arm: every other exit already + // drained `entered`, so this is a no-op for them and the guard would be a + // second statement of which paths compensate. + unwind_lap(root, branch, &pipeline, &mut entered, None, out, err)?; + // NOT A VERDICT ABOUT THE BRANCH. Exhausting the count says the loop stopped + // asking, never that the head is unlandable — so `3`, and the caller runs it + // again if the laps were lost to contention rather than to a defect. + // WHAT THE ACCOUNTING SUPPORTS, never the lap counter. The two look + // interchangeable and are not: a lap that stopped before the ready bought + // nothing, so reporting laps as spend states a cost that was never paid. + say_the_laps_are_spent(laps, &ledger, err)?; + Ok(ExitCode::Internal) +} + +/// The exhaustion refusal, which has two readings and used to have one. +/// +/// # A CONTENDED FLEET IS NOT A FAILING BRANCH +/// +/// `Ledger::lease_waits` was charged by `charge_the_lap` and read by nothing +/// here (review of #848), so on a contended fleet — where every lap exits +/// `Lease → Violation → Lap` having spent no CI at all — this named only `laps` +/// and `paid` and then asserted *"a conflict, a failed gate or red CI will lose +/// again"* over a landing that bought nothing and failed nothing. That is the +/// CLOUD-413 mis-diagnosis [`land::Bound::LeaseWaits`] exists to prevent, +/// arriving through the ordinary exit rather than through the bound. +/// +/// **CONDITIONAL RATHER THAN APPENDED**, because the two cases want opposite +/// advice — run it again, versus go and read the failure — and printing both +/// would be the hedge that leaves a reader no better off. +fn say_the_laps_are_spent(laps: u32, ledger: &land::Ledger, err: &mut dyn Write) -> Result<()> { + if ledger.lease_waits > 0 { + writeln!( + err, + "::error:: land: {laps} lap(s) bought no landing, spending {} CI matri(ces); {} of those lost only to another branch holding the landing lease, and spent nothing. A saturated fleet is not a failing branch — run this again.", + ledger.spent(), + ledger.lease_waits + )?; + return Ok(()); + } + writeln!( + err, + "::error:: land: {laps} lap(s) bought no landing, spending {} CI matri(ces). A conflict, a failed gate or red CI will lose again — read the lap lines above for how each ended. If every lap lost only to contention, running this again commits up to {laps} more.", + ledger.spent() + )?; + Ok(()) +} + +/// Run the undos this lap owes, newest first. +/// +/// # This is where the compensation cluster gets its entry point +/// +/// `redraft`, `abandon` and the lease tombstone were all built and none of them +/// were reached: a lap that readied — *"the one site that buys a matrix"* — and +/// then stopped at `push`, `wait` or `fast-forward` returned with the pull +/// request ready and CI still spending, while the tap sat uncalled in the same +/// file. PR #848's review found that; this function is the answer to it. +/// +/// # Every arm is a DURABLE EXTERNAL WRITE, which is why none of them is a trap +/// +/// [`crate::pipeline::Compensation`]'s header carries the argument and +/// `land.sh:353` carries the measurement — *"a trap runs on the container kill +/// too"* — so an in-process rollback does not run in the one case compensation +/// exists for. Each arm here lands on the forge or on a remote ref. +/// +/// # Nothing here is fatal, in either direction +/// +/// The caller is already leaving: it is lapping or stopping with an answer, and +/// an undo that could not be performed must not replace that answer with its own. +/// So every arm reports what it could not do and carries on to the next — which +/// also means the LATER undos still run when an earlier one cannot, and reversing +/// that would let one unreadable pull request strand a live matrix. +fn unwind_lap( + root: &Path, + branch: &str, + pipeline: &pipeline::Pipeline, + entered: &mut Vec, + seen: Option, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result<()> { + // **DRAINED HERE RATHER THAN AT EACH CALL SITE**, because there are five of + // them and the one that must NOT drain is the arm that does not call this at + // all. Clearing where the compensation happens is what keeps the two facts — + // "these were undone" and "these are still owed" — from drifting apart. + let owed = pipeline.unwind(entered); + entered.clear(); + if owed.is_empty() { + return Ok(()); + } + let repo = repo_or_placeholder(root); + // ONE OBSERVATION FOR TWO QUESTIONS. Whether this clone owns the pull request + // and whether it owes the lease back are the same fact, and asking twice + // invites the two answers to disagree across the gap between them. + let holder = lease_identity(root).ok().map(|(_, holder)| holder); + let now = i64::try_from(now_unix()).unwrap_or(i64::MAX); + let mine = match (&holder, lease::terms(root)) { + // THROUGH `lease::holds_now`, WHICH IS THE ONE READING OF "IS IT STILL + // MINE" (review of #848). This compared `body.holder` alone, so a lease + // this clone once held but has RELEASED or let EXPIRE still answered + // yes — and `closes_the_tap` would then re-draft a pull request another + // lander now owns. The two clauses it dropped are exactly the ones that + // change over time, which a bare comparison never can. + (Some(holder), Ok(terms)) => { + lease::observe(&terms).is_ok_and(|observed| lease::holds_now(&observed, holder, now)) + } + // COULD NOT LOOK IS NOT HELD. A clone that cannot read the lease has not + // shown it owns the pull request, and `closes_the_tap` refusing to draft + // somebody else's work is the property that keeps a refused second land + // from touching the live one's. + _ => false, + }; + + for compensation in owed { + match compensation { + // Filtered out by `Pipeline::unwind`, and matched rather than + // wildcarded so a new arm is a compile error here. + pipeline::Compensation::Nothing => {} + pipeline::Compensation::Abandon => { + let Ok(sha) = git::head_commit(root) else { + writeln!( + err, + "::error:: land: this clone's HEAD will not read, so the runs on it keep spending" + )?; + continue; + }; + // THE WORKFLOW, NEVER THE CHECK, and the two are distinct + // consumer values: `CI_FANIN_CHECK` names a CHECK and + // `CI_FANIN_WORKFLOW` names a workflow PATH. What either one + // holds is that consumer's own config and is deliberately not + // written down here (non-negotiable rule 1). `land::worthless` + // compares against a run's `path`, so reading the check name + // here made the comparison unsatisfiable — `spared` was always + // 0 and the fan-in's own run was cancelled with the rest, which + // is exactly the wedge this whole arm exists to prevent: the + // fan-in is the one context branch protection requires, so + // cancelling it leaves it ungraded and the pull request stuck. + // + // Found by reading `tests/abandon-matrix.bats`'s own titles while + // retiring it (CLOUD-1148) — *"THE ROW THAT MATTERS: the run + // carrying the fan-in is never cancelled"* — and by nothing else. + // Every suite in this crate was green over it. + // + // An unset one cancels NOTHING rather than guessing; + // `land::abandon` holds that guard. + // THE CONSTRUCTOR AND THE DECLARATION ARE ONE EXPRESSION, and + // that adjacency is what `ci-parity` binds on. `fan-in-is-wired` + // used to ask two independent questions of this file — does + // something read the declaration, does something call + // `land::abandon` — which an unrelated read plus a wrong argument + // satisfies (review of #848). `land::FanIn` refuses the check + // name at the type, and the module now requires the read to sit + // at the constructor rather than anywhere in 6,000 lines. + let fanin = land::FanIn::from_workflow_path( + std::env::var("CI_FANIN_WORKFLOW").unwrap_or_default(), + ); + let report = land::abandon(&repo, &sha, &fanin); + // COUNTS AND AN ABBREVIATED SHA, never a line from a cancelled + // run (non-negotiable rule 4). The predecessor carried the + // pointer too — `abandon-matrix.bats` pins it — because three + // counts with no subject cannot be told from another head's. writeln!( out, - "land: {branch} moved under this push; the remote refused and this lap is spent" + "land: undo on {} — {} run(s) cancelled, {} spared, {} refused", + sha.get(..7).unwrap_or(&sha), + report.cancelled, + report.spared, + report.refused )?; - Ok(ExitCode::Violation) } - }; + pipeline::Compensation::ReleaseLease => match (&holder, lease::terms(root)) { + (Some(holder), Ok(terms)) => { + lease_hand_back(root, &terms, holder, now); + writeln!(out, "land: undo — the landing lease is handed back")?; + } + // Not an error: a lap that never took the lease owes nothing, and + // a clone with no remote had nowhere to take one from. + _ => { + writeln!(out, "land: undo — no lease of this clone's to hand back")?; + } + }, + pipeline::Compensation::Redraft => { + let Some(pr) = fast_forward::open_pull_request(&repo, branch) else { + writeln!( + err, + "::error:: land: no open pull request could be read for {branch}, so the tap stays open" + )?; + continue; + }; + let read = land::draft_state(&repo, &pr); + let state = land::Tap { + // NOT A CLAIM THIS FUNCTION MAKES UP: every site that reaches + // here is a lap that laps or stops, and the one that lands + // returns before the undo. A merge closes nothing. + landed: false, + singleton_held: mine, + is_draft: read.as_ref().map(|state| state.draft), + verdict: seen, + }; + if !land::closes_the_tap(&state) { + continue; + } + let Some(land::Readiness { node, .. }) = read else { + continue; + }; + if land::redraft(&node) { + writeln!( + out, + "land: undo — the pull request is a draft again; the next push buys no runner" + )?; + } else { + writeln!( + err, + "::error:: land: the pull request would not go back to draft, so every push from here spends a runner on an unfixed failure" + )?; + } + } + } } + Ok(()) +} - match land::replay(root, url, &reference, &branch)? { - land::Replay::Conflicted { commit, paths } => { - writeln!( - out, +/// Settle an outstanding speculation, unwinding one that cannot come true. +/// +/// # THE ENTRY POINT `speculation` DID NOT HAVE +/// +/// That module is a complete decision layer — `settle`, `recover`, `carries`, +/// `Bet`, `Live`, with its own suite — and it was reachable from nothing but +/// `pub mod`. Twenty-one cases in `tests/land.bats` describe behaviour no call +/// site could produce, which is the shape PR #848's review found for the +/// compensation cluster and the ready event before it. +/// +/// # ASK GIT BEFORE ASKING THE PROCESS +/// +/// [`speculation::recover`] runs first and unconditionally. The predecessor +/// opened on *"did this process place a bet"* and returned on its first line +/// when the answer was no — while the ref holding the answer sat on disk beside +/// it. Measured (CLOUD-862): a stopped `land` left seven of another branch's +/// commits in the tree, and the next one ran a full clean `verify` and reached +/// the push with them. +/// +/// # `Some(code)` STOPS THE LAP, and only an unwind this tree refuses does that +/// +/// Every reading here fails open — an unreachable remote, an unresolvable ref +/// and an unknown ancestry all mean *the bet is stale*, never *stop the +/// landing*. The one thing that stops is a tree the unwind could not rewind, +/// because carrying on would push another branch's commits under this one's +/// pull request. +/// +/// # THE SETTLE RUNS BEFORE THE PLACEMENT, AND THE ORDER IS A DECISION +/// +/// [`place_the_bet`] runs immediately after this and never before it. A +/// placement over an unsettled bet stacks a second borrowed range on a first, +/// and the undo point recorded for the second is then already speculative — so +/// the unwind would restore a tree still carrying somebody else's commits, which +/// is the exact hazard [`speculation::Bet::undo`] exists to remove. +/// +/// This half is also the one that is useful alone: a bet left behind by the bash +/// lander, or by a `land` this loop replaced, is adopted from its ref and +/// unwound here rather than pushed, whether or not anything ever places one. +fn settle_the_bet( + root: &Path, + url: &str, + bet: &mut speculation::Bet, + reference: &str, + branch: &str, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result> { + // A ref this clone cannot read is not a bet, which is the same fail-open + // direction every other reading here takes. + if speculation::recover(root, bet).unwrap_or(false) { + writeln!( + out, + "land: adopting an unsettled speculation left by an earlier run; settling it before anything is pushed" + )?; + } + if !bet.live() { + return Ok(None); + } + // `land::tracking_ref`, not a second spelling of it — and the second + // spelling here carried the same last-segment defect it did (review of + // #848): a trunk named `release/1.x` resolved to `origin/1.x`, so this + // settled a bet against an unrelated branch's tracking ref. + let tracking = land::tracking_ref(reference); + // `None`, never `""`. A ref that would not read is a could-not-look, and + // flattening it into an empty string made it compare unequal to every + // `main_at_bet` — which is `speculation::settle`'s "the trunk moved and took + // something else" arm, so a transient read unwound a live bet. The reading is + // handed over three-valued and the settle defers to the lease. + let main_now = git::resolve_ref(root, &tracking).ok().flatten(); + let base = bet.published().unwrap_or_default().to_owned(); + // WON, and it is asked first and unconditionally: the base being an ancestor + // of the trunk is true whoever placed the bet, and both arms below would + // misread it. + let base_on_main = speculation::carries(root, &base, &tracking); + let live = bet_liveness(root, branch, &base); + + match speculation::settle(bet, main_now.as_deref(), base_on_main, live) { + speculation::Settle::Landed => { + // The tracking ref resolved, or `base_on_main` could not have been + // true — but the arm is spelled without an unwrap either way, because + // a line naming the trunk is not worth a panic on a reading this + // function has just finished treating as optional. + writeln!( + out, + "land: the speculation landed — already linearized on {}, no rebase needed", + main_now + .as_deref() + .map_or_else(|| tracking.clone(), |sha| short(sha).to_owned()) + )?; + drop_the_bet(root, bet); + Ok(None) + } + // Undecided — keep the tree: the holder is still landing and this branch + // is already linearized behind it. `Nothing` shares the arm because it is + // unreachable from here (the `live()` guard above returns first) and + // because it takes the same action if the guard ever moves: no bet is + // nothing to unwind. + speculation::Settle::Pending | speculation::Settle::Nothing => Ok(None), + speculation::Settle::Lost => unwind_the_bet(root, url, branch, bet, reference, out, err), + } +} + +/// Is the bet still on the branch that is about to land? Fails CLOSED. +/// +/// [`speculation::Live::decide`] treats anything but a confirmed yes as stale, +/// and this resolves the reading it decides over: who holds the lease NOW, and +/// whether the base is still on that branch. Failing open here would make a +/// network blip the thing that lands somebody else's work. +fn bet_liveness(root: &Path, branch: &str, base: &str) -> speculation::Live { + let Ok(terms) = lease::terms(root) else { + return speculation::Live::Unreadable; + }; + let Ok(observed) = lease::observe(&terms) else { + return speculation::Live::Unreadable; + }; + let lease::Observed::Held { body, .. } = &observed else { + // Nobody holds it. Holding no lease with the base not yet on the trunk + // can only mean the branch we bet on is gone — a base that actually + // landed is caught one arm earlier, by the ancestry check. + return speculation::Live::No; + }; + if body.branch.is_empty() || body.branch == branch { + // WE hold it, or it names nobody. Either way the bet is not on somebody + // else's landing any more. + return speculation::Live::No; + } + // The holder may have changed or force-pushed past our base, and the + // question is the same either way: is the commit we bet on still on the + // branch that is about to become the trunk. A SECOND ref, never `BASE_REF` — + // reusing it would overwrite the base while asking a question about it. + let reference = format!("refs/heads/{}", body.branch); + match land::advance(root, &terms.remote, &reference, speculation::LIVE_REF) { + Ok(_) if speculation::carries(root, base, speculation::LIVE_REF) => speculation::Live::Yes, + Ok(_) => speculation::Live::No, + Err(_) => speculation::Live::Unreadable, + } +} + +/// Drop the borrowed range, and say which unwind it took. +/// +/// **TWO UNWINDS, because an adopted bet has no undo point** (CLOUD-862). The +/// reset is exact and is the path whenever this process placed the bet; the +/// replay is for one inherited from a dead run, which has only the base — and +/// `origin/main..HEAD` minus the borrowed range is precisely this branch's own +/// commits. +/// Charge this lap against the budget it spent, or refuse having said which one +/// is exhausted. +/// +/// **A LAP THAT SPENT NOTHING IS REFUNDED, and nothing called this** (review of +/// #848). `Ledger::waited` and its siblings had no production caller at all, so +/// a lap lost to a lease another branch holds consumed one of the two — and a +/// contended fleet exhausted the budget and then reported "a conflict, a failed +/// gate or red CI will lose again", which is exactly the mis-diagnosis +/// [`land::Bound::LeaseWaits`] exists to prevent and which CLOUD-413 measured +/// being wrong twice across 24 laps. +/// +/// Two arms are wired now — the lease wait and the reclaimed gate (CLOUD-1586). +/// The bot's unreadable answer no longer reaches this point — the poll absorbs +/// it — and the transient re-run has no producer yet, so wiring either would be a +/// call site for a condition nothing raises. +/// +/// **THE MESSAGE IS PER BOUND, and it was one sentence for all of them.** This +/// function's own header names the mis-diagnosis class it exists to prevent, and +/// a hardcoded "the fleet is saturated" printed over a `GateReclaims` bound would +/// BE that class: nothing about the fleet is saturated, the trunk is simply +/// moving faster than this gate takes. Both arms have spent no CI, which is the +/// half they share and the half worth repeating in either sentence. +/// +/// `Some(code)` is the refusal and the caller still owes its unwind: the undos +/// are the driver's, and performing them here would put the compensation cluster +/// behind two call sites instead of one. +fn charge_or_refuse( + step: land::Step, + code: ExitCode, + ledger: &mut land::Ledger, + err: &mut dyn Write, +) -> Result> { + let Some(bound) = charge_the_lap(step, code, ledger) else { + return Ok(None); + }; + // NO CATCH-ALL, and clippy is what insisted: the match is total inside this + // crate, so a new `Bound` breaks the build here rather than falling into a + // generic sentence. That coupling is the whole point — an exhaustion whose + // diagnosis nobody wrote is the mis-diagnosis class this function's header + // names, arriving by omission instead of by a hardcoded sentence. + let diagnosis = match bound { + land::Bound::GateReclaims => { + "the base kept moving out from under the gate, so every lap's gate was reclaimed before it finished — the trunk is moving faster than this gate takes, and a re-run will not change that" + } + land::Bound::LeaseWaits => { + "the fleet is saturated: this branch never won the landing lease" + } + land::Bound::Unknowns => "the fast-forward bot never gave a readable answer", + land::Bound::Transients => { + "CI kept failing before reaching a verdict, so the provisioning path is broken rather than flaky" + } + }; + writeln!( + err, + "::error:: land: gave up after {bound:?} — {diagnosis}; this branch has spent no CI at all" + )?; + Ok(Some(ExitCode::Internal)) +} + +/// Record that the speculative range reached the remote. +/// +/// **THE ONE WRITER OF [`speculation::Bet::pushed`], which had none** (review of +/// #848). The field was declared with its consequence written on it — an unwind +/// then owes the remote a correction — and was neither set nor read, so the +/// correction never came due and origin kept another branch's commits under an +/// open pull request. +/// +/// A successful push under an outstanding bet is the only event that puts the +/// range there, so the write is here rather than in `run_land_push`: that is a +/// standalone verb and holds no bet. +fn note_the_push(step: land::Step, code: ExitCode, bet: &mut speculation::Bet) { + if step == land::Step::Push && code == ExitCode::Success && bet.live() { + bet.pushed = true; + } +} + +fn unwind_the_bet( + root: &Path, + url: &str, + branch: &str, + bet: &mut speculation::Bet, + reference: &str, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result> { + // DERIVED HERE rather than handed in, which is one argument and one + // duplicated derivation fewer: both are functions of what this already + // holds, and the caller's copies are the same two calls. + let base = bet.published().unwrap_or_default().to_owned(); + let tracking = land::tracking_ref(reference); + // FULLY QUALIFIED, because both writers end in `gitwrite::set_ref` and its + // `FullName::try_from` does not reject a SHORT name — it rejects a slashless + // lowercase one and takes a slashed one VERBATIM as a full name. `branch` + // comes from `git::current_branch`, which shortens, so `feature/x` wrote a + // stray loose ref outside `refs/heads/` while the checkout moved, and + // `Somelowercase` failed outright with the borrowed range still in the tree. + // Every sibling already qualifies — `place_the_bet` and `land::replay` both + // spell `refs/heads/{branch}`, and the only tests of these two writers pass a + // full name, which is why nothing caught it (review of #848). + let full = format!("refs/heads/{branch}"); + let outcome = if let Some(undo) = bet.undo.as_deref() { + writeln!( + out, + "land: the speculation did not land; unwinding to {} rather than carrying another branch's commits", + short(undo) + )?; + gitwrite::reset_hard(root, &full, undo).map(|_| true) + } else { + writeln!( + out, + "land: an earlier run bet on a base that is no longer landing; replaying this branch's own commits onto {tracking} rather than carrying another branch's" + )?; + gitwrite::replay_onto(root, &full, &base, &tracking) + .map(|replayed| !matches!(replayed, gitwrite::Rebase::Conflicted { .. })) + }; + if let Ok(true) = outcome { + // **THE REMOTE OWES A CORRECTION TOO, and nothing paid it** (review of + // #848). `Bet::pushed` was declared with this consequence written on it + // and was neither set nor read, so unwinding restored the LOCAL branch + // and left origin holding another branch's commits under this pull + // request — the measured two-PRs-at-one-sha state, and the one the field + // names in its own doc. + // + // The CAS makes this safe rather than merely correct: `lease::push` + // takes `old` from the advertisement, so a remote somebody else has since + // moved refuses and the lap reports a race instead of overwriting them. + // + // NOT FATAL, for `unwind_lap`'s reason: the caller is already leaving + // with an answer, and a correction that would not go through must not + // replace it. It is reported, and the next lap's own push re-attempts it + // from a tree that no longer carries the range. + if bet.pushed { + match land::push(root, url, branch) { + Ok(land::Pushed::Landed(head)) => writeln!( + out, + "land: the speculative range was pushed, so {branch} on the remote now reads {head} rather than another branch's commits" + )?, + Ok(land::Pushed::Raced) => writeln!( + err, + "::error:: land: {branch} moved on the remote, so the speculative range it carries was left in place; the next lap re-reads it" + )?, + Err(_) => writeln!( + err, + "::error:: land: the remote could not be corrected, so {branch} there may still carry another branch's commits" + )?, + } + } + drop_the_bet(root, bet); + return Ok(None); + } + // THE ONE STOP. A tree still carrying another branch's commits must not reach + // a push, so this is a decision rather than a lap: lapping would replay onto a + // HEAD nobody can describe. + writeln!( + err, + "::error:: land: the speculative range could not be unwound, so this tree still carries commits that are not landing and this loop must not push it" + )?; + Ok(Some(ExitCode::Internal)) +} + +/// Place a speculation: replay onto the head that is ABOUT to become the trunk. +/// +/// # WHAT IT BUYS, AND IT IS A LAP RATHER THAN A MATRIX +/// +/// While another branch holds the lease its head is what `main` will read once it +/// lands. A lap that replays onto today's trunk therefore verifies a head the +/// fast-forward will refuse, and the next lap replays and pays again. Replaying +/// onto the holder's head instead makes this branch a direct descendant of the +/// trunk the moment the holder lands, and the receipt taken over it is still +/// good. +/// +/// # EVERY EXIT IS "NO BET", AND THAT IS THE ONLY SAFE DIRECTION +/// +/// A bet not placed costs a lap. A bet placed on a base that will not land costs +/// somebody else's commits under this branch's pull request. So an unreadable +/// lease, an unresolvable ref, a holder that is us and a replay that conflicts +/// all leave without one — this is the mirror of [`bet_liveness`]'s fail-closed +/// reading, and it is closed in the same direction. +/// +/// A CONFLICTING BASE IS REFUSED RATHER THAN ADOPTED (CLOUD-369). A successor +/// whose base is known to conflict is guaranteed to be voided, so its run grades +/// a head the fast-forward will refuse and the rebase that follows still has to +/// resolve the same conflict. Measured for one such admission: a full CI run +/// burned, a ~200s `verify` discarded, a hand-resolved conflict, and a second +/// run. The refusal is recorded on the bet rather than merely returned, so a +/// reader can tell "no holder" from "a holder we will not build on". +/// +/// # THE REF IS WRITTEN BEFORE THE REPLAY +/// +/// A bet outlives the process that placed it. Recording after the replay leaves a +/// window in which the tree carries a borrowed range and nothing on disk says so +/// — which is CLOUD-862's state exactly, and the whole reason [`settle_the_bet`] +/// opens by asking git. +fn place_the_bet( + root: &Path, + bet: &mut speculation::Bet, + reference: &str, + branch: &str, + out: &mut dyn Write, +) -> Result<()> { + // THE ONE-OUTSTANDING-BET RULE, asked before anything is read. + if bet.live() { + return Ok(()); + } + let Some(candidate) = holder_head(root, branch) else { + return Ok(()); + }; + if !bet.would_rebet(&candidate) { + return Ok(()); + } + let tracking = land::tracking_ref(reference); + // THE HOLDER ALREADY LANDED. Their head is on the trunk, so an ordinary replay + // reaches it and a bet would borrow a range that is not borrowed. + if speculation::carries(root, &candidate, &tracking) { + return Ok(()); + } + // **AND THE HOLDER MUST BE BUILT ON CURRENT TRUNK, OR THE REPLAY THAT + // FOLLOWS CARRIES THE BORROWED RANGE ONTO IT** (review of #848). + // + // `Precheck::BetSettled` sits on the `Replay` row, so this runs and then + // `run_land_replay` immediately fetches a fresh trunk and rebases onto it. + // If trunk advanced past `candidate` while the holder was still mid-landing, + // `gitwrite::rebase`'s `Rebase::Current` short-circuit does not fire and the + // range `tracking..branch` — which now contains the commits just borrowed — + // is replayed onto trunk. `Step::Push` then publishes copies of another + // branch's commits under this pull request, which is precisely the state the + // whole speculation machinery exists to prevent. + // + // The clause above is the OTHER direction and does not cover this: it asks + // whether the holder is already IN trunk. This asks whether trunk is already + // in the holder — that is, whether betting on it linearizes this branch + // forward rather than sideways. A holder that trunk has passed is a bet + // worth nothing anyway, so declining costs a lap and no correctness. + if !speculation::carries(root, &tracking, &candidate) { + return Ok(()); + } + let Ok(undo) = git::head_commit(root) else { + return Ok(()); + }; + let main_at_bet = git::resolve_ref(root, &tracking).ok().flatten(); + + // RECORDED FIRST. See the header: a tree carrying a borrowed range with + // nothing on disk saying so is the state a killed process leaves behind. + if gitwrite::set_ref(root, speculation::BASE_REF, &candidate).is_err() { + return Ok(()); + } + let replayed = gitwrite::rebase(root, &format!("refs/heads/{branch}"), &candidate); + match replayed { + Ok(gitwrite::Rebase::Conflicted { .. }) => { + bet.conflicts = Some(candidate.clone()); + let _ = gitwrite::delete_ref(root, speculation::BASE_REF); + writeln!( + out, + "land: the branch holding the lease conflicts with this one, so this lap builds on {tracking} rather than speculating" + )?; + Ok(()) + } + Ok(_) => { + bet.base = Some(candidate.clone()); + bet.undo = Some(undo); + bet.main_at_bet = main_at_bet; + bet.recovered = false; + writeln!( + out, + "land: speculating on {} — the branch holding the lease is about to become the trunk", + short(&candidate) + )?; + Ok(()) + } + // A REPLAY THAT WOULD NOT RUN AT ALL leaves no bet and no ref. The tree + // is untouched by a refused rebase, so there is nothing to unwind. + Err(_) => { + let _ = gitwrite::delete_ref(root, speculation::BASE_REF); + Ok(()) + } + } +} + +/// The head of the branch currently holding the lease, or `None`. +/// +/// `None` for every could-not-look and for every reading that is not *somebody +/// else is landing right now*: no terms, no lease, a lease naming nobody, and a +/// lease this branch holds itself. [`place_the_bet`]'s header says why they all +/// take the same exit. +fn holder_head(root: &Path, branch: &str) -> Option { + let terms = lease::terms(root).ok()?; + let observed = lease::observe(&terms).ok()?; + let lease::Observed::Held { body, .. } = &observed else { + return None; + }; + if body.branch.is_empty() || body.branch == branch { + return None; + } + let reference = format!("refs/heads/{}", body.branch); + land::advance(root, &terms.remote, &reference, speculation::LIVE_REF).ok() +} + +/// Forget a settled bet: the bookkeeping and the ref that outlives the process. +/// +/// **BOTH HALVES, and neither alone is a forget.** The struct is this process's +/// memory and the ref is the one a later run reads, so clearing only the struct +/// leaves the next `land` adopting a bet this one already settled, and deleting +/// only the ref leaves this lap believing it still holds one. +fn drop_the_bet(root: &Path, bet: &mut speculation::Bet) { + bet.forget(); + let _ = gitwrite::delete_ref(root, speculation::BASE_REF); + let _ = gitwrite::delete_ref(root, speculation::LIVE_REF); +} + +/// The lap's own lease acquisition, so one branch at a time buys a matrix. +/// +/// # NOTHING CALLED THIS, AND THE WHOLE CLUSTER DEPENDED ON IT +/// +/// `run_lease_acquire` had exactly two callers before this: the `batten lease` +/// CLI dispatch, and `lease_hand_back` releasing what nobody took. So under +/// `mise run land` the lease was never held, `unwind_lap`'s `mine` was always +/// false, `Tap { singleton_held: mine }` was false, and `land::closes_the_tap` +/// returned before `land::redraft` could be reached. After a red wait the pull +/// request stayed READY and every later push bought another matrix on a failure +/// nobody had fixed — which is verbatim the leak `closes_the_tap`'s own header +/// says it exists to plug (review of #848). +/// +/// `land::push`'s receive-pack CAS is not this: it excludes two writers of one +/// BRANCH REF, and the lease excludes two branches of one FLEET. Reading the +/// first as the second is what made the gap invisible. +/// +/// # It is a thin adapter, deliberately +/// +/// Every decision is [`lease`]'s and every code is `run_lease_acquire`'s +/// already: `Success` took it or already held it, `Violation` somebody else +/// holds it, `Internal` it would not read. `land::progress` maps the second to a +/// LAP — the holder is landing, so this branch waits and asks again, which is +/// what makes the mechanism a queue — and the third to a STOP. +/// +/// # Errors +/// +/// Only for a stream that will not accept output; a lease that cannot be +/// resolved is a code rather than an error, for the reason above. +fn run_land_lease( + root: &Path, + branch: &str, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let terms = match lease::terms(root) { + Ok(terms) => terms, + Err(missing) => { + // `say` rather than `Display`, which this type deliberately does not + // implement: the diagnostic needs the remote's NAME to be readable + // and the enum does not carry it. + let name = std::env::var("LAND_LOCK_REMOTE").unwrap_or_else(|_| String::from("origin")); + writeln!( + err, + "::error:: land: the landing lease has no terms in this clone ({}), so nothing serialises which branch spends a matrix", + missing.say(&name) + )?; + return Ok(ExitCode::Internal); + } + }; + let now = i64::try_from(now_unix()).unwrap_or(i64::MAX); + run_lease_acquire(root, &terms, branch, now, out, err) +} + +/// What the merge confirmation decided, and the third arm is why it is a type. +/// +/// **`Some`/`None` COLLAPSED TWO ANSWERS THAT MUST COMPENSATE DIFFERENTLY** +/// (review of #848). A pull request the forge says did NOT merge leaves this +/// lap's ready and its runs this lap's to undo. A pull request nobody could ASK +/// about may already be merged — and under fast-forward landing that means this +/// branch's head is trunk's new tip, so `Compensation::Abandon`, which reads +/// `git::head_commit` and cancels every run carrying that sha, would cancel +/// `main`'s own post-merge runs. One 403 between the bot's `success` and the +/// confirming read was enough. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Landing { + /// It merged and the branch is retired. The landing is over. + Retired(ExitCode), + /// The forge answered: not merged. Lap, and undo what this lap did. + NotMerged, + /// The forge did not answer. Lap, and cancel NOTHING. + Unconfirmed, +} + +/// Retire the branch only if the pull request actually merged. +/// +/// # `Progress::Landed` IS NOT "MERGED", AND READING IT AS ONE DELETED BRANCHES +/// +/// That variant means the bot's run finished without refusing. +/// [`fast_forward`]'s own module header says the rest out loud: *"the merge shows +/// up as the pull request's own terminal state rather than here"*. Nothing read +/// that state, so a run concluding `skipped` — a job-level `if:`, a path filter, +/// a concurrency rule — reached here and [`retire_the_branch`] deleted +/// `refs/heads/` on the remote, its tracking ref and its receipts, under +/// a pull request that was still open (review of #848). The predecessor asked the +/// forge at exactly this point and died on anything but a merge. +/// +/// `Some(code)` is a landing that is over. `None` means lap: the caller +/// compensates and goes round again. +/// +/// **A COULD-NOT-LOOK LAPS**, and the asymmetry is the whole argument — lapping +/// costs a lap, and being wrong the other way deletes a branch somebody's open +/// pull request still points at. +/// +/// # Errors +/// +/// Only for a stream that will not accept output. +fn landed_for_real(root: &Path, url: &str, branch: &str, out: &mut dyn Write) -> Result { + let repo = repo_or_placeholder(root); + // ANY STATE, because a merged pull request is CLOSED and the open-only + // lookup can therefore never confirm one — see `pull_request_in_any_state`. + let merged = match fast_forward::pull_request_in_any_state(&repo, branch) { + fast_forward::Lookup::Found(pr) => fast_forward::merged(&repo, &pr), + // The pull request the bot was asked about cannot be found now. That is a + // could-not-look about the merge, never evidence of one. + fast_forward::Lookup::None => fast_forward::Merged::Unreadable(0), + fast_forward::Lookup::Unreadable(status) => fast_forward::Merged::Unreadable(status), + }; + match merged { + fast_forward::Merged::Yes => { + writeln!(out, "land: landed")?; + // **THE LEASE IS HANDED BACK ON THE WAY OUT, and the successful path + // never did it** (review of #848). `Compensation::ReleaseLease` runs + // only from `unwind_lap`, and this arm returns before reaching it — + // so a branch that landed cleanly held its lease until the TTL + // expired, and every waiter behind it sat out that TTL before it + // could spend a matrix. That is the exact cost the compensation's own + // doc says it exists to avoid, paid on the one path where the lease + // is certainly finished with. The predecessor released + // unconditionally from its exit handler, which the merged path + // reached too. + // + // Before the retirement rather than after: a hand-back that fails is + // best-effort either way, and doing it first means a slow remote + // delete cannot widen the window another branch waits through. + hand_back_the_lease(root, branch, out); + // THE BRANCH HAS DONE ITS WHOLE JOB (CLOUD-349, CLOUD-1471). Only + // here, never on a stop: an abandoned branch is evidence and has to + // survive, while a landed one left behind is how a short-lived branch + // becomes a long-lived one — and reusing the name afterwards is the + // stale-tracking-ref deadlock `land::stale_tracking` records. + retire_the_branch(root, url, branch, out)?; + Ok(Landing::Retired(ExitCode::Success)) + } + fast_forward::Merged::No => { + writeln!( + out, + "land: the bot's run finished but the pull request has not merged; lapping rather than retiring a branch that is still open" + )?; + Ok(Landing::NotMerged) + } + fast_forward::Merged::Unreadable(status) => { + writeln!( + out, + "land: could not read whether the pull request merged ({status}); lapping without cancelling, because this head may already be the trunk" + )?; + Ok(Landing::Unconfirmed) + } + } +} + +/// The repository this lap is landing in, as the forge spells it. +/// +/// # THE PLACEHOLDER WAS A GUARANTEED 404, NOT A FALLBACK +/// +/// Nine sites read `$GH_REPO` and fell back to [`pr_watch::REPO_PLACEHOLDER`]. +/// That literal is the forge CLI's own substitution, performed inside the client +/// the retirement removed — `rest::get` sends the path it is given, so +/// `{owner}/{repo}` reached the endpoint verbatim, every read 404'd, and +/// `open_pull_request` answered `None`. The caller then printed *"no open pull +/// request for ``"*, which is a could-not-look wearing a fact about the +/// branch, and `mise run land` stopped on it before its first lap (review of +/// #848). One site had already been corrected in place; the fallback was the +/// defect, so it is the fallback that moves. +/// +/// **The remote is the answer, and it is one this clone already has.** +/// [`race::slug_of`] reduces both spellings the forge hands out, and it refuses +/// to guess rather than deriving a slug that would ask the forge confidently +/// about a DIFFERENT repository. `$GH_REPO` still wins where it is set, because +/// a fork landing into an upstream is a fact only the operator has. +/// +/// `None` is could-not-look and callers must say so rather than reporting a +/// verdict about the branch. +fn repo_slug(root: &Path) -> Option { + if let Ok(declared) = std::env::var("GH_REPO") + && !declared.trim().is_empty() + { + return Some(declared); + } + // `LAND_LOCK_REMOTE`, which is what every sibling reads — `lease::terms`, + // the `lease` dispatch and `run_land_fast_forward` all resolve the remote by + // that name. This said `LAND_REMOTE`, a name appearing nowhere else in the + // tree, so a consumer pointing the lease at `upstream` would take the lease, + // fetch, push and delete against `upstream` while this looked up `origin` — + // resolving the wrong slug, or none, and falling back to the placeholder this + // function exists to remove (review of #848). + let name = std::env::var("LAND_LOCK_REMOTE").unwrap_or_else(|_| String::from("origin")); + let remotes = git::remotes(root).ok()?; + remotes + .iter() + .find(|(configured, _)| *configured == name) + .and_then(|(_, url)| race::slug_of(url)) +} + +/// The same reading with the placeholder kept for the sites that only ever +/// RENDER it, so a message naming the repository still has something to name. +fn repo_or_placeholder(root: &Path) -> String { + repo_slug(root).unwrap_or_else(|| pr_watch::REPO_PLACEHOLDER.to_owned()) +} + +/// A sha as a reader reads one. Pointer-only either way; this is the short form +/// every other line in this lap already uses. +fn short(sha: &str) -> &str { + sha.get(..7).unwrap_or(sha) +} + +/// The url of the remote this lap lands against, or `None` having said why. +/// +/// `None` rather than an error because every way this fails is a could-not-look +/// about the CLONE — no remotes readable, or none by the configured name — and +/// the caller turns that into the same `Internal` every other unreadable clone +/// produces. Returning the url by value rather than borrowing the remote list +/// keeps the arms above from having to hold it alive across the call. +fn land_remote(root: &Path, err: &mut dyn Write) -> Result> { + let name = std::env::var("LAND_LOCK_REMOTE").unwrap_or_else(|_| String::from("origin")); + let Ok(remotes) = git::remotes(root) else { + writeln!(err, "::error:: land: cannot read this repository's remotes")?; + return Ok(None); + }; + let Some((_, url)) = remotes.iter().find(|(configured, _)| *configured == name) else { + writeln!( + err, + "::error:: land: no remote named {name}, so this lap has no base" + )?; + return Ok(None); + }; + Ok(Some(url.clone())) +} + +/// `batten land fast-forward` (CLOUD-1338): ask, then read the answer to THAT ask. +/// +/// # `$LAND_WORKFLOW` and no default, for `$LAND_VERIFY`'s reason +/// +/// The bash lander defaults this to `fast-forward.yml`. That filename is THIS +/// consumer's, and a default compiled in here would be a consumer's vocabulary +/// inside `crates/batten` — non-negotiable rule 1's plainest violation, and the +/// same call `run_land_verify` already makes about the gate's name. +/// +/// The failure a default would buy is the quiet one: a repository whose bot lives +/// in a differently-named workflow would read an empty runs list, every lap, and +/// report a silent bot forever. A refusal costs one line of configuration. +/// +/// # The three exits, and why the middle one is not an error +/// +/// `0` the bot accepted, `2` it refused — the branch is no longer a direct +/// descendant, which is a verdict about this repository and the lap's cue to +/// rebase — and `3` no answer yet, which is the state the loop exists to sit in. +/// A forge that cannot be read is `3` and never a false `2`: a lap that could not +/// look has not been refused. +/// +/// `3` is spelled [`ExitCode::Internal`] because the table has four codes and no +/// per-verb exception (non-negotiable rule 5). The variant's name is about where +/// `3` came from historically; what it MEANS here is the same could-not-look +/// [`run_land_wait`] returns for an unanswered race, and the two agree +/// deliberately — a lap reads them through one contract. +/// # It writes no lap record, and the absence is a statement rather than an oversight +/// +/// Every other lap step writes a four-column line to the lap record, which is +/// what gives a `landing-loop` module something to decide over. This one does +/// not, because no predicate reads a fast-forward outcome yet — and a record +/// nothing reads is the dead channel this engine spends its time refusing +/// elsewhere. When a predicate wants one (whether a lap may re-ask after an +/// unknown conclusion is the obvious candidate), the record and the module land +/// together, which is the pairing `.claude/rules/policy-modules.md` requires in +/// both directions. +/// +/// **It DOES take a root, and the heading above used to say it did not** — that +/// sentence was about the record and was written as though it were about the +/// argument list. The root is what [`repo_slug`] resolves the remote from, and +/// without it this step fell back to a placeholder that cannot resolve (review +/// of #848). +fn run_land_fast_forward( + root: &Path, + branch: &str, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let workflow = std::env::var("LAND_WORKFLOW").unwrap_or_default(); + if workflow.trim().is_empty() { + writeln!( + err, + "::error:: land fast-forward: $LAND_WORKFLOW names no workflow, and this engine does not know which of this repository's workflows carries the fast-forward verdict" + )?; + return Ok(ExitCode::Usage); + } + // `GH_REPO` FIRST, AND THE PLACEHOLDER ONLY AS THE FALLBACK EVERY SIBLING + // SITE USES. This line read `String::from(REPO_PLACEHOLDER)` and the comment + // above it said the placeholder was "resolved by the client that used to be + // spawned and now by the endpoint itself" — which was true of the first half + // and false of the second. `{owner}/{repo}` is the FORGE CLI's own + // substitution, performed before the request left the process; `rest::get` + // sends the path it is given, so the literal braces reached the endpoint, the + // forge answered 404, `open_pull_request` returned `None`, and every + // `land fast-forward` — the lap's commit point included — stopped with "no + // open pull request for ". A retirement that moves a spawn in-process + // inherits the caller's substitutions or it inherits nothing, and this is the + // one site in the family that did not carry the read across. + let repo = repo_or_placeholder(root); + let pr = match fast_forward::look_up_pull_request(&repo, branch) { + fast_forward::Lookup::Found(pr) => pr, + fast_forward::Lookup::None => { + writeln!( + err, + "::error:: land fast-forward: no open pull request for {branch}, so there is nothing to ask" + )?; + return Ok(ExitCode::Internal); + } + fast_forward::Lookup::Unreadable(status) => { + writeln!( + err, + "::error:: land fast-forward: could not read {repo}'s pull requests ({status}), so whether {branch} has one is unknown — this is the environment, not the branch" + )?; + return Ok(ExitCode::Internal); + } + }; + let ask = fast_forward::Ask { repo, pr, workflow }; + + // STAMPED BEFORE THE COMMENT, never after, and that ordering is the whole of + // the anti-livelock property: a run created by an EARLIER lap of this same + // pull request must fall outside the window. Stamping afterwards would leave + // a gap in which this lap's own run is created and then excluded by its own + // fence — a lap that can never read its own answer. + // + // `receipt::rfc3339_utc` rather than a second formatter: it is already the + // crate's one epoch-to-ISO-8601 spelling and its tests pin the instants a + // hand-rolled one gets wrong (leap years, the 2100 non-leap century). + let since = receipt::rfc3339_utc(now_unix()); + + match fast_forward::ask(&ask)? { + fast_forward::Asked::Refused(status) => { + // NEVER ENTER THE POLL. Waiting for the answer to a question nobody + // received is a hang with a different cause, and the predecessor's + // was measured: the forge answered a secondary rate limit, nothing + // read the status, and the lap reported a comment it had not created. + writeln!( + err, + "::error:: land fast-forward: the forge did not create the comment (status {status}); nothing was asked, so there is no answer to wait for" + )?; + Ok(ExitCode::Internal) + } + fast_forward::Asked::Commented(comment) => { + writeln!( + out, + "land: asked #{} to fast-forward as comment {comment}", + ask.pr + )?; + // **POLLED, BECAUSE THE BOT HAS NOT STARTED YET** (review of #848). + // This read `answer` exactly once, immediately after `ask` — and the + // workflow takes ~23s just to create the run, so the first read was + // always `Pending`, which maps to `Internal`, which `land::progress` + // maps to `Lap` for `FastForward`. Every lap therefore unwound (the + // Abandon compensation cancelling this head's own green runs), + // replayed, re-verified, re-readied, re-pushed, re-waited, and posted + // a SECOND `/fast-forward` comment while the first was possibly + // merging — then exited `3` when the lap budget ran out. + // + // The header two screens up already said `3` is "no answer yet, which + // is the state the loop exists to sit in". No loop sat in it. This is + // that sentence made true. + // + // A COUNT, never a deadline, matching `run_land_wait`'s own bound and + // for its reason: the cost of too many asks is conditional requests + // the forge answers cheaply, and the cost of too few is a lap that + // reports no answer while one was moments away. Exhausting it still + // reports `Pending`, so the lap's own budget stays the outer bound. + // `LAND_ANSWER_ASKS`, not `LAND_ANSWER_MAX_UNKNOWNS`. The latter + // bounds the CI wait with a default three orders of magnitude larger, + // and in the predecessor it meant a third thing again — so one name + // over both loops is a setting that cannot be tuned for either + // (review of #848). + let asks = std::env::var("LAND_ANSWER_ASKS") + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .unwrap_or(120); + let mut verdict = fast_forward::Answer::Pending; + // `pause_until` slices its wait so a RACING arm can cut it short. + // Nothing races this one — the lap is serial here — so the flag is + // permanently false and the slicing is the only thing borrowed. + let answer_poll_stop = std::sync::atomic::AtomicBool::new(false); + for attempt in 0..asks.max(1) { + verdict = fast_forward::answer(&ask, &since, &comment); + // **A COULD-NOT-LOOK IS NOT AN ANSWER, and breaking on it undid + // the poll.** `Answer::Unknown` covers a transport failure, an + // unparseable body and any non-runs document — a 403 error page + // included — so ONE transient hiccup during the wait ended it, + // which laps: the compensation cancels this head's own in-flight + // runs, the lap re-verifies, re-readies (buying another matrix), + // re-pushes and posts a SECOND `/fast-forward` comment while the + // first may be merging. That is the exact failure this poll was + // added to fix, reintroduced by its own exit condition. + // + // Only a REFUSAL or an ACCEPTANCE ends the wait. Exhausting the + // count reports whatever the last read said, so an unknown that + // never resolves still reaches the caller as one. + if matches!( + verdict, + fast_forward::Answer::Accepted | fast_forward::Answer::Refused + ) { + break; + } + if attempt + 1 < asks.max(1) { + // `pr_watch`'s pause, never a second timer: there is ONE + // sleep in this crate and it carries the one + // `disallowed_methods` escape, which is what `land.rs`'s + // stale arm says at its own site and what `spawn-widening` + // refuses a second copy of. The bound is `LAND_ANSWER_ASKS` + // asks over `fast_forward::answer` — the loop breaks on + // `Accepted` or `Refused`, so this paces a poll rather than + // standing in for an exit condition, and exhausting the + // count reports whatever the last read said (CLOUD-1177). + crate::pr_watch::pause_until( + fast_forward::ANSWER_POLL_SECONDS, + &answer_poll_stop, + ); + } + } + match verdict { + fast_forward::Answer::Accepted => { + writeln!(out, "land: #{} was accepted", ask.pr)?; + Ok(ExitCode::Success) + } + fast_forward::Answer::Refused => { + writeln!( + out, + "land: #{} was refused; this head is no longer a direct descendant", + ask.pr + )?; + Ok(ExitCode::Violation) + } + fast_forward::Answer::Pending => { + writeln!(out, "land: #{} has no answer yet", ask.pr)?; + Ok(ExitCode::Internal) + } + // A CLOSED VOCABULARY REACHES THE READER AS A TOKEN, never as + // prose and never as "main moved" — that is a fact about a ref, + // and only the staleness arm may assert it. + fast_forward::Answer::Unknown(token) => { + writeln!(out, "land: #{} ran and decided nothing ({token})", ask.pr)?; + Ok(ExitCode::Internal) + } + } + } + } +} + +/// `batten land push`: the branch to its own ref, under receive-pack's CAS. +fn run_land_push(root: &Path, url: &str, branch: &str, out: &mut dyn Write) -> Result { + match land::push(root, url, branch)? { + land::Pushed::Landed(head) => { + writeln!(out, "land: {branch} on the remote now reads {head}")?; + Ok(ExitCode::Success) + } + // A LOST CAS IS A VERDICT ABOUT THE REPOSITORY, so `2` rather than a + // failure code: somebody else moved this branch, the lap has an answer, + // and the answer is to lap again. + land::Pushed::Raced => { + writeln!( + out, + "land: {branch} moved under this push; the remote refused and this lap is spent" + )?; + Ok(ExitCode::Violation) + } + } +} + +/// `batten land replay`: advance the base and replay this branch onto it. +fn run_land_replay( + root: &Path, + url: &str, + reference: &str, + branch: &str, + resolve: &[String], + out: &mut dyn Write, +) -> Result { + match land::replay(root, url, reference, branch, resolve)? { + land::Replay::Conflicted { commit, paths } => { + writeln!( + out, "land: replay of {branch} onto {reference} conflicted at {commit} in {} path(s); first is {}", paths.len(), paths.first().map_or("-", String::as_str) )?; Ok(ExitCode::Violation) } - land::Replay::Current => { + land::Replay::Current => { + writeln!( + out, + "land: {branch} already descends from {reference}; nothing replayed" + )?; + Ok(ExitCode::Success) + } + land::Replay::Replayed { head, commits } => { + writeln!( + out, + "land: replayed {commits} commit(s) of {branch} onto {reference}; head is {head}" + )?; + Ok(ExitCode::Success) + } + } +} + +/// `batten land verify` (CLOUD-1338): the lap's gate, run and recorded. +/// +/// # `$LAND_VERIFY` and no default, which is non-negotiable rule 1 as a mechanism +/// +/// The bash lander runs `mise run verify`. That name is THIS consumer's, and a +/// default compiled in here would be a consumer's vocabulary inside +/// `crates/batten` — the rule's plainest violation. So the command is read from +/// the environment and an absent one is a `Usage` refusal rather than a guess. +/// +/// The failure mode a default would buy is worse than the refusal, which is why +/// this is not merely tidy: a lap in a repository whose gate is spelled +/// differently would run something else, get a `0`, and record the head as +/// verified. A refusal costs one line of configuration; a wrong default costs a +/// receipt that is not true. +/// +/// # Whitespace splitting, and its stated bound +/// +/// The value is split on whitespace, so a gate whose argv carries a quoted +/// argument with a space in it cannot be spelled here. That bound is real and is +/// accepted rather than papered over with a shell: handing this to `sh -c` would +/// make the engine compose a shell line out of an environment variable, which is +/// exactly the argv-composition `policy/spawn-adapters.rego` records refusing for +/// `prune`'s deletes. A consumer needing that writes a script and names it. +fn run_land_verify( + root: &Path, + bet: &speculation::Bet, + branch: &str, + reference: Option<&str>, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let declared = std::env::var("LAND_VERIFY").unwrap_or_default(); + let command: Vec = declared.split_whitespace().map(str::to_owned).collect(); + if command.is_empty() { + writeln!( + err, + "::error:: land verify: $LAND_VERIFY names no command, and this engine does not know what verifying means in this repository" + )?; + return Ok(ExitCode::Usage); + } + // THE PUBLICATION IS A FUNCTION OF THE BET, never a side effect kept in step + // with it. `Bet::published` is `None` with no bet outstanding, so the variable + // is simply absent from the gate's environment — the predecessor called a + // `publish_speculation` at every point a bet was placed or cleared precisely + // because the two could disagree. + let published: Vec<(String, String)> = bet + .published() + .map(|base| vec![(speculation::PUBLISHED_AS.to_owned(), base.to_owned())]) + .unwrap_or_default(); + let environment = verify_environment(root); + // RACED AGAINST THE BASE, which is CLOUD-423's other half (CLOUD-1586). + // `land::stale` runs as a PRECHECK on the step after this one and saves the + // metered spend; it cannot save the gate's own minutes, because by the time + // it asks the gate has already finished. Measured on this container: a gate + // is ~25 minutes and ~45% of laps paid one to learn trunk had moved. + // + // FAIL OPEN TO THE UNRACED GATE, never to a refusal. A clone whose slug this + // engine cannot read has no forge to watch, so the honest answer is the gate + // alone — the same reading `base_moved` takes for the same missing fact. + let raced = repo_slug(root).map(|slug| trunk_watch(reference.unwrap_or("main"), "", &slug, 1)); + let verified = match (raced, reference) { + (Some(trunk), Some(reference)) => land::verify_raced( + root, + branch, + &command, + &published, + &environment, + &trunk, + reference, + )?, + _ => land::verify(root, branch, &command, &published, &environment)?, + }; + match verified { + land::Verified::Clean(head) => { + writeln!(out, "land: {head} passed the configured gate")?; + Ok(ExitCode::Success) + } + // A REFUSAL IS A VERDICT ABOUT THE REPOSITORY, so `2`. The gate's own + // output reached the caller's terminal — `land::verify` tees for exactly + // this — so repeating a pointer to it here would be the payload rule's + // exact failure. What this arm adds is not the output but the READING: + // which of three refusals it was, because the advice differs and two of + // the three were previously told the wrong thing. + // MAIN MOVED, WHICH IS THE LOOP WORKING (CLOUD-318). Reported and coded + // apart from every other refusal, because it is not one: the gate is + // saying the run raced trunk, the consumer's task manifest declares that + // `land` reads this as "lap", and the next replay is the whole remedy. `Internal` rather + // than `Violation` is what carries it — `progress`'s table already laps a + // could-not-look from `Verify`'s neighbours for the same reason, and a + // `2` here would land in the cell that stops. + // + // It writes no "refused by the configured gate" line either. That + // sentence is true of a verdict about this tree and false of this, and it + // is what an operator reads before deciding to go looking for a defect. + land::Verified::Refused { + sha, + cause: land::Refusal::Moved, + } => { + writeln!( + out, + "land: main moved under {}, so this lap's gate raced it; replaying onto the new trunk", + short(&sha) + )?; + Ok(ExitCode::Internal) + } + land::Verified::Refused { sha, cause } => { + writeln!(out, "land: {sha} was refused by the configured gate")?; + match cause { + // Handled above: it is not a refusal about anything and never + // reaches this reader. + land::Refusal::Moved => {} + // NOT THIS BRANCH'S DOING, so none of the tree advice applies. + // The remedy is the consumer's own words from the row that + // matched; this engine knows there was a match and nothing about + // what to do, which is what keeps the reclaim's name out of it. + land::Refusal::Environment { remedy } => { + writeln!( + err, + "::error:: land: the gate died of the environment rather than of this tree — {remedy}" + )?; + } + // A SUSPICION, NEVER A VERDICT, and the wording is load-bearing. + // This row retracted two attributions in one day for treating + // "speculative" as the explanation because it was the salient + // difference — so it says how to FIND OUT rather than deciding. + // + // BOTH recoveries, because `rebase --onto` is not the only one + // and the cheaper one is available whenever the remote still + // holds this branch unborrowed. + land::Refusal::Tree => { + if let Some(base) = bet.published() { + writeln!( + err, + "::error:: land: this tree is SPECULATIVE — it carries {} borrowed from {base}, so the failure may not be yours.", + short(base) + )?; + // THE BASE REF IS THE LAP'S AND A HAND-DRIVEN VERIFY HAS + // NONE, so it is `Option` rather than a guess. `batten + // land verify` run alone takes no positional — the lap + // is what knows which trunk this branch is landing onto + // — and naming a default here would print a recovery + // that reaches the wrong ref in any repository whose + // trunk is spelled differently (non-negotiable rule 1). + // The `reset --hard` half needs no base and is offered + // either way. + match reference { + Some(reference) => writeln!( + err, + " Re-run the gate off the borrowed base: git rebase --onto {reference} {base}, or git reset --hard ." + )?, + None => writeln!( + err, + " Re-run the gate off the borrowed base: git rebase --onto {base}, or git reset --hard ." + )?, + } + writeln!( + err, + " If it still fails off the borrowed base, it is yours." + )?; + } else { + writeln!(err, "::error:: land: reproduce and fix locally.")?; + } + } + } + Ok(ExitCode::Violation) + } + } +} + +/// The declared `[[verify_environment_pattern]]` rows, or none. +/// +/// **Could-not-look is an EMPTY table rather than a refusal**, and that is the +/// safe direction here: with no rows every refusal classifies as +/// [`land::Refusal::Tree`], which is the advice that was always given and is +/// right in the common case. A config this cannot read must not turn a refused +/// gate into a second failure on top of it. +/// +/// **ANCHORED AT THE WORKING TREE'S ROOT, never at the caller's directory and +/// never at the repository's** (review of #848, then CLOUD-1586). `root` is the +/// cwd the verb was invoked from, so anchoring there looked for `batten.toml` +/// beside wherever the operator happened to stand — and `authority_site` with no +/// `config_in` is `required: false`, so a miss is an EMPTY TABLE at exit 0 rather +/// than a refusal. Every `[[verify_environment_pattern]]` row then silently did +/// not load and every refusal classified as `Refusal::Tree`, which is +/// CLOUD-861's misattribution restored by the safe direction above. +/// +/// **AND `repo_root` WAS STILL THE WRONG ROOT, WHICH IS THE HALF THE FIRST FIX +/// MISSED.** From a linked worktree `repo_root` resolves the MAIN checkout — the +/// common dir is shared — so the rows that loaded were the main tree's and this +/// branch's were not read at all. `git::worktree_root`'s own header states the +/// rule this call has to obey: *"committed config is the WORKING TREE's, state +/// is the REPOSITORY's."* A `batten.toml` is a file this branch may change, so +/// it is read from here; the receipt store next door stays on `repo_root` +/// because it is shared. The failure mode is identical to the one above and +/// therefore invisible in the same way: wrong tree, no rows, empty table, exit +/// 0, every refusal reported as the branch's own defect. +/// +/// A root that will not resolve falls back to the anchor: this function's whole +/// posture is that a reading it cannot take yields no rows rather than an error. +fn verify_environment(root: &Path) -> Vec { + let anchor = git::worktree_root(root).unwrap_or_else(|_| root.to_path_buf()); + let site = config::authority_site(&anchor, None); + config::load_site(&site) + .ok() + .map(|(loaded, _)| loaded.verify_environment_patterns) + .unwrap_or_default() +} + +/// How many ticks the guard waits to be killed after a cancellation lands. +/// +/// A COUNT, never a deadline, and the exit condition is being killed rather than +/// the clock running out: exiting `0` here would let the job march into the +/// matrix the cancellation just paid an API call to prevent, and exiting non-zero +/// would red the run. So neither, for as long as a cancellation plausibly takes. +const CANCEL_TICKS: u32 = 24; + +/// One tick, in seconds. `pr_watch::pause` carries the crate's single sleep +/// exemption, so waiting through it opens no new site for `sleep_ban.rs`. +const CANCEL_TICK_SECONDS: f64 = 5.0; + +/// Report the guard's decision and, on a stop, act on it. +/// +/// # IT NEVER EXITS NON-ZERO, AND THAT IS THE WHOLE CONTRACT +/// +/// A job that reds before its cancellation lands makes the RUN's conclusion +/// `failure` rather than `cancelled`; `final` then runs under `!cancelled()`, +/// fails its `needs:` assertion, and the lander re-drafts every PR in the fleet +/// — the fleet-wide re-drafting this whole design exists to avoid, reintroduced +/// by its own remedy. So every path here returns [`ExitCode::Success`], +/// including the ones that could not look and the one whose cancellation was +/// refused. +/// +/// # The annotation is at COLUMN 0 and that is load-bearing +/// +/// The runner reads a workflow command only when the line begins with `::` after +/// trimming leading whitespace, so a prefix would emit the token and have it +/// ignored. A stopped run is a CANCELLED run with a red `final` and no failed +/// step of its own — so without the annotation a reader sees a red check, no +/// annotation, and no clue that the remedy is one rebase. +fn report_guard( + guarded: &lease::Guarded, + repo: &str, + run: &str, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let why = match guarded { + lease::Guarded::Run { why } => { + writeln!(out, "lease-precondition: {why}")?; + return Ok(ExitCode::Success); + } + lease::Guarded::Stop { why } => why, + }; + writeln!(err, "::error::lease-precondition: {why}")?; + + if run.trim().is_empty() { + writeln!( + err, + "lease-precondition: nothing to cancel — no run id was given; running anyway" + )?; + return Ok(ExitCode::Success); + } + writeln!( + err, + "::error::lease-precondition: this run is not authorised to spend a matrix; cancelling \ + run {run}" + )?; + if !lease::cancel_run(repo, run) { + writeln!( + err, + "lease-precondition: the cancellation was refused; running anyway" + )?; + return Ok(ExitCode::Success); + } + + // WAIT TO BE KILLED. See `CANCEL_TICKS`. + for _ in 0..CANCEL_TICKS { + pr_watch::pause(CANCEL_TICK_SECONDS); + } + writeln!( + err, + "lease-precondition: still alive after {CANCEL_TICKS} tick(s); running anyway" + )?; + Ok(ExitCode::Success) +} + +/// What the runner's step-0 guard is standing in and asking about. +/// +/// **A struct rather than three operands**, and the reason is the gate this +/// change also ships (CLOUD-1338). The two guard arms carried an +/// `#[expect(clippy::too_many_arguments)]` whose reason argued the arity was +/// necessary — *"three operands the caller must supply because the engine must +/// not GUESS any of them"* — which is true of the operands and says nothing +/// about the signature. They are one subject: the run this guard is standing in, +/// on the head and branch it was started for. Naming it removes the escape +/// rather than justifying it. +#[derive(Debug, Clone, Copy)] +struct Standing<'a> { + /// The head commit being judged. Never guessed: on a `pull_request` event + /// `github.sha` is the merge commit, which is a different tree. + head: &'a str, + /// The branch the lease is asked about. + branch: &'a str, + /// The run to cancel on a stop. Never guessed: cancelling the wrong one + /// stops somebody else's matrix. + run: &'a str, +} + +/// `batten lease guard` (CLOUD-420): the runner's step-0 precondition. +/// +/// # Errors +/// +/// Only for a stream that will not accept output. +fn run_lease_guard( + root: &Path, + overrides: &resolve::Overrides, + terms: &lease::Terms, + asking: &Standing<'_>, + now: i64, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let Standing { head, branch, run } = *asking; + if let Some(code) = fast_forward_lane(root, overrides, branch, out)? { + return Ok(code); + } + let repo = repo_or_placeholder(root); + let carries = lease_staleness(root, overrides, &repo, head); + + // THE LEASE IS ASKED UNLESS STALENESS ALREADY STOPPED, which is the + // predecessor's ordering: one fewer forge read on a head that is doomed + // either way. `Unknown` is NOT such a head — the shell left `stop` unset on + // an unreadable row and entered the lease table anyway, and skipping it here + // let a rate-limited forge wave a rival's live lease through (review of + // #848). `lease::guard`'s header carries the measurement. + let authority = match &carries { + lease::Carries::Stale { .. } => None, + lease::Carries::Current | lease::Carries::Unknown { .. } => { + let observed = lease::observe(terms).ok(); + Some(lease::authorises(observed.as_ref(), branch, now)) + } + }; + let guarded = lease::guard(&carries, authority.as_ref()); + report_guard(&guarded, &repo, run, out, err) +} + +/// The guard on a clone with no lease remote. +/// +/// The staleness half is the FORGE's answer and needs no remote, so it is still +/// asked — reaching the unleased `authorises` arm instead would skip a reading +/// that was available. +/// +/// **AND THE LEASE HALF IS NO LONGER ALWAYS `None`** (review of #848, +/// CLOUD-420). This is not a rare path: it is the ONLY path the verb has in CI, +/// where the workflow runs it as step 0 before any checkout, so `git::remotes` +/// is empty and `lease::terms` answers `NoRemote` every time. Passing `None` +/// there meant the guard's whole reason for existing was switched off on every +/// run — two landers could spend matrices concurrently while the step reported +/// green. +/// +/// `lease::terms_from_environment` is what closes it, and it carries the +/// predecessor's own argument for why a clone was never actually needed. Where +/// the environment names no repository either, the answer is still `None` and +/// the fail-open posture is exactly what it was. +fn run_lease_guard_unleased( + root: &Path, + overrides: &resolve::Overrides, + asking: &Standing<'_>, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let Standing { head, branch, run } = *asking; + if let Some(code) = fast_forward_lane(root, overrides, branch, out)? { + return Ok(code); + } + let repo = repo_or_placeholder(root); + let carries = lease_staleness(root, overrides, &repo, head); + // STALENESS FIRST AND THE LEASE ONLY IF IT DID NOT STOP, which is the same + // ordering the leased sibling takes and the predecessor's own. + let authority = match &carries { + lease::Carries::Stale { .. } => None, + lease::Carries::Current | lease::Carries::Unknown { .. } => { + let now = i64::try_from(now_unix()).unwrap_or(i64::MAX); + lease::terms_from_environment().map(|terms| { + let observed = lease::observe(&terms).ok(); + lease::authorises(observed.as_ref(), branch, now) + }) + } + }; + let guarded = lease::guard(&carries, authority.as_ref()); + report_guard(&guarded, &repo, run, out, err) +} + +/// The carve-out, asked BEFORE either reading and before anything is spent. +/// +/// **This ordering is the predecessor's and it is load-bearing.** The `case` sat +/// above both the staleness read and the lease read, so an exempt branch costs +/// no forge call at all — and, more importantly, cannot be stopped by a reading +/// that has nothing to say about it. Asking afterwards would let a stale-head +/// refusal fire on a branch this gate is not judging. +/// +/// `Some(Success)` means *not judging this branch*; `None` means carry on. +fn fast_forward_lane( + root: &Path, + overrides: &resolve::Overrides, + branch: &str, + out: &mut dyn Write, +) -> Result> { + let prefixes = lease_config(root, overrides) + .map(|lease| lease.fast_forward_branches) + .unwrap_or_default(); + if !lease::lands_by_fast_forward(branch, &prefixes) { + return Ok(None); + } + writeln!( + out, + "lease-precondition: {branch} is fast-forwarded by a lander rather than by a lease holder; not judging it" + )?; + Ok(Some(ExitCode::Success)) +} + +/// The `[lease]` table, read through the §8 authority chain. +/// +/// # THE READERS BELOW WERE HANDING A DIRECTORY TO A FILE READER +/// +/// Every one of them spelled this `config::load(root)`, and [`config::load`] +/// takes a **file** path. `fs::read_to_string(".")` is not `NotFound`, so it +/// became an internal error, which `.ok()` dropped, which `unwrap_or_default()` +/// turned into an empty table — so `[lease] landing_paths` read as *nothing +/// declared* in every checkout it has ever run in, and the staleness half of the +/// runner-side guard has been failing open since it landed. Measured over the +/// compiled binary from this repository's own root, with the rows in +/// `batten.toml` two directories up from the reader. +/// +/// That is the same class the key exists to close, arriving by a different +/// route: its own doc comment records the predecessor dying quietly and every +/// stale head passing. `crates/batten/tests/it/lease_config.rs` is the tier that +/// catches it, because it drives the ENGINE rather than the reader. +/// +/// # AND IT HONOURS `--config-in`, WHICH IS NOT A CONVENIENCE HERE +/// +/// `batten lease guard` is the runner's FIRST step, before any checkout, so the +/// directory it stands in is empty by construction. A reader anchored on the +/// working tree therefore has nothing to read even once the path bug is fixed — +/// the trust half of the same problem, since the only tree a checkout WOULD +/// offer is the pull request's own. [`config::authority_site`] is the boundary +/// that already answers this for every other verb: the caller names a directory +/// holding a trusted `batten.toml`, fetched from trunk exactly as `install.sh` +/// is, and the guard is judged by trunk's policy rather than by the head's. +/// +/// `None` is could-not-look and every caller reads it that way. +fn lease_config(root: &Path, overrides: &resolve::Overrides) -> Option { + // ANCHORED ON THE WORKING TREE'S ROOT, BECAUSE `authority_site` PERFORMS NO + // DIRECTORY WALK BY DESIGN (house-style §8, review of #848). `root` is + // `Path::new(".")` from `run_lease`, so a call from a subdirectory resolved + // `./batten.toml`, found nothing, left `landing_paths` empty, and + // `lease::carries` short-circuited to `Carries::Unknown` — exit 3, whose + // documented CI treatment is "run anyway". The staleness half fails open + // again, by the third route this function's own doc records. + // + // Only where `--config-in` names none: that flag is the runner's answer and + // it must keep winning, which is the paragraph above. + let anchored = if overrides.config_in.is_some() { + root.to_path_buf() + } else { + crate::git::worktree_root(root).unwrap_or_else(|_| root.to_path_buf()) + }; + let site = config::authority_site(&anchored, overrides.config_in.as_deref().map(Path::new)); + config::load_site(&site) + .ok() + .and_then(|(loaded, _)| loaded.lease) +} + +/// The staleness reading, over the declared landing paths. +/// +/// One place both guard arms read it, so they cannot disagree about which paths +/// or which trunk. +fn lease_staleness( + root: &Path, + overrides: &resolve::Overrides, + repo: &str, + head: &str, +) -> lease::Carries { + let paths = lease_config(root, overrides) + .map(|lease| lease.landing_paths) + .unwrap_or_default(); + let trunk = std::env::var("LEASE_TRUNK").unwrap_or_else(|_| String::from("main")); + lease::carries(repo, &trunk, head, &paths) +} + +/// `batten lease carries` (CLOUD-1148 §2): does this head carry the landing +/// mechanism trunk has? +/// +/// # THE EXIT TABLE, AND THE CALLER FAILS OPEN ON `3` +/// +/// `0` the head carries it, `2` it does not — a verdict about this branch, whose +/// remedy is one rebase — and `3` the reading could not be taken. The CI caller +/// treats `3` as run, which is this gate's whole posture and the opposite of +/// every other refusal in this repository: a reading nobody could take would +/// cancel every job in the fleet, where waving one matrix through costs one +/// matrix. +/// +/// # What it replaces, and why the replacement is a path set +/// +/// `ci-lease-precondition.sh:157` grepped the head's own `mise-tasks/land.sh` +/// for `land-lock acquire`. That dies with the retirement and dies QUIETLY: the +/// file goes, `from_ref` fails, the script takes its own fail-open path — "not +/// judging this head's age" — and every stale head passes. `[lease] +/// landing_paths` is a row a retirement edits rather than a literal a retirement +/// invalidates. +/// +/// # Errors +/// +/// Only for a stream that will not accept output. Every failure to reach the +/// forge is a could-not-look reported as `3`. +fn run_lease_carries( + root: &Path, + overrides: &resolve::Overrides, + head: &str, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let paths = lease_config(root, overrides) + .map(|lease| lease.landing_paths) + .unwrap_or_default(); + let repo = repo_or_placeholder(root); + let trunk = std::env::var("LEASE_TRUNK").unwrap_or_else(|_| String::from("main")); + + match lease::carries(&repo, &trunk, head, &paths) { + lease::Carries::Current => { + writeln!( + out, + "lease carries: {head} carries {trunk}'s landing mechanism" + )?; + Ok(ExitCode::Success) + } + // THE REMEDY IS ONE REBASE AND THE REFUSAL SAYS SO. A stopped run is a + // CANCELLED run with no failed step of its own, so a reader who is not + // told sees a red check and no cause. + lease::Carries::Stale { wanted } => { + writeln!( + err, + "::error:: lease carries: {head} does not carry {wanted}, so it cannot be \ + serialised against the fleet. Rebase onto current {trunk} and land with it." + )?; + Ok(ExitCode::Violation) + } + lease::Carries::Unknown { because } => { + writeln!( + err, + "::error:: lease carries: {because}; not judging this head" + )?; + Ok(ExitCode::Internal) + } + } +} + +/// The staleness arm's config, assembled in one place so both readers of it — +/// the raced wait and the between-gate-and-push probe — cannot disagree about +/// which ref, which repository or which interval they are asking about. +/// +/// The branch is the reference's SHORT NAME, because the endpoint path spells a +/// ref that way (`git/ref/heads/main`) where the caller carries a full one. +/// +/// **`land::short_ref`, never a second derivation here** (review of #848). This +/// said it took "the same derivation the tracking ref above takes" while taking +/// `rsplit('/')` — the leaf — after `tracking_ref` had been fixed off it. A +/// consumer whose trunk is `release/1.x` therefore asked the forge for +/// `git/ref/heads/1.x`, got a 404, and BOTH staleness arms went silently dead +/// while the sentence claiming they could not drift stood over the drift. +fn trunk_watch(reference: &str, base: &str, repo: &str, interval: u64) -> main_watch::Config { + main_watch::Config { + repo: repo.to_owned(), + branch: land::short_ref(reference).to_owned(), + base: base.to_owned(), + interval, + } +} + +/// The lap's READY phase: the gates that read the pull request's body. +/// +/// # Both the body source and the gates are the consumer's +/// +/// `LAND_BODY_SOURCE` is the argv that prints the body, and `LAND_BODY_GATES` is +/// a `|`-separated list of gate argvs. Neither is defaulted, for +/// `run_land_fast_forward`'s reason at its own site: a default compiled in here +/// would be a consumer's vocabulary inside `crates/batten`, which is +/// non-negotiable rule 1's plainest violation — and the failure a default buys is +/// the quiet one, a fleet whose gates silently do not run. +/// +/// **An UNDECLARED gate set is a pass, and a DECLARED one that cannot run is +/// not.** The asymmetry is the point: a consumer that names no body gates has +/// none, which is a legitimate configuration; a consumer that names one and +/// cannot run it has a dead gate, which is the class this engine exists to +/// refuse. `run_land_fast_forward` refuses an absent `LAND_WORKFLOW` instead, +/// because a lap with no fast-forward has no way to finish at all — the two +/// differ because one is optional and the other is the step. +/// +/// # It buys the matrix, and the accounting lives here for that reason +/// +/// Readying is what starts CI, so this is the ONE site that increments the +/// ledger's paid count. `land::Ledger`'s own header records why that cannot be +/// inferred from the lap counter instead. +fn run_land_ready( + root: &Path, + branch: &str, + ledger: &mut land::Ledger, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let declared = std::env::var("LAND_BODY_GATES").unwrap_or_default(); + let gates = land::body_gates(&declared); + // AN UNDECLARED GATE SET IS A PASS AND NOT AN EARLY RETURN, which is the + // correction this step needed most: returning here skipped the ready itself, + // so a consumer declaring no body gates got a lap that pushed and then waited + // out its whole ask count on a matrix nobody had started. + if gates.is_empty() { + writeln!(out, "land: no body gates declared; nothing to ask")?; + } else { + // FAIL OPEN ON THE FETCH, and only on the fetch. A body this never saw is + // not evidence about what the author wrote, which is the predecessor's + // posture spelled `[[ -n "$body" ]] &&`. An unreadable source therefore + // yields an empty body and `land::ready` treats that as clear. + let source = std::env::var("LAND_BODY_SOURCE").unwrap_or_default(); + let body = land::body_gates(&source) + .first() + // `Drop`: this string is PARSED as the body, so a client's notice + // on stderr would become text the author never wrote. The gates + // below take `Keep`, because their stderr IS their reason. + .and_then(|argv| exec::piped_argv(root, argv, "", exec::Diagnostics::Drop)) + .filter(|(code, _)| *code == 0) + .map(|(_, body)| body) + .unwrap_or_default(); + + match land::ready(root, &gates, &body) { + land::Readied::Clear => { + writeln!(out, "land: {} body gate(s) clear", gates.len())?; + } + land::Readied::Refused { gate, detail } => { + writeln!( + err, + "::error:: land: {gate} refused this pull request's body" + )?; + if !detail.is_empty() { + writeln!(err, "{detail}")?; + } + return Ok(ExitCode::Violation); + } + // INTERNAL RATHER THAN A VIOLATION, because the subject differs: a + // refusal is about the body and this is about the clone. + // `land::progress` stops on both for this step, so the lap behaves + // identically — what the split buys is a reader who can tell "the + // author must fix this" from "this checkout cannot ask". + land::Readied::Unrunnable { gate } => { + writeln!( + err, + "::error:: land: {gate} is declared in LAND_BODY_GATES and will not run, so \ + its verdict is unknown rather than clean" + )?; + // `Violation` rather than `Internal`, and the lap is UNCHANGED: + // `land::progress` stops on both arms for this step before and + // after. What the code now says is which KIND of stop it is — + // this one is the author's to fix, and `Internal` is reserved for + // a forge read that did not answer, which laps (review of #848). + return Ok(ExitCode::Violation); + } + } + } + + spend_the_matrix(root, branch, ledger, out, err) +} + +/// Fire the ready, which is the event that starts CI. +/// +/// # THIS IS THE STEP, AND THE GATES ABOVE ARE ITS PRECONDITION +/// +/// The step was named `Ready` and ran only the body gates: nothing in this crate +/// performed the ready itself, so `Compensation::Redraft` undid a state the +/// driver never created and the whole `push → wait → fast-forward` tail waited on +/// a matrix nobody had bought. Found reading `tests/land.bats`'s own case titles +/// against the successor — eight of them describe this economy and not one had a +/// call site here. +/// +/// # `Refire` is a draft-then-ready, and it is why this is not one call +/// +/// A pull request already ready cannot be readied again, so the only way to mint +/// a fresh run on one whose head can never grade is to put it back to draft +/// first. [`land::buys_a_matrix`] is where the three states are told apart, and +/// it reads the CHECKS verdict rather than a run count so that a lap arriving +/// while this lap's own run is still in flight leaves it alone — cancelling the +/// matrix it had just bought is the failure that arm exists to refuse. +fn spend_the_matrix( + root: &Path, + branch: &str, + ledger: &mut land::Ledger, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let repo = repo_or_placeholder(root); + let pr = match fast_forward::look_up_pull_request(&repo, branch) { + fast_forward::Lookup::Found(pr) => pr, + // A BRANCH WITH NO PULL REQUEST IS A STATE, NOT A FAILED READ, so it + // stops rather than lapping: no number of laps opens one. + fast_forward::Lookup::None => { + writeln!( + err, + "::error:: land: no open pull request for {branch}, so there is nothing to ready and no run to buy" + )?; + return Ok(ExitCode::Violation); + } + fast_forward::Lookup::Unreadable(status) => { + writeln!( + err, + "::error:: land: could not read {repo}'s pull requests ({status}), so nothing is readied — this is the environment, not the branch" + )?; + return Ok(ExitCode::Internal); + } + }; + let Some(land::Readiness { + draft: is_draft, + node, + head, + }) = land::draft_state(&repo, &pr) + else { + writeln!( + err, + "::error:: land: the pull request's draft state will not read, so whether a ready would buy a run is unknown" + )?; + return Ok(ExitCode::Internal); + }; + + // THE READING THIS LAP ALREADY OWNS. A verdict that cannot be taken is + // `None`, which `buys_a_matrix` answers `Nothing` to — the same posture the + // tap takes one direction over, and for the same reason. + // **THE FORGE'S HEAD, NEVER THIS CLONE'S** (review of #848). `Ready` runs + // before `Push`, so on a lap that replayed, the local head is a sha the forge + // has never seen — and deciding from it mints a run on the pull request's + // superseded head that `Compensation::Abandon` can never cancel, because + // that reads `git::head_commit`. `Readiness::head` carries the reason. + let reading = verdict_for(&repo, &head); + match land::buys_a_matrix(Some(is_draft), reading.as_ref()) { + land::Spend::Nothing => { writeln!( out, - "land: {branch} already descends from {reference}; nothing replayed" + "land: {branch} needs no ready; a run on this head has answered or is on its way" )?; Ok(ExitCode::Success) } - land::Replay::Replayed { head, commits } => { + land::Spend::Ready => fired(land::mark_ready(&node), ledger, out, err), + land::Spend::Refire => { + // DRAFT FIRST, and a draft that will not happen is a stop: readying an + // already-ready pull request is a no-op the forge reports as success, + // so proceeding would report a matrix it never bought. + if !land::redraft(&node) { + writeln!( + err, + "::error:: land: {branch} is ready over a head that can never grade, and it \ + would not go back to draft, so no fresh run can be minted for it" + )?; + return Ok(ExitCode::Internal); + } writeln!( out, - "land: replayed {commits} commit(s) of {branch} onto {reference}; head is {head}" + "land: {branch} carried no answer and no run in flight; re-firing its ready" )?; - Ok(ExitCode::Success) + fired(land::mark_ready(&node), ledger, out, err) } } } -/// `batten land verify` (CLOUD-1338): the lap's gate, run and recorded. +/// One report for both places a ready is fired. /// -/// # `$LAND_VERIFY` and no default, which is non-negotiable rule 1 as a mechanism +/// **A ready that did not fire STOPS the lap**, which is the one place this +/// family does not swallow a forge failure: pushing after it would spend the +/// wait's whole ask count on a matrix nobody started. `tests/land.bats` states +/// it as *"a ready that fails stops before the push rather than pushing into +/// silence"*. +fn fired( + happened: bool, + ledger: &mut land::Ledger, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + if happened { + // THE ONE SITE THAT BUYS A MATRIX, which is the whole reason `paid` is a + // counter rather than an inference off the lap count: a lap that stops + // before this point spends nothing, and `Ledger`'s own header records a + // refusal announcing "having spent 2 CI matrices" over a head that + // carried zero check-runs. + ledger.bought_a_matrix(); + writeln!(out, "land: the pull request is ready; the matrix is bought")?; + return Ok(ExitCode::Success); + } + writeln!( + err, + "::error:: land: the ready did not fire, so no run was started; pushing now would wait out the whole count on a matrix that does not exist" + )?; + // **`Violation`, NOT `Internal`, because a MUTATION THAT DID NOT FIRE IS NOT + // A READ THAT DID NOT ANSWER** (review of #848). `(Step::Ready, Internal)` + // laps — that arm was opened for a transient forge READ — and routing a + // failed `markPullRequestReadyForReview` through it made a permanent failure + // lap instead of stop. A credential without pull-request write scope answers + // a GraphQL `errors` array on every attempt, so the driver would re-run the + // replay and the whole `verify` gate once per lap, spend `LAND_MAX_LAPS`, + // and exit `3` on the same unanswerable state. + // + // `land::mark_ready`'s own doc states the contract this restores: a failed + // ready "is NOT swallowed by its caller … stops before the push". + Ok(ExitCode::Violation) +} + +/// Run `$LAND_ENTRY_GATES` over this landing's pull request, once. /// -/// The bash lander runs `mise run verify`. That name is THIS consumer's, and a -/// default compiled in here would be a consumer's vocabulary inside -/// `crates/batten` — the rule's plainest violation. So the command is read from -/// the environment and an absent one is a `Usage` refusal rather than a guess. +/// `Some(code)` stops the landing; `None` lets it proceed. /// -/// The failure mode a default would buy is worse than the refusal, which is why -/// this is not merely tidy: a lap in a repository whose gate is spelled -/// differently would run something else, get a `0`, and record the head as -/// verified. A refusal costs one line of configuration; a wrong default costs a -/// receipt that is not true. +/// # The consumer names the gate and the engine supplies the pull request /// -/// # Whitespace splitting, and its stated bound +/// `LAND_ENTRY_GATES` is a `|`-separated list of argvs, each run with the pull +/// request number appended. Undeclared is a pass and declared-but-unrunnable is a +/// refusal, which is [`run_land_ready`]'s asymmetry and is stated there. /// -/// The value is split on whitespace, so a gate whose argv carries a quoted -/// argument with a space in it cannot be spelled here. That bound is real and is -/// accepted rather than papered over with a shell: handing this to `sh -c` would -/// make the engine compose a shell line out of an environment variable, which is -/// exactly the argv-composition `policy/spawn-adapters.rego` records refusing for -/// `prune`'s deletes. A consumer needing that writes a script and names it. -fn run_land_verify( +/// # A pull request that will not resolve is a REFUSAL rather than a pass +/// +/// Every other read in this family answers could-not-look and carries on, because +/// spending nothing is the safe direction for a question about CI. Here it is the +/// other way round: a gate declared over a pull request nobody could name has not +/// run, and letting the lap proceed is precisely the dead-gate reading. The lap +/// cannot finish without a pull request anyway — `run_land_fast_forward` stops on +/// the same absence — so this refuses at the cheap end instead of after a matrix. +fn run_land_entry_gates( root: &Path, branch: &str, out: &mut dyn Write, err: &mut dyn Write, -) -> Result { - let declared = std::env::var("LAND_VERIFY").unwrap_or_default(); - let command: Vec = declared.split_whitespace().map(str::to_owned).collect(); - if command.is_empty() { - writeln!( - err, - "::error:: land verify: $LAND_VERIFY names no command, and this engine does not know what verifying means in this repository" - )?; - return Ok(ExitCode::Usage); +) -> Result> { + let declared = std::env::var("LAND_ENTRY_GATES").unwrap_or_default(); + let gates = land::body_gates(&declared); + if gates.is_empty() { + return Ok(None); } - match land::verify(root, branch, &command)? { - land::Verified::Clean(head) => { - writeln!(out, "land: {head} passed the configured gate")?; - Ok(ExitCode::Success) + let repo = repo_or_placeholder(root); + let pr = match fast_forward::look_up_pull_request(&repo, branch) { + fast_forward::Lookup::Found(pr) => pr, + fast_forward::Lookup::None => { + writeln!( + err, + "::error:: land: {} entry gate(s) are declared and no open pull request for {branch} will resolve, so they cannot be asked", + gates.len() + )?; + return Ok(Some(ExitCode::Internal)); } - // A REFUSAL IS A VERDICT ABOUT THE REPOSITORY, so `2`. The gate's own - // output already went to the caller's terminal; repeating a pointer to - // it here would be the payload rule's exact failure. - land::Verified::Refused(head) => { - writeln!(out, "land: {head} was refused by the configured gate")?; - Ok(ExitCode::Violation) + fast_forward::Lookup::Unreadable(status) => { + writeln!( + err, + "::error:: land: {} entry gate(s) are declared and {repo}'s pull requests could not be read ({status}), so they cannot be asked — this is the environment, not the branch", + gates.len() + )?; + return Ok(Some(ExitCode::Internal)); + } + }; + match land::admits_the_landing(root, &gates, &pr) { + land::Admitted::Clear => { + writeln!(out, "land: {} entry gate(s) clear", gates.len())?; + Ok(None) + } + land::Admitted::Refused { gate, detail } => { + // THE GATE'S OWN WORDS REACH THE OPERATOR (CLOUD-407). The + // predecessor's `die` added only the landing's context, because the + // gate is the authority on its own remedy and a summary here would be + // a second, staler copy of it. + writeln!(err, "::error:: land: {gate} refused this landing")?; + if !detail.is_empty() { + writeln!(err, "{detail}")?; + } + writeln!( + err, + "::error:: nothing has been spent — the refusal above says how to clear it, then run the landing again" + )?; + Ok(Some(ExitCode::Violation)) + } + land::Admitted::Unrunnable { gate } => { + writeln!( + err, + "::error:: land: the declared entry gate {gate} will not run, so this landing's precondition is unasked rather than met" + )?; + Ok(Some(ExitCode::Internal)) } } } +/// Hand the landing lease back, best-effort, on the path that landed. +/// +/// **`Compensation::ReleaseLease` cannot reach this path**: it runs from +/// `unwind_lap`, and a landing returns before any unwind. That left the one +/// outcome where the lease is definitively finished with as the one outcome that +/// never released it, so every waiter behind a successful landing paid the full +/// TTL (review of #848). +/// +/// Silent in every failure, which is [`retire_the_branch`]'s posture and its +/// reason: the landing already succeeded, and a cleanup step reported as failure +/// makes a good outcome look broken. A lease this clone does not hold is not an +/// error either — `lease_hand_back` already decides that. +fn hand_back_the_lease(root: &Path, branch: &str, out: &mut dyn Write) { + let Ok(terms) = lease::terms(root) else { + return; + }; + let Ok((_, holder)) = lease_identity(root) else { + return; + }; + let now = i64::try_from(now_unix()).unwrap_or(i64::MAX); + lease_hand_back(root, &terms, &holder, now); + let _ = writeln!(out, "land: handed the landing lease back after {branch}"); +} + +/// Retire the branch a lap has just landed, reporting what went. +/// +/// Every failure is silent in the exit code and visible in the line, which is +/// [`land::retire_branch`]'s posture and its reason: the landing already +/// succeeded, so reporting cleanup as failure would make it look broken. +fn retire_the_branch(root: &Path, url: &str, branch: &str, out: &mut dyn Write) -> Result<()> { + let retired = land::retire_branch(root, url, branch); + // COUNTS AND BOOLEANS. `Retired` has nowhere to put a finding's text, which + // is non-negotiable rule 4 held in the TYPE rather than in this call site. + writeln!( + out, + "land: retired {branch} — remote:{} tracking:{} receipts:{}", + retired.remote, retired.tracking, retired.receipts + )?; + Ok(()) +} + +/// What the required checks say about a sha the CALLER names, or `None`. +/// +/// Every way this fails is a could-not-look — a roster that can decide nothing, +/// a reading the forge would not give — and each answers `None` rather than a +/// verdict, because [`land::buys_a_matrix`] spends nothing on one and that is +/// the safe direction here. +/// +/// **THE SHA IS AN ARGUMENT, and reading this clone's HEAD instead was a defect +/// rather than a shorthand** (review of #848). `Step::Ready` runs BEFORE +/// `Step::Push`, so on every lap that replayed, the local head is a sha the +/// forge has never seen: the ready then fired against the pull request's +/// superseded head, minted a run on it, charged it to the ledger, and left it +/// uncancellable, because `Compensation::Abandon` reads `git::head_commit` and +/// that is the other sha. The ready asks about `Readiness::head`; a wait asks +/// about the head it just pushed. One reading for both is what conflated them. +fn verdict_for(repo: &str, sha: &str) -> Option { + let sha = sha.to_owned(); + let roster = checks_green::Roster { + required: roster_field(std::env::var("CI_REQUIRED_CHECKS").ok().as_deref()), + absent_ok: roster_field(std::env::var("CI_ABSENT_OK_CHECKS").ok().as_deref()), + // THE SAME DEFAULT `run_land_wait` USES, and the asymmetry was a silent + // disable (review of #848). `checks_green::decide` REFUSES an empty + // `answered` set, and this reads the verdict through `.ok()`, so a + // consumer who set a required roster and left the conclusions to the + // default got `None` here — could-not-look — which `buys_a_matrix` reads + // as "needs no ready". The step that buys CI then did nothing, `Push` + // updated a branch nobody had readied, and every later line said "no + // answer yet". + answered: roster_field(Some( + &std::env::var("CI_ANSWERED_CONCLUSIONS") + .unwrap_or_else(|_| String::from("success,failure,timed_out,action_required")), + )), + fanin: std::env::var("CI_FANIN_CHECK") + .ok() + .filter(|name| !name.is_empty()), + }; + let config = pr_watch::Config { + sha, + repo: repo.to_owned(), + interval: 1, + progress: None, + }; + let mut poll = pr_watch::Poll::default(); + let raw = pr_watch::read(&config, None)?; + // THE STATUS IS READ, AND A NON-`200` IS A COULD-NOT-LOOK RATHER THAN AN + // EMPTY HEAD (PR #848's review). `rest::get` answers `Some(Answer)` for a + // 401, a 403 or a 5xx as readily as for a reading — the transport worked, the + // forge declined — and the body is then not the array `runs_from_body` + // parses, so `absorb` leaves `runs` EMPTY. Downstream that is + // indistinguishable from a head no workflow has registered for: + // `checks_green::decide` answers `Pending::Unregistered`, `land::buys_a_matrix` + // reads that as `Refire`, and one forge blip re-drafts and re-readies the pull + // request — cancelling the very matrix this arm is documented to protect. + // + // `rest::Answer::is_reading` rather than a comparison written here, because a + // second spelling of *which statuses are answers* is a second authority over + // it. Every other failure in this function already answers `None` because + // `buys_a_matrix` spends nothing on one, and this is that direction. + if !raw.is_reading() { + return None; + } + // The interval it returns is for a LOOP to honour, and this is one read. + let _pace = poll.absorb(Some(&raw), config.interval); + checks_green::decide(poll.runs(), &roster).ok() +} + /// `batten land wait` (CLOUD-1338): the lap's raced wait, and the record it /// leaves for a module to decide over. /// @@ -6638,20 +9426,19 @@ fn run_land_verify( /// nothing to tell them apart. fn run_land_wait( root: &Path, - remote: &str, reference: &str, branch: &str, out: &mut dyn Write, err: &mut dyn Write, -) -> Result { +) -> Result<(ExitCode, Option)> { let Ok(sha) = git::head_commit(root) else { writeln!(err, "::error:: land: cannot read this clone's HEAD")?; - return Ok(ExitCode::Internal); + return Ok((ExitCode::Internal, None)); }; let required = std::env::var("CI_REQUIRED_CHECKS").unwrap_or_default(); let roster = checks_green::Roster { required: roster_field(Some(&required)), - absent_ok: roster_field(std::env::var("CI_ABSENT_OK").ok().as_deref()), + absent_ok: roster_field(std::env::var("CI_ABSENT_OK_CHECKS").ok().as_deref()), answered: roster_field(Some( &std::env::var("CI_ANSWERED_CONCLUSIONS") .unwrap_or_else(|_| String::from("success,failure,timed_out,action_required")), @@ -6665,28 +9452,53 @@ fn run_land_wait( // would be a hang whose cause is a typo. if let Err(problem) = checks_green::decide(&[], &roster) { writeln!(err, "::error:: land wait: {problem}")?; - return Ok(ExitCode::Usage); + return Ok((ExitCode::Usage, None)); } // The base as this clone last saw it. The wait is asking whether the REMOTE // has moved past it, so the comparison needs the local reading rather than a // freshly fetched one — a base refreshed first would compare a value to // itself and never report stale. - let tracking = format!( - "refs/remotes/origin/{}", - reference.rsplit('/').next().unwrap_or(reference) - ); - let base = git::resolve_ref(root, &tracking) - .ok() - .flatten() - .unwrap_or_default(); + // `land::tracking_ref`, not a second spelling of it — and the second + // spelling here carried the same last-segment defect it did (review of + // #848): a trunk named `release/1.x` resolved to `origin/1.x`, so this + // settled a bet against an unrelated branch's tracking ref. + let tracking = land::tracking_ref(reference); + // NO BASE IS A REFUSAL, NOT A SILENT BLOCK, and this is `main-watch.bats`'s + // last case conserved. `Poll::moved` reads an empty base as *nothing to + // compare against* and answers `None` for every reading — correct there, and + // a wait entered with one has silently lost its staleness arm: it would poll + // out its whole ask count on a base that moved under it on the first second. + // Could-not-look about this CLONE, so `Internal`, and `land::progress` laps + // on it — the next lap re-fetches, which is the one thing that fixes it. + let Some(base) = git::resolve_ref(root, &tracking).ok().flatten() else { + writeln!( + err, + "::error:: land wait: {tracking} will not resolve, so this wait has no base to judge staleness against and would race with one arm" + )?; + return Ok((ExitCode::Internal, None)); + }; let config = pr_watch::Config { sha: sha.clone(), - repo: std::env::var("GH_REPO").unwrap_or_else(|_| pr_watch::REPO_PLACEHOLDER.to_owned()), + repo: repo_or_placeholder(root), interval: 1, progress: None, }; + // AND THE REPOSITORY, before the loop, for the reason + // `pr_watch::Config::names_a_repository` gives. The lap's own poll is the + // other unbounded loop over this config, and it inherits the same shape: an + // unresolved slug leaves the placeholder in the path, the forge answers 404, + // `pr_watch::read` reports that as could-not-look because it cannot tell it + // from a dropped connection, and the wait spends its whole ask count on a + // question that was never going to be answered (review of #848). + if !config.names_a_repository() { + writeln!( + err, + "::error:: land wait: no repository resolved, so every check-run read would 404 — set $GH_REPO, or run this in a clone whose remote names one" + )?; + return Ok((ExitCode::Usage, None)); + } // A COUNT, never a deadline (CLOUD-1177). The default is generous because // the cost of too many asks is a few conditional requests the forge answers // `304`, and the cost of too few is a lap that reports no answer while one @@ -6697,7 +9509,9 @@ fn run_land_wait( .filter(|asks| *asks > 0) .unwrap_or(3600); - let waited = land::wait(&config, &roster, remote, reference, &base, asks, out)?; + let trunk = trunk_watch(reference, &base, &config.repo, config.interval); + let holding = Heartbeat::for_clone(root); + let waited = land::wait(&config, &roster, &trunk, asks, &|| holding.beat(), out)?; let (answers, code) = match &waited { land::Waited::Green { verdict } => ( land::answers(&sha, Some(verdict.as_str()), None), @@ -6707,6 +9521,12 @@ fn run_land_wait( land::answers(&sha, None, Some(base.as_str())), ExitCode::Violation, ), + // A VERDICT ABOUT THIS REPOSITORY, so `2` — the same code the stale arm + // carries, because both are. What tells them apart is the READING, which + // travels back beside this code and which `land::progress_of` is the one + // table over. Inventing a fifth code for red would be the per-verb + // exception non-negotiable rule 5 forbids. + land::Waited::Red { .. } => (land::answers(&sha, Some("red"), None), ExitCode::Violation), land::Waited::Unanswered => (land::answers(&sha, None, None), ExitCode::Internal), }; land::record_wait(root, branch, &answers)?; @@ -6715,6 +9535,34 @@ fn run_land_wait( land::Waited::Green { .. } => { writeln!(out, "land: {sha} is green; the loser was voided unread")?; } + land::Waited::Red { findings } => { + // POINTERS, and `Finding` has nowhere to put a log line. The count + // leads because it is what a reader acts on; the names follow. + writeln!( + err, + "::error:: land: {} required check(s) failed on {sha}", + findings.len() + )?; + for finding in findings { + writeln!(err, " {finding}")?; + } + // THE RE-RUN ECONOMY, and it is what `failed_runs`/`rerun_failed` + // were built for and never reached. `tests/land.bats` splits it in + // two: *"a run that died before any mise step is re-run, not + // reported red"* against *"a job that reached a verdict is red, and + // is never re-run"*. This engine cannot yet tell those apart — that + // needs the job-level reading, which is CLOUD-483's own row — so it + // takes the SAFE half: report the red, re-run nothing, and let the + // author look. Re-running a genuine failure would spend a matrix to + // learn what one already answered. + // + // Stated here rather than left silent, because the two functions + // exist and a reader finding them uncalled deserves the reason. + writeln!( + out, + "land: reproduce this locally; a rebase clears nothing here, so the lap stops" + )?; + } land::Waited::Stale { base } => { writeln!( out, @@ -6725,49 +9573,64 @@ fn run_land_wait( writeln!(out, "land: no answer yet on {sha} after {asks} ask(s)")?; } } - Ok(code) + // THE READING TRAVELS WITH THE CODE, because the exit table cannot carry it: + // a stale base and an unanswered wait are both a lap, and only one of them + // took a checks reading at all. Deriving the tap's verdict from the code in + // the driver would be a second authority over an answer this function holds. + Ok((code, land::tap_verdict(&waited))) } -fn lease_terms(root: &Path) -> std::result::Result { - let name = std::env::var("LAND_LOCK_REMOTE").unwrap_or_else(|_| String::from("origin")); - let remotes = git::remotes(root) - .map_err(|err| TermsMissing::Unreadable(format!("cannot read this repository: {err}")))?; - let url = remotes - .iter() - .find(|(configured, _)| *configured == name) - .map(|(_, url)| url.clone()) - .ok_or(TermsMissing::NoRemote)?; - let mut terms = lease::Terms { - remote: url, - ..lease::Terms::default() - }; - // Overridable so a suite can drive the bounds without waiting out a real TTL. - // Each falls back to the shipped default rather than to zero: a TTL of zero - // is a lease that has already lapsed, which would report as a fleet with no - // lease at all rather than as a misconfiguration. - if let Some(ttl) = env_secs("LAND_LOCK_TTL") { - terms.ttl = ttl; - } - if let Some(beat) = env_secs("LAND_LOCK_HEARTBEAT") { - terms.beat = beat; - } - if let Ok(reference) = std::env::var("LAND_LOCK_BRANCH") { - terms.reference = format!("refs/heads/{reference}"); - } - Ok(terms) +/// The lap's landing-lease heartbeat, paced by the terms it read once. +/// +/// # THE LAP HELD A 120-SECOND LEASE THROUGH A TWENTY-MINUTE WAIT +/// +/// `Step::Lease` acquires once and nothing renewed it (review of #848), so the +/// lease lapsed inside `Step::Wait`, a rival took it and bought a matrix +/// concurrently, and this lap's own later jobs then failed their step-0 guard +/// against the new holder and were cancelled mid-landing. The predecessor +/// backgrounded a heartbeat process; [`lease::beat`] carries the rest of the +/// reasoning. +/// +/// # THE PACE IS THE LEASE'S, NOT THE POLL'S +/// +/// The wait polls about once a second and a CAS per second would be a rate limit +/// of our own making, so a beat is taken only once `terms.beat` has elapsed. +/// `last` is an `AtomicI64` because the wait calls this from inside a scoped +/// thread, so it must be `Sync` without being `mut`. +/// +/// Terms that will not resolve leave this inert, which is the same fail-open the +/// rest of the lease surface takes: a clone that cannot read its lease never +/// acquired one to renew. +struct Heartbeat<'clone> { + root: &'clone Path, + terms: Option, + last: std::sync::atomic::AtomicI64, } -/// A positive whole number of seconds from the environment, or `None`. -/// -/// **Zero and negative are `None`**, not values: every bound here is a duration, -/// and a zero TTL or beat would turn a lease into a spin rather than into a -/// tighter test. -fn env_secs(name: &str) -> Option { - std::env::var(name) - .ok()? - .parse::() - .ok() - .filter(|seconds| *seconds > 0) +impl<'clone> Heartbeat<'clone> { + fn for_clone(root: &'clone Path) -> Self { + Self { + root, + terms: lease::terms(root).ok(), + last: std::sync::atomic::AtomicI64::new(0), + } + } + + /// Renew if a beat has elapsed. Silent and best-effort in every arm — see + /// [`lease::beat`] for why one failed beat is not a lost lease. + fn beat(&self) { + let Some(terms) = self.terms.as_ref() else { + return; + }; + let now = i64::try_from(now_unix()).unwrap_or(i64::MAX); + if now.saturating_sub(self.last.load(std::sync::atomic::Ordering::Relaxed)) < terms.beat { + return; + } + self.last.store(now, std::sync::atomic::Ordering::Relaxed); + // Bound rather than dropped: `beat` answers a `bool`, and dropping a + // `Copy` is a lint of its own. + let _renewed = lease::beat(self.root, terms, now); + } } /// How many beats a holder may stop progressing before its lease is disbelieved. @@ -6783,7 +9646,7 @@ fn env_secs(name: &str) -> Option { /// PRs when it was set. Deliberately generous: this exists to catch NEVER, not /// slow, and the cost of catching slow is a landing killed for being healthy. fn lease_stall_beats() -> i64 { - env_secs("LAND_LOCK_STALL_BEATS").unwrap_or(60) + lease::env_secs("LAND_LOCK_STALL_BEATS").unwrap_or(60) } /// `lease check`: the lease ref is free, or a live and well-formed hold. @@ -6804,11 +9667,31 @@ fn run_lease_check( return Ok(ExitCode::Internal); } }; - match lease::health(&observed, terms, now) { + report_health(&lease::health(&observed, terms, now), out, err) +} + +/// Write a health reading and answer with its exit code. +/// +/// **SPLIT FROM THE READING SO THE MAPPING IS REACHABLE** (CLOUD-1148). Every +/// state the retired `mise-tasks/land-lock-check.sh` reported needs a lease on a +/// remote to observe, and `lease::observe` has no offline fixture seam — so with +/// the reading and the mapping in one function, four of the six arms could be +/// exercised only against a live remote, which is a test of the network. +/// +/// A `Wedged` mapped to `Success` is the silent failure this is the sensor on: a +/// wedged lease would be reported in prose and pass its own gate. +fn report_health( + health: &lease::Health, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + match health { lease::Health::Free(why) | lease::Health::Held(why) => { writeln!(out, "lease: {why}")?; Ok(ExitCode::Success) } + // A VERDICT ABOUT THIS REPOSITORY, so `2` — never the `1` the predecessor + // spelled it. One table, no per-verb exception (non-negotiable rule 5). lease::Health::Wedged(why) => { writeln!( err, @@ -6823,8 +9706,36 @@ fn run_lease_check( } } -/// `lease status`, which is prose for a human and `-J` for everything else. +/// `lease status`, which is prose for a human, `-J` for everything else, and a +/// verdict for a `[[recorder]]` column. +/// +/// # The exit code is a THIRD channel, and dropping it was the port's defect +/// +/// `mise-tasks/land-lock.sh status` answered `0` for a lease that authorises +/// this clone — unheld, released, expired, or held by this clone — and `1` for +/// one held by somebody else. That is what `[program.land-lock-status]` records +/// and what the `landing-loop` preset's `held-elsewhere` token is a mapping of. +/// The first port rendered the document and returned `Success` on every +/// answering path, so a lease held by a rival recorded as `authorised` and the +/// preset allowed the overlapping spend it exists to refuse — a dead gate with a +/// green suite, because no case drove the producer. +/// +/// The codes are the engine's table rather than the predecessor's: a lease held +/// elsewhere is [`ExitCode::Violation`], not `1`. The consumer's `status` map is +/// where the two are reconciled, once, in config a reader can look up. +/// +/// **Reporting is unchanged in both other channels.** The document still emits +/// on every path including this one, for the reason the could-not-look arm below +/// already states: the exit code carries the verdict and the document carries the +/// answer, and a data channel that goes silent hands its reader a decode error. +/// +/// A clone whose own holder id will not read answers [`ExitCode::Internal`] +/// rather than guessing. The recorder leaves that code unmapped, the column +/// records `-`, and the preset allows — which is the lease's whole asymmetry: +/// waving one matrix through costs one matrix, and stopping the fleet over a +/// question about THIS clone costs every branch in it. fn run_lease_status( + root: &Path, terms: &lease::Terms, json: bool, now: i64, @@ -6856,7 +9767,28 @@ fn run_lease_status( // Reported as what it is rather than as a hold. Every DECISION still // treats it as held; this is the one place the two can be told apart, // which is the whole reason it is a state and not a default body. - lease::Observed::Garbage { .. } => return lease_report(json, "garbage", &[], out), + // + // **AND IT IS NOT A SUCCESS** (review of #848). `lease_report` answers + // `Ok(ExitCode::Success)` for every state it renders, so this arm exited + // `0` over a lease nobody could parse — the third channel this verb + // carries its verdict in, saying "authorised" about a ref that has told + // us nothing. `authorises_this_clone` reads `Garbage` as fail-CLOSED and + // its own doc says why: a ref that is there and will not parse has not + // shown this clone owns anything. The exit code has to agree with the + // predicate, or the two channels contradict each other on the one state + // where it matters. + // + // `Internal`, not `Violation`: this is a could-not-look about the ref, + // never a verdict about the branch — the same class the unreadable-terms + // arm above already takes. + lease::Observed::Garbage { .. } => { + lease_report(json, "garbage", &[], out)?; + writeln!( + err, + "::error:: lease: the lease ref is there and will not parse, so whether anyone holds it is unknown" + )?; + return Ok(ExitCode::Internal); + } }; // Checked BEFORE expiry, because a tombstone satisfies both: its expiry is the // sentinel, so `now >= 0` is trivially true and the expired arm would render a @@ -6905,7 +9837,31 @@ fn run_lease_status( fields.push(("stalled", stalled.to_string())); } } - lease_report(json, "held", &fields, out) + // THE ONE ARM THAT CARRIES A VERDICT, and the identity read happens HERE + // rather than at the top of the verb on purpose: every arm above is + // authorising regardless of who this clone is, so resolving an identity to + // answer them would make a clone with no readable holder id fail to report a + // free lease. + // + // `lease::authorises_this_clone` rather than a comparison written here, for + // the reason its own header gives: the `lease-status` recorder column asks + // the identical question, and two spellings of it are how the shell and the + // engine came to disagree about one lease. + let holder = match lease_identity(root) { + Ok((_, holder)) => holder, + Err(reason) => { + writeln!(err, "::error:: lease: {reason}")?; + lease_report(json, "held", &fields, out)?; + return Ok(ExitCode::Internal); + } + }; + let mine = lease::authorises_this_clone(&observed, &holder, now); + lease_report(json, "held", &fields, out)?; + if mine { + Ok(ExitCode::Success) + } else { + Ok(ExitCode::Violation) + } } /// One status line, in either channel, from one set of fields. @@ -7114,18 +10070,42 @@ fn run_lease_acquire( Ok(ExitCode::Success) } lease::Turn::Wait => { - let lease::Observed::Held { body, .. } = &observed else { - // Unreachable: `Absent` is always a `Take`. Reported rather than - // unwrapped, because a `Wait` over an absent lease would mean the - // decision table had changed underneath this arm. - writeln!( - err, - "::error:: lease: no lease is held, yet the turn was not taken" - )?; - return Ok(ExitCode::Internal); - }; - writeln!(out, "lease: held by {}", body.holder)?; - Ok(ExitCode::Violation) + match &observed { + lease::Observed::Held { body, .. } => { + writeln!(out, "lease: held by {}", body.holder)?; + Ok(ExitCode::Violation) + } + // **A REF THAT IS NOT A LEASE IS A WAIT, NOT AN INTERNAL ERROR** + // (review of #848). `turn` maps `Garbage` to `Wait` deliberately + // — a body nothing can parse stays held to every decision — but + // this arm assumed `Wait` implied `Held`, printed a message + // asserting an unreachable state, and returned `Internal`, which + // `land::progress` maps to `Stop`. So one stray commit pushed to + // the lease ref stopped EVERY lander in the fleet instead of + // making them wait it out, and told each operator the wrong cause. + // + // `Violation` is the same code the held case answers with, + // because it is the same answer to the caller's question: not + // this clone's turn. What differs is the line, which names the + // real state so somebody can go and delete the ref. + lease::Observed::Garbage { .. } => { + writeln!( + out, + "lease: the lease ref carries something that is not a lease, so nothing may take it until that is cleared" + )?; + Ok(ExitCode::Violation) + } + // Genuinely unreachable — `Absent` is always a `Take` — and + // reported rather than unwrapped, because reaching it would mean + // the decision table changed underneath this arm. + lease::Observed::Absent => { + writeln!( + err, + "::error:: lease: no lease is held, yet the turn was not taken" + )?; + Ok(ExitCode::Internal) + } + } } lease::Turn::Take(why) => { let body = lease::claim(terms, &holder, branch, &head, now); @@ -7267,7 +10247,7 @@ fn run_lease_hold( progress, terms, lease_stall_beats(), - env_secs("LAND_LOCK_HANG_BEATS").unwrap_or(3), + lease::env_secs("LAND_LOCK_HANG_BEATS").unwrap_or(3), now, ) { // RELEASE FIRST, SIGNAL SECOND. The release is the half that frees the @@ -7383,6 +10363,38 @@ fn lease_bail_reason(git_dir: &Path, why: &str) { } } +/// Tell a consumer's reclaim census that THIS process chose to stop. +/// +/// **The distinction the census draws, and the one thing that keeps it honest** +/// (CLOUD-451, conserving `land.sh`'s `drop_lease`). A heartbeat records a beat +/// per interval and a stop-note only where IT decides to stop — but the +/// commonest stop is not one of those: a lap that finishes normally KILLS the +/// heartbeat, so the loop never runs another statement and its last record stays +/// a beat. Left that way, every successful landing afterwards reads as *the +/// container died under active work* — the false positive that would wrongly +/// license the mechanism CLOUD-515 removed for want of evidence. +/// +/// **Written HERE and never from the heartbeat's own exit path** (CLOUD-491): an +/// exit path runs on the container kill too, and a note from one would erase the +/// only distinction the census draws. This is the release recording that it +/// chose to stop its own child. +/// +/// **The argv is the CONSUMER'S, read from `$LEASE_STOP_NOTE`.** A census +/// program's name inside `crates/batten` is non-negotiable rule 1's plainest +/// violation, and this is the same shape `LAND_BODY_GATES` already takes: the +/// engine spawns what the consumer declared, through the sanctioned boundary, +/// and a consumer that declares nothing gets nothing spawned. +/// +/// Silent and best-effort in every direction. A census note that could not be +/// written is not a reason to fail a release that already succeeded. +fn note_release(root: &Path) { + let declared = std::env::var("LEASE_STOP_NOTE").unwrap_or_default(); + let Some(argv) = land::body_gates(&declared).into_iter().next() else { + return; + }; + let _ = exec::piped_argv(root, &argv, "", exec::Diagnostics::Keep); +} + /// `lease release`: a tombstone, never a delete. /// /// Releasing a lease this clone does not hold is NOT an error: the trap that calls @@ -7429,6 +10441,7 @@ fn run_lease_release( // that this clone no longer holds it, and leaving one would let the // offline reader honour a lease its holder had already handed on. lease_receipt_clear(root, &body.branch); + note_release(root); writeln!(out, "lease: released")?; Ok(ExitCode::Success) } @@ -7559,6 +10572,20 @@ fn lease_receipt_path(root: &Path, branch: &str) -> Option { ) } +/// `batten mutate`: does each declared gate have a mutation its declared suite +/// is proven to catch (CLOUD-418, CLOUD-1267)? +/// +/// **The report is the deliverable and the exit code is the verdict**, and the +/// two say different things on purpose. Every finding reaches stdout as a +/// pointer — gate, mutation id, case — because the workflow that runs this cats +/// the file into a step summary, and a run that fails without publishing what it +/// found sends the reader back to re-run a sweep that costs the better part of +/// an hour. The `::error::` summary on stderr carries the count and nothing else. +/// +/// Exit follows the one table: `2` where the sweep decided against the tree, `3` +/// where it could not look, and the split is the acceptance rather than a +/// nicety — a gate whose declared suite cannot be resolved or run must never be +/// reported as "every mutation caught". fn run_mutate( command: cli::MutateCommand, out: &mut dyn Write, @@ -11078,7 +14105,29 @@ fn filed_here_pointers( verdicts: &config.verdicts, recorders: &config.recorders, }; - let scan = rules::run_static(&selected, &config.provisions, vocabulary, root).ok()?; + // `run_static_over` WITH AN INSTANT, because the four-argument wrapper hands + // `now: None` to `minted_facts`, which reads it as epoch 0 — so every receipt + // looks ancient, every `[[rule.minted]]` `max_age` bound refuses, and this + // path disagrees with `check` over the same tree (review of #848). The + // wrapper's `None` is the honest answer for a caller that has no clock; this + // one is a boundary and does. Latent here only because no `[[rule.minted]]` + // row is declared today. + let scan = rules::run_static_over( + &selected, + &config.provisions, + vocabulary, + root, + rules::RunOptions { + checks: policy::ModuleChecks::Run, + scope: &rules::Scope::Tree, + // A TREE walk, so the read surface — the same answer the sibling + // site upstream gives, and for its reason: this is what `check` + // does rather than the mediated boundary. + surface: facts::Surface::Check, + now: Some(now_unix()), + }, + ) + .ok()?; let flagged: std::collections::BTreeSet = scan .findings .iter() @@ -11245,6 +14294,11 @@ fn write_records(overrides: &Overrides, envelope: &hook::Envelope) { grammar: grammar.as_ref(), root, branch: Some(&branch), + // Read HERE, at the boundary, for the reason every other clock read in + // this crate is: `evaluate` stays a pure function of its inputs, and one + // instant per invocation is what makes two columns grading the same + // lease agree with each other. + now: i64::try_from(now_unix()).unwrap_or(i64::MAX), }; crate::recorder::append_all( recorders, @@ -15231,6 +18285,51 @@ mod tests { ); } + /// **THE EXIT TABLE OVER EVERY `Health` ARM, AND THE PREDECESSOR'S NUMBERS + /// WERE DIFFERENT** (CLOUD-1148, retiring `mise-tasks/land-lock-check.sh`). + /// + /// That program answered `0` healthy, `1` wedged-or-garbage, `2` could not + /// look. The engine has one table with no per-verb exception, so a verdict + /// about this repository is `2` and a could-not-look is `3` — the two + /// non-zero answers with their numbers swapped. A port that carried the old + /// numbers over would report a wedged lease with the code the table reserves + /// for a reading nobody could take. + /// + /// Exhaustive over the four arms rather than over the two that refuse: a + /// `Wedged` mapped to `Success` is silent, and the healthy arms are what a + /// wrong mapping would have to hide behind. + #[test] + fn every_health_reading_maps_to_the_one_exit_table() { + let table = [ + (lease::Health::Free(String::from("free")), ExitCode::Success), + (lease::Health::Held(String::from("held")), ExitCode::Success), + ( + lease::Health::Wedged(String::from("wedged")), + ExitCode::Violation, + ), + ( + lease::Health::Garbage(String::from("garbage")), + ExitCode::Violation, + ), + ]; + for (health, expected) in table { + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = report_health(&health, &mut out, &mut err).expect("report the reading"); + assert_eq!(code, expected, "{health:?} maps to the wrong code"); + // AND ONTO THE RIGHT CHANNEL. A refusal on stdout is invisible to a + // CI annotation reader, and a healthy reading on stderr reads as a + // problem in every log that colours it — so the code alone is only + // half of what a caller acts on. + let said = String::from_utf8_lossy(if expected == ExitCode::Success { + &out + } else { + &err + }); + assert!(!said.is_empty(), "{health:?} said nothing on its channel"); + } + } + /// The whole decision as a table, because the branch that matters cannot be /// reached over a spawned process: this sandbox gives a test no TTY, so /// `machine` is always true there and the attended arm would never run. The diff --git a/crates/batten/src/main_watch.rs b/crates/batten/src/main_watch.rs new file mode 100644 index 000000000..6d2842b21 --- /dev/null +++ b/crates/batten/src/main_watch.rs @@ -0,0 +1,380 @@ +//! The staleness half of a landing lap's wait: has `main` moved past the base +//! this branch was replayed onto? +//! +//! # Why this is a conditional forge read and not a ref advertisement +//! +//! The two questions a lap races are "is this SHA green" ([`crate::pr_watch`]) +//! and "is this SHA still landable" (here). The moment `main` advances, the +//! branch stops being a direct descendant, the run in flight cannot be used, the +//! fast-forward bot will refuse, and every remaining second of that run is +//! billed. Waiting it out to be told what is already knowable is the expensive +//! way to learn nothing. +//! +//! **An earlier revision of `land::wait` answered this arm with +//! [`crate::lease::advertise`] instead, and the reasoning was wrong.** It ran: +//! `main-watch` polls conditionally only because it goes through the forge's +//! metered REST tier, a ref advertisement is neither metered nor a spawn, +//! therefore the conditional poll is a regression. Three things are wrong with +//! that. +//! +//! *Conditionality is not about the meter, it is about the PACE.* The whole +//! reason `ci-wait` and `main-watch` can poll every second is that an unchanged +//! reading answers `304` with no body — `.claude/rules/toolchain.md` states it +//! plainly: "an unconditional poll had to stay slow to stay affordable, so the +//! news arrived late". A ref advertisement has no `304`. It re-sends the whole +//! ref list every ask, so it must either stay slow — which is the late-news +//! failure — or be fast and wasteful. +//! +//! *A git advertisement carries no server-directed backoff.* `X-Poll-Interval` +//! is the endpoint saying how often it is willing to be asked. Ref discovery +//! offers nothing to honour, so the arm cannot be a good citizen even in +//! principle. +//! +//! *And the `ETag` conditionality of the green arm is not shared with this one.* +//! That was the load-bearing error: `pr_watch`'s conditional read is over +//! check-runs, and a staleness arm answered by ref discovery inherits none of it. +//! "Both properties already exist on the green arm" was a claim about a different +//! arm. +//! +//! # The endpoint +//! +//! `git/ref/heads/main` rather than `commits/main`, because the body is a single +//! ref object: the smallest response that answers the question. The predecessor +//! (`mise-tasks/main-watch.sh`) chose it for the same reason and says so. +//! +//! # What this module does NOT do +//! +//! It never writes, and it holds no loop. The loop is the lap's — see +//! [`crate::land::wait`] — because a module with its own unbounded loop cannot be +//! raced without becoming a second authority over when to stop asking, which is +//! the mistake `pr_watch::read` was made public to avoid (CLOUD-1338). + +use crate::pr_watch::wait_for; + +/// What the staleness poll needs. +#[derive(Debug, Clone)] +pub struct Config { + /// The repository, in whatever spelling the client resolves. Defaults to + /// [`crate::pr_watch::REPO_PLACEHOLDER`]. + pub repo: String, + /// The trunk ref's short name, as the endpoint path spells it. + pub branch: String, + /// The sha the branch was replayed onto — what "moved" is measured against. + pub base: String, + /// Seconds between polls, a FLOOR the server may raise and nothing may lower. + pub interval: u64, +} + +/// The sha a ref-object body names. +/// +/// A body that will not parse yields `None` rather than an error, which is the +/// predecessor's posture: an unreadable reading is "no answer yet", so the lap +/// asks again instead of concluding from a response it never understood. +#[must_use] +pub fn head_from_body(body: &str) -> Option { + let document = serde_json::from_str::(body).ok()?; + let sha = document.get("object")?.get("sha")?.as_str()?; + (!sha.is_empty()).then(|| sha.to_owned()) +} + +/// One conditional read of the trunk ref. +/// +/// **IN PROCESS, over [`crate::rest`].** This was a `gh` spawn, annotated +/// `#[expect(clippy::disallowed_types)]` with the reason *"this crate carries no +/// HTTP client that resolves a forge credential"* — which was false when it was +/// written: `fetch.rs` is a vendored hyper client and `lease.rs` was already +/// reading `GH_TOKEN` through it. The spawn bought nothing and cost three +/// things: a child process per poll, a `spawn-adapters` placement to admit it, +/// and a hand-rolled response parser to undo the framing. +/// +/// `None` is could-not-look, never an error: every failure to reach the forge is +/// a reading the caller's own poll must survive. A lap that concluded "main +/// moved" from an unreachable forge would decide about the network rather than +/// about the work, at a cost of one CI run each time. +#[must_use] +pub fn read(config: &Config, etag: Option<&str>) -> Option { + crate::rest::get( + &format!("repos/{}/git/ref/heads/{}", config.repo, config.branch), + etag, + ) +} + +/// The state one staleness poll carries into the next. +#[derive(Debug, Default)] +pub struct Poll { + /// The validator to send with the next request. + etag: Option, + /// The last sha a body named. A `304` leaves this alone. + head: Option, + /// How many requests this poll has made. + polls: u64, + /// The last backoff the SERVER asked for, held across a poll that could not + /// look. + /// + /// `crate::pr_watch::Poll`'s field carries the measurement and the reason; + /// this is the other arm of the same race and takes the same reading. A + /// `200` retires the window, anything else may extend it, and a response + /// that could not be taken at all learned nothing that would retire it. + backoff: Option, +} + +impl Poll { + /// Fold one answer in, returning how long to wait before the next request. + /// + /// A `304` is the server saying the ref is byte-identical to the last + /// reading, so there is nothing to compare and the body is not parsed. + /// + /// **`None` is a poll that could not look**, and it is folded rather than + /// skipped: the count still advances, the previous reading still stands, and + /// the caller waits its configured interval. Dropping it would let an + /// unreachable forge make a bounded loop unbounded. + pub fn absorb(&mut self, answer: Option<&crate::rest::Answer>, configured: u64) -> f64 { + self.polls += 1; + let Some(answer) = answer else { + // The server's last word stands — `crate::pr_watch::Poll::backoff` + // carries the measurement, and these two are the arms of one race. + return wait_for(configured, None, self.backoff); + }; + // AN ETAG SURVIVES A RESPONSE THAT CARRIES NONE, which is what keeps a + // single unvalidated answer from turning every later request + // unconditional. + if let Some(etag) = &answer.etag { + self.etag = Some(etag.clone()); + } + // ONLY A READING REPLACES THE READING, and here the consequence is worse + // than the sibling's. `status != 304` parsed an ERROR document as a ref + // advertisement, so a `403` set `head` to `None` — and `moved()` reads + // `None` as *still landable*, which is the fail-open direction. The lap + // then pushes onto a base the forge already moved past, buys a matrix on + // a head the fast-forward will refuse, and does it again next lap. + // `crate::pr_watch::Poll::absorb` carries the same guard for the same + // reason; the two are the arms of one race (review of #848). + if answer.is_reading() { + self.head = head_from_body(&answer.body); + } + // A NORMAL answer retires the window, and `304` is one — + // `crate::pr_watch::Poll::absorb` carries the measurement, and reading + // `is_reading()` here would wedge this poll the same way. + self.backoff = if answer.answered() { + None + } else { + answer.backoff.or(self.backoff) + }; + // The cadence is clamped and the backoff is not — `wait_for` carries the + // reason, and it is the same reading the sibling arm takes. + // + // `self.backoff` rather than this answer's: an answer carrying none does + // not retire a window an earlier one opened, and + // `crate::pr_watch::Poll::absorb` states the measurement. + wait_for(configured, answer.poll_floor, self.backoff) + } + + /// The validator for the next request. + #[must_use] + pub fn etag(&self) -> Option<&str> { + self.etag.as_deref() + } + + /// The sha this poll currently holds, where it has read one. + #[must_use] + pub fn head(&self) -> Option<&str> { + self.head.as_deref() + } + + /// How many requests have been made. + #[must_use] + pub const fn polls(&self) -> u64 { + self.polls + } + + /// Where the trunk has moved to, or `None` for "still landable". + /// + /// **A READING THAT IS NOT AN ANSWER IS NOT MOVEMENT.** No body read yet, an + /// unparseable one, and a reading equal to the base all report `None` — the + /// three of them deliberately indistinguishable to the lap, because each + /// means the same thing to it: keep going. Reporting a could-not-look as + /// movement would cost a whole CI run per unreachable forge. + /// + /// **AND AN EMPTY BASE IS NOT A BASE**, which is the arm the first port of + /// this dropped. `mise-tasks/main-watch.bats` refuses one outright — *"no + /// base to compare against is a refusal, not a silent block"* — because an + /// empty base compares unequal to every sha, so the first poll reports + /// movement and the lap laps forever. Answering `None` here is the same + /// refusal in the shape this type has: there is nothing to have moved FROM, + /// so nothing has moved. + #[must_use] + pub fn moved(&self, base: &str) -> Option<&str> { + if base.is_empty() { + return None; + } + let head = self.head.as_deref()?; + (head != base).then_some(head) + } +} + +#[cfg(test)] +// Panicking on a failed assertion is how a test fails loudly; these are the +// module's own cases, not a reachable path. +#[allow(clippy::expect_used)] +mod tests { + use super::*; + + const BASE: &str = "1111111111111111111111111111111111111111"; + const MOVED: &str = "2222222222222222222222222222222222222222"; + + fn ref_body(sha: &str) -> crate::rest::Answer { + crate::rest::Answer { + status: 200, + etag: Some(String::from("W/\"a\"")), + poll_floor: None, + backoff: None, + body: format!("{{\"object\":{{\"sha\":\"{sha}\"}}}}"), + } + } + + fn answer(status: u16, floor: Option, body: &str) -> crate::rest::Answer { + crate::rest::Answer { + status, + etag: Some(String::from("W/\"a\"")), + poll_floor: floor, + backoff: None, + body: body.to_owned(), + } + } + + /// **The discriminating pair: an unmoved trunk is not movement and a moved + /// one is.** + #[test] + fn a_trunk_at_the_base_is_not_movement_and_one_past_it_is() { + let mut poll = Poll::default(); + poll.absorb(Some(&ref_body(BASE)), 1); + assert_eq!(poll.moved(BASE), None, "the base is where it was"); + + let mut poll = Poll::default(); + poll.absorb(Some(&ref_body(MOVED)), 1); + assert_eq!( + poll.moved(BASE), + Some(MOVED), + "a different sha is the branch losing its descent" + ); + } + + /// **THE POLL IS CONDITIONAL FROM THE SECOND ASK**, which is what makes a + /// one-second interval affordable at all: an unchanged ref answers `304` + /// with no body and costs no rate limit. + /// + /// Asserted over the VALIDATOR the poll carries rather than over a rendered + /// request. This case used to build `gh` argv through a `request` function, + /// and once `read` became `crate::rest::get` that argv reached no endpoint — + /// a test pinning the shape of a call nothing makes, which is the dead gate + /// this repository exists to refuse. The function is retired with it. + #[test] + fn the_second_ask_carries_the_etag_the_first_was_given() { + let mut poll = Poll::default(); + assert_eq!( + poll.etag(), + None, + "nothing to validate against before the first answer" + ); + poll.absorb(Some(&ref_body(BASE)), 1); + assert_eq!(poll.etag(), Some("W/\"a\"")); + } + + /// A `304` leaves the reading alone, so an unchanged trunk costs no parse and + /// reports no movement. + #[test] + fn a_not_modified_response_leaves_the_previous_reading_standing() { + let mut poll = Poll::default(); + poll.absorb(Some(&ref_body(BASE)), 1); + poll.absorb(Some(&answer(304, None, "")), 1); + + assert_eq!(poll.head(), Some(BASE), "the body was not re-read"); + assert_eq!(poll.moved(BASE), None); + assert_eq!(poll.polls(), 2, "the loop turned twice"); + } + + /// **CLOUD-390, and it is why the floor is an `f64` at the boundary.** + /// + /// The predecessor compared with `-gt`, which is integer-only, so a + /// fractional `X-Poll-Interval` read as "the server asked for no floor". The + /// first Rust port reproduced it exactly: `poll_floor` was `Option` and + /// `"0.5".parse()` yields `None`, byte-identical to an absent header. It is + /// [`crate::rest::Answer`]'s field now, parsed once where the header is read + /// rather than at each caller. + #[test] + fn a_fractional_server_floor_is_honoured_rather_than_silently_dropped() { + let mut poll = Poll::default(); + let waited = poll.absorb( + Some(&answer(200, Some(2.5), "{\"object\":{\"sha\":\"a\"}}")), + 1, + ); + assert!( + (waited - 2.5).abs() < f64::EPSILON, + "the endpoint asked for 2.5s and is entitled to it, got {waited}" + ); + + // ANTI-VACUITY: the floor only ever raises. A server asking to be polled + // FASTER than configured does not get to turn this into a spin. + let mut poll = Poll::default(); + let waited = poll.absorb( + Some(&answer(200, Some(0.1), "{\"object\":{\"sha\":\"a\"}}")), + 5, + ); + assert!( + (waited - 5.0).abs() < f64::EPSILON, + "a floor is a floor, never an absolute: {waited}" + ); + } + + /// Every failure to look reports "not moved", because the lap must survive it. + /// + /// **`None` is the shape a request that never completed now takes**, and it + /// heads the list deliberately: with the read in process it is the only way a + /// transport failure reaches here, where the spawn used to deliver it as + /// empty bytes indistinguishable from a body. + #[test] + fn an_unreadable_answer_is_never_read_as_movement() { + let unreadable: [Option; 5] = [ + None, + Some(answer(500, None, "")), + Some(answer(200, None, "not json at all")), + Some(answer(200, None, "{\"object\":{}}")), + Some(answer(200, None, "{\"object\":{\"sha\":\"\"}}")), + ]; + for raw in &unreadable { + let mut poll = Poll::default(); + poll.absorb(raw.as_ref(), 1); + assert_eq!( + poll.moved(BASE), + None, + "a could-not-look would cost a whole CI run if read as movement: {raw:?}" + ); + } + } + + /// **AN EMPTY BASE IS NOT A BASE, and the first port of this dropped the + /// arm.** + /// + /// `mise-tasks/main-watch.bats` refuses one outright: *"no base to compare + /// against is a refusal, not a silent block"*, because an empty base + /// compares unequal to every sha — so the first poll reports movement, the + /// lap abandons a run it never needed to, and it does that every lap + /// forever. Found by reading the dying suite's titles for the retirement + /// ledger rather than by any test here, which is the argument for reading + /// them. + #[test] + fn an_empty_base_is_never_reported_as_movement() { + let mut poll = Poll::default(); + poll.absorb(Some(&ref_body(MOVED)), 1); + assert_eq!( + poll.moved(""), + None, + "there is nothing to have moved FROM, so nothing has moved" + ); + assert_eq!( + poll.moved(BASE), + Some(MOVED), + "and a real base still reports movement, so the guard is not a mute" + ); + } +} diff --git a/crates/batten/src/outputs.rs b/crates/batten/src/outputs.rs index f79c42981..2b28d2af7 100644 --- a/crates/batten/src/outputs.rs +++ b/crates/batten/src/outputs.rs @@ -162,34 +162,72 @@ impl Hit { } } +/// Refuse a malformed `[[exec_pattern]]` table at load, so a typo cannot sit inert. +/// +/// # Errors +/// +/// As [`validate_named`]. +pub fn validate(patterns: &[OutputPattern]) -> anyhow::Result<()> { + validate_named(patterns, "exec_pattern") +} + +/// Refuse a malformed `[[verify_environment_pattern]]` table at load. +/// +/// # A SECOND ENTRY POINT OVER ONE IMPLEMENTATION, and both reasons are real +/// +/// The checks are identical — same type, same four ways to be malformed — so the +/// body is [`validate_named`] and this adds none of its own. What it adds is a +/// NAME, twice over. +/// +/// It names the table in the refusal. `validate` spelled `exec_pattern` into +/// every message, so the second table's malformed row would have reported a row +/// in a table the author had not touched — a pointer to the wrong file, which is +/// worse than no pointer. +/// +/// And it names the table to `config.rs`'s census, which resolves each declared +/// table to its validator BY THE CALL STRING. Two tables sharing +/// `outputs::validate(` are one call site to that gate: it finds the first, +/// reads the class wrapping it, and reports the second as unwrapped. A distinct +/// entry point is what makes the census able to see both. +/// +/// # Errors +/// +/// As [`validate_named`]. +pub fn validate_environment(patterns: &[OutputPattern]) -> anyhow::Result<()> { + validate_named(patterns, "verify_environment_pattern") +} + /// Refuse a malformed pattern table at load, so a typo cannot sit inert. /// +/// `table` is the caller's own table name, carried into every message so a +/// refusal points at the file and row an author has to edit. +/// /// # Errors /// /// Returns a [`UsageError`] for an empty id, pattern or reason, or a duplicate id. -pub fn validate(patterns: &[OutputPattern]) -> anyhow::Result<()> { +pub fn validate_named(patterns: &[OutputPattern], table: &str) -> anyhow::Result<()> { let mut seen: Vec<&str> = Vec::with_capacity(patterns.len()); for pattern in patterns { if pattern.id.trim().is_empty() { - return Err(UsageError::raise( - "exec_pattern: id must not be empty — it is what a match is reported as", - )); + return Err(UsageError::raise(format!( + "{table}: id must not be empty — it is what a match is reported as" + ))); } if pattern.pattern.is_empty() { return Err(UsageError::raise(format!( - "exec_pattern {}: pattern must not be empty — an empty literal matches every run", + "{table} {}: pattern must not be empty — an empty literal matches every run", pattern.id ))); } if pattern.reason.trim().is_empty() { return Err(UsageError::raise(format!( - "exec_pattern {}: reason is required — a promoted exit code is all the caller gets back", + "{table} {}: reason is required — a promoted exit code is all the caller gets back", pattern.id ))); } if seen.contains(&pattern.id.as_str()) { return Err(UsageError::raise(format!( - "exec_pattern {}: declared twice", + "{table} {}: declared twice", pattern.id ))); } diff --git a/crates/batten/src/pipeline.rs b/crates/batten/src/pipeline.rs new file mode 100644 index 000000000..ce74963ce --- /dev/null +++ b/crates/batten/src/pipeline.rs @@ -0,0 +1,817 @@ +//! The landing pipeline as a DECLARED list, with a compensation per step +//! (CLOUD-1338, PR #848's review). +//! +//! # What this replaces, and why a list rather than an array literal +//! +//! The driver was an array literal of [`crate::land::Step`] with a compile-time +//! step→function `match`. A consumer could not add, remove, reorder or +//! re-implement a step, nor supply a fast-forward for a forge without this +//! repository's bot — so the successor still described *"consumer-specific +//! landing policy a consumer inherits and cannot tailor"*, which is the sentence the +//! whole retirement exists to falsify. A shell script can at least be forked +//! where a `match` arm needs a release. +//! +//! # Compensation is per STEP, which is what forced the list +//! +//! Readying is undone by re-drafting; a held lease by a tombstone; a speculative +//! bet by an abandon. `Progress` is one global table, so there was nowhere to say +//! *what unwinds*. Giving each step its own undo IS a declared list — the two +//! changes are one change, which is why they land together. +//! +//! The shortage was already visible before anyone asked for this: a +//! `Progress::Proceed if step == Step::Verify` staleness probe sat in the driver +//! sixteen lines below a comment promising that policy *"cannot land in four +//! `if`s out of five"*. `(step, code) → progress` had no per-step room, so the +//! first thing that needed it leaked into the loop. [`StepRow::precheck`] is +//! where it goes instead. +//! +//! # A COMPENSATION IS A DURABLE EXTERNAL WRITE, NEVER AN IN-PROCESS UNWIND +//! +//! This is the part that had to be settled before the code existed, because the +//! obvious implementation is wrong here. A saga-style compensation stack unwound +//! in the same process does not run when the container is killed — +//! `mise-tasks/land.sh:353` records exactly that: *"a trap runs on the container +//! kill too."* The compensations that survive are the ones landing OUTSIDE the +//! process: a pull request re-drafted on the forge, a lease tombstone, a +//! cancelled run. +//! +//! The lease's own `expires` is the same idea already done right — a +//! compensation that needs no live process to perform it, because the passage of +//! time performs it. Every arm of [`Compensation`] is held to that standard, and +//! [`Compensation::is_durable`] is where a future arm gets asked. +//! +//! # The invariant the schema carries +//! +//! **An effectful step positioned before the commit point must declare a +//! compensation**, and [`crate::land::Step::FastForward`] is the commit point — +//! irreversible by definition, which is exactly why everything before it needs +//! one. [`Pipeline::validate`] refuses a composition that spends and then +//! abandons, so it fails to LOAD rather than failing in production. +//! +//! That is raise-only in the same spirit as the deny-only Rego surface: a +//! consumer may compose any pipeline, but not one that leaks spend. + +use crate::land::Step; + +/// A step's declared undo, run when a lap leaves without landing. +/// +/// **Every arm names a write that outlives this process.** An arm that named an +/// in-process rollback would be unable to run in the one case compensation is +/// for — see this module's header, and `land.sh:353`'s measured note that a trap +/// runs on the container kill too. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Compensation { + /// Nothing this step did outlives the process, so there is nothing to undo. + /// + /// **Declared rather than defaulted.** A step reaching this arm has said so; + /// a step that simply omitted the field would be indistinguishable from one + /// nobody thought about, which is the whole failure [`Pipeline::validate`] + /// exists to refuse. + Nothing, + /// Put the pull request back to draft. + /// + /// CI skips drafts, so this is what stops the NEXT push — from any source — + /// spending another runner on a failure nobody has fixed yet. + Redraft, + /// Cancel the runs still spending on this head, sparing the fan-in's. + Abandon, + /// Hand the landing lease back, so the next branch does not wait out a TTL. + ReleaseLease, +} + +impl Compensation { + /// Whether this undo lands outside the process that performs it. + /// + /// **The question a new arm must answer**, and the reason it is a method + /// rather than a comment: an arm added later gets asked by the compiler + /// rather than by a reviewer who remembers this file's header. + #[must_use] + pub const fn is_durable(self) -> bool { + // EVERY VARIANT NAMED, never a wildcard, and that is the mechanism this + // method is: `Compensation` is `#[non_exhaustive]`, so an arm added later + // fails to compile HERE and its author is asked the question by the + // compiler rather than by a reviewer who remembers this file's header. + // + // `Nothing` answers yes vacuously — there is no effect, so nothing could + // fail to survive — and the other three are writes to somebody else's + // server or ref. One arm rather than two because clippy is right that the + // bodies are identical today; the discrimination this buys is over the + // arm nobody has written yet, which is exactly what + // `Invalid::NotDurable` is the slot for. + match self { + Self::Nothing | Self::Redraft | Self::Abandon | Self::ReleaseLease => true, + } + } + + /// Whether this step actually undoes something. + #[must_use] + pub const fn undoes_something(self) -> bool { + !matches!(self, Self::Nothing) + } + + /// Whether this undo is owed as soon as its step is ATTEMPTED, rather than + /// once the step has succeeded. + /// + /// # A step's SUCCESS is not always what creates the effect + /// + /// The driver records an effectful row as entered on [`ExitCode::Success`] + /// alone, and for `Ready` that is exactly right: a refused ready bought no + /// matrix, so re-drafting over it would draft a pull request the lap never + /// made ready. Applied to the step that WAITS, the same rule inverts the arm + /// it exists for (PR #848's review). A wait comes back `Success` only when it + /// is GREEN; red, stale and unanswered are the three outcomes where runs are + /// still billing against a head nothing will land — and those were the three + /// that recorded nothing. [`Self::Abandon`] therefore ran only after a green + /// wait whose fast-forward then lapped, which is CLOUD-900's *"runs on a + /// superseded head keep spending"* backwards: it cancelled a green head's + /// runs and never a red one's. + /// + /// # Why it hangs off the COMPENSATION rather than off the step + /// + /// A `step == Wait` arm in the driver is the `step == Verify` exception this + /// module exists to have removed, reintroduced one field later. And the + /// discrimination is not really the step's: what makes this undo attempt-owed + /// is that it cancels runs the steps BEFORE it bought, so it is owed from the + /// moment those runs can exist. A consumer who hangs `Abandon` off a + /// different step inherits the same reading without declaring anything. + /// + /// A `match` rather than a comparison, for [`Self::is_durable`]'s reason: an + /// arm nobody has written yet is a compile error rather than a default. + #[must_use] + pub const fn owed_on_attempt(self) -> bool { + match self { + Self::Abandon => true, + Self::Nothing | Self::Redraft | Self::ReleaseLease => false, + } + } +} + +/// One step of a declared pipeline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct StepRow { + /// Which primitive this row dispatches. + pub step: Step, + /// Whether reaching this step leaves something behind that a later stop + /// would have to undo. + /// + /// **Not derivable from the step's name**, which is why it is declared: the + /// same primitive is effectful for a consumer whose gate posts a comment and + /// free for one whose gate only reads. + pub effectful: bool, + /// What undoes this step, where a lap leaves without landing. + pub compensate: Compensation, + /// Whether this row asks a question of its own before dispatching. + /// + /// This is where the driver's `step == Verify` exception goes: a per-step + /// slot rather than an `if` in the loop. + pub precheck: Option, +} + +impl StepRow { + /// Whether this row owes its undo, given how its primitive finished. + /// + /// **THE DRIVER'S RULE, HERE RATHER THAN IN THE LOOP**, for the reason + /// [`StepRow::precheck`] exists: a `step == Wait` arm in `run_land_lap` is + /// the `step == Verify` exception this module was written to remove, + /// reintroduced one conjunction later. It is also what makes the rule + /// testable without driving a whole lap against a forge. + /// + /// `succeeded` is the primitive's own answer, which is right for `Ready` and + /// wrong on its own for `Wait` — see [`Compensation::owed_on_attempt`], which + /// carries the measurement. + #[must_use] + pub const fn entered(&self, succeeded: bool) -> bool { + self.effectful && (succeeded || self.compensate.owed_on_attempt()) + } +} + +/// A question a row asks before its primitive runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Precheck { + /// Has the base moved while the previous step ran? + /// + /// **The last free moment**, which is the whole reason this is a precheck + /// rather than a step: everything after `verify` is metered, so a base that + /// moved while the gate ran makes the push a matrix spent to learn what one + /// ref read already knows. Fails open. + BaseMoved, + /// Is an outstanding speculation still worth carrying? + /// + /// **AT THE TOP OF THE LAP, BEFORE ANYTHING CAN PUSH**, which is the property + /// `mise-tasks/land.sh` states in as many words: *"there is no path from a + /// losing bet to a push, which is what makes speculating safe rather than + /// merely fast."* A lost bet leaves this branch carrying another branch's + /// commits, and the lap's own replay runs immediately after — so the unwind + /// has nothing to re-linearize by hand. + /// + /// A precheck rather than a `Step` because it spends nothing and cannot land: + /// it reads refs and the lease, and either keeps the tree or rewinds it. + BetSettled, +} + +/// A declared landing pipeline. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pipeline { + /// The steps, in the order a lap walks them. + pub steps: Vec, +} + +/// Why a composition will not load. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Invalid { + /// An effectful step before the commit point declares no undo. + /// + /// The finding a consumer sees when their composition would spend and then + /// abandon. Pointer-only: the step, never a rendering of the whole list. + Uncompensated(Step), + /// A compensation that would not survive the process performing it. + NotDurable(Step), + /// The commit point is missing, so nothing can ever land. + NoCommitPoint, + /// The commit point is not last, so a step would run after the irreversible + /// one. + CommitPointNotLast(Step), + /// One step declared twice. Which of the two an undo belongs to is then + /// unanswerable. + Duplicate(Step), +} + +/// The step that commits, after which nothing is reversible. +/// +/// Named once rather than compared inline: [`Pipeline::validate`] asks three +/// separate questions about it, and three spellings of "which one is the commit +/// point" is three places for them to disagree. +pub const COMMIT_POINT: Step = Step::FastForward; + +impl Pipeline { + /// Refuse a composition that would spend and then abandon. + /// + /// # The invariant, and why it is checked at LOAD + /// + /// An effectful step positioned before [`COMMIT_POINT`] must declare a + /// compensation. A composition that violates it does not fail here and + /// succeed in production — it never loads, which is the difference between a + /// schema and a convention. + /// + /// # Errors + /// + /// Every way the list is unwalkable, as [`Invalid`]. All of them are + /// returned, not just the first: an author fixing one at a time pays a load + /// cycle each. + #[must_use] + pub fn validate(&self) -> Vec { + let mut found = Vec::new(); + + let mut seen: Vec = Vec::new(); + for row in &self.steps { + if seen.contains(&row.step) { + found.push(Invalid::Duplicate(row.step)); + } + seen.push(row.step); + } + + let Some(commit_at) = self.steps.iter().position(|row| row.step == COMMIT_POINT) else { + found.push(Invalid::NoCommitPoint); + return found; + }; + if commit_at + 1 != self.steps.len() { + // NAMES THE STEP THAT FOLLOWS IT, not the commit point: the commit + // point is where it belongs and the trailing row is the mistake. + if let Some(row) = self.steps.get(commit_at + 1) { + found.push(Invalid::CommitPointNotLast(row.step)); + } + } + + for row in &self.steps[..commit_at] { + if row.effectful && !row.compensate.undoes_something() { + found.push(Invalid::Uncompensated(row.step)); + } + if !row.compensate.is_durable() { + found.push(Invalid::NotDurable(row.step)); + } + } + found + } + + /// The undos a lap owes, for the steps it actually entered, newest first. + /// + /// **REVERSE ORDER, and it is the same reason a stack unwinds that way**: a + /// later step's effect sits on top of an earlier one's, so undoing the + /// earlier one first can leave the later effect pointing at something that no + /// longer exists. + /// + /// Takes what was ENTERED rather than reading the whole list: a lap that + /// stopped at `Verify` never readied, so it owes no re-draft, and computing + /// the owed set from the composition alone would compensate effects nobody + /// caused. + /// **AT MOST ONCE EACH, because two steps may owe the SAME undo** (review of + /// #848). `Lease` and `Push` both declare [`Compensation::ReleaseLease`] — + /// one because it took the lease and one because it pushed under it — so a + /// lap entering both owed the hand-back twice and `lease_hand_back` ran, and + /// reported, twice per unwind. A compensation is a statement about what is + /// owed, not a count of who owes it. + /// + /// Deduped on the way out rather than by forbidding a shared compensation at + /// load: two steps owing one undo is a legitimate composition, and the + /// alternative would make an adopter's pipeline unloadable for describing its + /// effects accurately. + /// + /// **A SHARED UNDO IS OWED AT THE EARLIEST OWER'S POSITION, and getting that + /// backwards inverted the invariant this function's own header states** + /// (review of #848). Deduping newest-first keeps the NEWEST occurrence, so + /// `[Lease, Ready, Push, Wait]` released the lease at `Push`'s slot — before + /// the re-draft — which is precisely what + /// `the_unwind_runs_newest_first`'s rationale says must never happen: the + /// next branch gets a landing slot while this one's pull request is still + /// ready and still spending. The shipped test asserted the inversion, because + /// its entered set predates the `Lease` row and could not see it. + /// + /// Releasing a resource is safe only once every effect taken UNDER it is + /// undone, which is the same stack discipline read one level out: the lease + /// is acquired first, so it is released last. + #[must_use] + pub fn unwind(&self, entered: &[Step]) -> Vec { + // Forward, so the first sighting of a compensation is its earliest ower. + let mut owed: Vec<(usize, Compensation)> = Vec::new(); + for (index, step) in entered.iter().enumerate() { + let Some(row) = self.steps.iter().find(|row| row.step == *step) else { + continue; + }; + if !row.compensate.undoes_something() + || owed.iter().any(|(_, seen)| *seen == row.compensate) + { + continue; + } + owed.push((index, row.compensate)); + } + owed.sort_by_key(|(index, _)| std::cmp::Reverse(*index)); + owed.into_iter() + .map(|(_, compensation)| compensation) + .collect() + } +} + +impl Default for Pipeline { + /// This repository's own composition — the default an adopter amends. + /// + /// **`Ready` is the one that buys the matrix**, which its own primitive's + /// header says: *"readying is what starts CI, so it is the one site that buys + /// a matrix."* That makes it the effectful step whose compensation the whole + /// invariant exists for, and [`Compensation::Redraft`] is it. + /// + /// `Push` is effectful too and its undo is the lease: a push that landed on a + /// remote ref cannot be un-pushed, but the lease it holds can be handed back + /// so the next branch does not wait out a TTL. + fn default() -> Self { + Self { + steps: vec![ + StepRow { + step: Step::Replay, + effectful: false, + compensate: Compensation::Nothing, + // SETTLED BEFORE THE REPLAY, never after: the replay is what + // re-linearizes this branch, so a bet unwound here needs no + // second rebase — and a bet left un-settled would have the + // replay build on top of somebody else's commits. + precheck: Some(Precheck::BetSettled), + }, + StepRow { + step: Step::Verify, + effectful: false, + compensate: Compensation::Nothing, + precheck: None, + }, + // THE LEASE IS TAKEN BEFORE THE MATRIX IS BOUGHT, and the row + // exists because nothing took it at all until review of #848. + // `Push` below already declared `ReleaseLease`, so this + // composition asserted a lease was held that no step acquired — + // the compensation cluster was complete and its precondition was + // not. Effectful, because acquiring publishes a claim other + // clones read; its compensation is handing that claim back. + StepRow { + step: Step::Lease, + effectful: true, + compensate: Compensation::ReleaseLease, + precheck: None, + }, + // **THE PUSH PUBLISHES THE HEAD THE READY THEN BUYS A MATRIX + // FOR, AND THESE TWO WERE THE OTHER WAY ROUND** (review of + // #848). `Ready` ran first, so on any lap that replayed, the + // forge still held the SUPERSEDED head when `mark_ready` fired: + // the forge emitted `ready_for_review` on that sha and started a + // full matrix there, `Push` then moved the remote and started a + // second, and `Wait` polled only the second. The first was + // unreachable by `Compensation::Abandon`, which reads + // `git::head_commit` — so it billed to completion with nothing + // able to cancel it. + // + // Reading the forge's head instead of this clone's made the + // DECISION agree with the forge and left the ACT firing on the + // stale sha; the order is what fixes the act. With the push + // first there is one head, one matrix, and `Abandon` reaches it. + StepRow { + step: Step::Push, + effectful: true, + compensate: Compensation::ReleaseLease, + precheck: None, + }, + StepRow { + step: Step::Ready, + effectful: true, + compensate: Compensation::Redraft, + // THE DRIVER'S OLD EXCEPTION, as a row column. It ran after + // `Verify` answered and before `Ready` dispatched, and it + // still does — closer to the spend it guards, now that the + // push no longer sits between them. + precheck: Some(Precheck::BaseMoved), + }, + StepRow { + step: Step::Wait, + effectful: true, + compensate: Compensation::Abandon, + precheck: None, + }, + StepRow { + step: COMMIT_POINT, + effectful: true, + compensate: Compensation::Nothing, + precheck: None, + }, + ], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// **A WAIT THAT DID NOT SUCCEED STILL OWES ITS ABANDON, AND A READY THAT + /// DID NOT SUCCEED OWES NOTHING.** + /// + /// The driver's rule was `effectful && succeeded`, which is right for the + /// second half and inverts the first: `land wait` answers success only when + /// the head is GREEN, so red, stale and unanswered — the three outcomes where + /// runs are billing against a head nothing will land — recorded no entry and + /// `Compensation::Abandon` never ran for them. + /// + /// Four assertions rather than one, because each half of the conjunction can + /// be got wrong on its own: a rule that always returned `true` passes the + /// wait cases, a rule that kept `&& succeeded` passes the ready cases, and a + /// rule ignoring `effectful` passes both. + #[test] + fn a_wait_owes_its_undo_on_the_attempt_and_a_ready_owes_its_undo_on_success() { + let shipped = Pipeline::default(); + // `is_some_and` rather than an unwrap: the crate's lints refuse a panic + // on a reachable path even here, and a step the composition does not + // declare should read as *did not enter* rather than end the run. + let entered = |step: Step, succeeded: bool| { + shipped + .steps + .iter() + .find(|row| row.step == step) + .is_some_and(|row| row.entered(succeeded)) + }; + + assert!( + entered(Step::Wait, false), + "a red, stale or unanswered wait leaves runs live and owes the abandon" + ); + assert!(entered(Step::Wait, true), "a green wait owes it too"); + assert!( + !entered(Step::Ready, false), + "a refused ready bought no matrix, so there is no draft to undo" + ); + assert!(entered(Step::Ready, true)); + assert!( + !entered(Step::Verify, true), + "a step that is not effectful never owes an undo, however it ended" + ); + } + + /// **`Abandon` IS THE ONLY ATTEMPT-OWED UNDO, and the mirror is what makes + /// this case discriminate.** Without the second half, a predicate returning + /// `true` for everything would satisfy the first — and that predicate would + /// re-draft over a ready that never fired. + #[test] + fn only_the_undo_that_cancels_runs_is_owed_before_its_step_succeeds() { + assert!(Compensation::Abandon.owed_on_attempt()); + for compensation in [ + Compensation::Nothing, + Compensation::Redraft, + Compensation::ReleaseLease, + ] { + assert!( + !compensation.owed_on_attempt(), + "{compensation:?} undoes an effect its own step's success creates" + ); + } + } + + /// **THE INVARIANT, AND THE PAIR THAT SHOWS IT DISCRIMINATES.** + /// + /// An effectful step before the commit point with no undo fails to load; the + /// same step with one loads. Without the second half, a validator that + /// refused everything would satisfy the first. + #[test] + fn an_effectful_step_before_the_commit_point_must_declare_an_undo() { + let leaks = Pipeline { + steps: vec![ + StepRow { + step: Step::Ready, + effectful: true, + compensate: Compensation::Nothing, + precheck: None, + }, + StepRow { + step: COMMIT_POINT, + effectful: true, + compensate: Compensation::Nothing, + precheck: None, + }, + ], + }; + assert_eq!( + leaks.validate(), + vec![Invalid::Uncompensated(Step::Ready)], + "a composition that spends and then abandons must not load" + ); + + let compensated = Pipeline { + steps: vec![ + StepRow { + step: Step::Ready, + effectful: true, + compensate: Compensation::Redraft, + precheck: None, + }, + StepRow { + step: COMMIT_POINT, + effectful: true, + compensate: Compensation::Nothing, + precheck: None, + }, + ], + }; + assert!( + compensated.validate().is_empty(), + "and one that declares its undo loads: {:?}", + compensated.validate() + ); + } + + /// **THE COMMIT POINT ITSELF NEEDS NO UNDO, which is not an oversight.** + /// + /// It is irreversible by definition — that is what makes it the commit point, + /// and what makes everything before it need one. A validator demanding a + /// compensation for it would be demanding the impossible and would refuse + /// every honest composition. + #[test] + fn the_commit_point_needs_no_compensation() { + assert!(Pipeline::default().validate().is_empty()); + } + + /// **THE BET IS SETTLED BEFORE ANYTHING IS SPENT**, and the position is the + /// property rather than the presence. + /// + /// `mise-tasks/land.sh` states the invariant in as many words: *"there is no + /// path from a losing bet to a push."* A [`Precheck::BetSettled`] positioned + /// after any effectful row would give it one — the lap would spend a matrix, + /// or push, on a tree still carrying another branch's commits, and only then + /// discover the bet was lost. So this asserts the ORDER, which a row-presence + /// case would not: moving the declaration one row down leaves it present and + /// leaves the invariant broken. + #[test] + fn the_bet_settles_before_the_first_effectful_step() { + let shipped = Pipeline::default(); + let settles = shipped + .steps + .iter() + .position(|row| row.precheck == Some(Precheck::BetSettled)); + assert!( + settles.is_some(), + "the shipped composition settles an outstanding bet" + ); + let first_spend = shipped.steps.iter().position(|row| row.effectful); + assert!( + settles.is_some_and(|at| first_spend.is_none_or(|spend| at <= spend)), + "the settle is at row {settles:?} and the first spend at {first_spend:?}" + ); + } + + /// A step after the commit point is refused: it would run after the + /// irreversible one, so its own undo could never help. + #[test] + fn nothing_may_be_positioned_after_the_commit_point() { + let trailing = Pipeline { + steps: vec![ + StepRow { + step: COMMIT_POINT, + effectful: true, + compensate: Compensation::Nothing, + precheck: None, + }, + StepRow { + step: Step::Push, + effectful: true, + compensate: Compensation::ReleaseLease, + precheck: None, + }, + ], + }; + assert_eq!( + trailing.validate(), + vec![Invalid::CommitPointNotLast(Step::Push)], + "and the finding names the trailing row rather than the commit point" + ); + } + + /// **A COMPOSITION WITH NO COMMIT POINT CAN NEVER LAND**, which is a + /// different failure from an uncompensated one and must not be reported as a + /// clean list. + #[test] + fn a_composition_that_can_never_land_is_refused() { + let never = Pipeline { + steps: vec![StepRow { + step: Step::Verify, + effectful: false, + compensate: Compensation::Nothing, + precheck: None, + }], + }; + assert_eq!(never.validate(), vec![Invalid::NoCommitPoint]); + } + + /// One step declared twice makes "which row's undo is this" unanswerable. + #[test] + fn a_step_declared_twice_is_refused() { + let twice = Pipeline { + steps: vec![ + StepRow { + step: Step::Verify, + effectful: false, + compensate: Compensation::Nothing, + precheck: None, + }, + StepRow { + step: Step::Verify, + effectful: false, + compensate: Compensation::Nothing, + precheck: None, + }, + StepRow { + step: COMMIT_POINT, + effectful: true, + compensate: Compensation::Nothing, + precheck: None, + }, + ], + }; + assert!(twice.validate().contains(&Invalid::Duplicate(Step::Verify))); + } + + /// **EVERY FINDING IS RETURNED, not just the first**, because an author + /// fixing one at a time pays a load cycle each. + #[test] + fn a_composition_with_two_faults_reports_both() { + let two = Pipeline { + steps: vec![ + StepRow { + step: Step::Ready, + effectful: true, + compensate: Compensation::Nothing, + precheck: None, + }, + StepRow { + step: Step::Push, + effectful: true, + compensate: Compensation::Nothing, + precheck: None, + }, + StepRow { + step: COMMIT_POINT, + effectful: true, + compensate: Compensation::Nothing, + precheck: None, + }, + ], + }; + assert_eq!(two.validate().len(), 2, "{:?}", two.validate()); + } + + /// **THE UNWIND IS OVER WHAT WAS ENTERED, never over the whole list.** + /// + /// A lap that stopped at `Verify` never readied, so it owes no re-draft. + /// Computing the owed set from the composition alone would compensate + /// effects nobody caused — which on this pipeline means re-drafting a pull + /// request that was never made ready. + #[test] + fn a_lap_owes_undos_only_for_the_steps_it_entered() { + let pipeline = Pipeline::default(); + assert!( + pipeline.unwind(&[Step::Replay, Step::Verify]).is_empty(), + "nothing effectful was entered" + ); + assert_eq!( + pipeline.unwind(&[Step::Replay, Step::Verify, Step::Ready]), + vec![Compensation::Redraft], + "readying bought the matrix, so the tap is what closes" + ); + } + + /// **NEWEST FIRST, because a later effect sits on top of an earlier one.** + /// + /// Releasing the lease before re-drafting would hand the next branch a + /// landing slot while this one's pull request is still ready and still + /// spending. + #[test] + fn the_unwind_runs_newest_first() { + let pipeline = Pipeline::default(); + assert_eq!( + pipeline.unwind(&[Step::Ready, Step::Push, Step::Wait]), + vec![ + Compensation::Abandon, + Compensation::ReleaseLease, + Compensation::Redraft, + ], + "the reverse of the order they took effect in" + ); + } + + /// **THE LEASE IS RELEASED LAST WHEN THE LAP ACTUALLY TOOK IT**, which is the + /// case the one above cannot see: its entered set predates the `Lease` row. + /// + /// `Lease` and `Push` share [`Compensation::ReleaseLease`], and deduping + /// newest-first kept `Push`'s slot — so a full lap released the lease BEFORE + /// re-drafting, the exact ordering the case above says must not happen. This + /// is the mirror that makes that rationale enforced rather than merely + /// written down. + #[test] + fn a_shared_undo_is_owed_at_the_earliest_owers_position() { + let pipeline = Pipeline::default(); + assert_eq!( + pipeline.unwind(&[Step::Lease, Step::Ready, Step::Push, Step::Wait]), + vec![ + Compensation::Abandon, + Compensation::Redraft, + Compensation::ReleaseLease, + ], + "the lease is acquired first, so it is handed back last — after the \ + re-draft that stops this pull request spending under it" + ); + } + + /// AND IT IS STILL OWED ONCE. Without this the case above is satisfied by a + /// walk that emitted the hand-back at both slots and happened to end on the + /// right one. + #[test] + fn a_shared_undo_is_owed_exactly_once() { + let pipeline = Pipeline::default(); + let owed = pipeline.unwind(&[Step::Lease, Step::Ready, Step::Push, Step::Wait]); + assert_eq!( + owed.iter() + .filter(|compensation| **compensation == Compensation::ReleaseLease) + .count(), + 1, + "a compensation is what is owed, never a count of who owes it" + ); + } + + /// A step the composition does not declare contributes no undo rather than + /// panicking — an entered set naming one is a driver bug, and a compensation + /// pass is the wrong place to discover it loudly. + #[test] + fn an_entered_step_the_pipeline_does_not_declare_is_skipped() { + let pipeline = Pipeline { + steps: vec![StepRow { + step: COMMIT_POINT, + effectful: true, + compensate: Compensation::Nothing, + precheck: None, + }], + }; + assert!(pipeline.unwind(&[Step::Ready]).is_empty()); + } + + /// Every compensation this crate ships lands outside the process performing + /// it, which is the property the whole design rests on. + #[test] + fn every_shipped_compensation_is_durable() { + for compensation in [ + Compensation::Nothing, + Compensation::Redraft, + Compensation::Abandon, + Compensation::ReleaseLease, + ] { + assert!( + compensation.is_durable(), + "{compensation:?} would not survive the container being killed, \ + which is the one case compensation exists for" + ); + } + } +} diff --git a/crates/batten/src/policy/presets/landing-loop/lap-waits-on-one-answer.rego b/crates/batten/src/policy/presets/landing-loop/lap-waits-on-one-answer.rego index de1ce8234..c51e8e3c8 100644 --- a/crates/batten/src/policy/presets/landing-loop/lap-waits-on-one-answer.rego +++ b/crates/batten/src/policy/presets/landing-loop/lap-waits-on-one-answer.rego @@ -59,19 +59,52 @@ rules contains "lap-waits-on-one-answer" #MUTANT loser-read|s@^\tcount(wait_answered) > 1$@\tfalse@|the_landing_loop_preset_refuses_a_lap_that_read_both_answers #MUTANT single-answer-unpriced|s@^\tcount(wait_answered) > 1$@\ttrue@|the_landing_loop_preset_refuses_a_lap_that_read_both_answers -# Every wait outcome this lap recorded, in write order. +# Does a LATER line in this record open another lap? +# +# A `rebase` line is the first thing a lap writes — `Replay::line` has no other +# spelling, `current` and `replayed` included — so a wait line with one after it +# belongs to a lap that has already finished. +# +# DEFINED ABOVE ITS READER because regorus resolves a rule defined below its +# reader as undefined; this module's header records that being measured twice, +# both times silently. +# +# Undefined where there is no later `rebase` line, which is what makes +# `not wait_ended_a_lap_ago` hold for the lines of the lap standing now. +wait_ended_a_lap_ago(lines, index) if { + some later, line in lines + later > index + startswith(line, "rebase ") +} + +# Every wait outcome THE LAP STANDING NOW recorded, in write order. # # A COMPREHENSION RATHER THAN A SET, and here that is load-bearing twice over # rather than once: a set de-duplicates, so a lap that recorded the SAME arm # twice would collapse to one member and read as a lap that answered once — # which is the exact reading this module exists to refuse. # +# **SCOPED TO THIS LAP, AND IT READ THE WHOLE HISTORY** (review of #848). The +# record is APPEND-ONLY — `land::record`'s own doc says so, because a lap that +# conflicted and a later lap that resolved it are two facts — so this read every +# wait line the branch had ever written. A second lap therefore inherited the +# first's answered arm, `wait_answered` reached two, and the gate refused a +# branch whose every lap waited on exactly one answer. The sibling reading +# replays already had this right and takes `last_replay` for the same reason; +# this module took the count and did not. +# +# The lines of one record are iterated WITH THEIR INDEX rather than flattened, +# because position is the only thing that says which lap a line belongs to, and +# `input.tree.records[_][_]` discards it. +# # The `is_object` guard is first because `some .. in null` is a hard evaluation # FAULT in Rego, and a fault takes the whole bundle down rather than missing # quietly. wait_answers := [answer | is_object(input.tree.records) - line := input.tree.records[_][_] + lines := input.tree.records[_] + some index, line in lines + not wait_ended_a_lap_ago(lines, index) columns := split(line, " ") count(columns) == 4 columns[0] == "wait" @@ -248,6 +281,34 @@ test_another_kinds_line_is_skipped if { ]) } +# THE CASE THAT WAS MISSING, AND THE ONE THE DEFECT LIVED IN. The record is +# append-only, so a branch that has lapped twice carries both laps' wait lines — +# and reading them together made two correct laps look like one lap that read +# both answers. Each lap here answered exactly once. +test_two_laps_answering_once_each_is_clean if { + count(violation) == 0 with input as wait_record([ + "rebase replayed abc1234 -", + "wait green success abc1234", + "rebase replayed def5678 -", + "wait stale moved def5678", + ]) +} + +# AND ITS DISCRIMINATING PARTNER: the same two answers INSIDE one lap still +# refuse. Without this the fix is satisfied by a module that reads no wait line +# at all, which is the non-gate the anti-vacuity cases above exist to refuse. +test_two_arms_answering_within_one_lap_is_still_refused if { + some v in violation with input as wait_record([ + "rebase replayed abc1234 -", + "wait green success abc1234", + "rebase replayed def5678 -", + "wait green success def5678", + "wait stale moved def5678", + ]) + v.subjects[0].count == 2 + v.subjects[1].artifact == "def5678" +} + # COULD-NOT-LOOK OVER THE WHOLE STORE, and without the `is_object` guard this # case does not merely fail — it FAULTS, taking the whole bundle with it. test_an_absent_record_store_does_not_fault if { diff --git a/crates/batten/src/pr_watch.rs b/crates/batten/src/pr_watch.rs index 581a75a0c..bcd17c397 100644 --- a/crates/batten/src/pr_watch.rs +++ b/crates/batten/src/pr_watch.rs @@ -56,9 +56,6 @@ use crate::exit::ExitCode; /// TOOL's vocabulary, the same standing `semver.rs` gives its analyser. pub const REPO_PLACEHOLDER: &str = "{owner}/{repo}"; -/// The client this poll reads through. -const CLIENT: &str = "gh"; - /// Seconds between requests when the caller names none. /// /// One second, because the poll is CONDITIONAL: an unchanged reading answers @@ -77,6 +74,16 @@ pub const DEFAULT_INTERVAL: u64 = 1; /// before that landed, which is how it survived unnoticed. const PER_PAGE: u32 = 100; +/// How many requests may go unanswered FROM THE START before the poll refuses. +/// +/// Generous, and it costs nothing to be: the bound only ever fires where no +/// request has been answered at all, so a real forge answers one of these long +/// before the count is reached and resets it to zero permanently. What it stops +/// is a poll that was never going to be answered — a credential the endpoint +/// refuses, a repository this token cannot see — which is a fact about the +/// invocation and does not become truer by asking again. +pub(crate) const UNANSWERED_BEFORE_REFUSING: u64 = 30; + /// What the poll needs that is not the roster. #[derive(Debug, Clone)] pub struct Config { @@ -92,6 +99,29 @@ pub struct Config { pub progress: Option, } +impl Config { + /// Can a REQUEST be built from this config's repository? + /// + /// [`REPO_PLACEHOLDER`] is the forge CLI's own substitution and nothing in + /// this crate performs it — [`crate::rest::get`] sends the path it is given, + /// so `{owner}/{repo}` reaches the endpoint verbatim and the forge answers + /// `404`. That is the same defect `repo_slug` was written to remove one layer + /// up, and it survives at every site still reaching for the rendering + /// fallback. + /// + /// **The failure it produces is the worst shape a wait has**: [`read`] + /// answers `None` for a `404` exactly as it does for a dropped connection, + /// because both are could-not-look, and a poll that must survive a transient + /// failure therefore polls a guaranteed-404 forever without saying anything. + /// So the question is asked BEFORE the loop, where the roster's own + /// usability is already asked, and answered as a statement about the + /// invocation rather than about the checks. + #[must_use] + pub fn names_a_repository(&self) -> bool { + !self.repo.trim().is_empty() && self.repo != REPO_PLACEHOLDER + } +} + /// The caller's progress recorder: a program and the identity it files under. #[derive(Debug, Clone)] pub struct Progress { @@ -101,78 +131,6 @@ pub struct Progress { pub id: String, } -/// One response, split into the three things a conditional poll reads. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Response { - /// The status line's code. `0` where there was no status line at all, which - /// is what a client that could not answer looks like. - pub status: u16, - /// The validator to send back on the next request. - pub etag: Option, - /// A floor the server asked for, in seconds. - pub poll_floor: Option, - /// Everything past the header block. - pub body: String, -} - -/// Split a raw `-i` response into status, headers and body. -/// -/// Carriage returns are dropped first, because a header block arrives CRLF- -/// terminated and a trailing `\r` would ride along inside every value — an -/// `ETag` echoed back with one is not the `ETag` the server issued. -#[must_use] -pub fn parse_response(raw: &str) -> Response { - let clean = raw.replace('\r', ""); - let mut status = 0u16; - let mut etag = None; - let mut poll_floor = None; - let mut body = String::new(); - let mut in_body = false; - - for (index, line) in clean.split('\n').enumerate() { - if in_body { - body.push_str(line); - body.push('\n'); - continue; - } - if line.is_empty() { - in_body = true; - continue; - } - if index == 0 { - status = line - .split_whitespace() - .nth(1) - .and_then(|code| code.parse().ok()) - .unwrap_or(0); - continue; - } - if let Some(value) = header(line, "etag") { - etag = Some(value.to_owned()); - } else if let Some(value) = header(line, "x-poll-interval") { - poll_floor = value.parse().ok(); - } - } - - Response { - status, - etag, - poll_floor, - body, - } -} - -/// The value of `name` on `line`, matched case-insensitively — header names are -/// not case-sensitive and this endpoint has spelled `ETag` both ways. -fn header<'a>(line: &'a str, name: &str) -> Option<&'a str> { - let (key, value) = line.split_once(':')?; - if key.trim().to_ascii_lowercase() == name { - Some(value.trim()) - } else { - None - } -} - /// Project a check-runs document into the rows the decision reads. /// /// A body that will not parse yields NOTHING rather than an error, which is the @@ -220,19 +178,94 @@ fn string_at(row: &serde_json::Value, key: &str) -> String { .to_owned() } +/// How long to wait before the next request, honouring BOTH server bounds. +/// +/// **THE BACKOFF DOES NOT GO THROUGH `interval_for`, AND ROUTING IT THERE WAS A +/// DEFECT** (review of #848). The two are different things and `rest.rs` says so +/// at the field: `poll_floor` is *how often to ask*, `backoff` is *stop asking +/// until*. `MAX_FLOOR` is a ceiling on the first — its own doc justifies it +/// purely as a guard on `X-Poll-Interval`, "larger than any interval this forge +/// has been observed to ask for, so it clamps nothing real" — and it is +/// meaningless as a ceiling on the second. +/// +/// Measured consequence of conflating them: `rest::backoff_of` resolves +/// `x-ratelimit-reset - now` once `x-ratelimit-remaining` is `0`, so a primary +/// limit resetting fifty minutes out yields `3000`. Clamped to `300`, the poll +/// waits five minutes and re-issues the same request, ten more times, each +/// answered `403` — which is verbatim the "responding to being rate-limited by +/// generating more of the request that had just been refused" behaviour +/// `Answer::backoff` was added to stop. +/// +/// So the cadence is clamped and the backoff is not, and the answer is whichever +/// is longer: a backoff is a lower bound on the wait exactly as a floor is, and +/// satisfying only one of them satisfies neither. +#[must_use] +pub(crate) fn wait_for(configured: u64, floor: Option, backoff: Option) -> f64 { + let paced = interval_for(configured, floor); + // Narrowed the way `interval_for` narrows its own argument, and for the same + // reason: the conversion is exact for every value that survives it. + let backoff = backoff.map_or(0.0, |secs| { + f64::from(u32::try_from(secs).unwrap_or(u32::MAX)) + }); + paced.max(backoff) +} + /// The interval to honour: the configured one unless the server asked for more. /// /// A server-sent floor is the endpoint asking to be polled less often, so it /// wins over the configured interval — but only upward. Reading it as an /// absolute would let a server that asks for `0` turn this into a spin. +/// **The comparison is NUMERIC.** An integer one is what CLOUD-390 removed from +/// the predecessor, and restoring it here would drop any fractional floor the +/// endpoint sends — the same silent hole, in a different language. +/// +/// **LOSSLESS BY CONSTRUCTION rather than by annotation** (CLOUD-1338). This +/// carried an `#[expect(clippy::cast_precision_loss)]` over `configured as f64`, +/// whose reason argued the value is always small — which is a claim about +/// callers, checked by nobody. Narrowing to `u32` first makes the conversion +/// exact for every value that survives it, and saturating states the bound in +/// code: `u32::MAX` seconds is 136 years, so a poll interval that reached it was +/// never going to turn again anyway. #[must_use] -pub fn interval_for(configured: u64, floor: Option) -> u64 { +pub fn interval_for(configured: u64, floor: Option) -> f64 { + let configured = f64::from(u32::try_from(configured).unwrap_or(u32::MAX)); match floor { - Some(floor) if floor > configured => floor, + // FINITE OR IT IS NOT A FLOOR, and the guard belongs HERE rather than + // only at the boundary that parses one. `crate::rest` already filters a + // header to a finite positive, so nothing unusable arrives from the real + // path today — but this function is public and takes an `f64`, so a + // caller constructing one reaches it, and `INFINITY > configured` holds: + // the answer would be an infinite wait, which is the hang the + // predecessor's `+0` coercion existed to refuse. A floor nobody can + // satisfy is no floor. + // AND THE CEILING NEVER REDUCES THE CONFIGURED INTERVAL. `MAX_FLOOR` is a + // bound on what the SERVER may add, not a bound on what the caller asked + // for: with `configured` above it, a raw `min(MAX_FLOOR)` answered BELOW + // the interval this poll was told to use — the one direction this + // function must never take, since `Config::interval` is the caller's + // floor and nothing here is entitled to lower it. + Some(floor) if floor.is_finite() && floor > configured => { + floor.min(MAX_FLOOR.max(configured)) + } _ => configured, } } +/// The longest interval a server may ask this poll to wait, in seconds. +/// +/// **A CEILING, because the floor comes off the wire.** `X-Poll-Interval` is a +/// number the forge sends, and a finite one is not thereby a reasonable one: an +/// endpoint answering `86400` — or a proxy inventing one — turns a wait into a +/// hang that looks exactly like a slow bot. The finiteness guard above stops the +/// infinity; this stops the merely absurd. +/// +/// Five minutes, against a poll whose own default is one second and a landing +/// whose bound is a COUNT rather than a clock: honouring a floor this large +/// already means the lap spends its whole ask budget on a handful of requests, +/// which is the signal a reader needs. Larger than any interval this forge has +/// been observed to ask for, so it clamps nothing real. +const MAX_FLOOR: f64 = 300.0; + /// A change detector over a reading, never a digest anyone reads back. /// /// FNV-1a because the only property needed is that a changed reading changes @@ -258,9 +291,45 @@ pub struct Poll { polls: u64, /// The current reading's signature. signature: u64, + /// The last backoff the SERVER asked for, held across a poll that could not + /// look. + /// + /// **A `Retry-After` must outlive one transport failure** (review of #848). + /// `absorb`'s could-not-look arm recomputed the wait from the response alone, + /// and a `None` response has none — so a `403` carrying `Retry-After: 300` + /// was honoured once, and the very next reset connection dropped the wait + /// back to the configured cadence and resumed hammering for the rest of the + /// rate-limit window. That is exactly the behaviour `Answer::backoff` exists + /// to stop, reintroduced by the arm that has no answer to read it from. + /// + /// Cleared by a real reading, because a `200` is the server saying the window + /// is over — holding it past that would be a second authority over a cadence + /// the endpoint already answers for. + backoff: Option, /// The last thing said out loud, so a stall is stated once rather than /// every second. announced: String, + /// Has ANY request ever been answered — a `200` or a `304`? + /// + /// **The discriminator between a transient failure and a poll that will + /// never be answered** (review of #848). `read` moved off the forge CLI, + /// which authenticates from its own keyring, onto [`crate::rest::get`], + /// which reads `$GH_TOKEN`/`$GITHUB_TOKEN` alone — so a machine + /// authenticated by `gh auth login` and nothing else now gets `401` on every + /// request. Every `401` is a could-not-look, no reading ever replaces the + /// empty run set, the verdict stays `Pending`, its line is announced once + /// and then deduped, and the loop polls one request a second forever against + /// a guaranteed refusal, holding the landing lease throughout. + /// + /// **A COUNT OF CONSECUTIVE FAILURES WOULD BE THE WRONG PREDICATE**, which + /// is why this is a boolean about the whole poll rather than a streak: a + /// twenty-minute forge outage in the middle of a landing is exactly the + /// transient a wait must survive, and it is indistinguishable from a run of + /// failures. What is not survivable is never having been answered AT ALL — + /// that is a statement about the invocation (a credential, a repository, a + /// permission), which belongs before the loop's patience rather than inside + /// it. + answered_ever: bool, } impl Poll { @@ -270,17 +339,72 @@ impl Poll { /// A `304` is the server saying nothing moved: the previous reading stands /// and the signature is not recomputed, because re-hashing an unchanged /// string per poll is a cost spent to learn what the status line said. - pub fn absorb(&mut self, raw: &str, configured: u64) -> u64 { + /// **`None` is a poll that could not look**, and it is folded rather than + /// skipped: the count still advances, the previous reading still stands, and + /// the caller waits its configured interval. Dropping it would let an + /// unreachable forge make a bounded loop unbounded — the same fold + /// [`crate::main_watch::Poll::absorb`] makes, and deliberately the same + /// shape, since the two are the arms of one race. + pub fn absorb(&mut self, answer: Option<&crate::rest::Answer>, configured: u64) -> f64 { self.polls += 1; - let response = parse_response(raw); - if let Some(etag) = response.etag { - self.etag = Some(etag); + let Some(answer) = answer else { + // The server's last word stands: a poll that could not look learned + // nothing that would retire it. + return wait_for(configured, None, self.backoff); + }; + // AN ETAG SURVIVES A RESPONSE THAT CARRIES NONE, which is what keeps a + // single unvalidated answer from turning every later request + // unconditional. + if let Some(etag) = &answer.etag { + self.etag = Some(etag.clone()); } - if response.status != 304 { - self.runs = runs_from_body(&response.body); - self.signature = signature(&response.body); + // **ONLY A READING REPLACES THE READING** (review of #848). This was + // `status != 304`, which takes every OTHER status too — so a `401`, a + // `403` or a `404` had its ERROR DOCUMENT parsed as a check-run page, + // yielding zero runs, and that empty set overwrote a good reading. The + // trap is what follows: an error response carries no `ETag`, so the + // previous validator survives the branch above, the next request is + // conditional against it, the forge answers `304`, and the empty reading + // is then preserved by the very rule that exists to preserve a good one. + // One transient error and the poll holds "no runs" indefinitely. + // + // `is_reading()` is `200` alone, and `304` is deliberately not one: it + // means *nothing changed*, so the reading it refers to is the one already + // held. The guard was applied at `head_verdict` and not here, where every + // poll actually goes through. + if answer.is_reading() { + self.runs = runs_from_body(&answer.body); + self.signature = signature(&answer.body); } - interval_for(configured, response.poll_floor) + // **A NORMAL ANSWER RETIRES THE WINDOW, AND `304` IS ONE** (review of + // #848). This read `is_reading()`, which is `200` alone — but the + // ordinary success of a CONDITIONAL poll is `304`, so every unchanged + // answer took the `else` arm and re-armed the window it was supposed to + // retire. Measured shape: a `403` with a 50-minute reset sets 3000s; the + // window then reopens, the runs have not changed, the forge answers + // `304`, and `None.or(Some(3000))` sleeps another 50 minutes — for the + // rest of an unbounded `watch()`, holding the landing lease throughout. + // That is strictly worse than the over-polling the retention fixed. + self.answered_ever = self.answered_ever || answer.answered(); + self.backoff = if answer.answered() { + None + } else { + answer.backoff.or(self.backoff) + }; + // THE SERVER'S BACKOFF OUTRANKS THE CONFIGURED FLOOR. `Answer::backoff` + // carries `Retry-After`, and it had no consumer at all — a `403` that + // said "wait 60 seconds" was answered by continuing at the configured + // 1s cadence, which is the predecessor defect `rest.rs` names as the + // reason the field exists. + // + // **`self.backoff`, NEVER `answer.backoff`** (review of #848). The field + // above was stored and then not read: a second `403` carrying no + // `Retry-After` of its own extended the window in the struct and returned + // the un-backed-off interval anyway. The could-not-look arm three + // screens up already reads `self.backoff`; this is the same window, and + // the assignment above makes the two spellings equal whenever the answer + // carries one. + wait_for(configured, answer.poll_floor, self.backoff) } /// The reading this poll currently holds. @@ -313,6 +437,17 @@ impl Poll { self.etag.as_deref() } + /// How many requests this poll has made without ever being answered. + /// + /// `0` once anything has answered, which is what makes a caller's bound a + /// statement about the INVOCATION rather than about the forge's uptime. See + /// [`Poll::answered_ever`] for why a consecutive-failure streak is the wrong + /// predicate here. + #[must_use] + pub const fn unanswered_from_the_start(&self) -> u64 { + if self.answered_ever { 0 } else { self.polls } + } + /// Say `line` unless it is what was said last. /// /// Silence is how a poll that will never resolve looks exactly like one @@ -328,27 +463,6 @@ impl Poll { } } -/// The request this poll makes, as argv. -/// -/// Built rather than formatted at the call site so the page size and the -/// conditional header are one object a test can read. -#[must_use] -pub fn request(config: &Config, etag: Option<&str>) -> Vec { - let mut args = vec![ - String::from("api"), - String::from("-i"), - format!( - "repos/{}/commits/{}/check-runs?per_page={PER_PAGE}", - config.repo, config.sha - ), - ]; - if let Some(etag) = etag { - args.push(String::from("-H")); - args.push(format!("If-None-Match: {etag}")); - } - args -} - /// Poll until the required checks answer. /// /// # Errors @@ -369,6 +483,16 @@ pub fn watch( writeln!(err, "::error:: pr watch: {problem}")?; return Ok(ExitCode::Usage); } + // AND THE REPOSITORY, for the reason [`Config::names_a_repository`] gives: + // an unresolved slug makes every request a 404, every 404 a could-not-look, + // and the loop below unbounded and silent. + if !config.names_a_repository() { + writeln!( + err, + "::error:: pr watch: no repository resolved, so every read would 404 — set $GH_REPO, or run this in a clone whose remote names one" + )?; + return Ok(ExitCode::Usage); + } writeln!( out, @@ -379,11 +503,27 @@ pub fn watch( let mut poll = Poll::default(); loop { let raw = read(config, poll.etag.as_deref()); - let wait_for = poll.absorb(&raw, config.interval); + let wait_for = poll.absorb(raw.as_ref(), config.interval); // EVERY poll pushes, including the ones that learned nothing: proving // the loop turned is the tick's whole job. push_progress(config, poll.polls(), poll.signature()); + // NOTHING HAS EVER ANSWERED, SO THIS IS THE INVOCATION (review of #848). + // A credential the endpoint refuses answers `401` forever, and every + // `401` is a could-not-look the poll is right to survive — so the loop + // sat at one request a second against a guaranteed refusal, announced + // one `Pending` line, and never stopped. The bound is on requests that + // were never answered AT ALL, which a forge outage mid-wait resets; + // `Poll::answered_ever` carries why that is the discriminator rather + // than a streak. + if poll.unanswered_from_the_start() >= UNANSWERED_BEFORE_REFUSING { + writeln!( + err, + "::error:: pr watch: {UNANSWERED_BEFORE_REFUSING} requests and not one was answered, so this is the credential or the repository rather than the checks — set $GH_TOKEN (or $GITHUB_TOKEN) to something this repository accepts" + )?; + return Ok(ExitCode::Usage); + } + let verdict = match checks_green::decide(poll.runs(), roster) { Ok(verdict) => verdict, // Unreachable in practice — the roster was proved usable above — but @@ -473,25 +613,32 @@ fn render(findings: &[checks_green::Finding]) -> String { /// authority over the request, the argv and the failure posture. The loop is the /// caller's; the read stays here. /// -/// An unreadable answer is the empty string, never an error: every failure to +/// An answer that could not be taken is `None`, never an error: every failure to /// reach the forge is a could-not-look that the caller's own poll must survive. +/// +/// # THE SPAWN IS GONE, AND THE REASON IT CARRIED WAS FALSE +/// +/// This read was a `gh api -i` child process under +/// `#[expect(clippy::disallowed_types)]` with the reason *"this crate carries no +/// HTTP client, so the forge's own client IS the read"*. It does — +/// [`crate::fetch`] is a vendored hyper client and [`crate::rest`] is the tier +/// over it, both already in the crate — so the annotation recorded a decision +/// nobody had the facts for. It is [`crate::rest::get`] now, the same one +/// [`crate::main_watch::read`] takes, which is what makes the two arms of a lap's +/// race one client rather than two. +/// +/// The header parsing went with it. `-i` handed back a raw response that this +/// module then parsed itself for the status, the `ETag` and `X-Poll-Interval` — +/// a second header parser beside [`crate::rest::Answer`]'s, which is exactly the +/// second authority that split does not survive. #[must_use] -pub fn read(config: &Config, etag: Option<&str>) -> String { - #[expect( - clippy::disallowed_types, - reason = "stays: reading check runs is a network call and this crate carries no HTTP client, so the forge's own client IS the read (CLOUD-1143)" - )] - let output = - crate::rules::spawn_resolving(Some(std::path::Path::new(".")), CLIENT, |program, extra| { - std::process::Command::new(program) - .args(extra) - .args(request(config, etag)) - .stderr(std::process::Stdio::null()) - .output() - }); - output.map_or_else( - |_| String::new(), - |output| String::from_utf8_lossy(&output.stdout).into_owned(), +pub fn read(config: &Config, etag: Option<&str>) -> Option { + crate::rest::get( + &format!( + "repos/{}/commits/{}/check-runs?per_page={PER_PAGE}", + config.repo, config.sha + ), + etag, ) } @@ -553,12 +700,50 @@ fn record(progress: &Progress, signal: &str, value: &str) { /// `clippy.toml`'s timer ban exists to keep out. Named `pause` rather than /// `sleep` on the public surface because what a caller is asking for is the /// interval BETWEEN asks, not a duration of its own choosing. -pub fn pause(seconds: u64) { +pub fn pause(seconds: f64) { sleep(seconds); } -fn sleep(seconds: u64) { - if seconds > 0 { +/// The longest a raced arm sleeps before it re-reads the stop flag. +/// +/// Not a second interval — the wait is still the server's, and this only bounds +/// how coarsely it is served. One second because that is the poll's own default +/// cadence, so an arm that is NOT stopped behaves exactly as it did. +const STOP_CHECK_SLICE: f64 = 1.0; + +/// [`pause`], abandoned early when `stop` is raised. +/// +/// **THE LOSER OF A RACE HELD THE WHOLE WAIT** (review of #848). Both arms of +/// `land::wait` check their stop flag at the top of the loop and then sleep the +/// full interval, and `thread::scope` joins them before the verdict can be acted +/// on — so a green answer sat unused for as long as the loser's last interval. +/// That was survivable while the interval was the poll's one second. It stopped +/// being survivable when `wait_for` started honouring a rate-limit backoff, which +/// is measured in minutes: the arm that lost would hold a finished landing for +/// the whole of somebody else's `Retry-After`. +/// +/// **One clock still, which is the constraint that shapes this.** `clippy.toml` +/// bans timers precisely so a second arm cannot grow one, and the crate's single +/// exemption lives on [`sleep`] below. So this does not add a wait — it serves +/// the same one in slices, checking between them. An arm that is never stopped +/// sleeps the identical total. +pub fn pause_until(seconds: f64, stop: &std::sync::atomic::AtomicBool) { + if !seconds.is_finite() || seconds <= 0.0 { + return; + } + let mut left = seconds; + while left > 0.0 { + if stop.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + let slice = left.min(STOP_CHECK_SLICE); + sleep(slice); + left -= slice; + } +} + +fn sleep(seconds: f64) { + if seconds > 0.0 && seconds.is_finite() { #[expect( clippy::disallowed_methods, reason = "the interval between conditional requests, and the interval is the SERVER'S: \ @@ -567,7 +752,7 @@ fn sleep(seconds: u64) { `Verdict::Green` or `Verdict::Red` both return — never on a clock \ (CLOUD-1177)" )] - std::thread::sleep(std::time::Duration::from_secs(seconds)); + std::thread::sleep(std::time::Duration::from_secs_f64(seconds)); } } @@ -596,28 +781,79 @@ mod tests { } } + /// THE PLACEHOLDER IS A GUARANTEED 404, AND THE LOOP CANNOT TELL. `read` + /// answers `None` for a 404 exactly as it does for a dropped connection, + /// because both are could-not-look — so a poll that must survive a transient + /// failure polls this one forever without saying anything. #[test] - fn a_status_line_and_two_headers_are_read_off_a_raw_response() { - let parsed = - parse_response("HTTP/2.0 200 OK\r\nETag: W/\"a\"\r\nX-Poll-Interval: 4\r\n\r\n{}\n"); - assert_eq!(parsed.status, 200); - assert_eq!(parsed.etag.as_deref(), Some("W/\"a\"")); - assert_eq!(parsed.poll_floor, Some(4)); - assert_eq!(parsed.body.trim(), "{}"); + fn a_config_naming_no_repository_is_not_pollable() { + assert!( + !config().names_a_repository(), + "the rendering fallback is not a repository a request can be built from" + ); + assert!( + !Config { + repo: String::from(" "), + ..config() + } + .names_a_repository() + ); + // ANTI-VACUITY: a resolved slug is pollable, or the guard refuses every + // wait rather than the unresolved ones. + assert!( + Config { + repo: String::from("owner/repo"), + ..config() + } + .names_a_repository() + ); } - #[test] - fn a_header_name_is_matched_whatever_its_case() { - let parsed = parse_response("HTTP/2.0 200 OK\netag: W/\"b\"\nx-poll-interval: 2\n\n{}\n"); - assert_eq!(parsed.etag.as_deref(), Some("W/\"b\"")); - assert_eq!(parsed.poll_floor, Some(2)); + /// One answer, as `rest::get` hands it back. + /// + /// **THE HEADERS ARE ALREADY OFF IT, which is the point of the port.** These + /// cases used to build a raw `-i` response and drive this module's own header + /// parser; that parser is gone, and `crate::rest::Answer` is the one reading + /// of a status, an `ETag` and a poll floor in this crate. + fn answer(status: u16, etag: Option<&str>, body: &str) -> crate::rest::Answer { + crate::rest::Answer { + status, + etag: etag.map(str::to_owned), + poll_floor: None, + backoff: None, + body: body.to_owned(), + } } + /// **A POLL THAT COULD NOT LOOK IS FOLDED, NEVER SKIPPED.** The count still + /// advances and the previous reading still stands, which is what keeps an + /// unreachable forge from making a bounded loop unbounded — the arm the raw + /// empty string used to spell, now typed. #[test] - fn a_response_with_no_status_line_is_status_zero_and_an_empty_body() { - let parsed = parse_response(""); - assert_eq!(parsed.status, 0); - assert!(parsed.body.trim().is_empty()); + fn a_poll_that_could_not_look_keeps_the_reading_and_still_counts() { + let mut poll = Poll::default(); + poll.absorb( + Some(&answer( + 200, + Some("W/\"a\""), + r#"{"check_runs":[{"status":"completed","conclusion":"success","name":"ci"}]}"#, + )), + 1, + ); + assert_eq!(poll.runs().len(), 1); + + poll.absorb(None, 1); + assert_eq!( + poll.runs().len(), + 1, + "a reading nobody took must not clear the one that stands" + ); + assert_eq!(poll.polls(), 2, "and it is still a poll that turned"); + assert_eq!( + poll.etag(), + Some("W/\"a\""), + "nor may it drop the validator, which would turn every later request unconditional" + ); } #[test] @@ -654,13 +890,17 @@ mod tests { fn a_304_keeps_the_previous_reading_instead_of_clearing_it() { let mut poll = Poll::default(); poll.absorb( - "HTTP/2.0 200 OK\nETag: W/\"a\"\n\n{\"check_runs\":[{\"status\":\"completed\",\"conclusion\":\"success\",\"name\":\"ci\"}]}\n", + Some(&answer( + 200, + Some("W/\"a\""), + r#"{"check_runs":[{"status":"completed","conclusion":"success","name":"ci"}]}"#, + )), 1, ); let before = poll.signature(); assert_eq!(poll.runs().len(), 1); - poll.absorb("HTTP/2.0 304 Not Modified\nETag: W/\"a\"\n\n", 1); + poll.absorb(Some(&answer(304, Some("W/\"a\""), "")), 1); assert_eq!(poll.runs().len(), 1, "a 304 must not clear the reading"); assert_eq!( poll.signature(), @@ -674,12 +914,20 @@ mod tests { fn a_reading_that_changes_moves_the_signature() { let mut poll = Poll::default(); poll.absorb( - "HTTP/2.0 200 OK\n\n{\"check_runs\":[{\"status\":\"in_progress\",\"conclusion\":null,\"name\":\"ci\"}]}\n", + Some(&answer( + 200, + None, + r#"{"check_runs":[{"status":"in_progress","conclusion":null,"name":"ci"}]}"#, + )), 1, ); let before = poll.signature(); poll.absorb( - "HTTP/2.0 200 OK\n\n{\"check_runs\":[{\"status\":\"completed\",\"conclusion\":\"success\",\"name\":\"ci\"}]}\n", + Some(&answer( + 200, + None, + r#"{"check_runs":[{"status":"completed","conclusion":"success","name":"ci"}]}"#, + )), 1, ); assert_ne!(poll.signature(), before); @@ -688,42 +936,91 @@ mod tests { #[test] fn an_etag_survives_a_response_that_carries_none() { let mut poll = Poll::default(); - poll.absorb("HTTP/2.0 200 OK\nETag: W/\"a\"\n\n{}\n", 1); - poll.absorb("HTTP/2.0 200 OK\n\n{}\n", 1); + poll.absorb(Some(&answer(200, Some("W/\"a\""), "{}")), 1); + poll.absorb(Some(&answer(200, None, "{}")), 1); assert_eq!(poll.etag.as_deref(), Some("W/\"a\"")); } + /// Seconds, compared to a tolerance rather than for bit equality — the + /// interval became numeric with CLOUD-390's fix and a strict `==` over `f64` + /// is a lint this crate denies for the ordinary reason. + fn is(seconds: f64, expected: f64) -> bool { + (seconds - expected).abs() < f64::EPSILON + } + #[test] fn a_server_requested_floor_is_honoured_over_a_shorter_interval() { - assert_eq!(interval_for(1, Some(3)), 3); + assert!(is(interval_for(1, Some(3.0)), 3.0)); + } + + /// **A FRACTIONAL FLOOR IS A FLOOR, and this is CLOUD-390 held at its own + /// layer.** The predecessor compared with `-gt`, which is integer-only, so a + /// fractional `X-Poll-Interval` read as "no floor asked for". The first Rust + /// port reproduced it: `poll_floor` was `Option`, and `"2.5".parse()` + /// yields `None` — byte-identical to an absent header. + #[test] + fn a_fractional_server_floor_is_not_silently_dropped() { + assert!(is(interval_for(1, Some(2.5)), 2.5)); + assert!(is(interval_for(1, Some(0.5)), 1.0), "and only upward"); } // ...and only upward. A floor read as an absolute would let a server asking // for `0` turn an affordable poll into a spin. #[test] fn a_server_floor_below_the_configured_interval_does_not_lower_it() { - assert_eq!(interval_for(5, Some(1)), 5); - assert_eq!(interval_for(5, Some(0)), 5); - assert_eq!(interval_for(5, None), 5); + assert!(is(interval_for(5, Some(1.0)), 5.0)); + assert!(is(interval_for(5, Some(0.0)), 5.0)); + assert!(is(interval_for(5, None), 5.0)); } - // The request IS part of the predicate (CLOUD-337): this endpoint returns a - // run per event per name, and nothing here fetches page 2. + /// A floor nobody can compare must not become one nobody can satisfy: `NaN` + /// loses every comparison and an infinity wins every one, so both read as no + /// floor at all — the fail-open direction the predecessor's `+0` coercion + /// took. + /// + /// **The PARSE half of this case is `crate::rest`'s now**, and moving it is + /// what found the defect this case now pins. `exchange` filters a header to a + /// finite positive, so nothing unusable arrives from the real path — and + /// `interval_for` did not guard it at all, so `INFINITY > configured` held + /// and the answer was an infinite wait. The boundary's filter was the only + /// thing standing between a public `f64` parameter and a hang. #[test] - fn the_request_asks_for_a_full_page() { - let args = request(&config(), None); + fn a_non_finite_floor_reads_as_no_floor_rather_than_as_a_hang() { + assert!(is(interval_for(5, Some(f64::NAN)), 5.0)); + assert!(is(interval_for(5, Some(f64::INFINITY)), 5.0)); + assert!(is(interval_for(5, Some(-1.0)), 5.0)); + } + + /// **AND A FINITE FLOOR IS NOT THEREBY A REASONABLE ONE.** The value comes + /// off the wire, so an endpoint answering a day — or a proxy inventing one — + /// would turn this wait into a hang indistinguishable from a slow bot. The + /// pair: at the ceiling the floor is honoured exactly, above it clamps. + #[test] + fn a_floor_beyond_the_ceiling_is_clamped_and_one_at_it_is_honoured() { + assert!(is(interval_for(1, Some(MAX_FLOOR)), MAX_FLOOR)); + assert!(is(interval_for(1, Some(MAX_FLOOR * 100.0)), MAX_FLOOR)); + + // AND THE CEILING NEVER CUTS BELOW WHAT THE CALLER CONFIGURED. With an + // interval above `MAX_FLOOR`, a raw `min` answered 300 for a poll told + // to wait 600 — the ceiling reducing the caller's own floor, which is + // the one direction this function may never take. Found in review. + assert!( + is(interval_for(600, Some(700.0)), 600.0), + "a configured interval above the ceiling is never reduced by it" + ); assert!( - args.iter().any(|arg| arg.contains("per_page=100")), - "{args:?}" + is(interval_for(600, None), 600.0), + "and the same interval with no floor at all is unchanged" ); + // And an ordinary floor is untouched, so the clamp discriminates. + assert!(is(interval_for(1, Some(4.0)), 4.0)); } + // The request IS part of the predicate (CLOUD-337): this endpoint returns a + // run per event per name, and nothing here fetches page 2. #[test] - fn a_held_etag_becomes_a_conditional_header_and_nothing_else_moves() { - let plain = request(&config(), None); - let conditional = request(&config(), Some("W/\"a\"")); - assert_eq!(conditional.len(), plain.len() + 2); - assert!(conditional.contains(&String::from("If-None-Match: W/\"a\""))); + fn the_request_asks_for_a_full_page() { + assert_eq!(PER_PAGE, 100); } #[test] diff --git a/crates/batten/src/provision.rs b/crates/batten/src/provision.rs index e9bc3393e..2dfb85d55 100644 --- a/crates/batten/src/provision.rs +++ b/crates/batten/src/provision.rs @@ -648,6 +648,19 @@ fn freshness_of(entry: &Provision, cache_root: &Path) -> Result { if !relaunches_a_present_interpreter(&linked) { return Ok(Freshness::Missing); } + // AND THE DECLARED ENVIRONMENT IS PART OF WHAT IS CACHED, which is the + // other half of CLOUD-1455 and the one a byte-identical artifact hides. + // Every other input to this verdict lives in the cache — the artifact's + // digest, the binary's presence — but `[[provision.env]]` reaches the + // tool only through the launcher's own second line. Edit a row, and a + // warm cache reports `Fresh`, `apply` returns `AlreadyFresh` before + // reaching `install`, and the tool keeps running with the environment + // the manifest USED to declare. That is the same second-run shape the + // link check above records: the change never appears on the run that + // makes it. + if !launcher_declares(&linked, entry) { + return Ok(Freshness::Missing); + } } let cached = match fs::read(dir.join(ARTIFACT)) { Ok(bytes) => bytes, @@ -747,8 +760,37 @@ fn install(entry: &Provision, cache_root: &Path, bytes: &[u8]) -> Result<()> { Unpack::TarGz => extract(bytes, &entry.binary)?, }; let cached = bin_dir.join(&entry.binary); - fs::write(&cached, &binary).context("write the provisioned binary")?; - make_executable(&cached)?; + // **STAGED AND RENAMED, NEVER WRITTEN IN PLACE** (CLOUD-1586), which is + // [`link_onto_path`]'s discipline applied to the site that needed it just as + // much. Writing over this path returns `ETXTBSY` — "Text file busy" — the + // moment anything is executing it, and something usually is: the launcher's + // `#!` line names this exact file, so every `provision-exec` holds it open, + // and in this repository the adjudicating hook runs on every tool call. + // + // A rename does not have that problem, and the reason is worth stating + // because it looks like luck: the running process holds the old INODE, and + // rename only moves the name. The old bytes stay valid for whoever is + // mid-execution, the next execution finds the new ones, and no reader ever + // observes a half-written binary. + // + // Measured on this branch: `batten-check` and two `land` laps died here with + // `write the provisioned binary / Text file busy`, which reads as a + // filesystem fault and is really a self-collision. + // + // Keyed on the pid and dot-prefixed for `link_onto_path`'s reasons exactly — + // two provisions running at once must not write each other's staging file. + let staged = bin_dir.join(format!(".{}.{}.tmp", entry.binary, std::process::id())); + let written = fs::write(&staged, &binary) + .context("write the provisioned binary") + .and_then(|()| make_executable(&staged)); + if let Err(err) = written { + let _ = fs::remove_file(&staged); + return Err(err); + } + if let Err(err) = fs::rename(&staged, &cached) { + let _ = fs::remove_file(&staged); + return Err(err).context("move the provisioned binary into place"); + } // BEFORE the artifact, so the same crash window that leaves the entry // reading `missing` also leaves the link unmade. A fresh entry whose link // never landed would be the silent half-install this ordering exists to @@ -792,16 +834,49 @@ const LAUNCHER_VERB: &str = "provision-exec"; /// **An entry declaring [`Provision::env`] gets a LAUNCHER instead**, and the /// copy above is what it launches. See [`launcher`] for the shape and for why the /// environment cannot be baked in at this point. +/// # WRITTEN BESIDE AND RENAMED OVER, BECAUSE THE TARGET MAY BE RUNNING +/// +/// `fs::write` truncates in place, and the kernel refuses that for a file some +/// process is executing: `ETXTBSY`, *"Text file busy"*. The thing on `PATH` is +/// exactly the thing a session runs, so the target being busy is the ORDINARY +/// case here rather than a rare one — a `batten` on `PATH` re-provisioning while +/// a task runs it is a session doing what it is supposed to do. +/// +/// This was invisible while `freshness_of` never compared the declared +/// environment: a warm cache answered `Fresh`, `apply` returned `AlreadyFresh` +/// before reaching `install`, and the write that would have failed never +/// happened. Making the launcher's environment part of the freshness verdict +/// (CLOUD-1455) is what made the re-link real, and the re-link is what found +/// this. Both are the same second-run shape the link check records: the failure +/// needs a warm cache to appear at all. +/// +/// `rename` over a busy target succeeds — it swaps the directory entry and the +/// running process keeps its own open inode — so the temp file is made +/// executable BEFORE the rename and the file on `PATH` is never a moment +/// non-executable. Same directory, so it cannot cross a filesystem. fn link_onto_path(entry: &Provision, dest: &str, cached: &Path, binary: &[u8]) -> Result<()> { let dir = expand_home(dest)?; fs::create_dir_all(&dir).context("create the linked binary's directory")?; let path = dir.join(&entry.binary); - if entry.env.is_empty() { - fs::write(&path, binary).context("write the linked binary")?; + // Keyed on the PID so two provisions running at once cannot write each + // other's staging file, and dot-prefixed so a directory listing of `PATH` + // does not offer it as a command. + let staged = dir.join(format!(".{}.{}.tmp", entry.binary, std::process::id())); + let written = if entry.env.is_empty() { + fs::write(&staged, binary).context("write the linked binary") } else { - fs::write(&path, launcher(entry, cached)?).context("write the linked launcher")?; + launcher(entry, cached) + .and_then(|bytes| fs::write(&staged, bytes).context("write the linked launcher")) + }; + if let Err(err) = written.and_then(|()| make_executable(&staged)) { + let _ = fs::remove_file(&staged); + return Err(err); + } + if let Err(err) = fs::rename(&staged, &path) { + let _ = fs::remove_file(&staged); + return Err(err).context("move the linked binary into place"); } - make_executable(&path) + Ok(()) } /// The bytes of the launcher that stands in for a tool needing an environment. @@ -908,6 +983,33 @@ fn relaunches_a_present_interpreter(linked: &Path) -> bool { } } +/// Does the launcher at `linked` carry the environment rules `entry` declares? +/// +/// The rules only, never the whole file: the `#!` line names the batten that ran +/// the last `apply`, and comparing bytes would call every launcher stale as soon +/// as that binary moved — churn over a question +/// [`relaunches_a_present_interpreter`] already answers on its own terms. +/// +/// Unreadable is `true` for the same reason its sibling gives: presence was +/// answered above, and re-linking does not fix a permissions failure. A body +/// that will not parse is `false`, because that is precisely the launcher +/// [`exec_launcher`] refuses and tells the operator to rewrite. +fn launcher_declares(linked: &Path, entry: &Provision) -> bool { + let Ok(bytes) = fs::read(linked) else { + return true; + }; + let Some(body) = bytes.split_once_newline() else { + return false; + }; + let Ok(carried) = serde_json::from_slice::(body) else { + return false; + }; + let Ok(declared) = serde_json::to_value(&entry.env) else { + return false; + }; + carried.get("env") == Some(&declared) +} + /// Become the tool a launcher stands for, with the environment its row declares. /// /// `script` is the launcher's own path, which the kernel supplies after the `#!` @@ -2108,6 +2210,136 @@ mod tests { } } + /// THE DECLARED ENVIRONMENT IS PART OF WHAT IS CACHED (CLOUD-1455's other + /// half). It reaches the tool only through the launcher's second line, so a + /// warm cache holding a byte-identical artifact reported `Fresh` after an + /// `[[provision.env]]` edit, `apply` returned `AlreadyFresh` before reaching + /// `install`, and the tool kept running with the environment the manifest + /// used to declare. + /// + /// Over `launcher_declares` rather than through `freshness_of`, because the + /// premise this asserts is the launcher's own bytes — `rust.md`'s rule that a + /// test must be shown able to fail rather than depending on a condition the + /// sandbox cannot create. + #[test] + fn a_launcher_carrying_another_environment_is_not_fresh() { + let rule = |name: &str| ProvisionEnv { + name: name.to_owned(), + prepend_list: Vec::new(), + from_first_set: vec![String::from("SOURCE")], + when_trust_names: None, + reject_prefix: None, + unset: false, + }; + let mut declared = entry("tool", &"a".repeat(64)); + declared.env = vec![rule("TOKEN")]; + + let dir = std::env::temp_dir().join(format!("batten-launcher-env-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("a scratch directory"); + let linked = dir.join("tool"); + std::fs::write( + &linked, + launcher(&declared, Path::new("/cache/bin/tool")).expect("bytes"), + ) + .expect("write the launcher"); + + assert!( + launcher_declares(&linked, &declared), + "the launcher this entry just wrote carries this entry's environment" + ); + + let mut edited = declared.clone(); + edited.env = vec![rule("OTHER_TOKEN")]; + assert!( + !launcher_declares(&linked, &edited), + "an edited row must reach the tool, and it only can if the cache reports stale" + ); + + // A body that will not parse is the launcher `exec_launcher` refuses and + // tells the operator to rewrite, so it is stale rather than fresh. + std::fs::write(&linked, b"#!/x provision-exec\nnot json\n").expect("write a broken one"); + assert!(!launcher_declares(&linked, &declared)); + } + + /// THE RE-LINK MUST SURVIVE A TARGET THAT IS IN USE, which is what making + /// the declared environment part of the freshness verdict made reachable. + /// + /// **UNIX-ONLY, BECAUSE THE SUBJECT IS.** `ETXTBSY` is a Unix refusal and + /// the inode this asserts over is a Unix concept — `std::os::unix` does not + /// exist on the Windows target at all, so a case reaching for it does not + /// merely fail there, it does not TYPE-CHECK (`cross-check` caught exactly + /// that). Windows refuses a busy target too and refuses it differently; the + /// rename-over remedy is what both want, and asserting the Unix mechanism + /// is honest about which one is being shown. + /// + /// `fs::write` truncates in place and the kernel refuses that for a file + /// some process is EXECUTING — `ETXTBSY`. The thing on `PATH` is exactly + /// what a session runs, so a busy target is the ORDINARY case here. It was + /// unreachable while a warm cache always answered `Fresh`: `apply` returned + /// `AlreadyFresh` before `install`, and the write that would have failed + /// never happened. + /// + /// **THE INODE IS THE ASSERTION, and it is what makes this testable at + /// all.** This sandbox cannot make a file execute-busy on demand, so + /// asserting "the write succeeded over a busy target" would assert a + /// premise that was never created — `rust.md`'s rule. What CAN be pinned is + /// the property `ETXTBSY` actually needs: the bytes reach a DIFFERENT inode + /// and are renamed over. An in-place write leaves the target's own inode + /// holding them, and would fail the moment that inode were busy. + #[cfg(unix)] + #[test] + fn a_relink_replaces_the_target_rather_than_writing_through_it() { + let rule = |name: &str| ProvisionEnv { + name: name.to_owned(), + prepend_list: Vec::new(), + from_first_set: vec![String::from("SOURCE")], + when_trust_names: None, + reject_prefix: None, + unset: false, + }; + let mut declared = entry("tool", &"a".repeat(64)); + declared.env = vec![rule("TOKEN")]; + + let dir = std::env::temp_dir().join(format!("batten-relink-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("a scratch directory"); + let dest = dir.display().to_string(); + link_onto_path(&declared, &dest, Path::new("/cache/bin/tool"), &[]).expect("first link"); + + let linked = dir.join("tool"); + let inode = |path: &Path| { + std::fs::metadata(path) + .map(|meta| std::os::unix::fs::MetadataExt::ino(&meta)) + .expect("the target has an inode") + }; + let before = inode(&linked); + + let mut relinked = declared.clone(); + relinked.env = vec![rule("OTHER_TOKEN")]; + link_onto_path(&relinked, &dest, Path::new("/cache/bin/tool"), &[]).expect("the re-link"); + assert_ne!( + before, + inode(&linked), + "the bytes went through the target's own inode, so a target being \ + executed would have refused them with ETXTBSY" + ); + assert!( + launcher_declares(&linked, &relinked), + "and the file on PATH carries what the re-link declared" + ); + + // NOTHING IS LEFT BESIDE IT: the directory is on `PATH`, so a surviving + // staging file is a command a shell would offer. + let strays: Vec = std::fs::read_dir(&dir) + .expect("the directory reads") + .filter_map(std::result::Result::ok) + .map(|found| found.file_name().to_string_lossy().into_owned()) + .filter(|name| name != "tool") + .collect(); + assert!(strays.is_empty(), "staging files left behind: {strays:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + /// The same entry spelled as a platform table instead of a single url. fn platform_entry(name: &str, rows: &[(&str, &str)]) -> Provision { let mut entry = entry(name, &"a".repeat(64)); diff --git a/crates/batten/src/receipt.rs b/crates/batten/src/receipt.rs index ab84be2dc..a26486190 100644 --- a/crates/batten/src/receipt.rs +++ b/crates/batten/src/receipt.rs @@ -387,6 +387,7 @@ impl Validity { #[must_use] pub fn validity( receipt: Option<&Statement>, + expected: &str, head: &str, current_main: &str, git_dir: &str, @@ -397,6 +398,22 @@ pub fn validity( let Some(subject) = receipt.subject.first() else { return Validity::Missing; }; + // THE RECEIPT'S CONTENT IS BOUND TO ITS NAME, and it was not before. Every + // caller selects a receipt by PATH — `receipt_path` derives one from a scope + // fingerprint of the check's name — and then asked only about the checkout, + // the head and the trunk. Which check the receipt actually claims to record, + // and whether it records a PASS, were never compared: the type documents + // `conclusion` as *"always `CONCLUSION_PASS`: a failed check writes no + // receipt"*, and that invariant was asserted in prose and enforced nowhere. + // + // So a receipt for another check, or one carrying any other conclusion, made + // `receipt verified` exit `0` — a gate answering about evidence it never read + // the claim of. `Refuted` rather than `Missing`, because the file IS there and + // the remedy is not *run the step again*: something wrote a receipt that does + // not say what the reader needs it to say. + if receipt.predicate.check != expected || receipt.predicate.conclusion != CONCLUSION_PASS { + return Validity::Refuted; + } if receipt.predicate.recorded_git_dir != git_dir { return Validity::Missing; } @@ -473,6 +490,33 @@ fn validate_check_name(check: &str) -> Result<()> { } } +/// Refuse a `[receipt] verified_by` naming a check no receipt can ever be +/// written for. +/// +/// # AT LOAD, BECAUSE THE ALTERNATIVE IS A CONFIG THAT IS PERMANENTLY UNUSABLE +/// +/// `receipt record` and `receipt status` already refuse these names, and nothing +/// held the config table to the same rule (review of #848). So +/// `verified_by = ["fmt check"]` loaded clean, `receipt record "fmt check"` +/// exited `1` with *"not a valid identifier"*, no receipt could ever exist, and +/// [`run_verified`] — which validates nothing and hashes the name straight into +/// a path — reported `NOT verified: fmt check missing` forever. The +/// refusal pointed at a missing receipt rather than at the unwritable name that +/// made it missing, which is the inert-typo class every other declared table in +/// `config::validate_tables` is refused at load for. +/// +/// Fail-closed either way; what changes is that the diagnosis names the cause. +/// +/// # Errors +/// +/// [`crate::UsageError`] naming the first offending check. +pub fn validate_verified_by(checks: &[String]) -> Result<()> { + for check in checks { + validate_check_name(check)?; + } + Ok(()) +} + /// The canonical receipt path for a check: `/receipts/.json`. fn receipt_path(repo_root: &str, check: &str) -> Result { let fingerprint = identity::scope_fingerprint(check, RECEIPT_SCOPE_KEY); @@ -866,7 +910,13 @@ pub(crate) fn verdicts( let statement = receipt_path(&facts.repo_root, check) .ok() .and_then(|path| load_statement(&path)); - validity(statement.as_ref(), &facts.head, &facts.main, &facts.git_dir) + validity( + statement.as_ref(), + check, + &facts.head, + &facts.main, + &facts.git_dir, + ) } ReceiptKey::Branch => { branch.as_ref().map_or(Validity::Missing, |(branch, own)| { @@ -1634,7 +1684,13 @@ pub fn run_status( let statement = load_statement(&receipt_path(&facts.repo_root, check)?); ( facts.head.clone(), - validity(statement.as_ref(), &facts.head, &facts.main, &facts.git_dir), + validity( + statement.as_ref(), + check, + &facts.head, + &facts.main, + &facts.git_dir, + ), ) } ReceiptKey::Branch => { @@ -1672,6 +1728,195 @@ pub fn run_status( }) } +/// The checks a head must carry a valid receipt for, when a consumer declares +/// none. +/// +/// **These are THIS repository's task names, and that is stated rather than +/// hidden.** `verify` says the tree passed its gate; `linear-check` says the +/// branch was linear on the trunk it was measured against, and records WHICH +/// trunk, so a moved `origin/main` expires it — a head carrying only the first +/// has been proven against a base that may no longer exist. +pub(crate) const VERIFIED_BY: [&str; 2] = ["verify", "linear-check"]; + +/// The checks `verified` requires, from `[receipt] verified_by` or the default. +/// +/// # A DEFAULT RATHER THAN A REFUSAL, AND THE REFUSAL WAS TRIED FIRST +/// +/// The set moved into config because two task names compiled into the core are a +/// different adopter's problem, and the first draft made an undeclared one a +/// usage error — nothing is unverified when nothing is required, so passing +/// would be a false clean. +/// +/// That broke `tests/tree-clean.bats`, whose fixture is a throwaway repository +/// with no `batten.toml` and no interest in receipts: it asserts that a dirty +/// tree leaves HEAD unverified, and got the refusal instead of the verdict. That +/// suite's subject SURVIVES, so under `policy/shell-retirement.rego` the file +/// has exactly two landable shapes and neither is *edit the fixture* — the gate +/// is right, and a design that can only land by editing around it is the wrong +/// design. +/// +/// So the default stands and config OVERRIDES it, which is strictly more general +/// than the `const` this replaces and costs an adopter nothing: our names over +/// their receipts resolve to `Missing`, so `verified` refuses loudly on their +/// first run rather than passing quietly. A wrong answer that announces itself +/// is the acceptable failure here; a silent one is not. +/// # NO CONFIG AND A CONFIG THAT WILL NOT LOAD ARE DIFFERENT ANSWERS +/// +/// This was one `.ok()` over both, which is the could-not-look collapse the rest +/// of this crate refuses (review of #848). `load_site` reports an ABSENT +/// `batten.toml` as an error too, so the fixture repository that legitimately +/// has none took the same branch as a consumer whose config is malformed — and +/// the substituted default is a SUBSET of what such a consumer may have +/// declared. A `verified_by = ["verify", "linear-check", "audit"]` that failed +/// to parse would be answered by asking about the first two, and a head proven +/// on partial evidence would be reported verified. That is the one direction the +/// doc above calls unacceptable: a wrong answer that announces itself is fine, +/// a silent one is not. +/// +/// Presence is asked first, so an absent site keeps the default and a present +/// one that will not load propagates. +/// +/// # Errors +/// +/// Propagates [`crate::config::load_site`] where the file is there and unreadable. +fn verified_by(root: &Path) -> Result> { + let site = crate::config::authority_site(root, None); + let declared = if site.path.is_file() { + crate::config::load_site(&site)? + .0 + .receipt + .map(|receipt| receipt.verified_by) + } else { + None + }; + Ok(declared + .filter(|declared| !declared.is_empty()) + .unwrap_or_else(|| { + VERIFIED_BY + .iter() + .map(|check| (*check).to_owned()) + .collect() + })) +} + +/// Is HEAD verified — every check in [`VERIFIED_BY`] valid against this commit? +/// +/// # Why the composition is a verb rather than two calls +/// +/// The predecessor was a shell gate, and it existed because a caller had read +/// the wrong thing: `mise run verify 2>&1 | tail -60` exits with the PIPE's +/// status, so a branch `linear-check` had rejected reported success and the zero +/// was acted on. A verb that answers about the SET cannot be half-asked, where +/// two [`run_status`] calls can be — one of them forgotten, and a head reported +/// verified on half its evidence. +/// +/// # Exits +/// +/// `0` verified. `2` not verified — a receipt is missing or expired, which is a +/// verdict about this repository's state. `1` the checkout cannot be judged at +/// all (not a repository, unresolvable HEAD or `origin/main`), which is a +/// statement about the clone and never about the work. +/// +/// **The predecessor spelled those `1` and `2` the other way round**, and the +/// engine's table wins (non-negotiable rule 5): `2` is the policy verdict +/// everywhere, and `1`/`3` are the only codes a Batten failure produces. The +/// `mise` task that keeps the old name translates, so every caller reads what it +/// always read — the shape CLOUD-1170's shims already took. +/// +/// # Errors +/// +/// As [`run_status`]: a checkout that cannot be judged is a [`UsageError`], and +/// an unwritable stream is an internal error. +pub fn run_verified(out: &mut dyn Write) -> Result { + let facts = repo_facts()?; + // `[receipt] verified_by`, or this repository's own pair where a consumer + // declares none — see `verified_by`, which records why the undeclared case + // takes a default rather than the refusal it was first written as. Never + // empty, so the "verified having asked about nothing" arm is unreachable by + // construction rather than guarded here. + // + // **THE REPO ROOT, NOT THE PROCESS CWD** (review of #848). This read + // `Path::new(".")` while the very next line uses `facts.repo_root`, and + // `config::authority_site` performs no directory walk by design — so run from + // a subdirectory it found no `batten.toml`, silently fell through to the + // compiled default, and told a consumer who had declared + // `verified_by = ["ci", "fmt"]` that they were "NOT verified: verify + // missing" about checks they never named, while the ones they did name were + // never asked about. It passes in this repository only because the two sets + // happen to coincide. + // THE WORKING TREE'S ROOT, NOT THE REPOSITORY'S — and the difference decides + // the answer in a linked worktree, which is where agents work (review of + // #848). `git::repo_root` resolves to the parent of the COMMON git dir on + // purpose, so it is the MAIN checkout; anchoring a committed config there + // reads a different branch's `batten.toml` than the one being judged. A + // worktree tightening `verified_by` was judged against the main checkout's + // looser set and a head carrying half its receipts exited 0. + // + // `facts.repo_root` is still right for `receipt_path` below: a receipt store + // is repository state and one store across every worktree is the property + // CLOUD-164 bought. Config is the working tree's; state is the + // repository's. + // + // This read `Path::new(".")` before either, which found no `batten.toml` at + // all from a subdirectory — `authority_site` performs no directory walk by + // design. `worktree_root` walks up, so both defects close together. + // + // **AND IT MUST WALK FROM THE CWD, NOT FROM `facts.repo_root`** (CLOUD-1586, + // review of #848). Handing it the repo root made this a NO-OP in the one + // case it was written for: `facts.repo_root` is already `repo_root`'s answer + // (line 455, deliberately the main checkout, because receipt STATE is + // repository-wide), and walking up from the main checkout's root can only + // ever reach the main checkout. So the call resolved the very root the + // paragraph above rules out, while reading as though it had fixed it. + // + // The cwd is the anchor the walk needs and it reintroduces nothing: the + // subdirectory defect was `authority_site`'s missing walk, which + // `worktree_root` supplies, so starting inside the worktree being judged + // finds THAT worktree's root from any depth. The fallback stays + // `facts.repo_root` — the previous behaviour — because a root that will not + // resolve must not turn a judgement into a second failure. + let authority = crate::git::worktree_root(Path::new(".")) + .unwrap_or_else(|_| std::path::PathBuf::from(&facts.repo_root)); + let required = verified_by(&authority)?; + let mut unverified = Vec::new(); + for check in &required { + let statement = load_statement(&receipt_path(&facts.repo_root, check)?); + let verdict = validity( + statement.as_ref(), + check, + &facts.head, + &facts.main, + &facts.git_dir, + ); + if verdict != Validity::Valid { + unverified.push((check.clone(), verdict)); + } + } + if unverified.is_empty() { + writeln!( + out, + "verified: HEAD {} carries every receipt, linear on {}", + facts.head, facts.main + )?; + return Ok(ExitCode::Success); + } + // POINTER-ONLY: the check's name and its verdict token, never a receipt's + // contents. And "NOT verified" is the predecessor's own wording, kept rather + // than improved because a SURVIVING suite reads for it — + // `tests/tree-clean.bats` proves a dirty tree leaves HEAD unverified by + // looking for exactly this string, and that suite's subject does not retire + // here, so the string is part of the contract this port must conserve. + for (check, verdict) in &unverified { + writeln!( + out, + "NOT verified: {} {check} {}", + facts.head, + verdict.as_str() + )?; + } + Ok(ExitCode::Violation) +} + /// Whether the receipt at `path` records something other than what `bound` /// requires (CLOUD-1100). /// @@ -1821,30 +2066,71 @@ mod tests { fn validity_covers_all_four_verdicts() { let receipt = statement("head1", "main1", "/repo/.git"); assert_eq!( - validity(None, "head1", "main1", "/repo/.git"), + validity(None, "verify", "head1", "main1", "/repo/.git"), Validity::Missing ); assert_eq!( - validity(Some(&receipt), "head1", "main1", "/repo/.git"), + validity(Some(&receipt), "verify", "head1", "main1", "/repo/.git"), Validity::Valid ); assert_eq!( - validity(Some(&receipt), "head2", "main1", "/repo/.git"), + validity(Some(&receipt), "verify", "head2", "main1", "/repo/.git"), Validity::StaleHead ); assert_eq!( - validity(Some(&receipt), "head1", "main2", "/repo/.git"), + validity(Some(&receipt), "verify", "head1", "main2", "/repo/.git"), Validity::StaleMain ); } + /// **THE RECEIPT'S CLAIM IS READ, NOT JUST ITS PATH.** + /// + /// Every caller selects a receipt by a path derived from the check's name and + /// then asked only about the checkout, the head and the trunk — so a receipt + /// recording ANOTHER check, or one carrying any conclusion at all, made + /// `receipt verified` exit `0`. The type's own doc says `conclusion` is + /// *"always `CONCLUSION_PASS`: a failed check writes no receipt"*; that was + /// prose with nothing behind it. + /// + /// `Refuted`, not `Missing`: the file is there and the remedy is not *run the + /// step again*. + #[test] + fn a_receipt_claiming_another_check_or_no_pass_is_refuted() { + let receipt = statement("head1", "main1", "/repo/.git"); + assert_eq!( + validity( + Some(&receipt), + "linear-check", + "head1", + "main1", + "/repo/.git" + ), + Validity::Refuted, + "this receipt records `verify`, whatever path it was found at" + ); + + let mut failed = statement("head1", "main1", "/repo/.git"); + failed.predicate.conclusion = "fail".to_owned(); + assert_eq!( + validity(Some(&failed), "verify", "head1", "main1", "/repo/.git"), + Validity::Refuted + ); + + // AND THE PAIR THAT MAKES IT DISCRIMINATE: the matching receipt is still + // valid, so a binding that refused everything would not satisfy this. + assert_eq!( + validity(Some(&receipt), "verify", "head1", "main1", "/repo/.git"), + Validity::Valid + ); + } + // -- A receipt from another checkout is missing here, before any staleness // judgement: one worktree's receipt cannot authorise another. -- #[test] fn a_foreign_checkouts_receipt_is_missing_not_stale() { let receipt = statement("head2", "main2", "/elsewhere/.git"); assert_eq!( - validity(Some(&receipt), "head1", "main1", "/repo/.git"), + validity(Some(&receipt), "verify", "head1", "main1", "/repo/.git"), Validity::Missing ); } @@ -1854,7 +2140,7 @@ mod tests { let mut receipt = statement("head1", "main1", "/repo/.git"); receipt.subject.clear(); assert_eq!( - validity(Some(&receipt), "head1", "main1", "/repo/.git"), + validity(Some(&receipt), "verify", "head1", "main1", "/repo/.git"), Validity::Missing ); } @@ -1983,7 +2269,7 @@ mod tests { "a predicate missing configEpoch is unusable" ); assert_eq!( - validity(loaded.as_ref(), "head1", "main1", "/repo/.git"), + validity(loaded.as_ref(), "verify", "head1", "main1", "/repo/.git"), Validity::Missing, "and the verdict denies rather than passing over an unrecorded surface" ); @@ -2471,4 +2757,33 @@ mod tests { identity::scope_fingerprint("verify", RECEIPT_SCOPE_KEY) ); } + + /// NO CONFIG AND A CONFIG THAT WILL NOT LOAD ARE DIFFERENT ANSWERS. + /// + /// One `.ok()` covered both, and the substituted default is a SUBSET of what + /// a consumer may have declared — so a `verified_by` that failed to parse + /// was answered by asking about our two names, and a head proven on partial + /// evidence reported verified. + #[test] + fn an_unreadable_config_is_could_not_look_and_an_absent_one_takes_the_default() { + let dir = std::env::temp_dir().join(format!("batten-verified-by-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("a scratch directory"); + + // ABSENT: the fixture repository with no `batten.toml` at all, which is + // the case the default exists for. + assert_eq!( + verified_by(&dir).expect("an absent config is not a failure"), + VERIFIED_BY.map(str::to_owned).to_vec() + ); + + // PRESENT AND UNREADABLE: could-not-look, reported rather than defaulted. + std::fs::write(dir.join("batten.toml"), "this is not toml [[[").expect("write"); + assert!( + verified_by(&dir).is_err(), + "a config that will not load must not be answered with the compiled default" + ); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/batten/src/recorder.rs b/crates/batten/src/recorder.rs index f2b38d17f..c1e0f59d2 100644 --- a/crates/batten/src/recorder.rs +++ b/crates/batten/src/recorder.rs @@ -360,6 +360,18 @@ pub enum Value { pub enum Ask { /// [`crate::ready`] — the Ready-block grammar over a tracker payload. Ready, + /// [`crate::lease::Asked::Status`] — does the landing lease authorise this + /// clone right now? Answers in the engine's exit table, so the consumer's + /// `status` map reads `0` authorising / `2` held elsewhere and leaves `3` + /// unmapped, which is where the lease's fail-open asymmetry lives. + /// + /// **Named for the practice rather than for a program** (non-negotiable rule + /// 1): "a landing lease" is a word about trunk-based landing, where + /// `land-lock` was one repository's file name. + LeaseStatus, + /// [`crate::lease::Asked::Successor`] — which branch the live holder + /// admitted behind it, on stdout, or nothing where no reservation stands. + LeaseSuccessor, } /// What a recorder reads back from a program it ran. @@ -459,6 +471,24 @@ pub fn satisfied(declared: &Declared, context: &Context<'_>) -> bool { /// are missing. Reading it the other way round would file a could-not-look for /// every unrelated tool call in the session, which is the noise that gets a /// channel ignored. +/// +/// # A ROW WITH NO INPUT SELECTOR IS SELECTED BY EVERY CALL OF ITS TOOL +/// +/// Both selector lists are `all` over a possibly-empty collection, so an empty +/// one holds vacuously. That is correct and intended where the tool's identity +/// IS the question — [`Declared::requires_input_matching`] says so, and a board +/// write is any `save_issue` at all — and it is a trap for a GENERAL RUNNER, +/// where every call in the session arrives under one tool name. Such a row does +/// not merely over-record: it reports [`Outcome::Blocked`] for every unrelated +/// command that lacks its result paths, which is a could-not-look filed against +/// calls the author never meant, and the noise the ordering above exists to +/// keep out arriving by the one route ordering cannot stop. +/// +/// **Stated rather than gated, and the gap is named rather than papered over** +/// (non-negotiable rule 2's honest half). A load-time refusal would have to know +/// which tool names are runners, and that is a consumer fact — a core carrying +/// the list would violate rule 1. The declaration a consumer CAN make is +/// `requires_input_matching`, and every row here over a runner carries one. #[must_use] pub fn outcome(declared: &Declared, context: &Context<'_>) -> Outcome { let selected = declared @@ -577,6 +607,15 @@ pub struct Context<'a> { /// resolved inside [`evaluate`] for this struct's whole reason: the function /// stays a pure function of its inputs and needs no world to test. pub branch: Option<&'a str>, + /// The instant a column that grades a lifetime compares against. + /// + /// **The clock is the BOUNDARY'S, never the decision's**, which is the same + /// rule `.claude/rules/policy-modules.md` states for why no policy module + /// sees a timestamp and `crate::rules::Rule::max_age` states for a receipt's + /// age. Carried here rather than read inside [`evaluate`] for [`Context`]'s + /// whole reason — the function stays a pure function of its inputs — and it + /// is what makes two evaluations over one lease produce one answer. + pub now: i64, } /// Evaluate one expression, or `None` where it could not be resolved. @@ -645,6 +684,22 @@ pub fn evaluate(value: &Value, context: &Context<'_>) -> Option crate::ready::adjudicate(context.grammar?, &payload, context.root)?, + // THE PAYLOAD IS UNREAD ON BOTH LEASE ARMS, and that is a + // property of the subject rather than an oversight: the lease + // lives on a remote ref, so there is nothing about a tool result + // for it to judge. `stdin` is still evaluated above, because a + // column whose expression could not resolve must record + // could-not-look rather than quietly answering about the lease. + Ask::LeaseStatus => crate::lease::adjudicate( + crate::lease::Asked::Status, + context.root, + context.now, + )?, + Ask::LeaseSuccessor => crate::lease::adjudicate( + crate::lease::Asked::Successor, + context.root, + context.now, + )?, }; read_back(read, status, &out) } @@ -1237,6 +1292,12 @@ mod outcome_tests { patterns: &patterns, programs: &programs, grammar: None, + // FIXED, because this row grades no lifetime. `now` reached + // `Context` on trunk while this case was on a branch, and the + // rebase merged the two without a textual conflict; the value + // is unread here, so a clock would only make the case + // non-deterministic for nothing. + now: 0, }; let _ = &context; @@ -1248,6 +1309,7 @@ mod outcome_tests { patterns: &patterns, programs: &programs, grammar: None, + now: 0, }; assert_eq!( outcome(&row, &blocked), @@ -1263,6 +1325,7 @@ mod outcome_tests { patterns: &patterns, programs: &programs, grammar: None, + now: 0, }; assert_eq!( outcome(&row, &missed), @@ -1279,6 +1342,7 @@ mod outcome_tests { patterns: &patterns, programs: &programs, grammar: None, + now: 0, }; assert_eq!(outcome(&row, &applies), Outcome::Applies); assert!(satisfied(&row, &applies)); diff --git a/crates/batten/src/rest.rs b/crates/batten/src/rest.rs new file mode 100644 index 000000000..2eacc099f --- /dev/null +++ b/crates/batten/src/rest.rs @@ -0,0 +1,644 @@ +//! The forge's REST tier, IN PROCESS — one client, one credential reader, no +//! spawn (CLOUD-1338). +//! +//! **Named `rest` rather than `forge`, and the near-miss is worth the line.** +//! [`crate::forge`] is a different subject entirely: it reads a VERDICT RECORD a +//! producer wrote outside the engine, keyed by sha, off disk. Drafting this as +//! `forge.rs` overwrote it wholesale — the two nouns are one word apart and the +//! subjects share nothing, which is exactly when a name collision is silent. +//! +//! # This module exists because four sites claimed a client that was already here +//! +//! Every spawn it replaces carried the same `#[expect(clippy::disallowed_types)]` +//! reason: *"this crate carries no HTTP client that resolves a forge +//! credential — so the forge's own client IS the call."* That sentence was +//! **false when it was written, four times**, and one of the four was written in +//! `lease.rs`, eighty lines from the credential reader [`credential`] is — +//! a function that reads `GH_TOKEN`, falls back to `GITHUB_TOKEN`, and attaches +//! `Authorization: Bearer` to a [`crate::fetch`] call. +//! +//! [`crate::fetch`] is hyper plus hyper-rustls, vendored under CLOUD-745, with +//! explicit connect and total timeouts, a typed status, lowercased response +//! headers and a scoped current-thread runtime. It is strictly better than a +//! child process for every one of these reads, and it was in the crate the whole +//! time. +//! +//! **The escapes were the tell and nothing caught them.** `spawn-adapters` is the +//! gate over WHERE a spawn may appear, and it is answered by adding a word to a +//! Rego set — which is what happened: two placements went in, each with a +//! justification in a comment no gate reads. A branch whose whole subject is +//! *removing shell* added five annotated spawns and widened the placement table +//! twice, and every sensor stayed green. +//! +//! # What is NOT here +//! +//! The git smart-HTTP transport. [`crate::lease`] speaks that directly for the +//! lease ref's compare-and-swap — a different protocol over a different endpoint +//! family, and folding the two would put a ref-advertisement parser behind a REST +//! helper. What moved here is the credential reader the two share. + +use crate::fetch::{self, Call}; + +/// Where the REST tier lives. +/// +/// A constant rather than a config key: a consumer pointing this at another host +/// is asking for a different client, not a different value, and a key nobody sets +/// is a surface with no reader. +const API: &str = "https://api.github.com"; + +/// What the API is asked to send back. +const ACCEPT: &str = "application/vnd.github+json"; + +/// The bearer token this forge needs, or `None`. +/// +/// **Resolved here and returned to nobody outside this module.** It is +/// deliberately not a field of any value: a token in a struct is a token in that +/// struct's `Debug`, and non-negotiable rule 4 makes every report in this crate a +/// pointer. Keeping it inside the request builder means there is no value a +/// caller could print by accident. +/// +/// `GH_TOKEN` first, matching the forge CLI's own precedence, so a session that +/// set one for that tool does not have to set a second. **This is the reader +/// `lease.rs` already had**, promoted rather than copied — a second one would be +/// a second answer to "which variable holds the credential", and the four spawns +/// this module replaces existed because nobody looked for the first. +pub(crate) fn credential() -> Option { + // **THE EMPTINESS TEST IS INSIDE THE CLOSURE, and outside it the fallback + // above was a sentence the code did not implement** (review of #848). + // `find_map` commits to the first variable that EXISTS, so a trailing + // `.filter` judged only the already-chosen value: an exported-but-EMPTY + // `GH_TOKEN` yielded `None` rather than falling through to `GITHUB_TOKEN`. + // + // That is the ordinary shape rather than a corner: a forge's own CI + // substitutes the empty string for an unset secret, so a job naming a + // personal token that was never configured exports an empty one beside a + // perfectly good job token — and a task runner that resolves the variable + // from a chain of fallbacks emits an empty one when the chain runs out. Every + // REST read then goes out unauthenticated, and every caller reads the + // resulting 403/404 as could-not-look, so a landing reports "no in-flight + // runs" at exit 0 while knowing nothing at all. + ["GH_TOKEN", "GITHUB_TOKEN"] + .into_iter() + .find_map(|name| std::env::var(name).ok().filter(|token| !token.is_empty())) +} + +/// The request headers for one exchange. +/// +/// **An absent credential is not an error.** A public repository needs none, and +/// a private one answers `401` — which every caller here already reports as +/// could-not-look rather than as a verdict about the work. +fn headers(conditional: Option<&str>, json_body: bool) -> Vec<(String, String)> { + let mut headers = vec![ + (String::from("Accept"), ACCEPT.to_owned()), + // **REQUIRED, AND ITS ABSENCE IS A 403 ON EVERY CALL.** The forge + // documents it: *"All API requests MUST include a valid User-Agent + // header. Requests with no User-Agent header will be rejected."* The + // client this tier replaced sent one for free, so nothing in the port + // noticed it was gone — and the failure does not look like a missing + // header. It looks like a permission problem, which is where a reader + // goes first: measured on this repository with a token whose + // `pull_requests=read` claim `gh-preflight` reports as `ok`, the same + // request answered `200` from `curl` and `403` from here, and the only + // difference was this line. + // + // A NAME AND A VERSION, which is what the forge asks for and what makes + // a rate-limit conversation possible at all. No URL and nothing about + // the consumer: a header is sent on every request, so it is the last + // place a repository's identity should leak (non-negotiable rule 1). + ( + String::from("User-Agent"), + format!("batten/{}", env!("CARGO_PKG_VERSION")), + ), + ]; + if json_body { + headers.push(( + String::from("Content-Type"), + String::from("application/json"), + )); + } + if let Some(etag) = conditional { + headers.push((String::from("If-None-Match"), etag.to_owned())); + } + if let Some(token) = credential() { + headers.push((String::from("Authorization"), format!("Bearer {token}"))); + } + headers +} + +/// One answer from the REST tier. +/// +/// `PartialEq` without `Eq`, because [`Answer::poll_floor`] is an `f64` and no +/// float is `Eq`. That is the right way round rather than a concession: a +/// fractional floor is CLOUD-390's whole defect, so the field cannot be an +/// integer, and a total-equality bound on a value carrying one would be a claim +/// this type has no business making. +/// +/// **Typed at the boundary, which is the half the spawn could not give.** A child +/// process hands back bytes, so every caller had to re-parse a status line and a +/// header block out of `gh api -i` output. `pr_watch` carried the parser three +/// modules shared to undo that framing; with the last spawn gone the transport +/// has already read them, and that second parser is **retired** rather than left +/// standing beside this one. Two readings of one status line is the disagreement +/// class, not a duplication to tidy up later. +#[derive(Debug, Clone, PartialEq)] +pub struct Answer { + /// The HTTP status. `304` is the reading that did not change. + pub status: u16, + /// The validator to send with the next request, where one was sent. + pub etag: Option, + /// The interval the server asked to be polled at, in seconds. + /// + /// `f64` rather than an integer, which is CLOUD-390's defect and the reason + /// this field is not a `u64`: the predecessor compared with `-gt`, so a + /// fractional value read as *the server asked for no floor* — byte-identical + /// to an absent header, and silently faster than the endpoint allows. + pub poll_floor: Option, + /// How long the forge asked the caller to back off for, in seconds. + /// + /// **A DIFFERENT HEADER ANSWERING A DIFFERENT QUESTION from + /// [`Answer::poll_floor`]**, and conflating them is what made the + /// predecessor's loop respond to being rate-limited by generating more of + /// the request that had just been refused. `X-Poll-Interval` is *how often + /// to ask*; this is *stop asking until*. A poll honouring only the first + /// keeps its polite cadence straight into a secondary limit. + /// + /// Resolved from `Retry-After` where the forge states one, and otherwise + /// from `X-RateLimit-Reset` — but only once `X-RateLimit-Remaining` is `0`, + /// because a reset instant is always present and reading it as a backoff + /// would pause on every successful call. + pub backoff: Option, + /// The response body, as text. + pub body: String, +} + +impl Answer { + /// Whether this answer's body is a READING, rather than the forge declining. + /// + /// # `Some(Answer)` is not the same claim as *the forge answered the question* + /// + /// [`get`] answers `None` only where the exchange could not happen at all. A + /// `401`, a `403`, a `404` or a `5xx` is a completed exchange carrying a + /// refusal, so it arrives as `Some` — and its body is an error document + /// rather than the collection a caller parses. A caller that reads the body + /// without reading the status therefore gets an EMPTY parse, which is + /// byte-identical on the decision surface to a genuinely empty collection. + /// + /// Measured on this crate (PR #848's review): the lap's ready step read the + /// head's check-runs without this test, so a forge blip parsed as zero runs, + /// `checks_green::decide` answered *unregistered*, `land::buys_a_matrix` read + /// that as `Refire`, and the lap re-drafted and re-readied the pull request — + /// cancelling the in-flight matrix the arm exists to protect. + /// + /// # `304` is deliberately NOT a reading + /// + /// A not-modified says *your cached copy still stands*, which is an answer + /// only to a caller that HAS one. A one-shot read sends no validator and holds + /// no cache, so treating it as a reading would report the empty cache as the + /// forge's answer — the same defect one status along. A polling caller does + /// not use this: [`crate::pr_watch::Poll::absorb`] keeps its own runs across a + /// `304` precisely because it is the one that has something to keep. + #[must_use] + pub const fn is_reading(&self) -> bool { + self.status == 200 + } + + /// Did the server answer NORMALLY — a reading, or a not-modified? + /// + /// **The sibling of [`Answer::is_reading`], and the distinction is what a + /// RATE-LIMIT WINDOW turns on** (review of #848). A conditional poll's + /// ordinary success is `304`, which `is_reading` deliberately excludes + /// because it carries no body to read. But a `304` is still the forge + /// answering rather than refusing, so it retires a `Retry-After` the way a + /// `200` does — and a poll that retired the window only on `200` re-armed a + /// stale one on every unchanged answer and wedged itself for the rest of the + /// run. + /// + /// So: `is_reading` asks *did I get a body*, and this asks *did the server + /// serve me*. Two questions, and the same status separates them differently. + #[must_use] + pub const fn answered(&self) -> bool { + self.status == 200 || self.status == 304 + } +} + +/// One GET against the REST tier, or `None` where it could not be reached. +/// +/// **`None` is could-not-look and never a verdict.** Every caller here polls in a +/// loop that must survive an unreachable forge: a lap that concluded "trunk +/// moved" from a failed request would decide about the network rather than about +/// the work, and it would cost a whole CI run each time. +/// +/// `path` is API-relative and carries no leading slash — `repos/{owner}/{repo}/…` +/// — which is the spelling the forge's own client takes, so a call site moving +/// here keeps its endpoint string byte for byte. +#[must_use] +pub fn get(path: &str, etag: Option<&str>) -> Option { + exchange(path, etag, None) +} + +/// One POST against the REST tier, with no body. +/// +/// `false` where the call did not succeed, on the same could-not-look posture +/// [`get`] takes: the one caller cancels a run it is standing in, and a guard +/// that could not stop a run must not also fail the job it is standing in. +#[must_use] +pub fn post(path: &str) -> bool { + exchange(path, None, Some(&[])).is_some_and(|answer| (200..300).contains(&answer.status)) +} + +/// One POST carrying a JSON body. +/// +/// **The ANSWER comes back rather than a boolean**, because the one caller needs +/// the created object's id: the join key a lap waits on is minted from it, so a +/// call reporting only success would leave the lap with nothing to match. The +/// predecessor reached the same conclusion and said so — it used the API rather +/// than the client's comment porcelain precisely because the porcelain does not +/// return the object. +#[must_use] +pub fn post_json(path: &str, body: &serde_json::Value) -> Option { + let encoded = serde_json::to_vec(body).ok()?; + exchange(path, None, Some(&encoded)) +} + +/// Where a SUITE may put canned responses instead of the forge. +/// +/// **A test seam, and it is here because retiring a spawn retired a tier.** The +/// suites over `pr watch` and its siblings drove the engine by putting a stubbed +/// `gh` on `PATH`: the program was the seam, so a case could hand the poll a +/// `304`, a rate-limit header or a green body and count the calls. Moving the +/// read in-process removed that seam and left those cases with no way to answer, +/// so `pr watch`'s unbounded loop polled a forge it could not reach — measured at +/// 46 minutes on two cases before the run was killed. +/// +/// The alternative was to leave the spawn, and that is the wrong trade: a +/// compiled-binary tier is what proves the ENGINE builds what the caller reads, +/// and losing it is exactly the class `.claude/rules/policy-modules.md` names. +/// So the seam moves to the boundary the read moved to. +/// +/// **`LEASE_FROM_REF`'s standing, in the same words**: overridable only so the +/// suite can point it at a fixture. It is read once, here, at the one exchange +/// every verb in this module goes through — so there is no second route and +/// nothing a consumer gains by setting it except responses they wrote +/// themselves. +const FIXTURE: &str = "BATTEN_REST_FIXTURE"; + +/// Serve one response from the fixture directory, counting the call. +/// +/// The protocol is the stubbed program's, conserved exactly so the cases that +/// read it back need no rewrite: `resp.` for the n-th call and `resp.last` +/// once they run out, the count in `calls`, and the request appended to `args`. +fn from_fixture(dir: &std::path::Path, url: &str, etag: Option<&str>, now: u64) -> Option { + let calls = dir.join("calls"); + let n = std::fs::read_to_string(&calls) + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .unwrap_or(0) + + 1; + let _ = std::fs::write(&calls, format!("{n}\n")); + if let Ok(mut args) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("args")) + { + use std::io::Write as _; + // THE VALIDATOR IS PART OF THE REQUEST A CASE READS BACK. The stubbed + // program recorded its whole argv, so `-H "If-None-Match: …"` was + // visible and the 304 case asserts on it — the conditional poll IS the + // economy, so a fixture that hid it would let the header go away + // silently. Written in the client's own spelling, which is what keeps + // that assertion's bytes unchanged. + match etag { + Some(etag) => { + let _ = writeln!(args, "{url} -H If-None-Match: {etag}"); + } + None => { + let _ = writeln!(args, "{url}"); + } + } + } + let raw = std::fs::read_to_string(dir.join(format!("resp.{n}"))) + .or_else(|_| std::fs::read_to_string(dir.join("resp.last"))) + .ok()?; + Some(canned(&raw, now)) +} + +/// One `-i`-style response text, as an [`Answer`]. +/// +/// The fixtures are written in the shape the forge's own client printed, which +/// is what lets a case that predates this seam keep its bytes. +fn canned(raw: &str, now: u64) -> Answer { + let clean = raw.replace('\r', ""); + let (head, body) = clean.split_once("\n\n").unwrap_or((clean.as_str(), "")); + let mut lines = head.split('\n'); + let status = lines + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|code| code.parse().ok()) + .unwrap_or(0); + let header = |name: &str| { + lines.clone().find_map(|line| { + let (key, value) = line.split_once(':')?; + (key.trim().eq_ignore_ascii_case(name)).then(|| value.trim().to_owned()) + }) + }; + Answer { + status, + etag: header("etag"), + poll_floor: header("x-poll-interval") + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|seconds| seconds.is_finite() && *seconds > 0.0), + backoff: backoff_of(header, now), + body: body.to_owned(), + } +} + +fn exchange(path: &str, etag: Option<&str>, body: Option<&[u8]>) -> Option { + let now = crate::now_unix(); + let url = format!("{API}/{path}"); + if let Some(dir) = std::env::var_os(FIXTURE) { + return from_fixture(std::path::Path::new(&dir), &url, etag, now); + } + let headers = headers(etag, body.is_some_and(|bytes| !bytes.is_empty())); + let mut answers = fetch::spend(&[Call { + url: &url, + headers: &headers, + body, + // PROXIED, which is the ordinary path. `direct` exists so a credential + // can be proved against the forge with the proxy out of the way + // (`fetch::get_direct`); a forge REST call is not that question, and + // taking the direct route here would bypass the egress fence for every + // read this module makes. + direct: false, + }]) + .ok()?; + // ONE call in, one answer out. `spend` returns them in the order given and + // stops at the first failure, so a non-empty vector here is this call's. + let response = answers.pop()?; + Some(Answer { + status: response.status, + etag: response.header("etag").map(str::to_owned), + // LOWERCASE, because `fetch::Response` lowercases every name it read. + // Matching `X-Poll-Interval` here would find nothing and read as *the + // server asked for no floor* — the exact three-valued mistake CLOUD-390 + // records, arriving by a different route. + poll_floor: response + .header("x-poll-interval") + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|seconds| seconds.is_finite() && *seconds > 0.0), + backoff: backoff_from(&response, now), + body: String::from_utf8_lossy(&response.body).into_owned(), + }) +} + +/// The longest backoff this tier will honour, in seconds. +/// +/// **A CEILING ON THE FORGE'S OWN NUMBER, which `MAX_FLOOR` deliberately is +/// not.** That one bounds `X-Poll-Interval` — a cadence — and its doc says so; +/// putting a backoff through it would truncate a genuine rate-limit wait into a +/// retry loop against the refusal that caused it. But unbounded is not the other +/// option: `Retry-After` is a number off the wire and a reset instant is a +/// subtraction, so both can arrive absurd, and the consumer is a `thread::sleep` +/// inside a loop with no wall clock of its own. +/// +/// One hour, which is longer than any window this forge resets on, so it clamps +/// nothing a healthy exchange produces — and a landing that has waited an hour +/// has a caller who wants to hear about it rather than a process that should +/// still be asleep. +const MAX_BACKOFF: u64 = 3600; + +/// The backoff a response asks for, in seconds, or `None`. +/// +/// **`now` is the CALLER'S instant rather than a clock read here**, which is the +/// rule `.claude/rules/policy-modules.md` states for every other comparison in +/// this crate: the clock belongs to the boundary, so one exchange yields one +/// answer whoever asks and whenever they ask again. +fn backoff_from(response: &fetch::Response, now: u64) -> Option { + backoff_of(|name| response.header(name).map(str::to_owned), now) +} + +/// The backoff a response states, over a header accessor rather than a response. +/// +/// **ONE READER FOR BOTH PATHS, and this module's header names the class the +/// split belonged to: two readings of one header block.** The fixture seam +/// parsed `Retry-After` alone, so a fixture stating the RATE-LIMIT headers +/// yielded `backoff: None` — and a case asserting rate-limit backoff passed +/// without exercising the behaviour, which is coverage that has stopped testing +/// the thing it names. Found in review. +fn backoff_of(header: impl Fn(&str) -> Option, now: u64) -> Option { + // `Retry-After` FIRST, because a forge that states one has stated it about + // this exact refusal. The reset instant below is a property of the window. + if let Some(seconds) = header("retry-after") + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|seconds| *seconds > 0) + { + return Some(seconds.min(MAX_BACKOFF)); + } + // ONLY AT ZERO REMAINING. The reset instant rides every response, so reading + // it unconditionally would back off after each successful call. + if header("x-ratelimit-remaining").and_then(|raw| raw.trim().parse::().ok()) != Some(0) { + return None; + } + // **A CLOCK THAT DID NOT READ IS NOT AN INSTANT** (review of #848). + // `now_unix` answers `0` when `SystemTime::now` fails, and `0` passes the + // `reset > now` filter — so the subtraction yielded the raw absolute epoch, + // about 1.79e9 seconds, and `pr_watch::wait_for` deliberately does not clamp + // a backoff. One failed clock read therefore put a loop this crate documents + // as unbounded to sleep for roughly fifty-seven years, holding the landing + // lease and looking exactly like a slow bot. + if now == 0 { + return None; + } + header("x-ratelimit-reset") + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|reset| *reset > now) + .map(|reset| (reset - now).min(MAX_BACKOFF)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// **A REFUSAL IS AN ANSWER THAT ARRIVED, AND IT IS NOT A READING.** + /// + /// `get` answers `Some` for every completed exchange, so a `401`, a `403` and + /// a `5xx` all reach a caller carrying an error document where a collection + /// was expected. A caller reading the body alone parses that as EMPTY, which + /// is indistinguishable from a genuinely empty collection — the defect + /// measured on the lap's ready step, where it re-drafted a pull request over + /// a forge blip. + /// + /// The `200` arm is what keeps this from being satisfied by a predicate that + /// refuses everything, and the `304` arm pins the deliberate exclusion rather + /// than leaving it to be re-argued: a one-shot read holds no cache, so + /// not-modified answers a question it never asked. + #[test] + fn only_a_two_hundred_carries_a_reading() { + let with = |status: u16| Answer { + status, + etag: None, + poll_floor: None, + backoff: None, + body: String::new(), + }; + assert!(with(200).is_reading()); + for status in [304, 401, 403, 404, 422, 500, 502] { + assert!( + !with(status).is_reading(), + "{status} is the forge declining, not a reading" + ); + } + } + + /// Every endpoint a caller hands over is API-relative. + /// + /// A leading slash produces a double one and a `404`, which every caller here + /// reads as could-not-look — silent, and exactly the dead-gate class this + /// crate exists to refuse. + #[test] + fn an_api_relative_path_keeps_the_forge_clients_own_spelling() { + for path in [ + "repos/{owner}/{repo}/git/ref/heads/main", + "repos/o/r/actions/runs/7/cancel", + "repos/o/r/issues/42/comments", + ] { + assert!( + !path.starts_with('/'), + "API-relative, never rooted: {path:?}" + ); + assert!( + format!("{API}/{path}").starts_with("https://"), + "and HTTPS, which the connector enforces anyway" + ); + } + } + + /// The conditional header is attached only when there is a validator. + /// + /// **Asserted on the header LIST rather than on a request**, because building + /// a request would dial. What this pins is the shape a `304` depends on: an + /// unconditional poll had to stay slow to stay affordable, so a validator + /// that never reaches the wire makes the news arrive late. + #[test] + fn the_validator_is_attached_only_when_one_was_read() { + let first = headers(None, false); + assert!( + !first.iter().any(|(name, _)| name == "If-None-Match"), + "nothing to validate against yet: {first:?}" + ); + + let second = headers(Some("W/\"a\""), false); + assert!( + second + .iter() + .any(|(name, value)| name == "If-None-Match" && value == "W/\"a\""), + "a 304 is what makes a one-second poll affordable: {second:?}" + ); + } + + /// A body-bearing call declares its content type and a bodyless one does not. + /// + /// The anti-vacuity half matters as much: sending `Content-Type` on a GET is + /// how a caller learns the header builder ignores its argument. + #[test] + fn a_json_body_declares_its_type_and_a_bodyless_call_does_not() { + assert!( + headers(None, true) + .iter() + .any(|(name, value)| name == "Content-Type" && value == "application/json"), + ); + assert!( + !headers(None, false) + .iter() + .any(|(name, _)| name == "Content-Type"), + ); + } + + /// **The credential never reaches a value a caller can print.** + /// + /// Non-negotiable rule 4 lands here as a TYPE property rather than as a habit + /// at each call site: [`Answer`] has no credential field, so no `Debug` of + /// anything this module returns can carry one. + #[test] + fn no_value_this_module_returns_can_carry_the_credential() { + let answer = Answer { + status: 200, + etag: Some(String::from("W/\"a\"")), + poll_floor: Some(2.5), + backoff: Some(60), + body: String::from("{}"), + }; + let rendered = format!("{answer:?}"); + assert!( + !rendered.contains("Bearer"), + "the token is not a field and cannot be one: {rendered}" + ); + } + + fn answered(headers: &[(&str, &str)]) -> fetch::Response { + fetch::Response { + status: 200, + body: Vec::new(), + headers: headers + .iter() + .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())) + .collect(), + } + } + + /// **THE HEADER THE POLL FLOOR IS NOT.** `X-Poll-Interval` says how often to + /// ask; `Retry-After` says stop asking. The predecessor's loop honoured only + /// the first, so being rate-limited made it generate more of exactly the + /// request that had just been refused. + #[test] + fn a_stated_retry_after_is_the_backoff_and_wins_over_the_reset() { + let response = answered(&[ + ("retry-after", "45"), + ("x-ratelimit-remaining", "0"), + ("x-ratelimit-reset", "9000"), + ]); + assert_eq!( + backoff_from(&response, 1000), + Some(45), + "a forge stating one has stated it about THIS refusal" + ); + } + + /// The reset instant answers only once the window is actually spent. + /// + /// **The anti-vacuity half is the load-bearing one**: the reset rides every + /// response, so a reader that did not check `remaining` would back off after + /// each successful call and turn a healthy poll into a stall. + #[test] + fn the_reset_answers_at_zero_remaining_and_never_otherwise() { + let spent = answered(&[ + ("x-ratelimit-remaining", "0"), + ("x-ratelimit-reset", "1060"), + ]); + assert_eq!(backoff_from(&spent, 1000), Some(60)); + + let healthy = answered(&[ + ("x-ratelimit-remaining", "4999"), + ("x-ratelimit-reset", "1060"), + ]); + assert_eq!( + backoff_from(&healthy, 1000), + None, + "a reset instant is not a backoff while requests remain" + ); + } + + /// A response stating nothing asks for nothing, and a reset already past is + /// not a wait. + #[test] + fn a_silent_response_and_a_lapsed_reset_both_ask_for_no_backoff() { + assert_eq!(backoff_from(&answered(&[]), 1000), None); + assert_eq!( + backoff_from( + &answered(&[("x-ratelimit-remaining", "0"), ("x-ratelimit-reset", "900")]), + 1000 + ), + None, + "the window already reopened" + ); + } +} diff --git a/crates/batten/src/scratch.rs b/crates/batten/src/scratch.rs new file mode 100644 index 000000000..251efde9b --- /dev/null +++ b/crates/batten/src/scratch.rs @@ -0,0 +1,235 @@ +//! Test scratch space outside the tree, owned in one place and reaped by +//! liveness. +//! +//! # The defect this closes, measured rather than argued +//! +//! 269 leaked `batten-*` directories in `/tmp` after two suite runs, and the +//! count only ever grows. The cause is one line repeated across ~76 call sites: +//! +//! ```text +//! std::env::temp_dir().join(format!("batten-branch-receipt-{pid}-{name}")) +//! ``` +//! +//! **The pid is in the NAME.** `cargo nextest` runs every case in its own +//! process, so each case in each run mints a directory whose name no successor +//! will ever compute again — and the `remove_dir_all` those sites open with is +//! therefore a wipe of a path that was already empty. Nothing removes it. +//! +//! The sites that leave the pid OUT do not leak, which is what makes this the +//! cause rather than a correlate: `batten-startup-{name}` and +//! `batten-doctor-tests/{name}` are a fixed working set of 12 and 2, because a +//! stable path means the next run's `remove_dir_all` collects the last run's. +//! +//! # Why the pid stays, and moves one level up +//! +//! Two concurrent runs covering one binary DO collide on a stable path, which is +//! what the pid was defending against. So it is kept and made a path SEGMENT +//! rather than part of a leaf name: +//! +//! ```text +//! /tmp/batten-scratch// +//! ``` +//! +//! That is the whole fix. A segment is something a reaper can decide about — it +//! parses as a pid or it does not — where a pid spliced into `batten---` +//! can only be recovered by guessing at 76 different name shapes, which is the +//! second-authority class `[[pattern]]` exists to make unwritable. +//! +//! It also matches the discipline this repository already chose one directory +//! over. `tests/it/common`'s `in_lane` adds the pid only under +//! `BATTEN_TEST_SCRATCH_LANE`, because CLOUD-1252 measured that a per-run path on +//! every scratch moved the journal's fingerprint ordering and silently switched +//! off an emission budget. Nothing here reaches the journal — these are fixtures +//! outside the tree — so the pid may ride every path, and the reaper is what pays +//! for it. +//! +//! # Crash-only, because that is the only state this container is ever in +//! +//! A run here is killed constantly: a lap stops, a container is reclaimed, an +//! operator interrupts. Cleanup on the happy path is therefore not a mechanism, +//! and a `Drop` would be tidiness rather than the answer. This reaps on +//! **acquire** — a caller asking for scratch first sweeps every sibling whose pid +//! is gone. Recovery is the only path, which is the reasoning +//! [`crate::task::singleton_acquire`] already uses to reclaim a lock from a dead +//! holder. +//! +//! **Liveness, never age.** An mtime bound would reap a long-running suite's own +//! corpora out from under it, which is the direction that breaks a passing run. +//! A pid that does not exist cannot be using anything. + +use std::path::{Path, PathBuf}; + +/// The one directory every out-of-tree scratch lives under. +/// +/// A single parent rather than 39 prefixes loose in `/tmp`, so the reaper has one +/// place to look and an operator has one path to delete. +const SCRATCH_ROOT: &str = "batten-scratch"; + +/// This process's scratch root, with dead processes' subtrees reaped first. +/// +/// Test-support rather than product surface, and `#[doc(hidden)]` says so. It is +/// `pub` because the leaking call sites live in three scopes that cannot share a +/// `#[cfg(test)]` helper: this crate's own unit tests, the `it` integration +/// binary, and the standalone `tests/*.rs` binaries. +#[doc(hidden)] +#[must_use] +pub fn root() -> PathBuf { + let root = std::env::temp_dir().join(SCRATCH_ROOT); + reap_the_dead(&root); + root.join(std::process::id().to_string()) +} + +/// An EMPTY scratch directory named `name`, under this process's root. +/// +/// The wipe is kept because callers relied on it: every site this replaces opened +/// with `remove_dir_all`, and a case that finds a previous case's bytes is a +/// different test from the one that was written. +#[doc(hidden)] +#[must_use] +pub fn scratch(name: &str) -> PathBuf { + let dir = root().join(name); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::create_dir_all(&dir); + dir +} + +/// Remove every subtree whose owning process is gone. +/// +/// Best effort in every direction, and each arm is the could-not-look side on +/// purpose: an unreadable root reaps nothing rather than guessing, a name that is +/// not a pid is left alone rather than parsed loosely, and a directory that will +/// not remove is left for the next caller. This deletes what it can prove is +/// dead and nothing else. +fn reap_the_dead(root: &Path) { + let Ok(listing) = std::fs::read_dir(root) else { + return; + }; + for entry in listing.flatten() { + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + let Ok(pid) = name.parse::() else { + continue; + }; + if !pid_is_live(pid) { + let _ = std::fs::remove_dir_all(entry.path()); + } + } +} + +/// Is a process with this pid running? +/// +/// `kill(pid, 0)`: **EPERM means it exists** and this uid may not signal it, +/// which is LIFE — reading it as death is what would delete a live sibling's +/// corpora mid-run. Only ESRCH is death. The same asymmetry +/// [`crate::task`]'s own probe argues for, and for the same reason. +#[cfg(unix)] +fn pid_is_live(pid: i32) -> bool { + let Some(target) = rustix::process::Pid::from_raw(pid) else { + return true; + }; + !matches!( + rustix::process::test_kill_process(target), + Err(rustix::io::Errno::SRCH) + ) +} + +/// Off unix there is no `kill -0` in this closure at all — `rustix` is declared +/// under `[target.'cfg(unix)'.dependencies]` — so nothing is reaped. That is the +/// could-not-look direction, which never deletes a live run's scratch. +#[cfg(not(unix))] +fn pid_is_live(_pid: i32) -> bool { + true +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + /// The whole point, asserted over the mechanism rather than over a name: a + /// subtree owned by a pid that cannot exist is collected, and this process's + /// own is not. + /// + /// **A CORPSE IS UNREACHABLE OFF UNIX, AND BOTH ARMS ARE ASSERTED** + /// (CLOUD-1148). `pid_is_live` is two functions, not one: `rustix` is + /// declared under `[target.'cfg(unix)'.dependencies]`, so off unix there is + /// no `kill -0` in this closure at all and the module abstains by + /// construction — which is the safe direction, since a platform that cannot + /// tell life from death must not delete a live run's corpora. This case + /// asserted collection unconditionally and so demanded, on Windows, + /// behaviour the module deliberately does not have; the `windows` job found + /// it while every other leg was green. + /// + /// `cfg!` RATHER THAN AN ATTRIBUTE, for the reason + /// `crates/batten/tests/it/task_registry.rs` records beside the same + /// asymmetry: it keeps BOTH arms compiled on every target, so `cross-check` + /// type-checks the off-unix branch instead of skipping over it unparsed. + /// A `#[cfg(unix)]` over the whole case — which this fix first used — leaves + /// the Windows contract unstated and one arm never compiled locally, which is + /// how the class stayed invisible. + /// + /// The second assertion is the one both arms share: whatever the platform + /// decides about corpses, this process's own scratch survives its own reap, + /// so neither arm can pass by reaping everything. + /// + /// Fails by: dropping the `pid_is_live` guard in [`reap_the_dead`], which + /// takes the live directory with it. + #[test] + fn a_dead_processes_scratch_is_collected_and_a_live_ones_is_not() { + let mine = scratch("still-here"); + std::fs::write(mine.join("corpus"), "bytes").expect("seed the live subtree"); + + // A pid nothing can own. `i32::MAX` is above every `pid_max` this runs + // on, so the probe answers ESRCH rather than depending on a pid that + // happens to be free right now. + let parent = std::env::temp_dir().join(SCRATCH_ROOT); + let dead = parent.join(i32::MAX.to_string()); + std::fs::create_dir_all(dead.join("left-behind")).expect("seed the dead subtree"); + + let _ = root(); + + if cfg!(unix) { + assert!( + !dead.exists(), + "a subtree whose pid is gone must be collected" + ); + } else { + assert!( + dead.exists(), + "with no liveness probe the reaper must abstain, never guess" + ); + } + assert!( + mine.join("corpus").exists(), + "this process's own scratch must survive its own reap" + ); + let _ = std::fs::remove_dir_all(&dead); + } + + /// A name that is not a pid is not a corpse. Deleting one would make this + /// reaper a `rm -rf` over whatever else ends up under the root. + /// + /// Fails by: replacing the `parse::()` guard with an unconditional + /// remove. + #[test] + fn a_subtree_that_is_not_a_pid_is_left_alone() { + let root = std::env::temp_dir().join(SCRATCH_ROOT); + let stray = root.join("not-a-pid"); + std::fs::create_dir_all(&stray).expect("seed a stray"); + + reap_the_dead(&root); + + assert!(stray.exists(), "a name that is not a pid is not decidable"); + let _ = std::fs::remove_dir_all(&stray); + } + + /// COULD NOT LOOK IS NOT AN EMPTY ANSWER. An unreadable root reaps nothing + /// and says nothing, rather than reporting a clean sweep it never performed. + /// + /// Fails by: `expect`ing the `read_dir`, which panics instead of abstaining. + #[test] + fn an_unreadable_root_reaps_nothing() { + reap_the_dead(&std::env::temp_dir().join("batten-scratch-absent-entirely")); + } +} diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 321d275cd..1233fb6bf 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -535,6 +535,10 @@ mod tests { // flag on a reporting row would drop the reporting invocation // every consumer already uses. "lease authorises".to_owned(), + // The staleness half of the CI-side precondition (CLOUD-1148 + // §2). Two forge reads and no write, so it belongs here beside + // `check` rather than with the five arms that reach `swap`. + "lease carries".to_owned(), "lease check".to_owned(), "lease held".to_owned(), "lease peek".to_owned(), @@ -588,6 +592,11 @@ mod tests { // claiming otherwise would advertise a writing verb as read-only. "ready lint".to_owned(), "receipt status".to_owned(), + // The composed receipt read that retired `mise-tasks/verified.sh` + // (CLOUD-1148). Three `receipt::validity` reads and no write, so + // it belongs here beside `receipt status` rather than with + // `receipt record`. + "receipt verified".to_owned(), // CLOUD-1180's recovered `agent` slice. BOTH the noun and its // leaf are read, and that is the row's §2 predicate rather than // an accident: `show` is the read band under CLOUD-1184's @@ -806,6 +815,12 @@ mod tests { // this crate that moves something the fleet can see, `lease` being // the first, and is the sharpest reason the noun cannot be `read`. "land".to_owned(), + // `fast-forward` ASKS THE BOT AND READS ITS ANSWER, which is a write + // by the same reading `push` is: the comment it leaves is a request + // the fleet can see, and the bot acts on it. `lap` is the driver, so + // it inherits the widest effect of the steps it sequences. + "land fast-forward".to_owned(), + "land lap".to_owned(), "land push".to_owned(), "land replay".to_owned(), "land verify".to_owned(), @@ -830,7 +845,9 @@ mod tests { "lease".to_owned(), "lease acquire".to_owned(), "lease authorises".to_owned(), + "lease carries".to_owned(), "lease check".to_owned(), + "lease guard".to_owned(), "lease held".to_owned(), "lease hold".to_owned(), "lease peek".to_owned(), @@ -936,6 +953,7 @@ mod tests { "receipt".to_owned(), "receipt record".to_owned(), "receipt status".to_owned(), + "receipt verified".to_owned(), // The out-of-tree verdict stores' write half (CLOUD-1265). §2 // gains the noun and its two leaves in the same change, which is // what this assertion exists to prompt. diff --git a/crates/batten/src/speculation.rs b/crates/batten/src/speculation.rs new file mode 100644 index 000000000..7e58503a6 --- /dev/null +++ b/crates/batten/src/speculation.rs @@ -0,0 +1,698 @@ +//! Betting on the base that is about to exist (CLOUD-748, CLOUD-862, CLOUD-369). +//! +//! # What a speculation is +//! +//! The landing lease names one branch as the next thing to land. A waiter behind +//! it can either sit on today's `main` — and rebase onto the holder's work the +//! moment it lands, spending a lap — or linearize onto the holder's head NOW and +//! be already correct when it lands. The second is the bet, and the whole of the +//! machinery below exists because a bet can be wrong. +//! +//! # THIS IS A CONSERVING PORT, AND ONE KNOWN DEFECT TRAVELS WITH IT +//! +//! `settle` has THREE outcomes — the holder landed, the bet is still open, the +//! bet lost — and **no arm for a base whose tree is poisoned**: one that will +//! not pass `verify`. CLOUD-1306 is that gap, and it is deliberately NOT fixed +//! here. A port that improved behaviour could not be shown to conserve it, and +//! being able to say "this does what the bash did" is the whole discipline that +//! makes a 4,700-line retirement reviewable. +//! +//! What the gap costs, so nobody reads its absence as completeness: a waiter +//! linearizes onto a head that cannot go green, `settle` reads the bet as still +//! open every lap (the holder is still there and `main` has not moved, which is +//! exactly what "pending" looks like), and [`Bet::would_rebet`] bets on the same +//! holder again. Every waiter behind that holder stalls together. The fix is +//! CLOUD-1306's and belongs in one change that can be reviewed as a behaviour +//! change rather than smuggled into a port. +//! +//! # Every failure is a FALLBACK, never a stop +//! +//! The holder may never land, so a conflict against its head is information +//! about a base that may not happen — not the `die`-worthy conflict a rebase +//! onto `origin/main` reports. Reading an unreachable remote, an unresolvable ref +//! or an unknown ancestry all mean "do not bet" or "the bet is stale", never +//! "stop the landing". +//! +//! **Except in one direction, and the asymmetry is the correctness property.** +//! [`Live::decide`] fails CLOSED: an unreadable lease, an unfetchable branch and +//! an unknown ancestry are all *stale*, because failing open there would make a +//! network blip the thing that lands somebody else's work. + +use std::path::Path; + +use anyhow::Result; + +/// The ref a live bet's BASE is recorded under. +/// +/// A ref rather than a process variable because a bet outlives the process that +/// placed it: a `land` that was killed mid-lap leaves the tree linearized on +/// somebody else's commits, and the next one has to be able to find that out. +/// CLOUD-862 is that reading — measured, a stopped `land` left seven of another +/// branch's commits in the tree and the next run took them all the way to a push. +pub const BASE_REF: &str = "refs/batten-spec/base"; + +/// The ref the holder's CURRENT head is fetched into when re-confirming a bet. +/// +/// A SECOND ref, deliberately. The bet's base and the tip it is checked against +/// are two different commits, and reusing [`BASE_REF`] would overwrite the base +/// while answering a question about it. +pub const LIVE_REF: &str = "refs/batten-spec/live"; + +/// The variable a bet is published to the child process under. +/// +/// `verify` runs `claim-race-check`, which reads `claimed-keys`, which cannot +/// otherwise tell a commit this branch authored from one this speculation +/// adopted — so it reported the waiter as racing the very PR the bet was placed +/// on, twice in one session (CLOUD-748). The name is the CONSUMER's and reaches +/// the child through the environment; nothing in this crate reads it. +pub const PUBLISHED_AS: &str = "BATTEN_SPEC_BASE"; + +/// What a settle decided. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Settle { + /// No bet is outstanding, so there is nothing to settle. + Nothing, + /// The base is an ancestor of `origin/main`: the holder landed, and this + /// branch is already linearized on it. Nothing to undo. + Landed, + /// Undecided. The holder is still landing and this branch is already behind + /// it, so the tree is kept. + /// + /// **This is the arm CLOUD-1306's poisoned base hides in.** A base that will + /// never go green is indistinguishable here from one that simply has not + /// landed yet, and the module header says why that is conserved rather than + /// fixed. + Pending, + /// The bet cannot come true: the holder is gone, or `main` moved and took + /// something else. The borrowed range is dropped. + Lost, +} + +/// Whether the bet is still on the branch that is about to land. +/// +/// A three-valued reading rather than a bool, because "could not look" and "no" +/// take the same action here and must still be distinguishable to a reader — +/// the bash collapsed them into one non-zero exit and the collapse is what made +/// the fail-closed posture invisible. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Live { + /// Somebody else holds the lease and the base is still on their branch. + Yes, + /// The lease is free, held by US, or the base is no longer on the holder's + /// branch. + No, + /// The lease would not read, the branch would not fetch, or the ancestry is + /// unknown. **Decides as [`Live::No`]** — see [`Live::decide`]. + Unreadable, +} + +impl Live { + /// The fail-CLOSED reading: anything but a confirmed yes is stale. + /// + /// Failing open here would make a network blip the thing that lands somebody + /// else's work, which is the one place in this module where a could-not-look + /// must not be permissive. + #[must_use] + pub const fn decide(self) -> bool { + matches!(self, Self::Yes) + } +} + +/// One outstanding bet. +/// +/// **AT MOST ONE.** A waiter laps repeatedly while the same holder lands, and +/// re-betting each lap would overwrite [`Bet::undo`] with a HEAD that is itself +/// speculative — so unwinding would restore a tree that still carried somebody +/// else's commits, which is the exact hazard the undo exists to remove. It would +/// also mint a new sha every lap and throw away a `verify` receipt for no gain. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Bet { + /// The holder's head this branch was replayed onto. + pub base: Option, + /// This branch's own last NON-speculative HEAD, and the exact unwind point. + /// + /// `None` on a bet this process ADOPTED rather than placed: the undo point + /// died with the process that recorded it, and such a bet unwinds by + /// replaying onto `origin/main` from the base instead (CLOUD-862). + pub undo: Option, + /// The `origin/main` this bet was placed against. + /// + /// Without it, "not landed yet" and "landed something else" are the same + /// reading and the bet would be unwound every lap while the holder was + /// perfectly on course. + pub main_at_bet: Option, + /// Set when this process adopted a bet it did not place. + pub recovered: bool, + /// Set once the bet has been PUSHED. An unwind then owes the remote a + /// correction too: without one, a stop or a spent lap budget leaves origin + /// holding another branch's commits under an open PR — the measured + /// two-PRs-at-one-sha state. + pub pushed: bool, + /// The holder's base that is KNOWN to conflict with this branch. + /// + /// Kept rather than discarded (CLOUD-369): a successor whose base is known + /// to conflict is guaranteed to be voided, so its run grades a head the + /// fast-forward will refuse and the rebase that follows still has to resolve + /// the same conflict. Measured for one such admission: a full CI run burned, + /// a ~200s `verify` discarded, a hand-resolved conflict, and a second run. + /// + /// **IT CARRIED NO SUBJECT AND NOTHING READ IT** (review of #848). As a bare + /// `bool` it was set on a conflicting replay and never consulted, so + /// [`Bet::would_rebet`] — which compares `base`, and `base` is deliberately + /// NOT set when the replay conflicted — answered `true` on the next lap and + /// the same conflicting rebase was attempted again, every lap, to reach the + /// same answer. CLOUD-369's mechanism was written and then unreachable. + /// + /// An `Option` because refusing to re-bet needs to know WHICH base: + /// a flag cannot tell the holder that conflicted from the one that replaced + /// it, and reading it as "no more bets at all" would give up speculating for + /// the rest of the landing over one bad candidate. + pub conflicts: Option, +} + +impl Bet { + /// Is a bet outstanding? + #[must_use] + pub const fn live(&self) -> bool { + self.base.is_some() + } + + /// The value [`PUBLISHED_AS`] should carry, or `None` to unset it. + /// + /// A function rather than a side effect so the two states cannot disagree: + /// the bash called `publish_speculation` at every point the bet was placed or + /// cleared precisely because they could. + #[must_use] + pub fn published(&self) -> Option<&str> { + self.base.as_deref() + } + + /// **Would `speculate` place a bet on this candidate?** + /// + /// `false` for the same candidate twice — the one-outstanding-bet rule above. + /// + /// **AND `true` AGAIN ONCE THE BET IS FORGOTTEN, WHICH IS CLOUD-1306's OTHER + /// HALF.** A poisoned base settles as [`Settle::Pending`] and is never + /// forgotten, so this correctly answers `false` and the waiter sits. Where + /// the bet IS dropped, nothing here remembers that this candidate was already + /// tried, so the next lap bets on the same holder again. Conserved; the fix + /// is CLOUD-1306's. + /// + /// **A base known to CONFLICT is a different question and this now answers + /// it** (review of #848). That one is not about a tree that will not go + /// green — it is a replay this clone already attempted and watched fail, so + /// re-attempting it is guaranteed waste rather than a gamble whose odds + /// changed. [`Bet::conflicts`] records which base, and it is the one thing + /// here that survives the bet not being placed. + #[must_use] + pub fn would_rebet(&self, candidate: &str) -> bool { + if self.conflicts.as_deref() == Some(candidate) { + return false; + } + self.base.as_deref() != Some(candidate) + } + + /// Drop the bet's own bookkeeping. The REF is the caller's to delete. + /// + /// **`pushed` IS CLEARED AND `conflicts` IS NOT, and the asymmetry is the + /// whole of this doc** (review of #848). `pushed` is a fact about THIS bet's + /// range reaching the remote, so leaving it set leaks a settled bet's state + /// into the next one — and the reader is `unwind_the_bet`, which force-writes + /// the branch under a CAS that always applies, so a bet that never reached + /// the remote would have the remote "corrected" to a head it already had. + /// + /// `conflicts` survives deliberately, and its own doc says why: it records a + /// base KNOWN to conflict, which stays true of that base after the bet built + /// on some other one is settled. Forgetting it is what made CLOUD-369's + /// mechanism unreachable. + pub fn forget(&mut self) { + self.base = None; + self.undo = None; + self.main_at_bet = None; + self.recovered = false; + self.pushed = false; + } + + #[cfg(test)] + /// A settled bet carries nothing forward but the base it will not re-bet on. + fn is_forgotten(&self) -> bool { + self.base.is_none() + && self.undo.is_none() + && self.main_at_bet.is_none() + && !self.recovered + && !self.pushed + } +} + +/// **The settle table, and it is a pure function on purpose.** +/// +/// Every input is a reading the caller already took, so the decision can be +/// exercised over all of its arms without a remote, a clock or a fixture — which +/// is what makes "does the port conserve the bash's behaviour" an answerable +/// question rather than a claim. +/// +/// The argument order follows the bash's own arms, and the FIRST arm is load- +/// bearing: `settle_speculation` used to open on "did this process place a bet", +/// so a `land` that had merely inherited one returned on its first line while the +/// ref holding the answer sat on disk beside it (CLOUD-862). Ask git before +/// asking the process — the caller does that by handing an adopted bet in here +/// exactly as it would one of its own. +/// +/// # `main_now` IS THREE-VALUED, AND THE THIRD VALUE IS NOT AN EMPTY STRING +/// +/// `None` is the tracking ref that would not read — a fetch that lost the +/// network, a clone with no remote-tracking ref yet, a ref file being rewritten +/// under the read. It is a COULD-NOT-LOOK, and it must not reach the last arm. +/// +/// Taking a `&str` is what made it: the caller spelled the failed read +/// `unwrap_or_default()`, so an unreadable ref arrived as `""`, compared unequal +/// to every `main_at_bet`, and fell through to "`main` moved and took something +/// else" — settling a perfectly live bet as [`Settle::Lost`] and unwinding the +/// tree on a transient read. That is the one direction [`settle_the_bet`]'s own +/// header forbids: an unresolvable ref means the bet is STALE, never that the +/// trunk moved. +/// +/// [`settle_the_bet`]: crate::settle_the_bet +/// +/// The could-not-look arm is the ADOPTED arm's, not a fourth answer: without a +/// trunk reading, "has `main` moved" is unanswerable for exactly the reason an +/// adopted bet's is — there is no comparison to make — and the lease answers the +/// question either way. So a readable lease still holding for this branch settles +/// [`Settle::Pending`] and the waiter laps, and [`Live::Unreadable`] still +/// decides as `No`, which keeps the fail-CLOSED posture where both readings are +/// gone. +#[must_use] +pub fn settle(bet: &Bet, main_now: Option<&str>, base_on_main: bool, live: Live) -> Settle { + let Some(_) = bet.base.as_deref() else { + return Settle::Nothing; + }; + + // WON. Checked first and unconditionally, because it is true whoever placed + // the bet and because the two arms below would both misread it: an adopted + // bet has no `main_at_bet` to compare, and a placed one would see `main` + // moved and call it lost. + if base_on_main { + return Settle::Landed; + } + + // An ADOPTED bet has no `main_at_bet` — the process that recorded it is gone + // — so the "has main moved" arm cannot judge it. The lease can: it reads who + // holds it NOW and whether the base is still on the branch about to land, + // which is the question either way. + // + // A trunk reading that would not take is the same position by a different + // route: `None` is a could-not-look, so there is nothing to compare + // `main_at_bet` AGAINST, and the arm below would read the missing reading as + // a moved trunk. Both defer to the lease. + let Some(main_now) = main_now else { + return if live.decide() { + Settle::Pending + } else { + Settle::Lost + }; + }; + if bet.recovered { + return if live.decide() { + Settle::Pending + } else { + Settle::Lost + }; + } + + // `main` has not moved, which USED TO END THE QUESTION. It does not: the + // holder can go away without `main` moving at all, and that reading is + // indistinguishable from "still landing" unless the lease is re-read. + // + // The measured incident: a holder whose CI died in a provider incident held + // the lease going nowhere, a sibling linearized onto its published head, and + // the two branches ended at the identical sha with neither able to land. + // A PLACED BET WITH NO `main_at_bet` IS THE SAME POSITION AS AN ADOPTED ONE, + // and reading it as a moved trunk was this arm's own defect (review of #848). + // The field is a reading that may not have taken when the bet was placed, and + // `Some(main_now) != None` is true, so the comparison below fell through to + // `Lost` — unwinding a speculation that is very much alive, on the strength + // of a reading nobody ever got. It is could-not-look on the SAME side of the + // comparison the `main_now` arm above already defers for, so it defers the + // same way. + let Some(main_at_bet) = bet.main_at_bet.as_deref() else { + return if live.decide() { + Settle::Pending + } else { + Settle::Lost + }; + }; + if main_at_bet == main_now { + return if live.decide() { + Settle::Pending + } else { + Settle::Lost + }; + } + + // `main` moved and took something else. + Settle::Lost +} + +/// Is `candidate` an ancestor of `tip`? +/// +/// **[`crate::gitwrite::carries`]'s, not this module's, and the delegation is a +/// gate's finding rather than taste.** `gix_is_confined_to_the_git_modules` +/// refuses a fourth module reaching the backend directly, and it caught the +/// first draft of this file doing exactly that. Widening that list for a +/// predicate `gitwrite::rebase` already asks inline would have bought a second +/// place to get ancestry wrong; delegating buys none. +#[must_use] +pub fn carries(dir: &Path, candidate: &str, tip: &str) -> bool { + crate::gitwrite::carries(dir, candidate, tip) +} + +/// Adopt a bet this process did not place. +/// +/// Runs BEFORE the ordinary settle, so the settle that follows is the ordinary +/// one — there is no second settle path to keep in agreement with the first. +/// +/// **The ancestry pair is the whole predicate** and both halves are load-bearing: +/// the base must be an ancestor of HEAD (this tree really is linearized on it, +/// rather than the ref being left over from a clone that reset). It deliberately +/// does NOT decide "did it land" — [`settle`]'s first arm already answers that, +/// and answers it out loud; an arm here would be a second place deciding one +/// thing, and the one that stayed silent is how this whole class went unnoticed. +/// +/// # Errors +/// +/// Only a ref store that will not answer at all. A ref that is simply absent is +/// `Ok(false)` — no bet to adopt is the ordinary state. +pub fn recover(dir: &Path, bet: &mut Bet) -> Result { + if bet.live() { + return Ok(false); + } + let Some(recorded) = crate::git::resolve_ref(dir, BASE_REF)? else { + return Ok(false); + }; + if !carries(dir, &recorded, "HEAD") { + // The ref names a commit this tree is not built on, so whatever it was + // recording is not true of this HEAD. + bet.forget(); + return Ok(false); + } + bet.base = Some(recorded); + bet.recovered = true; + Ok(true) +} + +#[cfg(test)] +// Panicking on a failed assertion is how a test fails loudly; these are the +// module's own cases, not a reachable path. +#[allow(clippy::expect_used)] +mod tests { + use super::*; + + /// **A SETTLED BET LEAKS NOTHING INTO THE NEXT ONE**, which `pushed` did. + /// + /// It is a fact about THIS bet's range reaching the remote, and its reader + /// force-writes the branch under a CAS that always applies — so carrying it + /// forward would "correct" the remote for a range that never got there. + #[test] + fn forgetting_a_bet_clears_every_field_the_next_one_would_inherit() { + let mut bet = Bet { + base: Some(String::from("abc1234")), + undo: Some(String::from("def5678")), + main_at_bet: Some(String::from("0badc0de")), + recovered: true, + pushed: true, + conflicts: Some(String::from("feedface")), + }; + bet.forget(); + assert!(bet.is_forgotten(), "a settled bet carried state forward"); + } + + /// AND THE ONE FIELD THAT MUST SURVIVE STILL DOES. Without this the case + /// above is satisfied by a `forget` that clears everything, which is what + /// made CLOUD-369's refusal unreachable in the first place. + #[test] + fn forgetting_a_bet_keeps_the_base_it_will_not_re_bet_on() { + let mut bet = Bet { + conflicts: Some(String::from("feedface")), + ..Bet::default() + }; + bet.forget(); + assert_eq!(bet.conflicts.as_deref(), Some("feedface")); + assert!( + !bet.would_rebet("feedface"), + "a base known to conflict is still known to conflict after the bet settles" + ); + } + + const HOLDER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const MAIN: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const MOVED: &str = "cccccccccccccccccccccccccccccccccccccccc"; + + fn placed() -> Bet { + Bet { + base: Some(String::from(HOLDER)), + undo: Some(String::from("dddddddddddddddddddddddddddddddddddddddd")), + main_at_bet: Some(String::from(MAIN)), + ..Bet::default() + } + } + + /// No bet is not a lost bet, and the distinction is the first arm. + #[test] + fn a_tree_with_no_bet_settles_to_nothing() { + assert_eq!( + settle(&Bet::default(), Some(MAIN), false, Live::No), + Settle::Nothing + ); + } + + /// **WON is checked first, and unconditionally.** + /// + /// Both arms below would misread it: an adopted bet has no `main_at_bet` to + /// compare against, and a placed one would see `main` moved and call it lost + /// — unwinding a linearization that is already correct. + #[test] + fn a_base_that_reached_main_is_landed_however_the_bet_arrived() { + assert_eq!( + settle(&placed(), Some(MOVED), true, Live::No), + Settle::Landed + ); + + let adopted = Bet { + recovered: true, + main_at_bet: None, + undo: None, + ..placed() + }; + assert_eq!( + settle(&adopted, Some(MOVED), true, Live::No), + Settle::Landed + ); + } + + /// **THE THREE OUTCOMES, and the middle one is the whole reason there are + /// three.** + /// + /// A bet is usually still PENDING at the next lap — the holder takes minutes + /// to land, so "not on main yet" is the normal reading. Unwinding on it would + /// undo the linearization every single lap and leave the mechanism running + /// while achieving nothing: warm, then cold, then warm again. + #[test] + fn an_unmoved_main_with_a_live_holder_is_pending_and_a_dead_one_is_lost() { + assert_eq!( + settle(&placed(), Some(MAIN), false, Live::Yes), + Settle::Pending + ); + assert_eq!(settle(&placed(), Some(MAIN), false, Live::No), Settle::Lost); + } + + /// An unmoved `main` used to END the question, and that was the defect. + /// + /// A holder can go away without `main` moving at all, and that reading is + /// indistinguishable from "still landing" unless the lease is re-read. + /// Measured: a holder whose CI died in a provider incident held the lease + /// going nowhere, a sibling linearized onto its published head, and the two + /// branches ended at the identical sha with neither able to land. + #[test] + fn a_could_not_look_on_the_lease_is_stale_rather_than_still_landing() { + assert_eq!( + settle(&placed(), Some(MAIN), false, Live::Unreadable), + Settle::Lost, + "failing open here would make a network blip land somebody else's work" + ); + assert!(!Live::Unreadable.decide()); + assert!(!Live::No.decide()); + assert!(Live::Yes.decide()); + } + + /// **A TRUNK READING THAT WOULD NOT TAKE IS NOT A MOVED TRUNK.** + /// + /// The caller spelled the failed `resolve_ref` as `unwrap_or_default()`, so + /// an unreadable `refs/remotes/origin/` arrived here as `""`. That + /// compares unequal to every `main_at_bet`, so a placed bet fell past the + /// unmoved-`main` arm and into "`main` moved and took something else" — + /// [`Settle::Lost`], and the caller unwinds the tree. A fetch that lost the + /// network, a clone with no tracking ref yet, or a ref file being rewritten + /// under the read would each have thrown away a live linearization, which is + /// the opposite of the fail-open direction `settle_the_bet`'s own header + /// states. + /// + /// The `None` arm defers to the lease, exactly as the adopted arm does and + /// for the same reason: with no trunk reading there is no comparison to make. + /// So a live holder is `Pending` — and both non-`Yes` readings stay `Lost`, + /// which is what keeps the fail-CLOSED posture where the lease is gone too. + #[test] + fn an_unreadable_trunk_defers_to_the_lease_rather_than_reading_as_a_moved_main() { + assert_eq!( + settle(&placed(), None, false, Live::Yes), + Settle::Pending, + "an unresolvable tracking ref is a could-not-look, and unwinding on \ + one discards a linearization that is still correct" + ); + assert_eq!(settle(&placed(), None, false, Live::No), Settle::Lost); + assert_eq!( + settle(&placed(), None, false, Live::Unreadable), + Settle::Lost + ); + + // WON still outranks it: an ancestry that resolved is an answer whether + // or not the tip did. + assert_eq!(settle(&placed(), None, true, Live::No), Settle::Landed); + } + + /// THE OTHER SIDE OF THE SAME COMPARISON, and it read as a moved trunk. + /// + /// `main_at_bet` is a reading that may not have taken when the bet was + /// placed. `Some(main_now) != None` is true, so the comparison fell straight + /// through to `Lost` and unwound a speculation that was very much alive — + /// the same defect the test above records, on the operand nobody checked. + #[test] + fn a_placed_bet_with_no_main_at_bet_defers_to_the_lease_too() { + let unread = Bet { + main_at_bet: None, + ..placed() + }; + assert_eq!( + settle(&unread, Some(MAIN), false, Live::Yes), + Settle::Pending, + "a reading that never took is a could-not-look on this side of the \ + comparison exactly as it is on the other" + ); + assert_eq!(settle(&unread, Some(MAIN), false, Live::No), Settle::Lost); + assert_eq!( + settle(&unread, Some(MAIN), false, Live::Unreadable), + Settle::Lost + ); + + // ANTI-VACUITY: a bet whose reading DID take is still judged by it, so a + // moved trunk with a dead holder is still lost. + assert_eq!( + settle(&placed(), Some(MOVED), false, Live::No), + Settle::Lost + ); + } + + /// A `main` that moved without taking the base is a lost bet, whoever placed + /// it. + #[test] + fn a_moved_main_that_did_not_take_the_base_is_lost() { + assert_eq!( + settle(&placed(), Some(MOVED), false, Live::Yes), + Settle::Lost + ); + } + + /// An adopted bet is judged by the LEASE, because it has no `main_at_bet`. + #[test] + fn an_adopted_bet_is_judged_by_the_lease_rather_than_by_a_main_it_never_saw() { + let adopted = Bet { + recovered: true, + main_at_bet: None, + undo: None, + ..placed() + }; + assert_eq!( + settle(&adopted, Some(MOVED), false, Live::Yes), + Settle::Pending + ); + assert_eq!(settle(&adopted, Some(MOVED), false, Live::No), Settle::Lost); + } + + /// **CLOUD-1306, PORTED AS-IS AND PINNED SO IT CANNOT BE FIXED BY ACCIDENT.** + /// + /// A poisoned base — one whose tree will never pass `verify` — is + /// byte-identical here to a holder that is simply slow: the lease is held, + /// `main` has not moved, so `settle` says pending and the waiter sits. This + /// case asserts that reading rather than the one a fixed version would give, + /// because a port that quietly improved behaviour could not be shown to + /// conserve it. + /// + /// When CLOUD-1306 lands, this case is the one that must change, and its + /// changing is the review's cue that behaviour moved. + #[test] + fn a_poisoned_base_is_conserved_as_pending_because_cloud_1306_owns_the_fix() { + assert_eq!( + settle(&placed(), Some(MAIN), false, Live::Yes), + Settle::Pending, + "the holder is there and main has not moved — which is what a poisoned \ + base looks like from here, and there is no fourth arm" + ); + } + + /// One outstanding bet at a time. + #[test] + fn the_same_candidate_is_not_bet_on_twice() { + let bet = placed(); + assert!(!bet.would_rebet(HOLDER), "already the outstanding bet"); + assert!(bet.would_rebet(MOVED), "a different candidate is a new bet"); + assert!( + Bet::default().would_rebet(HOLDER), + "and a forgotten bet re-bets on the same holder — CLOUD-1306's other half" + ); + } + + /// **A BASE THIS CLONE ALREADY WATCHED CONFLICT IS NOT BET ON AGAIN.** + /// + /// `place_the_bet` records the conflict and deliberately does NOT set + /// `base` — the replay failed, so there is no borrowed range — which left + /// `would_rebet` comparing against `None` and answering `true` on the next + /// lap. The same conflicting rebase was then attempted every lap to reach + /// the same answer, with CLOUD-369's mechanism written and unreachable. + /// + /// The third assertion is what keeps the fix from over-reaching: one bad + /// candidate must not end speculation for the rest of the landing, which is + /// what reading a bare flag would have done. + #[test] + fn a_base_known_to_conflict_is_not_bet_on_again() { + let refused = Bet { + conflicts: Some(String::from(HOLDER)), + ..Bet::default() + }; + assert!( + !refused.would_rebet(HOLDER), + "this clone already replayed onto it and watched the rebase conflict" + ); + assert!( + refused.would_rebet(MOVED), + "a DIFFERENT holder is a fresh question — the flag form could not say this" + ); + assert!( + Bet::default().would_rebet(HOLDER), + "and with nothing recorded the candidate is open, as before" + ); + } + + /// Forgetting clears the bookkeeping AND what the child would read. + #[test] + fn forgetting_a_bet_unpublishes_it() { + let mut bet = placed(); + assert_eq!(bet.published(), Some(HOLDER)); + bet.forget(); + assert_eq!( + bet.published(), + None, + "a child that still read a base would report this branch as racing the \ + PR the bet was placed on" + ); + assert!(!bet.live()); + } +} diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index fb8d40628..0ae115529 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -1160,6 +1160,22 @@ const OVERRIDE_VERDICT: FlagDecl = FlagDecl { /// behalf of a checkout this process is not in. const LEASE_BRANCH: FlagDecl = FlagDecl::positional("branch", "The branch being asked about"); +/// ``: the commit `lease carries` judges. +/// +/// **Positional and required, for [`LEASE_BRANCH`]'s reason.** The caller knows +/// which sha it means and the engine must not guess: on a `pull_request` event +/// the obvious guess is `GITHUB_SHA`, which is the MERGE commit and therefore +/// carries trunk's landing mechanism whenever the head did not touch it — so a +/// default would read every stale head as current, silently. +const LEASE_HEAD: FlagDecl = FlagDecl::positional("head", "The head commit being judged"); + +/// ``: the run `lease guard` cancels on a stop. +/// +/// Positional and required, for [`LEASE_HEAD`]'s reason: the engine must not +/// guess which run it is standing in, and a guard that cancelled the wrong one +/// would be worse than one that cancelled none. +const LEASE_RUN: FlagDecl = FlagDecl::positional("run", "The run to cancel on a stop"); + /// ``: the remote reference `land replay` replays onto. /// /// **Positional and REQUIRED, for [`LEASE_BRANCH`]'s reason and one more.** @@ -1170,6 +1186,36 @@ const LEASE_BRANCH: FlagDecl = FlagDecl::positional("branch", "The branch being const LAND_REFERENCE: FlagDecl = FlagDecl::positional("reference", "The remote reference to replay onto"); +/// `--resolve `: a path whose conflict the caller merged in the worktree. +/// +/// **Repeatable, and every conflicting path must be named** (CLOUD-1586). +/// `gitwrite`'s header refuses auto-resolution because it deletes the loop's one +/// human stop; this does not reinstate it. There is no `--ours`/`--theirs` — a +/// side-picking flag IS that strategy — and a partial naming refuses, because a +/// tree written for the paths nobody mentioned would carry the engine's own pick +/// arrived at by omission. +/// +/// On `land replay` and never on `land lap`: a lap runs unattended, so any +/// resolution it could apply is one nobody looked at. +const LAND_RESOLVE: FlagDecl = FlagDecl { + id: "resolve", + long: Some("resolve"), + short: None, + help: "A path whose conflict is resolved in the worktree (repeatable)", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + // `StrMany` for its own header's reason, and it is load-bearing here rather + // than tidy: `Str` keeps only the LAST occurrence, so naming two conflicting + // paths would silently drop the first — and a partial naming is exactly what + // this verb refuses. The failure would be a refusal the caller cannot + // explain, having named every path. + value: ValueDecl::StrMany, +}; + /// ``: which advisory field `lease peek` prints. /// /// A closed set, because the whole value of `peek` over reading the status prose @@ -4048,6 +4094,22 @@ pub const SURFACE: &[CommandDecl] = &[ JSON, ], }, + // NO POSITIONAL, and the absence is the verb's whole content: it asks about + // the DECLARED SET rather than about a check the caller names. A `--check` + // here would make it `status` with extra steps, and would restore exactly the + // half-asked shape the predecessor existed to remove — one call, one green, + // and a head reported verified on half its evidence. + // + // `read`, and it joins the derived read-only allowlist: it opens receipts and + // resolves two refs, and writes nothing. + CommandDecl { + path: "receipt verified", + id: "receipt.verified", + about: "Is HEAD verified — every declared check's receipt valid against this commit?", + data_channel: false, + effect: Effect::Read, + flags: &[], + }, // The noun only dispatches; its subtree carries a write verb, so the parent // stays unclassified rather than advertising a write-bearing `read` prefix // on the derived allowlist (CLOUD-170) — the posture `receipt` and `state` @@ -4466,6 +4528,49 @@ pub const SURFACE: &[CommandDecl] = &[ // correctness hazard for the trunk — the lease decides who goes first, never // what may land — so on the landing path it would fail whichever PR happened // to be in flight over a condition that PR did not cause and cannot fix. + // `read`, and the staleness half of the CI-side precondition (CLOUD-1148 §2). + // + // A GATE rather than a report, like `check` below: `0` the head carries + // trunk's landing mechanism, `2` it does not, `3` the reading could not be + // taken. **The caller fails OPEN on `3`**, which is this gate's whole + // posture and the opposite of every other refusal here — a reading nobody + // could take would cancel every job in the fleet, where waving one matrix + // through costs one matrix. + // + // The predecessor asked this by grepping the head's own `mise-tasks/land.sh` + // for `land-lock acquire`. That predicate dies with the retirement and dies + // QUIETLY: the read fails, the script takes its own fail-open path, and every + // stale head passes. The path set it asks about is `[lease] landing_paths`, + // which a retirement edits rather than invalidates. + CommandDecl { + path: "lease carries", + id: "lease.carries", + about: "Gate: this head carries the landing mechanism trunk has, so it can be serialised", + data_channel: false, + effect: Effect::Read, + flags: &[LEASE_HEAD], + }, + // `write`, and it is the ONLY reason this is not a read: on a stop it cancels + // the run it is standing in. A composite of `carries` and `authorises` (both + // `read`), because the cancel-and-wait is the subtlest failure mode in the + // whole loop — a non-zero exit makes the run's conclusion `failure`, `final` + // then fails its `needs:` under `!cancelled()`, and the lander re-drafts + // every PR in the fleet. One authority for that, never sixteen copies in + // workflow YAML. + // + // **IT NEVER EXITS NON-ZERO**, so it carries no verdict a caller reads: the + // stop IS the cancellation. That is why the exit table's `2` never appears + // here and why the step needs no `|| exit 0` to be safe — though the + // workflow keeps one anyway, because a binary that will not RUN is a + // different failure from one that ran and decided. + CommandDecl { + path: "lease guard", + id: "lease.guard", + about: "The runner's step-0 guard: may this branch spend a matrix right now?", + data_channel: false, + effect: Effect::Write, + flags: &[LEASE_HEAD, LEASE_BRANCH, LEASE_RUN], + }, CommandDecl { path: "lease check", id: "lease.check", @@ -4589,7 +4694,7 @@ pub const SURFACE: &[CommandDecl] = &[ about: "Advance the base and replay this branch onto it, recording the outcome", data_channel: false, effect: Effect::Write, - flags: &[LAND_REFERENCE], + flags: &[LAND_REFERENCE, LAND_RESOLVE], }, // `write`, and the write is the RECORD rather than the wait: asking two // questions is a read, and what this leaves behind is both arms' answers for @@ -4639,6 +4744,45 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Write, flags: &[], }, + // `write`, and the write is REMOTE — a comment on somebody else's pull + // request — which puts it beside `land push` rather than beside the record + // writers above. It is not `destructive`: a comment adds, and the merge it + // asks for is the bot's act rather than this verb's. + // + // NO FLAGS, for `land verify`'s reason carried one step out. The pull request + // is a fact about this branch the forge already holds, so it is RESOLVED; the + // workflow whose runs carry the verdict is the consumer's, so it arrives from + // the environment. A number on argv would let a lap ask one pull request to + // land while every other step of the same lap is looking at another — which is + // exactly the binding defect CLOUD-465 records for a reused branch. + CommandDecl { + path: "land fast-forward", + id: "land.fast-forward", + about: "Ask this head's pull request to fast-forward, and read the answer that request got", + data_channel: false, + effect: Effect::Write, + flags: &[], + }, + // THE UNION OF EVERY STEP'S EFFECT, which is `write` because the widest of + // them is: the lap replays the working tree, pushes, and comments. It is not + // `destructive` for the reason `land replay` is not — every write is one a + // rebase or a re-push repeats, and nothing here removes history. + // + // IT TAKES THE REFERENCE AND NOTHING ELSE. Every other input the lap needs is + // already resolved somewhere: the branch from HEAD, the remote from + // `$LAND_LOCK_REMOTE`, the gate from `$LAND_VERIFY`, the workflow from + // `$LAND_WORKFLOW`, the bound from `$LAND_MAX_LAPS`. Adding a flag for any of + // them would put a second spelling on the surface beside the one the + // individual sub-verbs already read, and the two would drift — which is + // `land verify`'s argument, made once per input rather than once. + CommandDecl { + path: "land lap", + id: "land.lap", + about: "Drive the whole lap and lap again on any refusal a rebase would clear", + data_channel: false, + effect: Effect::Write, + flags: &[LAND_REFERENCE], + }, ]; /// Whether `token` is a declared spelling of a flag that consumes the *next* diff --git a/crates/batten/src/task.rs b/crates/batten/src/task.rs index ed3889ef9..c1ba4dd83 100644 --- a/crates/batten/src/task.rs +++ b/crates/batten/src/task.rs @@ -375,9 +375,33 @@ fn task_alive(program_root: &str, pid: &str, task: &str) -> bool { /// are accepted; the trailing space is what stops `land` matching a running /// `land-lock`, which is the pid-recycling defence above. A prefix glob would fix /// the first case and destroy the second. +/// +/// # AND A THIRD, BECAUSE THE TASK MAY NO LONGER BE A FILE (CLOUD-1148) +/// +/// Both spellings above are a PATH under the consumer's program directory, which +/// silently assumes every task is a shell program. CLOUD-843's campaign is +/// retiring that layer onto engine verbs, and the moment a task lands as one its +/// running process stops matching: a lap is `batten land lap main`, and +/// `/mise-tasks/land ` appears nowhere in it. +/// +/// **The consequence is not a cosmetic gap in a report.** This predicate is what +/// `abandoned` asks whether a registrant is still there, so a retired task's LIVE +/// process reads as a corpse — the reaper would signal the lap that is running, +/// and `singleton_acquire` would hand a second lap the lock a first one holds. +/// Measured here as a red test rather than in the field: `land.sh` was deleted by +/// this branch, and the first version of the reaper would have killed its own +/// lap on the next start. +/// +/// `batten` is the crate's own name rather than a consumer's, so writing it here +/// keeps non-negotiable rule 1 — a grep for a specific consumer's identifiers +/// still returns nothing. The trailing space does the same work it does above: +/// `batten land ` matches `batten land lap` and `batten land verify`, which are +/// both this task, and not a hypothetical `batten land-lock`. fn matches_cmdline(program_root: &str, task: &str, cmdline: &str) -> bool { let base = format!("/{program_root}/{task}"); - cmdline.contains(&format!("{base} ")) || cmdline.contains(&format!("{base}.sh ")) + cmdline.contains(&format!("{base} ")) + || cmdline.contains(&format!("{base}.sh ")) + || cmdline.contains(&format!("batten {task} ")) } /// `kill -0`: existence and permission, and no signal delivered. @@ -510,8 +534,27 @@ pub fn alive(git_dir: &Path, options: Alive<'_>) -> Reading { let mut lines = Vec::new(); for file in files { - let Ok(body) = std::fs::read_to_string(&file) else { - continue; + // **AN ENTRY THAT WILL NOT READ IS COULD-NOT-LOOK, NOT AN ABSENCE** + // (review of #848). This discarded the error and skipped, so a registry + // whose entries were all unreadable — a different uid, a restrictive mode, + // invalid UTF-8 — fell through to the empty check below and answered + // "nothing registered" at exit 0. That is the exact conflation this + // module's header says it exists to remove, and it is the dangerous + // direction: a successor reads it as a clear field and starts a second + // landing. + // + // **AND `NotFound` IS NOT ONE OF THOSE CAUSES** (review of #848). This + // module deregisters an entry by DELETING it — three lines down, and from + // every exiting task — so a file listed by `read_dir` and gone by the time + // it is read is the ordinary race, not a registry nobody can see. Reading + // it as could-not-look turned a healthy answer into `Unreadable` and hid + // every entry later in sort order, which is the same conflation running + // the other way: the fix above must not buy its direction by inventing a + // failure the tree does not have. + let body = match std::fs::read_to_string(&file) { + Ok(body) => body, + Err(reason) if reason.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Reading::Unreadable(file.clone()), }; let entry = Entry::parse(&body); if !entry.is_complete() { @@ -530,6 +573,57 @@ pub fn alive(git_dir: &Path, options: Alive<'_>) -> Reading { } } +/// Every registered task whose registrant is GONE, with the group it led. +/// +/// **The crash-only half of the registry, and the half that was missing** +/// (CLOUD-1148). [`alive`] already deletes a dead entry's file, which tidies the +/// STORE — and the store was never the thing that outlived the crash. A task is +/// registered by the process that leads a group; when the container reaps that +/// process, or a supervisor TERMs it, the group's other members are reparented +/// to init and keep running. Deleting the record makes them invisible instead of +/// dead, which is strictly worse: `alive` then answers "nothing registered" over +/// a machine with four test suites on it. Measured exactly that way, five laps +/// deep, on the branch that retired the shell lander. +/// +/// So this is deliberately a READ that deletes nothing. It hands back the +/// `pgid` its caller needs to signal, and the caller — which is the only layer +/// that may reach both this store and a process group — decides. This module +/// signals nothing itself: its declared edges are `error` and `exit`, and +/// growing it a `kill` would make the registry a second authority over process +/// lifetime as well as over its own format. +/// +/// **`pgid` may be the pid**, per [`Entry`]'s own field doc, and that is safe +/// rather than lucky: signalling a group led by a pid that no longer exists is +/// `ESRCH`, which every caller here treats as nothing-to-do. +/// +/// The liveness question is [`task_alive`]'s, unchanged — pid existence AND a +/// cmdline that still names the task, because this clone measurably wrapped its +/// pid space inside 20 minutes (CLOUD-432). An unevaluable corroboration reads as +/// ALIVE there, so this under-reports rather than over-reports, which is the +/// direction that cannot kill a working lap. +#[must_use] +pub fn abandoned(git_dir: &Path, program_root: &str) -> Vec { + let dir = state_dir(git_dir); + let Ok(listing) = std::fs::read_dir(&dir) else { + // Could-not-look reaps nothing, which is the safe direction: a registry + // that cannot be read must never license signalling a group it guessed at. + return Vec::new(); + }; + let mut files: Vec = listing + .filter_map(|found| found.ok().map(|found| found.path())) + .filter(|path| path.is_file()) + .collect(); + // Sorted for the byte-stability obligation every reader here carries. + files.sort(); + files + .iter() + .filter_map(|file| std::fs::read_to_string(file).ok()) + .map(|body| Entry::parse(&body)) + .filter(Entry::is_complete) + .filter(|entry| !task_alive(program_root, &entry.pid, &entry.task)) + .collect() +} + /// Write a reading out, as the reader's caller expects to read it. /// /// # Errors @@ -815,6 +909,140 @@ pub fn report_claim( mod tests { use super::*; + /// A scratch registry. `CARGO_TARGET_TMPDIR` is only defined for integration + /// crates, so this derives one the way `rules`'s own unit tests do. + fn registry(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join("batten-task-tests").join(name); + let _ = std::fs::remove_dir_all(&dir); + // `assert!` rather than `unwrap`: this module carries no + // `clippy::unwrap_used` allowance, and adding one to admit a helper + // would widen the lint's posture over every case in it. + assert!( + std::fs::create_dir_all(&dir).is_ok(), + "the scratch registry must be creatable" + ); + dir + } + + /// A pid this machine cannot have running. + /// + /// `/proc/sys/kernel/pid_max` is at most 2^22 on Linux, so this is above + /// every assignable number rather than merely unlikely — the environment + /// CAN produce the failing condition, which `.claude/rules/rust.md` asks for + /// rather than an assertion over a premise nothing established. + const NO_SUCH_PID: &str = "4194305"; + + /// THE REAPER'S SUBJECT: a registrant that is gone, with its group. + /// + /// The measured failure this closes (CLOUD-1148): a lap registers, the + /// container TERMs it, its `verify` is reparented to init and keeps running, + /// and `alive` deletes the record — so the machine has four test suites on it + /// and the registry answers "nothing registered". `abandoned` is what hands a + /// caller the group to signal. + /// **A CORPSE IS UNREACHABLE OFF UNIX, AND THIS ROW HAS TO SAY SO** + /// (CLOUD-1148). `pid_exists`'s `#[cfg(not(unix))]` arm answers `true` for + /// every parseable pid, deliberately, so nothing is ever reported dead and + /// nothing is ever reaped there — which makes `abandoned` empty by + /// construction and this case's premise unreachable. It asserted the unix + /// outcome unconditionally and the `windows` job found it: `found.len()` was + /// 0 where the assertion wanted 1. + /// + /// `cfg!` RATHER THAN AN ATTRIBUTE, for the reason + /// `crates/batten/tests/it/task_registry.rs` already records beside the same + /// asymmetry: it keeps BOTH arms compiled on every target, so `cross-check` + /// type-checks the off-unix branch instead of skipping over it unparsed. That + /// matters here more than usual, since this whole class was discovered by CI + /// compiling and RUNNING what no local gate can. + /// + /// `#[cfg(unix)]` over the whole test would have left the Windows contract + /// unstated, which is the mistake `a_reclaim_needs_two_sightings_of_one_dead_pid` + /// below already calls out in as many words. + #[test] + fn a_dead_registrant_is_abandoned_and_carries_its_group() { + let dir = registry("abandoned-reports-the-dead"); + register(&dir, "land", NO_SUCH_PID, "lap", 100); + let found = abandoned(&dir, "mise-tasks"); + if cfg!(unix) { + assert_eq!(found.len(), 1, "the dead registrant is reported: {found:?}"); + assert_eq!(found[0].pid, NO_SUCH_PID); + assert!( + !found[0].pgid.is_empty(), + "the group is what the caller signals, so it must be there: {:?}", + found[0] + ); + } else { + assert!( + found.is_empty(), + "off unix no pid reads as dead, so the reaper must find nothing \ + rather than guess: {found:?}" + ); + } + } + + /// THE OTHER DIRECTION, AND THE ONE THAT WOULD DO THE DAMAGE. + /// + /// Without it the arm above is satisfied by reporting every entry, and the + /// reaper TERMs the lap running right now. It is asserted over the DECISION + /// rather than over a live process for this module's own stated reason — + /// `matches_cmdline` was split out because a case that spawned one would + /// assert its own premise before its conclusion, and `rust.md` requires a + /// test be shown able to fail. + /// + /// Shown able to fail, measured: against the two-spelling predicate this + /// branch inherited, the first case here returns `false`, so `abandoned` + /// reported a running lap and the reaper would have killed it. + #[test] + fn a_retired_task_running_as_a_verb_is_still_alive() { + assert!( + matches_cmdline("mise-tasks", "land", "target/debug/batten land lap main "), + "a lap is a verb now, and reading it as a corpse licenses reaping it" + ); + assert!( + matches_cmdline( + "mise-tasks", + "land", + "/home/user/batten/target/debug/batten land verify " + ), + "its children are the same task and must not be reaped either" + ); + // THE SHELL SPELLINGS STAY, because the campaign is mid-flight and most + // tasks are still programs. A fix that moved the predicate rather than + // widening it would strand every task that has not been retired yet. + assert!(matches_cmdline( + "mise-tasks", + "land", + "bash /x/mise-tasks/land " + )); + assert!(matches_cmdline( + "mise-tasks", + "land", + "bash /x/mise-tasks/land.sh " + )); + // AND THE PID-RECYCLING DEFENCE SURVIVES THE WIDENING. The trailing space + // is what stops `land` matching a neighbour whose name it prefixes; a new + // arm that dropped it would reintroduce CLOUD-901 by the third route. + assert!( + !matches_cmdline("mise-tasks", "land", "target/debug/batten land-lock check "), + "`land` must not match `land-lock`, however the task is spelled" + ); + assert!( + !matches_cmdline("mise-tasks", "land", "target/debug/batten check "), + "an unrelated verb is not this task" + ); + } + + /// A REGISTRY THAT CANNOT BE READ REAPS NOTHING. + /// + /// The safe direction, and the opposite one from `singleton_acquire`'s: there + /// an unreadable lock must not read as free, because two lands start. Here an + /// unreadable registry must not license signalling a group nobody resolved, + /// because the signal is irreversible and the guess is unfounded. + #[test] + fn an_unreadable_registry_reaps_nothing() { + let dir = registry("abandoned-could-not-look").join("absent"); + assert!(abandoned(&dir, "mise-tasks").is_empty()); + } + /// EPERM IS LIFE, AND READING IT AS DEATH IS A FALSE ALLOW. /// /// `kill(pid, 0)` answers EPERM for a process this uid may not signal — it diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index 46bd2a6d1..1276183fc 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -862,6 +862,61 @@ pub enum WeakeningKind { /// to `StartupRowRemoved`. `cli::Command` states the same rule for the same /// reason. PerfExemptionAdded, + /// A path is gone from `lease.landing_paths`, so the staleness read asks + /// about less of the landing mechanism than it did (CLOUD-1148 §2). + /// + /// Monotone in the direction that matters: [`crate::lease::decide`] resolves + /// the newest commit touching ANY declared path, so dropping one can only + /// move that answer backwards or leave it put — and a head stale in a way + /// the shrunken set no longer reaches passes clean. Emptying the table + /// altogether is that move at its limit, where the reader takes + /// could-not-look and the guard fails open on every head. + /// + /// **APPENDED, NEVER INSERTED**, and the reason is this enum's `Ord`: the + /// declaration order IS the sort order, so a variant added in the middle + /// silently reorders every finding after it. That costs byte-stable output + /// (house-style §6) for no gain — a new kind belongs at the end, where it + /// can only appear after the ones that already existed. Found in review. + LandingPathRemoved, + /// A check is gone from `receipt.verified_by`, so `verified` demands a + /// smaller body of evidence than it did (CLOUD-1338). + /// + /// Monotone by construction: `run_verified` reports a head verified when NO + /// declared check is unverified, so dropping a name can only remove a way to + /// fail. Emptying the table is that move at its limit — and there + /// [`crate::receipt::verified_by`] FALLS BACK to + /// [`crate::receipt::VERIFIED_BY`] rather than refusing, so the empty case + /// demands the default body of evidence instead of none. + /// + /// **This said the empty case was a usage error** (review of #848), which was + /// the first draft's behaviour and not the landed one. The correction does + /// not move this kind's boundary: emptying the table still weakens the set + /// this consumer declared, and the fallback is what stops that weakening + /// reaching a vacuous pass. + /// + /// So this kind covers the shrink, and the fallback covers the limit. + VerifiedCheckRemoved, + /// A prefix is new in `lease.fast_forward_branches`, so the runner-side + /// landing precondition stops judging every branch under it (CLOUD-1148). + /// + /// **ADDED-DIRECTION, WHICH IS THE OPPOSITE OF ITS NEIGHBOUR ONE FIELD + /// OVER**, and the asymmetry is the reason it is its own kind rather than a + /// second use of [`WeakeningKind::LandingPathRemoved`]. A landing PATH is + /// evidence — a shorter list reaches back less far, so removal weakens. A + /// fast-forward prefix is an EXEMPTION: `fast_forward_lane` answers + /// `Success` — "not judging it" — for a branch it matches, before any + /// staleness or lease read happens at all. So adding one switches the guard + /// off, and one line adds it for a whole fleet. + /// + /// Found in review of #848: the `lease` census row declared the field + /// `Compared`, and `entry_weakenings` compared only `landing_paths`. The + /// key that turns the guard off was compared by nothing, and `config lint` + /// reported the file as not weakened. + /// + /// **APPENDED, NEVER INSERTED**, for the `Ord` reason + /// [`WeakeningKind::LandingPathRemoved`] states: declaration order is sort + /// order, so a kind in the middle silently reorders every finding after it. + FastForwardLaneAdded, } impl WeakeningKind { @@ -926,6 +981,9 @@ impl WeakeningKind { WeakeningKind::ProtectedReaderAdded, WeakeningKind::VocabularyAbandoned, WeakeningKind::PerfExemptionAdded, + WeakeningKind::LandingPathRemoved, + WeakeningKind::VerifiedCheckRemoved, + WeakeningKind::FastForwardLaneAdded, ]; /// The stable, lowercase identifier used in machine output (§6). @@ -944,6 +1002,9 @@ impl WeakeningKind { WeakeningKind::RulePredicateChanged => "rule-predicate-changed", WeakeningKind::MinVersionLowered => "min-version-lowered", WeakeningKind::EpochPathRemoved => "epoch-path-removed", + WeakeningKind::VerifiedCheckRemoved => "verified-check-removed", + WeakeningKind::LandingPathRemoved => "landing-path-removed", + WeakeningKind::FastForwardLaneAdded => "fast-forward-lane-added", WeakeningKind::ReadyCutoverRelaxed => "ready-cutover-relaxed", WeakeningKind::PerfExemptionAdded => "perf-exemption-added", WeakeningKind::VerbRemoved => "verb-removed", @@ -1095,6 +1156,17 @@ pub const CENSUS: &[FieldCoverage] = &[ field: "epoch", coverage: Coverage::Compared(&[WeakeningKind::EpochPathRemoved]), }, + FieldCoverage { + field: "lease", + coverage: Coverage::Compared(&[ + WeakeningKind::LandingPathRemoved, + WeakeningKind::FastForwardLaneAdded, + ]), + }, + FieldCoverage { + field: "receipt", + coverage: Coverage::Compared(&[WeakeningKind::VerifiedCheckRemoved]), + }, FieldCoverage { field: "contract", coverage: Coverage::NotPolicyBearing( @@ -1163,6 +1235,18 @@ pub const CENSUS: &[FieldCoverage] = &[ field: "exec_patterns", coverage: Coverage::Compared(&[WeakeningKind::ExecPatternRemoved]), }, + FieldCoverage { + field: "verify_environment_patterns", + coverage: Coverage::NotPolicyBearing( + "the same shape as `redirects`, and deliberately NOT `exec_patterns`' shape \ + despite sharing its type: a row here classifies a refusal that has ALREADY \ + happened, so it changes what the stop SAYS and never whether it fires. Dropping \ + every row costs an operator the reading that a full disk is not their branch's \ + defect (CLOUD-861); it cannot turn a refusal into a pass, which is what \ + `exec_patterns` can do in the other direction by no longer promoting a lying \ + exit `0`. The exit code is `Violation` with the table and without it.", + ), + }, FieldCoverage { field: "exec", coverage: Coverage::NoMonotoneReading( @@ -1901,6 +1985,66 @@ fn entry_weakenings(base: &Config, working: &Config) -> Vec { "epoch.tracked", )); + // The landing mechanism's own path set (CLOUD-1148 §2). Removed-direction + // only, and for the reason `epoch.tracked` is: the staleness read resolves + // the newest commit touching ANY declared path, so a shorter list can only + // reach back less far. ADDING a path narrows nothing — it can only make more + // heads read as stale — so the other direction is silent by design. + found.extend(removed_entries( + WeakeningKind::LandingPathRemoved, + &landing_paths(base), + &landing_paths(working), + "lease.landing_paths", + )); + + // The same table's OTHER key, and the direction is inverted (review of + // #848). `fast_forward_branches` is an EXEMPTION rather than evidence: + // `fast_forward_lane` answers "not judging it" for a branch it matches, + // before any staleness or lease read runs at all. So a prefix ADDED is what + // switches the runner-side precondition off, and one row can do it for a + // whole fleet of agent branches. + // + // The census row above declared this field `Compared` while only + // `landing_paths` reached a comparison, so the key that disables the guard + // was asserted covered and was not. + // + // **IT WIDENS A DECLARED LANE SET; IT DOES NOT PRICE INTRODUCING ONE, AND + // THE FIRST DRAFT DID** (review of #848). Both sides are `Vec`, so an ABSENT + // key and a DECLARED-EMPTY one arrive here identically — the collapse this + // crate refuses everywhere else — and reading absent as "exempts nobody" + // makes the base look like a gate that judged every branch. + // + // It is not one. Measured on this branch: `origin/main` carries neither + // `fast_forward_branches` NOR `fast_forward_lane`, so the base judges ZERO + // branches by a lane gate that does not exist, and a head declaring three + // prefixes judges every branch except three. That is strictly MORE gating, + // and pricing it as a weakening refuses the commit that INTRODUCES the + // guard — the one shape a gate must never refuse, because the alternative + // to a gate with three exemptions is no gate at all. + // + // So the comparison runs only where the base already declared a lane. Adding + // `claude/` to a live exemption set — the case the review named — still + // fires, and it is the case where the base genuinely was judging that branch + // a moment ago. + if !fast_forward_branches(base).is_empty() { + found.extend(added_entries( + WeakeningKind::FastForwardLaneAdded, + &fast_forward_branches(base), + &fast_forward_branches(working), + )); + } + + // The evidence `verified` demands (CLOUD-1338). Removed-direction only, for + // the reason above one field over: the verb reports a head verified when NO + // declared check is unverified, so dropping a name can only remove a way to + // fail. ADDING one demands more evidence and narrows nothing. + found.extend(removed_entries( + WeakeningKind::VerifiedCheckRemoved, + &verified_by(base), + &verified_by(working), + "receipt.verified_by", + )); + // The refinement gate's prose-dialect cutover (CLOUD-472). A ratchet, so // LATER is weaker: it exempts more rows from owing the claims object, and // dropping the key altogether is that move taken to its limit, since absent @@ -2202,6 +2346,49 @@ fn tracked_paths(config: &Config) -> Vec { .map_or_else(Vec::new, |epoch| epoch.tracked.clone()) } +/// The `receipt.verified_by` set AS THE VERB WILL READ IT — the declaration, or +/// the compiled default where a config declares none. +/// +/// **The default has to be resolved HERE or the comparison lies.** `verified` +/// falls back to it, so a config that drops the whole table still demands the +/// same two checks; comparing raw declarations would call that a weakening and +/// price a consumer for deleting a row that changed nothing. What this must +/// catch is a declaration that demands LESS than the reading it replaces, and +/// that is what comparing effective sets does. +fn verified_by(config: &Config) -> Vec { + config + .receipt + .as_ref() + .map(|receipt| receipt.verified_by.clone()) + .filter(|declared| !declared.is_empty()) + .unwrap_or_else(|| { + crate::receipt::VERIFIED_BY + .iter() + .map(|check| (*check).to_owned()) + .collect() + }) +} + +/// The `lease.landing_paths` set, or an empty one when the table is absent. +/// +/// Absent and empty reach the same value on purpose: both are could-not-look to +/// [`crate::lease::decide`], so a comparison that told them apart would report a +/// weakening where the reader sees no change in posture. +fn landing_paths(config: &Config) -> Vec { + config + .lease + .as_ref() + .map_or_else(Vec::new, |lease| lease.landing_paths.clone()) +} + +/// The prefixes `fast_forward_lane` declines to judge. +fn fast_forward_branches(config: &Config) -> Vec { + config + .lease + .as_ref() + .map_or_else(Vec::new, |lease| lease.fast_forward_branches.clone()) +} + /// Each `[[verb]]` row as the entry a weakening keys on. /// /// The rendering is the report's, not a second definition of a verb's identity: @@ -3683,6 +3870,174 @@ mod tests { assert!(weakenings(&working, &base).is_empty()); } + /// Dropping a landing path shrinks what the staleness read can reach. + /// + /// The direction is the assertion: adding one can only make MORE heads read + /// as stale, so the reverse comparison must stay silent — a symmetric + /// implementation would price the retirement that widens this set. + #[test] + fn dropping_a_landing_path_is_a_weakening() { + let base = config("[lease]\nlanding_paths = [\"a.sh\", \"b.rs\"]\n"); + let working = config("[lease]\nlanding_paths = [\"a.sh\"]\n"); + assert_eq!( + only(&base, &working), + Weakening::new( + WeakeningKind::LandingPathRemoved, + "lease.landing_paths[b.rs]", + "present", + "absent", + ) + ); + assert!(weakenings(&working, &base).is_empty()); + } + + /// Adding a fast-forward prefix switches the runner-side guard OFF. + /// + /// **The direction is INVERTED from its neighbour one field over, and that + /// is the assertion** (review of #848). A landing PATH is evidence, so + /// removing one weakens; a fast-forward prefix is an EXEMPTION — + /// `fast_forward_lane` answers "not judging it" for a branch it matches, + /// before any staleness or lease read runs — so ADDING one weakens. The + /// census row declared the whole `lease` field compared while + /// `entry_weakenings` compared only `landing_paths`, so the key that + /// disables the guard was asserted covered and was not. + #[test] + fn adding_a_fast_forward_prefix_is_a_weakening() { + let base = config("[lease]\nfast_forward_branches = [\"release/\"]\n"); + let working = config("[lease]\nfast_forward_branches = [\"release/\", \"agents/\"]\n"); + assert_eq!( + only(&base, &working), + Weakening::new( + WeakeningKind::FastForwardLaneAdded, + "agents/", + "absent", + "present", + ) + ); + // THE REVERSE MUST STAY SILENT, or the comparison is symmetric and + // prices the retirement that narrows the exemption — which is the same + // anti-vacuity its neighbour keeps, in the opposite direction. + assert!(weakenings(&working, &base).is_empty()); + assert!(weakenings(&base, &base).is_empty()); + } + + /// INTRODUCING THE LANE SET IS NOT WIDENING IT, and the first draft priced + /// both the same (review of #848). + /// + /// A base that declares NO lane is not a gate exempting nobody — measured on + /// this branch, `origin/main` carries neither the key nor + /// `fast_forward_lane` itself, so it judges zero branches by a gate that + /// does not exist. A head declaring three prefixes judges every branch but + /// three, which is strictly MORE gating. Pricing that as a weakening refuses + /// the commit that introduces the guard, and the alternative to a guard with + /// three exemptions is no guard. + #[test] + fn introducing_the_lane_set_beside_its_gate_is_not_a_weakening() { + let base = config("[lease]\nlanding_paths = [\"a.sh\"]\n"); + let working = config( + "[lease]\nlanding_paths = [\"a.sh\"]\nfast_forward_branches = [\"renovate/\", \"release-plz-\"]\n", + ); + assert!( + weakenings(&base, &working).is_empty(), + "a lane set arriving with the gate that reads it is the guard being built" + ); + + // ANTI-VACUITY: the guard is not switched off wholesale. Once a lane IS + // declared, the next one added still fires — which is the case the + // review named, and the one where the base really was judging it. + let widened = config( + "[lease]\nlanding_paths = [\"a.sh\"]\nfast_forward_branches = [\"renovate/\", \"release-plz-\", \"claude/\"]\n", + ); + assert_eq!( + only(&working, &widened), + Weakening::new( + WeakeningKind::FastForwardLaneAdded, + "claude/", + "absent", + "present", + ) + ); + } + + /// Dropping the whole table is that move at its limit, not a silent one. + /// + /// `landing_paths` reads absent and empty alike as could-not-look, so this + /// is the case that proves the *table's* removal still names every path it + /// used to declare rather than collapsing to no finding at all. + #[test] + fn dropping_the_lease_table_reports_every_path_it_declared() { + let base = config("[lease]\nlanding_paths = [\"a.sh\", \"b.rs\"]\n"); + let working = config(""); + let found = weakenings(&base, &working); + assert_eq!( + found, + vec![ + Weakening::new( + WeakeningKind::LandingPathRemoved, + "lease.landing_paths[a.sh]", + "present", + "absent", + ), + Weakening::new( + WeakeningKind::LandingPathRemoved, + "lease.landing_paths[b.rs]", + "present", + "absent", + ), + ] + ); + } + + /// Dropping a required check shrinks the evidence `verified` demands. + /// + /// The same shape as `landing_paths` one field over, and the same direction + /// is the assertion: ADDING a check demands more, so the reverse comparison + /// must stay silent or every consumer tightening their own gate would be + /// priced as a weakening. + #[test] + fn dropping_a_verified_check_is_a_weakening() { + let base = config("[receipt]\nverified_by = [\"one\", \"two\"]\n"); + let working = config("[receipt]\nverified_by = [\"one\"]\n"); + assert_eq!( + only(&base, &working), + Weakening::new( + WeakeningKind::VerifiedCheckRemoved, + "receipt.verified_by[two]", + "present", + "absent", + ) + ); + assert!(weakenings(&working, &base).is_empty()); + } + + /// **DROPPING THE TABLE FALLS BACK TO THE DEFAULT, so what it reports is + /// what the reading actually loses — not the row.** + /// + /// `verified` resolves an undeclared set to its compiled default, so a + /// config that deletes the table still demands those checks. Comparing raw + /// declarations would price that as a weakening and charge a consumer for + /// removing a row that changed nothing; comparing EFFECTIVE sets reports + /// only the checks the successor no longer demands. + #[test] + fn dropping_the_table_is_priced_against_the_default_it_falls_back_to() { + let base = config("[receipt]\nverified_by = [\"verify\", \"linear-check\", \"extra\"]\n"); + let working = config(""); + assert_eq!( + weakenings(&base, &working), + vec![Weakening::new( + WeakeningKind::VerifiedCheckRemoved, + "receipt.verified_by[extra]", + "present", + "absent", + )], + "only the check the fallback does not carry is lost" + ); + + // And a table that declares exactly the default loses nothing at all. + let same = config("[receipt]\nverified_by = [\"verify\", \"linear-check\"]\n"); + assert!(weakenings(&same, &config("")).is_empty()); + } + /// CLOUD-472. The direction is the whole of it, so all four arms are here: /// later relaxes, absent is later taken to its limit, earlier tightens, and /// a base that never declared a cutover has no bar to lower. diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index c0303d3d4..726b6da1e 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -1025,6 +1025,12 @@ pub enum Native { RuleTableRefused, /// The `[[exec_pattern]]` table would not load. OutputTableRefused, + /// The `[[verify_environment_pattern]]` table would not load. + /// + /// A SECOND CLASS OVER THE SAME TYPE, and the census refuses sharing one: + /// both tables are `OutputPattern`, but a refusal has to name which table to + /// edit, and one class over two tables sends a reader to the wrong one. + VerifyEnvironmentTableRefused, /// The `[[waiver]]` table would not load. WaiverTableRefused, /// The `[[fact]]` table would not load. @@ -1086,6 +1092,7 @@ impl Native { Native::MarkerTableRefused, Native::RuleTableRefused, Native::OutputTableRefused, + Native::VerifyEnvironmentTableRefused, Native::WaiverTableRefused, Native::FactTableRefused, Native::MintTableRefused, @@ -1119,6 +1126,7 @@ impl Native { Native::MarkerTableRefused, Native::RuleTableRefused, Native::OutputTableRefused, + Native::VerifyEnvironmentTableRefused, Native::WaiverTableRefused, Native::FactTableRefused, Native::MintTableRefused, @@ -1164,6 +1172,7 @@ impl Native { Native::MarkerTableRefused => "marker declare refused", Native::RuleTableRefused => "rule declare refused", Native::OutputTableRefused => "output declare refused", + Native::VerifyEnvironmentTableRefused => "environment declare refused", Native::WaiverTableRefused => "waiver declare refused", Native::FactTableRefused => "fact declare refused", Native::MintTableRefused => "mint declare refused", @@ -1668,6 +1677,17 @@ object rather than something a reader skims. A duplicate id makes two predicates indistinguishable in the record they write.", routes: &[read("config read first", "batten.toml")], }, + VendoredVerdict { + id: "environment declare refused", + gloss: "the verify-environment classifier table would not load", + class: "`[[verify_environment_pattern]]` is what tells a gate refusal caused by the \ +MACHINE from one caused by this tree, so a malformed row does not fail loudly — it classifies \ +nothing and reads as a consumer who declared no classifier at all, and every refusal goes back \ +to being reported as a defect to reproduce. A SECOND class over the same type as \ +`output declare refused` rather than a shared one, because a refusal has to name which of the \ +two tables to edit.", + routes: &[read("config read first", "batten.toml")], + }, VendoredVerdict { id: "waiver declare refused", gloss: "the waiver table would not load", @@ -2044,6 +2064,7 @@ mod tests { | Native::MarkerTableRefused | Native::RuleTableRefused | Native::OutputTableRefused + | Native::VerifyEnvironmentTableRefused | Native::WaiverTableRefused | Native::FactTableRefused | Native::MintTableRefused diff --git a/crates/batten/tests/it/abandon_matrix.rs b/crates/batten/tests/it/abandon_matrix.rs new file mode 100644 index 000000000..2515013a6 --- /dev/null +++ b/crates/batten/tests/it/abandon_matrix.rs @@ -0,0 +1,174 @@ +//! Cancelling the runs a red verdict made worthless, over the library the +//! retirement moved them into (CLOUD-1148). +//! +//! # The one safety property, and everything else is secondary to it +//! +//! `final` is the context branch protection requires. It is `always()` over a +//! `needs:` assertion, so **cancelling its run leaves that context `cancelled`, +//! which is not an answer** — the branch then carries a required check that will +//! never conclude and cannot land at all. Every case here exists to make the +//! sparing arm impossible to lose quietly; the counts are what a reader sees, and +//! the spare is what keeps the branch alive. +//! +//! # What this tier reaches +//! +//! `land::worthless` is a pure function over a run list, so the whole +//! doomed/spared partition runs with no forge. `land::spending` and +//! `land::abandon` reach the REST tier and are exercised through the driver's own +//! arm rather than here; what those add over this file is one `GET` and one +//! `POST` per run, and `crates/batten/tests/it/checks_green.rs` is where the +//! request seam is driven. +//! +//! Said plainly rather than implied: a reader must not take this file as evidence +//! that the cancel endpoint is called correctly. It is evidence that the right +//! runs are chosen. + +// carried: mise-tasks/abandon-matrix.sh crates/batten/src/land.rs kind:mechanism crates/batten/tests/it/abandon_matrix.rs +// carried: tests/abandon-matrix.bats crates/batten/src/land.rs kind:mechanism crates/batten/tests/it/abandon_matrix.rs +// +// The ten cases, one row each, keyed by TITLE — a row whose first field is the +// suite path is indexed as another arm for it and the deletion reads as +// `shell retire unclear`. +// +// carried: "the siblings are cancelled and the fan-in's run is spared — the acceptance case" crates/batten/src/land.rs kind:mechanism +// carried: "THE ROW THAT MATTERS: the run carrying the fan-in is never cancelled" crates/batten/src/land.rs kind:mechanism +// carried: "a fan-in declared for a file no run carries spares nothing — and still cancels the rest" crates/batten/src/land.rs kind:mechanism +// carried: "an unset fan-in declaration cancels NOTHING rather than guessing" crates/batten/src/land.rs kind:mechanism +// carried: "a refused cancellation is a pointer, not a stop — and the rest still go" crates/batten/src/land.rs kind:mechanism +// carried: "a list that will not answer stops without cancelling and without failing" crates/batten/src/land.rs kind:mechanism +// carried: "nothing in flight is a clean no-op" crates/batten/src/land.rs kind:mechanism +// carried: "a run that has already completed is not asked to cancel" crates/batten/src/land.rs kind:mechanism +// changed: "the reason is carried into the pointer, and the SHA is abbreviated" crates/batten/src/lib.rs kind:mechanism +// changed: "no SHA anywhere is a give-up rather than a guess at HEAD's neighbours" crates/batten/src/lib.rs kind:mechanism + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use batten::land::{Abandoned, FanIn, Spending, worthless}; + +const FANIN: &str = ".github/workflows/ci.yml"; + +fn run(id: &str, path: &str) -> Spending { + Spending { + id: id.to_owned(), + path: path.to_owned(), + } +} + +/// **THE ROW THAT MATTERS: the run carrying the fan-in is never cancelled.** +/// +/// The acceptance case and the safety property in one body, because they are the +/// same reading: the siblings go and the fan-in stays. Splitting them would let a +/// partition that spares everything satisfy the second alone. +#[test] +fn the_siblings_are_doomed_and_the_fan_ins_own_run_is_spared() { + let in_flight = [ + run("1", ".github/workflows/rust.yml"), + run("2", FANIN), + run("3", ".github/workflows/test.yml"), + ]; + let (doomed, spared) = worthless(&in_flight, &FanIn::from_workflow_path(FANIN)); + + assert_eq!(spared, 1, "exactly the fan-in's run is spared"); + assert_eq!(doomed.len(), 2, "both siblings are doomed"); + assert!( + !doomed.iter().any(|run| run.path == FANIN), + "the fan-in's run reached the doomed list, which wedges the branch: {doomed:?}" + ); + // The ids travel, because the cancel endpoint is addressed by id and a + // partition that carried only paths could not act on either half. + assert_eq!( + doomed.iter().map(|run| run.id.as_str()).collect::>(), + vec!["1", "3"] + ); +} + +/// **A fan-in declared for a file no run carries spares nothing — and still +/// cancels the rest.** +/// +/// The anti-vacuity half of the case above. A partition that spared on any +/// mismatch would pass the acceptance case and quietly stop cancelling anything +/// the day a workflow was renamed, which is a leak with no symptom. +#[test] +fn a_declaration_matching_no_run_spares_nothing_and_still_dooms_the_others() { + let in_flight = [ + run("1", ".github/workflows/rust.yml"), + run("2", ".github/workflows/test.yml"), + ]; + let (doomed, spared) = worthless( + &in_flight, + &FanIn::from_workflow_path(".github/workflows/renamed.yml"), + ); + + assert_eq!(spared, 0, "no run carries the declared path"); + assert_eq!(doomed.len(), 2, "the rest are still worthless and still go"); +} + +/// **An unset fan-in declaration cancels NOTHING rather than guessing.** +/// +/// The guard whose absence would have been worst, and the first port of this +/// dropped it: with no name to spare, EVERY run is doomed — including the one +/// carrying the fan-in, whose cancelled context is not an answer. +/// +/// The refusal lives in `land::abandon` rather than in the partition, so this +/// case asserts the shape that makes it necessary: `worthless` with an empty +/// declaration spares nothing and dooms everything. The two together are the +/// whole argument, and asserting only the partition would read as approval of it. +#[test] +fn an_empty_declaration_would_doom_the_fan_in_which_is_why_abandon_refuses_first() { + let in_flight = [run("1", ".github/workflows/rust.yml"), run("2", FANIN)]; + let (doomed, spared) = worthless(&in_flight, &FanIn::from_workflow_path("")); + + assert_eq!(spared, 0); + assert_eq!( + doomed.len(), + 2, + "an empty declaration matches nothing, so the partition dooms the fan-in too" + ); + + // AND THE CALLER IS WHAT STOPS IT. `abandon` returns an empty report without + // reading the forge at all when the declaration is unset — no request, no + // cancellation, no guess. A default report is three zeroes, which is what a + // reader sees on the lap's own line. + assert_eq!( + Abandoned::default(), + Abandoned { + cancelled: 0, + spared: 0, + refused: 0 + }, + "an unset declaration reports a measured nothing rather than a silence" + ); +} + +/// **Nothing in flight is a clean no-op.** +/// +/// Not an error and not a refusal: a head with no runs on it is the ordinary +/// state after a lap that stopped before readying, and reporting it as a failure +/// would make the compensation itself a reason to stop. +#[test] +fn an_empty_flight_list_dooms_nothing_and_spares_nothing() { + let (doomed, spared) = worthless(&[], &FanIn::from_workflow_path(FANIN)); + assert!(doomed.is_empty()); + assert_eq!(spared, 0); +} + +/// **Every run carrying the fan-in's path is spared, not merely the first.** +/// +/// A re-run leaves two runs on one workflow file, and a partition that stopped +/// at the first match would cancel the live one. Not a case the predecessor's +/// suite carried — found by reading its titles and asking what "the run carrying +/// the fan-in" means when there are two. +#[test] +fn a_re_run_leaves_two_fan_in_runs_and_both_are_spared() { + let in_flight = [ + run("1", FANIN), + run("2", ".github/workflows/rust.yml"), + run("3", FANIN), + ]; + let (doomed, spared) = worthless(&in_flight, &FanIn::from_workflow_path(FANIN)); + + assert_eq!(spared, 2, "both fan-in runs are spared"); + assert_eq!(doomed.len(), 1); + assert_eq!(doomed[0].id, "2"); +} diff --git a/crates/batten/tests/it/cfg_gated_test.rs b/crates/batten/tests/it/cfg_gated_test.rs new file mode 100644 index 000000000..49b7d5eb1 --- /dev/null +++ b/crates/batten/tests/it/cfg_gated_test.rs @@ -0,0 +1,354 @@ +//! `policy/cfg-gated-test.rego` over the COMPILED engine (CLOUD-1148). +//! +//! # Why this file exists when the module already has `test_` rules +//! +//! Those are the load-time tier and they pin the PREDICATE. They cannot pin that +//! the engine BUILDS the input the predicate reads, and this module reads two +//! facts that a `with input as` case fabricates for free and the engine has to +//! earn: `input.tree.lines[path]` for the working tree, and +//! `input.tree["base-delta"]["base-lines"][path]` for the same path's committed +//! bytes. The second is the one worth a tier of its own — it is the whole +//! difference between this rule and a state rule, and a module reading a +//! `base-lines` key the engine never filled would report clean over every branch +//! while its own suite stayed green. +//! +//! `rules/policy-modules.md` records that class twice: a module copied from +//! `policy.rs`'s own doc iterated a tree key the engine never built, and +//! OpenTelemetry's `weaver` printed "No policy violation", exit 0, over a +//! knowingly-broken registry because its module read a key the v1 schema does +//! not build. Both live instances in this tree were found by adding this tier. +//! +//! # What the rule is for +//! +//! A `#[cfg()]` on a `#[test]` takes the case out of the build on +//! every other target, so `cross-check` type-checks only the arm the local host +//! admits. `cfg!` inside the body keeps both compiled and states the +//! off-platform contract where a reader can see it. The rule is a RATCHET rather +//! than a state check: this tree carries ~40 legitimate `#[cfg(unix)]` `#[test]` +//! pairs whose subject genuinely does not exist off unix, and a gate refusing +//! those on its first run is one an author switches off. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use batten::rules::{self, Rule}; + +/// The predicate id the module declares — NOT the `[[rule]]` id, which is +/// `cfg-gated-test`. The two differ, and the difference is load-bearing: an +/// admission resolves its anchor by the FINDING's rule, so minting against the +/// config id silently produces a `call:` anchor that suppresses nothing. +const GATED_ADDED: &str = "platform-gated-test-added"; + +/// A fixture repository whose base commit carries `before` at +/// `crates/batten/src/subject.rs` and whose working tree carries `after`. +/// +/// The pair is the point: the engine has to resolve the committed side from git +/// as `base-lines` and read the working side as `lines`, and a rule that got +/// either from a harness would pass over a branch it never compared. +/// +/// `origin/main` is a local ref pointed at the base commit, for `test_targets.rs` +/// and `filed_here.rs`'s reason: `base_delta` resolves a rev, and configuring a +/// remote would make an entirely local question depend on the network. It is +/// written by [`common::pin_origin_main`] rather than by `git update-ref`, which +/// is all that verb does on a repository this young. +/// +/// NO HAND-ROLLED `git init` — [`common::init_repo`] copies the one template the +/// whole suite shares. CLOUD-1419 measured 79 forked inits producing 1,819 git +/// processes over one traced run, and `fixture-forks` refused this helper's first +/// spelling at the line that wrote it. +fn repo(name: &str, before: &[&str], after: &[&str]) -> PathBuf { + let root = common::scratch(name); + common::init_repo(&root); + + let subject = root.join("crates/batten/src/subject.rs"); + fs::create_dir_all(subject.parent().expect("a parent")).expect("scratch parent"); + fs::write(&subject, join(before)).expect("seed the committed side"); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "--quiet", "-m", "base"]); + common::pin_origin_main(&root); + + fs::write(&subject, join(after)).expect("write the working side"); + + install_module(&root); + root +} + +fn join(lines: &[&str]) -> String { + let mut text = lines.join("\n"); + text.push('\n'); + text +} + +/// The COMMITTED module, copied rather than re-typed. A fixture carrying its own +/// copy of the predicate would pass while the shipped one was broken, which is +/// the fidelity failure this tier exists to catch. +fn install_module(root: &Path) { + let source = common::at_root("policy/cfg-gated-test.rego") + .canonicalize() + .expect("the committed module is where the row says it is"); + fs::create_dir_all(root.join("policy")).expect("scratch policy dir"); + fs::copy(source, root.join("policy/cfg-gated-test.rego")).expect("install committed module"); +} + +/// The committed row's shape, so a registration the loader would reject cannot +/// pass here — including the `line_sources` glob, without which the module reads +/// no lines and refuses nothing. +fn row() -> Rule { + serde_json::from_value(serde_json::json!({ + "id": "cfg-gated-test", + "kind": "policy", + "scope": "tree", + "base": "origin/main", + "delta_sources": ["**"], + "line_sources": ["crates/**/*.rs"], + "module": "policy/cfg-gated-test.rego", + "severity": "deny", + })) + .expect("the loader accepts the committed row's shape") +} + +fn scan(root: &Path) -> rules::Scan { + let verdicts = common::verdicts_in(root); + rules::run_static( + &[row()], + &[], + batten::policy::Vocabulary { + patterns: &[], + verdicts: &verdicts, + recorders: &[], + }, + root, + ) + .expect("the read surface runs a policy row") +} + +fn verdicts(root: &Path) -> Vec { + scan(root) + .findings + .into_iter() + .map(|finding| finding.rule) + .collect() +} + +fn classes(root: &Path) -> Vec { + let scanned = scan(root); + scanned + .findings + .iter() + .filter_map(|finding| { + scanned + .classes + .get(&finding.identity.fingerprint.to_hex()) + .cloned() + }) + .collect() +} + +// --------------------------------------------------------------------------- +// The pass side first: without it every refusal below is satisfied by a module +// that refuses everything. +// --------------------------------------------------------------------------- + +#[test] +fn a_branch_that_adds_no_attribute_passes_untouched() { + let root = repo( + "cfg-gated-clean", + &["#[test]", "fn a() {}"], + &["#[test]", "fn a() {}", "// a new comment"], + ); + assert!( + verdicts(&root).is_empty(), + "an edit that narrows no case is not this rule's business" + ); +} + +/// THE REFUSAL, over the engine's own `base-lines` rather than a harness's. +/// +/// `#MUTANT direction-may-invert` and `#MUTANT reach-may-be-empty` both redden +/// here: the first flips `after > base`, the second empties the offset set so no +/// `cfg` reaches any `#[test]`. +#[test] +fn a_branch_that_adds_a_platform_gated_test_is_refused() { + let root = repo( + "cfg-gated-added", + &["#[test]", "fn a() {}"], + &["#[cfg(unix)]", "#[test]", "fn a() {}"], + ); + assert_eq!( + verdicts(&root), + vec![GATED_ADDED.to_owned()], + "the attribute is new against the committed side, and the engine's own \ + base-lines is what has to surface that" + ); + assert_eq!( + classes(&root), + vec!["test cover partial".to_owned()], + "the finding carries the declared class, which is what an admission \ + binds against" + ); +} + +/// THE CASE THAT MAKES THE RULE SURVIVABLE, and the one an implementer would +/// skip. This tree's ~40 pre-existing pairs are legitimate — their subject does +/// not exist off unix — so an edit to a file carrying one must pass, or the gate +/// fires on ordinary work and gets switched off. +/// +/// `#MUTANT base-may-read-as-empty` reddens exactly here: replacing the base +/// count with `0` makes every pre-existing pair read as newly added. +#[test] +fn a_pre_existing_platform_gated_test_survives_an_edit() { + let root = repo( + "cfg-gated-preexisting", + &["#[cfg(unix)]", "#[test]", "fn a() {}"], + &[ + "#[cfg(unix)]", + "#[test]", + "fn a() {}", + "", + "// an unrelated edit to the same file", + ], + ); + assert!( + verdicts(&root).is_empty(), + "the count did not go up, and a ratchet only ever asks the direction" + ); +} + +/// THE DISCRIMINATING PARTNER for `#MUTANT block-may-span-code`. Code between +/// the `cfg` and the `#[test]` means the attribute gates the CODE, not the case — +/// which is how every legitimate unix-only helper in this tree is written, sitting +/// directly above the cases it serves. +/// +/// With `attribute_or_doc` neutered to `true` the two ends join across the `use` +/// line and this passes-side case turns red, which is the kill. +#[test] +fn a_cfg_far_from_the_test_with_code_between_is_not_a_gated_test() { + let root = repo( + "cfg-gated-spanning", + &["#[test]", "fn a() {}"], + &[ + "#[cfg(unix)]", + "use std::os::unix::fs::MetadataExt as _;", + "", + "#[test]", + "fn a() {}", + ], + ); + assert!( + verdicts(&root).is_empty(), + "a cfg over an import is not a cfg over the case below it, and reading \ + one as the other refuses every unix-only helper in the tree" + ); +} + +/// THE ONE-KEYSTROKE EVASION, which is why the reach is three rather than one. +/// An `#[allow]` written between the two lines must not buy a bypass. +#[test] +fn an_interleaved_attribute_does_not_buy_a_bypass() { + let root = repo( + "cfg-gated-interleaved", + &["#[test]", "fn a() {}"], + &[ + "#[cfg(target_os = \"linux\")]", + "#[allow(clippy::unwrap_used)]", + "/// what it does", + "#[test]", + "fn a() {}", + ], + ); + assert_eq!( + verdicts(&root), + vec![GATED_ADDED.to_owned()], + "two attributes and a doc line between the cfg and the #[test] are still \ + one attribute run" + ); +} + +/// `#[cfg(test)]` IS THE MODULE GATE and varies with no target, so it can leave +/// no arm uncompiled. Refusing it would refuse every unit-test module in the +/// crate — a first firing that is a false positive on ~every file. +#[test] +fn the_test_module_gate_is_not_a_platform_gate() { + let root = repo( + "cfg-gated-module-gate", + &["fn a() {}"], + &[ + "#[cfg(test)]", + "mod tests {", + " #[test]", + " fn a() {}", + "}", + ], + ); + assert!(verdicts(&root).is_empty(), "cfg(test) names no platform"); +} + +/// `cfg!` IS THE REMEDY, so the shape the doctrine asks for has to pass — over +/// the compiled engine and not only in the module's own tier. A rule that +/// refused its own remedy would have no route out. +#[test] +fn the_cfg_macro_inside_the_body_is_the_remedy() { + let root = repo( + "cfg-gated-remedy", + &["#[test]", "fn a() {}"], + &[ + "#[test]", + "fn a() {", + " if cfg!(unix) {", + " assert!(true);", + " } else {", + " assert!(true);", + " }", + "}", + ], + ); + assert!( + verdicts(&root).is_empty(), + "the whole point of the rule is that this spelling lands" + ); +} + +/// AN ADDED FILE HAS NO BASE SIDE, and its count is zero rather than +/// unreadable: the same defect arriving in one commit instead of two. +#[test] +fn an_added_file_carrying_a_gated_test_is_refused() { + let root = repo("cfg-gated-new-file", &["fn a() {}"], &["fn a() {}"]); + let added = root.join("crates/batten/src/fresh.rs"); + fs::write(&added, join(&["#[cfg(windows)]", "#[test]", "fn b() {}"])) + .expect("write an added file"); + assert_eq!( + verdicts(&root), + vec![GATED_ADDED.to_owned()], + "a path with no committed side compares against zero, not against \ + could-not-look" + ); +} + +/// COULD NOT LOOK IS REPORTED, NEVER PASSED. With no `origin/main` the engine +/// resolves no delta, and a rule that refused nothing there would be +/// byte-identical to a clean tree on the decision surface — over a branch it +/// never read. +/// +/// The class is `diff read absent`, reused from the registry rather than +/// restated: one concept, one spelling. +#[test] +fn an_unresolvable_base_reports_rather_than_passing() { + let root = repo( + "cfg-gated-no-base", + &["#[test]", "fn a() {}"], + &["#[cfg(unix)]", "#[test]", "fn a() {}"], + ); + // The inverse of `pin_origin_main`'s loose-ref write, and a fork cheaper + // than `update-ref -d` for the same effect on a repository this young. + fs::remove_file(root.join(".git/refs/remotes/origin/main")).expect("unpin the base ref"); + assert_eq!( + classes(&root), + vec!["diff read absent".to_owned()], + "an unresolvable base is a read failure, and the rule says so instead of \ + reporting the branch clean" + ); +} diff --git a/crates/batten/tests/it/ci_parity.rs b/crates/batten/tests/it/ci_parity.rs index 14228f34a..032a2d619 100644 --- a/crates/batten/tests/it/ci_parity.rs +++ b/crates/batten/tests/it/ci_parity.rs @@ -198,8 +198,7 @@ fn row() -> Rule { ], "line_sources": [ ".github/workflows/*.yml", - "mise-tasks/abandon-matrix.sh", - "mise-tasks/land.sh", + "crates/batten/src/lib.rs", ], "module": "policy/ci-parity.rego", "severity": "deny", @@ -300,7 +299,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Landing lease precondition - run: bash -c "$body" || exit 0 + run: | + "$RUNNER_TEMP/batten-bin/batten" lease guard \ + "$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0 - run: mise run lint - run: mise exec -- cargo nextest run --workspace final: @@ -374,15 +375,22 @@ fn sound(name: &str) -> PathBuf { common::write(&root, "mise.toml", MANIFEST); common::write(&root, "renovate.json5", RENOVATE); common::write(&root, "release-plz.toml", "[pr]\npr_draft = true\n"); + // THE COMPENSATION'S OWN SITE, since CLOUD-1148 retired the two shell + // programs this used to stand in for. Both fan-in clauses read one file + // now — the site that resolves the declaration and the site that reaches + // `land::abandon` are the same lines — so the sound fixture carries both. + // + // THE DECLARATION SITS AT THE CONSTRUCTOR, which is the binding review of + // #848 added: `abandon_reads_declaration` no longer accepts the read + // anywhere in the file, so this fixture is written the way rustfmt renders + // the real call rather than as one line. common::write( &root, - "mise-tasks/abandon-matrix.sh", - "#!/usr/bin/env bash\nrun=\"$CI_FANIN_WORKFLOW\"\n", - ); - common::write( - &root, - "mise-tasks/land.sh", - "#!/usr/bin/env bash\nmise run abandon-matrix\n", + "crates/batten/src/lib.rs", + "let fanin = land::FanIn::from_workflow_path(\n\ + \x20 std::env::var(\"CI_FANIN_WORKFLOW\").unwrap_or_default(),\n\ + );\n\ + let report = land::abandon(&repo, &sha, &fanin);\n", ); install_module(&root); root @@ -724,10 +732,15 @@ fn a_fanin_workflow_declaring_no_such_job_is_refused() { #[test] fn an_abandon_that_restates_the_path_is_refused() { let root = sound("abandon-literal"); + // A LITERAL WHERE THE DECLARATION BELONGS. This also covers the + // sibling-variable defect CLOUD-1148 measured: reading `CI_FANIN_CHECK` + // here compiles, runs, and cancels the fan-in's own run, because that value + // is a check NAME and `land::worthless` compares against a workflow PATH. common::write( &root, - "mise-tasks/abandon-matrix.sh", - "#!/usr/bin/env bash\nrun=.github/workflows/ci.yml\n", + "crates/batten/src/lib.rs", + "let fanin = land::FanIn::from_workflow_path(\".github/workflows/ci.yml\");\n\ + let report = land::abandon(&repo, &sha, &fanin);\n", ); assert!( !findings(&root).is_empty(), @@ -741,10 +754,15 @@ fn a_lander_that_never_abandons_is_refused() { // THE ANTI-VACUITY TERM. Every other fan-in clause makes the abandon SAFE; // none of them notices it is never called. let root = sound("abandon-uncalled"); + // The declaration is read and the abandon is never reached — which is what a + // compensation arm deleted, renamed, or left behind a `match` that no longer + // dispatches it looks like from here. common::write( &root, - "mise-tasks/land.sh", - "#!/usr/bin/env bash\nmise run ci-wait\n", + "crates/batten/src/lib.rs", + "let fanin = land::FanIn::from_workflow_path(\n\ + \x20 std::env::var(\"CI_FANIN_WORKFLOW\").unwrap_or_default(),\n\ + );\n", ); assert!( !findings(&root).is_empty(), @@ -752,6 +770,64 @@ fn a_lander_that_never_abandons_is_refused() { ); } +#[test] +fn a_declaration_read_far_from_the_constructor_does_not_satisfy_the_clause() { + // **THE CLASS REVIEW OF #848 NAMED, AND THE ONE THE ROW COULD NOT SEE.** + // `abandon_reads_declaration` and `lander_calls_abandon` were two + // INDEPENDENT line questions over one file, so a read of the declaration + // anywhere — a comment, a doc block, an unrelated helper six thousand lines + // away — plus a call handed the WRONG value satisfied both, and the module + // reported clean. + // + // That is not a hypothetical shape: the row's own header records the engine + // reading `CI_FANIN_CHECK` where it needed `CI_FANIN_WORKFLOW` for the whole + // of the branch that wrote it, which is exactly this, so the rule could not + // catch its own subject. + // + // The fixture is written to pass the OLD spelling and fail the new one: + // `land::abandon` is reached, `CI_FANIN_WORKFLOW` appears, and the value the + // constructor is handed is a different variable entirely. + let root = sound("declaration-far-from-the-call"); + common::write( + &root, + "crates/batten/src/lib.rs", + "// the fan-in is declared as CI_FANIN_WORKFLOW in the manifest\n\ + fn unrelated() -> String {\n\ + \x20 std::env::var(\"CI_FANIN_WORKFLOW\").unwrap_or_default()\n\ + }\n\ + \n\ + let fanin = land::FanIn::from_workflow_path(\n\ + \x20 std::env::var(\"CI_FANIN_CHECK\").unwrap_or_default(),\n\ + );\n\ + let report = land::abandon(&repo, &sha, &fanin);\n", + ); + assert!( + !findings(&root).is_empty(), + "a declaration read that is not the constructor's own argument should \ + be refused: the call is handed a check name and the module cannot see it" + ); +} + +#[test] +fn the_constructor_and_its_declaration_may_sit_on_one_line() { + // The window is THREE lines rather than one, deliberately: pinning rustfmt's + // current rendering would make a reflow silence the gate, which is strictly + // worse than the duplication the binding exists to stop. So the collapsed + // spelling has to pass too, and this is the case that says so. + let root = sound("constructor-one-line"); + common::write( + &root, + "crates/batten/src/lib.rs", + "let fanin = land::FanIn::from_workflow_path(std::env::var(\"CI_FANIN_WORKFLOW\").unwrap_or_default());\n\ + let report = land::abandon(&repo, &sha, &fanin);\n", + ); + assert!( + findings(&root).is_empty(), + "one line carrying both halves is still the binding: {:?}", + findings(&root) + ); +} + #[test] fn a_job_that_starts_without_asking_the_lease_is_refused() { // The lease serialises landing, but enforcing it only inside the lander means @@ -763,7 +839,7 @@ fn a_job_that_starts_without_asking_the_lease_is_refused() { &root, ".github/workflows/ci.yml", &WORKFLOW.replace( - " - name: Landing lease precondition\n run: bash -c \"$body\" || exit 0\n", + " - name: Landing lease precondition\n run: |\n \"$RUNNER_TEMP/batten-bin/batten\" lease guard \\\n \"$LEASE_HEAD_SHA\" \"$LEASE_HEAD_REF\" \"$LEASE_RUN_ID\" || exit 0\n", "", ), ); @@ -782,7 +858,7 @@ fn a_precondition_invoked_without_the_tolerant_suffix_is_refused() { common::write( &root, ".github/workflows/ci.yml", - &WORKFLOW.replace("run: bash -c \"$body\" || exit 0", "run: bash -c \"$body\""), + &WORKFLOW.replace("\"$LEASE_RUN_ID\" || exit 0", "\"$LEASE_RUN_ID\""), ); assert!( !findings(&root).is_empty(), diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 756cfc1e0..72fde7650 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -114,9 +114,31 @@ fn repo_with_gh_policy(name: &str) -> PathBuf { /// points at `crates/batten/`, which has no `batten.toml` — that is the /// no-authority case, which several tests want. fn run_hook_in(dir: &std::path::Path, harness: &str, payload: &str, bypass: bool) -> Output { + run_hook_in_promoted(dir, harness, payload, bypass, false) +} + +/// [`run_hook_in`], with `--fail-on-warning` selectable. +/// +/// A `warn` shape row is SILENT on the mediated surface rather than advisory: +/// `hook::blocks` is false, `adjudicate` returns `Decision::Allow`, and the +/// document is empty — the same bytes a repository with no such row emits. So a +/// warn row's predicate is only reachable with promotion on, and a census that +/// could not turn it on could only cover deny rows (CLOUD-1148). +/// +/// The flag is GLOBAL, so it leads the argv ahead of the subcommand. +fn run_hook_in_promoted( + dir: &std::path::Path, + harness: &str, + payload: &str, + bypass: bool, + promoted: bool, +) -> Output { let mut command = common::batten_at_real_root(); + command.current_dir(dir); + if promoted { + command.arg("--fail-on-warning"); + } command - .current_dir(dir) .args(["adjudicate", "--harness", harness]) .env_remove("BATTEN_HOOK_BYPASS") .env_remove("BATTEN_GH_GUARD_BYPASS") @@ -2904,7 +2926,15 @@ const SHAPE_CENSUS: &[ShapeCase] = &[ // `gh` lifecycle rows above rather than apart from them: all five refuse an // ad-hoc spelling of a step `mise run land` already drives. // - // The census case is the DENY arm only. Its allow arm — `git rebase + // THE ONE `warn` ROW IN THIS TABLE, so it is the case the promoted arm + // exists for (CLOUD-1148). It landed as a `deny` and deadlocked the first + // conflict it met — `gitwrite.rs` moves nothing on a conflict, so `land` + // never leaves a rebase to `--continue`, and the one command that produces + // the resolvable state was the one this row refused. `batten.toml` carries + // the measurement. The severity is read off the row here rather than + // written into the case, so this needs no edit if it ever denies again. + // + // The census case is the REFUSAL arm only. Its allow arm — `git rebase // --continue`, the one spelling a conflict exit requires by hand — cannot be // written here, because this table pairs a call with the row that must // refuse it and an allowed call has no such row. It lives in @@ -3161,6 +3191,19 @@ const MANIFEST_ARTIFACTS: &[&str] = &["one.txt", "two.txt", "three.txt", "four.t #[test] fn the_committed_shape_rules_fire_on_every_banned_shape() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + // THE ARM IS READ OFF THE ROW, never written into the case — the same + // discipline `census_gaps` already applies to the SITE, and for the same + // reason: a case naming its own arm would keep passing after the authority + // changed severity underneath it, which is the drift this census exists to + // catch. A `warn` row is silent at default strictness (see + // `run_hook_in_promoted`), so it is judged with promotion on. + let parsed = batten::config::parse(&committed_config(), "batten.toml").expect("parse"); + let promoted_rows: std::collections::BTreeSet<&str> = parsed + .rules + .iter() + .filter(|rule| rule.severity() == batten::severity::RuleSeverity::Warn) + .map(|rule| rule.id.as_str()) + .collect(); for case in SHAPE_CENSUS { let dir = match case.site { CensusSite::Checkout => root.clone(), @@ -3176,12 +3219,18 @@ fn the_committed_shape_rules_fire_on_every_banned_shape() { repeat, } => claude_spawn_payload(tool, &prompt.repeat(*repeat)), }; - let output = run_hook_in(&dir, "exit-code", &payload, false); + let promoted = promoted_rows.contains(case.rule); + let output = run_hook_in_promoted(&dir, "exit-code", &payload, false, promoted); assert_eq!( output.status.code(), Some(2), - "the committed policy must still refuse {:?}", - case.call.describe() + "the committed policy must still refuse {:?}{}", + case.call.describe(), + if promoted { + " (its row is `warn`, so this is the promoted arm)" + } else { + "" + } ); let stderr = String::from_utf8_lossy(&output.stderr); // The rule id is still the engine's own attribution and is still what diff --git a/crates/batten/tests/it/common/mod.rs b/crates/batten/tests/it/common/mod.rs index b2a817462..ab091a98b 100644 --- a/crates/batten/tests/it/common/mod.rs +++ b/crates/batten/tests/it/common/mod.rs @@ -1304,6 +1304,53 @@ pub(crate) fn annotations_naming(source: &str, lint: &str) -> Vec<(usize, String if close + 2 > next { continue; } + // AN ATTRIBUTE INSIDE A COMMENT IS PROSE, NOT AN INVENTORY ROW. + // + // The bound above handles a doc comment naming an annotation WITHOUT its + // arguments — that has no closer of its own and is skipped. It does not + // handle one that spells it in full, and that is the shape a module + // explaining why its spawn was retired naturally writes: "this was a + // child process under `#[expect(clippy::disallowed_types)]`". Seven such + // sentences read as seven annotations carrying no `reason`, so the census + // reported the prose that RECORDS a retirement as a row that had not + // been decided. + // + // Keyed on the line's own opening rather than on a span search: a + // comment marker anywhere earlier in the file says nothing about this + // line, and the question is only ever whether THIS attribute is + // commented out. + let line_start = source[..open].rfind('\n').map_or(0, |at| at + 1); + let before = &source[line_start..open]; + if before.trim_start().starts_with("//") { + continue; + } + // NOR IS ONE INSIDE A STRING LITERAL. `spawn_widening.rs` builds fixture + // MODULES as string constants, so the escape a case hands the gate under + // test is spelled in full inside quotes — and the census read three of + // its own fixtures as undecided rows. An odd number of quotes before the + // opener means this `#[` is inside one; escaped quotes do not open or + // close, so they are skipped rather than counted. + let quotes = before + .char_indices() + .filter(|&(at, ch)| ch == '"' && !before[..at].ends_with('\\')) + .count(); + if quotes % 2 == 1 { + continue; + } + // AND AN ATTRIBUTE THAT IS NOT A LINT LEVEL IS NOT THIS INVENTORY'S. + // + // The span bound above stitches a BARE attribute — `#[test]`, which has + // no closer of its own — to the next `)]` further down, so a case whose + // body mentions the lint made its own `#[test]` a finding. Requiring the + // opener to be one of the four level words is what the callers actually + // mean by an annotation, and it decides in one comparison rather than by + // guessing where a bare attribute ends. + let opens_a_level = ["expect(", "allow(", "warn(", "deny("] + .iter() + .any(|level| rest[2..].trim_start().starts_with(level)); + if !opens_a_level { + continue; + } let attribute = &rest[..close + 2]; if attribute.contains(lint) { found.push((source[..open].lines().count() + 1, attribute.to_owned())); diff --git a/crates/batten/tests/it/config_fault_class.rs b/crates/batten/tests/it/config_fault_class.rs index 3b2d728ae..2b6f257cc 100644 --- a/crates/batten/tests/it/config_fault_class.rs +++ b/crates/batten/tests/it/config_fault_class.rs @@ -95,6 +95,18 @@ const FAULTS: &[(&str, &str, &str)] = &[ [[exec_pattern]]\nid = \"twice\"\npattern = \"x\"\nreason = \"r\"\n\ [[exec_pattern]]\nid = \"twice\"\npattern = \"y\"\nreason = \"r\"\n", ), + // The SECOND `OutputPattern` table, and it earns its own row here for the + // reason it earns its own class: the fault is byte-identical to the one above + // — a duplicate id — and only the table it sits in decides which file an + // author has to open. A case reaching one class over both tables would report + // the wrong one and pass. + ( + "environment declare refused", + "verify_environment_pattern", + "version = 1\n\ + [[verify_environment_pattern]]\nid = \"twice\"\npattern = \"x\"\nreason = \"r\"\n\ + [[verify_environment_pattern]]\nid = \"twice\"\npattern = \"y\"\nreason = \"r\"\n", + ), ( "waiver declare refused", "waiver", diff --git a/crates/batten/tests/it/land.rs b/crates/batten/tests/it/land.rs index 713034c11..aad930787 100644 --- a/crates/batten/tests/it/land.rs +++ b/crates/batten/tests/it/land.rs @@ -266,3 +266,233 @@ fn a_conflict_with_no_path_still_refuses() { "the finding names its own predicate, got {out}{err}" ); } + +/// A gate program in the fixture that exits `code`, and the argv naming it. +/// +/// A path rather than a bare name, because `$LAND_VERIFY` is split on whitespace +/// and run as argv with no shell: `sh -c 'exit 0'` cannot survive that split, and +/// a bare `true` would resolve against whatever the runner's `PATH` happens to +/// carry — which is the harness answering a question about the engine. +fn gate(repo: &Path, name: &str, code: i32) -> String { + let path = repo.join(name); + std::fs::write(&path, format!("#!/bin/sh\nexit {code}\n")).expect("write the gate"); + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .expect("make the gate executable"); + } + path.display().to_string() +} + +/// The lap record this branch has accumulated, or an empty string where the +/// writer never reached it. +fn lap_record(repo: &Path, branch: &str) -> String { + let dir = common::git_in(repo, &["rev-parse", "--git-dir"]); + let path = repo + .join(dir.trim()) + .join("batten-receipts") + .join(format!("lap.{}", branch.replace('/', "-"))); + std::fs::read_to_string(path).unwrap_or_default() +} + +/// `batten land verify`, with the consumer's gate named in the environment. +fn land_verify(repo: &Path, command: &str) -> (i32, String, String) { + let output = batten() + .args(["land", "verify"]) + .env("LAND_VERIFY", command) + .current_dir(repo) + .output() + .expect("run batten land verify"); + ( + output.status.code().expect("exit code"), + stdout(&output), + stderr(&output), + ) +} + +/// **The verb was unreachable in every clone, and this is the pair that shows it.** +/// +/// `land::verify` handed `exec::run_in` the caller's anchor — a literal `.` — and +/// `exec`'s capture store is keyed by the repository's own directory NAME, which +/// `state::derive_repo_name` cannot read off `.`. So the boundary refused before +/// starting anything, on EVERY invocation, and the `None` arm wrapped that +/// `UsageError` in a context naming the gate. Measured against the shipped +/// binary: a passing gate and a refusing gate produced byte-identical output and +/// the same exit `1`. +/// +/// **The pair is what discriminates, and neither half alone does.** Asserting only +/// the clean arm passes over an engine that reports success without running +/// anything; asserting only the refusal passes over one that cannot run anything +/// at all — which is precisely the state this repairs. The record is asserted +/// too, because an exit code alone cannot tell "the gate ran and passed" from +/// "nothing ran and nobody wrote it down". +#[test] +fn a_configured_gate_is_actually_run_and_its_two_answers_are_told_apart() { + let repo = repo("land-verify-runs"); + let branch = branch_of(&repo); + + let (code, out, err) = land_verify(&repo, &gate(&repo, "passes.sh", 0)); + assert_eq!(code, 0, "a gate that passed is exit 0: {err}{out}"); + let record = lap_record(&repo, &branch); + assert!( + record.contains("verify clean "), + "the clean answer reaches the record, got {record:?}" + ); + + // THE MIRROR, on the SAME branch, so the record is a history rather than a + // replacement — and so the two answers are told apart by their own column + // rather than by which fixture produced them. + let (code, out, err) = land_verify(&repo, &gate(&repo, "refuses.sh", 1)); + assert_eq!( + code, 2, + "a gate that refused is the policy verdict, not an error: {err}{out}" + ); + let record = lap_record(&repo, &branch); + assert!( + record.contains("verify refused "), + "the refusal reaches the record as its own token, got {record:?}" + ); +} + +/// The anti-vacuity half of the pair above, and a different failure. +/// +/// An unconfigured gate is a USAGE error — exit `1` — and it must stay +/// distinguishable from both answers above. Without this, the repair could be +/// "always report clean", which the pair above would not catch: `$LAND_VERIFY` +/// naming nothing is the one case where refusing to guess is the whole behaviour, +/// since a default compiled into this crate would be non-negotiable rule 1's +/// plainest violation. +#[test] +fn an_unconfigured_gate_refuses_rather_than_guessing_and_writes_no_record() { + let repo = repo("land-verify-unconfigured"); + let branch = branch_of(&repo); + + let (code, _out, err) = land_verify(&repo, ""); + assert_eq!(code, 1, "an unconfigured gate is a usage error: {err}"); + assert!( + err.contains("LAND_VERIFY"), + "the refusal names the variable the caller must set, got {err}" + ); + assert!( + lap_record(&repo, &branch).is_empty(), + "a lap that never ran a gate records no verdict about one" + ); +} + +/// `batten land lap`, with the lap bound named in the environment. +/// +/// `$LAND_VERIFY` is a gate that always REFUSES, which is what makes the stop +/// arm reachable without a real gate; the mirror below passes one that succeeds. +fn land_lap(repo: &Path, laps: &str, gate: &str) -> (i32, String, String) { + let output = batten() + .args(["land", "lap", "refs/heads/main"]) + .env("LAND_MAX_LAPS", laps) + .env("LAND_VERIFY", gate) + .env("LAND_WORKFLOW", "fast-forward.yml") + .current_dir(repo) + .output() + .expect("run batten land lap"); + ( + output.status.code().expect("exit code"), + stdout(&output), + stderr(&output), + ) +} + +/// **The loop reaches a clone it cannot read and stops carrying that step's own +/// code**, rather than lapping toward an answer no lap can produce. +/// +/// # What this tier can and cannot assert, stated rather than implied +/// +/// A lap begins with `replay`, which fetches over smart-HTTP — so driving the +/// whole loop in-tree needs a live git server, which this suite does not stand +/// up. What IS assertable here is the wiring and the failure posture: the verb +/// parses, resolves its branch, reaches the first step, and reports the clone's +/// own could-not-look instead of spinning. +/// +/// **The decision the loop encodes is tested where it lives** — `land::progress` +/// is a pure table with its own exhaustive cases in `crates/batten/src/land.rs`, +/// including the discriminating pair (a refusal a rebase would clear laps; one +/// it would not stops) and both anti-vacuity mirrors. Asserting that here would +/// need the server; asserting it there needs nothing, and it is the same claim. +#[test] +fn a_lap_over_a_clone_with_no_remote_stops_rather_than_lapping_toward_nothing() { + let repo = repo("land-lap-no-remote"); + + let (code, out, err) = land_lap(&repo, "3", "true"); + assert_eq!( + code, 3, + "a clone with no remote is could-not-look: {out} {err}" + ); + assert_ne!( + code, 2, + "exit 2 would be a verdict about the branch, and nothing here judged one" + ); + assert!( + out.matches("land: lap ").count() <= 1, + "it must not spend its whole count on a remote that will not resolve: {out}" + ); +} + +/// `batten land fast-forward`, with the workflow named in the environment. +fn land_fast_forward(repo: &Path, workflow: &str) -> (i32, String, String) { + let output = batten() + .args(["land", "fast-forward"]) + .env("LAND_WORKFLOW", workflow) + // NO FORGE, deliberately. Both arms below resolve before any request is + // made, which is what makes them assertable at all — a case that needed a + // live pull request would be a test of the forge's availability. + .env("PATH", "/nonexistent") + .current_dir(repo) + .output() + .expect("run batten land fast-forward"); + ( + output.status.code().expect("exit code"), + stdout(&output), + stderr(&output), + ) +} + +/// **An unconfigured workflow refuses rather than guessing, and that is rule 1.** +/// +/// The bash lander defaults `$LAND_WORKFLOW` to `fast-forward.yml`. That filename +/// is THIS consumer's, so compiling it in here would put a consumer's vocabulary +/// inside `crates/batten` — and the failure it would buy is the quiet one: a +/// repository whose bot lives in a differently-named workflow reads an empty runs +/// list on every lap and reports a silent bot forever, which is exactly the +/// diagnosis CLOUD-413 spent 24 laps reaching wrongly. +/// +/// The mirror is the case below: unconfigured is `1`, and a configured workflow +/// with nothing to ask is `3`. Without the pair, "always refuse" passes. +#[test] +fn an_unconfigured_workflow_refuses_rather_than_guessing_a_filename() { + let repo = repo("land-ff-unconfigured"); + + let (code, _out, err) = land_fast_forward(&repo, ""); + assert_eq!(code, 1, "an unconfigured workflow is a usage error: {err}"); + assert!( + err.contains("LAND_WORKFLOW"), + "the refusal names the variable the caller must set, got {err}" + ); +} + +/// The anti-vacuity mirror: configured, but there is nothing to ask. +/// +/// `3` and never `2`. A lap that could not find a pull request has not been +/// REFUSED by anybody — reading it as a refusal would tell the caller its head is +/// no longer a direct descendant, which is a claim about the branch that nothing +/// here established. Exit `2` is reserved for the bot actually saying no. +#[test] +fn no_pull_request_to_ask_is_could_not_look_and_never_a_refusal() { + let repo = repo("land-ff-no-pr"); + + let (code, _out, err) = land_fast_forward(&repo, "fast-forward.yml"); + assert_eq!( + code, 3, + "no pull request is a could-not-look, not a refusal: {err}" + ); + assert_ne!( + code, 2, + "exit 2 would claim the bot refused this head, which nothing established" + ); +} diff --git a/crates/batten/tests/it/land_entry_gates.rs b/crates/batten/tests/it/land_entry_gates.rs new file mode 100644 index 000000000..0c1172507 --- /dev/null +++ b/crates/batten/tests/it/land_entry_gates.rs @@ -0,0 +1,266 @@ +//! The landing's entry gates and the retirement of a landed branch +//! (CLOUD-1471), over the compiled binary and the library. +//! +//! # Why these two are one file +//! +//! They are the two clusters `mise-tasks/land.sh` carried that no successor +//! did — its first act and its last. Neither is a step of the lap, so neither +//! landed with the pipeline; both would have gone on the floor with the program. +//! Keeping them together is what makes that pairing legible to the next reader. +//! +//! # The entry gate is driven end to end, and it can be +//! +//! It runs before the lease, the singleton and any spend, so `land lap` reaches +//! it with no forge state to fabricate beyond the pull request lookup — which is +//! `rest`'s fixture seam. A refusal there is the whole assertion: the lap stops +//! at exit 2 having spent nothing. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +const REPO: &str = "acme/widgets"; +const PR: &str = "42"; + +/// One open pull request, in the response shape the fixture reads. +fn one_pull_request() -> String { + format!("HTTP/2 200\ncontent-type: application/json\n\n[{{\"number\":{PR}}}]\n") +} + +/// A repository on a branch, with the pull-request lookup canned. +fn fixture(name: &str) -> std::path::PathBuf { + let dir = common::scratch(name); + common::init_repo(&dir); + // A REMOTE THAT RESOLVES AND CANNOT BE REACHED, which is exactly the state + // these cases need. `land lap` resolves `origin` before the first step, so + // without one it stops with "no base" and the entry gate is never reached; + // and every case here asserts that the gate stops the lap BEFORE it fetches, + // so a remote that would answer is the wrong fixture — a case reaching the + // fetch has already failed its own claim. + common::git_in(&dir, &["remote", "add", "origin", "file:///nonexistent"]); + // NO TRACKING REF IS PINNED, and the absence is the assertion's other half: + // every case here claims the entry gate stops the lap before it looks at the + // base at all, so a fixture that supplied one would let a case pass over a + // gate asked too late. + common::git_in(&dir, &["checkout", "-q", "-b", "topic"]); + std::fs::write(dir.join("resp.last"), one_pull_request()).expect("write the canned answer"); + dir +} + +/// Write an executable gate that records its argv and exits with `code`. +/// +/// **PER-PLATFORM, BECAUSE `run_land_entry_gates` SPAWNS THE ARGV DIRECTLY** and +/// the shebang script this wrote unconditionally is not a program Windows can +/// execute. Measured on the `windows` leg: both cases below read `asked` as `""` +/// and failed at their first assertion, because the gate had never run. +/// +/// That is `rules/rust.md`'s "shown able to fail" rule inverted — a case +/// asserting a conclusion over a premise the environment never created — and it +/// is worth naming that `cfg-gated-test` does NOT see this shape: the `#[cfg]` +/// was on a block inside this helper rather than on a `#[test]`, so the cases +/// compiled and ran on Windows and only their fixture was missing. The remedy is +/// to make the premise real on both platforms rather than to narrow the cases. +fn gate(dir: &std::path::Path, name: &str, code: i32) -> String { + let asked = dir.join("asked"); + // `.cmd` is what `CreateProcess` will run without an interpreter, so the + // extension is part of the fixture rather than cosmetic. + let path = if cfg!(windows) { + dir.join(std::path::Path::new(name).with_extension("cmd")) + } else { + dir.join(name) + }; + let body = if cfg!(windows) { + format!( + "@echo off\r\necho %* >>\"{}\"\r\necho the gate spoke\r\nexit /b {code}\r\n", + asked.display() + ) + } else { + format!( + "#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >>'{}'\necho 'the gate spoke'\nexit {code}\n", + asked.display() + ) + }; + std::fs::write(&path, body).expect("write the gate"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .expect("make the gate executable"); + } + path.display().to_string() +} + +/// What the gates recorded about how they were called. +fn asked(dir: &std::path::Path) -> String { + std::fs::read_to_string(dir.join("asked")).unwrap_or_default() +} + +/// Run `batten land lap` against the fixture with `gates` declared. +fn lap(dir: &std::path::Path, gates: &str) -> (i32, String, String) { + let output = common::batten() + .arg("land") + .arg("lap") + .arg("main") + .env("GH_REPO", REPO) + .env("LAND_ENTRY_GATES", gates) + .env("BATTEN_REST_FIXTURE", dir) + // Bounded so a lap that gets PAST the entry gate cannot run away: every + // case here is about stopping before the first step, and a runaway would + // be reported as a hang rather than as the miss it is. + .env("LAND_MAX_LAPS", "1") + .current_dir(dir) + .output() + .expect("the compiled binary runs"); + ( + output.status.code().expect("the child exited normally"), + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +/// **A DECLARED GATE IS ASKED ABOUT THIS PULL REQUEST, AND ITS REFUSAL STOPS THE +/// LANDING BEFORE ANYTHING IS SPENT.** +/// +/// The number is appended by the ENGINE rather than spelled by the consumer, +/// which is the half a reader is most likely to get wrong: a gate reading it from +/// its own environment would be a second authority over which pull request a lap +/// is landing, and the two can name different ones. +#[test] +fn a_refusing_entry_gate_stops_the_landing_and_is_told_which_pull_request() { + let dir = fixture("land-entry-refuses"); + let refuses = gate(&dir, "refuses.sh", 1); + let (code, _, err) = lap(&dir, &refuses); + + assert_eq!( + code, 2, + "a gate's refusal is a verdict about this repository" + ); + assert!( + asked(&dir).trim() == PR, + "the gate should be handed the pull request number; it got: {:?}", + asked(&dir) + ); + assert!( + err.contains("refused this landing"), + "the refusal should name itself: {err}" + ); + // THE GATE'S OWN WORDS REACH THE OPERATOR (CLOUD-407), rather than a summary + // written here that would be a second, staler copy of the remedy. + assert!(err.contains("the gate spoke"), "stderr: {err}"); +} + +/// **THE ADVISORY MARKER IS WHAT THE PAIR ACTUALLY NEEDS, and without it the port +/// promotes an exit code the predecessor deliberately ignored.** +/// +/// `land.sh` ran the drop as `… || true` and only the check as an `if !`. A +/// marked gate is still ASKED — that is the whole point of keeping the call — and +/// its verdict does not stop the lap. +/// +/// The mirror is the case above: an unmarked gate with the same exit code stops +/// the landing, so this cannot be satisfied by a runner that ignores every code. +#[test] +fn an_advisory_gate_is_asked_and_its_refusal_does_not_stop_the_landing() { + let dir = fixture("land-entry-advisory"); + let refuses = gate(&dir, "refuses.sh", 1); + let (code, _, _) = lap(&dir, &format!("?{refuses}")); + + assert!( + asked(&dir).trim() == PR, + "an advisory gate is still asked; it recorded: {:?}", + asked(&dir) + ); + assert_ne!( + code, 2, + "an advisory gate's refusal must not become the landing's verdict" + ); +} + +/// **A DECLARED GATE THAT CANNOT RUN IS A REFUSAL, NOT A PASS.** +/// +/// The dead-gate class this engine exists to refuse: naming no entry gates is a +/// legitimate configuration, and naming one that will not run is a precondition +/// left unasked. `3` rather than `2` — nothing was decided about the repository. +#[test] +fn a_declared_entry_gate_that_will_not_run_is_refused() { + let dir = fixture("land-entry-unrunnable"); + let (code, _, err) = lap(&dir, &dir.join("no-such-gate").display().to_string()); + + assert_eq!(code, 3, "an unasked precondition is a could-not-look"); + assert!(err.contains("will not run"), "stderr: {err}"); +} + +/// **DECLARED GATES OVER A PULL REQUEST NOBODY CAN NAME ARE REFUSED, and this is +/// the one read in the family that does NOT fail open.** +/// +/// Everywhere else could-not-look carries on, because spending nothing is the +/// safe direction for a question about CI. Here carrying on is exactly the dead +/// gate: the precondition was declared and never asked. +#[test] +fn entry_gates_over_an_unresolvable_pull_request_are_refused() { + let dir = fixture("land-entry-no-pr"); + // An empty list: the lookup succeeds and names nothing, which is the shape a + // branch with no open pull request produces. + std::fs::write( + dir.join("resp.last"), + "HTTP/2 200\ncontent-type: application/json\n\n[]\n", + ) + .expect("write the canned answer"); + let passes = gate(&dir, "passes.sh", 0); + let (code, _, err) = lap(&dir, &passes); + + assert_eq!(code, 3); + assert!(err.contains("cannot be asked"), "stderr: {err}"); + assert!( + asked(&dir).is_empty(), + "no gate should have been asked at all" + ); +} + +/// **THE BRANCH-KEYED RECEIPTS GO WITH THE BRANCH (CLOUD-774), and the third +/// family is the one a literal port would have missed.** +/// +/// The predecessor swept two stores by name. `Suppression::PerSet` writes a third +/// under the same key shape and landed after that cleanup was written, so a copy +/// of the two literals would leave one family accumulating forever — which is +/// what a named list in the engine exists to stop. +/// +/// The remote half is not driven here: `retire_branch` reaches a remote, and this +/// case is about the sweep. It is stated rather than left implied — a reader +/// should not take this file as evidence that the delete or the tracking-ref +/// prune is covered. +#[test] +fn retiring_a_landed_branch_drops_every_branch_keyed_receipt() { + let dir = common::scratch("land-retire-receipts"); + common::init_repo(&dir); + let store = dir.join(".git").join("batten-receipts"); + std::fs::create_dir_all(&store).expect("create the receipt store"); + + // A branch whose name carries the separator the store flattens, because a + // sweep spelling the slug differently would delete nothing and report a clean + // count — the silent-empty-answer shape, one layer down. + let branch = "claude/some-work"; + let families = ["board-writes", "filed-here-nudged", "filed-set-nudged"]; + for family in families { + std::fs::write(store.join(format!("{family}.claude-some-work")), "x\n") + .expect("write a receipt"); + } + // One store this sweep must NOT touch: a sha-keyed receipt dies with its sha + // and belongs to no branch. Without it, a sweep that removed the whole + // directory would pass. + std::fs::write(store.join("verify.deadbeef"), "x\n").expect("write a sha-keyed receipt"); + + let retired = batten::land::retire_branch(&dir, "file:///nonexistent", branch); + + assert_eq!(retired.receipts, families.len()); + for family in families { + assert!( + !store.join(format!("{family}.claude-some-work")).exists(), + "{family} should have gone with the branch" + ); + } + assert!( + store.join("verify.deadbeef").exists(), + "a sha-keyed receipt belongs to no branch and must survive" + ); +} diff --git a/crates/batten/tests/it/land_forge_reads.rs b/crates/batten/tests/it/land_forge_reads.rs new file mode 100644 index 000000000..db871effc --- /dev/null +++ b/crates/batten/tests/it/land_forge_reads.rs @@ -0,0 +1,110 @@ +//! What the lap actually ASKS the forge, over the compiled binary (CLOUD-1338). +//! +//! # Why this tier exists rather than another `with input as` case +//! +//! Every read in the landing family used to be a spawn of the forge's own client, +//! and that client performed two substitutions on the caller's behalf before the +//! request left the process: it expanded `{owner}/{repo}` from the checkout, and +//! it was the only thing that ever saw the endpoint string. Moving those reads +//! in-process (`crate::rest`) inherits neither for free — and a request that goes +//! out malformed comes back as a `404`, which every caller here is written to read +//! as *could not look* and therefore to survive quietly. +//! +//! That is the class this file drives: a defect visible ONLY in the bytes of the +//! request, invisible to any test that constructs the answer. `rest`'s fixture +//! seam records each URL to `args`, so the request is the assertion. +//! +//! Both cases below were live in this branch when it was written, and both were +//! green under every other tier in the crate. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +/// The repository a case names. Two segments, and deliberately not this +/// repository's own: a placeholder that leaked through would be a literal +/// `{owner}` rather than a plausible-looking slug. +const REPO: &str = "acme/widgets"; + +/// An empty pull-request list, in the response shape the fixture reads. +const NO_PULL_REQUESTS: &str = "HTTP/2 200\ncontent-type: application/json\n\n[]\n"; + +/// A repository on a branch, with the fixture wired and one canned answer. +fn fixture(name: &str) -> std::path::PathBuf { + let dir = common::scratch(name); + common::init_repo(&dir); + common::git_in(&dir, &["checkout", "-q", "-b", "topic"]); + std::fs::write(dir.join("resp.last"), NO_PULL_REQUESTS).expect("write the canned answer"); + dir +} + +/// Everything the fixture recorded about the requests that went out. +fn requests(dir: &std::path::Path) -> String { + std::fs::read_to_string(dir.join("args")).unwrap_or_default() +} + +/// Run `batten land fast-forward` against the fixture. +fn fast_forward(dir: &std::path::Path) -> std::process::Output { + common::batten() + .arg("land") + .arg("fast-forward") + .env("GH_REPO", REPO) + .env("LAND_WORKFLOW", "land.yml") + .env("BATTEN_REST_FIXTURE", dir) + .current_dir(dir) + .output() + .expect("the compiled binary runs") +} + +/// **THE LOOKUP ASKS ABOUT THE CONFIGURED REPOSITORY, NOT ABOUT A PLACEHOLDER.** +/// +/// `pr_watch::REPO_PLACEHOLDER` is the literal `{owner}/{repo}` — the FORGE +/// CLIENT's substitution, performed in the process that no longer runs. This step +/// sent it as the path, so the forge answered `404`, `open_pull_request` answered +/// `None`, and every `land fast-forward` — the lap's commit point included — +/// stopped with *"no open pull request"*. It is the one site in the family that +/// did not read `GH_REPO`; the assertion is over the bytes that went out, because +/// the exit code is identical either way. +#[test] +fn the_fast_forward_lookup_names_the_configured_repository() { + let dir = fixture("land-forge-reads-repo"); + let _ = fast_forward(&dir); + + let asked = requests(&dir); + assert!( + asked.contains(&format!("repos/{REPO}/pulls")), + "the lookup should name the configured repository; it asked: {asked}" + ); + // THE MIRROR, and it is what makes this discriminate: an assertion that only + // looked for the slug would pass over a request that carried both, which is + // exactly what a partial fix produces. + assert!( + !asked.contains("{owner}"), + "the client's own substitution reached the endpoint: {asked}" + ); +} + +/// **THE HEAD FILTER CARRIES THE OWNER, because the forge documents it as +/// `user:ref-name` and IGNORES anything else.** +/// +/// An ignored filter is not an error: the endpoint answers with the newest open +/// pull request of ANY branch, so the lap can comment `/fast-forward` on — and +/// ready, and re-draft — a pull request that is not this branch's. The failure is +/// silent in both directions, which is why the request is the subject here. +#[test] +fn the_head_filter_is_owner_qualified_rather_than_a_bare_branch() { + let dir = fixture("land-forge-reads-head"); + let _ = fast_forward(&dir); + + let asked = requests(&dir); + let owner = REPO.split('/').next().expect("the slug has an owner"); + assert!( + asked.contains(&format!("head={owner}:topic")), + "the head filter should be owner-qualified; it asked: {asked}" + ); + assert!( + !asked.contains("head=topic"), + "a bare branch is silently ignored by the forge: {asked}" + ); +} diff --git a/crates/batten/tests/it/land_hand_stepping.rs b/crates/batten/tests/it/land_hand_stepping.rs index 4e10a045d..6524e4351 100644 --- a/crates/batten/tests/it/land_hand_stepping.rs +++ b/crates/batten/tests/it/land_hand_stepping.rs @@ -33,6 +33,33 @@ //! So the deny case alone proves nothing: a row that refused every `git rebase` //! passes it while breaking conflict resolution, which is this defect pointed //! the other way. The pair is the assertion. +//! +//! # THE ROW IS `deny`, AND EVERY CASE HERE JUDGES THE DEFAULT ARM +//! +//! Lowering it to `warn` was tried and withdrawn, and the reason is worth +//! keeping because the instinct recurs. The row deadlocks a rebase conflict: +//! `gitwrite.rs` moves nothing on a conflict and so leaves no rebase to +//! `--continue`, the one command that produces the resolvable state is the one +//! the row refuses, and a `shape` row declares no `[[verdict]]` class, so there +//! is no override route either. +//! +//! **But `warn` does not soften that, it deletes it.** A `warn` shape row is +//! SILENT on the mediated surface, not advisory: `hook::blocks` is false, +//! `adjudicate` returns `Decision::Allow`, and the decision document is EMPTY — +//! no verdict, no row id, nothing reaching the agent at the call. So the trade +//! was a refusal that is wrong on one path for a row that decides nothing on +//! every path. `the_row_is_live_at_default_strictness` is the arm pinning which +//! of the two this file is testing, and it is here because the config comment +//! this file pairs with once claimed `warn` still reached the agent. +//! +//! The conflict cost is real and is a NAMED LIMIT rather than one this branch +//! pays: on a conflict the stop is a human's, which `AGENTS.md` already +//! sanctions. Narrowing the predicate to see the lap record — the fact that +//! separates the race from the resolution — is what would remove it. +//! +//! Judging at default strictness is what makes a DELETED row redden here: with +//! the row live, silence is a finding rather than the same document an absent +//! row emits. // Panicking on setup failure is the idiomatic way for a test to fail loudly. #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -60,20 +87,42 @@ fn bash_payload(command: &str) -> String { } /// The decision document this harness emits, as text. -fn decision(command: &str) -> String { +/// +/// `promoted` selects the arm. The row is `deny`, so its predicate is live +/// without the flag; the promoted arm is retained for the one case that pins +/// the severity itself — see this file's header. +fn decision_with(command: &str, promoted: bool) -> String { + // `adjudicate`, not `hook`. The rename ships no alias and an unknown + // subcommand is clap exit 1 — which every host reads as ALLOW — so this + // file failed with three "must refuse" assertions the moment the rename + // landed, over a policy that was refusing correctly. That is the same + // failure mode a stale binary produces in production, seen from inside + // the suite. + // + // The flag is GLOBAL and so leads the argv, ahead of the subcommand. + let args: &[&str] = if promoted { + &[ + "--fail-on-warning", + "adjudicate", + "--harness", + "claude-code", + ] + } else { + &["adjudicate", "--harness", "claude-code"] + }; stdout(&run_with_stdin_at_real_root( &root(), - // `adjudicate`, not `hook`. The rename ships no alias and an unknown - // subcommand is clap exit 1 — which every host reads as ALLOW — so this - // file failed with three "must refuse" assertions the moment the rename - // landed, over a policy that was refusing correctly. That is the same - // failure mode a stale binary produces in production, seen from inside - // the suite. - &["adjudicate", "--harness", "claude-code"], + args, &bash_payload(command), )) } +/// The document at DEFAULT strictness, which is where this row's predicate is +/// live and where a consumer meets it. +fn decision(command: &str) -> String { + decision_with(command, false) +} + fn denied_by_the_row(command: &str) { let out = decision(command); assert!( @@ -99,6 +148,30 @@ fn not_refused_by_the_row(command: &str) { ); } +// --- the severity itself, pinned as a case rather than as a comment ---------- + +/// THE SEVERITY IS THE ASSERTION HERE, not the predicate. +/// +/// A `warn` shape row emits an EMPTY decision document at default strictness — +/// `hook::blocks` is false, `adjudicate` returns `Decision::Allow`, and the +/// bytes are the ones a repository with no such row emits. So a silent lowering +/// of this column would leave every other case in this file green while the row +/// reached no agent at any call. +/// +/// This is the arm that reddens on that. It asserts the refusal is present +/// WITHOUT `--fail-on-warning`, which is exactly what `deny` buys and `warn` +/// does not, and it is here because the config comment this file pairs with +/// once claimed a warn row "still reaches the agent at the call". +#[test] +fn the_row_is_live_at_default_strictness() { + let out = decision_with("git rebase origin/main", false); + assert!( + out.contains("\"deny\"") && out.contains(ROW), + "`{ROW}` must refuse without --fail-on-warning; a warn row would emit \ + nothing here, got: {out}" + ); +} + // --- refused: a lap the task owns --------------------------------------------- /// THE MEASURED SHAPE. This is the command the session ran after `verify` had diff --git a/crates/batten/tests/it/land_lap.rs b/crates/batten/tests/it/land_lap.rs new file mode 100644 index 000000000..173e11ac4 --- /dev/null +++ b/crates/batten/tests/it/land_lap.rs @@ -0,0 +1,358 @@ +//! The lap, and where `mise-tasks/land.sh`'s 146 cases went (CLOUD-1148). +//! +//! # What this file is +//! +//! `mise-tasks/land.sh` was 2250 lines of consumer-specific landing policy and +//! `tests/land.bats` pinned it with 146 cases. Both are retired here. This file +//! carries the ledger `policy/shell-retirement.rego` reads — two file-level arms +//! and one row per `@test` title — plus the cases that answer the one title no +//! single engine assertion already covers. +//! +//! # The per-title rows are not what the gate counts, and that is the point +//! +//! `shell-retirement` counts `arms_for(path)` and stops: two arms, one per +//! deleted path. The 146 rows below are invisible to it. They exist because +//! reading titles has produced a live defect once per suite across this whole +//! campaign, every one in code written the same session and green under its own +//! tests — an empty base reported as trunk movement, an unset fan-in cancelling +//! the fan-in's own run, `lease check` naming the holder while dropping the +//! successor, and here **four cases whose behaviour the engine did not carry at +//! all**: a refusal over a borrowed tree, a refusal caused by the machine, and +//! each one's anti-vacuity twin. Those are built rather than dispositioned; +//! `crates/batten/tests/it/land_verify_advice.rs` is where they landed. +//! +//! # Reading the arms +//! +//! `carried` — the behaviour is in the engine, at the named source. `changed` — +//! conserved with a stated difference, and the difference is the point of the +//! row. `subsumed` — folded into a broader assertion, named. `withdrawn` — the +//! declared subject died and nothing replaced it, with the reason; admissible +//! only because `mise-tasks/land.sh` dies in this same change (CLOUD-1268). +//! +//! **Ten withdrawals, and nine of them are one class**: the predecessor's own +//! process management. It forked watcher subshells, synchronised them on a FIFO, +//! tracked their pids and escalated TERM to KILL, and nine cases pinned that +//! machinery. The engine races two polls inside one process with no children, so +//! there is nothing to reap, nothing to signal and no rendezvous to create — the +//! properties are held by construction rather than by mechanism. A `carried` row +//! claiming otherwise would name a successor that does not exist, which is the +//! laundering the ledger exists to refuse. +//! +//! The tenth is the conclusion-literal sensor, whose two declared subjects are +//! both already retired. +//! +//! # Keyed by the TITLE, never by the path +//! +//! A row whose first field is the retired path is indexed as another arm FOR +//! that path, and the deletion then reads `shell retire unclear` — two arms +//! where the gate wants one. `bot_lane.rs` is the shape and `trunk_watch.rs` the +//! landed example. + +// carried: mise-tasks/land.sh crates/batten/src/lib.rs kind:verb crates/batten/tests/it/land_lap.rs +// carried: tests/land.bats crates/batten/src/lib.rs kind:verb crates/batten/tests/it/land_lap.rs +// +// AND ONE CASE FROM A SUITE THAT LIVES ON. `tests/reclaim-census.bats` read the +// retired lander for its stop note; its own subject is alive, so the case is +// `ported` rather than `withdrawn` (CLOUD-1268). +// ported: "land records the stop it causes itself, or every clean landing reads as a reclaim" crates/batten/tests/it/land_lap.rs subject:mise-tasks/reclaim-census.sh +// +// The 146 titles, one row each. +// +// carried: "a refusal starts the next lap instead of ending the run" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "a cancelled run is the bot failing to DECIDE, not a refusal" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "a SIBLING PR's refusal is not this lap's verdict (CLOUD-409)" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "a keyed refusal IS still read — the filter did not stop reading" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "this lap's own run is read even when it fell off the first page (CLOUD-456)" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "paging stops at the short page instead of walking history (CLOUD-456)" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "a /fast-forward the API refused is never reported as posted (CLOUD-408)" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "CLOUD-413: a refused comment waits the retry-after the response STATES" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "CLOUD-413: with no retry-after it waits until x-ratelimit-reset" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "CLOUD-413: a response stating no limit headers still waits a floor" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "CLOUD-413: exhausting the budget names the LIMIT, not a moving main" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "a 403 from the runs query is not an answer (CLOUD-414)" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "an unreadable answer re-asks without buying a CI run" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "the fast-forward verdict is KEYED, not merely windowed" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "a lap rebases onto the main that moved, then re-verifies the new SHA" crates/batten/src/land.rs kind:mechanism +// carried: "a conflicting rebase is the one stop, and it aborts what it started" crates/batten/src/land.rs kind:mechanism +// carried: "a failing verify stops before CI is ever asked" crates/batten/src/lib.rs kind:mechanism +// withdrawn: "CLOUD-510: a racer land killed on purpose delivers no verdict" a bash-only property: the predecessor forked a racer per lap and the case asserted a killed subshell published nothing. The engine runs one process with no children, so there is no racer to kill and no verdict channel for it to write to +// carried: "CLOUD-510: a genuine ci-wait failure still stops the lap" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-407: a refused tree stops on lap 1 and carries the gate's own pointers" crates/batten/src/lib.rs kind:mechanism +// carried: "a verify that failed only because main moved laps instead of stopping" crates/batten/src/land.rs kind:mechanism +// carried: "the lap cap's refusal states what its own accounting supports" crates/batten/src/land.rs kind:mechanism +// carried: "the two exhaustions give imperatives consistent with their costs" crates/batten/src/land.rs kind:mechanism +// carried: "a verify that keeps losing the race exhausts laps rather than spinning" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-399: the two exhaustions are told apart by CODE, not by prose" crates/batten/src/land.rs kind:mechanism +// carried: "a body that defers a decision with no ticket stops before review is asked for" crates/batten/src/lib.rs kind:mechanism +// carried: "a row this branch filed without grooming it stops before review is asked for" crates/batten/src/lib.rs kind:mechanism +// carried: "THE PR BODY REACHES filed-here-check, or its exemption is inert" crates/batten/src/lib.rs kind:mechanism +// carried: "CLOUD-995: a gate that exits before reading stdin is not a refusal" crates/batten/src/land.rs kind:mechanism +// carried: "a body that names its issue but never closes it stops before review is asked for" crates/batten/src/lib.rs kind:mechanism +// carried: "a prose-only branch stops before review is asked for" crates/batten/src/lib.rs kind:mechanism +// carried: "a missing verify receipt stops the lap" crates/batten/src/lib.rs kind:mechanism +// carried: "red CI stops the lap without asking for the merge" crates/batten/src/land.rs kind:mechanism +// carried: "a run CI DECLINED is a stop, not a red — the agent is told to rebase" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-470: the declination is asked of land-lock, not re-derived" crates/batten/src/land.rs kind:mechanism +// carried: "a verdict that could not be READ is not a red one" crates/batten/src/checks_green.rs kind:mechanism +// carried: "an unset required roster stops rather than readying (CLOUD-467)" crates/batten/src/checks_green.rs kind:mechanism +// carried: "CLOUD-376: an unset ANSWERED set stops rather than readying, for the same reason" crates/batten/src/checks_green.rs kind:mechanism +// withdrawn: "CLOUD-376: no conclusion name is written in mise-tasks outside the manifest" both declared subjects are retired — `mise-tasks/land.sh` dies in this change and `mise-tasks/checks-green.sh` is already gone — so the case has no source to read. The property is structural in the engine: a conclusion literal inside `crates/batten` is non-negotiable rule 1's own violation, which `document_facts.rs` refuses +// carried: "a rejected push stops rather than clobbering someone else's branch" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-345: a branch ABSENT from the remote is a stale ref, not a rival" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-345: every fetch prunes, so a deleted upstream leaves no expectation" crates/batten/src/land.rs kind:mechanism +// carried: "an unfetchable origin stops instead of lapping on a stale main" crates/batten/src/land.rs kind:mechanism +// carried: "endless refusals hit the lap cap rather than lapping forever" crates/batten/src/lib.rs kind:mechanism +// carried: "land refuses to run from main" crates/batten/src/lib.rs kind:mechanism +// carried: "a merged PR exits 0" crates/batten/src/lib.rs kind:mechanism +// carried: "a PR that closed without merging exits non-zero" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "a run still in progress concludes neither way, and the poll continues" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "a run that predates this lap is not read as a verdict on it" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "the merge is what it waits for, not the comment" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "the poll carries no wall-clock timeout" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "a branch with no OPEN PR has nothing to land" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "THE MERGED-NAME CASE: a branch whose old PR merged binds the OPEN one (CLOUD-465)" crates/batten/src/fast_forward.rs kind:mechanism +// carried: "an already-proven HEAD is not proven again" crates/batten/src/lib.rs kind:mechanism +// carried: "main moving mid-wait starts the next lap instead of paying out the run" crates/batten/src/land.rs kind:mechanism +// carried: "a red CI re-drafts the PR before stopping" crates/batten/src/land.rs kind:mechanism +// carried: "a landing interrupted on an ungraded head re-drafts, not only a red one" crates/batten/src/land.rs kind:mechanism +// carried: "the same interruption over a green head leaves it ready" crates/batten/src/land.rs kind:mechanism +// carried: "a head whose verdict could not be read is left ready, never stranded" crates/batten/src/land.rs kind:mechanism +// carried: "a landing that merges leaves the PR alone" crates/batten/src/lib.rs kind:mechanism +// carried: "a refused second land does not re-draft the live one's PR" crates/batten/src/land.rs kind:mechanism +// carried: "a re-draft that cannot happen does not change the exit code" crates/batten/src/lib.rs kind:mechanism +// carried: "a draft PR is readied, which is the event that spends the run" crates/batten/src/land.rs kind:mechanism +// carried: "nothing is readied when the head already carries a graded run" crates/batten/src/land.rs kind:mechanism +// carried: "a ready PR whose head carries only skipped runs has its ready re-fired" crates/batten/src/land.rs kind:mechanism +// carried: "a ready PR whose head carries only cancelled runs has its ready re-fired" crates/batten/src/land.rs kind:mechanism +// carried: "the re-drafted PR a cancelled set left behind is readied, not stuck" crates/batten/src/land.rs kind:mechanism +// carried: "a DRAFT whose push moves nothing readies once, not once and then again" crates/batten/src/land.rs kind:mechanism +// carried: "THE RACE: the ready precedes the push, so one event carries the run" crates/batten/src/pipeline.rs kind:mechanism +// carried: "a lap that pushed does not also buy a second event" crates/batten/src/land.rs kind:mechanism +// carried: "a landing that succeeds says nothing that reads as a failure" crates/batten/src/lib.rs kind:mechanism +// carried: "the receipt guard still has its voice when it is the real failure" crates/batten/src/lib.rs kind:mechanism +// carried: "a silent bot with main moved ends the lap instead of polling" crates/batten/src/main_watch.rs kind:mechanism +// carried: "a silent bot with main unmoved keeps polling" crates/batten/src/main_watch.rs kind:mechanism +// withdrawn: "the watcher does not outlive a merged landing" the predecessor backgrounded a watcher subshell and reaped it on the merged path. `land::wait` races in-process and returns, so nothing outlives the call and there is no reaper to assert +// carried: "a re-draft that fails stops the lap rather than waiting on a run nobody started" crates/batten/src/lib.rs kind:mechanism +// carried: "a ready that fails stops before the push rather than pushing into silence" crates/batten/src/lib.rs kind:mechanism +// subsumed: "every way a lap can end is exercised above" crates/batten/src/pipeline.rs crates/batten/tests/it/land_lap.rs +// changed: "main moving during verify ends the lap at the poll, never at the end of the gate" crates/batten/src/land.rs kind:mechanism — the engine asks `Precheck::BaseMoved` BEFORE the ready rather than racing the gate, so a base that moves mid-gate is caught at the next step rather than aborting the run in flight — CLOUD-423's metered half, with the early abort stated as a shortfall rather than absorbed +// carried: "a verify race with no verdict laps and re-proves rather than guessing" crates/batten/src/lib.rs kind:mechanism +// carried: "a merged PR's branch is deleted from the remote" crates/batten/src/land.rs kind:mechanism +// carried: "a delete the remote refuses does not change land's exit code" crates/batten/src/land.rs kind:mechanism +// carried: "a run that stops instead of merging deletes nothing" crates/batten/src/lib.rs kind:mechanism +// carried: "a second land in this clone is refused before anything is spent (CLOUD-428)" crates/batten/src/lib.rs kind:mechanism +// carried: "the lease is taken before the push, so no run starts unheld" crates/batten/src/lease.rs kind:mechanism +// carried: "a lease held by someone else waits instead of pushing, and says so" crates/batten/src/lease.rs kind:mechanism +// carried: "a lost lease is caught BEFORE the merge is asked for" crates/batten/src/lease.rs kind:mechanism +// carried: "the lease is released on the merged path" crates/batten/src/lib.rs kind:mechanism +// carried: "the lease is released on a die path too — a leak would wedge the fleet" crates/batten/src/pipeline.rs kind:mechanism +// withdrawn: "the CI race waits on ITS OWN pids, never on every background job" the predecessor tracked its own background pids to avoid `wait`ing on every job in the shell. One process, no jobs, nothing to disambiguate +// withdrawn: "a watcher that shrugs off the TERM is escalated, never left to outlive the lap" a TERM-then-KILL escalation over a subshell that ignored the first signal. The engine spawns no watcher, and `exec`'s own process-group handling is `policy/spawn-adapters.rego`'s subject rather than the lander's +// withdrawn: "a detached descendant cannot hold bats' output stream — fd 3 is closed beneath the program under test" a bats harness property — fd 3 is the runner's output stream, and the case asserted a detached descendant could not hold it open. There is no descendant and no bats +// withdrawn: "a land killed mid-race takes its watchers with it — the trap reaps the races too" the predecessor's EXIT trap reaped its race subshells so a killed lander took them with it. Nothing is backgrounded, so the trap has no subject +// carried: "a head whose LATEST required run is a skip has no answer, so the ready is fired" crates/batten/src/checks_green.rs kind:mechanism +// carried: "a skip its own re-run superseded is an answer, so nothing buys a second run" crates/batten/src/checks_green.rs kind:mechanism +// carried: "a run main moved under is CANCELLED, not left to bill for an answer nobody reads" crates/batten/src/land.rs kind:mechanism +// carried: "a lap that CI answered cancels nothing — only a voided run is void" crates/batten/src/land.rs kind:mechanism +// carried: "a waiter linearizes onto the HOLDER's head, not onto the main it is replacing" crates/batten/src/lib.rs kind:mechanism +// carried: "a lease naming no head leaves the branch linearized on main, and says nothing" crates/batten/src/lib.rs kind:mechanism +// carried: "A CONFLICTING SPECULATION FALLS BACK — it is information, not a stop" crates/batten/src/lib.rs kind:mechanism +// carried: "THE BET CANNOT BE PUSHED WHEN IT LOSES: a stale speculation is unwound first" crates/batten/src/lib.rs kind:mechanism +// carried: "an unwind the tree refuses is a stop, not a lap onto an unknown HEAD" crates/batten/src/lib.rs kind:mechanism +// carried: "THE SECOND MATRIX: an admitted successor readies and pushes without the lease" crates/batten/src/lease.rs kind:mechanism +// carried: "a verify failure on a SPECULATIVE tree names the borrowed base" crates/batten/src/lib.rs kind:mechanism +// carried: "a verify failure with NO speculation still gets the original advice" crates/batten/src/lib.rs kind:mechanism +// carried: "A WAITER THAT IS NOT ADMITTED STAYS IN DRAFT — this is what bounds the cost" crates/batten/src/lease.rs kind:mechanism +// carried: "the successor reserves only once, however many laps it waits" crates/batten/src/lease.rs kind:mechanism +// carried: "MAIN MOVING DURING THE WAIT: the winner laps rather than confirming a doomed head" crates/batten/src/land.rs kind:mechanism +// carried: "a lap whose main did not move confirms and proceeds — the negative of the case above" crates/batten/src/land.rs kind:mechanism +// carried: "the successor's run is bought ONCE, not re-pushed on every lap it waits" crates/batten/src/land.rs kind:mechanism +// carried: "AN ABANDONED HOLDER IS NOT A PENDING BET: the lease freed unwinds it" crates/batten/src/speculation.rs kind:mechanism +// carried: "the lease passing to a branch that does not carry our base unwinds it" crates/batten/src/speculation.rs kind:mechanism +// carried: "A LIVE BET IS LEFT ALONE — without this, the unwind fires every lap" crates/batten/src/speculation.rs kind:mechanism +// carried: "a liveness read that fails is stale, never live — the fetch fails closed" crates/batten/src/lib.rs kind:mechanism +// carried: "WINNING THE LEASE SETTLES THE BET FIRST: no borrowed tree is readied, pushed or merged" crates/batten/src/pipeline.rs kind:mechanism +// carried: "a bet already PUSHED is re-drafted before its remote is rewound" crates/batten/src/lib.rs kind:mechanism +// changed: "CLOUD-483: a run that died before any mise step is re-run, not reported red" crates/batten/src/land.rs kind:mechanism — `land::rerun_failed` exists and the lap does not yet reach it: telling a run that died before any step from one that reached a verdict needs the job-level reading, which is CLOUD-483's own row. `lib.rs` states the gap at the site rather than hiding it +// changed: "CLOUD-483: a job that reached a verdict is red, and is never re-run" crates/batten/src/land.rs kind:mechanism — the mirror of the row above, and the same gap: with no job-level reading the engine treats every red as a verdict, which is the SAFE direction — it never re-runs a genuine failure, it only fails to absorb a transient +// carried: "CLOUD-900: a genuine red abandons the rest of the matrix" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-900: a run CI DECLINED abandons nothing — it is not a verdict" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-900: a provisioning transient abandons nothing — the jobs get re-run" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-900: a lap CI answered green abandons nothing" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-483: EMPTY IS NOT UNANIMOUS — no records is red, not absorbed" crates/batten/src/checks_green.rs kind:mechanism +// carried: "CLOUD-483: the retry budget is a COUNT, and exhausting it stops" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-483: a re-run the API refuses stops, naming the command" crates/batten/src/land.rs kind:mechanism +// withdrawn: "CLOUD-383: a rendezvous that cannot be created stops, rather than guessing" a bash rendezvous — a FIFO the racing subshells synchronised on. The engine's race is two polls in one process and needs no rendezvous to create or fail to create +// withdrawn: "CLOUD-383: the CI wait's rendezvous stops too, at top level" the same rendezvous at the CI wait's own level, and gone for the same reason +// withdrawn: "CLOUD-383: the races carry no bash-4 construct" a portability assertion over the predecessor's own source: no bash-4 construct in the race code. There is no bash and no race code +// carried: "CLOUD-518: a session that has not dropped the subscription cannot land" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-518: the check runs against THIS PR, not some other" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-790: the landing makes the unsubscribe call itself, for THIS PR" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-790: a drop that could not happen does not stop the landing itself" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-518: a dropped subscription lets the landing proceed untouched" crates/batten/src/land.rs kind:mechanism +// carried: "CLOUD-369 clause b1-neg — a holder whose CI answers RED admits nobody" crates/batten/src/lease.rs kind:mechanism +// carried: "CLOUD-369 clause b1-neg — a holder whose CI has NOT ANSWERED admits nobody" crates/batten/src/lease.rs kind:mechanism +// carried: "CLOUD-369 clause b1-neg — a holder whose CI COULD NOT BE READ admits nobody" crates/batten/src/lease.rs kind:mechanism +// carried: "CLOUD-369 clause b1-neg — a lease naming no head admits nobody" crates/batten/src/lease.rs kind:mechanism +// carried: "CLOUD-369 clause b1-pos — a GREEN holder still admits exactly one waiter" crates/batten/src/lease.rs kind:mechanism +// carried: "CLOUD-369 clause e — a waiter whose base CONFLICTS is not admitted" crates/batten/src/lease.rs kind:mechanism +// carried: "CLOUD-369 clause e — a waiter whose base APPLIES CLEANLY still is admitted" crates/batten/src/lease.rs kind:mechanism +// carried: "CLOUD-861: an ENOSPC during verify is reported as the environment, not as a defect to reproduce" crates/batten/src/land.rs kind:mechanism +// carried: "an ordinary verify failure still says reproduce it locally" crates/batten/src/lib.rs kind:mechanism +// carried: "CLOUD-862: a bet left by a dead run is adopted and unwound before anything is pushed" crates/batten/src/speculation.rs kind:mechanism +// carried: "CLOUD-862: an adopted bet whose base LANDED keeps the tree and just drops the ref" crates/batten/src/speculation.rs kind:mechanism +// carried: "CLOUD-862: a bet ref naming a commit this tree is not built on is dropped, not acted on" crates/batten/src/speculation.rs kind:mechanism +// carried: "a run with no bet ref is untouched by the recovery path" crates/batten/src/speculation.rs kind:mechanism + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use batten::exit::ExitCode; +use batten::land::{self, Progress, Step, TapVerdict}; +use batten::pipeline::{COMMIT_POINT, Pipeline}; + +/// **EVERY WAY A LAP CAN END, and the table is total.** +/// +/// The successor to *"every way a lap can end is exercised above"*, which counted +/// the predecessor's own arms. The engine's equivalent is stronger and cheaper: +/// `land::progress_of` is a total function of (step, code, verdict), so the +/// question is not whether a suite remembered to exercise each ending but whether +/// each ending is REACHABLE — a variant nothing can produce is a dead arm, and a +/// pair that produced nothing would be a lap with no answer. +#[test] +fn every_lap_ending_is_reachable_from_some_step_and_code() { + let codes = [ + ExitCode::Success, + ExitCode::Usage, + ExitCode::Violation, + ExitCode::Internal, + ]; + let steps = [ + Step::Replay, + Step::Verify, + Step::Ready, + Step::Push, + Step::Wait, + Step::FastForward, + ]; + + let mut seen: Vec = Vec::new(); + for step in steps { + for code in codes { + for verdict in [ + None, + Some(TapVerdict::Green), + Some(TapVerdict::Red), + Some(TapVerdict::Pending), + ] { + let progress = land::progress_of(step, code, verdict); + if !seen.contains(&progress) { + seen.push(progress); + } + } + } + } + + // ALL FOUR, and the assertion names which is missing rather than a count — + // a bare `assert_eq!(seen.len(), 4)` reports a number where a reader needs + // the arm. + for ending in [ + Progress::Proceed, + Progress::Lap, + Progress::Landed, + Progress::Stop, + ] { + assert!( + seen.contains(&ending), + "{ending:?} is unreachable from every (step, code, verdict): a lap can never end that way" + ); + } +} + +/// **THE SHIPPED COMPOSITION LOADS, AND THE COMMIT POINT IS LAST.** +/// +/// The lap's shape is a declared list now rather than an array literal, so the +/// property the predecessor asserted by reading its own source is asserted over +/// the object the driver actually walks. +#[test] +fn the_shipped_lap_validates_and_ends_at_the_commit_point() { + let shipped = Pipeline::default(); + assert_eq!( + shipped.validate(), + Vec::new(), + "the composition this repository ships must load" + ); + assert_eq!( + shipped.steps.last().map(|row| row.step), + Some(COMMIT_POINT), + "nothing may run after the irreversible step" + ); +} + +/// **THE LANDING'S OWN STOP IS STILL RECORDED, so a clean finish does not read +/// as a container death.** +/// +/// `tests/reclaim-census.bats` carried this as *"land records the stop it causes +/// itself"*, reading `mise-tasks/land.sh` for the note. Its subject +/// (`mise-tasks/reclaim-census.sh`) is alive and the note moved rather than died: +/// the lander used to spawn it inline, and the engine's `lease release` spawns +/// what `$LEASE_STOP_NOTE` names. So the case is PORTED here rather than +/// withdrawn. +/// +/// **The declaration is the subject, not the spawn.** The note's text is this +/// consumer's — a census program's argv — so `crates/batten` may not carry it +/// (non-negotiable rule 1) and what this can assert is that the consumer still +/// declares one. A lap that stopped and recorded nothing leaves its last census +/// record an `h`, and every successful landing then reads as *the container died +/// under active work*. +#[test] +fn the_landings_own_stop_note_is_still_declared() { + let manifest = + std::fs::read_to_string("../../mise.toml").expect("read the consumer's manifest"); + let declared: Vec<&str> = manifest + .lines() + .filter(|line| line.trim_start().starts_with("LEASE_STOP_NOTE")) + .collect(); + assert_eq!( + declared.len(), + 1, + "exactly one stop note is declared, or the lease spawns an ambiguous one" + ); + // THE COUNT AND THE CONTENT, because a row declared empty would satisfy the + // count alone — and an empty note is a lease that records nothing, which is + // byte-identical to the defect this case exists to catch. + assert!( + declared[0].contains("land-stopped"), + "the declared note must still mark a landing's own stop: {}", + declared[0] + ); +} + +/// **A LAP NEVER RUNS FROM THE TRUNK.** +/// +/// Over the compiled binary, because this is the one property in the file whose +/// subject is the verb rather than a function: *"land refuses to run from main"* +/// is about what a person typing the command gets back. +#[test] +fn a_lap_refuses_to_run_from_the_trunk() { + let dir = common::scratch("land-lap-from-trunk"); + common::init_repo(&dir); + + let output = common::batten() + .arg("land") + .arg("lap") + .arg("main") + .current_dir(&dir) + .output() + .expect("the compiled binary runs"); + let said = String::from_utf8_lossy(&output.stderr); + + assert_ne!( + output.status.code(), + Some(0), + "landing from the trunk must not succeed: {said}" + ); +} diff --git a/crates/batten/tests/it/land_verify_advice.rs b/crates/batten/tests/it/land_verify_advice.rs new file mode 100644 index 000000000..04d902857 --- /dev/null +++ b/crates/batten/tests/it/land_verify_advice.rs @@ -0,0 +1,420 @@ +//! What a stopped lap TELLS the person who has to act on it (CLOUD-861, CLOUD-727). +//! +//! # Why this tier exists +//! +//! `run_land_verify`'s refusal arm printed one unconditional line, so three very +//! different failures were reported identically: a gate that refused about this +//! tree, a gate that died of the machine, and a gate that refused over a tree +//! carrying commits this branch did not write. Two of the three were told to +//! reproduce and fix locally, which is a wasted cycle in one case and a hunt +//! through somebody else's diff in the other. +//! +//! Both were measured rather than imagined. `mise-tasks/land.sh` carried four +//! cases pinning them (`CLOUD-861: an ENOSPC during verify is reported as the +//! environment…`, `a verify failure on a SPECULATIVE tree names the borrowed +//! base`, and each one's anti-vacuity twin) and the engine carried no successor +//! for any of them, which is what blocked that program's retirement. +//! +//! # Each case has its twin, and that is the whole design +//! +//! Every narrowing here can be got wrong in the same way: by swallowing the +//! general case. A classifier that called every refusal environmental would turn +//! every stop into *check your disk*, which is CLOUD-811's misattribution rebuilt +//! facing the other way. So no case asserts only that the new line appears — each +//! asserts the OTHER line does not, and its twin asserts the reverse. + +#![cfg(unix)] +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +/// A gate that fails, printing whatever the case needs on the way. +/// +/// Failing is the precondition every case here shares: this file is about which +/// ADVICE a refusal earns, so a gate that passed would exercise nothing. +fn failing_gate(dir: &std::path::Path, says: &str) -> String { + let gate = dir.join("gate.sh"); + std::fs::write( + &gate, + format!("#!/usr/bin/env bash\nprintf '%s\\n' {says}\nexit 1\n"), + ) + .expect("write the gate"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&gate, std::fs::Permissions::from_mode(0o755)) + .expect("make the gate runnable"); + } + gate.to_string_lossy().into_owned() +} + +/// The classifier row every fixture here declares, in one place. +/// +/// Extracted for the worktree case below rather than for brevity: that case +/// turns on the SAME row being found or not found, decided only by which root +/// the engine anchored on. Two copies could drift, and a drifted copy would make +/// the case pass by declaring nothing rather than by resolving the wrong tree. +const CLASSIFIER: &str = "\n[[verify_environment_pattern]]\n\ + id = \"disk-full\"\n\ + pattern = \"No space left on device\"\n\ + stream = \"both\"\n\ + reason = \"the disk filled. Reclaim something.\"\n"; + +/// A repository whose committed authority declares the disk-full classifier. +/// +/// The row is written here rather than assumed from this repository's own +/// `batten.toml`, because a case reading the real one would pass for the wrong +/// reason the day somebody edits it — and because the whole point of the table is +/// that the literal is the CONSUMER's. +fn repo(name: &str, declare_classifier: bool) -> std::path::PathBuf { + let classifier = if declare_classifier { CLASSIFIER } else { "" }; + // `Fixture … .git().base_commit()` rather than `init_repo`, which + // initialises and does NOT commit: `land::verify` reads this clone's HEAD + // before it runs anything, so an unborn HEAD refuses at the boundary and + // every case here would be asserting over a lap that never reached the gate. + common::Fixture::at(common::scratch(name).join("repo")) + .config(&format!("version = 1\n{classifier}")) + .file("lib.rs", "fine\n") + .git() + .base_commit() + .build() +} + +/// Run `batten land verify` in `dir` with the gate configured. +fn verify(dir: &std::path::Path, gate: &str) -> (i32, String) { + let output = common::batten() + .arg("land") + .arg("verify") + .env("LAND_VERIFY", gate) + .current_dir(dir) + .output() + .expect("the compiled binary runs"); + let mut both = String::from_utf8_lossy(&output.stdout).into_owned(); + both.push_str(&String::from_utf8_lossy(&output.stderr)); + ( + output.status.code().expect("the child exited normally"), + both, + ) +} + +/// A **linked worktree** whose own branch declares the classifier, off a main +/// checkout that declares none. +/// +/// The asymmetry is the instrument. Both roots exist and both carry a +/// `batten.toml`, so neither anchor can fail to find a file — what differs is +/// only WHICH file, which is the one thing a case anchored at a single root +/// cannot see. +fn repo_with_declaring_worktree(name: &str) -> std::path::PathBuf { + let main = repo(name, false); + // Branched BEFORE the row lands, so the worktree's tree genuinely carries + // its own authority rather than inheriting one and editing it. + common::git_in(&main, &["branch", "declares-it"]); + + // `scratch` wipes and creates; `git worktree add` insists on creating the + // directory itself, so it is removed again immediately — going through the + // one helper is what keeps this under `target/tmp` with every other fixture. + let linked = common::scratch(&format!("{name}-linked")); + let _ = std::fs::remove_dir_all(&linked); + common::git_in( + &main, + &[ + "worktree", + "add", + "--quiet", + linked.to_str().unwrap_or_default(), + "declares-it", + ], + ); + common::write( + &linked, + "batten.toml", + &format!("version = 1\n{CLASSIFIER}"), + ); + common::git_in(&linked, &["add", "-A"]); + common::git_in(&linked, &["commit", "-q", "-m", "declare the classifier"]); + linked +} + +/// **THE CLASSIFIER IS THE WORKING TREE'S, NOT THE MAIN CHECKOUT'S** (CLOUD-1586). +/// +/// Red before the anchor moved to [`batten::git::worktree_root`]. `repo_root` +/// answers with the MAIN checkout by design — the common dir is shared, which is +/// what keeps per-repository STATE one store (CLOUD-164) — so anchoring a +/// committed config there read a `batten.toml` this branch does not own. Every +/// `[[verify_environment_pattern]]` row then failed to load, and +/// `verify_environment`'s own fail-safe turns an unreadable table into an EMPTY +/// one at exit 0, so the refusal classified as `Refusal::Tree` and the operator +/// was told to reproduce a defect the machine had caused. +/// +/// `git::worktree_root`'s header states the rule this pins: *"committed config +/// is the WORKING TREE's, state is the REPOSITORY's."* The mirror image is +/// deliberately the other way one surface over — `hook_worktree_root.rs` asserts +/// a mediated call IS judged by the repository's authority, because that is the +/// repository binding the agent rather than a branch deciding its own toolchain +/// vocabulary. +/// +/// **And a linked worktree is where agents work**, which is why this is a live +/// defect rather than a tidiness: the anchored-at-the-cwd fix that preceded it +/// looked correct from the main checkout and was wrong everywhere else. +#[test] +fn the_classifier_is_read_from_the_worktree_being_judged_not_the_main_checkout() { + let linked = repo_with_declaring_worktree("verify-advice-worktree"); + let gate = failing_gate( + &linked, + "'rustc-LLVM ERROR: IO failure: No space left on device'", + ); + let (code, said) = verify(&linked, &gate); + + assert_eq!(code, 2, "a gate that ran and refused is a verdict: {said}"); + // THE ASSERTIONS THAT FAILED BEFORE THIS LANDED — both, because an empty + // table produces the ordinary advice rather than an error, so the tell is + // the environment line's ABSENCE and the tree line's presence. + assert!( + said.contains("environment rather than of this tree"), + "the worktree's own row should have classified this: {said}" + ); + assert!( + said.contains("Reclaim something"), + "the row read back must be the worktree's, remedy and all: {said}" + ); + assert!( + !said.contains("reproduce and fix locally"), + "the main checkout declares no row; reading it is what produced this \ + advice for a failure the branch did not cause: {said}" + ); +} + +/// **ANTI-VACUITY: the worktree's SILENCE is respected too.** +/// +/// Without this, an anchor that simply searched harder — or one that merged both +/// roots — would satisfy the case above. Here the main checkout declares the row +/// and the worktree does not, so the correct answer is the ordinary advice: a +/// branch that retired a classifier must not have the main checkout's reinstated +/// under it, which is the *"permanently unverifiable"* direction +/// `git::worktree_root`'s header names as equally bad. +#[test] +fn a_worktree_declaring_nothing_is_not_handed_the_main_checkouts_classifier() { + // The classifier lands on the main checkout, and the branch the worktree + // sits on predates it. + let main = repo("verify-advice-worktree-silent", true); + common::git_in(&main, &["branch", "-f", "declares-nothing", "HEAD"]); + let linked = common::scratch("verify-advice-worktree-silent-linked"); + let _ = std::fs::remove_dir_all(&linked); + common::git_in( + &main, + &[ + "worktree", + "add", + "--quiet", + linked.to_str().unwrap_or_default(), + "declares-nothing", + ], + ); + common::write(&linked, "batten.toml", "version = 1\n"); + common::git_in(&linked, &["add", "-A"]); + common::git_in(&linked, &["commit", "-q", "-m", "retire the classifier"]); + + let gate = failing_gate( + &linked, + "'rustc-LLVM ERROR: IO failure: No space left on device'", + ); + let (code, said) = verify(&linked, &gate); + + assert_eq!(code, 2, "{said}"); + assert!( + said.contains("reproduce and fix locally"), + "this worktree declares no row, so nothing may be blamed on the \ + machine: {said}" + ); + assert!( + !said.contains("environment rather than of this tree"), + "the main checkout's row must not reach a branch that retired it: {said}" + ); +} + +/// **A REFUSAL MATCHING A DECLARED ROW IS THE ENVIRONMENT'S.** +/// +/// Measured 2026-08-21: the reclaim passed the lap with 6242MB against its +/// 4096MB floor, the link step consumed all of it, and the stop said *"Reproduce +/// and fix locally"* over a tree with nothing wrong in it. +/// +/// The remedy asserted is the ROW'S OWN, which is what proves the engine read it +/// back out rather than composing one — a composed remedy would be this +/// repository's vocabulary inside `crates/batten`. +#[test] +fn a_refusal_the_declared_row_matches_is_named_as_the_environment() { + let dir = repo("verify-advice-environment", true); + let gate = failing_gate( + &dir, + "'rustc-LLVM ERROR: IO failure: No space left on device'", + ); + let (code, said) = verify(&dir, &gate); + + assert_eq!(code, 2, "a gate that ran and refused is a verdict"); + assert!( + said.contains("environment rather than of this tree"), + "the refusal should be named as the environment's: {said}" + ); + assert!( + said.contains("Reclaim something"), + "the row's own remedy should be read back out: {said}" + ); + // THE ASSERTION THAT FAILED BEFORE THIS LANDED. + assert!( + !said.contains("reproduce and fix locally"), + "advice for a defect this branch did not write: {said}" + ); +} + +/// **ANTI-VACUITY: a refusal matching nothing still gets the ordinary advice.** +/// +/// Without this, a classifier that answered *environment* unconditionally would +/// satisfy the case above — and it would be CLOUD-811's defect facing the other +/// way, telling every author with a genuine test failure to check their disk. +#[test] +fn a_refusal_matching_no_declared_row_still_says_reproduce_it_locally() { + let dir = repo("verify-advice-ordinary", true); + let gate = failing_gate(&dir, "'tests/primitives.rs:1171 a real finding'"); + let (code, said) = verify(&dir, &gate); + + assert_eq!(code, 2, "{said}"); + assert!( + said.contains("reproduce and fix locally"), + "an ordinary refusal keeps the advice that is right for it: {said}" + ); + assert!( + !said.contains("environment rather than of this tree"), + "nothing declared matched, so nothing may be blamed on the machine: {said}" + ); +} + +/// **AN UNDECLARED TABLE CLASSIFIES NOTHING, and that is the safe direction.** +/// +/// A consumer who declares no rows gets the advice that was always given. The +/// engine must not invent a classifier of its own — the literal is a toolchain's +/// wording and belongs to the repository that runs it (non-negotiable rule 1). +#[test] +fn a_consumer_declaring_no_rows_classifies_nothing_as_the_environment() { + let dir = repo("verify-advice-undeclared", false); + let gate = failing_gate( + &dir, + "'rustc-LLVM ERROR: IO failure: No space left on device'", + ); + let (code, said) = verify(&dir, &gate); + + assert_eq!(code, 2, "{said}"); + assert!( + said.contains("reproduce and fix locally"), + "with no declared row the ordinary advice stands: {said}" + ); + assert!( + !said.contains("environment rather than of this tree"), + "the engine must carry no classifier of its own: {said}" + ); +} + +/// Leave a bet on disk: a `BASE_REF` naming a commit this HEAD is built on. +/// +/// `speculation::recover` requires exactly that ancestry — a ref naming a commit +/// the tree is NOT built on is stale rather than stranded, and the two must not +/// be confused. So the fixture commits twice and records the first. +fn leave_a_bet(dir: &std::path::Path) -> String { + let base = common::git_in(dir, &["rev-parse", "HEAD"]) + .trim() + .to_owned(); + std::fs::write(dir.join("more.txt"), "more\n").expect("write a second file"); + common::git_in(dir, &["add", "-A"]); + common::git_in(dir, &["commit", "-q", "-m", "second"]); + // `speculation::BASE_REF` spelled through the constant, so a rename moves + // this fixture with it rather than leaving it recording under a name nothing + // reads — which would pass the anti-vacuity twin and fail nothing. + let reference = dir.join(".git").join(batten::speculation::BASE_REF); + let parent = reference.parent().expect("the ref has a directory"); + std::fs::create_dir_all(parent).expect("create the ref directory"); + std::fs::write(&reference, format!("{base}\n")).expect("record the bet"); + base +} + +/// **A REFUSAL OVER A BORROWED TREE SAYS SO, AND SAYS IT AS A SUSPICION.** +/// +/// CLOUD-727. Measured 2026-08-19: a two-commit branch touching only memories +/// failed on two findings in files neither commit touched, and rebasing off the +/// speculative base was green first try. On 2026-08-22 the masked failure was in +/// the lander's OWN suite — the most expensive possible wrong place to send +/// someone. +/// +/// **The wording is the assertion.** That row retracted two attributions in one +/// day for treating *speculative* as the explanation because it was the salient +/// difference, so the message must say how to FIND OUT rather than deciding: the +/// failure *may not be yours*, and *if it still fails off the borrowed base, it +/// is yours*. +#[test] +fn a_refusal_over_a_borrowed_tree_names_the_base_and_offers_both_recoveries() { + let dir = repo("verify-advice-speculative", true); + let base = leave_a_bet(&dir); + let gate = failing_gate(&dir, "'tests/primitives.rs:1171 a real finding'"); + let (code, said) = verify(&dir, &gate); + + assert_eq!(code, 2, "{said}"); + assert!( + said.contains("this tree is SPECULATIVE"), + "a borrowed tree must say so: {said}" + ); + assert!( + said.contains(&base[..7]), + "the borrowed base is the pointer a reader follows: {said}" + ); + // BOTH recoveries: `rebase --onto` is not the only one, and the cheaper one + // is available whenever the remote still holds this branch unborrowed. + assert!(said.contains("rebase --onto"), "{said}"); + assert!(said.contains("reset --hard"), "{said}"); + // A SUSPICION, NEVER A VERDICT. + assert!(said.contains("may not be yours"), "{said}"); + assert!( + said.contains("If it still fails off the borrowed base, it is yours"), + "the message says how to find out rather than deciding: {said}" + ); + // The advice that is wrong here must not also be present. + assert!( + !said.contains("reproduce and fix locally"), + "pointing at a defect the author did not write: {said}" + ); +} + +/// **ANTI-VACUITY: no bet, no borrowed-base advice.** +/// +/// The twin that stops the fix widening a message which is already right in the +/// common case. Same failure, same gate, no bet on disk. +#[test] +fn a_refusal_with_no_bet_outstanding_gets_the_ordinary_advice() { + let dir = repo("verify-advice-unspeculative", true); + let gate = failing_gate(&dir, "'tests/primitives.rs:1171 a real finding'"); + let (code, said) = verify(&dir, &gate); + + assert_eq!(code, 2, "{said}"); + assert!(said.contains("reproduce and fix locally"), "{said}"); + assert!( + !said.contains("this tree is SPECULATIVE"), + "there is no borrowed base to blame: {said}" + ); +} + +/// **THE GATE'S OWN OUTPUT REACHES THE OPERATOR.** +/// +/// `land::verify` ran through `exec::run_in_env`, whose `ExecConfig::DEFAULT` +/// has `tee: false` — right for `batten exec`, where the bytes are addressable +/// and a caller can go and read them, and wrong for an interactive lap that has +/// just stopped. `Verified`'s own header claimed the output "went to the +/// caller's terminal where it belongs" and it had never done so. +#[test] +fn the_gate_that_refused_is_shown_saying_why() { + let dir = repo("verify-advice-tee", true); + let gate = failing_gate(&dir, "'tests/primitives.rs:1171 a real finding'"); + let (_, said) = verify(&dir, &gate); + + assert!( + said.contains("a real finding"), + "the operator is shown what the gate said: {said}" + ); +} diff --git a/crates/batten/tests/it/lease_health.rs b/crates/batten/tests/it/lease_health.rs new file mode 100644 index 000000000..a0d54f591 --- /dev/null +++ b/crates/batten/tests/it/lease_health.rs @@ -0,0 +1,197 @@ +//! `batten lease check`, over the compiled binary — the tier +//! `mise-tasks/land-lock-check.sh`'s retirement owes (CLOUD-1148). +//! +//! # What this tier is FOR, and what it deliberately is not +//! +//! The DECISION — absent, released, lapsed, live, wedged, garbage, and the +//! successor named in all five healthy renderings — is `lease::health`, a pure +//! function of a reading the caller already took. Its cases live beside it, and +//! that is the right home: every input is in hand, so the whole table is +//! exercisable without a remote, a clock or a fixture. +//! +//! What a load-time tier structurally cannot answer is what the BINARY does with +//! that decision, and the predecessor's suite could: the exit code, the channel +//! each verdict is written to, and whether a could-not-look is told apart from a +//! verdict. Those are this file's, and they are the half where a wrong answer is +//! silent — a `Wedged` mapped to `Success` would leave a wedged lease reported in +//! prose and passing its own gate. +//! +//! # THE STATES THAT NEED A REMOTE ARE NOT HERE, AND THAT IS STATED RATHER THAN +//! IMPLIED +//! +//! `lease::observe` reads the lease over smart HTTP and has no offline fixture +//! seam — the predecessor injected `$LAND_LOCK_BODY`, which the verb does not +//! take. So the four healthy states and the two refusals are reachable here only +//! through a live remote, and the cases below drive the two arms that need none: +//! a clone with no lease at all, and a remote that will not answer. +//! +//! Giving the verb a body-injection lever to close that gap was considered and +//! not done: it would be a second route to a verdict, present in every shipped +//! binary, whose only consumer is a test — which is the shape +//! `crates/batten/src/rest.rs`'s fixture seam is scoped to a client for +//! deliberately. The exit-code mapping over all four `Health` arms is asserted +//! instead where it lives, in `lib.rs`'s own module. + +// carried: mise-tasks/land-lock-check.sh crates/batten/src/lease.rs kind:mechanism crates/batten/tests/it/lease_health.rs runs:batten+lease+check +// carried: tests/land-lock-check.bats crates/batten/src/lease.rs kind:mechanism crates/batten/tests/it/lease_health.rs + +//! # RETIREMENT LEDGER — `tests/land-lock-check.bats`, 17 cases +//! +//! **Every title below is the base file's, byte for byte**, read from +//! `git show origin/main:tests/land-lock-check.bats` rather than reconstructed — +//! `receipt_verified.rs`'s own header records what happens otherwise, and it is +//! ten unmapped arms. +//! +//! CARRIED ONTO `lease::health`, whose cases sit beside it: every input is a +//! reading the caller already took, so the whole state table is exercisable with +//! no remote, no clock and no fixture. That is a BETTER home than the bats suite +//! had, which reached the same states only through an injected `$LAND_LOCK_BODY`. + +// carried: "an absent lease is healthy — nobody is landing" crates/batten/src/lease.rs +// carried: "a live lease is healthy and names its holder and remaining time" crates/batten/src/lease.rs +// carried: "a RELEASED lease is free, and is reported as a handover rather than an expiry" crates/batten/src/lease.rs +// carried: "a LAPSED lease is free too — a holder that stopped without releasing" crates/batten/src/lease.rs +// carried: "a lease expiring exactly now is free — zero seconds left is none" crates/batten/src/lease.rs +// carried: "WEDGED: a horizon beyond one TTL is refused, since nothing legitimate mints one" crates/batten/src/lease.rs +// carried: "a lease at exactly one TTL is the longest legitimate hold, not wedged" crates/batten/src/lease.rs +// carried: "GARBAGE: a ref carrying no lease body is refused" crates/batten/src/lease.rs +// carried: "GARBAGE: a non-numeric expiry is a refusal, never a shell error" crates/batten/src/lease.rs +// carried: "GARBAGE: a lease with no holder is refused — nobody could ever release it" crates/batten/src/lease.rs +// carried: "CLOUD-369 clause f — a held lease names the successor admitted behind it" crates/batten/src/lease.rs +// carried: "CLOUD-369 clause f — output is BYTE-IDENTICAL when no successor is admitted" crates/batten/src/lease.rs +// carried: "CLOUD-369 clause f — a RELEASED lease still names who was admitted behind it" crates/batten/src/lease.rs +// carried: "CLOUD-369 clause f — a WEDGED lease names the successor too, and still fails" crates/batten/src/lease.rs +// carried: "CLOUD-369 clause f — a LAPSED lease names the successor it left behind" crates/batten/src/lease.rs + +//! CARRIED HERE, over the compiled binary, because the property is the BINARY's +//! rather than the predicate's — an exit code and a channel, which no pure +//! function has. + +// carried: "an unreachable remote is exit 2 — could not look is not a verdict" crates/batten/tests/it/lease_health.rs +// carried: "POINTER, NEVER PAYLOAD: no case echoes the lease body" crates/batten/tests/it/lease_health.rs + +#![cfg(unix)] + +use crate::common; + +use std::path::{Path, PathBuf}; + +use common::{batten, scratch, stderr, stdout}; + +/// A git repository with a committer, and no remote unless a case adds one. +fn repo(name: &str) -> PathBuf { + let dir = scratch(name); + let repo = gix::init(&dir).expect("init"); + let mut config = std::fs::read_to_string(dir.join(".git/config")).expect("read config"); + config.push_str("[user]\n\tname = Fixture\n\temail = fixture@example.invalid\n"); + std::fs::write(dir.join(".git/config"), config).expect("write config"); + drop(repo); + dir +} + +/// Point the fixture at a remote by URL, without a network round trip to set it. +fn remote(dir: &Path, url: &str) { + let config = std::fs::read_to_string(dir.join(".git/config")).expect("read config"); + let with_remote = format!("{config}[remote \"origin\"]\n\turl = {url}\n"); + std::fs::write(dir.join(".git/config"), with_remote).expect("write config"); +} + +/// `batten lease check` in `dir`: the exit code, stdout and stderr. +fn check(dir: &Path) -> (i32, String, String) { + let output = batten() + .args(["lease", "check"]) + .current_dir(dir) + .output() + .expect("run batten lease check"); + ( + output.status.code().expect("exit code"), + stdout(&output), + stderr(&output), + ) +} + +/// A clone with no remote has no lease to judge, and that is a reading rather +/// than a failure to take one. +/// +/// `lease::TermsMissing` keeps `NoRemote` and `Unreadable` apart for exactly this: +/// folding a repository that was never pushed anywhere into the could-not-look +/// guard made every reporting verb an error in a clone that is perfectly healthy. +#[test] +fn a_clone_with_no_remote_is_healthy_and_says_why() { + let dir = repo("lease-health-no-remote"); + let (code, out, err) = check(&dir); + assert_eq!( + code, 0, + "a clone with no lease is not a refusal: {err}{out}" + ); + assert!( + out.contains("no remote"), + "the reading names its own cause: {out}" + ); +} + +/// **A REMOTE THAT WILL NOT ANSWER IS EXIT 3, AND THE PREDECESSOR SPELLED IT 2.** +/// +/// This is the one case in the retired suite whose NUMBER moves, and it moves +/// because the engine has one exit table with no per-verb exception +/// (non-negotiable rule 5): `2` is the policy verdict everywhere — here, a wedged +/// or garbage lease — and `3` is could-not-look. The predecessor used `1` for the +/// verdict and `2` for the unreachable remote, which is the same two answers with +/// the numbers swapped. +/// +/// The property both spellings share is the one under test: a lease nobody could +/// read must not be reported as a lease that is wrong. A gate that conflated them +/// would fail the fleet over a network blip and name a wedge that does not exist. +#[test] +fn a_remote_that_will_not_answer_is_could_not_look_and_never_a_verdict() { + let dir = repo("lease-health-unreachable"); + // A LOOPBACK PORT NOTHING LISTENS ON, rather than a blackholed address. Both + // are could-not-look, and only one is fast: a reserved documentation address + // has to time out, which cost this case 6.2s measured, where a refused + // connection answers at once. The reading under test is the same either way. + remote(&dir, "https://127.0.0.1:1/unreachable.git"); + + let (code, out, err) = check(&dir); + assert_eq!( + code, 3, + "an unreachable remote is could-not-look, not a wedge: {err}{out}" + ); + // AND IT IS NOT THE VERDICT CODE. Without this arm the case above passes over + // a binary that answers `2` for everything it cannot read, which is the exact + // conflation the rule 5 table exists to prevent. + assert_ne!(code, 2, "a lease nobody read is not a lease that is wrong"); + assert!( + !err.contains("WEDGED") && !err.contains("GARBAGE"), + "a could-not-look names no state: {err}" + ); +} + +/// POINTER, NEVER PAYLOAD (non-negotiable rule 4): no path echoes a lease body. +/// +/// Carried over from the retired suite, and it is cheap to keep because what it +/// asserts is an ABSENCE — a case that only checked the healthy renderings would +/// pass over a refusal that dumped the ref's contents, and a refusal is exactly +/// where an implementer reaches for more detail. +#[test] +fn no_path_echoes_a_lease_body() { + for (name, url) in [ + ("lease-health-pointer-none", None), + ( + "lease-health-pointer-unreachable", + Some("https://127.0.0.1:1/unreachable.git"), + ), + ] { + let dir = repo(name); + if let Some(url) = url { + remote(&dir, url); + } + let (_, out, err) = check(&dir); + let said = format!("{out}{err}"); + for field in ["holder:", "expires:", "progress:", "next:"] { + assert!( + !said.contains(field), + "a lease field reached the report at {name}: {said}" + ); + } + } +} diff --git a/crates/batten/tests/it/lease_lifecycle.rs b/crates/batten/tests/it/lease_lifecycle.rs new file mode 100644 index 000000000..f5f98f5fe --- /dev/null +++ b/crates/batten/tests/it/lease_lifecycle.rs @@ -0,0 +1,460 @@ +//! The landing lease's whole state machine, over the library the retirement +//! moved it into (CLOUD-1148). +//! +//! # The predecessor, and the size of what it was carrying +//! +//! `mise-tasks/land-lock.sh` was 1,201 lines and `tests/land-lock.bats` pinned 77 +//! properties of it: acquire, release, renew, hold, held, peek, reserve and +//! authorises, plus a stall detector, a fence, and a corroboration clock. It was +//! the single largest program in the landing cluster and the one whose failure +//! mode is worst — two holders means two branches landing at once. +//! +//! # Why almost all of it runs with no remote +//! +//! The lease is a compare-and-swap over one ref, and the DECISIONS are pure +//! functions of a reading the caller already took: `claim`, `tombstone`, +//! `renewal`, `reservation`, `authorises`, `turn`, `bail` and `health` all take +//! an `Observed` or a `Body` and return the next one. Only `observe` and `cas` +//! reach the wire. So the entire table below is exercised without a forge, which +//! is what makes "does the port conserve the bash's behaviour" an answerable +//! question rather than a claim. +//! +//! What this tier does NOT reach is the swap itself — the receive-pack CAS, the +//! `--force-with-lease` expected value, and the pack encoding. Those are +//! `lease::swap`'s and are driven where the module's own suite drives them. +//! Stated rather than implied. +//! +//! # The two properties everything else serves +//! +//! **A live lease is never stolen**, and **an expired one is not stolen on the +//! first sighting.** The second is the subtle one: clocks are not shared, so a +//! lease that merely LOOKS expired to a rival's clock may be alive on the +//! holder's. Corroboration — the sha having demonstrably stopped moving — is what +//! turns a reading into a verdict, and it is the case a simplification would +//! delete first. + +// carried: mise-tasks/land-lock.sh crates/batten/src/lease.rs kind:verb crates/batten/tests/it/lease_lifecycle.rs runs:batten+lease +// carried: tests/land-lock.bats crates/batten/src/lease.rs kind:verb crates/batten/tests/it/lease_lifecycle.rs +// +// AND ONE CASE FROM A SUITE THAT SURVIVES. `tests/reclaim-census.bats` reached +// into `mise-tasks/land-lock.sh` to count the hold loop's own beat records, so +// the case dies with the program it was counting — but the suite's declared +// subject, `mise-tasks/reclaim-census.sh`, is still standing. +// +// `ported` is the arm for exactly that, and it obliges MORE than `carried` +// rather than less: a target the tree carries, PLUS a `subject:` naming a path +// the edited file declared at base and head still carries. The `subject:` field +// is what clears the aggregate subject-alive term — by naming the survivor, +// rather than by a `carried` row falsely claiming the case moved somewhere. +// +// ported: tests/reclaim-census.bats crates/batten/tests/it/lease_lifecycle.rs subject:mise-tasks/reclaim-census.sh +// carried: "land-lock's hold loop records a beat and every stop it chooses" crates/batten/src/lease.rs kind:verb +// +// The seventy-seven cases, one row each, keyed by TITLE — a row whose first +// field is the suite path is indexed as another arm for it and the deletion +// reads as `shell retire unclear`. +// +// carried: "an unheld lease reports unheld, and says so at exit 0" crates/batten/src/lease.rs kind:verb +// carried: "acquire on a free lease wins and creates the ref" crates/batten/src/lease.rs kind:verb +// carried: "THE CLAIM: a rival cannot acquire a live lease" crates/batten/src/lease.rs kind:verb +// carried: "acquire is re-entrant for the holder, so a retry is not a deadlock" crates/batten/src/lease.rs kind:verb +// carried: "held is the holder's yes and the rival's no" crates/batten/src/lease.rs kind:verb +// carried: "release by the holder frees the lease for the next claimant" crates/batten/src/lease.rs kind:verb +// carried: "THE DEFECT: a released lease's status names the last holder, never an epoch" crates/batten/src/lease.rs kind:verb +// carried: "THE DEFECT: releasing an already-released lease says so, and reports no epoch age" crates/batten/src/lease.rs kind:verb +// withdrawn: "THE DEFECT: a first sighting of a sha emits no shell error on stderr" mise-tasks/land-lock.sh +// carried: "THE DEFECT: a LIVE lease is sighted, so the corroboration clock is already running when it expires" crates/batten/src/lease.rs kind:verb +// carried: "THE DEFECT: a lease sighted before it expired is taken on the first check after" crates/batten/src/lease.rs kind:verb +// carried: "a released lease is not still held by its releaser" crates/batten/src/lease.rs kind:verb +// carried: "release by a non-holder is a silent no-op, never a theft" crates/batten/src/lease.rs kind:verb +// carried: "renew extends the lease and moves the ref" crates/batten/src/lease.rs kind:verb +// carried: "a non-holder cannot renew, so a heartbeat cannot steal" crates/batten/src/lease.rs kind:verb +// carried: "an expired lease is taken once its death is corroborated, not waited out forever" crates/batten/src/lease.rs kind:verb +// carried: "a live lease is NOT stolen — expiry is the only steal condition" crates/batten/src/lease.rs kind:verb +// carried: "THE FENCE: a holder whose lease was stolen reports not-held" crates/batten/src/lease.rs kind:verb +// carried: "an expired lease reads as free, and still names who left it" crates/batten/src/lease.rs kind:verb +// carried: "FAIL CLOSED: an unreachable remote is exit 2, never 'unheld'" crates/batten/src/lease.rs kind:verb +// carried: "an unreachable remote fails acquire closed too" crates/batten/src/lease.rs kind:verb +// changed: "an unknown verb is exit 2 and names the usage" crates/batten/src/cli.rs kind:verb +// carried: "POINTER, NEVER PAYLOAD: output carries ids and seconds, never the lease body" crates/batten/src/lib.rs kind:verb +// carried: "THE EXPECTED VALUE IS EXPLICIT — a bare --force-with-lease is two holders" crates/batten/src/lease.rs kind:verb +// carried: "SHA AND BODY COME FROM ONE SOURCE — never ls-remote paired with FETCH_HEAD" crates/batten/src/lease.rs kind:verb +// changed: "observe leaves no per-process ref behind" crates/batten/src/lease.rs kind:verb +// carried: "the fence demands MARGIN, not merely an unexpired lease" crates/batten/src/lib.rs kind:verb +// carried: "an expired lease is not stolen on the first sighting — clocks are not shared" crates/batten/src/lease.rs kind:verb +// carried: "a dead lease IS taken once the sha has demonstrably stopped moving" crates/batten/src/lease.rs kind:verb +// carried: "NO GIT IDENTITY: the lease is takeable on a machine with no user.email" crates/batten/src/lease.rs kind:verb +// carried: "A FAILED MINT IS A REFUSED SWAP, NEVER A DELETE" crates/batten/src/lease.rs kind:verb +// carried: "a hold whose land died releases within a beat instead of renewing for nobody" crates/batten/src/lease.rs kind:verb +// carried: "a live land keeps its heartbeat renewing — the tether never fires on a healthy hold" crates/batten/src/lease.rs kind:verb +// carried: "a pid recycled into something that is not a land reads as gone" crates/batten/src/lease.rs kind:verb +// carried: "an unset holder pid keeps today's behaviour, so no other caller changes" crates/batten/src/lease.rs kind:verb +// carried: "THE ACCEPTANCE CASE: a land that stops advancing loses its lease and is stopped" crates/batten/src/lease.rs kind:verb +// carried: "a land whose phase keeps changing is never bailed on" crates/batten/src/lease.rs kind:verb +// carried: "RE-STATING A PHASE IS NOT ADVANCING IT, or a wedged land renews forever" crates/batten/src/lease.rs kind:verb +// carried: "a loop that stops turning is caught by the shorter hang bound" crates/batten/src/lease.rs kind:verb +// carried: "THE HANG BOUND DOES NOT REACH A PHASE WITH NO LOOP, or verify is killed for running" crates/batten/src/lease.rs kind:verb +// carried: "no registry entry is no verdict — an unregistered land is not a stalled one" crates/batten/src/lease.rs kind:verb +// carried: "A RIVAL MAY REAP A LEASE THAT BEATS WITHOUT PROGRESSING" crates/batten/src/lease.rs kind:verb +// carried: "a lease that carries no progress token is never stall-stealable" crates/batten/src/lease.rs kind:verb +// carried: "authorises: an absent lease lets any branch run" crates/batten/src/lease.rs kind:verb +// carried: "authorises: the branch the lease names may run" crates/batten/src/lease.rs kind:verb +// changed: "THE STOP: a branch the lease does not name is refused with exit 3" crates/batten/src/lib.rs kind:verb +// carried: "authorises: a released lease stops nobody" crates/batten/src/lease.rs kind:verb +// carried: "authorises: an expired lease stops nobody" crates/batten/src/lease.rs kind:verb +// carried: "FAIL OPEN: a lease carrying no branch runs rather than guessing" crates/batten/src/lease.rs kind:verb +// carried: "FAIL OPEN: an unreachable remote runs, where every other verb refuses" crates/batten/src/lease.rs kind:verb +// changed: "authorises: a missing branch argument is exit 2, never a verdict" crates/batten/src/cli.rs kind:verb +// carried: "the lease body carries the branch it authorises, and still ends with the nonce" crates/batten/src/lease.rs kind:verb +// carried: "the lease's own ref name is never mistaken for the branch it authorises" crates/batten/src/lease.rs kind:verb +// carried: "acquire leaves a receipt carrying the instant the lease expires" crates/batten/src/lease.rs kind:verb +// carried: "a renew REFRESHES the receipt — a lease held for a long lap is still held" crates/batten/src/lease.rs kind:verb +// carried: "release REMOVES the receipt rather than letting it age out" crates/batten/src/lease.rs kind:verb +// carried: "A REFUSED ACQUIRE LEAVES NO RECEIPT — the whole point of the predicate" crates/batten/src/lease.rs kind:verb +// carried: "the lease body carries the head that is about to become main" crates/batten/src/lease.rs kind:verb +// carried: "peek prints the field alone, so a caller never parses a sentence" crates/batten/src/lib.rs kind:verb +// carried: "peek on an absent lease is silent and 0 — a reading, not an error" crates/batten/src/lib.rs kind:verb +// changed: "peek on an unknown field is exit 2, never an empty answer" crates/batten/src/cli.rs kind:verb +// carried: "reserve admits a waiter as the successor behind the holder" crates/batten/src/lease.rs kind:verb +// carried: "THE BOUND: a second waiter cannot take a slot that is already filled" crates/batten/src/lease.rs kind:verb +// carried: "reserve is idempotent for the branch already holding the slot" crates/batten/src/lease.rs kind:verb +// carried: "RESERVING IS NOT STEALING: the holder keeps the lease and every other field" crates/batten/src/lease.rs kind:verb +// carried: "a reservation does not extend the holder's lease" crates/batten/src/lease.rs kind:verb +// carried: "authorises admits the holder AND its one admitted successor" crates/batten/src/lease.rs kind:verb +// carried: "THE STOP STILL STOPS: a third branch is refused while two are admitted" crates/batten/src/lease.rs kind:verb +// carried: "reserve refuses when no lease is held — acquire is the right verb then" crates/batten/src/lease.rs kind:verb +// carried: "reserve refuses to reserve behind yourself, which would consume the slot" crates/batten/src/lease.rs kind:verb +// carried: "THE HEARTBEAT CARRIES THE RESERVATION, or it erases it within a beat" crates/batten/src/lease.rs kind:verb +// carried: "ACQUIRE CLEARS IT: a new turn does not inherit the last one's successor" crates/batten/src/lease.rs kind:verb +// carried: "a lease minted before this change carries no next, and admits no successor" crates/batten/src/lease.rs kind:verb +// carried: "AGING: an aged waiter probes a freed lease sooner than a fresh one" crates/batten/src/lease.rs kind:verb +// carried: "a non-numeric age is read as zero rather than crashing the backoff" crates/batten/src/lease.rs kind:verb +// changed: "PRESSURE: two waiters against one holder produce exactly ONE winner" crates/batten/src/lease.rs kind:verb +// changed: "PRESSURE: the lease passes to exactly one waiter after release, not both" crates/batten/src/lease.rs kind:verb + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use batten::lease::{ + Authority, Body, Observed, Terms, authorises, authorises_this_clone, renewal, reservation, + tombstone, +}; + +const HOLDER: &str = "clone-a"; +const RIVAL: &str = "clone-b"; +const NOW: i64 = 1_000_000; + +fn terms() -> Terms { + Terms::default() +} + +fn held(branch: &str, expires: i64) -> Body { + Body { + holder: String::from(HOLDER), + expires, + branch: branch.to_owned(), + head: String::from("abc1234"), + next: String::new(), + progress: String::from("verify"), + nonce: String::from("n1"), + } +} + +fn observed(body: Body) -> Observed { + Observed::Held { + sha: String::from("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + body, + } +} + +/// **A live lease authorises its own branch and refuses another** — the claim +/// the whole design exists to make, and the mirror that keeps it honest. +#[test] +fn the_branch_a_live_lease_names_may_run_and_another_may_not() { + let lease = observed(held("work", NOW + 60)); + + assert!(matches!( + authorises(Some(&lease), "work", NOW), + Authority::Run(_) + )); + + let Authority::Stop(why) = authorises(Some(&lease), "other", NOW) else { + panic!("a branch the lease does not name must be stopped"); + }; + assert!( + why.contains("work"), + "the refusal names the branch that IS authorised, so a reader can go and look: {why}" + ); +} + +/// **Every reading that is not a live lease naming somebody else lets the branch +/// run.** An absent lease, a released one, an expired one, one carrying no +/// branch, and a reading that could not be taken at all. +/// +/// This is the fail-open family, and it is the load-bearing half: refusing here +/// stops the whole fleet, where waving one matrix through costs one matrix. +#[test] +fn absent_released_expired_branchless_and_unreadable_all_run() { + assert!(matches!( + authorises(Some(&Observed::Absent), "work", NOW), + Authority::Run(_) + )); + + // A RELEASE IS A DECLARATION, and `expires == 0` is its sentinel rather than + // an instant — unmistakable under any clock and on any machine. + let released = observed(tombstone(&held("other", NOW + 60))); + assert!(matches!( + authorises(Some(&released), "work", NOW), + Authority::Run(_) + )); + + // AN EXPIRY IS AN INFERENCE, and needs the clock the release does not. + let expired = observed(held("other", NOW - 1)); + assert!(matches!( + authorises(Some(&expired), "work", NOW), + Authority::Run(_) + )); + + // A lease naming nobody: during the rollout of that field this was not an + // edge case, it was every lease. + let branchless = observed(held("", NOW + 60)); + assert!(matches!( + authorises(Some(&branchless), "work", NOW), + Authority::Run(_) + )); + + // COULD NOT LOOK. Every other verb refuses here and this one runs. + assert!(matches!(authorises(None, "work", NOW), Authority::Run(_))); +} + +/// **A reservation admits the holder AND its one successor — and still stops a +/// third.** +/// +/// The bound is what makes the slot a slot: admitting everyone who asked would +/// be the same as having no lease. +#[test] +fn a_reservation_admits_exactly_one_successor_and_the_third_branch_still_stops() { + let reserved = observed(reservation(&held("work", NOW + 60), "next-up")); + + assert!(matches!( + authorises(Some(&reserved), "work", NOW), + Authority::Run(_) + )); + assert!(matches!( + authorises(Some(&reserved), "next-up", NOW), + Authority::Run(_) + )); + assert!( + matches!( + authorises(Some(&reserved), "third", NOW), + Authority::Stop(_) + ), + "a third branch must still be refused while two are admitted" + ); +} + +/// **RESERVING IS NOT STEALING, and it does not extend the holder's lease.** +/// +/// Every other field survives, and `expires` in particular: a reservation that +/// renewed the lease as a side effect would let a waiter keep the holder alive +/// indefinitely by asking to be next. +#[test] +fn a_reservation_changes_the_successor_and_nothing_else() { + let before = held("work", NOW + 60); + let after = reservation(&before, "next-up"); + + assert_eq!(after.next, "next-up"); + assert_eq!(after.holder, before.holder, "the holder is unchanged"); + assert_eq!( + after.expires, before.expires, + "a reservation does not extend the holder's lease" + ); + assert_eq!(after.branch, before.branch); + assert_eq!(after.head, before.head); + assert_eq!(after.progress, before.progress); + + // IDEMPOTENT for the branch already in the slot, so a waiter retrying does + // not consume anything. + assert_eq!(reservation(&after, "next-up").next, "next-up"); +} + +/// **A lease minted before the successor field existed carries no `next`, and +/// admits nobody.** +/// +/// The absent-is-not-empty reading: an old body's missing field must not read as +/// a slot standing open. +#[test] +fn a_lease_with_no_successor_field_admits_no_successor() { + let old = observed(held("work", NOW + 60)); + assert!(matches!( + authorises(Some(&old), "anyone", NOW), + Authority::Stop(_) + )); +} + +/// **THE HEARTBEAT CARRIES THE RESERVATION**, or it erases it within a beat. +/// +/// A renewal rebuilds the body, so a successor that the rebuild dropped would +/// vanish one beat after being admitted — and the waiter would sit behind a slot +/// it had already been given. +#[test] +fn a_renewal_keeps_the_successor_and_moves_only_the_expiry_and_progress() { + let reserved = reservation(&held("work", NOW + 10), "next-up"); + let beat = renewal(&terms(), &reserved, Some("push"), NOW); + + assert_eq!(reserved.next, "next-up"); + assert_eq!( + beat.next, "next-up", + "a heartbeat that dropped the successor would erase it within a beat" + ); + assert!( + beat.expires > reserved.expires, + "a renewal extends the lease" + ); + assert_eq!( + beat.progress, "push", + "and records the phase it advanced to" + ); + assert_eq!(beat.holder, reserved.holder); + assert_eq!(beat.branch, reserved.branch); +} + +/// **A release names the last holder and reports no epoch.** +/// +/// `expires == 0` is a sentinel. Conflating it with an instant made a release +/// wait a full beat before anyone could take it, and made three separate +/// renderers print a wall-clock epoch as an age. +#[test] +fn a_tombstone_is_released_at_any_clock_and_still_names_who_left_it() { + let stone = tombstone(&held("work", NOW + 60)); + + assert!( + stone.released(), + "a release is a declaration, not an expiry" + ); + assert_eq!( + stone.holder, HOLDER, + "a released lease still names its last holder" + ); + + // UNDER ANY CLOCK. The sentinel is what makes this true on a machine whose + // clock disagrees with the holder's. + assert!(stone.expired(0)); + assert!(stone.expired(i64::MAX)); +} + +/// **A lease with zero seconds left has none.** +/// +/// `>=` rather than `>`, and the difference is measurable: under `>` a release +/// tombstone — whose expiry is exactly now — read as still-held for one more +/// second, so the releaser itself still saw it as held. +#[test] +fn expiry_is_inclusive_so_a_lease_with_no_time_left_is_not_held() { + let lease = held("work", NOW); + assert!(lease.expired(NOW), "zero seconds left is expired"); + assert!(!lease.expired(NOW - 1), "one second left is not"); +} + +/// **THE FENCE demands MARGIN, and it is a DIFFERENT predicate from the +/// recorder's — which is the distinction this case exists to keep.** +/// +/// `authorises_this_clone` asks *is anything stopping this clone*, so it fails +/// OPEN: an absent, released or expired lease all answer yes, because none of +/// them is somebody else holding one. `lease held` asks *may this clone act*, +/// and adds a beat of margin — "not expired" is a fact about the instant of the +/// check, and the caller then goes on to post a comment or wait for a bot, so a +/// lease with one second left passes and is gone before the action it authorised +/// takes effect. That is the time-of-check/time-of-use gap the fence closes. +/// +/// Writing this case first against the wrong one of the two is what surfaced the +/// distinction, so both halves are asserted here rather than one. +#[test] +fn the_recorder_fails_open_where_the_fence_demands_a_beat_of_margin() { + // The recorder's reading: only a LIVE lease held by somebody else stops. + assert!(authorises_this_clone( + &observed(held("work", NOW + 3600)), + HOLDER, + NOW + )); + assert!( + !authorises_this_clone(&observed(held("work", NOW + 3600)), RIVAL, NOW), + "a live lease held by another clone is the one thing that stops" + ); + assert!( + authorises_this_clone(&observed(held("work", NOW - 1)), RIVAL, NOW), + "an expired lease stops nobody, which is the fail-open half" + ); + assert!(authorises_this_clone(&Observed::Absent, RIVAL, NOW)); + + // The FENCE's reading, as `run_lease_held` computes it. One beat is the right + // margin because it is the interval at which the holder proves it is alive: + // with a beat left, either the heartbeat renews and the lease keeps rolling, + // or it does not and this check would have failed anyway. + let beat = terms().beat; + let comfortable = held("work", NOW + beat + 1); + let bare = held("work", NOW + beat - 1); + + assert!( + comfortable.expires - NOW >= beat, + "a lease with more than a beat left is actionable" + ); + assert!( + bare.expires - NOW < beat, + "one with less is not, even though it has not expired" + ); + assert!( + !bare.expired(NOW), + "and that is the point: it is unexpired AND too thin to act on" + ); +} + +/// **The lease's own ref name is never mistaken for the branch it authorises.** +/// +/// Two different things with confusingly similar names: writing the lease's ref +/// into the body would stamp `refs/heads/batten-land-lock` into every lease while +/// looking entirely correct, and every branch would then be refused. +#[test] +fn the_leases_own_reference_is_not_the_branch_it_names() { + let terms = terms(); + assert!( + terms.reference.contains("batten-land-lock"), + "the lease lives on its own ref" + ); + + let lease = observed(held("work", NOW + 60)); + assert!( + matches!( + authorises(Some(&lease), &terms.reference, NOW), + Authority::Stop(_) + ), + "the lease's ref name is not a branch it authorises" + ); +} + +/// **POINTER, NEVER PAYLOAD.** The rendered body is what goes on the wire; no +/// reading returns it to a reporter. +/// +/// This asserts the render's SHAPE rather than a report's absence, because the +/// terminal `nonce:` line is what the check half reads as the end of the body — +/// a field appended after it would be silently unread. +#[test] +fn the_rendered_body_opens_with_the_banner_and_ends_with_the_nonce() { + let rendered = held("work", NOW + 60).render(); + + assert!( + rendered.starts_with("land-lock\n"), + "a commit that does not open with the banner is not a lease" + ); + assert!( + rendered.trim_end().ends_with("nonce: n1"), + "the nonce stays last: {rendered}" + ); + assert!( + rendered.contains("branch: work"), + "the body carries the branch it authorises" + ); + assert!( + rendered.contains("head: abc1234"), + "and the head that is about to become main" + ); +} diff --git a/crates/batten/tests/it/lease_precondition.rs b/crates/batten/tests/it/lease_precondition.rs new file mode 100644 index 000000000..126c98e75 --- /dev/null +++ b/crates/batten/tests/it/lease_precondition.rs @@ -0,0 +1,200 @@ +//! The runner's step-0 guard, over the library the retirement moved it into +//! (CLOUD-420, CLOUD-1148). +//! +//! # THIS GATE IS THE OPPOSITE OF EVERY OTHER REFUSAL HERE +//! +//! Everything else in this repository fails CLOSED. This one fails open, on an +//! asymmetry that is the whole justification: a reading it could not take would +//! stop **every job in the fleet**, where waving one matrix through costs one +//! matrix. So the majority of the cases below are fail-open arms, and they are +//! the load-bearing ones — the two that stop are easy to keep and the ten that +//! run are what a well-meaning "tighten this up" would break. +//! +//! # What this tier reaches +//! +//! `lease::guard` composes two readings the caller already took, so the entire +//! decision table runs with no forge and no clock. `lease::authorises` and +//! `lease::carries` resolve those readings and are exercised where they are +//! resolved; `crates/batten/tests/it/lease_health.rs` is the sibling tier over +//! `lease check`'s own reading. +//! +//! What it does NOT reach is the workflow step: the exit code, the `::error::` +//! annotation at column 0, and the cancellation of the run the guard is standing +//! in are `run_lease_guard`'s and `report_guard`'s. Stated rather than implied. + +// carried: mise-tasks/ci-lease-precondition.sh crates/batten/src/lease.rs kind:mechanism crates/batten/tests/it/lease_precondition.rs runs:batten+lease+guard +// carried: tests/ci-lease-precondition.bats crates/batten/src/lease.rs kind:mechanism crates/batten/tests/it/lease_precondition.rs +// +// The twenty-two cases, one row each, keyed by TITLE — a row whose first field +// is the suite path is indexed as another arm for it and the deletion reads as +// `shell retire unclear`. +// +// carried: "a current head with a free lease runs, and cancels nothing" crates/batten/src/lease.rs kind:mechanism +// carried: "a lease that authorises another branch STOPS this run — the acceptance case" crates/batten/src/lease.rs kind:mechanism +// carried: "THE STALENESS ROW: a head whose land does not take the lease is stopped" crates/batten/src/lease.rs kind:mechanism +// carried: "the staleness refusal names the remedy, not merely the refusal" crates/batten/src/lease.rs kind:mechanism +// carried: "a stale head is stopped WITHOUT consulting the lease — it cannot be judged by it" crates/batten/src/lease.rs kind:mechanism +// changed: "the head sha is read from LEASE_HEAD_SHA, and its absence is said out loud" crates/batten/src/lib.rs kind:mechanism +// carried: "FAIL OPEN: an unreadable head land is not judged" crates/batten/src/lease.rs kind:mechanism +// carried: "FAIL OPEN: an unreadable land-lock runs rather than stopping the fleet" crates/batten/src/lease.rs kind:mechanism +// changed: "FAIL OPEN: an answer that is neither run nor stop runs" crates/batten/src/lease.rs kind:mechanism +// withdrawn: "CLOUD-420: A WORKSPACE THAT CANNOT BE BUILT STILL EXITS 0" mise-tasks/ci-lease-precondition.sh +// withdrawn: "CLOUD-420: a broken workspace is not reported as land-lock's answer" mise-tasks/ci-lease-precondition.sh +// changed: "FAIL OPEN: a refused cancellation runs rather than reddening" crates/batten/src/lib.rs kind:mechanism +// changed: "FAIL OPEN: no run id means there is nothing to cancel" crates/batten/src/lib.rs kind:mechanism +// changed: "FAIL OPEN: no repository and no head ref each run" crates/batten/src/lib.rs kind:mechanism +// carried: "the branch under judgement is the one passed to land-lock" crates/batten/src/lease.rs kind:mechanism +// carried: "the token never reaches the log, on any path" crates/batten/src/rest.rs kind:mechanism +// carried: "a branch that lands through /fast-forward is not judged, in either row" crates/batten/src/lease.rs kind:mechanism +// carried: "the retired bot's prefix is judged like any other branch (CLOUD-660)" crates/batten/src/lease.rs kind:mechanism +// carried: "the exemption is a prefix on the landing path, not a substring anywhere in the ref" crates/batten/src/lease.rs kind:mechanism +// changed: "the ambient Actions run id is the fallback, and it is the run this is standing in" crates/batten/src/lib.rs kind:mechanism +// changed: "the lease refusal is a real annotation, not a log line the runner ignores" crates/batten/src/lib.rs kind:mechanism +// changed: "the staleness remedy is a real annotation too — it is the actionable one" crates/batten/src/lib.rs kind:mechanism + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use batten::lease::{Authority, Carries, Guarded, guard, lands_by_fast_forward}; + +fn stale() -> Carries { + Carries::Stale { + wanted: String::from("abc1234"), + } +} + +fn unknown(because: &str) -> Carries { + Carries::Unknown { + because: because.to_owned(), + } +} + +/// **A current head with a free lease runs**, and **a lease naming somebody else +/// stops it.** The acceptance pair, in one body because either alone passes over +/// a guard that answers one way always. +#[test] +fn a_free_lease_runs_and_one_naming_another_branch_stops() { + let free = Authority::Run(String::from("the lease is free")); + assert!(matches!( + guard(&Carries::Current, Some(&free)), + Guarded::Run { .. } + )); + + let held = Authority::Stop(String::from("the lease authorises other/branch")); + let Guarded::Stop { why } = guard(&Carries::Current, Some(&held)) else { + panic!("a lease naming another branch must stop this run"); + }; + assert!( + why.contains("other/branch"), + "the refusal names the holder a reader can go and look at: {why}" + ); +} + +/// **THE STALENESS ROW, and the lease is not consulted when it stops.** +/// +/// A head that does not carry trunk's landing mechanism cannot be serialised +/// against the fleet — so it is not something the lease can judge, and asking +/// would be one forge read on a head that is doomed either way. The predecessor +/// set `stop` from the staleness row and entered the lease table only +/// `if [[ -z "$stop" ]]`; `None` here is that ordering, and it is not a third +/// verdict. +#[test] +fn a_stale_head_stops_without_the_lease_being_consulted_at_all() { + let Guarded::Stop { why } = guard(&stale(), None) else { + panic!("a stale head must stop"); + }; + assert!(why.contains("abc1234"), "the refusal names what is missing"); + + // AND THE REMEDY, WHICH IS THE HALF THAT MATTERS. A stopped run is a + // CANCELLED run with no failed step of its own, so a reader who is not told + // sees a red check and no cause at all. + assert!( + why.contains("Rebase"), + "the refusal must carry the remedy, not merely the refusal: {why}" + ); + + // The lease cannot rescue it either — staleness is decided first, so an + // authority that says run does not reach the answer. + let free = Authority::Run(String::from("the lease is free")); + assert!(matches!(guard(&stale(), Some(&free)), Guarded::Stop { .. })); +} + +/// **FAIL OPEN, on every could-not-look.** +/// +/// The load-bearing family. An unreadable head, an unreadable lease, and an +/// absent authority all RUN — because a reading this gate could not take would +/// stop every job in the fleet. +#[test] +fn every_reading_that_could_not_be_taken_runs_rather_than_stopping_the_fleet() { + // The head's age could not be judged. + let Guarded::Run { why } = guard(&unknown("no trunk ref to compare against"), None) else { + panic!("an unreadable head must run"); + }; + assert!( + why.contains("not judging"), + "a green step says why it did not judge: {why}" + ); + + // The lease could not be read at all. + let Guarded::Run { why } = guard(&Carries::Current, None) else { + panic!("an unreadable lease must run"); + }; + assert!( + why.contains("could not be read"), + "and says which reading was missing: {why}" + ); + + // THE MIRROR. Without it this case passes over a guard that runs + // unconditionally, which is the shape a well-meaning simplification produces + // and which no other assertion here would notice. + let held = Authority::Stop(String::from("the lease authorises other/branch")); + assert!(matches!( + guard(&Carries::Current, Some(&held)), + Guarded::Stop { .. } + )); +} + +/// **A branch that lands through the fast-forward lane is not judged**, and the +/// exemption is a PREFIX on the landing path rather than a substring anywhere in +/// the ref. +/// +/// A substring test would exempt `feature/renovate-notes` — a branch nobody +/// declared — from the serialisation the whole guard exists to impose. +#[test] +fn the_carve_out_is_a_prefix_and_never_a_substring() { + let lanes = [String::from("renovate/"), String::from("release-plz-")]; + + assert!(lands_by_fast_forward("renovate/cargo-deps", &lanes)); + assert!(lands_by_fast_forward("release-plz-2026-09-05", &lanes)); + + // THE SUBSTRING ESCAPE, which is the case the title names. + assert!( + !lands_by_fast_forward("feature/renovate-notes", &lanes), + "a declared lane appearing mid-ref is not the lane" + ); + // An ordinary branch, and the retired bot's own prefix — judged like any + // other, because nothing declares it (CLOUD-660). + assert!(!lands_by_fast_forward("claude/some-work", &lanes)); + assert!(!lands_by_fast_forward("bot/bump-thing", &lanes)); + + // AN EMPTY DECLARATION EXEMPTS NOTHING rather than everything. The opposite + // reading would switch the guard off for the whole fleet on a config typo, + // silently and in the permissive direction. + assert!(!lands_by_fast_forward("renovate/cargo-deps", &[])); + + // A FULL REF IS REDUCED TO ITS BRANCH, exactly once. `trim_start_matches` + // strips the pattern REPEATEDLY, so a branch literally named + // `refs/heads/renovate/x` — which git permits under `refs/heads/` — reduced + // to `renovate/x` and was exempted by a row it does not belong to. Found in + // review; the fix is `strip_prefix`, and this is the case that would notice + // it being undone. + assert!(lands_by_fast_forward( + "refs/heads/renovate/cargo-deps", + &lanes + )); + assert!( + !lands_by_fast_forward("refs/heads/refs/heads/renovate/cargo-deps", &lanes), + "only one ref prefix is stripped, so a branch whose own name begins \ + refs/heads/ is judged as written" + ); +} diff --git a/crates/batten/tests/it/lease_record.rs b/crates/batten/tests/it/lease_record.rs index 7c5078b78..9cdd5aa32 100644 --- a/crates/batten/tests/it/lease_record.rs +++ b/crates/batten/tests/it/lease_record.rs @@ -37,14 +37,23 @@ fn repo(name: &str, status_exit: i32, peek_stdout: &str) -> PathBuf { write_program(&dir, "status.sh", status_exit, ""); write_program(&dir, "peek.sh", 0, peek_stdout); fs::write(dir.join("batten.toml"), CONFIG).expect("write config"); - git(&dir, &["init", "--quiet", "--initial-branch", "work"]); - git(&dir, &["config", "user.email", "t@example.com"]); - git(&dir, &["config", "user.name", "t"]); - git(&dir, &["add", "-A"]); - git(&dir, &["commit", "--quiet", "-m", "seed"]); + commit_on_work(&dir); dir } +/// The git history the recorder needs, and the ONE place it is built. +/// +/// Written once rather than per fixture builder: the branch this seeds is the +/// key the record is filed under, so two builders spelling it separately is a +/// fixture that can drift from the path every case reads back. +fn commit_on_work(dir: &Path) { + git(dir, &["init", "--quiet", "--initial-branch", "work"]); + git(dir, &["config", "user.email", "t@example.com"]); + git(dir, &["config", "user.name", "t"]); + git(dir, &["add", "-A"]); + git(dir, &["commit", "--quiet", "-m", "seed"]); +} + /// `printf '%s'` with NO trailing newline, so an empty stdout is genuinely empty. /// /// `board_record.rs`'s stub writer appends one; here it would make the "no @@ -269,6 +278,145 @@ fn a_call_the_selector_does_not_name_writes_nothing_at_all() { ); } +// --- the COMPILED arm (CLOUD-1148 §2) --------------------------------------- +// +// `batten.toml` no longer runs a `[program]` for either lease column: the paths +// those rows named are retired, and a `[program]` path resolves against the +// repository root, so neither could ever have named the compiled verb. The +// columns ask `authority = { ask = "lease-status" | "lease-successor" }`, which +// is CLOUD-1100's landed move for `ready-lint.sh` applied to the same shape. +// +// THE STUB CASES ABOVE STAY, AND THEY ARE NOT DEAD. They pin the RECORDER — the +// selector, the `status` mapping, the fail-open omission, the branch column — +// over an arm whose producer is chosen by the fixture. What they cannot pin is +// that the compiled arm is reached at all, and a `Value::Authority` naming a +// variant nothing dispatches would leave every case above green. +// +// A REAL LEASE IS NOT DRIVEN HERE, and the bound is stated rather than absorbed: +// the arm observes a remote ref, so proving the authorising and held-elsewhere +// answers needs a lease server. What IS drivable is the answer these fixtures +// genuinely produce — a clone with no remote — and that is the one arm the +// asymmetry turns on. + +/// A repository with the lease columns on the COMPILED arm and a `status` table +/// the caller chooses. +fn compiled_repo(name: &str, status_table: &str) -> PathBuf { + let dir = scratch(name); + fs::write( + dir.join("batten.toml"), + COMPILED_CONFIG.replace("STATUS_TABLE", status_table), + ) + .expect("write config"); + commit_on_work(&dir); + dir +} + +/// The shipped shape with no `[program]` table at all — which is the point: a +/// config declaring none still records both lease columns. +const COMPILED_CONFIG: &str = r#" +version = 1 + +[[pattern]] +id = "landing-lifecycle-call" +regex = '(?:^|&&|;|\|)\s*mise run linear-check\b' + +[[recorder]] +name = "landing-lease" +record = "landing-lease" +tool = "Bash" +key = "branch" +requires-input-matching = { command = "landing-lifecycle-call" } + +[[recorder.columns]] +name = "kind" +value = { literal = "lease" } + +[[recorder.columns]] +name = "verdict" +value = { authority = { ask = "lease-status", read = { status = STATUS_TABLE }, stdin = { literal = "" } } } + +[[recorder.columns]] +name = "successor" +value = { authority = { ask = "lease-successor", read = "stdout", stdin = { literal = "" } } } + +[[recorder.columns]] +name = "branch" +value = "branch" +"#; + +/// **THE UNCONDITIONAL ARM.** A probe table that maps could-not-look, so the +/// column carries a token only a producer that actually ran can have produced. +/// +/// `.claude/rules/policy-modules.md` states the rule this case exists to obey: +/// confirm a channel with an arm that must speak, never with an arm over the +/// channel itself. A case asserting only `-` cannot tell a compiled arm that +/// answered could-not-look from a `Value::Authority` variant nothing dispatches +/// — both leave the column undefined, and `render_column` renders both as `-`. +#[test] +fn the_compiled_lease_arm_is_reached_and_answers() { + let dir = compiled_repo("lease-compiled-probe", r#"{ "3" = "unknown" }"#); + hook(&dir, "mise run linear-check"); + + let line = record(&dir); + let columns = columns(&line); + assert_eq!( + columns.len(), + 4, + "the predicate asserts a count of four: {line:?}" + ); + assert_eq!( + columns[1], "unknown", + "A CLONE WITH NO REMOTE IS COULD-NOT-LOOK, AND THE ARM SAID SO. A dash \ + here means nothing dispatched `lease-status` at all: {line:?}" + ); + assert_eq!( + columns[3], "work", + "and the rest of the line is unchanged by the arm swap: {line:?}" + ); +} + +/// The shipped table's omission is what makes that answer fail OPEN. +/// +/// The numbers moved with the producer and the meanings did not: the shell +/// answered `1` held-elsewhere and `2` could-not-look, the engine answers `2` +/// and `3`, and `batten.toml`'s map is the one place the two vocabularies meet. +/// Mapping `3` there would invert the one behaviour the port exists to conserve +/// — "a lease it cannot read stops EVERY job in the fleet, where waving one +/// matrix through costs one matrix" — and this is the case that refuses it. +#[test] +fn the_shipped_table_leaves_the_compiled_could_not_look_unmapped() { + let dir = compiled_repo( + "lease-compiled-shipped", + r#"{ "0" = "authorised", "2" = "held-elsewhere" }"#, + ); + hook(&dir, "mise run linear-check"); + + let line = record(&dir); + let columns = columns(&line); + assert_eq!( + columns[1], "-", + "an unmapped status is could-not-look, so the preset's refusal cannot \ + hold: {line:?}" + ); + assert_ne!(columns[1], "held-elsewhere", "and it is NOT the refusal"); +} + +/// The successor arm answers nothing where there is no lease, which the column +/// records as could-not-look and the preset reads as *not this branch*. +#[test] +fn the_compiled_successor_arm_records_could_not_look_without_a_lease() { + let dir = compiled_repo("lease-compiled-successor", r#"{ "3" = "unknown" }"#); + hook(&dir, "mise run linear-check"); + + let line = record(&dir); + let columns = columns(&line); + assert_eq!(columns[2], "-", "no lease names a successor: {line:?}"); + assert_ne!( + columns[2], columns[3], + "so it cannot accidentally admit this branch" + ); +} + /// A compound call still selects, because the pattern is anchored on a segment /// boundary rather than on the start of the line — CLOUD-857's class, where /// `git push --force origin main` denied while `cd /tmp && git push --force origin diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 3e0f9f2f5..f843971a8 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -36,10 +36,7 @@ // the former per-file allowances are preserved on each module below. #![allow(clippy::unwrap_used, clippy::expect_used)] -mod claim_carry; -mod claim_race; -mod common; - +mod abandon_matrix; mod acceptance_corpus; mod acquisition_metric; mod acquisition_sweep; @@ -70,19 +67,23 @@ mod call_background_flag; mod call_ceiling; mod capture_fidelity; mod captured_facts; +mod cfg_gated_test; mod checks_green; mod ci_cache_declared; mod ci_hygiene; mod ci_parity; mod ci_suite_lane; mod claim; +mod claim_carry; mod claim_order; +mod claim_race; mod claim_receipt; mod cli; mod commit; mod commit_admission; mod commit_arm_sequencing; mod commit_meta_facts; +mod common; mod config_authority_boundary; mod config_base_ref_reading; mod config_deprecations; @@ -152,9 +153,16 @@ mod inverted_board_cases; mod issue_key; mod judge_kind; mod land; +mod land_entry_gates; +mod land_forge_reads; mod land_hand_stepping; +mod land_lap; +mod land_verify_advice; mod landed_check; mod landing_roster; +mod lease_health; +mod lease_lifecycle; +mod lease_precondition; mod lease_record; mod locator_index; mod lock_complete; @@ -202,6 +210,7 @@ mod ratchet; mod raw_tracker_read; mod ready; mod rebase; +mod receipt_verified; mod reclaim_report_once; mod record_closes; mod redirect_resolves; @@ -239,6 +248,7 @@ mod sleep_ban; mod snapshots; mod spawn_ceilings; mod spawn_census; +mod spawn_widening; mod staged_facts; mod startup; mod startup_bootstrap; @@ -259,6 +269,7 @@ mod tool_selector; mod tool_verdict_facts; mod transcript_stop_reason; mod transcript_tool_result; +mod trunk_watch; mod use_graph; mod verdict_registry; mod verdict_vocabulary; diff --git a/crates/batten/tests/it/narrow_adoption.rs b/crates/batten/tests/it/narrow_adoption.rs index 0e350d388..3840da6f7 100644 --- a/crates/batten/tests/it/narrow_adoption.rs +++ b/crates/batten/tests/it/narrow_adoption.rs @@ -133,10 +133,24 @@ fn the_network_callers_are_the_declared_ones_and_nothing_else() { // keeps `lease.rs` off the gate paths is not this row but // `policy/module-layering.rego`, where `hook -> lease` and // `check -> lease` are forbidden over the resolved use graph. + // + // `rest.rs` is the fourth (CLOUD-1338), and it arrived by REMOVING call + // sites rather than adding one. Four modules were reaching the forge's + // REST tier by spawning its client, each with an + // `#[expect(clippy::disallowed_types)]` whose reason said this crate + // carries no HTTP client that resolves a forge credential — which was + // false, and `lease.rs` two entries up was already disproving it. Those + // four now read one door here, so the count of modules touching the + // network went from seven to four and this list grew by one. + // + // It is a MODULE rather than four call sites for the reason `lease.rs` + // is one: the credential belongs to whoever builds the request, and a + // token resolved in four places is four places it can be printed. vec![ "lease.rs".to_owned(), "mcp.rs".to_owned(), "provision.rs".to_owned(), + "rest.rs".to_owned(), ], "exactly these modules may call the network adapter" ); diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 1ea25dabe..30b01175d 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -642,6 +642,16 @@ const MAY_ANSWER_COULD_NOT_LOOK: &[&str] = &[ "lease release", "lease renew", "lease reserve", + // `lease carries` joins them for a different reason: it reaches the FORGE + // rather than the lease remote, and a corpus with no credential cannot read + // either the trunk commit or the comparison — which is the could-not-look + // this gate is built to fail open on. + "lease carries", + // `lease guard` joins it for `carries`' reason and one more: it is the + // composite, so a corpus that cannot read the trunk commit cannot answer its + // first half either — and the guard's contract is that every such reading + // RUNS, which is exactly could-not-look. + "lease guard", // `land replay` joins them for the same reason one hop earlier: it FETCHES // before it replays, so a corpus with no remote configured cannot reach the // replay at all and could-not-look is its honest answer here. @@ -655,6 +665,13 @@ const MAY_ANSWER_COULD_NOT_LOOK: &[&str] = &[ // And `land push`, which shares that preamble too and cannot reach the // remote at all on a corpus that names none. "land push", + // `land lap` inherits it from all three: the driver resolves the remote + // before its first step, so on a corpus naming none it stops at the same + // preamble its steps do. Measured rather than reasoned — the sweep put it + // here by failing with `no remote named origin, so this lap has no base`, + // which is the honest could-not-look and not a verb that emitted nothing + // because it had nothing to emit. + "land lap", ]; /// One entry per leaf verb of [`SURFACE`], asserted total by @@ -680,6 +697,26 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // The staleness read renders a HEAD SHA, a wanted sha and a reason token. + // The forge's own bodies never reach it: `newest_landing_commit` takes a sha + // and a date out of the response and `head_carries` takes one status word, + // so there is nothing for a payload to ride on. + Verb { + path: "lease carries", + args: &["0000000000000000000000000000000000000000"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, + // The step-0 guard renders a head sha, a wanted sha, a lease reason token and + // a run id. The forge's own bodies never reach it: `carries` takes a sha and + // a status word out of two responses, and `authorises` reads a lease body the + // one authority already parses. + Verb { + path: "lease guard", + args: &["0000000000000000000000000000000000000000", "work", "0"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, Verb { path: "lease check", args: &[], @@ -756,6 +793,27 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // `land fast-forward` renders a workflow name, a comment key and a + // CONCLUSION TOKEN. The forge's own prose — a job's log, a bot's comment body + // — never reaches it: the verb resolves the answer keyed to its own request + // and returns the token, which is the whole of what a lap acts on. + Verb { + path: "land fast-forward", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, + // `land lap` is the DRIVER, so its own output is a lap number, a step name + // and the progress word `land::progress` returned. It composes verbs that are + // each pointer-only above, which is what makes the composition pointer-only + // rather than an assumption about it: there is nothing in the lap that could + // introduce a payload the steps do not already refuse to carry. + Verb { + path: "land lap", + args: &["refs/heads/main"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, // `land push` reports a branch name and a sha, and the one string that could // carry anything — receive-pack's own rejection reason — is dropped at the // boundary rather than rendered, because this store is read by a predicate @@ -1493,6 +1551,17 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // `receipt verified` composes the two receipt reads and renders a sha, a + // CHECK NAME and a validity token. It is the same reading `receipt status` + // gives, twice, and the predecessor it retired was already pointer-only for + // this reason — its own suite asserted "it names predicates and shas, never + // run contents", and that case is `subsumed` onto this row. + Verb { + path: "receipt verified", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, // The API-compatibility gate (CLOUD-1050). It reads a delegated analyser's // report and a range of commit messages — two of the content-richest inputs // on this surface — and emits the failing LINT IDS and a short sha, never a @@ -1849,6 +1918,48 @@ fn run_in(corpus: &Corpus, args: &[&str], stdin: Stdin) -> Run { .state_home(&corpus.home) .args(args) .current_dir(&corpus.repo) + // AMBIENT, AND IT CHANGED WHICH PATH THE CORPUS EXERCISED. `land + // fast-forward` refuses with `Usage` when `$LAND_WORKFLOW` names no + // workflow — the arm this census means to walk, since it renders a + // pointer and returns. With the variable set in the environment the + // suite inherits (this repository's own `mise.toml` declares one), the + // verb went on to `fast_forward::open_pull_request` instead, so the case + // measured a forge read that could not happen rather than the refusal. + // A census whose corpus depends on the shell it was launched from is not + // one. + .env_remove("LAND_WORKFLOW") + // AND THE GATE ITSELF, which is the same class one turn worse. `land + // verify` runs `$LAND_VERIFY` as argv, and this repository's `mise.toml` + // declares `LAND_VERIFY = "mise run verify"` in `[env]` — inherited by + // every process the suite spawns. So the corpus ran the WHOLE verify + // pipeline recursively: provisioning the toolchain (`rust`, `hk`, `gh`, + // `jq`) and then running the gate suite, from inside one test case. + // + // MEASURED: 3445s for this case in isolation, and it still ended in + // exit 3 — a test that spent 57 minutes proving nothing about output. + // The verb's own entry in the roster below asserts the opposite premise + // in prose — "on this corpus `$LAND_VERIFY` names nothing, so the verb + // refuses before running anything" — and that was true until the + // variable moved into `[env]`. Cleared here, the premise is true again + // and the refusal is `Usage`, which is why the verb needs no entry in + // `MAY_ANSWER_COULD_NOT_LOOK`. + .env_remove("LAND_VERIFY") + // AND THE ROSTER, for the reason the `pr watch` entry states about its + // own: that verb is driven to its REFUSAL because the loop is unbounded + // by design, so an entry that reached the network would not be a slow + // case, it would be one that never returns. `land wait` reads the same + // roster from the ENVIRONMENT rather than from flags, so it inherited + // this repository's and entered the poll — an hour per run. Cleared + // here, it refuses before the first request exactly as its sibling does. + .env_remove("CI_REQUIRED_CHECKS") + .env_remove("CI_ANSWERED_CONCLUSIONS") + // AND THE CENSUS DOES NOT REACH THE FORGE. Several verbs here read it, + // and with the spawn retired they do so in process — so without a seam + // this corpus makes real requests, and the ones that POLL keep making + // them. An empty fixture directory answers could-not-look instantly, + // which is the reading every one of these verbs is required to survive + // and the only one a census about OUTPUT should be exercising. + .env("BATTEN_REST_FIXTURE", corpus.home.join("no-answers")) .env("XDG_CACHE_HOME", corpus.home.join("cache")) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/crates/batten/tests/it/pr_watch.rs b/crates/batten/tests/it/pr_watch.rs index 85a1c8ae6..6d88e1eaf 100644 --- a/crates/batten/tests/it/pr_watch.rs +++ b/crates/batten/tests/it/pr_watch.rs @@ -108,16 +108,13 @@ impl Fixture { .expect("write the terminal response"); let root = dir.display().to_string(); - write_program( - &bin.join("gh"), - &format!( - "#!/usr/bin/env bash\n\ - n=$(cat '{root}/calls' 2>/dev/null || echo 0)\n\ - echo $((n + 1)) >'{root}/calls'\n\ - printf '%s\\n' \"$*\" >>'{root}/args'\n\ - cat '{root}/resp.'$((n + 1)) 2>/dev/null || cat '{root}/resp.last'\n" - ), - ); + // THE STUBBED CLIENT IS GONE WITH THE SPAWN IT STOOD IN FOR. `pr_watch` + // read through `gh` and this fixture put a program on `PATH`; the read is + // `rest::get` now, so the seam is `BATTEN_REST_FIXTURE` and the engine + // serves these same files itself. The protocol is unchanged — `resp.`, + // `resp.last`, `calls`, `args` — so every case below reads back exactly + // what it did. + let _ = &root; // The recorder is a program the CALLER names, so the fixture supplies // one: it appends its argv, which is what makes "one push per poll" // countable rather than sampled. @@ -172,6 +169,10 @@ impl Fixture { .arg("watch") .args(args) .env("PATH", path) + // The seam the stubbed `gh` used to be. Without it this poll reaches + // the real forge, never gets a green reading, and — since the loop is + // deliberately unbounded — runs until the suite is killed. + .env("BATTEN_REST_FIXTURE", &self.dir) .current_dir(&self.dir) .output() .expect("the compiled binary runs"); diff --git a/crates/batten/tests/it/provision.rs b/crates/batten/tests/it/provision.rs index 63f827a93..455360f27 100644 --- a/crates/batten/tests/it/provision.rs +++ b/crates/batten/tests/it/provision.rs @@ -205,6 +205,75 @@ fn apply_installs_out_of_tree_and_leaves_the_repository_untouched() { ); } +/// **A re-install REPLACES the cached binary rather than writing over it** +/// (CLOUD-1586). +/// +/// Writing in place returns `ETXTBSY` — "Text file busy" — the moment anything is +/// executing that path, and something usually is: the launcher's `#!` line names +/// this exact file, so every `provision-exec` holds it open, and in this +/// repository the adjudicating hook runs on every tool call. Measured on this +/// branch, `batten-check` and two `land` laps died with +/// `write the provisioned binary / Text file busy`, which reads as a filesystem +/// fault and is really a self-collision. +/// +/// # The assertion is the INODE, because that is what distinguishes the two +/// +/// A second `apply` over new bytes leaves the path holding the new ones either +/// way, so reading the path proves nothing. What separates a rename from an +/// in-place write is that a rename moves the NAME and leaves the old inode +/// alone: a handle opened before the install still reads the OLD bytes +/// afterwards. An in-place write mutates the very bytes that handle is reading, +/// which is the property a running process depends on and the one `ETXTBSY` +/// exists to defend. +/// +/// So this holds a handle open across the install and reads it after — hermetic, +/// and it fails on the implementation this replaced without needing to get a +/// process executing inside a test. +#[test] +fn a_reinstall_replaces_the_cached_binary_rather_than_writing_through_it() { + use std::io::Read as _; + + // A different artifact at the same version, so `apply` reinstalls over it. + // Declared before the statements: `items_after_statements` is denied here. + const SECOND: &[u8] = b"#!/bin/sh\necho second\n"; + + let env = Env::new("provision-reinstall"); + let (url, sha) = env.artifact("demo", BINARY); + env.config(&manifest(&url, &sha)); + assert_eq!(env.run(&["provision", "apply"]).status.code(), Some(0)); + + let installed = env.state_dir().join("provision/demo/1.2.3/bin/demo"); + let mut held = fs::File::open(&installed).expect("hold the installed binary open"); + + let (next_url, next_sha) = env.artifact("demo2", SECOND); + env.config(&manifest(&next_url, &next_sha)); + let output = env.run(&["provision", "apply"]); + assert_eq!( + output.status.code(), + Some(0), + "the reinstall must not fail: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + + assert_eq!( + fs::read(&installed).unwrap(), + SECOND, + "the path carries the new bytes" + ); + + // THE LOAD-BEARING HALF. The handle predates the install; under a rename it + // still reads the bytes it was opened on, and under an in-place write it + // would read the new ones or a torn mixture. + let mut carried = Vec::new(); + held.read_to_end(&mut carried) + .expect("read the held handle"); + assert_eq!( + carried, BINARY, + "a handle opened before the install must still read the ORIGINAL bytes, \ + which is what makes replacing safe for a binary that is executing" + ); +} + // --- (c) a wrong checksum installs nothing ------------------------------------- #[test] diff --git a/crates/batten/tests/it/rebase.rs b/crates/batten/tests/it/rebase.rs index 8449a33e5..5d0a230df 100644 --- a/crates/batten/tests/it/rebase.rs +++ b/crates/batten/tests/it/rebase.rs @@ -27,6 +27,30 @@ //! the property this whole campaign exists to keep, and a suite that reaches for //! `git` to build its own fixtures is asserting nothing about a `git`-free //! engine. +//! +//! # The declared mutation, and why the row is in THIS file +//! +//! `obligations-bound` reads the declared file's own lines for a row beginning +//! `#MUTANT |`, and its `line_sources` covers `crates/batten/tests/**` and +//! not `crates/batten/src/**` — so the row lives here even though the expression +//! it applies belongs to `gitwrite::next_offer`. A block comment because the +//! match is on a line PREFIX and Rust has no line comment starting with `#`. +//! +//! It collapses the per-path QUEUE back to a set by never recording a spend, so +//! every conflict of a path is offered that path's FIRST entry — which is the +//! behaviour CLOUD-1670 replaced, exactly. +//! +//! **It reddens both of the same-path cases, and that is stated rather than +//! glossed.** The declaration names the chain case, because the content +//! assertion there is what says the entries were spent in order; the twin turns +//! red too, since a reused entry resolves the second merge it is supposed to +//! refuse. Both hinge on the one counter, so no mutation separates them — the +//! PAIR discriminates the fix, and this mutation discriminates the counter. + +/* +#MUTANT-SUITE crates/batten/tests/it/rebase.rs +#MUTANT same-path-offer-collapses|s@ *used.entry(path).or_insert(0) += 1;@ *used.entry(path).or_insert(0) += 0;@|a_chain_of_conflicts_at_the_same_path_resolves_with_one_entry_each +*/ #![cfg(unix)] @@ -214,6 +238,374 @@ fn a_conflicting_replay_refuses() { ); } +/// **A commit whose change is ALREADY ON THE BASE is dropped, not replayed** +/// (CLOUD-1586). +/// +/// This is `git rebase`'s own behaviour — it builds its list through +/// `git cherry`, which compares patch identities and omits what the upstream +/// already carries — and the port did not carry it. The consequence was not a +/// slow path but a PERMANENT one: the commit is not reachable from the base, so +/// it stays in the range, gets three-way merged against a base that already +/// contains its change, and conflicts. Every later lap re-derives the same range +/// and conflicts identically, so the loop can never make progress. +/// +/// Measured on #848: `3f308039` and `main`'s `a7935a7b` share patch identity +/// `f185159e…`, and the lap stopped on it every time. Resolving it needed a hand +/// rebase, which `rebase-not-hand-stepped` denies — so this engine gap presented +/// as a policy deadlock and cost a human override to get past. +/// +/// # The fixture is shaped by what makes the bug visible +/// +/// The two commits must share a patch identity while being different objects, so +/// they make the same single-file change from DIFFERENT parents — the fixture's +/// committer and instant are fixed, so same-parent-same-tree would collide into +/// one object and prove nothing. +/// +/// And the trunk MOVES ON past its copy, which is the half that makes this +/// conflict at all. While the trunk's post-state still equals the branch +/// commit's, the three-way merge resolves cleanly and nothing is refused; it is +/// the later trunk commit that makes the two sides disagree about `shared.txt`. +/// That also rules out the cheaper predicate: comparing final trees reads this +/// as unrelated work, because by now they genuinely differ. Only the CHANGE is +/// the same. +#[test] +fn a_commit_whose_change_is_already_on_the_base_is_dropped_rather_than_conflicting() { + let (dir, repo) = init("rebase-already-upstream"); + let root: Files<'_> = &[("shared.txt", "base\n")]; + let base = commit(&repo, &[], root); + + // An unrelated trunk commit, present only so the ported copy below has a + // different parent from the branch's and therefore a different sha. + let widened: Files<'_> = &[("shared.txt", "base\n"), ("unrelated.txt", "trunk\n")]; + let widen = commit(&repo, &[base], widened); + + // The trunk's copy of the change, and then the trunk moving past it. + let ported_files: Files<'_> = &[("shared.txt", "changed\n"), ("unrelated.txt", "trunk\n")]; + let ported = commit(&repo, &[widen], ported_files); + let later: Files<'_> = &[ + ("shared.txt", "changed\nand then more\n"), + ("unrelated.txt", "trunk\n"), + ]; + let moved = commit(&repo, &[ported], later); + + // The branch makes the IDENTICAL change to `shared.txt`, from the same base. + let mine: Files<'_> = &[("shared.txt", "changed\n")]; + let tip = commit(&repo, &[base], mine); + assert_ne!(tip, ported, "the fixture needs two distinct commits"); + + point(&dir, "refs/heads/main", moved); + point(&dir, "refs/heads/work", tip); + materialise(&dir, mine); + + let outcome = gitwrite::rebase(&dir, "refs/heads/work", "refs/heads/main").expect("rebase"); + let Rebase::Replayed { head, commits } = outcome else { + panic!("an already-upstream commit must be dropped, got {outcome:?}"); + }; + assert_eq!( + commits, 0, + "the one commit in the range was already on the base, so nothing replayed" + ); + assert_eq!( + head, + moved.to_hex().to_string(), + "with every commit dropped the branch IS the base" + ); + + // The trunk's own later work survives: a drop must not take the base's + // content with it. + assert_eq!( + std::fs::read_to_string(dir.join("shared.txt")).expect("read worktree"), + "changed\nand then more\n", + "the worktree carries the base's content, not the dropped commit's" + ); +} + +/// **The loop's one human stop, given a route to take it** (CLOUD-1586). +/// +/// The caller edits the conflicting path in the worktree and names it; the +/// replay uses those bytes for that path and carries on. Nothing is persisted +/// and no half-replayed branch is left behind, so the module header's "nothing +/// moves on a conflict" survives — a run that resolves either completes or +/// refuses, exactly as before. +#[test] +fn a_named_path_takes_its_resolution_from_the_worktree() { + let (dir, repo) = init("rebase-resolve"); + let root: Files<'_> = &[("shared.txt", "base\n")]; + let base = commit(&repo, &[], root); + + let trunk: Files<'_> = &[("shared.txt", "the trunk's line\n")]; + let moved = commit(&repo, &[base], trunk); + + let side: Files<'_> = &[("shared.txt", "the branch's line\n")]; + let tip = commit(&repo, &[base], side); + + point(&dir, "refs/heads/main", moved); + point(&dir, "refs/heads/work", tip); + + // The human's work: a merge of both sides, written where they did it. + materialise(&dir, &[("shared.txt", "both lines, reconciled\n")]); + + let outcome = gitwrite::rebase_resolving( + &dir, + "refs/heads/work", + "refs/heads/main", + &["shared.txt".to_owned()], + ) + .expect("rebase resolving"); + let Rebase::Replayed { head, commits } = outcome else { + panic!("a named resolution must let the replay finish, got {outcome:?}"); + }; + assert_eq!( + commits, 1, + "the conflicting commit was replayed, not dropped" + ); + + let landed = repo + .rev_parse_single("refs/heads/work") + .expect("resolve work") + .detach(); + assert_eq!(landed.to_hex().to_string(), head); + assert_eq!( + repo.find_commit(landed) + .expect("find replayed") + .parent_ids() + .map(gix::Id::detach) + .collect::>(), + vec![moved], + "the replayed commit sits on the trunk" + ); + + // THE RESOLUTION IS WHAT LANDED, not either side. A test asserting only that + // the replay finished would pass over an engine that picked `ours`. + assert_eq!( + std::fs::read_to_string(dir.join("shared.txt")).expect("read worktree"), + "both lines, reconciled\n", + "the caller's bytes are the ones that landed" + ); +} + +/// **A CHAIN resolves: two commits conflicting at two different paths.** +/// +/// The case that drove the rule from "spend the offer at the first conflicting +/// commit" to "spend each PATH once". The first spelling enforced the same +/// no-reuse property and made this unresolvable: nothing moves on a conflict, so +/// every run resolved the first commit, refused at the second, moved nothing, +/// and the next run re-derived the identical range — the permanent-stop shape +/// [`a_commit_whose_change_is_already_on_the_base_is_dropped_rather_than_conflicting`] +/// exists to remove, reintroduced by its own remedy. Measured on #848, where a +/// real branch conflicted at `fetch.rs` on one commit and `egress-fencing.rego` +/// on another. +#[test] +fn a_chain_of_conflicts_at_different_paths_resolves_in_one_run() { + let (dir, repo) = init("rebase-resolve-chain"); + let root: Files<'_> = &[("first.txt", "base\n"), ("second.txt", "base\n")]; + let base = commit(&repo, &[], root); + + // The trunk edits BOTH paths, so each branch commit below meets a changed side. + let trunk: Files<'_> = &[("first.txt", "trunk\n"), ("second.txt", "trunk\n")]; + let moved = commit(&repo, &[base], trunk); + + // Two branch commits, each conflicting at a DIFFERENT path. + let one: Files<'_> = &[("first.txt", "branch\n"), ("second.txt", "base\n")]; + let first = commit(&repo, &[base], one); + let two: Files<'_> = &[("first.txt", "branch\n"), ("second.txt", "branch\n")]; + let tip = commit(&repo, &[first], two); + + point(&dir, "refs/heads/main", moved); + point(&dir, "refs/heads/work", tip); + materialise( + &dir, + &[ + ("first.txt", "first reconciled\n"), + ("second.txt", "second reconciled\n"), + ], + ); + + let outcome = gitwrite::rebase_resolving( + &dir, + "refs/heads/work", + "refs/heads/main", + &["first.txt".to_owned(), "second.txt".to_owned()], + ) + .expect("rebase resolving"); + let Rebase::Replayed { commits, .. } = outcome else { + panic!("a chain at two paths must resolve in one run, got {outcome:?}"); + }; + assert_eq!(commits, 2, "both commits replayed"); + + // Each path kept ITS OWN resolution — a run that reused one set of bytes for + // both merges would still land two files, so the content is the assertion. + assert_eq!( + std::fs::read_to_string(dir.join("first.txt")).expect("read first"), + "first reconciled\n" + ); + assert_eq!( + std::fs::read_to_string(dir.join("second.txt")).expect("read second"), + "second reconciled\n" + ); +} + +/// **A CHAIN AT ONE PATH resolves, one authored entry per merge (CLOUD-1670).** +/// +/// The case above drove the rule from "spend the offer at the first conflicting +/// commit" to "spend each PATH once", and per-path was a permanent stop of its +/// own for a path that conflicts TWICE. Measured on #848: one declared config +/// document conflicted at four commits, because four of them edit a single line +/// the base branch had also moved, and a second document conflicted at four +/// more. Every run resolved the first and refused at the second, moved nothing, +/// and the next run re-derived the identical range — the same shape, arrived at +/// through the path rather than through the offer. Naming the path four times +/// did not help, because the offer was a SET of paths. +/// +/// The documents go unnamed for rule 1's reason rather than for brevity: they +/// are a consumer's own config and this file sits in `crates/batten`, where a +/// grep for a specific consumer's names must return zero hits. +/// +/// So an entry may name a SOURCE, and entries naming one path are spent in the +/// order given. The no-reuse property is unchanged: each merge gets bytes +/// somebody wrote for it, and what changed is that a second version is now +/// writable at all. +#[test] +fn a_chain_of_conflicts_at_the_same_path_resolves_with_one_entry_each() { + let (dir, repo) = init("rebase-resolve-same-path"); + let root: Files<'_> = &[("shared.txt", "base\n")]; + let base = commit(&repo, &[], root); + + let trunk: Files<'_> = &[("shared.txt", "trunk\n")]; + let moved = commit(&repo, &[base], trunk); + + // TWO branch commits editing THE SAME path, so each meets the moved trunk. + let one: Files<'_> = &[("shared.txt", "branch one\n")]; + let first = commit(&repo, &[base], one); + let two: Files<'_> = &[("shared.txt", "branch two\n")]; + let tip = commit(&repo, &[first], two); + + point(&dir, "refs/heads/main", moved); + point(&dir, "refs/heads/work", tip); + // One file per merge, which is the whole of what the row adds. `shared.txt` + // itself is deliberately NOT the content of either: a run that fell back to + // reading the path would land these bytes and pass for the wrong reason. + materialise( + &dir, + &[ + ("shared.txt", "never read\n"), + ("first.merged", "first merge\n"), + ("second.merged", "second merge\n"), + ], + ); + + let outcome = gitwrite::rebase_resolving( + &dir, + "refs/heads/work", + "refs/heads/main", + &[ + "shared.txt=first.merged".to_owned(), + "shared.txt=second.merged".to_owned(), + ], + ) + .expect("rebase resolving"); + let Rebase::Replayed { commits, .. } = outcome else { + panic!("a chain at one path must resolve in one run, got {outcome:?}"); + }; + assert_eq!(commits, 2, "both commits replayed"); + + // THE ORDER IS THE ASSERTION. The second entry is spent on the second + // merge, so the tip carries it — a run that spent the first entry twice, or + // spent them in the other order, lands `first merge` here. + assert_eq!( + std::fs::read_to_string(dir.join("shared.txt")).expect("read shared"), + "second merge\n" + ); +} + +/// **AND ONE ENTRY FOR TWO CONFLICTS STILL REFUSES**, which is what says the +/// no-reuse rule survived the change rather than being widened out of it. +/// +/// This is the case the per-occurrence spend must NOT pass. If a spent entry +/// were reused, the second merge would silently take bytes written for the +/// first — a resolution for a merge nobody looked at, which is the property the +/// whole mechanism exists to hold. +#[test] +fn one_entry_does_not_resolve_a_second_conflict_at_the_same_path() { + let (dir, repo) = init("rebase-resolve-same-path-once"); + let root: Files<'_> = &[("shared.txt", "base\n")]; + let base = commit(&repo, &[], root); + let moved = commit(&repo, &[base], &[("shared.txt", "trunk\n")]); + let first = commit(&repo, &[base], &[("shared.txt", "branch one\n")]); + let tip = commit(&repo, &[first], &[("shared.txt", "branch two\n")]); + + point(&dir, "refs/heads/main", moved); + point(&dir, "refs/heads/work", tip); + materialise(&dir, &[("shared.txt", "only answer\n")]); + + let outcome = gitwrite::rebase_resolving( + &dir, + "refs/heads/work", + "refs/heads/main", + &["shared.txt".to_owned()], + ) + .expect("rebase resolving"); + let Rebase::Conflicted { paths, .. } = outcome else { + panic!("one entry cannot answer two merges, got {outcome:?}"); + }; + assert_eq!( + paths, + vec!["shared.txt".to_owned()], + "the second merge is refused, and it names the path it wants an answer for" + ); +} + +/// **A conflict at a path the caller did not name still refuses.** +/// +/// The vacuity twin, and the one that matters: resolving SOME paths would write +/// a tree carrying the engine's own pick for the rest, which is the +/// auto-resolution the module refuses — reached by omission rather than by a +/// flag, so nothing in the call site would look wrong. +#[test] +fn an_unnamed_conflicting_path_still_refuses_the_whole_replay() { + let (dir, repo) = init("rebase-resolve-partial"); + let root: Files<'_> = &[("named.txt", "base\n"), ("unnamed.txt", "base\n")]; + let base = commit(&repo, &[], root); + + let trunk: Files<'_> = &[("named.txt", "trunk\n"), ("unnamed.txt", "trunk\n")]; + let moved = commit(&repo, &[base], trunk); + + let side: Files<'_> = &[("named.txt", "branch\n"), ("unnamed.txt", "branch\n")]; + let tip = commit(&repo, &[base], side); + + point(&dir, "refs/heads/main", moved); + point(&dir, "refs/heads/work", tip); + materialise( + &dir, + &[("named.txt", "reconciled\n"), ("unnamed.txt", "branch\n")], + ); + + let outcome = gitwrite::rebase_resolving( + &dir, + "refs/heads/work", + "refs/heads/main", + &["named.txt".to_owned()], + ) + .expect("rebase resolving"); + let Rebase::Conflicted { paths, .. } = outcome else { + panic!("a partial resolution must still refuse, got {outcome:?}"); + }; + assert_eq!( + paths, + vec!["named.txt".to_owned(), "unnamed.txt".to_owned()], + "the refusal names every conflicting path, including the resolved one" + ); + + let still = repo + .rev_parse_single("refs/heads/work") + .expect("resolve work") + .detach(); + assert_eq!( + still, tip, + "the branch is untouched after a partial refusal" + ); +} + /// A branch that already descends from the base mints nothing. /// /// The receipts the landing loop runs on are keyed to the commit they validated, @@ -291,6 +683,210 @@ fn a_path_the_base_deleted_leaves_the_worktree() { ); } +// --- unwinding a speculation (CLOUD-862, CLOUD-1456) -------------------------- +// +// The two primitives the lap's bet settle is built on, driven over a real +// repository rather than over the driver — which is the same reason the header +// gives for reaching `land::record` instead of `land::replay`: the composition +// above them reads a lease over the network, and a case that needed one would be +// a test of the network rather than of the unwind. + +/// **An ADOPTED bet unwinds by replaying this branch's OWN commits.** +/// +/// The bet has no undo point — the process that recorded it died — so the range +/// bound and the graft point are two different commits, which is the whole reason +/// [`gitwrite::replay_onto`] exists beside `rebase`. The bound is the borrowed +/// base and the graft is the trunk, so `base..HEAD` is precisely what this branch +/// authored. +/// +/// The load-bearing assertion is the NEGATIVE one: the holder's file must be gone +/// from the replayed tree. A `rebase` onto the trunk would land the borrowed +/// commits too and pass every other assertion here — which is exactly the state +/// CLOUD-862 measured reaching a push. +#[test] +fn an_adopted_bet_replays_only_this_branchs_own_commits() { + let (dir, repo) = init("rebase-adopted-bet"); + let root: Files<'_> = &[("shared.txt", "base\n")]; + let base = commit(&repo, &[], root); + + // The holder's commit, which this tree was speculatively linearized onto. + let borrowed: Files<'_> = &[("shared.txt", "base\n"), ("from-holder.txt", "theirs\n")]; + let holder = commit(&repo, &[base], borrowed); + + // Our own commit, sitting on top of the borrowed one. + let speculative: Files<'_> = &[ + ("shared.txt", "base\n"), + ("from-holder.txt", "theirs\n"), + ("ours.txt", "ours\n"), + ]; + let tip = commit(&repo, &[holder], speculative); + + point(&dir, "refs/heads/main", base); + point(&dir, "refs/heads/work", tip); + materialise(&dir, speculative); + + let outcome = gitwrite::replay_onto( + &dir, + "refs/heads/work", + &holder.to_hex().to_string(), + "refs/heads/main", + ) + .expect("replay onto the trunk"); + let Rebase::Replayed { head, commits } = outcome else { + panic!("the unwind must replay, got {outcome:?}"); + }; + assert_eq!(commits, 1, "only this branch's own commit was in the range"); + + let landed = repo + .rev_parse_single("refs/heads/work") + .expect("resolve work") + .detach(); + assert_eq!(landed.to_hex().to_string(), head); + assert_eq!( + repo.find_commit(landed) + .expect("find replayed") + .parent_ids() + .map(gix::Id::detach) + .collect::>(), + vec![base], + "the unwound branch sits on the trunk rather than on the holder" + ); + + let names = tree_names(&repo, landed); + assert!( + !names.contains(&"from-holder.txt".to_owned()), + "the borrowed commit came along, which is the state that must never push: {names:?}" + ); + assert!( + names.contains(&"ours.txt".to_owned()), + "this branch's own work was dropped: {names:?}" + ); + // And the WORKTREE, which is what the next lap's `verify` compiles. + assert!( + !dir.join("from-holder.txt").exists(), + "the borrowed file is still on disk" + ); + assert!(dir.join("ours.txt").is_file(), "our own file left the disk"); +} + +/// **A bet this process PLACED unwinds exactly, to the sha it recorded.** +/// +/// Not a replay: the undo point is this branch's own last non-speculative HEAD, +/// so restoring it mints nothing and throws no receipt away. A replay here would +/// produce a new sha for identical work and cost a CI run to re-prove it. +#[test] +fn a_placed_bet_unwinds_to_the_recorded_sha() { + let (dir, repo) = init("rebase-placed-bet"); + let root: Files<'_> = &[("shared.txt", "base\n")]; + let base = commit(&repo, &[], root); + + // The undo point: where this branch stood before anything was borrowed. + let mine: Files<'_> = &[("shared.txt", "base\n"), ("ours.txt", "ours\n")]; + let undo = commit(&repo, &[base], mine); + + let speculative: Files<'_> = &[ + ("shared.txt", "base\n"), + ("ours.txt", "ours\n"), + ("from-holder.txt", "theirs\n"), + ]; + let tip = commit(&repo, &[undo], speculative); + + point(&dir, "refs/heads/work", tip); + materialise(&dir, speculative); + + let restored = gitwrite::reset_hard(&dir, "refs/heads/work", &undo.to_hex().to_string()) + .expect("reset to the undo point"); + assert_eq!( + restored, + undo.to_hex().to_string(), + "the unwind lands on the EXACT recorded sha, minting nothing" + ); + assert_eq!( + repo.rev_parse_single("refs/heads/work") + .expect("resolve work") + .detach(), + undo, + "the ref did not move" + ); + // The worktree is the half a ref-only assertion never reaches, and the + // REMOVAL is the half a checkout does not do on its own. + assert!( + !dir.join("from-holder.txt").exists(), + "the borrowed file survived the unwind" + ); + assert!(dir.join("ours.txt").is_file(), "our own file was removed"); +} + +/// **A BET REACHES THE GATE'S ENVIRONMENT, and the publication is a function of +/// the bet rather than a side effect kept in step with it.** +/// +/// `land::verify` is the one metered step a speculation has to be visible to: a +/// gate cannot otherwise tell a commit this branch authored from one the lap +/// adopted, and CLOUD-748 measured the consequence twice in one session — the +/// consumer's race check reported the waiter as racing the very PR the bet was +/// placed on. +/// +/// Driven through `land::verify` over a real repository, because the thing under +/// test is whether the pairs SURVIVE the exec boundary. A case asserting that +/// `Bet::published` returns the base would pass over a `verify` that dropped +/// them on the floor, which is the whole class the second tier exists for. +#[test] +fn a_published_bet_reaches_the_gate_and_an_absent_one_publishes_nothing() { + let (dir, repo) = init("verify-publication"); + let base = commit(&repo, &[], &[("shared.txt", "base\n")]); + point(&dir, "refs/heads/work", base); + // `verify` reads HEAD, and the other cases in this file never do — so the + // fixture's initial branch has to exist as well as `work`. Both spellings, + // because which one `gix::init` writes into HEAD is the host git's default + // and not this case's to depend on. + point(&dir, "refs/heads/main", base); + point(&dir, "refs/heads/master", base); + materialise(&dir, &[("shared.txt", "base\n")]); + + // A gate that passes only when the variable carries the expected value. The + // gate is the assertion, so a dropped pair reddens the case rather than + // leaving it to a follow-up read. + let gate = dir.join("gate.sh"); + std::fs::write( + &gate, + format!( + "#!/bin/sh\ntest \"${{{}}}\" = \"speculated-base\"\n", + batten::speculation::PUBLISHED_AS + ), + ) + .expect("write the gate"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&gate, std::fs::Permissions::from_mode(0o755)) + .expect("make the gate runnable"); + } + let command = vec![gate.to_string_lossy().into_owned()]; + + let published = vec![( + batten::speculation::PUBLISHED_AS.to_owned(), + String::from("speculated-base"), + )]; + assert!( + matches!( + batten::land::verify(&dir, "work", &command, &published, &[]).expect("run the gate"), + batten::land::Verified::Clean(_) + ), + "the published pair did not reach the gate" + ); + + // THE MIRROR, and it is not hygiene: without it the case passes over a + // boundary that publishes the variable unconditionally from some other + // source, which would make a settled bet stay visible to every later gate. + assert!( + matches!( + batten::land::verify(&dir, "work", &command, &[], &[]).expect("run the gate"), + batten::land::Verified::Refused { .. } + ), + "the variable reached the gate with no bet outstanding" + ); +} + /// The filenames in a commit's tree. fn tree_names(repo: &gix::Repository, id: gix::ObjectId) -> Vec { let tree = repo diff --git a/crates/batten/tests/it/receipt_verified.rs b/crates/batten/tests/it/receipt_verified.rs new file mode 100644 index 000000000..72f68d8da --- /dev/null +++ b/crates/batten/tests/it/receipt_verified.rs @@ -0,0 +1,362 @@ +//! `batten receipt verified` over the compiled binary — the composed +//! receipt read that retired `mise-tasks/verified.sh` (CLOUD-1148). +//! +//! # RETIREMENT LEDGER, PER PATH — what `shell-retirement` reads +//! +//! The predecessor was a gate over three reads: a `verify` receipt for this +//! exact HEAD, a `linear-check` receipt, and the `origin/main` that receipt was +//! taken against still being current. Every one of those is `receipt::validity` +//! already, so the port composes rather than reimplements — which is also why +//! the successor is `receipt.rs` rather than a module of its own. + +// carried: mise-tasks/verified.sh crates/batten/src/receipt.rs kind:verb crates/batten/tests/it/receipt_verified.rs runs:batten+receipt+verified +// carried: tests/verified.bats crates/batten/src/receipt.rs kind:verb crates/batten/tests/it/receipt_verified.rs + +//! # RETIREMENT LEDGER — `tests/verified.bats`, 10 cases +//! +//! **Every title below is the base file's, byte for byte.** The first draft of +//! this block invented them from the brief instead of reading +//! `git show origin/main:tests/verified.bats`, so all ten arms matched nothing +//! and `bats-tests-not-deleted` reported ten unmapped cases — which is the +//! ratchet doing exactly what it exists to do. +//! +//! CARRIED — the predicate moved intact onto the composed verb. + +// carried: "a commit with both current receipts is verified" crates/batten/tests/it/receipt_verified.rs +// carried: "a verify receipt alone is not enough — linear-check is a separate claim" crates/batten/tests/it/receipt_verified.rs +// carried: "an amend invalidates the receipt, because it produces a new HEAD" crates/batten/tests/it/receipt_verified.rs +// carried: "a main that moved under the branch invalidates the receipt" crates/batten/tests/it/receipt_verified.rs + +//! CHANGED — behaviour that diverges deliberately, with its reason. + +// changed: "THE INVERSION: a failed verify whose exit code was swallowed leaves HEAD unverified" crates/batten/tests/it/receipt_verified.rs the predicate is conserved and the WORDING moved: the verb says WHAT is unverified and the `mise` task keeping the `verified` name says what to do about it, because the remedy names `mise run verify` — a consumer task name, which non-negotiable rule 1 forbids in `crates/batten`. The suite asserting the prose therefore asserts it one layer out +// changed: "the failure names what to run, not merely that it refused" crates/batten/tests/it/receipt_verified.rs the same move as the row above, and the same reason: a remedy that names a task cannot live in the repo-agnostic core, so the wrapper emits it +// changed: "an unresolvable origin/main exits 2 — a checkout problem, not a verdict" crates/batten/tests/it/receipt_verified.rs the engine has ONE exit table and no per-verb exception (non-negotiable rule 5): `2` is the policy verdict everywhere and `1`/`3` are the only codes a Batten failure produces. The predecessor spelled an unusable checkout `2` and an unverified head `1`, which is the table inverted. The `mise` task keeping the `verified` name translates, so every caller reads what it always read — the shim shape CLOUD-1170 established +// changed: "outside a git repository it exits 2 rather than claiming unverified" crates/batten/tests/it/receipt_verified.rs the same inversion as the row above, on the other environment arm + +//! SUBSUMED — the assertion is a property of a reader the verb now shares. + +// subsumed: "a receipt for a different commit does not vouch for this one" crates/batten/tests/it/receipt_verified.rs +// subsumed: "output is a pointer — it names predicates and shas, never run contents" crates/batten/tests/it/receipt_verified.rs + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::fs; +use std::path::Path; + +use common::{Fixture, batten, git_in, scratch}; + +/// A repository with one commit and an `origin/main` pointing at it. +/// +/// Built through [`Fixture`] rather than by hand, and that is not style: the +/// scratch tree lives under `target/`, which is INSIDE this repository, so a +/// fixture that only ran `git init` still had this checkout above it — and +/// `repo_facts` answered about the real HEAD. Measured: the first draft of these +/// cases reported this branch's own sha back at them. +fn repo(name: &str) -> std::path::PathBuf { + // A COMMITTED CONFIG, because a receipt records the policy epoch it was + // taken under — `receipt record` refuses a tree with no `batten.toml` at + // HEAD, and rightly: a receipt that named no policy would still be valid + // after the policy changed underneath it. + // AND IT DECLARES THE SET, because `verified_by` is the consumer's now: the + // two names used to be a `const` in `receipt.rs`, which put this + // repository's own task names inside `crates/batten` (rule 1). An + // undeclared set REFUSES rather than passing, so a fixture that omitted the + // row would exercise the usage error instead of the predicate. + let dir = Fixture::at(scratch(name).join("repo")) + .config("version = 1\n\n[receipt]\nverified_by = [\"verify\", \"linear-check\"]\n") + .file("src.rs", "fn main() {}\n") + .git() + .base_commit() + .build(); + git_in(&dir, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + dir +} + +fn out_of(dir: &Path, args: &[&str]) -> String { + git_in(dir, args).trim().to_owned() +} + +/// Record the two receipts a verified head carries, THROUGH THE ENGINE'S OWN +/// WRITER. +/// +/// Hand-writing the files is what the first draft did, and it failed for the +/// right reason: a receipt is not a sha in a file. `validity` reads a statement +/// that also records WHICH checkout took it, so a hand-rolled one is `Missing` +/// — correctly. Driving `receipt record` makes this a test of the real +/// writer/reader pair rather than of my guess at their format, which is the same +/// argument `land.rs`'s tier makes for driving `land::record`. +fn record(dir: &Path, check: &str) { + let output = batten() + .args(["receipt", "record", check]) + .current_dir(dir) + .output() + .expect("run batten receipt record"); + assert!( + output.status.success(), + "recording {check} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn verified(dir: &Path) -> (i32, String) { + let output = batten() + .args(["receipt", "verified"]) + .current_dir(dir) + .output() + .expect("run batten receipt verified"); + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + (output.status.code().expect("exit code"), text) +} + +/// **The discriminating pair: both receipts present is verified, one missing is +/// not.** +/// +/// The predecessor existed because a caller acted on a zero that came from a +/// pipe rather than from the gate — `mise run verify 2>&1 | tail -60` exits with +/// `tail`'s status, so a branch `linear-check` had rejected reported success. +/// Composing the two reads into one verb is what makes that unaskable: there is +/// no half of this to answer. +#[test] +fn a_head_carrying_both_receipts_is_verified_and_one_missing_is_not() { + let dir = repo("receipt-verified-pair"); + + record(&dir, "verify"); + record(&dir, "linear-check"); + let (code, text) = verified(&dir); + assert_eq!(code, 0, "both receipts valid is verified: {text}"); + + // The other half, on a fixture that simply never recorded the second + // receipt. Deleting the file by name is what the first draft did, and it + // guessed the layout wrong and passed for the wrong reason — the head stayed + // verified because nothing had been removed. Not recording it cannot guess. + let half = repo("receipt-verified-half"); + record(&half, "verify"); + + let (code, text) = verified(&half); + assert_eq!( + code, 2, + "a missing receipt is a verdict about the tree: {text}" + ); + assert!( + text.contains("NOT verified"), + "the predecessor's wording is conserved because a surviving suite reads for it: {text}" + ); + assert!( + text.contains("linear-check"), + "the refusal names WHICH receipt is missing: {text}" + ); +} + +/// A main checkout declaring `main_set`, plus a **linked worktree** whose own +/// branch declares `worktree_set`. +/// +/// Both roots carry a `batten.toml` and both declare a set, so no anchor can +/// fail to find one — the only difference is WHICH, which is the difference a +/// single-root fixture cannot express. +fn repo_with_worktree(name: &str, main_set: &str, worktree_set: &str) -> std::path::PathBuf { + let main = Fixture::at(scratch(name).join("repo")) + .config(&format!( + "version = 1\n\n[receipt]\nverified_by = {main_set}\n" + )) + .file("src.rs", "fn main() {}\n") + .git() + .base_commit() + .build(); + git_in(&main, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + // Branched before this branch's own set lands, so the worktree carries its + // own authority rather than inheriting one. + git_in(&main, &["branch", "sibling"]); + + let linked = scratch(&format!("{name}-linked")); + let _ = fs::remove_dir_all(&linked); + git_in( + &main, + &[ + "worktree", + "add", + "--quiet", + linked.to_str().unwrap_or_default(), + "sibling", + ], + ); + common::write( + &linked, + "batten.toml", + &format!("version = 1\n\n[receipt]\nverified_by = {worktree_set}\n"), + ); + git_in(&linked, &["add", "-A"]); + git_in(&linked, &["commit", "-q", "-m", "this branch's own set"]); + linked +} + +/// **`verified_by` IS THE WORKING TREE'S, NOT THE MAIN CHECKOUT'S** (CLOUD-1586). +/// +/// This is `git::worktree_root`'s own measured defect, verbatim: *"a worktree +/// whose branch TIGHTENED the check set was judged against the main checkout's +/// looser one and a head carrying half the receipts exited `0`."* +/// +/// **The fix this replaces was a NO-OP and green, which is why the case is +/// here.** `worktree_root` was being handed `facts.repo_root` — already +/// `repo_root`'s answer, deliberately the main checkout because receipt STATE is +/// repository-wide (CLOUD-164) — and walking up from the main checkout's root +/// can only ever reach the main checkout. So the call named the right function, +/// resolved the wrong root, and no case in this file could tell: every one of +/// them runs from a single checkout where the two roots coincide. +#[test] +fn the_check_set_is_read_from_the_worktree_being_judged_not_the_main_checkout() { + // Main is LOOSER — one check. The branch demands two. + let linked = repo_with_worktree( + "receipt-verified-worktree-tightened", + "[\"verify\"]", + "[\"verify\", \"linear-check\"]", + ); + // Only the check BOTH sets name is recorded, so the answer turns entirely on + // whether the second one was demanded. + record(&linked, "verify"); + + let (code, text) = verified(&linked); + // THE ASSERTION THAT WAS GREEN OVER THE NO-OP: reading main's looser set + // made this exit 0 with half the receipts. + assert_eq!( + code, 2, + "the branch demands linear-check and it was never recorded: {text}" + ); + assert!( + text.contains("NOT verified"), + "a head missing a receipt its own branch requires is not verified: {text}" + ); + assert!( + text.contains("linear-check"), + "the refusal names the check THIS branch added: {text}" + ); +} + +/// **ANTI-VACUITY: a branch that RETIRED a check is not held to it.** +/// +/// The reverse direction, which `git::worktree_root`'s header names as *"as +/// bad"*: a main config naming a check the branch retired makes the worktree +/// permanently unverifiable. Without this case, an anchor that read the main +/// checkout — or one that unioned both — would satisfy the case above by simply +/// demanding more, and the union would be invisible. +#[test] +fn a_worktree_that_retired_a_check_is_not_judged_by_the_main_checkouts_set() { + // Main is TIGHTER this time; the branch narrowed the set to one. + let linked = repo_with_worktree( + "receipt-verified-worktree-retired", + "[\"verify\", \"linear-check\"]", + "[\"verify\"]", + ); + record(&linked, "verify"); + + let (code, text) = verified(&linked); + assert_eq!( + code, 0, + "this branch requires only `verify`, and it is recorded: {text}" + ); + assert!( + !text.contains("linear-check"), + "a check this branch retired must not be demanded of it: {text}" + ); +} + +/// A moved trunk expires the linear-check receipt, which is the whole reason +/// that receipt records the trunk it was taken against. +/// +/// A head proven linear on a base that has since moved has been proven against +/// something that no longer exists. Without this the pair above passes over a +/// receipt that means nothing. +#[test] +fn a_moved_trunk_expires_the_receipt_taken_against_the_old_one() { + let dir = repo("receipt-verified-moved-trunk"); + let head = out_of(&dir, &["rev-parse", "HEAD"]); + record(&dir, "verify"); + record(&dir, "linear-check"); + assert_eq!(verified(&dir).0, 0, "the fixture starts verified"); + + // Move trunk on, without touching the branch or its receipts. + fs::write(dir.join("other.rs"), "fn other() {}\n").expect("seed a second file"); + git_in(&dir, &["add", "other.rs"]); + git_in(&dir, &["commit", "-q", "-m", "chore: trunk moves"]); + git_in(&dir, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + git_in(&dir, &["reset", "-q", "--hard", &head]); + + let (code, text) = verified(&dir); + assert_eq!( + code, 2, + "a receipt taken against a moved trunk is stale: {text}" + ); + assert!(text.contains("NOT verified"), "got {text}"); +} + +/// An amend expires the receipt, because it produces a new HEAD. +/// +/// The other half of the keying: the row above moves the TRUNK and this moves +/// the HEAD, and a reader that answered only one of them would vouch for work +/// nobody verified. Cheap to state and the pair is what makes the keying +/// meaningful rather than incidental. +#[test] +fn an_amended_head_expires_the_receipt_taken_against_the_old_one() { + let dir = repo("receipt-verified-amended"); + record(&dir, "verify"); + record(&dir, "linear-check"); + assert_eq!(verified(&dir).0, 0, "the fixture starts verified"); + + let before = out_of(&dir, &["rev-parse", "HEAD"]); + git_in(&dir, &["commit", "-q", "--amend", "-m", "chore: reworded"]); + let after = out_of(&dir, &["rev-parse", "HEAD"]); + assert_ne!(before, after, "the amend minted a new sha"); + + let (code, text) = verified(&dir); + assert_eq!( + code, 2, + "a receipt names the commit it validated, and this is not that commit: {text}" + ); + assert!(text.contains("NOT verified"), "got {text}"); +} + +/// **The exit table is the engine's, and the inversion is deliberate.** +/// +/// The predecessor answered `1` for an unverified head and `2` for a checkout it +/// could not judge. The engine has one table with no per-verb exception: `2` is +/// the policy verdict everywhere, `1`/`3` are the only codes a Batten failure +/// produces. So the two swap, and the `mise` task keeping the old name is what +/// translates for callers that still read the old numbers. +/// +/// Anti-vacuity for the pair above: without this, a verb that answered `2` for +/// everything — including a directory that is not a repository — would pass. +#[test] +fn a_checkout_that_cannot_be_judged_is_not_reported_as_an_unverified_head() { + // OUTSIDE THE REPOSITORY TREE, not merely un-`git init`ed. The scratch tree + // lives under `target/`, so a directory there still has this checkout above + // it and `git rev-parse` climbs to it — which is a repository, and the case + // would then be asserting the opposite of what it says. + // + // AND UNIQUE PER PROCESS. A fixed name under `temp_dir()` is shared by every + // concurrent run — nextest runs each case in its own process, and two of them + // racing meant one `remove_dir_all` deleting the directory the other had just + // created. The lane keeps runs on one machine apart and the pid keeps the + // processes within a lane apart; both are needed, since a lane is reused. + // Found in review. + let lane = std::env::var("BATTEN_TEST_SCRATCH_LANE").unwrap_or_else(|_| String::from("0")); + let dir = std::env::temp_dir().join(format!( + "batten-receipt-verified-not-a-repo-{lane}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create the fixture"); + + let (code, text) = verified(&dir); + assert_eq!( + code, 1, + "a directory that is not a repository is a usage error, never a verdict: {text}" + ); + assert_ne!( + code, 2, + "exit 2 would report the WORK as unverified when the checkout is what could not be read" + ); +} diff --git a/crates/batten/tests/it/shell_retirement.rs b/crates/batten/tests/it/shell_retirement.rs index 9eee98760..357b846df 100644 --- a/crates/batten/tests/it/shell_retirement.rs +++ b/crates/batten/tests/it/shell_retirement.rs @@ -472,6 +472,90 @@ fn an_edit_repointing_a_task_name_at_the_declared_invocation_is_admitted() { ); } +/// **A `.bats` suite binding its subject through `$BATS_TEST_DIRNAME`**, which is +/// the one spelling of "this file's own directory" the module could not resolve. +/// +/// Arm 2b. The suite binds the retired path in `setup()` and spends `"$GATE"` in +/// a case, so the spend line carries no path, no naming form and no variable the +/// module could resolve — the binding was removable and the spend was not, in +/// either direction. Measured on `tests/tree-clean.bats` while retiring +/// `mise-tasks/verified.sh`: a SURVIVING suite broken by a retirement with no +/// landable repair. +/// +/// The second tier rather than a `with input as` case for the reason this file +/// exists: the arm reads a `[[pattern]]` row, so a fixture supplying the pattern +/// vocabulary itself would pass over a row `batten.toml` does not carry. +#[test] +fn a_bats_suite_binding_its_subject_by_test_dirname_can_be_repointed() { + let root = repo( + "bats-dirname-repointed", + &[ + ("mise-tasks/old-gate.sh", GATE), + ( + "tests/pinned.bats", + "setup() {\n GATE=\"$BATS_TEST_DIRNAME/../mise-tasks/old-gate.sh\"\n cd /tmp\n}\n@test \"it holds\" {\n run \"$GATE\"\n}\n", + ), + ], + &Head { + written: &[ + ( + "tests/pinned.bats", + "setup() {\n cd /tmp\n}\n@test \"it holds\" {\n run batten claim bot\n}\n", + ), + ( + "crates/batten/tests/old_gate.rs", + &ledger_running("mise-tasks/old-gate.sh", "batten claim bot"), + ), + ], + removed: &["mise-tasks/old-gate.sh"], + }, + ); + assert!( + findings(&root).is_empty(), + "a suite spelling its subject relative to its own directory may be \ + repointed at the declared invocation: {:?}", + findings(&root) + ); +} + +/// ANTI-VACUITY for arm 2b: the head must be the SUITE'S OWN directory. +/// +/// Without the anchored pattern the arm resolves a variable bound to anywhere at +/// all, and repointing a reference into somebody else's tree — which no +/// retirement here owns — becomes admissible. Same edit as the case above, one +/// variable different. +#[test] +fn a_bats_binding_outside_the_suite_directory_is_refused() { + let root = repo( + "bats-dirname-foreign", + &[ + ("mise-tasks/old-gate.sh", GATE), + ( + "tests/pinned.bats", + "setup() {\n GATE=\"$OTHER_TREE/../mise-tasks/old-gate.sh\"\n cd /tmp\n}\n@test \"it holds\" {\n run \"$GATE\"\n}\n", + ), + ], + &Head { + written: &[ + ( + "tests/pinned.bats", + "setup() {\n cd /tmp\n}\n@test \"it holds\" {\n run batten claim bot\n}\n", + ), + ( + "crates/batten/tests/old_gate.rs", + &ledger_running("mise-tasks/old-gate.sh", "batten claim bot"), + ), + ], + removed: &["mise-tasks/old-gate.sh"], + }, + ); + assert!( + !findings(&root).is_empty(), + "a binding that names somebody else's tree is not a reference this \ + retirement owns, so repointing it is a rewrite" + ); +} + /// ANTI-VACUITY for the case above, and the one CLOUD-1299 names by hand: an arm /// that admitted any span merely CONTAINING a naming form would be the licence /// the module refuses in as many words. This edit repoints the same span AND diff --git a/crates/batten/tests/it/sleep_ban.rs b/crates/batten/tests/it/sleep_ban.rs index dc46fd6c3..d15bb51de 100644 --- a/crates/batten/tests/it/sleep_ban.rs +++ b/crates/batten/tests/it/sleep_ban.rs @@ -198,9 +198,31 @@ fn the_async_row_is_live_exactly_while_the_time_feature_is_on() { #[test] fn every_delay_carries_an_expect_naming_a_bound_that_resolves() { // THE PREDICATE THIS ROW IS ABOUT. Every other assertion here is about the - // config; this one is about the thirteen annotations the config forced into - // existence, and it is what stops the ban being satisfied by thirteen - // waivers. + // config; this one is about the annotations the config forced into + // existence. + // + // **AND IT IS NOT WHAT STOPS THE BAN BEING SATISFIED BY WAIVERS, WHICH IS + // WHAT THIS COMMENT USED TO CLAIM** (CLOUD-1148). Measured 2026-09-06: 11 + // `#[expect(clippy::disallowed_methods, …)]` stand over 13 + // `std::thread::sleep` sites, so the ban is waived at essentially every site + // it governs — the state the claim said this test prevents. + // + // The reason it cannot is structural rather than a gap to close here. Every + // clause below is a property of the SENTENCE: `expect` not `allow`, a reason + // present, a backticked token, that token resolving elsewhere in the file. + // None of them asks whether the delay was NECESSARY, because neither clippy + // nor a text scan can — so what this decides is whether an author pointed at + // something real, which is a proxy for having thought about it. Measured + // against the agent that wrote this amendment: it was satisfied in about + // thirty seconds by copying the shape of a neighbouring annotation, over a + // grace loop that was then found unnecessary and deleted outright. A gate + // that certifies a delay which should not exist is estimating, and + // non-negotiable rule 3 forbids that. + // + // What DOES stop a twelfth waiver is `delay-waivers-not-growing` in + // `batten.toml` — a ratchet over the COUNT, which is a real object with a + // real exit code and no judgement in it. This test keeps its narrower and + // honest job: the annotations that exist point at something that resolves. // // "Names a bound that resolves" is deliberately ONE span rather than all of // them: a reason names the exit condition, the interval and often the diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index fc38b5523..fc7e86e52 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -1199,6 +1199,33 @@ expression: stdout_of(&output) "data_channel": false, "flags": [], "subcommands": [ + { + "path": "land fast-forward", + "id": "land.fast-forward", + "about": "Ask this head's pull request to fast-forward, and read the answer that request got", + "effect": "write", + "data_channel": false, + "flags": [], + "subcommands": [] + }, + { + "path": "land lap", + "id": "land.lap", + "about": "Drive the whole lap and lap again on any refusal a rebase would clear", + "effect": "write", + "data_channel": false, + "flags": [ + { + "name": "reference", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The remote reference to replay onto" + } + ], + "subcommands": [] + }, { "path": "land push", "id": "land.push", @@ -1222,6 +1249,14 @@ expression: stdout_of(&output) "takes_value": true, "positional": true, "help": "The remote reference to replay onto" + }, + { + "name": "resolve", + "short": null, + "long": "resolve", + "takes_value": true, + "positional": false, + "help": "A path whose conflict is resolved in the worktree (repeatable)" } ], "subcommands": [] @@ -1409,6 +1444,24 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "lease carries", + "id": "lease.carries", + "about": "Gate: this head carries the landing mechanism trunk has, so it can be serialised", + "effect": "read", + "data_channel": false, + "flags": [ + { + "name": "head", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The head commit being judged" + } + ], + "subcommands": [] + }, { "path": "lease check", "id": "lease.check", @@ -1418,6 +1471,40 @@ expression: stdout_of(&output) "flags": [], "subcommands": [] }, + { + "path": "lease guard", + "id": "lease.guard", + "about": "The runner's step-0 guard: may this branch spend a matrix right now?", + "effect": "write", + "data_channel": false, + "flags": [ + { + "name": "branch", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The branch being asked about" + }, + { + "name": "head", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The head commit being judged" + }, + { + "name": "run", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The run to cancel on a stop" + } + ], + "subcommands": [] + }, { "path": "lease held", "id": "lease.held", @@ -2234,6 +2321,15 @@ expression: stdout_of(&output) } ], "subcommands": [] + }, + { + "path": "receipt verified", + "id": "receipt.verified", + "about": "Is HEAD verified — every declared check's receipt valid against this commit?", + "effect": "read", + "data_channel": false, + "flags": [], + "subcommands": [] } ] }, @@ -2987,6 +3083,10 @@ expression: stdout_of(&output) "id": "lease.authorises", "path": "lease authorises" }, + { + "id": "lease.carries", + "path": "lease carries" + }, { "id": "lease.check", "path": "lease check" @@ -3063,6 +3163,10 @@ expression: stdout_of(&output) "id": "receipt.status", "path": "receipt status" }, + { + "id": "receipt.verified", + "path": "receipt verified" + }, { "id": "show", "path": "show" diff --git a/crates/batten/tests/it/spawn_widening.rs b/crates/batten/tests/it/spawn_widening.rs new file mode 100644 index 000000000..f7886d22e --- /dev/null +++ b/crates/batten/tests/it/spawn_widening.rs @@ -0,0 +1,328 @@ +//! `spawn-widening` over the compiled engine (CLOUD-1338). +//! +//! # Why this tier, and what the module's own suite structurally cannot prove +//! +//! The module's `test_` cases hand themselves a `base-delta` object, so they are +//! green over a shape the engine may never build — the hazard +//! `.claude/rules/policy-modules.md` names, and the hazard this particular rule +//! walked into four separate times while it was being written: +//! +//! 1. the two `[[pattern]]` ids were undeclared, so both lookups resolved to +//! undefined and neither clause could hold; +//! 2. the row declared `sources` where the delta reader needs `delta_sources`, +//! so no base side was acquired and every run reported could-not-look; +//! 3. it then declared `delta_sources` and no `line_sources`, so the WORKING +//! side was empty and both clauses ran over nothing; +//! 4. and a comprehension over an absent `base-lines` key yields the EMPTY SET +//! rather than undefined, which made every line of every unchanged file read +//! as added — 81 of 81 engine modules refused on one run. +//! +//! Three of those four are byte-identical to a passing gate on the decision +//! surface. Every one was found by SEEDING a refusal and watching for it, never +//! by reading a clean exit — which is why the acceptance case below seeds rather +//! than asserts absence. +//! +//! # The pair that carries the rule +//! +//! `an_added_spawn_escape_is_refused` and `an_added_spawn_placement_is_refused` +//! are the two halves, and the second is the one that actually failed in the +//! field: the placement table is deny-by-omission, so widening it is a two-word +//! edit whose justification lives in a comment nothing reads. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use batten::rules::{self, Rule}; + +/// The row as `batten.toml` declares it, deserialized rather than +/// struct-literalled: `Rule` carries `deny_unknown_fields`, so this goes through +/// the same column census a consumer's config does. +/// +/// **BOTH SOURCE KEYS, because they fill different halves and the module +/// subtracts one from the other.** A row carrying only one of them is defect 2 +/// or 3 above, and both reported clean. +fn row() -> Rule { + serde_json::from_value(serde_json::json!({ + "id": "spawn-widening", + "kind": "policy", + "scope": "tree", + "base": "origin/main", + "delta_sources": ["crates/batten/src/*.rs", "policy/spawn-adapters.rego"], + "line_sources": ["crates/batten/src/*.rs", "policy/spawn-adapters.rego"], + "module": "policy/spawn-widening.rego", + "severity": "deny", + })) + .expect("the row batten.toml declares") +} + +/// What the working tree does to the base: files written, files removed. +struct Head<'a> { + written: &'a [(&'a str, &'a str)], + removed: &'a [&'a str], +} + +/// A repository whose `origin/main` carries `base`, with `head` applied on top. +fn repo(name: &str, base: &[(&str, &str)], head: &Head<'_>) -> PathBuf { + let root = common::scratch(&format!("spawn-widening-{name}")); + common::git_in(&root, &["init", "--initial-branch=main"]); + write_all(&root, base); + install_module(&root); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "-m", "base"]); + let base_sha = common::git_in(&root, &["rev-parse", "HEAD"]); + common::git_in( + &root, + &["update-ref", "refs/remotes/origin/main", &base_sha], + ); + + for path in head.removed { + fs::remove_file(root.join(path)).expect("remove at head"); + } + write_all(&root, head.written); + root +} + +fn write_all(root: &Path, files: &[(&str, &str)]) { + for (path, body) in files { + let full = root.join(path); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent).expect("scratch parent"); + } + fs::write(full, body).expect("write fixture file"); + } +} + +/// The COMMITTED module, copied in rather than restated: an inline copy would +/// drift from the shipped one and pass while the real gate was broken. +fn install_module(root: &Path) { + let source = common::at_root("policy/spawn-widening.rego") + .canonicalize() + .expect("the committed module is where the row says it is"); + fs::create_dir_all(root.join("policy")).expect("scratch policy dir"); + fs::copy(source, root.join("policy/spawn-widening.rego")).expect("install committed module"); +} + +fn verdicts(root: &Path) -> Vec { + // THE COMMITTED PATTERN TABLE, derived rather than listed — the same reason + // `install_module` copies the module in: a hand-written copy would drift + // from the shipped regex and let these cases pass over a broken one. An + // empty table makes `check_pattern_refs` refuse the load outright, so the + // whole file would go red over a module that is fine. + // + // A CONSUMER MODULE MAY READ THE REGISTRY, unlike a preset: the exemption + // `.claude/rules/policy-modules.md` records is for compiled-in presets, + // whose consumers cannot add rows on their behalf. This module ships beside + // the `batten.toml` that declares its two, so supplying them here is the + // shape a real consumer has rather than a harness inventing vocabulary. + let patterns = common::committed_patterns(); + let declared = common::verdicts_in(root); + rules::run_static( + &[row()], + &[], + batten::policy::Vocabulary { + patterns: &patterns, + verdicts: &declared, + recorders: &[], + }, + root, + ) + .expect("the read surface runs a policy row") + .findings + .into_iter() + .map(|finding| finding.rule) + .collect() +} + +const CLEAN_MODULE: &str = "fn ordinary() {}\n"; + +const ADAPTERS: &str = "package batten.spawn_adapters\n\nadapters := {\n\t\"exec\",\n}\n"; + +/// **THE CASE THE RULE EXISTS FOR, half one — and it SEEDS.** +/// +/// Asserting a clean tree stays clean would have passed over three of the four +/// defects this module shipped with. The only reading that tells a live gate +/// from a dead one is a refusal that arrives. +#[test] +fn an_added_spawn_escape_is_refused() { + let root = repo( + "escape-added", + &[ + ("crates/batten/src/thing.rs", CLEAN_MODULE), + ("policy/spawn-adapters.rego", ADAPTERS), + ], + &Head { + written: &[( + "crates/batten/src/thing.rs", + "fn ordinary() {}\n#[expect(clippy::disallowed_types)]\nfn spawner() {}\n", + )], + removed: &[], + }, + ); + assert_eq!( + verdicts(&root), + vec![String::from("spawn-widening")], + "an escape this change added is the whole subject of the rule" + ); +} + +/// **THE CASE THAT ACTUALLY FIRED IN THE FIELD, half two.** +/// +/// `spawn-adapters` refuses a spawn in an unplaced module; adding the module to +/// its set answers that refusal in one edit, and nothing read the edit. Two +/// placements landed that way on the branch this rule was written for. +#[test] +fn an_added_spawn_placement_is_refused() { + let root = repo( + "placement-added", + &[ + ("crates/batten/src/thing.rs", CLEAN_MODULE), + ("policy/spawn-adapters.rego", ADAPTERS), + ], + &Head { + written: &[( + "policy/spawn-adapters.rego", + "package batten.spawn_adapters\n\nadapters := {\n\t\"exec\",\n\t\"thing\",\n}\n", + )], + removed: &[], + }, + ); + assert_eq!( + verdicts(&root), + vec![String::from("spawn-widening")], + "the table is deny-by-omission, so widening it is the escape" + ); +} + +/// **THE ANTI-VACUITY MIRROR.** Without it every case above is satisfied by a +/// module that refuses unconditionally, which is not a gate (CLOUD-418). +/// +/// It is also the case that would have caught defect 4: an unchanged file whose +/// every line read as added refused 81 of 81 modules on one real run. +#[test] +fn an_unchanged_tree_carrying_escapes_is_clean() { + let escaped = "#[expect(clippy::disallowed_types)]\nfn spawner() {}\n"; + let root = repo( + "unchanged", + &[ + ("crates/batten/src/thing.rs", escaped), + ("policy/spawn-adapters.rego", ADAPTERS), + ], + &Head { + written: &[], + removed: &[], + }, + ); + assert!( + verdicts(&root).is_empty(), + "the inventory is what a tree HOLDS; this rule decides only whether it GREW" + ); +} + +/// Removing a placement is not widening, which is the direction the rule turns +/// on: the remedy for a refusal here is to delete the entry, and a symmetric +/// predicate would refuse the fix. +#[test] +fn removing_a_placement_is_clean() { + let root = repo( + "placement-removed", + &[ + ("crates/batten/src/thing.rs", CLEAN_MODULE), + ( + "policy/spawn-adapters.rego", + "package batten.spawn_adapters\n\nadapters := {\n\t\"exec\",\n\t\"thing\",\n}\n", + ), + ], + &Head { + written: &[("policy/spawn-adapters.rego", ADAPTERS)], + removed: &[], + }, + ); + assert!( + verdicts(&root).is_empty(), + "the fix for this rule's own refusal must not be refused by it" + ); +} + +/// The test-module idiom is exempt, and it has to be: every `mod tests` in this +/// crate opens with one, inside `crates/batten/src/**` where the path exclusion +/// cannot reach it. A rule firing on the universal case is one somebody switches +/// off. +#[test] +fn the_test_module_idiom_is_not_an_escape() { + let root = repo( + "idiom", + &[ + ("crates/batten/src/thing.rs", CLEAN_MODULE), + ("policy/spawn-adapters.rego", ADAPTERS), + ], + &Head { + written: &[( + "crates/batten/src/thing.rs", + "fn ordinary() {}\n#[cfg(test)]\n#[allow(clippy::expect_used)]\nmod tests {}\n", + )], + removed: &[], + }, + ); + assert!( + verdicts(&root).is_empty(), + "panicking loudly is how a test fails, and the whole crate says so this way" + ); +} + +/// **AND THE EXEMPTION IS THREE NAMED LINTS RATHER THAN A SHAPE.** Without this +/// the case above is satisfied by an exemption that waives every `#[allow]`, +/// which would take the rule with it. +#[test] +fn another_lints_allow_is_still_an_escape() { + let root = repo( + "other-lint", + &[ + ("crates/batten/src/thing.rs", CLEAN_MODULE), + ("policy/spawn-adapters.rego", ADAPTERS), + ], + &Head { + written: &[( + "crates/batten/src/thing.rs", + "fn ordinary() {}\n#[expect(clippy::too_many_arguments)]\nfn wide() {}\n", + )], + removed: &[], + }, + ); + assert_eq!( + verdicts(&root), + vec![String::from("spawn-widening")], + "`too_many_arguments` is a claim about the code, not about how a test reports failure" + ); +} + +/// A doc comment naming a lint is not an escape. +/// +/// The modules this rule reads discuss `clippy::disallowed_types` at length — +/// including the one it was written for — so a pattern without the leading +/// anchor would refuse every commit that explains itself. +#[test] +fn a_doc_comment_naming_a_lint_is_not_an_escape() { + let root = repo( + "prose", + &[ + ("crates/batten/src/thing.rs", CLEAN_MODULE), + ("policy/spawn-adapters.rego", ADAPTERS), + ], + &Head { + written: &[( + "crates/batten/src/thing.rs", + "fn ordinary() {}\n/// `clippy::disallowed_types` refuses a spawn here.\nfn documented() {}\n", + )], + removed: &[], + }, + ); + assert!( + verdicts(&root).is_empty(), + "explaining the lint is not escaping it" + ); +} diff --git a/crates/batten/tests/it/surface.rs b/crates/batten/tests/it/surface.rs index 9cb4bcb2f..72feb9664 100644 --- a/crates/batten/tests/it/surface.rs +++ b/crates/batten/tests/it/surface.rs @@ -227,10 +227,25 @@ fn committed_pages() -> Vec<(PathBuf, String)> { .to_owned(); // The filename is the hyphen-joined command path prefixed by the // program name; the argv the page is emitted from is the spaced form. - let command = stem - .strip_prefix("batten-") - .map(|rest| rest.replace('-', " ")) - .unwrap_or_default(); + // RESOLVED AGAINST THE DECLARATION, never guessed back out of the + // filename. The page name hyphen-joins a command PATH, and `-` is + // also legal INSIDE a segment — `land fast-forward` commits as + // `batten-land-fast-forward.1` — so `replace('-', " ")` recovers + // `land fast forward`, which is no command at all. That spelling was + // correct for as long as no verb carried a hyphen, and it fails + // closed rather than silently: the argv does not resolve, the page + // "did not render", and three cases here name it. + // + // The fallback keeps an ORPHAN reaching a failure: a committed page + // the surface never declared resolves to nothing here, renders + // nothing, and reddens — which is the direction + // `the_committed_artifacts_are_exactly_the_ones_the_surface_declares` + // owns, and this must not quietly pass it. + let command = declared_commands().get(&stem).cloned().unwrap_or_else(|| { + stem.strip_prefix("batten-") + .map(|rest| rest.replace('-', " ")) + .unwrap_or_default() + }); (path, command) }) .collect(); @@ -269,6 +284,48 @@ fn declared_pages() -> BTreeSet { pages } +/// Every declared page STEM, mapped to the argv that renders it. +/// +/// The inverse of the filename rule, taken from the declaration rather than +/// reconstructed from the name — because the rule is not invertible. Hyphen- +/// joining a command path is lossy the moment a segment contains a hyphen, and +/// two different paths can produce one filename: `land fast-forward` and a +/// hypothetical `land fast forward` both commit as `batten-land-fast-forward.1`. +/// That collision is currently unreachable — no two declared paths collide, and +/// `the_committed_artifacts_are_exactly_the_ones_the_surface_declares` compares +/// the sets — but the ambiguity is in the scheme rather than in this test. +fn declared_commands() -> std::collections::BTreeMap { + let output = batten().arg("spec").output().expect("run batten spec"); + assert_eq!(output.status.code(), Some(0), "batten spec did not emit"); + let spec: serde_json::Value = serde_json::from_slice(&output.stdout).expect("the spec is JSON"); + let program = spec["path"] + .as_str() + .expect("the spec's root carries the program name"); + let mut commands = std::collections::BTreeMap::new(); + collect_commands(&spec, program, &mut commands); + commands +} + +/// Walk every `subcommands` level, recording stem → argv. +fn collect_commands( + node: &serde_json::Value, + program: &str, + into: &mut std::collections::BTreeMap, +) { + let Some(children) = node["subcommands"].as_array() else { + return; + }; + for child in children { + if let Some(path) = child["path"].as_str() { + into.insert( + format!("{program}-{}", path.replace(' ', "-")), + path.to_owned(), + ); + } + collect_commands(child, program, into); + } +} + /// Walk every `subcommands` level, adding one page path per command path. fn collect_pages(node: &serde_json::Value, program: &str, into: &mut BTreeSet) { let Some(children) = node["subcommands"].as_array() else { diff --git a/crates/batten/tests/it/trunk_watch.rs b/crates/batten/tests/it/trunk_watch.rs new file mode 100644 index 000000000..26ca74f48 --- /dev/null +++ b/crates/batten/tests/it/trunk_watch.rs @@ -0,0 +1,251 @@ +//! The staleness poll, over the library the retirement moved it into +//! (CLOUD-1148). +//! +//! # What this tier is for +//! +//! `mise-tasks/main-watch.sh` blocked until `origin/main` advanced past a given +//! sha, polling the ref endpoint conditionally so a quiet trunk cost no rate +//! limit. `tests/main-watch.bats` pinned eight properties of that loop. Both are +//! retired here, and this file is where those eight are answered — the ledger +//! arms below name it, and `shell-retirement`'s `test port missing` arm is what +//! refuses a retirement that names no compiled tier at all. +//! +//! # What it reaches, and what it deliberately does not +//! +//! Everything the eight cases pinned is a property of the POLL'S STATE MACHINE — +//! which validator goes out next, what a `304` does and does not overwrite, when +//! a reading counts as movement, and how a server-sent floor composes with the +//! configured interval. All of that is `main_watch::Poll` plus `rest::Answer`, +//! both public and both constructible without a forge, so these cases drive the +//! real decision rather than a fixture of it. +//! +//! What they do NOT drive is the request itself. `main_watch::read` goes through +//! `rest::get`, whose seam is `$BATTEN_REST_FIXTURE` and whose caller in a lap is +//! `land::stale` — reachable only from `land lap`, which fetches from a remote +//! before it gets there. Stated rather than left implied: a reader should not +//! take this file as evidence that the endpoint path or the header spelling is +//! covered. `crates/batten/tests/it/pr_watch.rs` is where the fixture seam is +//! exercised. +//! +//! # The loop is gone on purpose, and that is the one behavioural change +//! +//! The predecessor blocked. `land::stale` asks ONCE per lap and answers `None` +//! for both *unmoved* and *could not look*. The blocking is the lap's now +//! (`land::wait`), because a module holding its own unbounded loop cannot be +//! raced without becoming a second authority over when to stop asking. The +//! disposition rows below record that against the case it changes. + +// carried: mise-tasks/main-watch.sh crates/batten/src/main_watch.rs kind:mechanism crates/batten/tests/it/trunk_watch.rs +// carried: tests/main-watch.bats crates/batten/src/main_watch.rs kind:mechanism crates/batten/tests/it/trunk_watch.rs +// +// The eight cases, one row each, keyed by the TITLE rather than by the suite — +// a row whose first field were the path would be indexed as a ninth arm for it +// and the deletion would read as `shell retire unclear`. `changed` carries the +// difference rather than hiding it; a title with no row here is a behavioural +// claim nobody decided on. +// +// carried: "main having moved exits 0 and points at both ends" crates/batten/src/main_watch.rs kind:mechanism +// changed: "main standing still blocks, because losing the race is the normal case" crates/batten/src/land.rs kind:mechanism +// carried: "the second request is conditional on the first response's ETag" crates/batten/src/main_watch.rs kind:mechanism +// carried: "a 304 is not read as a change, however many arrive" crates/batten/src/main_watch.rs kind:mechanism +// carried: "movement after a run of 304s is still caught" crates/batten/src/main_watch.rs kind:mechanism +// carried: "a server-sent poll interval is honoured as a floor" crates/batten/src/main_watch.rs kind:mechanism +// carried: "a transient gh failure costs one poll, not the landing" crates/batten/src/main_watch.rs kind:mechanism +// carried: "no base to compare against is a refusal, not a silent block" crates/batten/src/main_watch.rs kind:mechanism + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use batten::main_watch::Poll; +use batten::rest::Answer; + +const BASE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const MOVED: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +/// A `200` carrying a ref object, and optionally a validator. +fn ref_object(sha: &str, etag: Option<&str>) -> Answer { + Answer { + status: 200, + etag: etag.map(ToOwned::to_owned), + poll_floor: None, + backoff: None, + body: format!("{{\"object\":{{\"sha\":\"{sha}\"}}}}"), + } +} + +/// A `304`: no body, and the validator the server echoes back. +fn unchanged(etag: Option<&str>) -> Answer { + Answer { + status: 304, + etag: etag.map(ToOwned::to_owned), + poll_floor: None, + backoff: None, + body: String::new(), + } +} + +/// **`main` having moved is reported WITH the sha it moved to.** +/// +/// The predecessor printed both ends — `main moved -> ` — and the +/// pointer is the half that matters: three counts with no subject cannot be told +/// from another head's reading. `moved` returns the new sha rather than a bool +/// for exactly that reason, and the lap's own line prints both. +#[test] +fn movement_reports_where_the_trunk_went_and_not_merely_that_it_went() { + let mut poll = Poll::default(); + poll.absorb(Some(&ref_object(MOVED, None)), 5); + assert_eq!(poll.moved(BASE), Some(MOVED)); + + // THE MIRROR, and it is not hygiene: without it this case passes over a + // `moved` that reports every reading as movement, which is the shape that + // costs one CI run per poll. + assert_eq!( + poll.moved(MOVED), + None, + "a reading equal to the base is not movement" + ); +} + +/// **The second request carries the first response's validator.** +/// +/// This IS the economy the whole design rests on: a `304` costs no rate limit, +/// which is what makes a second poller affordable beside the CI wait at all. An +/// unconditional second request would be silently correct and silently +/// expensive. +#[test] +fn the_validator_from_one_answer_is_what_the_next_request_sends() { + let mut poll = Poll::default(); + assert_eq!(poll.etag(), None, "the first ask carries no validator"); + + poll.absorb(Some(&ref_object(BASE, Some("W/\"first\""))), 5); + assert_eq!(poll.etag(), Some("W/\"first\"")); + + // A LATER ANSWER CARRYING NONE MUST NOT CLEAR IT. Without this arm one + // validator-less response turns every request after it unconditional, and + // nothing about the poll's behaviour would look wrong. + poll.absorb(Some(&ref_object(BASE, None)), 5); + assert_eq!( + poll.etag(), + Some("W/\"first\""), + "an answer with no validator leaves the standing one alone" + ); +} + +/// **A `304` is not a change, however many arrive — and movement after a run of +/// them is still caught.** +/// +/// Two of the predecessor's cases, in one body because the second is the first's +/// anti-vacuity half: a poll that simply ignored every answer would satisfy the +/// `304` arm perfectly and never report anything at all. +#[test] +fn a_run_of_unchanged_answers_hides_nothing_from_the_reading_that_follows() { + let mut poll = Poll::default(); + poll.absorb(Some(&ref_object(BASE, Some("W/\"one\""))), 5); + assert_eq!(poll.moved(BASE), None); + + for _ in 0..5 { + poll.absorb(Some(&unchanged(Some("W/\"one\""))), 5); + assert_eq!( + poll.moved(BASE), + None, + "a 304 leaves the last reading standing rather than clearing it" + ); + } + assert_eq!(poll.polls(), 6, "every answer is counted, 304s included"); + + poll.absorb(Some(&ref_object(MOVED, Some("W/\"two\""))), 5); + assert_eq!( + poll.moved(BASE), + Some(MOVED), + "movement after a run of 304s is still caught" + ); +} + +/// **A server-sent poll interval is honoured as a FLOOR, never as the pace.** +/// +/// `f64` throughout, which is CLOUD-390's defect: the predecessor compared with +/// `-gt`, so a fractional value read as *the server asked for no floor* — +/// byte-identical to an absent header, and silently faster than the endpoint +/// allows. +#[test] +fn a_server_floor_raises_the_interval_and_never_lowers_it() { + let mut poll = Poll::default(); + + let mut asked = ref_object(BASE, None); + asked.poll_floor = Some(11.0); + assert!( + poll.absorb(Some(&asked), 5) >= 11.0, + "a floor above the configured interval is honoured" + ); + + // AND THE OTHER DIRECTION, which is what makes it a floor rather than an + // override: an endpoint asking to be polled FASTER than configured does not + // get to set the pace. + let mut lower = ref_object(BASE, None); + lower.poll_floor = Some(1.0); + assert!( + poll.absorb(Some(&lower), 30) >= 30.0, + "a floor below the configured interval leaves it alone" + ); + + // A FRACTIONAL FLOOR IS A FLOOR. This is the exact reading the integer + // comparison dropped, so it is asserted rather than assumed. + let mut fractional = ref_object(BASE, None); + fractional.poll_floor = Some(0.5); + let paced = poll.absorb(Some(&fractional), 0); + assert!( + paced >= 0.5, + "a sub-second floor is still read as one, got {paced}" + ); +} + +/// **A read that did not answer costs one poll, not the landing.** +/// +/// `None` is could-not-look. It is FOLDED rather than skipped — the count +/// advances and the previous reading stands — because dropping it would let an +/// unreachable forge make a bounded loop unbounded, and treating it as movement +/// would decide about the network rather than about the work. +#[test] +fn a_read_that_did_not_answer_advances_the_count_and_changes_no_reading() { + let mut poll = Poll::default(); + poll.absorb(Some(&ref_object(BASE, Some("W/\"held\""))), 5); + + poll.absorb(None, 5); + assert_eq!(poll.polls(), 2, "a failed read is still a poll"); + assert_eq!( + poll.etag(), + Some("W/\"held\""), + "a failed read does not discard the validator" + ); + assert_eq!( + poll.moved(BASE), + None, + "a forge that did not answer is not the trunk moving" + ); + assert_eq!(poll.head(), Some(BASE), "the previous reading still stands"); +} + +/// **An empty base is not a base**, and this is the arm the first port dropped. +/// +/// `main-watch.bats` refuses one outright — *"no base to compare against is a +/// refusal, not a silent block"* — because an empty base compares unequal to +/// every sha, so the first poll reports movement and the lap laps forever. +/// `None` here is that refusal in the shape this type has: there is nothing to +/// have moved FROM, so nothing has moved. +/// +/// Found by reading the predecessor's titles rather than by any gate, which is +/// the whole reason the dispositions above exist as rows. +#[test] +fn an_empty_base_is_not_movement_however_the_reading_came_back() { + let mut poll = Poll::default(); + poll.absorb(Some(&ref_object(MOVED, None)), 5); + assert_eq!( + poll.moved(""), + None, + "an empty base compares unequal to every sha and must not read as movement" + ); + + // And with no reading at all, which is the state a first lap is in. + assert_eq!(Poll::default().moved(""), None); + assert_eq!(Poll::default().moved(BASE), None); +} diff --git a/install.sh b/install.sh index 3e0c2531c..4ca08e7b0 100755 --- a/install.sh +++ b/install.sh @@ -105,6 +105,11 @@ usage() { Environment: BATTEN_VERSION tag to install (e.g. v0.0.61); default: latest + BATTEN_VERSION_FROM_REF + read the version from this ref's Cargo.toml and + install that tag, falling back to the latest + release when the tag has none. Ignored when + BATTEN_VERSION is set. BATTEN_TARGET override target detection BATTEN_INSTALL_DIR destination; default \${XDG_BIN_HOME:-\$HOME/.local/bin} BATTEN_GITHUB_TOKEN token for the release API (also GH_TOKEN, @@ -543,29 +548,73 @@ main() { # An unreadable release is "could not look" (2), never "this release is # broken" (1): a network blip and an unauthorized token are both environment, # and reporting them as a bad release points the reader at the wrong thing. - # BOTH RESOLVERS GUARD THEIR OWN BODIES, because calling one as a condition - # SUSPENDS `set -e` inside it — and so does `f || x=$?`, which is why the - # first attempt at this fix was no fix. A local fault (`flatten` unable to - # write, `awk` missing) would otherwise stop aborting and fall through to - # the "release carries no asset" refusal: exit 1 blaming the release for a - # problem on this machine, where 2 (could not look) is the honest answer. - # The remedy is `|| die 2` on every internal command, never a call shape. + # THE PIN MAY COME FROM A REF, AND THAT IS THE CI GUARD'S WHOLE PROPERTY + # (CLOUD-420). `batten lease guard` runs as the FIRST step of every + # `pull_request` job, before any checkout — so the version it runs must be + # decided by TRUNK and not by the head's own workflow file, which is what the + # fetched-script design it replaces protected by reading its logic from trunk. + # `BATTEN_VERSION_FROM_REF` names that ref; an explicit `BATTEN_VERSION` still + # wins, because a caller naming a version means it. + from_ref="" + if [ -z "${BATTEN_VERSION:-}" ] && [ -n "${BATTEN_VERSION_FROM_REF:-}" ]; then + if api_get "$API/repos/$REPO/contents/Cargo.toml?ref=${BATTEN_VERSION_FROM_REF}" \ + "application/vnd.github.raw" "$tmp/manifest.toml"; then + # The workspace root's `version`, first match: the anchored form cannot + # pick up a dependency's version, which is never at column 0. + pinned=$(sed -n 's/^version = "\(.*\)"$/\1/p' "$tmp/manifest.toml" | head -n 1) + [ -z "$pinned" ] || from_ref="v$pinned" + [ -z "$from_ref" ] || BATTEN_VERSION="$from_ref" + fi + [ -n "${BATTEN_VERSION:-}" ] || + echo "install.sh: could not read a version from ${BATTEN_VERSION_FROM_REF}; using the latest release" >&2 + fi + + # THE REF-DERIVED PIN MAY NAME A TAG THAT DOES NOT EXIST YET, and one retry + # with it cleared is what keeps the CI guard running a batten at all. + # release-plz bumps the manifest BEFORE publishing the tag, so trunk's version + # routinely names an unreleased release — and the step-0 guard swallows a + # failure by design, so a hard stop here would be silent. An explicitly named + # `BATTEN_VERSION` never falls back: a caller who named a version wants that + # version or an error, which is why only `from_ref` reaches this arm. if ! resolve_via_api; then - [ -n "$WEB_FALLBACK" ] || - die 2 "cannot read the release list from $REPO at $API. If you are being rate-limited, set BATTEN_GITHUB_TOKEN, GH_TOKEN or GITHUB_TOKEN." - resolve_via_web || case $? in - 3) - # Both absences reach here and the message names neither - # specifically, because the remedy is one: no `SHA256SUMS` at all, - # and a `SHA256SUMS` with no row for this asset, are the same - # statement about the release — it does not publish the digest this - # script refuses to install without. - die 1 "release $tag publishes no sha256 for $asset, and this script does not install unverified bytes. Re-run release-artifacts.yml against that tag; uploads are idempotent." - ;; - *) - die 2 "cannot read release metadata for $REPO from either $API or $WEB. If the API is rate-limiting this address, set BATTEN_GITHUB_TOKEN, GH_TOKEN or GITHUB_TOKEN; if the repository is private, a token is required." - ;; - esac + # THE REF-DERIVED PIN MAY NAME A TAG THAT DOES NOT EXIST YET, and one + # retry with it cleared is what keeps the CI guard running a batten at + # all. release-plz bumps the manifest BEFORE publishing the tag, so + # trunk's version routinely names an unreleased release — and the step-0 + # guard swallows a failure by design, so a hard stop here would be + # silent. An explicitly named `BATTEN_VERSION` never falls back: a caller + # who named a version wants that version or an error, which is why only a + # `from_ref` pin reaches this arm. + # + # Cleared by assignment rather than by an env prefix on the call: for a + # shell FUNCTION that prefix persists afterwards in POSIX sh, so the two + # spellings differ in what they leave behind and only this one says which + # it means. `resolve_via_web` reads the same variable, so clearing it is + # also what makes the fallback below ask for the latest. + retried="" + if [ -n "$from_ref" ]; then + echo "install.sh: ${from_ref} has no published release yet; using the latest instead" >&2 + BATTEN_VERSION="" + from_ref="" + ! resolve_via_api || retried=yes + fi + if [ -z "$retried" ]; then + [ -n "$WEB_FALLBACK" ] || + die 2 "cannot read the release list from $REPO at $API. If you are being rate-limited, set BATTEN_GITHUB_TOKEN, GH_TOKEN or GITHUB_TOKEN." + resolve_via_web || case $? in + 3) + # Both absences reach here and the message names neither + # specifically, because the remedy is one: no `SHA256SUMS` at all, + # and a `SHA256SUMS` with no row for this asset, are the same + # statement about the release — it does not publish the digest this + # script refuses to install without. + die 1 "release $tag publishes no sha256 for $asset, and this script does not install unverified bytes. Re-run release-artifacts.yml against that tag; uploads are idempotent." + ;; + *) + die 2 "cannot read release metadata for $REPO from either $API or $WEB. If the API is rate-limiting this address, set BATTEN_GITHUB_TOKEN, GH_TOKEN or GITHUB_TOKEN; if the repository is private, a token is required." + ;; + esac + fi fi api_get "$asset_url" "application/octet-stream" "$tmp/$asset" "$asset_anon" || @@ -613,6 +662,40 @@ main() { fi ;; esac + + # A BINARY WITHOUT THE VERB THE CALLER NEEDS IS THE SILENT-ABSENCE CASE AGAIN. + # + # The paragraph above refuses an unreachable binary because absent and + # unreachable produce identical quiet results. An installed binary that + # RESOLVES and does not carry the asked-for verb is the third member of that + # family, and the fallback above is what produces it: `BATTEN_VERSION_FROM_REF` + # names trunk's manifest version, release-plz bumps that BEFORE the tag is + # published, so a routine window resolves to `latest` instead — and `latest` + # is by definition older than the verb that has not shipped yet. + # + # Measured shape rather than hypothetical: the CI lease precondition installs + # from `main` and then runs `batten lease guard`, with `|| exit 0` on every + # line so it can never red the job. A binary predating that verb therefore + # fails the invocation, takes the tolerance, and the whole fleet runs + # unguarded with nothing in the log naming why. The guard's absence has to be + # attributable at the step that caused it. + # + # `BATTEN_REQUIRE` is ONE verb path — `lease guard`, not a list — and it is + # checked through `--help` so nothing runs for its effect. Unset asks nothing, + # which keeps every existing caller unchanged. + # + # A LIST WAS THE FIRST DRAFT AND IT WAS WRONG. Iterating `for verb in + # $BATTEN_REQUIRE` splits on every space, so `lease guard` became two + # iterations — `batten lease --help` passes and `batten guard --help` does + # not, and the check refused a binary that carried exactly what was asked for. + # The value is one argv, and the word splitting below is what makes it one. + if [ -n "${BATTEN_REQUIRE:-}" ]; then + # shellcheck disable=SC2086 # deliberate: the value IS an argv, and + # quoting it would ask for a single verb literally named "lease guard". + if ! "$dest/$BIN" ${BATTEN_REQUIRE} --help >/dev/null 2>&1; then + die 1 "installed $tag, which does not carry \`$BIN $BATTEN_REQUIRE\`. BATTEN_VERSION_FROM_REF falls back to the latest published release when the named ref's version has no tag yet, so this is what that fallback looks like when the caller needs a verb newer than the last release. Publish the pending version, or pin BATTEN_VERSION to one that carries it." + fi + fi } main "$@" diff --git a/man/batten-land-fast-forward.1 b/man/batten-land-fast-forward.1 new file mode 100644 index 000000000..4251db828 --- /dev/null +++ b/man/batten-land-fast-forward.1 @@ -0,0 +1,13 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-land-fast-forward 1 batten +.SH NAME +batten\-land\-fast\-forward \- Ask this head\*(Aqs pull request to fast\-forward, and read the answer that request got +.SH SYNOPSIS +\fBbatten land fast\-forward\fR [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Ask this head\*(Aqs pull request to fast\-forward, and read the answer that request got +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-land-lap.1 b/man/batten-land-lap.1 new file mode 100644 index 000000000..cb8c1bd5f --- /dev/null +++ b/man/batten-land-lap.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-land-lap 1 batten +.SH NAME +batten\-land\-lap \- Drive the whole lap and lap again on any refusal a rebase would clear +.SH SYNOPSIS +\fBbatten land lap\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIreference\fR> +.SH DESCRIPTION +Drive the whole lap and lap again on any refusal a rebase would clear +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIreference\fR> +The remote reference to replay onto diff --git a/man/batten-land-replay.1 b/man/batten-land-replay.1 index 4c5dd631b..6488193e2 100644 --- a/man/batten-land-replay.1 +++ b/man/batten-land-replay.1 @@ -4,11 +4,14 @@ .SH NAME batten\-land\-replay \- Advance the base and replay this branch onto it, recording the outcome .SH SYNOPSIS -\fBbatten land replay\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIreference\fR> +\fBbatten land replay\fR [\fB\-\-resolve\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIreference\fR> .SH DESCRIPTION Advance the base and replay this branch onto it, recording the outcome .SH OPTIONS .TP +\fB\-\-resolve\fR +A path whose conflict is resolved in the worktree (repeatable) +.TP \fB\-h\fR, \fB\-\-help\fR Print help .TP diff --git a/man/batten-land.1 b/man/batten-land.1 index b0e3f8823..797c75758 100644 --- a/man/batten-land.1 +++ b/man/batten-land.1 @@ -25,5 +25,11 @@ Push this branch to its own ref, under receive\-pack\*(Aqs compare\-and\-swap batten\-land\-verify(1) Run the configured gate over this head and record what it answered .TP +batten\-land\-fast\-forward(1) +Ask this head\*(Aqs pull request to fast\-forward, and read the answer that request got +.TP +batten\-land\-lap(1) +Drive the whole lap and lap again on any refusal a rebase would clear +.TP batten\-land\-help(1) Print this message or the help of the given subcommand(s) diff --git a/man/batten-lease-carries.1 b/man/batten-lease-carries.1 new file mode 100644 index 000000000..2e2153ed1 --- /dev/null +++ b/man/batten-lease-carries.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-lease-carries 1 batten +.SH NAME +batten\-lease\-carries \- Gate: this head carries the landing mechanism trunk has, so it can be serialised +.SH SYNOPSIS +\fBbatten lease carries\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIhead\fR> +.SH DESCRIPTION +Gate: this head carries the landing mechanism trunk has, so it can be serialised +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIhead\fR> +The head commit being judged diff --git a/man/batten-lease-guard.1 b/man/batten-lease-guard.1 new file mode 100644 index 000000000..541c1602d --- /dev/null +++ b/man/batten-lease-guard.1 @@ -0,0 +1,22 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-lease-guard 1 batten +.SH NAME +batten\-lease\-guard \- The runner\*(Aqs step\-0 guard: may this branch spend a matrix right now? +.SH SYNOPSIS +\fBbatten lease guard\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIhead\fR> <\fIbranch\fR> <\fIrun\fR> +.SH DESCRIPTION +The runner\*(Aqs step\-0 guard: may this branch spend a matrix right now? +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIhead\fR> +The head commit being judged +.TP +<\fIbranch\fR> +The branch being asked about +.TP +<\fIrun\fR> +The run to cancel on a stop diff --git a/man/batten-lease.1 b/man/batten-lease.1 index ec1d23795..6d932f733 100644 --- a/man/batten-lease.1 +++ b/man/batten-lease.1 @@ -16,6 +16,12 @@ Print help batten\-lease\-authorises(1) May this branch spend a matrix right now? .TP +batten\-lease\-carries(1) +Gate: this head carries the landing mechanism trunk has, so it can be serialised +.TP +batten\-lease\-guard(1) +The runner\*(Aqs step\-0 guard: may this branch spend a matrix right now? +.TP batten\-lease\-check(1) Gate: the lease is free or a live, well\-formed hold — never a wedge and never garbage .TP diff --git a/man/batten-receipt-verified.1 b/man/batten-receipt-verified.1 new file mode 100644 index 000000000..8ac77ff64 --- /dev/null +++ b/man/batten-receipt-verified.1 @@ -0,0 +1,13 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-receipt-verified 1 batten +.SH NAME +batten\-receipt\-verified \- Is HEAD verified — every declared check\*(Aqs receipt valid against this commit? +.SH SYNOPSIS +\fBbatten receipt verified\fR [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Is HEAD verified — every declared check\*(Aqs receipt valid against this commit? +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-receipt.1 b/man/batten-receipt.1 index 10500ac3c..5941dd699 100644 --- a/man/batten-receipt.1 +++ b/man/batten-receipt.1 @@ -19,5 +19,8 @@ Record that the named check concluded pass against the current HEAD batten\-receipt\-status(1) Judge the named check\*(Aqs recorded receipt against HEAD and origin/main .TP +batten\-receipt\-verified(1) +Is HEAD verified — every declared check\*(Aqs receipt valid against this commit? +.TP batten\-receipt\-help(1) Print this message or the help of the given subcommand(s) diff --git a/mise-tasks/abandon-matrix.sh b/mise-tasks/abandon-matrix.sh deleted file mode 100755 index e93f13a58..000000000 --- a/mise-tasks/abandon-matrix.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash -#MISE description="A required check went red: cancel the runs still spending on this SHA for a verdict nobody will read" -# -# Usage: mise run abandon-matrix [reason] -# -# CLOUD-900. A PR here buys eighteen required checks across five `pull_request` -# workflows, and every one of those files supersedes ONLY ITS OWN runs — each -# carries its own `concurrency` group, and there is no cross-workflow signal at -# all. So the first red check stops nothing, and up to seventeen siblings run to -# completion for a verdict nobody will read. -# -# The two most expensive are exactly the ones no cheap early check can reach: -# `windows` is 50.6% of the CI bill at ~11 minutes (CLOUD-813) and lives in -# `rust.yml`, while the bats suite is the pole of `ci` (CLOUD-386) in `ci.yml`. -# The worst shape is the cheapest failure — `commit-lint` renders a verdict in -# ~90 seconds and the rest of the matrix then bills out in full behind it. -# -# THIS TASK DOES NOT DECIDE THAT THE BRANCH IS RED, and that is the whole reason -# it is this small. `checks-green` is the one definition of "is this SHA green" -# (CLOUD-327, CLOUD-346, CLOUD-391), `ci-wait` polls around it, and `land` calls -# this only from the arm it has already reached by that verdict — past the lease -# test and past the provisioning-transient test, where the red is known to be an -# answer about the tree. Re-deriving membership from `$CI_REQUIRED_CHECKS` here -# would be a SECOND AUTHORITY for one fact, which is the CLOUD-351 shape and the -# mistake this repository keeps paying for. -# -# WHY NOT A STEP IN THE FAILING JOB, which is the obvious placement and was the -# first design. Three costs, none of them worth the seconds it would save: -# every required job in five workflows would carry the step and its matrix-name -# plumbing; `ci-local-parity` property 3 would refuse a workflow running a task -# `verify` does not, so the rule would ship with an exemption carved for it; and -# a job that dies IN PROVISIONING would abandon its siblings, which is precisely -# the case `absorbed_transient` re-runs — `gh run rerun --failed` restores the -# failed jobs of a run, and nothing restores a sibling run that was cancelled. -# Calling from `land` gets the same saving from one call site that already holds -# the credential, the SHA and the verdict. -# -# THE RUN CARRYING THE FAN-IN IS NEVER CANCELLED. `final` is the single context -# `protect-main` requires (`batten.toml`'s `[ci].required_checks`), it is -# `always()` over a `needs:` assertion (CLOUD-351), and `land` cannot land -# without it. Cancelling its run leaves that context `cancelled`, which is not an -# answer (CLOUD-363) — so the saving would buy a branch that can never grade and -# never land. `$CI_FANIN_WORKFLOW` names the file it lives in, declared once in -# mise.toml [env] beside the roster and checked against the tree by -# `ci-local-parity` property 17 rather than trusted. -# -# The cost of that exclusion, stated rather than buried: a red `windows` does not -# stop `ci`, because `ci` shares a run with the fan-in. The saving is asymmetric -# by construction — anything red kills `windows`, and only the fan-in's own file -# is ever spared — and that is the trade the wedge is worth. -# -# THIS IS NOT "CANCELLING SOMEONE ELSE'S RUNS". `cancel_own_run` in this same -# file already cancels every run on a head SHA, and its header makes the argument -# this borrows: a head SHA is one no other branch has, so the blast radius is one -# push's worth of runs BY CONSTRUCTION rather than by filtering. CLOUD-240's -# "supersede your own runs, never someone else's" is about another REF's runs, -# and no other ref is reachable from here. -# -# BEST-EFFORT THROUGHOUT, AND NEVER A VERDICT. A cancellation that is refused -# costs the minutes it would have saved and changes no conclusion, so nothing -# here is guarded into a stop and every path exits 0. The caller is on its way to -# a `die` that names the real failure; a cleanup step that could not reach the -# API must not replace that diagnosable message with a confusing one. -# -# Output is pointer-only per non-negotiable rule 4: a run id and a workflow path, -# never a log line from the run being stopped. -set -uo pipefail - -say() { echo "abandon-matrix: $*"; } - -# Exit 0 on every early return. See the best-effort note in the header. -give_up() { - say "$* — nothing cancelled" - exit 0 -} - -sha="${1:-${ABANDON_SHA:-${SHA:-}}}" -[[ -n "$sha" ]] || sha=$(git rev-parse HEAD 2>/dev/null || true) -[[ -n "$sha" ]] || give_up "no SHA to abandon: no argument, no \$ABANDON_SHA, no git HEAD" - -# Free text, for the pointer only. It is never parsed and never decides anything -# — the decision was `checks-green`'s, upstream of this call. -reason="${2:-a required check went red}" - -fanin_workflow="${CI_FANIN_WORKFLOW:-}" -[[ -n "$fanin_workflow" ]] || - give_up "CI_FANIN_WORKFLOW is unset — run this through \`mise run abandon-matrix\`, which is where the fan-in is declared. Without it this cannot tell which run carries the fan-in, and cancelling that one wedges the branch" - -repo="${REPO:-${GH_REPO:-}}" -[[ -n "$repo" ]] || repo='{owner}/{repo}' - -# `status != "completed"` is the whole filter: a run that has already finished -# bills nothing further, so asking to cancel it is an API call that buys nothing. -# `per_page=100` because a head SHA carries a handful of runs, not a page -# boundary's worth. -runs=$(gh api "repos/$repo/actions/runs?head_sha=$sha&per_page=100" \ - --jq '.workflow_runs[]? | select(.status != "completed") | "\(.id)\t\(.path)"' 2>/dev/null) || - give_up "could not list the runs on ${sha:0:8}" - -[[ -n "${runs//[[:space:]]/}" ]] || give_up "nothing still in flight on ${sha:0:8}" - -cancelled=0 -spared=0 -while IFS=$'\t' read -r id path; do - [[ -n "$id" ]] || continue - - if [[ "$path" = "$fanin_workflow" ]]; then - spared=$((spared + 1)) - say "sparing run $id — $path carries the fan-in, and an ungraded fan-in wedges the landing" - continue - fi - - if gh api -X POST "repos/$repo/actions/runs/$id/cancel" >/dev/null 2>&1; then - cancelled=$((cancelled + 1)) - say "cancelled run $id ($path) on ${sha:0:8} — $reason" - else - # Not a stop, and not silent either: a refused cancellation is exactly - # the minutes this task exists to save, so it is worth a pointer even - # though it changes no verdict. - say "cancellation refused for run $id ($path) on ${sha:0:8} — it bills out" - fi -done <<<"$runs" - -say "$cancelled run(s) cancelled, $spared spared on ${sha:0:8}" diff --git a/mise-tasks/ci-lease-precondition.sh b/mise-tasks/ci-lease-precondition.sh deleted file mode 100755 index cfc3d18f0..000000000 --- a/mise-tasks/ci-lease-precondition.sh +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env bash -#MISE description="CI-side landing-lease precondition: may this head spend a matrix?" -#MISE hide=true -# -# MUTATION COVERAGE (CLOUD-418). `||`: applying -# the script to a throwaway copy of this file must turn the named case RED. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT workspace-guard-misattributed|s/cannot build the lease workspace under/land-lock answered ?/|CLOUD-420: a broken workspace is not reported as land-lock's answer - -set -uo pipefail - -# THE LEASE, ENFORCED WHERE THE MONEY IS SPENT (CLOUD-420). -# -# CLOUD-393's rolling lease serialises landing, but it is enforced entirely -# inside `mise-tasks/land.sh`: anything else that pushes to a ready PR buys a full -# matrix without ever touching the lock. Measured 2026-08-12 05:17–05:19Z, four -# concurrent `pull_request` matrices ran while the lease changed hands three -# times — every session holding the lease honoured it, and all four ran anyway. -# The dominant case is residue rather than defiance: `land` re-drafts only on -# red, so a landing interrupted any other way leaves the PR ready forever. -# -# This is the runner's half. It runs as the FIRST step of every `pull_request` -# job that has no `needs:`, before any checkout or toolchain install, and asks -# one question: may this branch spend a matrix right now? If not, it cancels the -# run it is standing in. -# -# READ FROM `main`, NEVER FROM THE HEAD BEING JUDGED. The workflow step fetches -# this file (and `mise-tasks/land-lock.sh` below) from trunk, so a stale clone -# cannot dodge the predicate by carrying stale rules of its own. That is also -# why the logic lives here rather than in seven copies of workflow YAML: one -# authority, gated by `shellcheck`/`shfmt`/`awk-regex-check` like every other -# task, and testable by bats with `gh` and `git` stubbed — none of which a body -# embedded in YAML would get. -# -# FAIL OPEN AT EVERY UNKNOWN. Every other refusal in this repository fails -# closed; this one deliberately does not, for the same asymmetry `land-lock -# authorises` documents: a lease this cannot read would stop every job in the -# fleet, where waving one matrix through costs one matrix. An unreadable script, -# an unreachable remote, an unparseable answer — all run. -# -# AND IT NEVER EXITS NON-ZERO. A job that reds before its cancellation lands -# makes the RUN's conclusion `failure` rather than `cancelled`; `final` then runs -# under `!cancelled()`, fails its `needs:` assertion, and `land` re-drafts the PR -# — which is the fleet-wide re-drafting this whole design exists to avoid, -# reintroduced by its own remedy. Stopping is a cancel plus a bounded wait to be -# killed, never a failure. - -repo="${GH_REPO:-${GITHUB_REPOSITORY:-}}" -head_ref="${LEASE_HEAD_REF:-${GITHUB_HEAD_REF:-}}" -run_id="${LEASE_RUN_ID:-${GITHUB_RUN_ID:-}}" -# DELIBERATELY NOT `GITHUB_SHA`. On a `pull_request` event that is the MERGE -# commit, which carries trunk's `mise-tasks/land.sh` whenever the head did not touch -# it — so the staleness row below would pass for every stale head, silently, and -# look implemented. The workflow passes `github.event.pull_request.head.sha`; if -# it is missing, the row is skipped out loud rather than answered wrongly. -head_sha="${LEASE_HEAD_SHA:-}" -server="${GITHUB_SERVER_URL:-https://github.com}" -# The ref this predicate and its dependencies are read from. Trunk, and -# overridable only so the suite can point it at a fixture. -from="${LEASE_FROM_REF:-main}" -# How long to wait to be killed after asking. GitHub cancels in seconds; this is -# the backstop on a cancel that never arrives, and it is deliberately inside one -# job-minute so a failed cancel costs the rounding rather than five minutes. -cancel_wait="${LEASE_CANCEL_WAIT:-45}" - -# Pointer-only, and prefixed so a stopped run is greppable in a log full of -# runner noise. -say() { echo "lease-precondition: $*"; } - -run_anyway() { - say "$1; running rather than stopping the fleet" - exit 0 -} - -# One read of a file from trunk. `Accept: raw` rather than the default JSON so -# nothing here has to base64-decode, which is one fewer thing to get wrong on a -# path that must fail open. -from_ref() { - gh api -H "Accept: application/vnd.github.raw" \ - "repos/$repo/contents/$1?ref=$2" 2>/dev/null -} - -[[ -n "$repo" ]] || run_anyway "no repository in the environment" -[[ -n "$head_ref" ]] || run_anyway "no head ref in the environment" - -# THE BRANCHES THAT LAND WITHOUT `land`, AND WHY STOPPING THEM WOULD COST MORE -# THAN LETTING THEM RUN. -# -# `renovate/*` and `release-plz-*` are landed by `auto-bot-land.yml` / -# `auto-release-land.yml`, which fire on `workflow_run: types: [completed]` and -# then re-check that the required set is green on the sha. A CANCELLED run is -# `completed`, so those workflows do fire — find the checks not green, and stop. -# Nothing retries. The head then waits for its next push to mint a fresh run, -# which is another whole matrix: cancelling here would not save a matrix, it would -# DEFER one and add a stall. That is the ground this carve-out rests on, and it is -# unchanged. -# -# THE FIRST NAME MOVED FROM `dependabot/*` TO `renovate/*` (CLOUD-660, CLOUD-692), -# and the move is load-bearing rather than cosmetic. Dependabot is retired — its -# config and its lander are deleted, and its security lane is off at the -# repository setting — so a `dependabot/*` arm now authorises nothing that exists. -# `renovate/*` is what took its place, and WITHOUT this arm the new lane cannot -# work at all: `auto-bot-land.yml` readies a bot draft, that ready mints the one -# matrix the whole economy is built around, and no agent holds the lease on the -# bot's behalf. The gate would refuse the run it exists to let through. -# -# WHAT IS NO LONGER TRUE IS "BOT PRs ARE RARE" (CLOUD-596). This paragraph used to -# claim the overlap was small on that basis. Measured over `dependabot/*` since -# 2026-08-06: 13 `ci.yml` runs and not one skip, five of them on a single cargo -# group in seventeen hours as the bot re-proposed the batch on every base move. -# What makes the claim approximately true again is not rarity but the draft: -# `draftPR: true` means a Renovate head grades nothing until the lander readies -# it, so the lane's overlap with an agent's lease is one matrix per PR LANDED -# rather than one per SHA proposed. The release PR is a draft until its debounce -# readies it, and that debounce fires on 30 minutes of quiet `main`, which is -# precisely when nobody holds the lease. -# -# This is NOT the stale-tooling exemption in disguise: these branches are cut from -# current `main`, so their `mise-tasks/land.sh` is current; they simply never call it. -case "$head_ref" in -renovate/* | release-plz-*) - say "$head_ref lands through /fast-forward rather than through mise run land; not judging it" - exit 0 - ;; -# Every other branch IS judged, which is this gate's default and the reason the -# exemption above is a short named list rather than a pattern. -*) ;; -esac - -# --------------------------------------------------------------------------- -# THE STALENESS ROW, WHICH THE LEASE TABLE CANNOT COVER. -# -# An agent running tooling that predates the lease TAKES no lease, so the ref -# reads absent and the table below authorises it. The lease's state is evidence -# of a free queue only among participants that can see the queue; for everyone -# else it is evidence of nothing — which is exactly how four matrices ran beside -# three orderly handoffs. -# -# So: does THIS head's own `land` acquire the lease? Read content-wise, from one -# API call, rather than by ancestry — `merge-base --is-ancestor` needs a deep -# fetch and answers wrongly after a rebase or a cherry-pick, both of which are -# the normal shape of work here. -stale_remedy() { - # An ANNOTATION, not a log line, and the column matters: the runner only reads - # a workflow command when the line begins with `::` after trimming leading - # whitespace, so routing this through `say` (which prefixes - # `lease-precondition: `) would emit the token and have it ignored. Verified - # against actions/runner's ActionCommand.TryParseV2, which is line-anchored. - # - # It has to be an annotation because a stopped run is a CANCELLED run with a - # red `final` and no failed step of its own — so without this the reader sees - # a red check, no annotation, and no clue that the remedy is one rebase. - echo "::error::lease-precondition: this head's mise-tasks/land.sh does not take the landing lease, so it cannot be serialised against the fleet. Rebase onto current main and land with it: git fetch origin main && git rebase origin/main && mise run land" -} - -if [[ -n "$head_sha" ]]; then - if head_land=$(from_ref mise-tasks/land.sh "$head_sha"); then - if ! grep -q 'land-lock acquire' <<<"$head_land"; then - stale_remedy - stop=1 - fi - else - say "cannot read this head's mise-tasks/land.sh; not judging its age" - fi -else - say "no head sha in the environment; not judging this head's age" -fi - -# --------------------------------------------------------------------------- -# THE LEASE TABLE, answered by the one authority that owns it. `land-lock` is -# fetched rather than reimplemented: a second reader of the lease body is a -# second thing to keep in step with `mint`, and the nonce/expiry parsing is -# precisely where a divergence would be invisible. -if [[ -z "${stop:-}" ]]; then - work="${RUNNER_TEMP:-/tmp}/batten-lease.$$" - if ! lock_body=$(from_ref mise-tasks/land-lock.sh "$from"); then - run_anyway "cannot read mise-tasks/land-lock.sh from $from" - fi - # THE WORKSPACE IS A GUARDED STEP, NOT AN ASSUMED ONE (CLOUD-420). None of the - # setup below was checked, and with `set -e` deliberately off a failure fell - # through to the `cd` at the bottom — reaching fail-open by ACCIDENT of the - # `*)` arm, which then blamed `land-lock` for an answer it was never asked for. - # Guarding it here keeps the same verdict (run) and stops misattributing the - # cause, which is the line a human reads when the fleet misbehaves. - if ! mkdir -p "$work" || - ! printf '%s\n' "$lock_body" >"$work/land-lock" || - ! chmod +x "$work/land-lock"; then - run_anyway "cannot build the lease workspace under ${RUNNER_TEMP:-/tmp}" - fi - - # `land-lock` reads the lease with `git ls-remote "$remote"`, and this - # repository is private — so the precondition must carry its own credential - # rather than borrow a checkout's, several of which set - # `persist-credentials: false`. A throwaway repo in RUNNER_TEMP is what lets - # this run as the genuine FIRST step, before any checkout exists. - # - # Header rather than a userinfo URL: `land-lock` prints `$remote` when it - # cannot reach it, and a token in that string is a token in the log. - if ! git init -q "$work/clone" || - ! git -C "$work/clone" remote add origin "$server/$repo"; then - run_anyway "cannot build the lease workspace clone under ${RUNNER_TEMP:-/tmp}" - fi - if [[ -n "${GH_TOKEN:-}" ]]; then - # The token never reaches the log on any path, including this one — the - # message names the config that failed, never the value it carried. - if ! auth=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n') || - ! git -C "$work/clone" config --local \ - "http.$server/.extraheader" "AUTHORIZATION: basic $auth"; then - run_anyway "cannot configure the lease workspace credential" - fi - fi - - rc=0 - (cd "$work/clone" && "$work/land-lock" authorises "$head_ref") || rc=$? - case "$rc" in - 0) exit 0 ;; - 3) stop=1 ;; - # `authorises` fails open by contract, so anything else here is this script - # holding it wrong — a missing argument, a script that would not execute. - # Same posture: one matrix beats a stopped fleet. - *) run_anyway "land-lock answered $rc, which is neither run nor stop" ;; - esac -fi - -# --------------------------------------------------------------------------- -# STOP. Not by failing — see the header — but by cancelling the run this job is -# standing in, and then waiting to be killed. -[[ -n "${run_id:-}" ]] || run_anyway "nothing to cancel: no run id in the environment" - -# Column 0, for the reason spelled out in `stale_remedy`: `say` would push the -# `::` behind its prefix and the runner would treat the whole thing as ordinary -# output. The greppable prefix moves inside the annotation body instead. -echo "::error::lease-precondition: this run is not authorised to spend a matrix; cancelling run $run_id" -if ! gh api -X POST "repos/$repo/actions/runs/$run_id/cancel" >/dev/null 2>&1; then - run_anyway "the cancellation was refused" -fi - -# Wait to be killed. Exiting 0 here would let the job march on into the matrix -# this just paid an API call to prevent; exiting non-zero would red the run. So: -# neither, for as long as a cancellation plausibly takes. -waited=0 -while [[ "$waited" -lt "$cancel_wait" ]]; do - sleep 5 - waited=$((waited + 5)) -done -run_anyway "still alive ${cancel_wait}s after the cancellation was accepted" diff --git a/mise-tasks/ci-slow-needed.sh b/mise-tasks/ci-slow-needed.sh index 21fe25983..6027679c1 100755 --- a/mise-tasks/ci-slow-needed.sh +++ b/mise-tasks/ci-slow-needed.sh @@ -72,8 +72,6 @@ if [[ "${1:-}" = "--probe" ]]; then Cargo.lock \ batten.toml \ mise.toml \ - mise-tasks/land.sh \ - tests/land.bats \ bench/tokens/fixtures/x \ .github/workflows/ci.yml \ .claude/hooks/git-hook.sh \ diff --git a/mise-tasks/land-lock-check.sh b/mise-tasks/land-lock-check.sh deleted file mode 100755 index 6b7182d34..000000000 --- a/mise-tasks/land-lock-check.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: the landing lease is either free or a live, well-formed hold — never a wedge and never garbage" -# -# The lease (`mise-tasks/land-lock.sh`, CLOUD-393) is a ref every session writes and -# every session reads. `land-lock` itself is careful — every write is a -# compare-and-swap, and an unparseable lease is treated as held until it expires -# rather than as free, so a stray push can never FREE the lock. That is the right -# failure direction and it is not the whole obligation: what it cannot do is tell -# anyone the lease has gone wrong. -# -# Non-negotiable 2 is why this file exists. "The lease ref must stay well-formed" -# was prose, and prose is feedforward only: it warns, it cannot fail. This is the -# runnable half — a predicate over the ref with an exit code. -# -# THE STATES, and which are healthy: -# -# absent nobody has ever taken it. Healthy. -# released the `expires: 0` sentinel a holder writes on the way out. -# Healthy, and clock-free by construction. -# lapsed expiry in the past. A holder that stopped without -# releasing — its VM reclaimed, most likely. Healthy: the -# next claimant takes it once the death is corroborated. -# live expiry in the future, body parses, held for less than one -# TTL. Healthy — somebody is landing right now. -# WEDGED expiry in the future but further out than one TTL allows. -# Nothing legitimate can produce this: `land-lock` only ever -# mints `now + ttl`, so a longer horizon means somebody wrote -# the ref by hand or with a different TTL. It would block the -# fleet for however long it says. -# GARBAGE the ref exists but carries no lease body. `land-lock` reads -# this as held-until-expiry, which is safe and silent — so -# without this gate a stray push blocks landing for a TTL -# with nothing anywhere saying why. -# -# Both failures are the same shape: something that is not this protocol wrote the -# ref. Neither is a correctness hazard for `main` — the lease decides who goes -# first, never what may land — so this runs on a CLOCK, not on the landing path. -# The split is `lock-complete`'s and `branch-age-check`'s: a property of the -# WORLD (what the remote's ref says right now) belongs on a schedule, because on -# the landing path it would fail whichever PR happened to be in flight over a -# condition that PR did not cause and cannot fix. -# -# Output is a pointer, never the payload (non-negotiable 4): the state, the -# holder id, and a number of seconds. Never the ref body. -# -# Exit: 0 healthy, 1 wedged or garbage, 2 could not look. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT wedged-horizon-passes|s/^if \[\[ "\$left" -gt "\$ttl" \]\]; then$/if false; then/|a horizon beyond one TTL is refused - -set -uo pipefail - -remote="${LAND_LOCK_REMOTE:-origin}" -branch="${LAND_LOCK_BRANCH:-batten-land-lock}" -ref="refs/heads/$branch" -ttl="${LAND_LOCK_TTL:-120}" - -# Injectable so the suite runs offline and against a fixture, the same lever -# `branch-age-check` takes for its two readings: the lease BODY, and the clock. -# A gate whose verdict needs the network cannot be tested, and one that cannot be -# tested is the shape this repository keeps finding dead. -body="${LAND_LOCK_BODY-}" -now="${LAND_LOCK_NOW:-$(date -u +%s)}" - -if [[ -z "${LAND_LOCK_BODY+set}" ]]; then - if ! ls=$(git ls-remote "$remote" "$ref" 2>/dev/null); then - echo "::error:: land-lock-check: cannot reach $remote; read mem:github-access before concluding the network is blocked." >&2 - exit 2 - fi - if [[ -z "$ls" ]]; then - echo "land-lock: absent — nobody holds the landing lease" - exit 0 - fi - if ! git fetch -q "$remote" "$ref" 2>/dev/null; then - echo "::error:: land-lock-check: lease present but unreadable" >&2 - exit 2 - fi - body=$(git show -s --format=%B FETCH_HEAD) -fi - -# An EMPTY body is the fixture form of "absent"; the live path exits above. -if [[ -z "$body" ]]; then - echo "land-lock: absent — nobody holds the landing lease" - exit 0 -fi - -holder=$(printf '%s\n' "$body" | sed -n 's/^holder: //p' | head -1) -expires=$(printf '%s\n' "$body" | sed -n 's/^expires: //p' | head -1) -# THE ADMITTED SUCCESSOR (CLOUD-369). The lease bounds confirming runs at two — -# the holder plus one branch `reserve` admitted — and this gate is what a human -# runs on a wedged lease. Reporting only the holder showed half the occupancy, so -# the one view meant to explain who is spending CI could not name the second -# spender. -# -# Advisory, exactly like `branch:` and `head:`: read for the report, never for a -# verdict. Absent on every lease minted before CLOUD-369 and on every lease -# nobody has reserved behind, so an empty reading is the ordinary case and the -# output is byte-identical to before whenever it is empty. -next=$(printf '%s\n' "$body" | sed -n 's/^next: //p' | head -1) -# Rendered once rather than at each of the four call sites below, so the four -# cannot drift into describing the same field differently. -behind="" -[[ -z "$next" ]] || behind=", $next admitted behind it" - -# GARBAGE. Checked before the arithmetic, because a missing or non-numeric -# expiry cannot be compared and `[` would report a syntax error rather than a -# verdict — an error is not a refusal, and this gate must produce one or the -# other. -case "$expires" in -'' | *[!0-9]*) - echo "::error:: land-lock: GARBAGE at $ref — no parsable lease. land-lock reads this as held, so landing is blocked until it expires or is overwritten." >&2 - exit 1 - ;; -# A parsable epoch, which the holder and horizon checks below then judge. -*) ;; -esac -if [[ -z "$holder" ]]; then - echo "::error:: land-lock: GARBAGE at $ref — a lease with no holder cannot be released by anyone, since release requires recognising your own id." >&2 - exit 1 -fi - -# The release sentinel. `land-lock` writes a literal `expires: 0` when a holder -# hands the lease back, because a release is a declaration and needs no clock — -# so it is reported as one rather than as an expiry 56 years in the past. -if [[ "$expires" = 0 ]]; then - echo "land-lock: free — released by $holder$behind" - exit 0 -fi - -left=$((expires - now)) - -if [[ "$left" -le 0 ]]; then - echo "land-lock: free — lapsed by $holder ${left#-}s ago$behind" - exit 0 -fi - -# WEDGED. `land-lock` mints exactly `now + ttl`, so a horizon beyond one TTL did -# not come from this protocol. Reported rather than repaired: overwriting a lease -# this gate does not understand is how a well-meant fix races a real holder. -if [[ "$left" -gt "$ttl" ]]; then - echo "::error:: land-lock: WEDGED at $ref — held by $holder$behind for another ${left}s, beyond the ${ttl}s any lease may claim. Landing is blocked until it expires." >&2 - exit 1 -fi - -echo "land-lock: held by $holder, ${left}s left$behind" -exit 0 diff --git a/mise-tasks/land-lock.sh b/mise-tasks/land-lock.sh deleted file mode 100755 index 03018c21f..000000000 --- a/mise-tasks/land-lock.sh +++ /dev/null @@ -1,1201 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Effect: hold a rolling fleet-wide landing lease, so exactly one branch at a time spends CI on a landing attempt" -# -# WHY THIS EXISTS. Landing is fast-forward only, so a branch wins only while it -# is still a direct descendant of `main`. With several sessions landing at once -# every attempt races every other, and the loser has already paid for a full CI -# matrix by the time it asks. Measured 2026-08-11 21:31->22:01Z over the whole -# available `fast-forward.yml` history: 400 runs, 248 executed, **243 refusals -# against 5 merges** — a ~2% success rate per attempt. The bot was never the -# problem; it answered every one of those 248 within 23s (median 12s). The cost -# is a thundering herd (CLOUD-393, CLOUD-399). -# -# So the queue already exists — it is just implemented as 243 discarded CI -# matrices per half hour instead of as a lease. This is the lease. Waiting costs -# nothing; a lost lap costs a CI run, and turning the second into the first is -# the entire point. -# -# WHAT IT IS NOT. It does not change what CI proves, which SHA may land, or how -# `main` advances — `final` still gates the fast-forward and `main` still only -# ever takes an already-graded commit. It decides who goes first, nothing else. -# It also deliberately does NOT red or cancel anyone else's PR: red must keep -# meaning "this change is broken", and cancelling another ref's runs would -# reverse CLOUD-240's "supersede your own runs, never someone else's". -# -# HUMANS DO NOT TOUCH THIS, and that is the design rather than an omission. -# Commenting `/fast-forward` is unchanged and remains the whole landing action -# for a person: `fast-forward.yml` is not modified, this task is never on their -# path, and there is no second way to land — `land` ends in the same comment on -# the same workflow. What the lease governs is who SPENDS CI, and a person -# landing an already-green PR spends none, so there is nothing here for them to -# hold. -# -# The cost of exempting them is one number, and it is why the exemption is -# affordable: a human lands about once a day, so at worst one agent lap a day is -# voided by a merge it did not expect — against the 243 refusals per half hour -# this replaces. `land` already handles that case, since a moved `main` is the -# ordinary lap. It gets cheaper still in practice: agents colliding with each -# other far less means `main` is quieter when the person lands, so their -# `/fast-forward` succeeds more often than it does today. -# -# Making `fast-forward.yml` lease-aware was considered and refused. It would -# make a person wait out an agent's whole CI window, and a workflow waiting is a -# runner billing — worse for the human and more CI minutes, to serialise the one -# participant whose volume never needed serialising. -# -# THE PRIMITIVE: one operation, compare-and-swap, and nothing else. -# `git push --force-with-lease=:` is a server-side CAS. Measured -# against a real remote: the correct expected value wins, a stale one is rejected -# with `stale info`, and the empty expected value means "must not exist", so even -# the first claim is a CAS rather than a create-and-hope. -# -# Everything is that one operation. Acquiring CASes from the free state, -# renewing CASes the expiry forward, and releasing CASes the expiry to zero — -# there is deliberately no delete. A tombstoned lease is instantly claimable by -# anyone, which is all a release has to mean, and avoiding the delete removes -# every permission and namespace question from the design at once. It also -# removes the REST API from this task entirely, which matters more than it looks: -# the API budget is shared with `land`'s own polling and was measurably exhausted -# by the fleet during development. -# -# WHERE THE LEASE LIVES. `refs/heads/` today, a custom namespace later, and the -# code does not care — that is the point of CAS-only. A custom namespace is the -# better home (measured on a local remote: invisible to `git branch -r`, -# untouched by `git push --all`, absent from PR base pickers, and CAS behaves -# identically there), and it is deferred rather than chosen because THIS sandbox -# proxies git and its write policy 403s any push outside `refs/heads`. That is an -# environment limitation, not a property of GitHub, and a design must not be bent -# around it — humans and CI push straight to GitHub and never see it. Moving is a -# one-line default change once the proxy allows it. -# -# The holder id in the lease body is load-bearing rather than decorative: git -# addresses objects by content, so two sessions building an identical lease would -# build the SAME sha, and the second push would succeed as a no-op with both -# believing they hold it. -# -# LIVENESS: the TTL rolls, and that changes what the number has to mean. A static -# TTL has to bound how long a hold might legitimately take — a guess about the -# future, wrong in both directions: too low and a slow CI run has its lease stolen -# mid-flight, too high and a reclaimed VM blocks the fleet for the whole window. A -# rolling lease only has to bound how long until we NOTICE a holder stopped -# beating, which is small, stable, and independent of how long CI takes. Hence a -# 30s beat against a 120s TTL: three missed beats before a lease is declared dead -# is the usual Raft/etcd margin, so one transient failure never drops a live -# lease, and a dead holder blocks landing for ~2 minutes rather than ~17. -# -# WHO MAY SPEND A RUNNER, asked by the runner (CLOUD-420). The lease body carries -# `branch:` alongside the holder id, because the holder identifies a CLONE and a -# GitHub job has no clone to compare it with — a branch name is the one -# identifier both ends can see. `authorises ` is the read-only verb that -# answers it, and it is the one place in this file that fails OPEN: a lease it -# cannot read stops every job in the fleet, where waving one matrix through costs -# one matrix. -# -# WHO MAY SPEND THE SECOND RUNNER (CLOUD-369). The lease bounds confirming runs -# at one, which is right for cost and wrong for latency: after every merge the -# queue is empty and the next branch starts cold. `next:` names ONE admitted -# successor, written by the waiter itself through `reserve` — the holder cannot -# name one, since waiters are registered nowhere — and `authorises` admits it -# alongside the holder. The bound is therefore two, enforced at the runner rather -# than merely agreed between cooperating sessions, and it does not grow with the -# fleet: one CAS-guarded slot cannot hold two branches. -# -# `head:` is the third advisory field, and it is what makes WAITING productive: -# it names the commit that is about to become `main`, so a waiter can rebase onto -# the trunk that is coming rather than onto the one the holder is about to -# replace. All three — `branch:`, `head:`, `next:` — are read by CI and by -# waiters, and by no predicate that decides ownership. `mine` compares holder ids -# and nothing else; an identity another clone could DERIVE is one it could -# accidentally claim. -# -# Output is pointer-only (non-negotiable 4): a holder id and an age in seconds, -# never a ref body. Exit codes follow the one contract: 0 acquired/held, 1 held -# by someone else, 2 could not look — plus 3 from `authorises` alone, for "stop", -# which is a third answer the 0/1 pair cannot carry (1 already means "held by -# someone else", a reason to stop rather than the instruction). -# -# MUTATION COVERAGE (CLOUD-418). `||`: applying -# the script to a throwaway copy of this file must turn the named case RED. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -# -# The first row is the regression fixture CLOUD-418 names by hand: a lease that -# refuses nobody is the fail-open posture taken one step too far, and it is the -# whole point of the verb the runner spends money on. -#MUTANT authorises-never-stops|s/^ exit 3$/ exit 0/|THE STOP: a branch the lease does not name is refused with exit 3 -# CLOUD-499's two bounds, declared separately because they catch different -# failures and a single row could not show that either one discriminates alone. -# The first neuters the stall comparison, so a holder that never advances is -# never bailed on; the second neuters the rival's steal, so a beating-but- -# stalled lease stays unstealable — the wedge this issue exists to end. -#MUTANT stall-never-bails|s/^\t\t\tif \[\[ "\$((\$(now) - advance))" -ge "\$((stall_beats \* beat))" \]\]; then$/\t\t\tif false; then/|a land that stops advancing loses its lease and is stopped -#MUTANT stalled-lease-unstealable|s/^\t\telif \[\[ -n "\$observed_progress" \]\] \&\& \[\[ "\$progress_for" -ge "\$((stall_beats \* beat))" \]\]; then$/\t\telif false; then/|A RIVAL MAY REAP A LEASE THAT BEATS WITHOUT PROGRESSING - -set -euo pipefail - -verb="${1:-}" -case "$verb" in -acquire | hold | renew | held | release | status) ;; -authorises) - # The only verb that takes an argument, and it is required: `authorises` - # with no branch cannot answer, and a verb that cannot answer must say so - # rather than defaulting to either verdict. Exit 2 is that, matching the - # "could not look" family the other verbs already use. - if [[ -z "${2:-}" ]]; then - echo "::error:: usage: land-lock authorises " >&2 - exit 2 - fi - ;; -reserve) - # Takes the branch reserving, for the same reason `authorises` does: the - # caller may be reserving on behalf of a checkout this process is not in. - if [[ -z "${2:-}" ]]; then - echo "::error:: usage: land-lock reserve " >&2 - exit 2 - fi - ;; -peek) - case "${2:-}" in - branch | head | next) ;; - *) - echo "::error:: usage: land-lock peek " >&2 - exit 2 - ;; - esac - ;; -*) - echo "::error:: usage: land-lock " >&2 - exit 2 - ;; -esac - -remote="${LAND_LOCK_REMOTE:-origin}" -# The lease's OWN ref name. Deliberately not the branch a lease authorises — -# see `land_branch` below, which is a different thing with a confusingly similar -# name, and writing this one into the body would stamp `batten-land-lock` into -# every lease while looking correct. -branch="${LAND_LOCK_BRANCH:-batten-land-lock}" -ref="refs/heads/$branch" -ttl="${LAND_LOCK_TTL:-120}" -beat="${LAND_LOCK_HEARTBEAT:-30}" -# HOW LONG A HOLDER MAY STOP PROGRESSING BEFORE ITS LEASE IS DISBELIEVED -# (CLOUD-499). Neither of these bounds how long a landing may TAKE — both reset -# on every advance, so an arbitrarily long landing that keeps producing state -# changes never reaches either. They bound how long we keep believing a holder -# that has stopped producing evidence, which is what the rolling TTL above -# already does one signal shallower: the TTL notices a holder that stopped -# BEATING, these notice one that stopped LANDING. -# -# 60 beats = 30 minutes, against a measured floor of 1332s (~45 beats): the -# longest gap between consecutive check-run completions over the six most -# recently merged PRs on 2026-08-12. Deliberately generous — this exists to -# catch NEVER, not slow, and the cost of catching slow is a landing killed for -# being healthy. Re-measure if `CI_REQUIRED_CHECKS` grows. -stall_beats="${LAND_LOCK_STALL_BEATS:-60}" -# The hang bound is the same three-beat margin the TTL uses, and applies only -# while a fast loop is actually pushing ticks (see `holder_progress`): a poll -# iterating every ~1.5s that has produced nothing for 90s is blocked, not -# waiting. A phase with no loop is judged by the stall bound alone, because -# `verify`'s own steps legitimately run longer than this. -hang_beats="${LAND_LOCK_HANG_BEATS:-3}" -# How long `acquire` waits before handing the caller its turn back. Past one TTL -# the holder is either beating (and the wait is legitimate) or dead (and the -# lease is stealable), so a longer wait can only mean something more waiting will -# not fix. -wait_for="${LAND_LOCK_WAIT:-$ttl}" -# The branch this lease AUTHORISES — what CI checks itself against (CLOUD-420). -# The holder id identifies a clone, which a runner has nothing to compare with; -# a branch name is the one thing both ends can see. Derived from the checkout by -# default, the way `holder_id` is derived rather than configured, and overridable -# so the suites can drive it. Empty is a legitimate reading (a detached HEAD, a -# bare clone) and must never become the string "HEAD". -land_branch="${LAND_LOCK_LAND_BRANCH:-$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)}" -[[ "$land_branch" != HEAD ]] || land_branch= - -# THE HEAD THIS LEASE IS LANDING (CLOUD-369). `branch:` says who may spend a -# matrix; `head:` says what the next `main` is about to be, which is the thing a -# WAITER needs. A waiter that rebases onto `origin/main` warms nothing — the -# holder is about to replace it — so pre-warming is only a linearization when it -# targets the commit that is about to become trunk. -# -# Advisory exactly like `branch:`: read by a waiter deciding what to rebase onto, -# never by any predicate that decides ownership. `mine` still compares holder ids -# and nothing else, and that separation is load-bearing — an identity another -# clone could DERIVE (from a branch, a head, an issue key) is an identity another -# clone could accidentally claim, which is the two-holders bug this file exists -# to prevent. -land_head="${LAND_LOCK_LAND_HEAD:-$(git rev-parse HEAD 2>/dev/null || true)}" - -# THE ADMITTED SUCCESSOR (CLOUD-369). A branch that has reserved the right to -# spend the SECOND matrix — the one that overlaps the holder's merge instead of -# starting cold after it. Empty on every lease until a waiter reserves. -# -# Carried through every mint rather than re-derived, because the holder's -# heartbeat re-mints the lease every beat and a field it did not carry forward -# would be erased within 30 seconds of being written by someone else. -land_next="${LAND_LOCK_LAND_NEXT:-}" - -# THE PROGRESS TOKEN (CLOUD-499). Opaque by design: a rival tests it for -# EQUALITY OVER TIME and never interprets it, so no clock crosses the wire and -# no field of it means anything to anyone but its writer. That keeps it in the -# advisory class with `branch:`, `head:` and `next:` — read by waiters, never by -# a predicate that decides ownership. -# -# Empty is the honest reading whenever the holder cannot see its own progress: -# no registry entry, or a heartbeat with no holder pid to look up. An empty -# token is never stall-stealable, which is also every lease minted before this -# change — during rollout that is not an edge case, it is all of them. -land_progress="${LAND_LOCK_LAND_PROGRESS:-}" - -git rev-parse --git-dir >/dev/null 2>&1 || { - echo "::error:: land-lock: not a git repository" >&2 - exit 2 -} - -state_dir="$(git rev-parse --git-dir)/batten-land-lock" -holder_file="$state_dir/holder" -# Per-process observation ref (see `observe`). Namespaced under refs/batten-lock -# so it is local bookkeeping and never confusable with the lease itself, and -# swept on exit so a clone does not accumulate one per land. -obs_ref="refs/batten-lock-obs/$$" -trap 'git update-ref -d "$obs_ref" 2>/dev/null || true' EXIT - -now() { date -u +%s; } - -# One id per clone, minted once and reused by every later verb, because `hold`, -# `held` and `release` run as separate processes from the `acquire` that won: a -# per-process id would leave the holder unable to recognise its own lease. -holder_id() { - if [[ ! -s "$holder_file" ]]; then - mkdir -p "$state_dir" - printf '%s-%s-%s\n' "${HOSTNAME:-host}" "$$" \ - "$(od -An -tx1 -N8 /dev/urandom | tr -d ' \n')" >"$holder_file" - fi - cat "$holder_file" -} - -# A lease is a parentless commit over the empty tree, so it shares no history -# with anything and can never fast-forward over a live lease. -# -# The nonce is not decoration. Git addresses objects by content, so two mints -# agreeing on holder and expiry — the same clone renewing twice inside one -# second, or two clones colliding on both fields — 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: without -# it, a second `acquire` from the holder reported "acquired" rather than -# recognising its own lease, and a renew left the ref unmoved. -# $1 is the lifetime in seconds; 0 mints a TOMBSTONE — a lease already expired, -# which is what a release leaves behind instead of deleting the ref. -mint() { - local tree - tree=$(git hash-object -t tree /dev/null) - # A lifetime of 0 writes a literal `expires: 0` rather than "now", because a - # release is a DECLARATION and an expiry is an INFERENCE, and only the second - # needs a clock. Epoch 0 is unmistakable under any clock and on any machine, - # so a released lease hands over immediately while a merely expired one still - # has to be corroborated (see `sha_held_for`). Conflating the two made a - # release wait a full beat before anyone could take it. - # The identity is supplied explicitly, and that is a portability fix rather - # than a style choice: `git commit-tree` refuses with "Author identity - # unknown" on any machine with no configured `user.email` — a CI runner, a - # fresh clone. Measured: every acquiring test in this suite passed locally and - # failed in CI for exactly that reason. Pinning it also makes the lease object - # independent of whoever runs it, which is the right property for a commit - # nobody authored and nothing merges. - # `branch:` is read by CI, never by this task's own predicates except - # `authorises` (CLOUD-420). It is written from the `land_branch` global - # rather than passed as a parameter because `mint` is only ever reached - # through `swap`, and three of `swap`'s five call sites pass nothing but the - # expected sha — threading a second argument through all of them to carry a - # value that never varies within a process would be a wider change for no - # added expressiveness. `nonce:` stays LAST: its uniqueness argument above is - # what makes every mint a distinct sha, and `land-lock-check`'s fixture - # treats it as the terminal line. - # `head:`, `next:` and `progress:` join `branch:` as ADVISORY fields, on the - # same terms: read by a waiter and by CI, never by an ownership predicate. - # They sit between `branch:` and `nonce:` so the nonce stays terminal. - # - # Every one of the five is overridable through a `mint_*` global, and that is - # what `reserve` needs rather than a convenience: a waiter appending itself as - # `next:` must re-mint the HOLDER's lease — its holder, its expiry, its branch, - # its head — changing one field and nothing else. Without the overrides that - # path would have to duplicate this printf, and a second writer of the lease - # body is precisely where a divergence from `observe` would be invisible. - printf 'land-lock\nholder: %s\nexpires: %s\nbranch: %s\nhead: %s\nnext: %s\nprogress: %s\nnonce: %s\n' \ - "${mint_holder:-$(holder_id)}" \ - "${mint_expires:-$([[ "${1:-}" = 0 ]] && echo 0 || echo "$(($(now) + ${1:-$ttl}))")}" \ - "${mint_branch:-$land_branch}" \ - "${mint_head:-$land_head}" \ - "${mint_next:-$land_next}" \ - "${mint_progress:-$land_progress}" \ - "$(od -An -tx1 -N8 /dev/urandom | tr -d ' \n')" | - GIT_AUTHOR_NAME=batten GIT_AUTHOR_EMAIL=batten@localhost \ - GIT_COMMITTER_NAME=batten GIT_COMMITTER_EMAIL=batten@localhost \ - git commit-tree "$tree" -} - -observed_sha= -observed_holder= -observed_expires= -observed_branch= -observed_head= -observed_next= -observed_progress= -# Reads the remote lease, leaving the three `observed_*` empty when it is absent. -# Exit 2 is reserved for "could not look" — an unreachable remote must never read -# as an unheld lock, since that is precisely the misread that would let two -# sessions land at once. -observe() { - local ls body - observed_sha= - observed_holder= - observed_expires= - # Cleared with the rest: a value left over from an earlier observe in this - # same process is the FETCH_HEAD-class misread the comment below describes, - # reached through a variable instead of a file. - observed_branch= - observed_head= - observed_next= - observed_progress= - ls=$(git ls-remote "$remote" "$ref" 2>/dev/null) || { - echo "::error:: land-lock: cannot reach $remote; read mem:github-access before concluding the network is blocked." >&2 - return 2 - } - [[ -n "$ls" ]] || return 0 - # NOT FETCH_HEAD, and this is a correctness fix rather than a tidy-up. - # FETCH_HEAD is ONE FILE PER CLONE, and this task runs concurrently inside a - # single clone by design: `land` backgrounds the heartbeat's observe loop and - # then runs `held` and `release` in the foreground of the same checkout. Two - # fetches racing means a reader can be handed the other one's result. - # Measured on a local remote: 16 of 40 concurrent reads returned the WRONG - # lease body. - # - # What that costs is not a bad message. The sha comes from `ls-remote` and - # the body came from FETCH_HEAD, so a collision pairs THIS lease's sha with - # ANOTHER lease's holder — and `release` CASes against that sha while judging - # ownership from that holder. It could tombstone a live lease belonging to - # someone else, which is precisely the theft the CAS exists to prevent. - # - # A per-process ref has no such sharing. It is force-updated because a stale - # one from an earlier run of the same pid must never be read as current. - git fetch -q --force "$remote" "+$ref:$obs_ref" 2>/dev/null || { - echo "::error:: land-lock: lease present but unreadable" >&2 - return 2 - } - # Both readings come from the fetched ref, so the sha and the body are - # guaranteed to describe the same lease. Taking the sha from `ls-remote` and - # the body from the fetch is what allowed them to disagree at all. - observed_sha=$(git rev-parse "$obs_ref" 2>/dev/null) || { - echo "::error:: land-lock: lease fetched but unreadable" >&2 - return 2 - } - body=$(git cat-file commit "$observed_sha" 2>/dev/null) || { - echo "::error:: land-lock: lease object missing after fetch" >&2 - return 2 - } - observed_holder=$(printf '%s\n' "$body" | sed -n 's/^holder: //p') - observed_expires=$(printf '%s\n' "$body" | sed -n 's/^expires: //p') - # Absent on every lease minted before CLOUD-420, and on a lease minted from a - # detached HEAD. Empty is therefore a real state rather than an error, and - # `authorises` treats it as "cannot tell" — which fails OPEN, since failing - # closed on an unreadable lease would stop every PR in the fleet. - observed_branch=$(printf '%s\n' "$body" | sed -n 's/^branch: //p') - # Absent on every lease minted before CLOUD-369, exactly as `branch:` is - # absent on every lease minted before CLOUD-420. Empty is a reading, not a - # failure: a waiter that cannot learn the head speculates on `origin/main` - # instead, and `authorises` admits nobody as `next` when none is named. - observed_head=$(printf '%s\n' "$body" | sed -n 's/^head: //p') - observed_next=$(printf '%s\n' "$body" | sed -n 's/^next: //p') - # Absent on every lease minted before CLOUD-499, and on any holder that - # cannot see its own progress. Empty means "no stall evidence exists", which - # the steal path treats as not-stealable — the rival half fails CLOSED, the - # opposite of the holder half, and deliberately: a wrongly released lease - # costs its holder one lap, a wrongly STOLEN one puts two holders on the - # same trunk. - observed_progress=$(printf '%s\n' "$body" | sed -n 's/^progress: //p') - # A lease we cannot parse is one we do not understand, and treating it as - # free would be the same misread as an unreachable remote. Give it a full - # TTL from now so it is respected until it ages out, never ignored. - [[ -n "$observed_expires" ]] || observed_expires=$(($(now) + ttl)) - return 0 -} - -# Compare-and-swap the lease from $1 to a fresh one. The expected value is what -# makes this safe to call from a heartbeat: a lease that changed hands under us -# is rejected rather than clobbered. An empty $1 means "must not exist", which is -# how the very first claim is made without a separate create path. -# The expected value is passed EXPLICITLY (`:`) and must stay that way. -# Bare `--force-with-lease` compares against this clone's remote-tracking ref — -# what the last fetch happened to see — which for a ref other sessions are -# actively rewriting is precisely the stale value this must not trust. The two -# forms look interchangeable and are not: the bare one would let a holder whose -# lease had already been taken stamp its own back on top, which is the -# two-holders bug the whole task exists to prevent. -# -# The flag is also named backwards for what it does here. It is the OPPOSITE of a -# force push: it refuses in exactly the case a plain `--force` would clobber. -# A FAILED MINT MUST NEVER BECOME A DELETE. Interpolating `$(mint)` straight -# into the refspec meant an empty result produced `":$ref"` — which is git's -# delete refspec, not a no-op. On the renew path, whose expected value is our own -# live lease, that CAS would have succeeded and destroyed the lease we held. The -# mint is captured and checked first so a failure is a refused swap, which every -# caller already handles. -swap() { - local lease - lease=$(mint "${2:-}") || return 1 - [[ -n "$lease" ]] || return 1 - git push --quiet --force-with-lease="$ref:$1" "$remote" "$lease:$ref" 2>/dev/null || return 1 - # The receipt is written HERE rather than in `acquire`, because `swap` is the - # lease's only writer: acquire, renew, the heartbeat's steal path and release - # all reach the remote through it, so one line covers every way the lease can - # change hands. A receipt minted at acquire alone would go stale mid-lap — - # `verify` runs longer than a TTL — and `land` readies AFTER its push. - # - # Never fatal. The lease is taken the moment the push returns; a clone that - # cannot write to its own `.git` has a problem, but it is not this one, and - # failing here would report a held lease as unheld. - lease_receipt "${2:-}" || true -} - -# `ready-guard`'s offline half (CLOUD-420 §3). The guard must not touch the -# network — it runs in a PreToolUse hook — so what it reads is this: the instant -# this clone's lease expires, refreshed by every heartbeat. That makes the -# receipt accurate to within one beat rather than to within one acquire. -# -# Keyed by BRANCH, like `claim-check`'s and unlike `verify`'s: a lease attests to -# a decision about which branch may land, which every commit on that branch -# continues to serve. A sha-keyed one would demand a re-acquire per rebase, which -# is every lap. -lease_receipt() { - local dir key - [[ -n "$land_branch" ]] || return 0 - dir=$(git rev-parse --git-dir 2>/dev/null) || return 0 - dir="$dir/batten-receipts" - # `/` -> `-`, the same transform `claim-check` and `receipt::branch_receipt_name` - # already use - # on their branch-keyed receipt. Every branch in this repository carries a - # slash (`claude/…`, `wenzowski/…`), and a raw name makes `lease.claude/foo` - # a path through a directory that does not exist — the write fails, no - # receipt is left, and `ready-guard` then refuses every ready while looking - # exactly like a mechanism that is working. The suites missed it because a - # scratch repository's default branch is the one shape with no slash in it. - key="lease.${land_branch//\//-}" - # A release is a declaration that this clone no longer holds it, so the - # receipt goes rather than ageing out — otherwise `ready-guard` would honour - # a lease its holder had already handed on. - if [[ "${1:-}" = 0 ]]; then - rm -f "$dir/$key" - return 0 - fi - mkdir -p "$dir" || return 0 - echo "$(($(now) + ${1:-$ttl}))" >"$dir/$key" -} - -# `-ge`, not `-gt`: a lease with zero seconds left has none, and the release -# tombstone sets the expiry to exactly now. Under `-gt` that read as still-held -# for one more second, so a release did not free the lease until the clock -# ticked — measured, as a release the releaser itself still saw as held. -expired() { [[ "$(now)" -ge "${observed_expires:-0}" ]]; } -# Explicitly handed over, as opposed to merely lapsed. No clock involved. -released() { [[ "${observed_expires:-1}" = 0 ]]; } - -# How long THIS sha has been the lease, measured only on our own clock. -# -# Expiry alone is not safe to steal on, because `expires` is an absolute instant -# minted on the HOLDER's clock and compared against ours. Skew in one direction -# makes a live lease look expired, and stealing on that reading produces exactly -# the two holders this task exists to prevent. (Skew the other way makes a lease -# look far-future, which `land-lock-check` reports as WEDGED — so a WEDGED -# verdict on a healthy fleet is a clock complaint, not a vandalism report.) -# -# A heartbeat mints a new nonce every beat, so a live holder CHANGES THE SHA -# every beat. "This exact sha has been sitting there for longer than a beat" is -# therefore evidence of the same thing expiry claims, derived entirely from -# durations on one clock, and no skew can forge it. Cost is one extra beat before -# a dead lease can be taken, which `acquire` spends waiting anyway. -sha_held_for() { - local seen prev_sha prev_at - seen="$state_dir/seen" - mkdir -p "$state_dir" - prev_sha= - prev_at= - # Absence is guarded BEFORE the redirect, not caught after it (CLOUD-433). - # Bash opens an input redirect before the `2>/dev/null` on the same command - # is in effect, so `read <"$seen" 2>/dev/null` on a missing file still - # printed `No such file or directory` to the CALLER's stderr — on every - # first sighting of a sha, which is every acquire that reaches this path. - if [[ -f "$seen" ]]; then - read -r prev_sha prev_at <"$seen" 2>/dev/null || { - prev_sha= - prev_at= - } - fi - if [[ "$prev_sha" != "$observed_sha" ]] || [[ -z "$prev_at" ]]; then - printf '%s %s\n' "$observed_sha" "$(now)" >"$seen" - echo 0 - return 0 - fi - echo $(($(now) - prev_at)) -} -# How long THIS progress token has been the lease's, on our own clock and by the -# same argument as `sha_held_for` above: the holder re-mints a nonce every beat, -# so a lease whose SHA keeps changing while its progress token does not is a -# holder that is beating without landing. Equality over time is the whole test — -# nothing here interprets the token, so the holder's clock never enters ours. -# -# Echoes seconds. A first sighting is 0, which is why a rival needs the full -# stall bound of observations before it can conclude anything. -progress_held_for() { - local seen prev_tok prev_at - seen="$state_dir/seen-progress" - mkdir -p "$state_dir" - prev_tok= - prev_at= - # Absence guarded before the redirect, per CLOUD-433 — see `sha_held_for`. - if [[ -f "$seen" ]]; then - read -r prev_tok prev_at <"$seen" 2>/dev/null || { - prev_tok= - prev_at= - } - fi - if [[ "$prev_tok" != "$observed_progress" ]] || [[ -z "$prev_at" ]]; then - printf '%s %s\n' "$observed_progress" "$(now)" >"$seen" - echo 0 - return 0 - fi - echo $(($(now) - prev_at)) -} -mine() { [[ -n "$observed_holder" ]] && [[ "$observed_holder" = "$(holder_id)" ]]; } -# A tombstone is a HANDOVER, not an expiry, and every verb that renders seconds -# must say so (CLOUD-433). `expires: 0` is a sentinel, not an instant, so the -# ordinary arithmetic against it yields wall-clock epoch — `released after -# 1786501354s`, observed live. Written as an `if` rather than `released && …` -# because a false `&&` list would be the function's exit status under `set -e`. -age() { - if released; then - echo 0 - return 0 - fi - echo $((ttl - (observed_expires - $(now)))) -} - -# CLOUD-432: is the land this heartbeat serves still alive? `land` passes its -# pid down as LAND_LOCK_HOLDER_PID; unset means "no holder declared", which is -# every other caller and keeps their behaviour. Existence is not enough — pids -# recycle, and this clone measurably wrapped its pid space inside 20 minutes — -# so the pid must still BE a mise-tasks/land.sh process. Any probe that cannot be -# evaluated reads as gone: a wrongly released lease costs one lap (the `held` -# fence catches it before the comment), a wrongly renewed one wedges the fleet -# for as long as nobody notices, so release is the cheap direction. -holder_alive() { - local pid="${LAND_LOCK_HOLDER_PID:-}" cmd - [[ -n "$pid" ]] || return 0 - kill -0 "$pid" 2>/dev/null || return 1 - cmd=$(tr '\0' ' ' /dev/null) || return 1 - case "$cmd" in - *"/mise-tasks/land.sh "*) return 0 ;; - esac - return 1 -} - -# CLOUD-499: is the land this heartbeat serves still MOVING? Liveness answers a -# different question, and answers it happily for a process wedged forever. -# -# The registry is the source, read through `task-registry` by path rather than -# parsed here — one owner of that file layout, and by path for the reason -# `ci-wait` already reads `checks-green` that way. Three stamps, and the reading -# is deliberately the LATEST of them rather than any single one: -# -# phase_since a lap step began (a task with no loop has only this) -# sig_at the world moved — `ci-wait`'s check-run reading changed -# tick_at a loop went round, whether or not it learned anything -# -# Echoes ` `, or nothing at all when there is no entry to -# read. NOTHING is the honest answer there, and every caller treats it as "no -# verdict": a land whose bookkeeping never registered is not evidence of a -# stall, and killing one on that reading would be inventing the finding. -# -# The two are reported separately rather than folded into one maximum, because -# the hang bound may only be applied WHILE A LOOP IS ACTUALLY TICKING — which is -# exactly `tick_at > last_advance`. Folding them would apply a 90s bound to -# `verify`, whose single steps legitimately run for minutes without a tick, and -# the mechanism's first act would be to kill healthy landings. -holder_progress() { - local pid="${LAND_LOCK_HOLDER_PID:-}" phase_since sig_at tick_at advance - [[ -n "$pid" ]] || return 1 - phase_since=$(batten task read "$pid" phase_since 2>/dev/null) || return 1 - sig_at=$(batten task read "$pid" sig_at 2>/dev/null) || return 1 - tick_at=$(batten task read "$pid" tick_at 2>/dev/null) || return 1 - advance=${phase_since:-0} - [[ "${sig_at:-0}" -le "$advance" ]] 2>/dev/null || advance=$sig_at - # An entry with no usable stamp at all is no evidence, not a stall at the - # epoch — the same "cannot tell" the missing entry gets. - [[ "$advance" != 0 ]] || return 1 - printf '%s %s\n' "$advance" "${tick_at:-0}" -} - -case "$verb" in -acquire) - deadline=$(($(now) + wait_for)) - # Jittered exponential backoff — the CSMA/CD posture mem:workflow/agent-fanout - # already argues for. The jitter is the load-bearing half: without it every - # waiter wakes on the same schedule and re-collides the instant a lease drops, - # which is the herd this task exists to disperse. - # - # AGING (CLOUD-369), and it is what stops the capture effect the analogy - # predicts. Backoff alone disperses a herd but does not make it FAIR: a branch - # that has lost ten times re-enters on exactly the terms of one that just - # arrived, so aggregate throughput stays healthy — `main` advancing is the - # fleet working — while an individual station starves and then abandons its - # lap budget having landed nothing. Measured on PR #325: 8 laps, 8 greens, - # zero commits landed. - # - # So an aged waiter probes a freed lease SOONER: its ceiling falls as its - # waits accumulate, which raises its chance of being the one holding the CAS - # when the ref drops. Deliberately weaker than FIFO — strict ordering needs a - # coordinator, which this design rules out — and deliberately not a priority - # written anywhere: age is a count this process observed about itself, never - # shared state, so two clones cannot disagree about it because they never - # compare it. - # - # The floor is 1, never 0: a zero delay is a spin, and the busy loop is what - # the backoff exists to prevent. The jitter survives every value of age, since - # two equally-aged waiters are exactly the collision it disperses. - age="${LAND_LOCK_AGE:-0}" - case "$age" in - '' | *[!0-9]*) age=0 ;; - esac - cap=30 - while [[ "$age" -gt 0 ]] && [[ "$cap" -gt 2 ]]; do - cap=$((cap / 2)) - age=$((age - 1)) - done - delay=2 - [[ "$delay" -le "$cap" ]] || delay="$cap" - # CLOUD-450: how many observations this acquire spent looking at an EXPIRED - # lease before it won. A count, on no clock, so the suite can assert the - # "one extra beat" promise without grading wall time on a loaded runner. - post_expiry_probes=0 - while :; do - observe || exit 2 - # Record the sighting on EVERY observation, not only once expired - # (CLOUD-433). The corroboration clock starts at the first time we saw - # this sha, and starting it only after expiry meant it started when the - # backoff had already grown to 8–30s: measured 19s from expiry to steal - # at TTL=4/beat=2, against this file's own promise of one extra beat. - # Recording here lands the steal on the FIRST post-expiry check. - # - # This shortens no precondition — the sha must still have sat unchanged - # for a full beat, and a live holder still remints it every beat. It - # removes an accidental delay, it does not make anything stealable - # sooner than the design intends. - held_for=0 - [[ -z "$observed_sha" ]] || held_for=$(sha_held_for) - # Recorded on every observation for the same reason `held_for` is: the - # corroboration clock starts at the first sighting, not at the first - # sighting that happened to be interesting. - progress_for=0 - [[ -z "$observed_progress" ]] || progress_for=$(progress_held_for) - # Counted here, where every observation passes, so it measures probes and - # not loop iterations that skipped the read (CLOUD-450). - if [[ -n "$observed_sha" ]] && expired; then - post_expiry_probes=$((post_expiry_probes + 1)) - fi - if mine && ! expired; then - echo "land-lock: already held by this clone" - exit 0 - fi - # One compare-and-swap covers all three ways in: the ref does not exist - # yet (expected value empty), it was tombstoned by a release, or its - # holder stopped beating. Two sessions racing the same free state CAS - # from the same expected value, so exactly one wins and the other is told - # `stale info` — which is why there is no separate create path to race. - # An absent ref is unambiguous and needs no corroboration. An EXPIRED one - # does: see `sha_held_for`. Requiring the sha to have sat unchanged for a - # beat costs a dead lease one extra beat and makes the steal immune to a - # holder whose clock disagrees with ours. - steal=no - if [[ -z "$observed_sha" ]] || released; then - # Absent, or explicitly handed over. Both are statements rather than - # deductions, so neither needs corroboration or a clock. - steal=yes - elif expired && [[ "$held_for" -ge "$beat" ]]; then - steal=yes - elif [[ -n "$observed_progress" ]] && [[ "$progress_for" -ge "$((stall_beats * beat))" ]]; then - # THE BEATING-BUT-STALLED LEASE (CLOUD-499). Every branch above this - # one waits for the holder to stop beating; this one is why a holder - # that beats forever without landing is no longer unstealable. - # - # It fails CLOSED, unlike the holder's own bail: no token, no steal — - # which is every lease minted before this change, and every holder - # that cannot see its own progress. The asymmetry is deliberate and - # is the same one CLOUD-432 argued in the other direction: releasing - # a lease wrongly costs its holder one lap, stealing one wrongly puts - # two holders on the same trunk. - steal=yes - fi - if [[ "$steal" = yes ]]; then - if swap "$observed_sha"; then - if [[ -n "$observed_holder" ]] && [[ "$observed_holder" != "$(holder_id)" ]]; then - # Seconds since the previous holder's expiry, computed here - # rather than through `age`: that helper measures a lease - # against OUR ttl, not the one its last holder ran under. - # THE PROBE COUNT IS THE HONEST QUANTITY (CLOUD-450). The - # seconds are kept because they are what a human reads, but - # both ends of that delta are instants on one clock, so a - # deschedule between the expiry and the winning probe inflates - # it — which made `tests/land-lock.bats`'s duration assertion - # a wall clock that failed under the parallel runner ~2 runs - # in 4, and a flaky gate is a bypassed gate. - # - # `probes` counts the observations this acquire spent after - # the lease expired. It is a count on no clock at all, so a - # loaded box cannot move it: the promise "a dead lease costs - # one extra beat" is exactly "the steal lands on the FIRST - # post-expiry probe", and that is now stated rather than - # inferred from elapsed time. - if [[ -n "$observed_progress" ]] && [[ "$progress_for" -ge "$((stall_beats * beat))" ]] && ! expired; then - # A steal from a holder that never stopped beating - # reads as theft unless it says which evidence it - # acted on (CLOUD-499). Pointer-only: two counts. - echo "land-lock: took the lease from $observed_holder, which was still beating but had not progressed in ${progress_for}s (stall bound: $((stall_beats * beat))s)" - elif released; then - # A TOMBSTONE IS A HANDOVER, NOT AN EXPIRY, and the - # arithmetic below renders one as wall-clock epoch — - # `took the lease 1786577736s after …`, observed live - # while probing CLOUD-499. Same defect CLOUD-433 fixed - # in `status` and `release`; this third renderer was - # missed because nothing printed it until a stalled - # holder started releasing on its own. - echo "land-lock: took the lease $observed_holder released" - else - echo "land-lock: took the lease $(($(now) - observed_expires))s after $observed_holder stopped holding it (probes since expiry: $post_expiry_probes)" - fi - else - echo "land-lock: acquired by $(holder_id), ${ttl}s lease" - fi - exit 0 - fi - # Lost the CAS: somebody claimed the same free state first. Fall - # through to the deadline and the backoff rather than retrying at - # once — an immediate retry is the tight spin that turns a contended - # lease into a busy loop, measured when this `continue`d instead. - fi - if [[ "$(now)" -ge "$deadline" ]]; then - echo "land-lock: still held by ${observed_holder:-another session} after ${wait_for}s" - exit 1 - fi - sleep $((delay + RANDOM % delay)) - [[ "$delay" -ge "$cap" ]] || delay=$((delay * 2)) - done - ;; - -renew) - observe || exit 2 - { [[ -n "$observed_sha" ]] && mine; } || exit 1 - # CARRY THE RESERVATION FORWARD (CLOUD-369). A renewal re-mints the whole - # body, so a `next:` written by a waiter between two beats would be erased - # within 30 seconds of being written — by the holder, silently, and the - # admitted successor would then be cancelled by CI mid-run. - # - # Renew and hold carry it; ACQUIRE DELIBERATELY DOES NOT. A fresh acquire is - # a new turn, and the previous holder's successor has already had its - # admission — carrying it forward would authorise a third branch, then a - # fourth, and the bound this whole design rests on would drift upward one - # handover at a time. - land_next="$observed_next" - # Carried for the same reason and against the same hazard (CLOUD-499): a - # renew re-mints the whole body, so a progress token this caller cannot - # compute — `renew` is a one-shot with no holder pid to look up — would be - # erased by the act of renewing, and the lease would look unstealable-forever - # to every rival. - land_progress="$observed_progress" - swap "$observed_sha" || exit 1 - exit 0 - ;; - -hold) - # The heartbeat. `land` backgrounds this for the length of the hold and kills - # it from the same trap that releases. It exits non-zero the moment the lease - # stops being ours, which is the signal that something took it — the `held` - # check before the comment is the backstop that acts on that. - # - # A FAILED PUSH IS NOT A LOST LEASE, and treating it as one was a real - # fragility: `swap` returns non-zero both when the lease genuinely changed - # hands AND when the push simply did not go through — a dropped connection, a - # proxy hiccup, a rate limit. Exiting on the second hands the lease away over - # a blip, and the whole reason the TTL is three beats wide is to survive - # exactly that. So a push failure retries on the next beat, and only a lease - # that is demonstrably no longer OURS ends the loop. Two consecutive failures - # are tolerated; a third means the remaining TTL is about to run out anyway, - # and continuing to believe we hold it past that point is the one thing this - # must never do. - # CLOUD-451's census rides this loop rather than running its own. This beat - # ticks for exactly as long as a `land` holds the lease — the window "active - # work is in flight" — so an `h` here, left in final position by a previous - # boot, is the evidence that a container replacement interrupted real work. - # By path, never `mise run`: this is a hot loop and the task runner costs - # ~150ms a call (CLOUD-435). The task swallows its own failures, so a census - # that cannot write can never end a landing. - census="$(dirname -- "${BASH_SOURCE[0]}")/reclaim-census.sh" - [[ -x "$census" ]] || census= - beat_note() { [[ -n "$census" ]] && "$census" note "$@" >/dev/null 2>&1 || true; } - misses=0 - while :; do - sleep "$beat" - beat_note h - # CLOUD-432, before anything else each beat: a heartbeat whose land is - # gone must not renew a lease for nobody. SIGKILL, an OOM kill, and the - # harness's un-reaped task stop all skip land's trap, and an orphan - # that keeps renewing blocks every rival while land-lock-check reports - # a healthy hold. Release first, then exit, so the lease frees now - # rather than after a TTL nobody is refreshing. - if ! holder_alive; then - echo "land-lock: the land holding this lease (pid ${LAND_LOCK_HOLDER_PID:-?}) is gone; releasing rather than renewing for nobody" - if observe && [[ -n "$observed_sha" ]] && mine; then - swap "$observed_sha" 0 || true - fi - beat_note x holder-gone - exit 1 - fi - # CLOUD-499, the complementary case and the one liveness cannot see: the - # land is alive, its trap would fire perfectly well, and it has stopped - # landing. Read the progress stamps and publish them, so this beat's mint - # carries what a rival needs to reach the same conclusion independently. - bail= - if progress=$(holder_progress); then - advance=${progress% *} - tick_at=${progress#* } - land_progress="$advance.$tick_at" - if [[ "$(($(now) - advance))" -ge "$((stall_beats * beat))" ]]; then - bail="has not advanced in $stall_beats beats" - elif [[ "$tick_at" -gt "$advance" ]] && - [[ "$(($(now) - tick_at))" -ge "$((hang_beats * beat))" ]]; then - # Only while a loop is ticking — see `holder_progress` for why - # folding the two stamps together would kill healthy landings. - bail="stopped turning $hang_beats beats ago" - fi - else - # No entry, no verdict. An unregistered land is not evidence of a - # stall, and an empty token is never stall-stealable either, so this - # clone and every rival agree to say nothing rather than guess. - land_progress= - fi - if [[ -n "$bail" ]]; then - # RELEASE FIRST, SIGNAL SECOND. The release is the half that frees - # the fleet and it always lands; the signal's promptness depends on - # what `land` is blocked in — immediate in a `wait`, which is every - # long phase (its verify and CI waits are both raced, CLOUD-423), - # and deferred to the end of a short foreground `git`/`gh` call - # otherwise. Ordering them the other way would make a fleet-wide - # unwedge wait on a signal that might be pending. - echo "land-lock: the land holding this lease $bail; releasing and stopping it rather than holding the fleet" - if observe && [[ -n "$observed_sha" ]] && mine; then - swap "$observed_sha" 0 || true - fi - # WHY, where the agent will look (CLOUD-470). A landing that stops - # without saying why reaches its agent as "verify and CI disagree", - # and the remedy it then reaches for is wrong. `land`'s exit trap - # prints this and removes it. - mkdir -p "$state_dir" 2>/dev/null && - echo "the landing $bail, so its lease was released and it was stopped. Nothing is wrong with the branch: look at what its last phase was waiting for (\`mise run alive\`), fix that, and land again." \ - >"$state_dir/bail-reason" 2>/dev/null || true - # Re-corroborated immediately before the kill, never inferred from - # the probe at the top of this beat: pids recycle inside 20 minutes - # on this clone, and the stall bound is longer than that. - if holder_alive && [[ -n "${LAND_LOCK_HOLDER_PID:-}" ]]; then - kill -TERM "$LAND_LOCK_HOLDER_PID" 2>/dev/null || true - fi - beat_note x stalled - exit 1 - fi - if ! observe; then - misses=$((misses + 1)) - elif [[ -n "$observed_sha" ]] && ! mine; then - # Unambiguous: somebody else's id is on the lease. No retry can undo - # that, and pretending otherwise is how two sessions both comment. - echo "land-lock: lease lost to $observed_holder" - beat_note x lease-lost - exit 1 - else - # Carry the reservation across the beat. See `renew` for why this is - # carried here and deliberately cleared on acquire. - land_next="$observed_next" - if swap "$observed_sha"; then - misses=0 - continue - fi - misses=$((misses + 1)) - fi - [[ "$misses" -lt 3 ]] || { - echo "land-lock: could not renew for $misses beats; letting the lease lapse rather than assuming it" - beat_note x lease-lapsed - exit 1 - } - done - ;; - -held) - # The pre-comment re-check, and the cheap stand-in for a fencing token. A - # holder that was paused past its TTL and stolen from MUST discover that - # before it comments `/fast-forward`, not after — acting on a lease you no - # longer hold is how a lock protocol reintroduces the collision it removed. - # - # It demands MARGIN, not merely a lease that has not expired yet. "Not - # expired" is a fact about the instant of the check, and the caller then goes - # on to do something — post a comment, wait for a bot — so a lease with one - # second left passes this and is gone before the action it authorised takes - # effect. That is the same time-of-check/time-of-use gap the fence exists to - # close, just moved a few lines later. - # - # One beat is the right margin because it is the interval at which the holder - # proves it is alive: with at least a beat left, either the heartbeat renews - # and the lease keeps rolling, or it does not and this check would have failed - # anyway. Less than a beat means the next renewal is already overdue. - observe || exit 2 - { [[ -n "$observed_sha" ]] && mine; } || exit 1 - [[ "$((observed_expires - $(now)))" -ge "$beat" ]] || { - echo "land-lock: lease has under ${beat}s left — too little to act on" - exit 1 - } - exit 0 - ;; - -release) - observe || exit 2 - # Releasing a lease we do not hold is not an error: the trap that calls this - # fires on every exit path, including ones that never acquired. Exiting - # non-zero there would turn an orderly cleanup into a reported failure. - { [[ -n "$observed_sha" ]] && mine; } || exit 0 - # Already handed over: re-tombstoning it would mint a second release of the - # same lease and report an epoch-scale age for it (CLOUD-433). A release is - # idempotent in effect, so it must be idempotent in what it says too. - if released; then - echo "land-lock: already released" - exit 0 - fi - # A tombstone, not a delete: CAS the expiry to now, which leaves the lease - # instantly claimable by anyone. That is all a release has to mean, and it - # keeps every write in this task the same single operation. - swap "$observed_sha" 0 || { - echo "land-lock: could not release; it expires in $((observed_expires - $(now)))s" - exit 0 - } - echo "land-lock: released after $(age)s" - exit 0 - ;; - -status) - observe || exit 2 - # Expired and absent are one state to a caller: both mean the next `acquire` - # will win. The distinction still matters for diagnosis, so an expired lease - # names who left it and how long ago rather than vanishing from the report — - # a lease nobody released is the tell for a session that died holding one. - if [[ -z "$observed_sha" ]]; then - echo "land-lock: unheld" - exit 0 - fi - # Checked BEFORE `expired`, because a tombstone satisfies both: its expiry is - # 0, so `now >= 0` is trivially true and the expired branch would render - # `free for s` — `free for 1786499426s`, observed live after the - # lease's first fleet release (CLOUD-433). `land-lock-check` already drew - # this distinction; `status` never did. - if released; then - echo "land-lock: released — last held by $observed_holder" - exit 0 - fi - if expired; then - echo "land-lock: unheld — last held by $observed_holder, free for $(($(now) - observed_expires))s" - exit 0 - fi - # The successor is named when there is one, because "who else may be spending - # a matrix right now" is the question a reader of this verb is actually - # asking, and a bound of two that reports as a bound of one is the kind of - # gap between mechanism and diagnosis this file keeps closing. Pointer-only - # still: a ref name, never a body. - # HELD AND ADVANCING IS NOT HELD AND STALLED (CLOUD-499), and rendering them - # identically is how a wedged fleet looked healthy for as long as anyone - # cared to watch. A count of seconds this token has not moved, never what the - # holder is doing — the phase belongs to `alive`, and the payload belongs - # nowhere (non-negotiable 4). - # READ FROM THE TOKEN, NOT FROM THE SIGHTING FILE, and that is a correctness - # choice rather than a shortcut. `progress_held_for` RECORDS what it sees — - # it is the corroboration `acquire` steals on — so calling it from a reader - # would let a `status` run move the instant a rival's steal becomes due, and - # would report nothing on a first call anyway, since a first sighting is 0 by - # construction. The token's own first field is the holder's last advance, and - # reading it here costs nothing and changes nothing. - # - # It is the holder's clock, which is exactly why no PREDICATE may use it. A - # diagnostic line may: the worst a skewed reading does here is print a number - # a human squints at, where the steal path stealing on one would put two - # holders on the same trunk. - stalled= - if [[ -n "$observed_progress" ]]; then - advance=${observed_progress%%.*} - if [[ "${advance:-0}" -gt 0 ]] 2>/dev/null && - [[ "$(($(now) - advance))" -ge "$((stall_beats * beat))" ]]; then - stalled=", stalled $(($(now) - advance))s" - fi - fi - if [[ -n "$observed_next" ]]; then - echo "land-lock: held by $observed_holder, $((observed_expires - $(now)))s left$stalled, $observed_next admitted behind it" - else - echo "land-lock: held by $observed_holder, $((observed_expires - $(now)))s left$stalled" - fi - mine && exit 0 - exit 1 - ;; - -authorises) - # CLOUD-420. THE ONE QUESTION A RUNNER CAN ASK: may this branch spend a - # matrix right now? Every other verb answers about THIS clone — `mine` - # compares a holder id minted per clone, which a GitHub runner has nothing - # to compare against. A branch name is the one identifier both ends see, and - # `branch:` in the lease body is what makes the lease checkable by the thing - # spending the money rather than only by the code path that cooperates. - # - # Read-only and side-effect free: no mint, no swap, no state file. It is a - # pure function of (lease state, branch) so the suite can drive every row - # without a second clone. - # - # THE EXIT CODES ARE NOT THIS FILE'S USUAL PAIR. 0 run / 3 stop / 2 could not - # look, because "stop" is a third answer that the 0/1 vocabulary cannot - # carry: 1 already means "held by someone else", which here is a REASON to - # stop rather than the instruction. A caller keying on 3 cannot mistake a - # refusal for an error. - # - # FAIL OPEN, EVERYWHERE IT CANNOT TELL. Every other refusal in this file - # fails closed, and this one deliberately does not: a lease it cannot read - # stops EVERY job in the fleet, where waving one matrix through costs one - # matrix. The asymmetry is the whole justification, and it is why an - # unreachable remote answers `run` here while it answers `exit 2` in - # `status`. A lease minted before this change carries no `branch:` at all, - # so during rollout that row is not an edge case, it is every lease. - want="$2" - if ! observe; then - echo "land-lock: cannot read the lease; running rather than stopping the fleet" - exit 0 - fi - if [[ -z "$observed_sha" ]] || released || expired; then - echo "land-lock: no lease is held; $want may run" - exit 0 - fi - if [[ -z "$observed_branch" ]]; then - echo "land-lock: the lease names no branch; running rather than guessing" - exit 0 - fi - if [[ "$observed_branch" = "$want" ]]; then - echo "land-lock: the lease authorises $want" - exit 0 - fi - # THE ADMITTED SUCCESSOR (CLOUD-369), and the reason the bound is two rather - # than one. A branch that reserved the slot behind this holder is buying the - # matrix that overlaps the holder's merge — so stopping it here would cancel - # the very run the reservation exists to start, and the queue would be cold - # again with the mechanism intact and useless. - # - # Exactly one, by construction: `reserve` fills the slot with a CAS, so the - # lease can name one successor and never two. Nothing here counts, compares - # ages or breaks ties — the field is either this branch or it is not. - if [[ -n "$observed_next" ]] && [[ "$observed_next" = "$want" ]]; then - echo "land-lock: the lease authorises $want as the successor behind $observed_branch" - exit 0 - fi - # Pointer-only (non-negotiable 4): the holder's branch is a ref name the - # caller could read for itself, and naming it is what makes a stopped run - # diagnosable rather than mysterious. No lease body, no expiry arithmetic. - echo "land-lock: the lease authorises $observed_branch, not $want" - exit 3 - ;; - -peek) - # CLOUD-369. ONE ADVISORY FIELD, ON STDOUT, FOR A CALLER THAT MEANS TO ACT ON - # IT. `status` is prose for a human; a caller parsing that sentence would turn - # a message into an interface, and the next edit to the wording would be a - # silent breakage. This prints the field alone, or nothing. - # - # Silent and 0 when the lease is absent, released or expired: "no lease names - # a head" is a legitimate reading a waiter handles by staying on `origin/main`, - # not an error it should report. Exit 2 stays reserved for "could not look". - if ! observe; then - exit 2 - fi - if [[ -z "$observed_sha" ]] || released || expired; then - exit 0 - fi - case "$2" in - branch) printf '%s\n' "$observed_branch" ;; - head) printf '%s\n' "$observed_head" ;; - next) printf '%s\n' "$observed_next" ;; - esac - exit 0 - ;; - -reserve) - # CLOUD-369. THE SECOND MATRIX, AND THE ONLY ONE. The lease bounds confirming - # runs at one, which is correct for cost and wrong for latency: after every - # merge the queue is empty, and the next branch starts cold — a rebase, a - # `verify` and a full matrix — before `main` can move again. Admitting one - # successor while the holder is still merging is what overlaps that window. - # - # THE WAITER WRITES IT, NOT THE HOLDER, and that is forced rather than - # chosen: waiters are not registered anywhere, so the holder has no way to - # name one. A waiter appending itself is also what makes the slot a RACE with - # exactly one winner — the same CAS that makes the lease itself safe, used for - # a second, smaller decision. - # - # IT IS NOT A CLAIM ON THE LEASE. Every other field of the holder's lease is - # re-minted verbatim: its holder id, its expiry, its branch, its head. The - # holder keeps holding, its heartbeat carries the new field forward, and - # `mine` — which compares holder ids and nothing else — still answers for the - # holder. A reservation that moved the holder id would be a steal wearing a - # different name. - want="$2" - if ! observe; then - echo "::error:: land-lock: cannot read the lease to reserve behind it" >&2 - exit 2 - fi - # Nothing to reserve behind. Not an error: a free lease means the caller - # should be ACQUIRING, and reporting that is more useful than a refusal. - if [[ -z "$observed_sha" ]] || released || expired; then - echo "land-lock: no lease is held; acquire rather than reserve" - exit 1 - fi - # Reserving behind yourself would authorise your own branch twice and admit - # nobody, which is worse than doing nothing: it consumes the one slot. - if [[ "$observed_branch" = "$want" ]]; then - echo "land-lock: $want already holds the lease; nothing to reserve" - exit 1 - fi - # The slot is taken. Idempotent for the branch that already holds it, so a - # waiter re-reserving each lap is a read rather than a churn of the ref. - if [[ -n "$observed_next" ]]; then - if [[ "$observed_next" = "$want" ]]; then - echo "land-lock: $want is already the admitted successor" - exit 0 - fi - echo "land-lock: $observed_next is already the admitted successor, not $want" - exit 1 - fi - # Re-mint the holder's lease with one field added. The `mint_*` overrides are - # what keep this a single writer of the body: without them this path would - # carry its own copy of the printf, and a copy is where a field silently - # stops matching what `observe` reads. - # - # `mint_expires` is the holder's own instant, NOT recomputed — a reservation - # must not extend somebody else's lease, and recomputing it here would hand - # the holder a fresh TTL every time a waiter arrived. - if mint_holder="$observed_holder" mint_expires="$observed_expires" \ - mint_branch="$observed_branch" mint_head="$observed_head" \ - mint_next="$want" mint_progress="$observed_progress" swap "$observed_sha"; then - echo "land-lock: $want admitted as the successor behind $observed_branch" - exit 0 - fi - # Lost the CAS: the holder's heartbeat re-minted, or another waiter took the - # slot first. Either way this is an ordinary loss, and the caller's next lap - # re-reads and re-decides — no retry here, for the same reason `acquire` - # does not retry inside its own CAS. - echo "land-lock: could not reserve behind $observed_branch; the lease moved" - exit 1 - ;; -esac diff --git a/mise-tasks/land.sh b/mise-tasks/land.sh deleted file mode 100755 index 77b65a505..000000000 --- a/mise-tasks/land.sh +++ /dev/null @@ -1,2250 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Land this branch's PR: rebase, verify, push, wait for CI, /fast-forward — lapping until it merges or a rebase conflicts" -# -# Workflow-contract step 4, and it drives the WHOLE loop rather than one lap of -# it. `main` advances constantly, so the fast-forward bot refuses the moment -# this branch stops being a direct descendant. That refusal is the design -# working: each lap rebases onto a little more landed work, so conflicts arrive -# one small resolvable increment at a time instead of accumulating until a -# branch cannot land at all. -# -# A lap is fetch → rebase → `verify` → `verified` → push → `ci-wait` → -# `/fast-forward` → read the answer, and a refusal simply starts the next one. -# Every lap re-verifies and re-waits because a rebase mints a new SHA and the -# receipts keyed to the old one are gone — that is the loop, not a re-run of an -# already-tested commit. `verified` reads the receipt keyed to this exact HEAD, -# which also carries the `origin/main` it was linear against, so there is no -# second linearity check here; a second copy would be a second authority. -# `ci-wait` makes landing a red PR structurally impossible. Both are called -# per-lap rather than declared as `#MISE depends`, because a dependency runs -# once and a loop needs them every time round. -# -# **The stops are the things bash cannot reason about**: a rebase that CONFLICTS, -# a local `verify` that fails, a CI run that comes back red on a branch whose -# `verify` was green, and — since CLOUD-323 — a PR body that defers a decision -# without naming the issue that owns it, which needs a human to file one. Everything else in a lap is mechanical, so -# this runs unattended and halts exactly at the steps that need a decision (AGENTS.md, "When you SHOULD still stop") — which is also the -# step frequent lapping keeps small. CLOUD-238: ending the lap and leaving the -# caller to rebase and retry was only half the design. Measured over one -# session, every refusal arrived out-of-band and was handled by hand, and from -# that the agent inferred landing was "a race I keep losing" and began batching -# rebase→verify→push→land into one command to close the window — optimising -# against the design, since batching removes no refusal and only makes each lap -# bigger. A loop a caller has to notice is a loop a caller will eventually -# mis-model, so the task laps itself and the inference has nowhere to start. -# -# The merge button is blocked on purpose: `main` only advances to a SHA that -# already passed CI. Commenting is the whole landing action, but the *result* is -# asynchronous, so the naive form is a comment plus a guessed `sleep`, which -# either reports too early or wastes time. Poll instead, with two exit -# conditions so it cannot hang: the PR reaches a terminal state, or the -# fast-forward workflow concludes anything but success. -# -# CLOUD-235 — the refusal condition used to be dead code, and the shape of the -# mistake is worth keeping: it filtered `commits/$sha/check-runs` for a run named -# `fast-forward`, where `$sha` is the PR head. But the bot triggers on -# `issue_comment`, and an `issue_comment` run attaches its check-run to the -# DEFAULT-BRANCH TIP, never to the PR head — two SHAs that differ by -# construction before a landing, so the filter always returned empty. (The -# workflow also grants no `checks: write`, so it could not have created one on -# any SHA.) A refusal is a PR comment plus a failed workflow-run conclusion, and -# nothing else. So the verdict is read from the run's `conclusion`: an exit code, -# not prose, which is the only kind of thing a gate may decide on. The task then -# claimed for months to have an exit condition it did not have, and polled -# forever the first time a refusal arrived. -# -# A lap's wait is a RACE between two answers (CLOUD-240). `ci-wait` answers "is -# this SHA green"; `main-watch` answers "is this SHA still landable". The moment -# `main` advances, the run in flight is already waste — its verdict cannot be -# used and the bot will refuse — so waiting it out to be told what is already -# knowable is the expensive way to learn nothing. Whichever answers first -# decides, and a `main-watch` win simply starts the next lap. The push that lap -# makes cancels the doomed run through the workflows' `concurrency: -# cancel-in-progress`, so nothing here cancels a run by hand. -# -# Two more economies, both from the same premise — a runner is metered and this -# sandbox is not: -# -# * A lap whose HEAD already carries a `verify` receipt does not re-run -# `verify`. A refusal with an unmoved `main` changes no bytes, and the -# receipt is keyed to the exact commit, so re-proving it is work with a -# known answer. -# * A red CI converts the PR back to DRAFT before stopping. CI does not run on -# drafts, so this closes the tap while the failure is diagnosed locally; -# left ready, the next push from any source starts another run over a -# failure nobody has fixed yet. `land` readies it again on the lap that -# follows, which is the single event that spends the one confirming run. -# -# The poll is deliberately unbounded, like `ci-wait`: the fix for a hang is an -# exit condition that can actually fire, never a wall-clock timeout, which would -# only reintroduce the VM-reap gap. `LAND_MAX_LAPS` bounds the number of LAPS -# instead — a runaway backstop, not a timeout on any wait: hitting it means -# `main` is moving faster than a lap takes, which is a real condition a human -# should see. Run it backgrounded; several laps are normal and each costs a CI -# run, which is the price of the design, not waste. -# -# `set -e` is off on purpose: this is a poll, and a transient `gh` failure must -# cost one iteration rather than abort the landing. Every command whose failure -# would change the verdict is guarded by hand instead. -# -# MUTATION COVERAGE (CLOUD-418). `||`: applying -# the script to a throwaway copy of this file must turn the named case RED. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -# CLOUD-904 replaces an identity mutation with two that discriminate. The -# predecessor was `s/RUN THIS AGAIN/look/` against an assertion hardcoding `RUN -# THIS AGAIN`: it reddened its case by matching the same literal from both sides, -# which proves the string is present and nothing about the property. These two -# break the PROPERTY instead — the first restores the refuted diagnosis into the -# emission site, the second gives the expensive path the free path's remedy. -#MUTANT lap-cap-asserts-refuted-diagnosis|s/The count is what the readying recorded, not the lap number/\\`main\\` is moving faster than a lap takes/|the lap cap's refusal states what its own accounting supports -#MUTANT lap-cap-remedies-swapped|s/run this again, which commits up to another \$max_laps./the fleet is saturated: wait, or land later./|the two exhaustions give imperatives consistent with their costs -#MUTANT exit-codes-collapse|s/^readonly LAND_EXIT_RUNAWAY=5$/readonly LAND_EXIT_RUNAWAY=4/|CLOUD-399: the two exhaustions are told apart by CODE -#MUTANT declined-always|s/^\t\[\[ \"\$rc\" = 3 \]\]$/\ttrue/|red CI stops the lap without asking for the merge -# CLOUD-369. The admission predicates, each proven to discriminate rather than -# merely to exist. Case names carry no regex metacharacters: `mutant` passes the -# name to `bats --filter`, which reads it as a PATTERN, so a clause spelled -# `(b1)` matches nothing and the row reports `names-no-case` instead of a verdict. -# -# THE CONFLICT CLAUSE TAKES TWO ROWS, not one, because it has two halves that -# fail independently: `speculate` must RECORD the conflict it computed, and the -# admission must READ it. One row could only ever show that the pair works, not -# that either half does — and a half that stops discriminating is exactly how a -# mutation survives while its case still passes (measured on CLOUD-520, where a -# row neutering one half of a two-part predicate was caught SURVIVING). -#MUTANT admits-without-green|s/elif ! mise run checks-green "\$holder_head" >\/dev\/null 2>&1; then/elif false; then/|CLOUD-369 clause b1-neg — a holder whose CI answers RED admits nobody -#MUTANT admits-a-conflicting-base|s/^\t\tif \[\[ "\$admitted" = 0 \]\] \&\& \[\[ "\$spec_conflicts" = 1 \]\]; then$/\t\tif false; then/|CLOUD-369 clause e — a waiter whose base CONFLICTS is not admitted -#MUTANT admits-with-no-head|s/if \[\[ -z "\$holder_head" \]\]; then/if false; then/|CLOUD-369 clause b1-neg — a lease naming no head admits nobody -#MUTANT speculation-never-conflicts|s/^\t\tspec_conflicts=1$/\t\tspec_conflicts=0/|CLOUD-369 clause e — a waiter whose base CONFLICTS is not admitted -#MUTANT verdict-first-page-only|s/\[\[ \"\$ff_seen\" -lt 100 \]\] && break/break/|fell off the first page - -set -uo pipefail - -cd "${LAND_ROOT:-$(git rev-parse --show-toplevel)}" || exit 1 - -branch="${LAND_BRANCH:-$(git rev-parse --abbrev-ref HEAD)}" -# THE OPEN PR FOR THIS BRANCH, AND ONLY AN OPEN ONE (CLOUD-465). A bare -# `gh pr view` answers with whatever PR this branch name has EVER had, in any -# state — so once a name has carried a merged PR, every later landing on that -# name binds to the merged one. That is the default shape here rather than an -# edge case: trunk-based development deletes the branch on merge (CLOUD-349) -# while the session harness pins an agent to one branch name for its whole -# engagement, so the second landing of any session recycles a merged name. -# -# Observed: after #366 merged, a new commit and a new PR #368 on the same name -# produced `could not re-draft #366`. What kept that from being worse was -# incidental — `redraft` runs before the wait loop and GitHub refuses to -# re-draft a merged PR, so the run died before reaching the terminal-state read -# below, which treats MERGED as landed and exits 0. A bound-merged PR is one -# refactor away from reporting a landing that never happened, and a false -# completion signal is the one thing this repository exists to refuse. -# -# `// empty` rather than a null: `--jq` prints the string "null" for a missing -# field, which is not empty and would sail past the guard as a PR number. -pr="${PR:-$(gh pr list --head "$branch" --state open --json number --jq '.[0].number // empty' 2>/dev/null)}" -if [[ -z "$pr" ]]; then - echo "::error:: no open pull request for this branch, so there is nothing to land. Open one first: gh pr create --draft" >&2 - exit 1 -fi -interval="${LAND_INTERVAL:-10}" -workflow="${LAND_WORKFLOW:-fast-forward.yml}" -# THE TWO BACKSTOPS BOUND DIFFERENT RESOURCES, AND USED TO BE PRICED AS IF THEY -# BOUND THE SAME ONE (CLOUD-399). A lap is METERED: it buys a CI matrix, measured -# at ~17 job-minutes. A lease wait is FREE: a conditional poll against a ref, no -# runner. Both defaulted to 8, which authorised ~2 runner-hours of metered spend -# against ~16 minutes of free waiting — the expensive budget draining first -# (measured on #302, 2026-08-12, four active sessions: 5 waits lost and 3 laps -# entered in 22 minutes). The trade these defaults must express is MANY FREE -# WAITS, FEW PAID LAPS. -# -# Neither is a clock. `max_laps` counts laps, `max_waits` counts whole lease turns -# lost; a wall-clock cap on either reintroduces the VM-reap gap and lands as a -# false "refused" on a slow bot (`mem:workflow/landing-loop`). -max_laps="${LAND_MAX_LAPS:-2}" -# Consecutive whole lease waits lost before landing reports the fleet saturated. -# ~64 is the queue depth a contended fleet reaches — ~2h at the observed 2-5 -# minute lease turn — and waiting that long costs nothing but wall clock. -max_waits="${LAND_LOCK_MAX_WAITS:-64}" -# How many consecutive passes may end with no readable answer from the bot before -# this stops (CLOUD-413). A count of unreadable answers, not a clock on the poll. -max_unknowns="${LAND_ANSWER_MAX_UNKNOWNS:-3}" -# How many provisioning transients may be absorbed by re-running the failed jobs -# before this stops (CLOUD-483). A count of absorbed transients, not a clock on -# how long they keep happening: three in a row is a broken world, not a flake, -# and the stop says so rather than re-running forever. -max_transients="${LAND_MAX_TRANSIENTS:-3}" - -# EXIT CODES, NOT PROSE, ARE WHAT A CALLER DECIDES ON (CLOUD-399). Every stop -# used to be `exit 1`, so a saturated fleet ("wait, and land later — nothing is -# wrong") and a runaway branch ("`main` moves faster than a lap takes — look") -# were indistinguishable to anything but a human reading stderr. A fleet driver -# keying on a status could not tell "retry me later" from "I am broken", which is -# the same class of defect as reading a non-answer as an answer. -# -# The two exhaustions therefore get their OWN codes, and `die` keeps 1 so the -# other eighteen call sites are unchanged. The house-style 0/1/2/3 table governs -# the CLI's verbs; `land` is a lifecycle task, and these are additive stop -# reasons above that range rather than a re-spelling of it. -readonly LAND_EXIT_FLEET_SATURATED=4 -readonly LAND_EXIT_RUNAWAY=5 - -die_with() { - local code="$1" - shift - echo "::error:: land: $*" >&2 - exit "$code" -} - -die() { - die_with 1 "$@" -} - -# THE ROSTER, GUARDED WHERE AN EXIT CAN ACTUALLY EXIT (CLOUD-467). `graded_runs` -# reads `$CI_REQUIRED_CHECKS`, and its abort on an unset one used to be swallowed -# by a `|| n=0` written for a transient API failure — so the one input this task -# cannot compute became `0`, which both call sites read as "this head carries no -# graded run": the branch that FIRES THE READY THAT STARTS CI. -# -# The guard belongs HERE and not in that function, which is the correction that -# matters. Both call sites wrap it in `$( )`, so a `die` inside it exits the -# SUBSHELL only — the lap then continues with an empty reading and falls into the -# unbounded answer poll, turning a fail-closed guard into a hang. Measured: the -# suite stopped terminating at all. -# -# `checks-green` guards the same variable for the same reason, and the two are -# deliberately paired, so opposite behaviour on a missing roster is exactly the -# drift that pairing exists to prevent. -[[ -n "${CI_REQUIRED_CHECKS:-}" ]] || - die "CI_REQUIRED_CHECKS is unset — run this through \`mise run land\`, which is where the required set is declared. Readying a PR against an unknown roster would spend a matrix to answer a question this task could not ask." -# The answered set is guarded HERE for exactly the same reason and in exactly the -# same place (CLOUD-376, CLOUD-467): `graded_runs` reads it, both call sites wrap -# that in `$( )`, and a `:?` abort inside a subshell exits the subshell only — -# turning a fail-closed guard into an empty reading, which both call sites read as -# "this head carries no graded run". That is the branch that fires the ready. -[[ -n "${CI_ANSWERED_CONCLUSIONS:-}" ]] || - die "CI_ANSWERED_CONCLUSIONS is unset — run this through \`mise run land\`, which is where the answered set is declared. An empty set makes every conclusion an answer, which is a false green in a new spelling." - -# Stopping on a red run without closing the tap is a leak this exists to plug: -# CI skips drafts, so re-drafting is what stops the next push — from any source -# — spending another runner on a failure nobody has fixed yet. -redraft() { - gh pr ready "$pr" --undo >/dev/null 2>&1 && - echo "land: re-drafted #$pr — ${1:-CI does not run on drafts, so nothing more is spent until this is fixed locally}" -} - -# --- the landing lease (CLOUD-393) ---------------------------------------- -# -# Only one branch at a time may spend CI on a landing attempt. Measured before -# this existed: 248 attempts in 30 minutes produced 5 merges, because every -# branch that finished CI had already gone behind by the time it asked. Waiting -# is free; a lost lap costs a whole CI matrix, and this converts the second into -# the first. -# -# `heartbeat_pid` renews the lease for as long as this lap holds it. It must be -# reaped on EVERY way out — the merged path, a die, a signal — or a dead session -# leaves a beating lease that nobody can steal, which is the one failure mode -# that would wedge the fleet rather than merely slow it. -heartbeat_pid= -# CLOUD-434: a group TERM is a request, not a fact — it demonstrably missed -# grandchildren twice in one loaded gate run, and the survivors held bats' -# output fd and wedged the whole gate. So after every TERM-and-wait, the reap -# verifies the GROUP is gone — `kill -0 -- -pgid` answers for any surviving -# member, where `wait` proves only that the leader died — and escalates a -# survivor to SIGKILL rather than trusting it to die eventually. Run AFTER the -# wait, so a group exiting gracefully is never escalated for being mid-exit. -reap_residue() { - ! kill -0 -- -"$1" 2>/dev/null || kill -9 -- -"$1" 2>/dev/null -} - -# THE RENDEZVOUS (CLOUD-383). `wait -n` needs bash 4.3 and its PID-list form -# needs 5.1; macOS ships bash 3.2 as `/bin/bash` and `mise registry` carries no -# bash, so this file was the last bash-4 construct in the tree — CLOUD-282 fixed -# every other macOS blocker and could not touch it because another session held -# the file. `darwin-link` is a required check, so this is not hypothetical. -# -# A FIFO is the portable form of the same wait: each racer writes one byte AFTER -# writing its rc file, and the parent's blocking read returns as soon as either -# does. That ordering is what preserves the invariant both call sites read — the -# loser is killed before it reaches its `echo`, so its rc file stays EMPTY, and -# "empty means this racer never finished" still decides the lap. -# -# A racer that finishes second may block writing to a FIFO nobody is reading any -# more. That is harmless and deliberate: the group kill below reaps it, and its -# rc file was already written before the write it is blocked on. -# RETURNS non-zero; it does NOT die. Every caller wraps it in a command -# substitution, and a `die` inside `$( )` exits the SUBSHELL — the lap would carry -# on with an empty fifo path and both rc files empty, reporting "no verdict" over -# a race that never ran. `graded_runs` carries the same warning for the same -# reason (CLOUD-467); this is that defect rediscovered by writing it again. -new_rendezvous() { - local f - f="$(mktemp -u)" || return 1 - mkfifo "$f" 2>/dev/null || return 1 - printf '%s' "$f" -} -# The winner's token, set by `await_first` in THIS shell (see there). -AWAIT_WINNER="" -await_first() { - # The byte NAMES THE WINNER, and the caller voids the loser's rc with it - # (CLOUD-510). It used to be discarded — "which racer won is read from the rc - # files" — and that reading is only correct while the loser leaves no file. - # - # The loser usually leaves none, because the group kill lands before its - # `echo $? >` runs; measured, and that is why this was safe enough to ship. - # But "usually" is doing load-bearing work there: the two racers are answering - # at the same instant by construction, so nothing stops the loser finishing on - # its OWN — `ci-wait` returning a red verdict in the same breath as - # `main-watch` reporting main moved. Then the rc file is non-empty, honestly - # written, and about a run whose verdict is already void; the arm below reads - # it and stops the landing over a run the next lap supersedes anyway. - # - # Emptiness is a proxy for "this racer lost". The token is the fact itself, - # and it is minted by the rendezvous that actually decided the race. An empty - # token — an unreadable FIFO — leaves both codes as they are, so a caller that - # cannot learn the winner falls back to exactly today's reading. - # - # THROUGH A GLOBAL, NEVER A COMMAND SUBSTITUTION, and that is not style. The - # read here BLOCKS, and `$( )` runs its body in a subshell: bash defers a trap - # until the current foreground command finishes, so with the read one level - # down a `land` killed mid-race would never reach `on_exit` — it would hang on - # the FIFO with its watchers still polling, which is the leak the trap exists - # to close. Measured: it hung `tests/land.bats`'s "a land killed mid-race - # takes its watchers with it". Assigning in THIS shell keeps the read - # interruptible and the trap prompt. - AWAIT_WINNER="" - read -r AWAIT_WINNER <"$1" || true - rm -f "$1" -} -# The live race pids, so the EXIT trap can reap what an in-flight race spawned. -# A land that dies THROUGH the trap — a TERM, a die inside a wait — used to -# reap only the heartbeat, and a measured 10 minutes of orphaned gh-polling -# ci-wait followed (CLOUD-434's review finding). Two scalars, not a list: the -# races are sequential and at most a pair wide, and scalars need no -# word-splitting. Cleared after every inline reap, so the trap can never kill -# a recycled pid from a race that already ended. -race_pid_a= -race_pid_b= -reap_races() { - local p - for p in "$race_pid_a" "$race_pid_b"; do - [[ -n "$p" ]] || continue - kill -- -"$p" 2>/dev/null || true - reap_residue "$p" - done - race_pid_a= - race_pid_b= -} -drop_lease() { - reap_races - if [[ -n "$heartbeat_pid" ]]; then - kill -- -"$heartbeat_pid" 2>/dev/null || true - wait "$heartbeat_pid" 2>/dev/null - reap_residue "$heartbeat_pid" - heartbeat_pid= - # CLOUD-451's census, and this line is what keeps it honest. The - # heartbeat records a beat per 30s and an `x` on every path where IT - # chooses to stop — but the commonest stop is not one of those: a land - # that finishes normally reaches here and KILLS it, so the loop never - # runs another statement and its last record stays an `h`. Left that - # way, every successful landing would afterwards read as "the container - # died under active work", which is the false positive that would - # wrongly license the mechanism CLOUD-515 removed for want of evidence. - # - # Written HERE and never from the heartbeat's own trap, per CLOUD-491: - # a trap runs on the container kill too, and an `x` from one would erase - # the only distinction the census draws. This is `land` recording that - # IT chose to stop its child, which is exactly the event an `x` means. - census="$(dirname -- "${BASH_SOURCE[0]}")/reclaim-census.sh" - [[ -x "$census" ]] && "$census" note x land-stopped >/dev/null 2>&1 || true - fi - mise run land-lock release >/dev/null 2>&1 || true -} - -# A lap reads `origin/main` twice now — once to rebase onto, once inside the -# hold to confirm it has not moved (CLOUD-369) — and the failure is the same -# failure both times. One definition, so the two cannot drift into disagreeing -# about what an unreachable remote means. -# `--prune` IS LOAD-BEARING, NOT TIDINESS (CLOUD-345). When a PR merges the head -# branch is deleted, but a plain fetch never removes `refs/remotes/origin/` -# — so the tracking ref survives, still naming the SHA that landed. Reusing that -# branch name then hits a `--force-with-lease` whose expectation names a ref the -# remote does not have, and the push is rejected as `stale info` FOREVER: no -# number of laps clears it, because every lap re-fetches without pruning. -# -# Measured 2026-08-11, and it misled three readers at once: this push ("someone -# else moved the branch" — nobody had), the harness stop hook ("20 unpushed -# commits" on a branch whose true unlanded set was 1), and `land`'s own post-merge -# delete ("already gone, or the remote refused" — both halves were live). -# -# It belongs here rather than in each reader: the readers are not all ours, and -# only the landing loop knows when the ref went stale. -fetch_main() { - git fetch -q --prune origin main || - die "cannot fetch origin/main; read mem:github-access before concluding the network is blocked." -} - -# A wait is a lap that spent no CI, and there are now two ways to have one: the -# lease was held by someone else, or it was won over a `main` that had moved. Both -# must refund the lap — a busy fleet would otherwise exhaust LAND_MAX_LAPS on -# waiting alone and give up without ever having attempted — and both must count -# toward the wait backstop, because "never counted" is how a loop becomes -# unbounded with no condition that can fire. -charge_wait() { - lap=$((lap - 1)) - lease_waits=$((lease_waits + 1)) - [[ "$lease_waits" -le "$max_waits" ]] || - die_with "$LAND_EXIT_FLEET_SATURATED" \ - "never won the landing lease in $max_waits attempts, having spent no CI matrix. The fleet is saturated: wait, or land later. Run \`mise run land-lock-check\`, which tells that apart from a wedged lease (a ref nothing legitimate wrote) — they look identical from here." -} - -# The same accounting for a pass that got no READABLE ANSWER from the bot -# (CLOUD-413, CLOUD-414). It is `charge_wait`'s shape for the same reason: the -# pass spent nothing, so charging it to the lap budget would let a rate-limited -# bot exhaust the budget that exists to catch "main moves faster than a lap -# takes" — and would report that diagnosis, which is what CLOUD-413 measured -# being wrong twice over across 24 laps. -# -# An unknown re-ask laps, and on an unmoved `main` that lap is free by -# construction: `verified` short-circuits on the unchanged HEAD, `graded_runs` is -# non-zero because the head just graded green so neither the ready nor the -# `--undo` re-fire can fire, and the force-push moves nothing so no -# `synchronize` event and no run. The lap costs a lease acquire, a fetch and one -# comment. That is what makes re-asking the right move rather than a spend. -# -# A count, never a clock — the bound `mem:github-rest-etiquette` calls the one -# place a retry cap belongs. -charge_unknown() { - lap=$((lap - 1)) - answer_unknowns=$((answer_unknowns + 1)) - [[ "$answer_unknowns" -le "$max_unknowns" ]] || - die "the fast-forward bot gave no readable answer $max_unknowns times running on #$pr (${sha:0:8}). Nothing about this branch is wrong and \`main\` has not moved under it.${rate_reset_note:-} Do: mise run land" -} - -# HONOUR THE NUMBER THE SERVER STATES (CLOUD-413). Measured on PR #323: 24 laps -# across three invocations, never merging, and not one lap failed for any of the -# three reasons `land` stops on. Several refusals were a 403 rate limit, which -# `land` could not tell from "main moved" — so its response to being rate-limited -# was to generate more of exactly the request that was rate-limited, each retry -# costing a `verify`, a CI run and another comment. -# -# `mem:github-rest-etiquette` names this in as many words: a 4xx/5xx means fix the -# interaction rather than retry blindly, and repeated secondary-limit failures get -# backoff bounded by a COUNT — "the one place a retry cap belongs". That cap is -# `$max_unknowns` above, unchanged. What was missing is the delay, and a guessed -# margin is the wrong shape when the server states the number: -# -# retry-after: N wait N -# x-ratelimit-remaining: 0 + …-reset: EPOCH wait until EPOCH -# neither a floor, because some delay beats none -# -# NOT A WALL CLOCK ON ANYTHING. This is a delay before re-asking, on a path that -# has already decided to lap; no wait in this task gains a deadline from it. -rate_limit_pause() { - local headers="$1" retry remaining reset now secs - retry=$(sed -n 's/^[Rr]etry-[Aa]fter:[[:space:]]*\([0-9]*\).*/\1/p' <"$headers" | head -1) - remaining=$(sed -n 's/^[Xx]-[Rr]ate[Ll]imit-[Rr]emaining:[[:space:]]*\([0-9]*\).*/\1/p' <"$headers" | head -1) - reset=$(sed -n 's/^[Xx]-[Rr]ate[Ll]imit-[Rr]eset:[[:space:]]*\([0-9]*\).*/\1/p' <"$headers" | head -1) - now=$(date -u +%s) - secs="" - if [[ -n "$retry" ]] && [[ "$retry" -gt 0 ]] 2>/dev/null; then - secs="$retry" - rate_reset_note=" The API asked for ${retry}s (retry-after)." - elif [[ "${remaining:-1}" = 0 ]] && [[ -n "$reset" ]] && [[ "$reset" -gt "$now" ]] 2>/dev/null; then - secs=$((reset - now)) - # The reset TIME, which the code has and used to throw away in favour of - # telling the human to go run `gh api rate_limit` for it. - rate_reset_note=" The rate limit resets at $(date -u -d "@$reset" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "epoch $reset")." - else - secs="${LAND_RATE_FLOOR:-60}" - rate_reset_note=" The response stated no limit headers." - fi - # A stated reset can be far away; the count is what bounds the loop, so a - # single pause is capped only to keep one lap from swallowing the whole - # budget in one sleep. - [[ "$secs" -le "${LAND_RATE_PAUSE_MAX:-900}" ]] || secs="${LAND_RATE_PAUSE_MAX:-900}" - echo "land: lap $lap — backing off ${secs}s before re-asking;${rate_reset_note}" >&2 - sleep "$secs" -} - -# --- speculative linearization (CLOUD-369) ----------------------------------- -# -# PRE-WARMING IS A LINEARIZATION, NOT A REFRESH. A waiter that rebases onto -# `origin/main` warms nothing: the branch holding the lease is about to replace -# that commit, so the waiter is stale again the moment it wins — which is the -# cold window this exists to close, paid earlier and no cheaper. The `main` worth -# linearizing against is the one about to EXIST, and the lease publishes it as -# `head:`. -# -# Done while waiting, this costs nothing. Local execution is free and CI does not -# run on drafts, so a waiter can rebase, resolve and re-verify indefinitely for -# the price of CPU this sandbox does not meter. -# -# THE HAZARD, AND THE INVARIANT THAT ANSWERS IT. A speculative rebase puts -# ANOTHER BRANCH'S unlanded commits into this branch's history. If that branch -# then fails to land, a fast-forward from here would carry them onto `main` — -# landing somebody else's unmerged work as a side effect of ours, which is a far -# worse failure than the cold window. `origin/main --is-ancestor HEAD` does NOT -# catch it: the speculated base is itself a descendant of main, so that check -# passes for exactly the case that must fail. -# -# So the bet is recorded and settled, never assumed: -# -# spec_base the commit we rebased onto; the bet is "this becomes main" -# spec_undo our HEAD before the rebase; the bet losing costs a reset to it -# -# and `settle_speculation` runs at the TOP of every lap, before anything can -# push. Won (the base is now an ancestor of `origin/main`) keeps the tree; lost -# resets it. There is no path from a losing bet to a push, which is the property -# that makes speculating safe rather than merely fast. -# -# AND THE THIRD READING, WHICH THE FIRST VERSION DID NOT HAVE (CLOUD-495). "Won" -# and "lost" are both statements about `main` MOVING, so a holder that abandons -# while `main` stays put falls through to "pending" — and pending returned 0 -# forever. Measured: a holder whose CI died in a provider incident held the lease -# going nowhere, a sibling linearized onto its published head, and the two -# branches ended at the identical sha with neither able to land. Left to run, the -# waiter wins the lease over an unmoved `main`, the in-hold re-confirmation asks -# only whether `main` moved, and the `/fast-forward` lands the other branch's -# unmerged commits. -# -# So pending is now a POSITIVE claim rather than the absence of the other two: a -# bet is live only while the branch the lease names NOW is somebody else's and -# still carries the base. Everything else is stale — the lease freed, the lease -# passed on, the lease won by us, the lease unreadable. -spec_base= -spec_undo= - -# THE BOUNDARY, PUBLISHED TO THE CHILD (CLOUD-748). `verify` runs `claim-race-check`, -# which reads `claimed-keys`, which cannot otherwise tell a commit this branch -# authored from one this speculation adopted — so it reported the waiter as racing -# the very PR the bet was placed on, twice in one session. These are shell -# variables in this process; a child inherits only what is exported. Called at -# every point the bet is placed or cleared, so the two can never disagree. -publish_speculation() { - if [[ -n "$spec_base" ]]; then - export BATTEN_SPEC_BASE="$spec_base" - else - unset BATTEN_SPEC_BASE - fi -} -spec_main= -# THE CONFLICT THE PROBE ALREADY COMPUTED (CLOUD-369). `speculate` learns, for -# free, whether the holder's base applies to this branch — and until now that -# answer suppressed only the SPECULATION while the reservation below ran anyway. -# A successor whose base is known to conflict is guaranteed to be voided: its run -# grades a head the fast-forward will refuse, and the rebase that follows still -# has to resolve the same conflict. Measured 2026-08-13 for one such admission: -# a full CI run burned, a ~200s `verify` discarded, a hand-resolved conflict, and -# a second run required. So the answer is kept rather than discarded. -spec_conflicts=0 -# Set once the bet has been PUSHED. An unwind then owes the remote a correction -# too: without one, a `die` or a spent lap budget leaves origin holding another -# branch's commits under an open PR, which is the measured two-PRs-at-one-sha -# state. -spec_pushed=0 -spec_ref=refs/batten-spec/base -# A SECOND ref, deliberately. The bet's base and the tip it is checked against are -# two different commits, and reusing `$spec_ref` would overwrite the base while -# answering a question about it. -spec_live_ref=refs/batten-spec/live -# CLOUD-862. Set when this process ADOPTED a bet it did not place, which is the -# case `spec_undo` cannot serve: that variable is the pre-bet HEAD and it died -# with the process that placed it. An adopted bet unwinds by replaying onto -# `origin/main` from the base instead, which needs only the base — the repair -# that recovered this branch by hand was exactly `rebase --onto origin/main -# `, and it never consulted an undo point. -spec_recovered=0 - -# `$spec_ref` EXISTING MEANS A BET IS LIVE, and that is the property CLOUD-862 -# adds. It did not hold before: the ref is a fetch destination written before the -# bet is decided (`speculate` below), so it equally marked a candidate fetched -# and declined, a bet already settled, and a bet in flight. A later `land` could -# read it and learn nothing. -# -# Making it mean one thing costs one call on every path that leaves without a -# live bet. Deliberately NOT a second ref beside it: the state was never missing, -# only unreadable, and a sibling ref would be two authorities on one fact. -forget_bet() { - git update-ref -d "$spec_ref" 2>/dev/null || true - spec_base= - spec_undo= - spec_main= - spec_recovered=0 -} - -# Adopt a bet this process did not place. Runs before `settle_speculation`'s own -# logic, so the settle that follows is the ordinary one — there is no second -# settle path to keep in agreement with the first. -# -# The ancestry pair is the whole predicate, and both halves are load-bearing: -# the base must be an ancestor of HEAD (this tree really is linearized on it, -# rather than the ref being left over from a clone that reset) and must NOT be -# an ancestor of `origin/main` (it has not landed, so the bet is still open). -# A ref failing either test is stale and is dropped rather than acted on. -recover_speculation() { - local recovered - [[ -z "$spec_base" ]] || return 0 - recovered=$(git rev-parse --verify -q "$spec_ref" 2>/dev/null) || return 0 - # DELIBERATELY NOT deciding "did it land" here. `settle_speculation`'s first - # arm already answers that, and answers it out loud; an arm here would be a - # second place deciding one thing, and the one that stayed silent — which is - # how this whole class went unnoticed. Adopt, then let the ordinary settle - # run. The only judgement this function makes is whether the ref describes - # THIS tree at all. - if ! git merge-base --is-ancestor "$recovered" HEAD 2>/dev/null; then - # The ref names a commit this tree is not built on, so whatever it was - # recording is not true of this HEAD. - forget_bet - return 0 - fi - spec_base="$recovered" - spec_recovered=1 - publish_speculation - echo "land: adopting an unsettled speculation on $(git rev-parse --short "$recovered") left by an earlier run; settling it before anything is pushed" -} - -# THREE OUTCOMES, NOT TWO, and conflating the middle one with the last is the -# defect worth naming: a bet is usually still PENDING at the next lap. The holder -# takes minutes to land, so "not on main yet" is the normal reading, and -# unwinding on it would undo the linearization every single lap and leave the -# mechanism running while achieving nothing — warm, then cold, then warm again. -# -# won the base is an ancestor of `origin/main`; the holder landed -# pending `origin/main` has not moved since the bet; nothing has been decided -# lost `main` moved and took something else; the bet cannot come true -# -# IS THE BET STILL LIVE? Asked of the lease as it reads NOW, not of the lease as -# it read when the bet was placed. Fails closed everywhere: an unreadable lease, -# an unfetchable branch and an unknown ancestry are all "stale", because failing -# open here would make a network blip the thing that lands somebody else's work. -bet_is_live() { - local now - now=$(mise run land-lock peek branch 2>/dev/null || true) - # Nobody holds it, or WE do. Holding the lease with the base not yet on - # `main` can only mean the branch we bet on is gone: a base that actually - # landed is caught one arm earlier, by the ancestry check against - # `origin/main`. So this costs a warm tree in no case that was going to win. - [[ -n "$now" ]] || return 1 - [[ "$now" != "$branch" ]] || return 1 - git fetch -q origin "+refs/heads/$now:$spec_live_ref" 2>/dev/null || return 1 - # The holder may have changed, or force-pushed past our base. Either way the - # question is the same one: is the commit we bet on still on the branch that - # is about to become `main`. Asked of the REF rather than a sha resolved from - # it, so a ref the fetch did not actually write is a non-zero exit here rather - # than a resolve step that has to remember to fail closed. - git merge-base --is-ancestor "$spec_base" "$spec_live_ref" 2>/dev/null -} - -# Drop the borrowed range, and correct the remote if the bet was published. The -# lap's ordinary rebase onto `origin/main` runs immediately after this returns, -# so there is nothing to re-linearize by hand — and `speculate` re-bets on -# whoever holds the lease now, which is the "rewind onto the next holder" half. -unwind_speculation() { - # TWO UNWINDS, because an adopted bet has no undo point (CLOUD-862). The - # reset is exact and stays the path whenever this process placed the bet. - # The replay is for a bet inherited from a dead run: it needs only the base, - # and it is what recovered this branch by hand — `origin/main..HEAD` minus - # the borrowed range is precisely this branch's own commits. - if [[ -n "$spec_undo" ]]; then - echo "land: $1; unwinding to $(git rev-parse --short "$spec_undo") rather than carrying another branch's commits" - git reset -q --hard "$spec_undo" || die "could not unwind the speculative rebase; the tree is not somewhere this loop can push from." - else - echo "land: $1; replaying this branch's own commits onto $(git rev-parse --short origin/main) rather than carrying another branch's" - git rebase --onto origin/main "$spec_base" >/dev/null 2>&1 || { - git rebase --abort 2>/dev/null || true - die "could not replay off the adopted speculation base $(git rev-parse --short "$spec_base"); the tree carries another branch's commits and this loop must not push it." - } - fi - if [[ "$spec_pushed" = 1 ]]; then - # Re-draft BEFORE moving the ref, the same ordering the red path uses: - # the corrective push emits a `synchronize`, and a ready PR would spend a - # matrix on it. The next lap readies again when it has something worth - # confirming. - redraft "its published head carried a base that is not landing, so the branch is being rewound" - git push --force-with-lease -u origin "$branch" >/dev/null 2>&1 || - echo "land: could not rewind the published branch; it still carries commits that are not landing" - spec_pushed=0 - # The run in flight graded a head this branch no longer has, so the - # successor's ready/push pair is owed again for the new one. - admitted_sha= - fi - forget_bet - publish_speculation -} - -settle_speculation() { - # ASK GIT BEFORE ASKING THE PROCESS (CLOUD-862). This used to open on - # `[ -n "$spec_base" ] || return 0`, so a `land` that had not placed the bet - # itself returned on the first line — while the ref holding the answer sat - # on disk beside it. Measured: a stopped `land` left seven of another - # branch's commits in the tree, and the next one ran a full clean `verify` - # and reached the push with them. - recover_speculation - [[ -n "$spec_base" ]] || return 0 - if git merge-base --is-ancestor "$spec_base" origin/main 2>/dev/null; then - echo "land: the speculation landed — already linearized on $(git rev-parse --short origin/main), no rebase needed" - forget_bet - spec_pushed=0 - publish_speculation - return 0 - fi - # An ADOPTED bet has no `spec_main` to compare against — the process that - # recorded it is gone — so the "has main moved" arm cannot judge it. The - # lease can: `bet_is_live` reads who holds it NOW and whether the base is - # still on the branch about to land, which is the question either way. - if [[ "$spec_recovered" = 1 ]]; then - if bet_is_live; then - return 0 - fi - unwind_speculation "an earlier run bet on a base that is no longer landing" - return 0 - fi - if [[ "$(git rev-parse origin/main)" = "$spec_main" ]]; then - # `main` has not moved, which used to end the question. It does not: the - # holder can go away without `main` moving at all, and that reading is - # indistinguishable from "still landing" unless the lease is re-read. - if bet_is_live; then - # Undecided. Keep the tree: the holder is still landing, and this - # branch is already linearized behind it. - return 0 - fi - unwind_speculation "the branch this speculation bet on is no longer the base that is about to land" - return 0 - fi - # The bet lost: the holder did not land, or `main` took something else. Undo - # it rather than trying to salvage it — our own commits are all that - # `spec_undo` holds, and the lap's ordinary rebase onto `origin/main` is - # about to run anyway. - unwind_speculation "the speculation did not land" -} - -# Rebase onto the head the lease says is about to become `main`. Every failure -# here is a FALLBACK, never a stop: the holder may never land, so a conflict -# against its head is information about a base that may not happen, not the -# `die`-worthy conflict the real rebase onto `origin/main` reports. -speculate() { - local base head_ref - head_ref="$1" - [[ -n "$head_ref" ]] || return 0 - # Fetch the holder's branch into a ref of our own rather than reading - # FETCH_HEAD, which is one file per clone and therefore racy the moment - # anything else in this process fetches (`land-lock`'s own read path carries - # the same fix, for the same reason). - git fetch -q origin "+refs/heads/$head_ref:$spec_ref" 2>/dev/null || { - echo "land: cannot fetch $head_ref to speculate on; staying linearized on main" - return 0 - } - base=$(git rev-parse "$spec_ref" 2>/dev/null) || { - git update-ref -d "$spec_ref" 2>/dev/null || true - return 0 - } - # ONE OUTSTANDING BET AT A TIME. A waiter laps repeatedly while the same - # holder lands, and re-betting on each lap would overwrite `spec_undo` with a - # HEAD that is itself speculative — so unwinding would restore a tree that - # still carried somebody else's commits, which is the exact hazard the undo - # exists to remove. It would also re-mint a sha every lap and throw away a - # verify receipt for no gain, since the base has not changed. - [[ "$spec_base" != "$base" ]] || return 0 - # Already a descendant — nothing to speculate, and rebasing would be a no-op - # that still mints a new sha and throws away this HEAD's verify receipt. - # The ref goes with it (CLOUD-862): the fetch wrote it before this branch was - # taken, and leaving it behind is what made its existence mean nothing. - if git merge-base --is-ancestor "$base" HEAD; then - git update-ref -d "$spec_ref" 2>/dev/null || true - return 0 - fi - # Only ever our own last NON-speculative HEAD. Settling clears it, so a bet - # placed after a settled one records the right undo point. - [[ -n "$spec_undo" ]] || spec_undo=$(git rev-parse HEAD) - if ! git rebase "$base" >/dev/null 2>&1; then - git rebase --abort 2>/dev/null || true - git reset -q --hard "$spec_undo" 2>/dev/null || true - spec_undo= - # No bet was placed, so the ref must not claim one (CLOUD-862). - git update-ref -d "$spec_ref" 2>/dev/null || true - # A conflict discovered here is the whole point of doing this early: it - # is free now and expensive later. Reported, not resolved — resolving - # another branch's conflict before it has landed would be resolving it - # against a base that may never exist. - spec_conflicts=1 - echo "land: $head_ref conflicts with this branch; not speculating on it (the conflict is real and arrives when it lands)" - return 0 - fi - spec_conflicts=0 - spec_base="$base" - # The `main` this bet was placed against. Without it "not landed yet" and - # "landed something else" are the same reading, and the bet would be unwound - # every lap while the holder was still perfectly on course. - spec_main=$(git rev-parse origin/main) - publish_speculation - echo "land: speculatively linearized onto $head_ref@$(git rev-parse --short "$base") — the main that is about to exist" -} - -# --- say what this land is doing, so nobody has to read its log (CLOUD-425) --- -# -# `land` is backgrounded by contract, and a backgrounded task's death used to be -# observable only as a harness notification that does not survive a container -# restart. Pushing the phase at transitions this loop ALREADY has means the -# answer is readable — by `mise run alive` — without cooperation from a process -# blocked in a 200s gate, and without anyone grepping this task's log. -# -# Every call is best-effort: a land must never fail because its own bookkeeping -# could not be written. The registry degrades to silence; it never lies. -note_phase() { - mise run task-registry phase "$$" "$1" >/dev/null 2>&1 || true -} -# Nested gates report against THIS task's entry rather than minting one per -# subprocess: `step-receipt check` reads it to name the step `verify` is on, so -# a land blocked in a 200s `cargo test` says `test:cargo` rather than `verify`. -export BATTEN_TASK_PID=$$ - -# --- one land per clone (CLOUD-428) ------------------------------------------ -# -# The landing lease cannot answer this: it is re-entrant per clone by design, so -# two `land` processes in one checkout both acquire and the second heartbeat -# renews the first's lease. Measured 2026-08-12 — three concurrent lands on one -# branch, rebasing and pushing against each other for ~30 minutes. -# -# `singleton_held` is why the release is conditional: a REFUSED land must run -# its EXIT trap without deleting the lock the live one holds. Same discipline as -# `target-ensure`'s `held` flag, for the same reason. -singleton_held=no - -# CLOUD-458. Readying is how this task says "a landing is in progress"; nothing -# said the opposite when it stopped. Only the RED path re-drafted, so a landing -# interrupted any other way — a lost lease, a rebase conflict, a stopped task — -# left the PR ready for good, and every later push to it bought a full matrix -# with no landing attempt in progress at all. Measured 2026-08-12: four -# concurrent `pull_request` matrices, three of the four PRs `draft=false` behind -# an interrupted land. -landed=no - -# CONDITIONAL ON THE VERDICT, which is the design rather than a caveat. The -# pre-push ready fires only on a head with NO graded run, so re-drafting a head -# that already graded green strands it: unmergeable while draft, and unreadyable -# because `graded_runs` is no longer zero. Readying it anyway is the worse -# repair — `ready_for_review` is a CI trigger, so recovering a green head would -# buy a whole matrix, where today that resume is a free fast-forward. -# -# `checks-green` is the authority for "is this head green", NOT `graded_runs`: -# the two answer different questions, and the one that counts a failed or -# cancelled run as an answer would leave a green head ready only by accident. -close_the_tap() { - [[ "$landed" = no ]] || return 0 - # A land that never took the singleton owns neither the lease nor the PR — - # the discipline `singleton_held` already enforces for the release, and the - # reason a REFUSED second land must not touch the live one's work. - [[ "$singleton_held" = yes ]] || return 0 - [[ -n "${pr:-}" ]] || return 0 - # Only the draft state is asked for, in the same call shape the ready block - # uses. Whether the PR is still OPEN is already answered: a merge sets - # `landed`, and a PR closed without merging has died above — re-drafting one - # would fail, which `redraft` swallows. Asking anyway would cost a `pr view - # --json state`, and that call is sequenced by the poll it belongs to. - local rc=0 - [[ "$(gh pr view "$pr" --json isDraft --jq .isDraft 2>/dev/null)" = "false" ]] || return 0 - mise run checks-green >/dev/null 2>&1 || rc=$? - # 0 green: leave it ready, since the resume costs nothing. 2 could not look: - # never strand a head on a reading we failed to take. 1 red and 3 no-answer - # both mean the resume needs a fresh run whatever happens, so the draft - # costs nothing and stops every push until one starts. - case "$rc" in - 1 | 3) ;; - *) return 0 ;; - esac - redraft "the landing stopped without merging, so no later push spends a runner until one starts again" -} - -on_exit() { - # Before the lease drop: the tap is what another session's spend depends on, - # and a failure here must not stop the rest of the trap. `redraft` already - # swallows its own failure, which is the required posture — an exit path - # that can fail on cleanup is worse than the leak it closes. - close_the_tap || true - drop_lease - # WHY THIS STOPPED, when what stopped it was not this process (CLOUD-499). - # The lease heartbeat kills a landing that has stopped progressing, and a - # stop with no stated reason reaches the agent as "verify and CI disagree" — - # CLOUD-470's failure, reintroduced by the fix for a different one. Read and - # removed here so it can never be reported twice or outlive its landing. - bail_reason="$(git rev-parse --git-dir 2>/dev/null)/batten-land-lock/bail-reason" - if [[ -s "$bail_reason" ]]; then - echo "land: $(cat "$bail_reason")" >&2 - rm -f "$bail_reason" 2>/dev/null || true - fi - if [[ "$singleton_held" = yes ]]; then - mise run singleton release land >/dev/null 2>&1 || true - fi - # A SIGKILLed land cannot run this, which is exactly the case `alive` reports - # as crashed rather than as absent — the distinction that cost seventeen - # minutes of guessing on 2026-08-12. - mise run task-registry unregister "$$" >/dev/null 2>&1 || true -} -trap on_exit EXIT -trap 'exit 1' INT TERM - -# Before anything else: a second land must spend no CI, take no lease and move -# no ref. The refusal names the live pid and its phase, so the answer to "then -# what is running?" comes with the refusal rather than needing a hunt. -# A `die`, not a bare `exit`, so `tests/land.bats`'s stop counter sees it: an -# exit nothing counts is an exit nothing tests, and that assertion exists -# precisely to stop a new stopping condition being added silently. The pid and -# phase come from `singleton`'s own refusal on the line above. -if ! mise run singleton acquire land "$$"; then - die "refusing to start a second land in this clone — the live one is named above." -fi -singleton_held=yes - -# Registered only past the refusal, so a refused run leaves no entry claiming it -# is running. -mise run task-registry register land "$$" starting >/dev/null 2>&1 || true - -# "Does this SHA carry an answer yet?" — the conclusions `checks-green` grades, -# over the checks it requires, so the two agree by construction on what counts as -# one. Both read $CI_REQUIRED_CHECKS from mise.toml [env]; a second copy that -# drifted would put this task back to waiting on runs that will never be graded. -# -# The CONCLUSION list has to mirror it just as exactly, and CLOUD-363 is what a -# drift there costs. `cancelled` counted as graded here while `checks-green` read -# it as red, and the two composed into a trap with no exit: the red stopped the -# lap, and "already graded" then suppressed the ready that would have replaced -# the cancelled runs, so every later lap re-read the identical stale set. A -# cancelled run is not an answer at either end now, which is what makes the next -# lap re-fire the ready and buy a real run. `neutral` is here for the mirror-image -# reason: `checks-green` grades it, so omitting it would leave a green SHA reading -# as unanswered and buy a second run for a head that already has its verdict. -# Absent from the remote, or unreachable, reads as 0: a SHA with no runs is -# exactly the one that needs the event, and the caller's other guards decide -# whether to spend it. -# -# Scoped to the required set for the reason CLOUD-327 records: `SonarCloud Code -# Analysis` and `release-plz` are not draft-gated, so they grade on the draft -# push. Counting them made this read "answered" on a head whose own checks were -# all draft-era skips, so the ready was never re-fired and the skips were never -# replaced — the #182/#177 shape, reached from the other direction. -# CANCEL WHAT IS ALREADY VOID (CLOUD-369, and CLOUD-240 is what permits it). -# When `main-watch` wins the CI race the run in flight cannot land — its verdict -# is void by construction, which is why the lap ends. But the run keeps BILLING -# until something supersedes it, and the only thing that does is the next push's -# `concurrency: cancel-in-progress`. Since the lease budgets were re-priced -# (CLOUD-399) that next push can be many whole lease waits away, so a doomed -# four-job matrix bills for minutes to produce an answer nobody will read. -# -# CLOUD-240 refused hand-cancelling and its wording is the licence here: -# "supersede your own runs, never someone else's", and "cancelling another ref's -# runs would reverse" that. This reaches runs for THIS lap's head sha and nothing -# else — a sha no other branch has, so the blast radius is one push's worth of -# runs by construction rather than by filtering. -# -# Best-effort throughout: a cancellation that fails costs the minutes it would -# have saved and changes no verdict, so nothing here is guarded into a stop. The -# lap is already over. -cancel_own_run() { - local id - for id in $(gh api "repos/{owner}/{repo}/actions/runs?head_sha=$1&per_page=30" \ - --jq '.workflow_runs[]? | select(.status != "completed") | .id' 2>/dev/null); do - gh api -X POST "repos/{owner}/{repo}/actions/runs/$id/cancel" >/dev/null 2>&1 && - echo "land: cancelled run $id on ${1:0:8} — void the moment main moved, and a void run still bills" - done -} - -# A run CI DECLINED is not a run that failed (CLOUD-470). `ci-lease-precondition` -# stops an unauthorised head by CANCELLING the run it stands in, and `final` reds -# under `always()` because its `needs:` assertion fails — so the wait comes back -# non-zero with nothing about this branch broken. The generic red message then -# tells the agent that verify and CI disagree and to fix the mismatch locally, -# which names the one cause that is not true and points away from the remedy. -# -# Measured on the population that cannot afford a wrong instruction: 11 of 13 open -# PRs carried a `land` predating the lease, so every one of those agents, on -# restart, hits a cancelled run and is sent to debug a disagreement that does not -# exist. The runner already writes the right answer as an annotation; this is the -# same answer carried back through the channel `land` actually speaks on. -# -# IT CALLS `land-lock authorises` OR IT IS WRONG. The first cut of this read the -# run list for a raw `conclusion == "cancelled"`, which is a SECOND PREDICATE for -# "this run was declined" — and two authorities for one fact is the CLOUD-351 -# shape, where only the newer one decides. `authorises` is what the runner itself -# consults, so asking it here is asking the same question of the same oracle, -# locally, on a path that is already stopping. It costs nothing on the hot loop. -# -# Its contract: 0 run / 3 stop / 2 could not look, fail-open everywhere it cannot -# tell. Exit 3 is the declination, and it FAILS OPEN to the red message on -# anything else — the asymmetry is deliberate, because a wrong "nothing is broken" -# costs a landing while a wrong "go debug this" costs a session. -# -# WHAT THIS DELIBERATELY DOES NOT ANSWER, recorded rather than papered over. -# `authorises` takes a BRANCH and answers about the lease as it is NOW: it has no -# run id, no SHA and no history, so it cannot literally answer "was this concluded -# run declined". That is sound for the population CLOUD-470 names — a stale agent -# takes no lease, so when it reads red the lease authorises someone else and this -# returns 3 — and for a current `land`, which holds its own lease and gets today's -# message. What is genuinely lost is a `cancel-in-progress` cancellation, which -# the raw read also caught; that is a superseded run, not a declined one, and the -# lap that superseded it is the thing to look at. -declined_by_lease() { - local rc=0 - mise run land-lock authorises "$1" >/dev/null 2>&1 || rc=$? - [[ "$rc" = 3 ]] -} - -# THE THIRD THING THAT ARRIVES AT THE RED STOP (CLOUD-483). A job that died in the -# setup action that installs our toolchain reds the branch and answers nothing: -# measured five times on CLOUD-404, twice with different curl codes, and every -# time `land` sent the agent to reproduce a failure that passes locally. The remedy it named cost a -# whole matrix; `gh run rerun --failed` costs one job, measured on #376. -# -# IT CALLS `nonverdict-scan` OR IT IS WRONG, for the reason the arm above states. -# That task owns the classification — a failed job reached a verdict iff one of -# its failed steps is named `Run mise run ` — and it is a CLOSED predicate -# resting on the invariant `ci-local-parity` gates, not an allowlist of setup -# steps that goes stale the first time a workflow gains one. A copy of that jq -# here would be a second authority over the same fact (CLOUD-351). -# -# EMPTY IS NOT UNANIMOUS, and this is the whole hazard. Zero records satisfies -# "every record is a nonverdict" vacuously, and a branch that is genuinely red -# would then be re-run until the budget ran out. Zero records is what an -# unreadable payload, a roster miss, or a failure confined to the `final` fan-in -# all produce — so it is the "could not look" case and falls through to the red -# message, the same order `checks-green` uses when it tests no-answer before red. -transient_runs= -absorbed_transient() { - local sha="$1" runs run one records="" - transient_runs= - - # No `per_page` here, deliberately: `tests/land.bats`'s keyed-verdict sensor - # asserts this file carries no windowed page size, because the fast-forward - # verdict must be found by its key rather than by a window. This query is a - # different endpoint entirely, and it needs no page size — a head SHA's failed - # runs are a handful — so the sensor stays exact instead of being spelled past. - runs=$(gh api "repos/{owner}/{repo}/actions/runs?head_sha=$sha&status=failure" \ - --jq '.workflow_runs[]?.id' 2>/dev/null) || return 1 - [[ -n "${runs//[[:space:]]/}" ]] || return 1 - - while IFS= read -r run; do - [[ -n "$run" ]] || continue - one=$(mise run nonverdict-scan --run "$run" 2>/dev/null) || return 1 - records="${records}${one}"$'\n' - done <<<"$runs" - - [[ -n "${records//[[:space:]]/}" ]] || return 1 - ! grep -q '^verdict' <<<"$records" || return 1 - - transient_runs="$runs" - # A here-string, not a pipe: `pipefail-grep-check` refuses a producer piped - # into an early-exiting grep, and is right to — under `pipefail` that shape is - # how a MATCH comes to report failure. The same rule caught the same mistake - # in `finding-sink-check` this morning. - grep '^nonverdict' <<<"$records" || true - return 0 -} - -# Re-run the failed jobs, refund the lap, and charge the count. The pointer is not -# decoration: an absorbed transient with no durable trace is how the last one was -# diagnosed correctly and then lost, so every occurrence stays attachable to -# CLOUD-404. -charge_transient() { - local run - for run in $transient_runs; do - gh run rerun "$run" --failed >/dev/null 2>&1 || - die "CI on ${sha:0:8} failed before any \`mise run\` step — a provisioning transient, not a verdict — but re-running run $run was refused. Do: gh run rerun $run --failed, then mise run land" - echo "land: lap $lap — run $run failed before reaching a verdict; re-ran its failed jobs (CLOUD-404). Not a verdict on this branch." - done - lap=$((lap - 1)) - transients=$((transients + 1)) - [[ "$transients" -le "$max_transients" ]] || - die "CI failed before reaching a verdict $max_transients times running on ${sha:0:8}. That is not a flake any more — the provisioning path is broken and re-running it again would spend jobs to learn the same thing. Do: look at the failing step, then mise run land" -} - -graded_runs() { - local n - # The roster is guarded at TOP LEVEL, not here — see the check beside the - # other preconditions. A `die` in this function would run inside the `$( )` - # its callers wrap it in, so it would exit the SUBSHELL and the lap would - # carry on with an empty reading (CLOUD-467). - # - # The `|| n=0` below is KEPT for what it was written for: a transient `gh` or - # `awk` failure answering "ungraded" is the choice that makes progress, and - # stopping the loop on it would be worse. - n=$(gh api "repos/{owner}/{repo}/commits/$1/check-runs" \ - --jq '.check_runs[]? | "\(.conclusion // "-")\t\(.name)\t\(.started_at // "")\t\(.id // 0)"' 2>/dev/null | - awk -F'\t' -v req="${CI_REQUIRED_CHECKS:?}" -v answered="${CI_ANSWERED_CONCLUSIONS:?}" ' - BEGIN { - n = split(req, roster, ","); for (i = 1; i <= n; i++) want[roster[i]] = 1 - # ONE DECLARED SET, read by `checks-green` too (CLOUD-376). The - # two ends kept a hand-maintained list each, in agreement only by - # a paragraph of comment — and that is exactly the guarantee that - # had already failed: `neutral` was missing from this side until - # #302 added it, and nothing detected the gap. - m = split(answered, concls, ","); for (i = 1; i <= m; i++) isanswer[concls[i]] = 1 - } - !($2 in want) { next } - { - # The same latest-per-name rule `checks-green` judges by, over - # the same roster (CLOUD-436). A draft-created head keeps its - # skip set forever, and counting a superseded run would report - # an answer this head does not have — leaving the ready that - # starts CI unfired. - key = $3 "|" sprintf("%020d", $4 + 0) - # Not-an-answer ranks ABOVE an answer on an equal key, so an - # unorderable pair falls to "no verdict" rather than to one. - # `cancelled` lands here by its absence from the declared set, so - # the two ends cannot disagree the way CLOUD-363 did. - rank = (!($1 in isanswer)) ? 2 : 1 - if (!($2 in bestkey) || key > bestkey[$2] || - (key == bestkey[$2] && rank > bestrank[$2])) { - bestkey[$2] = key - bestrank[$2] = rank - bestconcl[$2] = $1 - } - } - END { - for (i = 1; i <= n; i++) { - if (bestconcl[roster[i]] in isanswer) hit++ - } - print hit + 0 - } - ') || n=0 - echo "${n:-0}" -} - -# `set -m` gives each background job its own process group, so `kill -- -PID` -# reaches the whole tree (mise -> the task -> gh/sleep). Without it the loser of -# the race is orphaned and keeps polling for the rest of the session. -set -m - -[[ "$branch" != "main" ]] || die "refusing to land from main — work happens on a short-lived branch." - -# --- the webhook subscription this repo's contract forbids (CLOUD-518) -------- -# -# AGENTS.md bans PR-webhook babysitting twice over — this loop runs on "no -# timeout, no cap, **never the PR webhook**", and no heartbeat may babysit a PR — -# and the harness arms a subscription on every PR this repo opens anyway. Measured -# on #397, #402 and #489, and on two of those with no `subscribe_pr_activity` call -# behind it, which is why a deny rule on the tool closes only the path nobody -# used. The remedy was an agent remembering, which is prose and therefore -# feedforward only. -# -# THE DROP HAPPENS HERE NOW (CLOUD-790). This block used to say the tool was -# unreachable from a task, because a POST to the session's MCP endpoint answered -# `401` (CLOUD-673) — and that 401 was a missing `Authorization` header, not a -# missing credential. Re-measured 2026-08-20: with the container's own -# session-ingress token as a bearer, `POST /v2/ccr-sessions//github/mcp` -# answers 200 and serves `unsubscribe_pr_activity`. The toolbox route stays shut -# to this principal (403), which is why `drop` takes the github one. -# -# That matters because the agent-side call could never be made silent: the -# connector sets the verb to `always_ask`, and CLOUD-765 measured that a hook -# returning `allow` does not skip that prompt. So the previous shape charged a -# human one approval click per landing, while the harness ARMED the subscription -# with no click and no tool call at all. `drop` closes that asymmetry. -# -# `drop` FAILS OPEN and `check` is unchanged, which is what keeps this safe on the -# critical path: where the call cannot be made — off harness, no token, any -# non-200 — nothing is minted, `check` refuses exactly as before, and the agent's -# manual `record` is still the way through. The pair sits before the singleton and -# the lease, so a refusal still costs nothing at all. -# -# The gate's own words reach the operator (CLOUD-407): it names the command that -# mints the receipt, so this `die` adds only the landing's context. -# THE SCRIPT MUST CARRY NO `|`, and this line is why the rule exists: the -# declaration grammar is three pipe-separated fields, so the `|| true` below — -# matched literally by the obvious sed — was parsed as a fourth field and -# truncated, and `mutant` reported `unterminated s command`. `pr-unsubscribed` -# records the same trap against its own rows. Matching on the prefix and `.*` -# keeps the script pipe-free while still naming exactly one line. -#MUTANT subscription-undropped|s@^mise run pr-unsubscribed drop.*@true@|the landing makes the unsubscribe call itself -mise run pr-unsubscribed drop "$pr" || true -#MUTANT subscription-unenforced|s/^if ! mise run pr-unsubscribed check "\$pr"; then$/if false; then/|a session that has not dropped the subscription cannot land -if ! mise run pr-unsubscribed check "$pr"; then - die "#$pr's webhook subscription has not been dropped, and the refusal above says how. Nothing has been spent — do that, then run land again." -fi - -lap=0 -lease_waits=0 -answer_unknowns=0 -# Absorbed provisioning transients, counted across the whole invocation rather -# than reset per lap: three in one landing is the broken-world signal, whether or -# not a good lap happened in between (CLOUD-483). -transients=0 -# Set by `rate_limit_pause` so the exhaustion message can state the reset time the -# code already had, instead of telling the human to go run `gh api rate_limit`. -rate_reset_note="" -# Whether this pass holds the lease, or is running as the admitted successor. -have_lease=1 -# Sticky across laps: the reservation is held until the lease turns over, so -# re-reserving each lap would rewrite the ref to say what it already says. -admitted=0 -# The HEAD this branch last pushed as the admitted successor. A speculation that -# moves HEAD makes it stale, and a stale one means the run in flight grades a -# commit this branch no longer has — so the pair runs again for the new head. -admitted_sha= -# Matrices actually bought, incremented at the one site that buys one. See the -# comment there for why this is counted rather than inferred from `lap`. -paid_laps=0 -while :; do - lap=$((lap + 1)) - # Computed here rather than inline in the refusal below: inside that - # argument every quote is escaped, so a `[[ ]]` there compares the literal - # `\"$paid_laps\"` against `1` and can never match (SC2193). - matrices=matrices - [[ "$paid_laps" -ne 1 ]] || matrices=matrix - # THE REMEDY IS DERIVED FROM THE ACCOUNTING, NOT RESTATED BESIDE IT (CLOUD-904), - # and the spend it names is COUNTED at the ready rather than inferred from this - # counter. - # - # The inference was tried first and is wrong. It rested on "`charge_wait` - # refunds every lap that bought no CI, so a lap counts only when it spent a - # matrix" — and that premise fails in both directions. There are FIVE refund - # sites, not the three CLOUD-904 names (the two in `charge_wait`, bot silence, - # `charge_transient`, and the admitted-successor push), and they still miss the - # ordinary case: a lap where `main` moves while `verify` runs aborts before the - # ready, buys nothing, and is charged anyway. - # - # Measured on PR #651 while landing this very change: two laps, both lost to - # `main` moving under `verify`, `gh pr ready` never reached, ZERO check-runs on - # the head — and the refusal announced "having spent 2 CI matrices". So `lap` - # is an attempt counter and nothing more; `paid_laps` is the spend. - # - # It does NOT mean `main` outran a lap. That inference is what the refunds - # removed, and it is the same diagnosis the comment at the bot-silence refund - # above records CLOUD-413 measuring wrong twice over across 24 laps. The message - # asserted it anyway for as long as the refunds have existed, so the sentence - # reported a state its own file had already made impossible. - # - # THE TWO EXHAUSTION PATHS MUST NOT CONTRADICT THEIR COSTS. The fleet-saturated - # exit spent NOTHING and may say wait; this one spent `max_laps` matrices, so it - # names a continuing action AND the spend the caller is re-committing. An - # unconditional "run this again" is not that: it re-arms the only brake on this - # spend and buys `max_laps` more matrices with nothing about the branch or `main` - # changed. What decides it is WHY the laps lost — a rebase conflict, a failed - # `verify` or red CI are defects that will lose again, while pure contention is - # the case that converges. - [[ "$lap" -le "$max_laps" ]] || - die_with "$LAND_EXIT_RUNAWAY" \ - "stopped after $max_laps laps, having bought $paid_laps CI $matrices and landed nothing. The count is what the readying recorded, not the lap number: a lap that ended before the ready bought nothing, so $paid_laps of $max_laps is what this cost. Read this run's \`::error::\` lines for how each lap ended, and \`gh pr view $pr --json isDraft,statusCheckRollup\` — a draft PR means CI went red and this task re-drafted it. A rebase conflict, a failed \`verify\` or red CI will lose again; fix it first. If every lap lost only to contention, run this again, which commits up to another $max_laps." - - # A lap holds the lease only across its own CI window. Dropping it here — at - # the top, covering every `continue` below uniformly — means a lap that lost - # re-queues behind whoever is landing now instead of holding the fleet - # through its own rebase and re-verify. Idempotent, so the laps that never - # acquired pay nothing. - drop_lease - - # --- be a direct descendant of origin/main, or stop for the one decision --- - note_phase "rebase(lap $lap)" - fetch_main - # Settle any bet from the previous lap FIRST, and before anything below can - # push. A won bet leaves this branch already linearized on the new `main` - # with its verify receipt intact — which is the entire saving. A lost one is - # unwound here, where it costs a reset, rather than discovered at the push. - settle_speculation - if ! git merge-base --is-ancestor origin/main HEAD; then - echo "land: lap $lap — rebasing onto $(git rev-parse --short origin/main)" - if ! git rebase origin/main; then - git rebase --abort 2>/dev/null - die "rebase onto origin/main conflicts. This is the one step the loop cannot do for you — resolve it and run land again. Lapping often is what keeps this small." - fi - fi - # The `main` this lap is built against, captured once. The lease wait below - # can take a full TTL, and this is what the winner compares against before it - # spends a matrix (CLOUD-369). - lap_main="$(git rev-parse origin/main)" - - # --- prove the tree, unless this exact commit already carries the proof --- - # - # `verified` reads the receipt keyed to this exact HEAD, which also carries - # the `origin/main` it was linear against — so an amend, a rebase, or a main - # that moved all invalidate it. When it still holds, nothing has changed and - # re-running `verify` would spend minutes reaching a known answer. - # - # Asked as a QUESTION, so its output is dropped: `verified` is a gate, and a - # gate answering "no receipt" says so with an `::error::`. Here a no is the - # ordinary answer — the commit was just made — and printing it on every - # successful landing is how `::error::` stops meaning anything. The same call - # below is a GUARD, where a no really is the failure, and it keeps its voice. - note_phase "verify(lap $lap)" - if mise run verified >/dev/null 2>&1; then - echo "land: lap $lap — HEAD already carries a verify receipt; not re-proving it" - else - # A non-zero `verify` is not one answer (CLOUD-318). `verify` runs for - # ~150s, and on a busy `main` that is long enough for the tip to move - # past the one this lap rebased onto — so `linear-check` measures - # against a newer main and refuses. Nothing is wrong with the branch and - # there is nothing to reproduce: the next lap's rebase fixes it by - # construction, which is the same race the wait phase below already - # treats as a lap. Exit 2 is that verdict and only that verdict; every - # other non-zero is content, and content still stops on lap 1 with the - # message unchanged. Measured on #240: run 1 died here, run 2 landed - # after three laps with zero edits. - # --- CLOUD-423: verify races main-watch, the same pair as the CI wait --- - # - # verify used to run blind: only its last step discovered main had moved, - # ~220s after the fact, and ~45% of laps paid the full gate to learn what - # a 1s conditional poll knew (CLOUD-392 measured the steady state: - # survival ≈ e^(-220/363) per lap). The abort is safe by construction — - # verify writes its receipt only after its guarded steps - # (tests/task-fail-closed.bats pins it), so a killed verify leaves no - # receipt and the next lap re-proves, cheaply, from the per-step receipts - # (CLOUD-424). Named pids and post-wait residue reaping, per 0552c41 and - # CLOUD-434. The interval drops to 1s for this race: a conditional 304 - # costs no rate limit, so the poll's price is the round trip. - rc_v="$(mktemp)" - rc_vm="$(mktemp)" - log_v="$(mktemp)" - : >"$rc_v" - : >"$rc_vm" - : >"$log_v" - fifo_v="$(new_rendezvous)" || - die "could not create the race rendezvous for verify — the lap cannot wait on two answers without it, and guessing which one won is the false green this race exists to avoid." - ( - # TEED, not redirected (CLOUD-407). The operator still sees verify - # stream live — a redirect would make a ~170s gate look hung — and the - # copy is what lets the stop below carry the gate's own `path:line` - # pointers. They already existed on PR #322, three of them, and the - # only reason nobody saw them was that this subshell's output scrolled - # past twenty lines above a message that said "rebase". - # - # `${PIPESTATUS[0]}` rather than `$?`, spelled out rather than leaning - # on `pipefail`: the verdict is verify's, and reading it from the - # pipeline's aggregate would make a failure of `tee` — a full disk, a - # vanished tmpdir — indistinguishable from a refused tree. - mise run verify 2>&1 | tee "$log_v" - echo "${PIPESTATUS[0]}" >"$rc_v" - echo v >"$fifo_v" - ) & - v_pid=$! - ( - LAND_RACE=verify MAIN_WATCH_INTERVAL="${LAND_VERIFY_WATCH_INTERVAL:-1}" \ - mise run main-watch "$(git rev-parse origin/main)" >/dev/null 2>&1 - echo $? >"$rc_vm" - echo m >"$fifo_v" - ) & - vm_pid=$! - race_pid_a=$v_pid - race_pid_b=$vm_pid - await_first "$fifo_v" - vwinner="$AWAIT_WINNER" - kill -- -"$v_pid" -"$vm_pid" 2>/dev/null - wait "$v_pid" "$vm_pid" 2>/dev/null - reap_residue "$v_pid" - reap_residue "$vm_pid" - race_pid_a= - race_pid_b= - verify_rc="$(cat "$rc_v" 2>/dev/null)" - vmain_rc="$(cat "$rc_vm" 2>/dev/null)" - # CLOUD-510: void the loser. `m` means main-watch reached the rendezvous - # first, so whatever verify managed to write is about a tree that is no - # longer the one being landed. The arms below are unchanged and stay the - # authority on what a code MEANS; this only decides whose code counts. - case "$vwinner" in - m) verify_rc="" ;; - v) vmain_rc="" ;; - esac - # The tail is taken BEFORE the temp files go, because `die` exits and a - # message assembled after the cleanup would name a file that is gone. It - # is only ever read on the stop path below; a lap that laps pays nothing. - verify_tail="$(tail -n "${LAND_VERIFY_TAIL_LINES:-40}" "$log_v" 2>/dev/null || true)" - # CLOUD-861: a full disk is not a verdict about this tree. Grepped over - # the WHOLE log rather than `$verify_tail`, because the linker writes - # ENOSPC where it fails and the gate keeps running after it — measured - # 2026-08-21, the line landed ~40 lines above the tail and the stop below - # reported a clean-looking test failure instead. - # - # `grep -qF` over a literal, never a `df` reading: the question is what - # THIS run hit, and by the time the stop is composed the space may have - # been reclaimed by another process. The string is the compiler's, so a - # reclaim between failure and report cannot erase the evidence. - verify_enospc="" - grep -qF 'No space left on device' "$log_v" 2>/dev/null && verify_enospc=1 - rm -f "$rc_v" "$rc_vm" "$log_v" - if [[ -z "$verify_rc" ]] && [[ "$vmain_rc" = "0" ]]; then - echo "land: lap $lap — main moved past $(git rev-parse --short origin/main) while verify ran; its receipt would be void, so the rest of the gate is not paid out. Lapping." - continue - fi - if [[ -z "$verify_rc" ]]; then - # Neither answered: verify died without a verdict and main did not - # move. Lap rather than guess — the next lap re-proves from the - # step receipts, so the retry costs seconds. - echo "land: lap $lap — no verdict from verify's race; re-proving on the next lap" - continue - fi - if [[ "$verify_rc" = 2 ]]; then - echo "land: lap $lap — verify refused only because main moved past $(git rev-parse --short origin/main) while it ran; that is a rebase, not a defect. Lapping." - continue - fi - # CLOUD-407: the stop carries the gate's own last words. `verify` now exits - # 2 for exactly one reason — main moved — so every other non-zero arriving - # here is a refusal OF THIS TREE, and the thing the operator needs is the - # `path:line` the refusing step already printed. Pointer-only by - # inheritance: every gate's output is held to non-negotiable rule 4, and - # these bytes were on the terminal a moment ago regardless. - # CLOUD-861, and it precedes the arm below because that arm's advice — - # "reproduce and fix locally" — is actively wrong here: there is nothing - # in the diff to fix and nothing to reproduce. `target-prune` runs at lap - # start and answers "is there room to BEGIN"; the build then consumed - # 6242MB of certified headroom, so the floor cannot see this class at all. - # Same misattribution shape CLOUD-811 records in `linear-check`: a task - # reading every non-zero exit as a verdict about the thing it is about. - # - # Pointer-only per non-negotiable rule 4: MB free and the reclaim to run, - # never a listing of the build tree. - if [[ "$verify_rc" != 0 ]] && [[ -n "$verify_enospc" ]]; then - free_mb="$(df -Pm . 2>/dev/null | awk 'NR == 2 { print $4 }')" - die "verify could not run on $(git rev-parse --short HEAD): the disk filled during it (${free_mb:-unknown}MB free now). This is the environment, NOT this branch — there is nothing here to reproduce. Reclaim and run \`mise run land\` again: \`mise run target-prune\` takes the superseded artifacts, and \`target/debug/incremental\` is the one it cannot (it is never superseded, only unbounded)." - fi - # CLOUD-727, and it precedes the generic arm below for the same reason the - # disk arm does: that arm's two sentences are both WRONG on a speculatively - # linearized tree. "Reproduce and fix locally" points at a defect the author - # did not write, and "CI is not where you discover this" implies discovery is - # overdue when the tree under test is not the one the author will ever push. - # - # This task already HOLDS the fact — `spec_base` is set, and it printed that - # base a few lines earlier ("speculatively linearized onto @ — the - # main that is about to exist"). It then emitted the unconditional message - # anyway, so the reader had to reconstruct whose tree they were looking at. - # - # MEASURED 2026-08-19: a two-commit branch touching only `.serena/memories/*` - # failed on `Cargo.lock sbom-ntia-conformance` and `mise-tasks/claim-race-check - # claim-not-raced`, neither file touched by either commit. Rebasing off the - # speculative base was green first try. The cost is not the wasted `verify` — - # that is the speculation's accepted price — it is the reasoning afterwards, - # and the live risk that an author takes the message at its word and starts - # repairing a sibling's branch through their own. On 2026-08-22 the masked - # failure was in `land`'s OWN suite, which is the most expensive possible - # wrong place to send someone. - # - # BOTH RECOVERIES ARE NAMED, because `rebase --onto` is not the only one and - # the cheaper one is available whenever the remote still holds the clean - # branch: the speculation is local and nothing borrowed has been pushed. - # - # IT NAMES A SUSPICION, NEVER A VERDICT. The failure may still be the - # author's — CLOUD-727 records an instance where an identical-looking refusal - # reproduced with no speculation at all, and treating "speculative" as the - # explanation because it is the salient difference is the error that row - # retracted twice in one day. So this says which tree was under test and how - # to find out; it does not decide. - if [[ "$verify_rc" != 0 ]] && [[ -n "$spec_base" ]]; then - die "verify failed on $(git rev-parse --short HEAD) (exit $verify_rc), but this tree is SPECULATIVE: it carries $(git rev-parse --short "$spec_base")'s unlanded commits as well as your own, so the failure may not be yours. Find out with \`git rebase --onto origin/main $(git rev-parse --short "$spec_base")\` and re-run \`mise run verify\` — or, since nothing borrowed has been pushed, \`git reset --hard origin/$branch\`. If it still fails off the borrowed base, it is yours. Its last words: -$verify_tail" - fi - [[ "$verify_rc" = 0 ]] || - die "verify failed on $(git rev-parse --short HEAD) (exit $verify_rc). Reproduce and fix locally; CI is not where you discover this. Its last words: -$verify_tail" - mise run verified || die "no verify receipt for HEAD — something swallowed verify's verdict." - fi - - sha="$(git rev-parse HEAD)" - remote_before="$(git rev-parse "origin/$branch" 2>/dev/null || echo none)" - - # Readying is the single event that starts CI, and it happens BEFORE the - # push. This task is the ONLY readier; the workflow contract's step 3 used to - # ready as well, which is CLOUD-247. - # - # The order is the load-bearing part (CLOUD-254). Pushing first and readying - # after puts two webhooks in the same instant and the same - # `concurrency: ci-` group: the `synchronize` event carries - # `draft: true`, so its run evaluates `if: !draft` and every job is - # `skipped`, and the `ready_for_review` run does not survive beside it under - # `cancel-in-progress`. The head ends up carrying a complete set of skipped - # runs and no graded one, and `ci-wait` polls forever — correctly, since a - # graded conclusion is what stops draft-era skips reading as green. On #182 - # both events are stamped 22:14:20Z and exactly one run exists, skipped. - # Readying first makes the push's own `synchronize` the confirming run: one - # event, carrying `draft: false`, with nothing to contend with. - # - # The condition is "this SHA has no graded check-run", not "the PR is a - # draft" — a head that already carries a graded set must not buy a second run - # for a SHA that has one, which is step 5 of the contract. Before the push - # the SHA is usually absent from the remote and the query 404s, which reads - # as zero: exactly right, since a SHA with no runs is the one that needs the - # event. `graded` is `ci-wait`'s own list, and deliberately so: "is this set - # an answer" has one definition, and a second copy here that drifted would - # put this task back to waiting on runs that will never be graded. - # --- a deferred decision must have a ticket before review is asked for --- - # - # CLOUD-323, and the FOURTH stop this task has. Readying is the commitment to - # review, which is exactly when "we will decide this later" has to name where - # later lives. Checked per lap and before the ready block rather than inside - # it, because that block is conditional and a deferral must not slip through - # on a lap that happened not to re-ready. - # - # `gh pr view` is a read `gh-guard` allows. A body that cannot be fetched - # fails OPEN — this gate is about what a body says, and a body it never saw - # is not evidence of anything. - note_phase "deferral-check(lap $lap)" - body=$(gh pr view "$pr" --json body --jq .body 2>/dev/null || true) - if [[ -n "$body" ]] && ! mise run deferral-check <<<"$body"; then - die "#$pr defers a decision with no ticket. File it and name the issue in that paragraph, then run land again." - fi - - # --- and a row this branch FILED must have been groomed before it landed --- - # - # CLOUD-514, and the sibling of the stop above: `deferral-check` prices a - # decision left with no home, this prices a home opened instead of a fix. - # Filing satisfies every other gate here in seconds while finishing costs a - # diff, a suite and a landing, so a defect in this branch's own diff is - # arithmetically cheaper to spin off than to close — unless the new row costs - # a complete Ready block. - # - # No stdin: the record is `board-write-record`'s file under `$GIT_DIR`, keyed - # to this branch. Reads no tracker, judges no content, and fails open on an - # absent record — a branch that filed nothing, or predates the recorder, is - # untouched. Checked per lap and before the ready block for the reason the - # deferral stop is: that block is conditional, and a lap that happened not to - # re-ready must not be a way through. - # THE BODY IS PIPED IN (CLOUD-774), which is what lets the gate exempt a row - # this PR CLOSES. Without it the gate fires on every row the branch filed and - # then fixed — their paths are in the diff by construction — so the honest - # file-then-fix path would need an override every time, and a routinely - # overridden gate is bypassed rather than satisfied. - # - # `$body` is already in hand from the deferral stop above; an empty one (the - # fetch failed) simply yields no exemption, which is the pre-CLOUD-774 - # behaviour and refuses rather than waves through. - note_phase "filed-here-check(lap $lap)" - if ! mise run filed-here-check <<<"$body"; then - die "#$pr filed a row that was never groomed to Ready, or names code this branch has open without closing it. Fix it here and close the row, comment on the issue that already owns it, or groom the row and run land again." - fi - - # --- and the merge must actually move the board ------------------------- - # - # CLOUD-192, the FIFTH stop. The tracker's merged-event automation fires only - # for a CLOSING pull request; one that merely mentions its issue links, - # attaches, and moves nothing. Measured as a pair on one issue with one - # variable: #398 (`Refs:`) never moved, #400 (`Closes`) moved in two seconds. - # - # Same body, fetched once above, and the same fail-open reasoning: a body this - # never saw is not evidence that the PR closes nothing. - note_phase "closing-key-check(lap $lap)" - if [[ -n "$body" ]] && ! mise run closing-key-check <<<"$body"; then - die "#$pr names its issue but never closes it, or closes some of the keys its commits served and strands the rest (CLOUD-674) — the gate's own output above names which. Merging either way leaves the board a column behind for at least one row. Write \"Closes \" for every row this PR completes (or DO-NOT-CLOSE if it is not meant to complete them), then run land again." - fi - - # --- and the matrix must be able to have an opinion about the diff -------- - # - # CLOUD-827, the SIXTH stop, and the only one that is about what the change is - # WORTH rather than whether it is correct. `verify` asks whether it is right, - # `linear-check` whether it is landable, `ready-guard` whether both were - # proved — and then a full required matrix is spent on a diff no required - # check can say anything about that `verify` did not already say locally. - # - # Measured: a branch whose whole diff was two rewritten sentences of `//!` doc - # comment reached this point, and what stopped it was a human rather than a - # gate. The agent had the rule — it is in AGENTS.md — which is the definition - # of prose being feedforward only. - # - # No stdin: the predicate reads the diff. Every could-not-look path inside it - # exits 0, so this stops a lap only on a positive verdict. - note_phase "prose-only-check(lap $lap)" - if ! mise run prose-only-check; then - die "#$pr is a prose-only branch, so the matrix it is about to buy can confirm nothing that \`verify\` has not already proved locally. Put the content on the row that owns it and let the next change to those files carry it, or set BATTEN_PROSE_ONLY_OVERRIDE=1 if the prose is the deliverable and cannot wait — that records which branch used it." - fi - - # --- take the lease before anything can start a run ----------------------- - # - # Here, and not later: the push below is what starts CI, so acquiring first - # is what makes "only the holder's matrix runs" true. Not earlier either — - # `verify` above is purely local work, and holding across it would lengthen - # every hold for no exclusivity. Its VALIDITY does depend on main, which is - # CLOUD-392's correction to this comment's older claim: that dependence is - # answered by racing verify against main-watch (CLOUD-423) and by the - # per-step receipts that make a re-proof cost seconds (CLOUD-424) — never - # by holding a fleet-wide lease across a local gate. - # - # Before the ready/push pair rather than between them, because that pair is - # load-bearing and adjacent (CLOUD-254): readying first makes the push's own - # `synchronize` the confirming run, and anything inserted between them risks - # the two events landing in one `concurrency: ci-` group. - # - # A lease we cannot win is an ordinary lap, not a failure: `acquire` already - # waited, so someone else is mid-landing and `main` is about to move. Lapping - # re-fetches and rebases, and the receipt short-circuit skips `verify` when - # `main` did not move — so the wait costs a poll, never a CI run. - note_phase "lease(lap $lap)" - # The wait count is this branch's AGE, and passing it is what makes the - # admission fair rather than merely dispersed (CLOUD-369): a branch that has - # lost repeatedly probes a freed lease sooner than one that just arrived, so - # no branch spends its whole lap budget while siblings land repeatedly. - if ! LAND_LOCK_AGE="$lease_waits" mise run land-lock acquire; then - echo "land: lap $lap — another branch holds the landing lease; lapping rather than spending a run behind it" - # This lap spent no CI, so it does not count against a backstop that - # exists to catch "main is moving faster than a lap takes". Counting it - # would make a busy fleet exhaust LAND_MAX_LAPS on waiting alone and give - # up without ever having attempted — the opposite of what the lease is - # for. - charge_wait - - # --- lost the lease, so do the work that losing makes possible -------- - # - # Linearize onto the head that is about to become `main`, so this branch's - # turn — whenever it comes — costs a ready and a push rather than a - # rebase, a verify and a matrix. `peek` is the machine-readable read; - # parsing `status`'s prose would make a sentence into an interface. - speculate "$(mise run land-lock peek branch 2>/dev/null || true)" - - # Then reserve the successor slot. If it is empty this branch becomes the - # one that may spend the SECOND matrix — the one that overlaps the - # holder's merge instead of starting cold behind it. One CAS-guarded - # slot, so exactly one waiter wins it and every other stays in draft: the - # bound is two whatever N is. A refusal is the ordinary case (somebody - # reserved first, or we already hold the slot) and costs nothing. - # - # AFTER the speculation, deliberately. Being admitted means pushing, and - # pushing before the linearization would spend the matrix on a head that - # is already stale — buying exactly the run this design exists to stop - # buying. - # - # TWO CONDITIONS, AND BOTH ARE ABOUT WHETHER THE RUN CAN EVER PAY. - # - # GREEN. The second matrix is a favourable bet *because* it is - # conditioned: a holder that is green AND holds the lease will almost - # certainly fast-forward, so the successor's run overlaps a merge that is - # about to happen. Reserving the instant the lease is lost buys it behind - # a holder whose CI has not answered and may yet come back red — and then - # the merge never happens, the successor's run is voided, and the - # mechanism has spent an extra matrix to save nothing. That is the waste - # this whole issue exists to remove, reappearing inside its own fix. - # - # `checks-green` is the one definition of "is this SHA green" - # (CLOUD-346), asked once rather than polled: 0 green, 1 red, 2 could not - # look, 3 no answer yet. Only 0 admits. The three non-green answers are - # all "not yet", and not yet is the safe direction — a lap that declines - # stays linearized and verified locally and reserves on a later lap for - # the price of one poll. - # - # The read lives HERE and not in `land-lock`, whose suite asserts it - # never calls `gh`: a lease that reached for the API would become a - # second authority for CI state as well as for the lock. - # - # NO CONFLICT. A base known to conflict cannot pay either, for a - # different reason: the run is not merely likely to be voided, it is - # certain to be — `speculate` already declined to linearize onto it, so - # this branch is not on the base its run would need. - if [[ "$admitted" = 0 ]] && [[ "$spec_conflicts" = 1 ]]; then - echo "land: lap $lap — the holder's base conflicts with this branch, so a run behind it could never pay; not reserving" - elif [[ "$admitted" = 0 ]]; then - holder_head="$(mise run land-lock peek head 2>/dev/null || true)" - if [[ -z "$holder_head" ]]; then - echo "land: lap $lap — the lease names no head, so the holder's CI cannot be read; not reserving" - elif ! mise run checks-green "$holder_head" >/dev/null 2>&1; then - echo "land: lap $lap — the holder's run has not gone green, so a second matrix behind it is not yet a bet worth making" - elif mise run land-lock reserve "$branch" >/dev/null 2>&1; then - admitted=1 - echo "land: lap $lap — admitted as the successor behind a green holder; this branch may spend the run that overlaps the merge" - fi - fi - # ONCE PER HEAD, not once per lap. A successor that keeps waiting laps - # repeatedly, and re-entering the ready/push pair each time would push an - # unchanged head — which emits no `synchronize`, buys nothing, and drops - # into the `--undo` re-fire path that exists for a different case - # entirely. The run it wants is already in flight; what it owes now is - # patience. - if [[ "$admitted" = 1 ]] && [[ "$admitted_sha" = "$(git rev-parse HEAD)" ]]; then - echo "land: lap $lap — still the admitted successor, and its run is already in flight" - continue - fi - if [[ "$admitted" = 1 ]]; then - admitted_sha="$(git rev-parse HEAD)" - # Fall through WITHOUT the lease: ready and push so the confirming - # run starts now, then lap. Everything past the push needs the lease - # — the fast-forward comment above all — so `have_lease` stops this - # pass there rather than duplicating the ready/push pair, which is - # the one place CLOUD-254's ordering is written down. - have_lease=0 - else - continue - fi - else - have_lease=1 - fi - # The heartbeat belongs to the HOLDER only. An admitted successor holds - # nothing to renew, and starting one here would have it renewing somebody - # else's lease — which `land-lock hold` refuses anyway, but the refusal would - # be a background process failing silently rather than a thing never started. - if [[ "$have_lease" = 1 ]]; then - # LAND_LOCK_HOLDER_PID is CLOUD-432's tether: the heartbeat releases and - # exits the moment this land stops existing, so a SIGKILL here can no - # longer leave a lease renewing for nobody. - LAND_LOCK_HOLDER_PID=$$ mise run land-lock hold >/dev/null 2>&1 & - heartbeat_pid=$! - - # RE-CONFIRM THE BASE INSIDE THE HOLD (CLOUD-369). `acquire` waits up to a - # full TTL, and the winner is at its most stale in the instant it wins: - # `main` may have moved since the rebase at the top of this lap, and the - # next two statements are a ready and a push that buy a matrix. Confirming - # here is what makes a speculation safe to act on — and what stops the - # oldest failure in this loop, a green matrix bought for a head the - # fast-forward will refuse. - # - # Release and lap rather than rebase under the hold: rebasing here would - # hold a fleet-wide lease across local work, which is the thing the - # comment above refuses on principle. The lap top rebases and re-verifies, - # and the receipts make that cheap. - # - # It costs no LAP — nothing was spent — but it does count as a WAIT, so - # the backstop that catches a branch which never gets a turn still fires. - # Asked as "did `main` MOVE since this lap rebased", not as an ancestry - # query. They differ where it matters: a lap that speculated is a - # descendant of a commit that is itself a descendant of main, so - # `--is-ancestor` passes for exactly the case that must be caught. The - # sha comparison is also the same question `main-watch` answers, and it - # costs one rev-parse rather than a graph walk. - fetch_main - if [[ "$(git rev-parse origin/main)" != "$lap_main" ]]; then - echo "land: lap $lap — main moved to $(git rev-parse --short origin/main) while this lap waited for the lease; lapping rather than confirming a head it will refuse" - drop_lease - charge_wait - continue - fi - - # AND RE-SETTLE THE BET, for the reading `main` cannot answer (CLOUD-495). - # The top-of-lap settle can be a full TTL old by the time `acquire` - # returns, and this is the last computable moment before the ready, the - # push and the fast-forward comment. Winning the lease is itself the - # strongest evidence a bet is dead: the branch we bet on is not the one - # holding the lease any more. - # - # An unwind here moved HEAD, so `$sha` and this lap's receipts describe a - # commit this branch no longer has. Lap rather than push from it — the lap - # top rebases onto `origin/main` and re-verifies, and it costs no lap for - # the same reason the arm above does not. - spec_head_before="$(git rev-parse HEAD)" - settle_speculation - if [[ "$spec_head_before" != "$(git rev-parse HEAD)" ]]; then - drop_lease - charge_wait - continue - fi - fi - - readied=0 - if [[ "$(graded_runs "$sha")" = "0" ]] && - [[ "$(gh pr view "$pr" --json isDraft --jq .isDraft 2>/dev/null)" = "true" ]]; then - gh pr ready "$pr" >/dev/null 2>&1 || - die "could not mark #$pr ready for review, so CI would never start." - readied=1 - # THE ONE PLACE A MATRIX IS ACTUALLY BOUGHT, and therefore the only honest - # place to count one (CLOUD-904). The readying is the event that starts CI; - # every lap that ends before here spent nothing. - # - # The runaway refusal used to DERIVE its spend from the lap counter, on the - # premise that `charge_wait` refunds every lap that bought no CI. That - # premise is false in both directions and this counter is what replaces it: - # there are FIVE refund sites rather than the three CLOUD-904 names, and - # they still do not cover the ordinary case — a lap where `main` moved - # while `verify` ran aborts before the ready, buys nothing, and is charged - # anyway. Measured on PR #651: two laps, both lost that way, `gh pr ready` - # never reached, ZERO check-runs on the head — and the refusal said "having - # spent 2 CI matrices". A refusal that overstates what it cost is the same - # defect CLOUD-904 exists to fix, one level down: a message asserting what - # the accounting does not support. - # - # Counted here rather than refunded at each non-spending exit because the - # spend has ONE cause and many non-causes; enumerating the non-causes is - # what produced five refund sites and still missed one. - paid_laps=$((paid_laps + 1)) - echo "land: lap $lap — readied #$pr before pushing, so the push's own event is the one confirming run" - fi - - # TWO CAUSES, TWO REMEDIES (CLOUD-345). One undifferentiated line named the - # only cause that was usually NOT true, and it named it toward the dangerous - # action: "someone else moved the branch" describes a concurrent writer, whose - # correct response is caution — while the common case is our own merge having - # deleted the branch, where every check an operator would run (`git log - # HEAD..origin/` is empty) says forcing looks safe, for the wrong - # reason. A message that misnames the cause pushes toward a bare `--force`, - # which is the one thing the lease exists to prevent. - # - # The split is mechanical, not a judgement: absent from the remote is a - # different state from present at an unexpected SHA. `--force-with-lease` is - # still what pushes in both — the lease is never weakened to a bare force, - # which would trade this bug for a worse one. - if ! git push --force-with-lease -u origin "$branch"; then - if [[ -z "$(git ls-remote --heads origin "$branch" 2>/dev/null)" ]]; then - die "push rejected, and \`$branch\` is ABSENT from the remote — this is a stale tracking ref, not a concurrent writer. Our own merge deleted the branch and the local ref outlived it. Do: git fetch --prune origin && mise run land. Do NOT force." - fi - die "push rejected, and \`$branch\` IS on the remote at a SHA this clone did not expect. Someone else moved it; look before forcing." - fi - - # The bet is now PUBLISHED (CLOUD-495). Speculation was justified as free - # because local execution costs nothing; this is the statement that it stopped - # being local, and it is what an unwind reads to know the remote is owed a - # correction too. - [[ -z "$spec_base" ]] || spec_pushed=1 - - base_main="$(git rev-parse origin/main)" - - # A push that moved nothing emitted no `synchronize`, so nothing started a - # run — and if the head carries only draft-era skips there is no answer to - # wait for either. This is the #177 shape, and it is the one case that still - # needs the `--undo` re-fire: converting back to draft and readying again is - # what actually emits a fresh `ready_for_review` on an unchanged head - # (`ready-guard` permits the undo since CLOUD-237). Guarded on the ref not - # moving, so a lap that did push never pays for a second event. - # - # And guarded on `readied`, because the two conditions otherwise overlap: a - # DRAFT on an unchanged head satisfies both, and the lap readied and then - # immediately re-drafted and readied again — the second `ready_for_review` - # cancelling the run the first started, through the same - # `cancel-in-progress` this task relies on elsewhere (CLOUD-255). `--undo` - # is for a PR that is ALREADY ready; a draft has a cheaper way to emit the - # event and has just used it. - if [[ "$readied" = 0 ]] && - [[ "$(git rev-parse "origin/$branch")" = "$remote_before" ]] && - [[ "$(graded_runs "$sha")" = "0" ]]; then - gh pr ready "$pr" --undo >/dev/null 2>&1 || - die "could not re-draft #$pr to re-fire the ready that starts CI." - gh pr ready "$pr" >/dev/null 2>&1 || - die "could not mark #$pr ready for review, so CI would never start." - echo "land: lap $lap — the push moved nothing and $(git rev-parse --short "$sha") carries no graded run; re-fired the ready" - fi - - # THE SUCCESSOR'S PASS ENDS HERE (CLOUD-369). Its matrix is now running - # alongside the holder's merge, which is the entire point — but everything - # below needs the lease. Waiting on CI would be the harmless half; commenting - # `/fast-forward` without holding the lease is the collision the lease exists - # to prevent, and `held` would refuse it anyway. - # - # So: lap. The next pass re-acquires, and by then this branch is linearized, - # verified AND green — so its turn costs the fast-forward comment and nothing - # else. That is the cold window closed. - # - # `admitted` is deliberately NOT cleared: the reservation stays ours until the - # lease turns over, and re-reserving every lap would churn the ref to say what - # it already says. - if [[ "$have_lease" = 0 ]]; then - echo "land: lap $lap — pushed as the admitted successor; its run overlaps the merge in flight" - lap=$((lap - 1)) - continue - fi - - # --- race the two answers: green, or no longer landable --- - # LAND_RACE labels which of the two waits this watcher serves. `main-watch` - # ignores it; it exists so an observer never has to INFER the role from - # racing state. tests/land.bats used to deduce it from whether a comment had - # been posted yet — a file this same lap mutates — so a watcher that forked - # slowly classified itself as the other race and won one it was never - # scripted to win. That reordering is rare on an idle box and ordinary under - # a loaded one, which made a real assertion fail only inside a full gate run - # and pass six times out of six alone (CLOUD-426). - note_phase "ci-wait(lap $lap)" - rc_ci="$(mktemp)" - rc_main="$(mktemp)" - : >"$rc_ci" - : >"$rc_main" - fifo_ci="$(new_rendezvous)" || - die "could not create the race rendezvous for the CI wait — the lap cannot wait on two answers without it, and guessing which one won is the false green this race exists to avoid." - ( - mise run ci-wait - echo $? >"$rc_ci" - echo c >"$fifo_ci" - ) & - ci_pid=$! - ( - LAND_RACE=ci mise run main-watch "$base_main" >/dev/null 2>&1 - echo $? >"$rc_main" - echo m >"$fifo_ci" - ) & - main_pid=$! - race_pid_a=$ci_pid - race_pid_b=$main_pid - # THIS RACE'S OWN RENDEZVOUS, and only these two racers write to it. The - # property the old `wait -n "$ci_pid" "$main_pid"` bought by naming pids is - # kept for free here, and it is load-bearing: a bare `wait -n` returns on the - # FIRST job of any kind to exit, and the lease heartbeat is also a job of this - # shell — so a heartbeat that ended would read as this race concluding, - # leaving both result files empty and the lap reporting "no verdict". - # Measured: it turned every lap of tests/land.bats into a no-verdict lap. A - # FIFO nobody else writes to cannot be woken by a third job at all. - await_first "$fifo_ci" - ciwinner="$AWAIT_WINNER" - kill -- -"$ci_pid" -"$main_pid" 2>/dev/null - # NAMED, never bare. A bare `wait` waits for EVERY background job of this - # shell, and since CLOUD-393 one of them is the lease heartbeat — which by - # design never exits. So a bare wait here blocks forever, every time, the - # moment CI answers. Measured: `land` sat at this line for five minutes with - # every check green and the SHA landable, logging nothing. CLOUD-383 names - # the shape; the heartbeat turned an intermittent hang into a certain one. - wait "$ci_pid" "$main_pid" 2>/dev/null - reap_residue "$ci_pid" - reap_residue "$main_pid" - race_pid_a= - race_pid_b= - ci_rc="$(cat "$rc_ci" 2>/dev/null)" - main_rc="$(cat "$rc_main" 2>/dev/null)" - # CLOUD-510: void the loser, before any arm reads a code. `m` means main-watch - # won, and a CI verdict on a SHA that is no longer landable is not a verdict - # about this branch — it is about a run the next lap's push supersedes through - # `concurrency: cancel-in-progress`. Stopping the landing on it reports a red - # that nobody needs to fix. - case "$ciwinner" in - m) ci_rc="" ;; - c) main_rc="" ;; - esac - rm -f "$rc_ci" "$rc_main" - - if [[ -z "$ci_rc" ]] && [[ "$main_rc" = "0" ]]; then - echo "land: lap $lap — main moved under ${sha:0:8} before CI finished; that run's verdict is void. Lapping early rather than paying it out." - cancel_own_run "$sha" - continue - fi - if [[ -n "$ci_rc" ]] && [[ "$ci_rc" != "0" ]]; then - # `redraft` first on every arm (CLOUD-458): the tap closes on any - # non-merged exit, whatever the reason turns out to be. - redraft - # Three different things arrive here, and only the last one is a red run. - if declined_by_lease "$branch"; then - die "the run on ${sha:0:8} was CANCELLED, not red — CI declined it because another branch holds the landing lease (CLOUD-420). Nothing here is broken. Do: git fetch origin main && git rebase origin/main && mise run land" - fi - [[ "$ci_rc" = 1 ]] || - die "could not read CI's verdict on ${sha:0:8} (ci-wait exit $ci_rc) — that is not a red run, and nothing about this branch has been judged. Do: mise run land" - # A red that never reached a verdict is not a verdict (CLOUD-483). Tested - # after the lease arm and before the red message, because both of those - # are answers about the branch and this one is an answer about the runner. - if absorbed_transient "$sha"; then - charge_transient - continue - fi - # THE MATRIX IS ABANDONED HERE, and the position in this arm is the - # whole of the safety argument (CLOUD-900). Everything above it is a - # reason the red is NOT a verdict about the tree — a lease decline - # (CLOUD-420), a run that died before reaching a gate (CLOUD-483) — and - # both of those are recovered by re-running jobs that a cancellation - # would put out of reach. Past them the failure is an answer, the rest - # of the matrix is spending to re-learn it, and `checks-green` now says - # so the moment the first non-fan-in check goes red rather than waiting - # for its siblings to finish. - # - # Before the `die` rather than after, because `die` does not return; and - # never guarded into a stop, because a cancellation that fails changes - # no verdict and must not replace the message below with its own. - mise run abandon-matrix "$sha" "a required check is red on this head" || true - die "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." - fi - if [[ -z "$ci_rc" ]]; then - # Neither answered (a killed ci-wait with no main movement). Lap rather - # than guess: the next lap re-reads the checks, and an already-green SHA - # is answered from the existing check-runs without spending a new run. - echo "land: lap $lap — no verdict from the wait; re-reading on the next lap" - continue - fi - - # --- ask for the merge, then read the answer --- - # - # Stamped BEFORE commenting, so a run that predates this lap — an earlier - # lap of this same PR, refused and since rebased — can never be mistaken - # for a verdict on this one. Exported for the `--jq` filter below, rather - # than interpolated into it, so a value can never be read as jq syntax. - # Now that the task laps by itself the window is load-bearing twice over: - # without it, lap 2 would read lap 1's refusal and abandon its own attempt - # instantly, turning the old hang into a livelock. - note_phase "fast-forward(lap $lap)" - SINCE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - export SINCE - - # THE FENCE. Ask for the merge only while the lease is still ours. A session - # paused past its TTL — a throttled VM, a long stall — has been stolen from, - # and commenting anyway would put two branches in the fast-forward queue at - # once, which is the collision the lease exists to remove. Cheap stand-in for - # a fencing token: the lock cannot stop a comment, so the holder checks - # immediately before making one. - if ! mise run land-lock held; then - echo "land: lap $lap — the landing lease was lost before the comment; lapping rather than racing its new holder" - continue - fi - - # THE SUCCESS LINE IS A CONSEQUENCE OF THE COMMENT EXISTING, not of control - # reaching the next line (CLOUD-408). 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. Task bodies here do not run under - # `set -e`, so an unread status is a silent false green; this is the fourth - # instance of that shape and it is in the one task whose whole job is to - # drive the lifecycle. - # - # `gh api` rather than `gh pr comment`, and not for style: it returns the - # created comment object, so `.id` — the key the verdict filter below needs - # (CLOUD-409) — comes back on stdout, and a non-2xx gives both a real - # non-zero exit and a body worth printing. `gh-guard` allows `gh api`; its - # `pr comment` rule is about a human hand-typing the directive. - # - # `-i` so the RESPONSE HEADERS come back with the body (CLOUD-413): when this - # is refused, the reason and the delay are stated there, and asking a second - # endpoint for them would be one more request against the limit that just - # refused this one. The body split is `main-watch`'s idiom — headers to the - # first blank line, body after it — so `--jq` is applied here rather than by - # `gh`, which cannot filter a response it is also printing headers for. - ff_err="$(mktemp)" - ff_head="$(mktemp)" - ff_resp="$(gh api -i "repos/{owner}/{repo}/issues/$pr/comments" \ - -f body="/fast-forward" 2>"$ff_err")" - printf '%s\n' "$ff_resp" | awk '/^\r?$/ { exit } { print }' >"$ff_head" - comment_id="$(printf '%s\n' "$ff_resp" | - awk 'body { print } /^\r?$/ { body = 1 }' | - jq -r '.id // empty' 2>/dev/null)" - if [[ -z "$comment_id" ]]; then - echo "land: lap $lap — could not ask #$pr to fast-forward: $(tr '\n' ' ' <"$ff_err")" >&2 - rm -f "$ff_err" - # Never enter the answer poll: polling for the answer to a question - # nobody received is the CLOUD-235 hang with a different cause. The lap - # ends and the loop laps — but only AFTER the delay the response states, - # because lapping with no delay is not backoff, it is the same request - # again, which is what CLOUD-413 measured 24 times. - rate_limit_pause "$ff_head" - rm -f "$ff_head" - unknown="the comment was refused" - charge_unknown - continue - fi - rm -f "$ff_err" "$ff_head" - # The join key the workflow mints as its `run-name`, and the filter below - # matches on it exactly. - FF_KEY="fast-forward #$pr @$comment_id" - export FF_KEY - echo "land: lap $lap — commented /fast-forward on #$pr as comment $comment_id ($sha); waiting for the merge" - - # This wait is raced too (CLOUD-246). The CI wait above got the race and - # this one did not, which is backwards: a bot that answers nothing is - # exactly how landing once blocked for 26,123s (~7h15m) on #159 while - # `main` advanced 10 commits. The two exits below both depend on something - # external happening — the PR moving, or a run appearing and completing — - # and neither is guaranteed. `LAND_MAX_LAPS` cannot cover it either: the lap - # counter only advances when this poll breaks, so a poll that never breaks - # never reaches the backstop. - # - # `main-watch` is the same authority the CI wait races, not a second - # derivation of "is this SHA still landable". Once `main` moves past this - # branch the bot's answer can only be "no", whatever it does or does not - # say, so a win here is an ordinary lap rather than an error. - rc_ff="$(mktemp)" - : >"$rc_ff" - ( - LAND_RACE=answer mise run main-watch "$base_main" >/dev/null 2>&1 - echo $? >"$rc_ff" - ) & - ff_watch_pid=$! - race_pid_a=$ff_watch_pid - race_pid_b= - # Reap the watcher on every way out of this poll, the merged path included, - # so no `gh` poller outlives the task that started it. The pid is killed as - # well as its group, and the `wait` names that pid rather than waiting on - # everything: the loser of this race blocks indefinitely by construction, so - # a bare `wait` after a group-kill that did not land would hang here — which - # is the failure this whole change exists to remove. - reap_watch() { - kill "$ff_watch_pid" 2>/dev/null - kill -- -"$ff_watch_pid" 2>/dev/null - wait "$ff_watch_pid" 2>/dev/null - # The wait proves the leader died; only the group check proves the - # TREE did (CLOUD-434). - reap_residue "$ff_watch_pid" - race_pid_a= - rm -f "$rc_ff" - } - - refused="" - moved="" - unknown="" - while :; do - state=$(gh pr view "$pr" --json state --jq .state 2>/dev/null) - if [[ -n "$state" ]] && [[ "$state" != "OPEN" ]]; then - reap_watch - # PRUNING MATTERS MOST EXACTLY HERE (CLOUD-345). This is the merged - # path — the instant GitHub deletes the head branch, and therefore the - # instant the local tracking ref becomes the phantom that deadlocks the - # next reuse of this branch name. Unguarded on purpose: the PR has - # already reached a terminal state, so a failed fetch changes no verdict. - git fetch -q --prune origin main - echo "land: PR #$pr is $state after $lap lap(s); origin/main is now $(git rev-parse --short origin/main)" - [[ "$state" = "MERGED" ]] || die "PR #$pr is $state — closed without merging." - # The one exit that must leave the PR alone (CLOUD-458): it landed, - # so there is no tap to close and nothing left to re-draft. - landed=yes - # The branch has done its whole job and trunk-based development is - # explicit that it should not outlive it: keep the review's - # commentary, delete the branch (CLOUD-349). A name left behind is - # how a short-lived branch becomes a long-lived one, and reusing one - # after its PR merged is the stale-tracking-ref deadlock CLOUD-345 - # records. - # - # ONLY here, never on a `die` path: an abandoned branch is evidence - # and has to survive. And a failure to delete is a warning, not a - # `die` — the PR has already landed, so reporting failure over - # cleanup would make a successful landing look like a broken one. - if git push -q origin --delete "$branch" 2>/dev/null; then - echo "land: deleted origin/$branch" - else - echo "land: could not delete origin/$branch (already gone, or the remote refused) — the PR has landed either way." >&2 - fi - # THE BRANCH'S FILING HISTORY IS SPENT (CLOUD-774), so the receipts - # keyed to its name go with it. Every row it filed is now landed, - # closed by the body, or recorded in the override log; keeping them - # would judge the NEXT piece of work on this branch name against rows - # that belong to this one. - # - # This is the event-driven half of scoping, and it exists because the - # obvious predicate does not work. Measured 2026-08-20: after a merge - # the old base is still an ancestor of HEAD, so "not an ancestor" - # cannot see a reset; and "equals the current base" excludes rows filed - # before any ordinary rebase. Neither separates a reset from a rebase. - # The merge does, exactly, and it is an event rather than an inference. - # - # Same posture as the branch delete above: a failure here is silent and - # harmless, because the landing already succeeded. - if git_dir=$(git rev-parse --git-dir 2>/dev/null); then - rm -f "$git_dir/batten-receipts/board-writes.${branch//\//-}" \ - "$git_dir/batten-receipts/filed-here-nudged.${branch//\//-}" 2>/dev/null || true - fi - exit 0 - fi - - # THE VERDICT IS KEYED TO THIS PR AND THIS COMMENT, not merely to a - # timestamp (CLOUD-409, CLOUD-456). 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 existing field says which PR - # asked. The window was therefore the `SINCE` stamp and a 20-run - # page — about 90 seconds — and at the measured cadence (400 runs in - # 30 minutes, 243 of them refusals) that window is 243 strangers' - # refusals. Any lap polling after commenting would, with near - # certainty, find one and report a refusal nobody gave it. That is - # how "the bot is silent or slow" was inferred while the bot was in - # fact answering every attempt within 23 seconds. - # - # `run-name` in the workflow mints `display_title`; `$FF_KEY` is the - # same string, built from the comment id the POST returned. - # - # BOTH FENCES, and the client-side one is the correctness half. The - # `created` query parameter bounds the page server-side so page size - # stops being a silent second window — but a query parameter is an - # optimisation: mistype it, or meet an endpoint that ignores it, and - # the fence vanishes with nothing failing. `select(.created_at >= - # env.SINCE)` is what 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. - # - # The exit status is READ (CLOUD-414). On a 403 `gh` writes the - # error body to stdout, the filter fails on it, and the unfiltered - # body reached `$refused` — where the test was `[ -z ]`, so any - # non-empty string was a refusal and a transport error was - # indistinguishable from a verdict. `2>/dev/null` stays: silencing - # stderr was never the defect, silently trusting stdout was. - # AND THE DEPTH IS DERIVED FROM THE WINDOW, NEVER FROM A PAGE SIZE - # (CLOUD-456's second half). The key says WHICH run is this lap's; - # the depth says whether this lap's run is in the page at all, and - # they are independent limits on the same read. A keyed filter over - # a window that has already rolled past the run returns empty, which - # this loop reads as "not answered yet" — byte-identical to a silent - # bot, which is the reading that cost CLOUD-399 its diagnosis. At the - # measured 13 runs/minute one page of 100 is ~7.7 minutes, and a lap - # routinely outlives that. - # - # So this pages until a page comes back short. That terminates - # because `created>=SINCE` bounds the SET server-side: the pages are - # a walk over a finite window, not over all history. The 20-page cap - # is a runaway backstop and nothing else — 2000 runs is ~2.5 hours - # at the measured rate, far outside any lap's `SINCE`, so reaching it - # means the `created` fence stopped being honoured, not that the - # window is genuinely that deep. - answer="" - ff_page=1 - while [[ "$ff_page" -le 20 ]]; do - ff_body="$(gh api \ - "repos/{owner}/{repo}/actions/workflows/$workflow/runs?event=issue_comment&per_page=100&page=$ff_page&created=%3E%3D$SINCE" \ - 2>/dev/null)" - ff_rc=$? - if [[ "$ff_rc" -ne 0 ]]; then - answer=unreadable - break - fi - # A body that is not a runs list can never read as an answer. - ff_seen="$(printf '%s' "$ff_body" | jq -r ' - if (.workflow_runs | type) != "array" then "-" - else (.workflow_runs | length) end' 2>/dev/null)" || ff_seen=- - if [[ "$ff_seen" = "-" ]]; then - answer=unreadable - break - fi - answer="$(printf '%s' "$ff_body" | jq -r ' - [ .workflow_runs[] - | select(.created_at >= env.SINCE) - | select(.display_title == env.FF_KEY) - | select(.status == "completed") - | .conclusion // "-" ] | first // empty' 2>/dev/null)" || { - answer=unreadable - break - } - [[ -n "$answer" ]] && break - # A short page is the end of the window, which is the whole - # termination argument — never a fixed number of pages. - [[ "$ff_seen" -lt 100 ]] && break - ff_page=$((ff_page + 1)) - done - - case "$answer" in - "") - # No keyed run yet. The bot is quiet and this is the ordinary - # state — keep polling, and forget any earlier unreadable pass. - answer_unknowns=0 - ;; - success | skipped) - # It ran and did not refuse; the merge shows up as the PR's - # terminal state above rather than here. - answer_unknowns=0 - ;; - failure) - # A CLOSED VOCABULARY, and `failure` still needs one more read - # (CLOUD-414). A refusal fails at the action's own step; a - # `failure` with no failed step, or one that died in `Set up - # job`, is the bot hitting its own 403 — which judged nothing. - refused=failure - break - ;; - *) - # `cancelled`, `timed_out`, `startup_failure`, `stale`, - # `action_required`, `unreadable`: the bot ran and did not - # decide, or we could not read whether it did. Never "main - # moved" (CLOUD-413) — that is a fact about a ref, and only - # `main-watch` may assert it. - unknown="$answer" - break - ;; - esac - - # The other way this lap can end: the bot has said nothing, but `main` - # has moved past this branch, so there is no answer left worth waiting - # for. An unmoved `main` and a quiet bot is NOT this case — nothing has - # changed and the PR may still merge, so that keeps polling. - if [[ "$(cat "$rc_ff" 2>/dev/null)" = "0" ]]; then - moved=1 - break - fi - - sleep "$interval" - done - reap_watch - - # THREE OUTCOMES, and they were two (CLOUD-413). Every non-success - # conclusion — a rate-limited 403 among them — was narrated as "main moved - # under the branch" and fell into a full lap. Measured across 24 laps of one - # landing: that diagnosis was wrong twice over, since 7 of 8 laps in one run - # reached green CI and several refusals were the limit rather than `main`. - # The loop's response to being rate-limited was to generate more of exactly - # the request that was rate-limited. - if [[ -n "$unknown" ]]; then - echo "land: lap $lap — no readable answer from the fast-forward bot ($unknown); \`main\` has NOT moved, so this is the bot, not the branch. Re-asking." - charge_unknown - continue - fi - - if [[ -n "$moved" ]]; then - echo "land: lap $lap — main moved under ${sha:0:8} while the bot was still silent, so the fast-forward can only be refused. Lapping: rebase, re-verify, retry." - continue - fi - - answer_unknowns=0 - # The branch stopped being a direct descendant, which is what the bot's own - # step refuses on. Not "main moved" as an inference — that claim belongs to - # `main-watch` and is made above. - echo "land: lap $lap — the fast-forward bot refused ($refused); the branch is no longer a direct descendant. Lapping: rebase, re-verify, retry." -done diff --git a/mise-tasks/main-watch.sh b/mise-tasks/main-watch.sh deleted file mode 100755 index f251c1c8f..000000000 --- a/mise-tasks/main-watch.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Block until origin/main advances past a given SHA (conditional poll — a quiet main costs no rate limit)" -# -# The other half of a landing lap's wait. `ci-wait` answers "is this SHA green"; -# this answers "is this SHA still landable". They are raced, because the moment -# `main` advances, the branch stops being a direct descendant and the CI run in -# flight is already waste — its verdict cannot be used, the fast-forward bot -# will refuse, and every remaining second of that run is billed. Waiting it out -# to be told what is already knowable is the expensive way to learn nothing. -# -# So `land` starts this alongside `ci-wait` and takes whichever answers first. -# A win here is not a failure: it means lap early, rebase onto the main that -# moved, and push — and that push cancels the doomed run through the workflows' -# `concurrency: cancel-in-progress`, which is why no `gh run cancel` is needed -# here. This task never writes anything. -# -# The poll is conditional, exactly as `ci-wait` is: each request carries the -# previous response's ETag as `If-None-Match`, and GitHub answers 304 with no -# body and no rate-limit charge when the ref has not moved. That is what makes a -# second watcher affordable at all — an unconditional poll would double the -# request cost of every lap to usually learn nothing. `git/ref/heads/main` is -# used rather than `commits/main` because the body is a single ref object: the -# smallest response that answers the question. -# -# Deliberately unbounded, like `ci-wait`: the exit condition is "main moved", -# and the caller races it against a wait that always terminates, so a `main` -# that never moves simply loses the race. A wall-clock cap here would turn a -# quiet main into a spurious lap, which costs a whole CI run. -# -# `set -e` is off on purpose: this is a poll, and a transient `gh` failure must -# cost one iteration rather than abort the landing. -set -uo pipefail - -base="${1:-${MAIN_WATCH_BASE:-$(git rev-parse origin/main)}}" -interval="${MAIN_WATCH_INTERVAL:-5}" -etag="" - -if [[ -z "$base" ]]; then - echo "::error:: main-watch: no base SHA to compare against." >&2 - exit 1 -fi - -echo "main-watch: watching main for movement past ${base:0:8} (conditional, ${interval}s)" - -while :; do - args=(-i "repos/{owner}/{repo}/git/ref/heads/main") - [[ -n "$etag" ]] && args+=(-H "If-None-Match: $etag") - resp=$(gh api "${args[@]}" 2>/dev/null | tr -d '\r') - - status=$(printf '%s' "$resp" | sed -n '1s@^HTTP/[0-9.]* \([0-9]*\).*@\1@p') - new_etag=$(printf '%s' "$resp" | sed -n 's/^[Ee][Tt]ag: //p' | head -n1) - [[ -n "$new_etag" ]] && etag="$new_etag" - - server_floor=$(printf '%s' "$resp" | sed -n 's/^[Xx]-[Pp]oll-[Ii]nterval: //p' | head -n1) - wait_for="$interval" - # A NUMERIC comparison, not an integer one. `-gt` is integer-only, and the - # `2>/dev/null` beside it turned "this interval is not an integer" into "the - # server did not ask for a floor" — so any fractional MAIN_WATCH_INTERVAL - # silently dropped a floor the endpoint is entitled to set. Found by - # CLOUD-390 setting the suite's interval to 0.2; the same hole was open to - # any caller configuring a sub-second poll. - # - # `sleep` already accepts fractions, which is what makes a fractional - # interval legal in the first place, so the comparison is the only thing - # that was assuming otherwise. Values come from the environment and the - # response header, so both are coerced with `+0` and a non-numeric floor - # reads as 0 — i.e. no floor, which is the fail-open direction. - if [[ -n "$server_floor" ]] && - awk -v f="$server_floor" -v i="$interval" 'BEGIN { exit !((f + 0) > (i + 0)) }'; then - wait_for="$server_floor" - fi - - # 304: the ref is byte-identical to the last reading, so there is nothing to - # compare — skip straight to the sleep rather than parsing an empty body. - if [[ "$status" != "304" ]]; then - body=$(printf '%s' "$resp" | awk 'body {print} /^$/ {body=1}') - head=$(printf '%s' "$body" | jq -r '.object.sha // empty' 2>/dev/null) - if [[ -n "$head" ]] && [[ "$head" != "$base" ]]; then - echo "main-watch: main moved ${base:0:8} -> ${head:0:8}" - exit 0 - fi - fi - - sleep "$wait_for" -done diff --git a/mise-tasks/verified.sh b/mise-tasks/verified.sh deleted file mode 100755 index 4a26e7084..000000000 --- a/mise-tasks/verified.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: is HEAD verified? Answers from the receipts, so no shell idiom can mask the verdict" -# -# `verify` already writes a receipt keyed to HEAD, and writes it only after its -# guarded steps pass — the mechanism was sound and nothing read it. Every -# consumer read the exit CODE instead, and an exit code is destroyed by an -# ordinary idiom: `mise run verify 2>&1 | tail -60` exits 0 whether verify passed -# or failed. That produced a real false green in this repo — `linear-check` -# rejected the branch, no receipt was written, and the zero the session acted on -# was `tail`'s. -# -# `run-shape-guard` denies the idioms observed so far, which is a fast path with -# a good error message and inherently incomplete: `| grep -c`, `| wc -l`, -# `; true`, or a wrapper script all escape shape recognition. This is the -# invariant underneath it. It never consults a remembered exit code, so no idiom -# — present or future — can fool it. -# -# The predicate, all of it a pure function of the receipts and the git refs: -# -# verify-receipt-missing no verify receipt for this exact HEAD -# linear-receipt-missing no linear-check receipt for this exact HEAD -# main-moved the linear-check receipt records an origin/main that -# is no longer the current one -# -# An amend or a rebase produces a new HEAD and therefore no receipt, which is the -# point. Receipts live under `--git-dir`, so they resolve per-worktree and one -# worktree's receipt cannot vouch for another's. -# -# Output is a pointer: which predicate failed and what to run, never the contents -# of a run. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT unverified-head-passes|s/^\texit 1$/\texit 0/|leaves HEAD unverified - -set -euo pipefail - -git_dir=$(git rev-parse --git-dir 2>/dev/null) || { - echo "::error:: not a git repository, so there is no HEAD to verify" >&2 - exit 2 -} -head=$(git rev-parse HEAD 2>/dev/null) || { - echo "::error:: HEAD does not resolve" >&2 - exit 2 -} -receipts="$git_dir/batten-receipts" - -fail() { - echo "::error:: HEAD ${head:0:8} is NOT verified — $1" >&2 - echo " Run \`mise run verify\` and read its exit status directly; never through a pipe, which reports the pipe's status. Then re-run \`mise run verified\`." >&2 - exit 1 -} - -[[ -f "$receipts/verify.$head" ]] || - fail "no verify receipt for this commit. A verify that failed, or whose exit status was swallowed by a pipe, leaves no receipt — which is exactly what this gate exists to catch." - -recorded_main=$(cat "$receipts/linear-check.$head" 2>/dev/null) || - fail "no linear-check receipt for this commit." - -current_main=$(git rev-parse origin/main 2>/dev/null) || { - echo "::error:: origin/main does not resolve, so currency cannot be judged. This is a checkout problem, not a verification failure." >&2 - exit 2 -} - -[[ "$recorded_main" = "$current_main" ]] || - fail "the linear-check receipt was taken against origin/main ${recorded_main:0:8}, but origin/main is now ${current_main:0:8}. Rebase, then verify again." - -echo "verified: HEAD ${head:0:8} has verify + linear-check receipts, linear on origin/main ${current_main:0:8}" diff --git a/mise.toml b/mise.toml index 4dca15988..f410693ee 100644 --- a/mise.toml +++ b/mise.toml @@ -338,7 +338,39 @@ CARGO_TERM_COLOR = "always" # itself, so there is nothing to re-download or re-hash. The rule generalises: # lock a binary artifact, vendor a source tree. Run `git submodule update # --init` once per clone. -_.path = ["tests/bats/bin"] +# +# `target/release` IS THE SECOND ENTRY, AND IT IS THIS CHECKOUT'S BINARY WINNING +# OVER AN INSTALLED ONE. Four task bodies invoke `batten` as a bare word — +# `checks green`, `pr watch`, `task`, `task alive` — and nothing put a build +# directory on PATH, so they resolved whatever `install:local` last copied to +# `~/.local/bin`. That is a DIFFERENT binary from the tree the task is running +# against the moment anybody edits `crates/`, and it answers confidently. +# +# Measured while landing CLOUD-1338: three consecutive `batten override request` +# calls refused a `batten.toml` the working tree had just grown a key for — +# `unknown field fast_forward_branches`, then `unknown field receipt` — because +# the installed binary predated the config it was judging. A gate reading a +# config its own schema does not know is the loudest form of this; the quiet form +# is a task that agrees with a stale answer. +# +# A path entry rather than `cargo run --quiet -p batten --` in each body, which +# was the other candidate: `alive` exists to answer WHILE something else is +# building, and `cargo run` would block on the target-dir lock exactly then. +# +# WHAT THIS DOES NOT DO, and the previous wording claimed the opposite. It said a +# clean checkout with no build "still fails loudly — `batten: command not found` +# is an honest report". That is FALSE: `_.path` PREPENDS, so an absent +# `target/release/batten` falls through to whatever `~/.local/bin` holds — the +# stale binary this entry exists to stop being reached. A false assurance is +# worse than a stated gap, which is this repository's own recurring lesson. +# +# The compensating control is provisioning rather than resolution: +# `[tasks."session:batten"]` runs `install:local`, which BUILDS the release +# binary at session start and reports an `::error::` with a log pointer when it +# cannot. So the ordinary session has the tree's own binary before any task runs, +# and a session that does not has already said so out loud. Making the fallback +# itself refuse needs a mechanism `_.path` has no spelling for. Found in review. +_.path = ["tests/bats/bin", "target/release"] # The checks that carry a verdict about THIS repository, named exactly as GitHub # names their check-runs (the matrix leg's suffix included). Shared by `ci-wait` # ("is this SHA green?") and `land`'s `graded_runs` ("does this SHA carry an @@ -362,6 +394,98 @@ _.path = ["tests/bats/bin"] # waiting only on `final` would strand every landing on a check that fans in from # eighteen. Two sets, two purposes; neither derives the other. CI_REQUIRED_CHECKS = "ci,batten-check,bats,cross,commit-lint,zizmor,darwin-link (aarch64-apple-darwin),semver,perf,windows,final,action (ubuntu-latest),action (macos-latest),action (windows-latest),action-violation,action-deny,action-usage,action-internal (ubuntu-latest),action-internal (macos-latest),action-final" + +# ─── THE LAP'S READY PHASE (CLOUD-1148) ─────────────────────────────────────── +# +# `batten land lap`'s ready step reads the pull request's BODY and asks the gates +# that judge it. Both halves are declared here rather than compiled in, and that +# is non-negotiable rule 1 rather than taste: a task name — or a forge client's +# argv — inside `crates/batten` is that rule's plainest violation, and the engine +# refuses to default either. +# +# THE ENGINE TREATS THESE TWO ASYMMETRICALLY, on purpose. An UNDECLARED gate list +# is a legitimate configuration and passes; a DECLARED gate that cannot run is a +# refusal, because a gate whose verdict is unknown has not said clean. So a typo +# in a task name here stops a lap rather than quietly retiring the gate — which +# is the failure this whole campaign is about. +# +# `|`-separated argvs, space-separated words. A comma would give one argv per +# word. +# +# WHY THESE TWO AND NOT `filed-here-check`: that gate is already the engine's +# (CLOUD-1051) and the lap reaches it through `batten check`, so listing it here +# would run it twice. These two are still bash and still have `land.sh` as their +# only caller — which is what slice 7's deletion would orphan. +LAND_BODY_SOURCE = "gh pr view --json body --jq .body" +LAND_BODY_GATES = "mise run deferral-check|mise run closing-key-check" +# THE ENTRY GATES, ASKED ONCE BEFORE THE FIRST LAP (CLOUD-1471). Same grammar and +# same asymmetry as `LAND_BODY_GATES`, and one difference that matters: the engine +# appends THE PULL REQUEST NUMBER to each argv, because which task drops a +# subscription is this consumer's vocabulary and which pull request is being +# landed is a fact the engine already resolved. A gate reading the number from its +# own environment would be a second authority over it. +# +# WHY THIS PAIR. AGENTS.md bans PR-webhook babysitting and the harness arms a +# subscription on every pull request this repo opens anyway, with no tool call +# behind it on two of the three measured (CLOUD-518) — so a deny rule on the tool +# closes only the path nobody used. `drop` FAILS OPEN and `check` does not, which +# is what keeps the pair safe on the critical path: off harness, no token or any +# non-200 mints nothing, `check` refuses exactly as before, and the manual +# `record` is still the way through (CLOUD-790). +# +# `land.sh` ran these two as its first act and nothing in the engine carried them, +# so retiring the lander would have dropped a live gate on the floor. Declared +# here, they survive the deletion with their caller intact. +# The `?` marks the drop ADVISORY, which is the pair's own asymmetry rather than +# a softening: the bash ran `drop … || true` and only `check` as an `if !`. Drop +# it and a `drop` that answers 1 (not dropped) or 2 (could not look) would stop +# the landing where the predecessor carried on to `check` — whose refusal is the +# one that names `record` as the way through. +LAND_ENTRY_GATES = "?mise run pr-unsubscribed drop|mise run pr-unsubscribed check" +# THE THREE THE LAP CANNOT RUN WITHOUT, AND NOTHING DECLARED THEM (review of +# #848). Every one of these is a consumer fact `crates/batten` refuses to guess — +# which is non-negotiable rule 1 working — but refusing to guess only reaches a +# working lander if the consumer then DECLARES them, and this file did not. Only +# the suites injected them, so `mise run land` could not complete: the entry +# gates resolved the repo to `pr_watch::REPO_PLACEHOLDER` and exited 3, and past +# that the empty `LAND_VERIFY` returned `Usage`, which `land::progress` maps to +# `Stop`. A gate that refuses to guess and a manifest that never answers are the +# same silence from the caller's side. +# +# `LAND_VERIFY` is the gate a lap re-proves the tree with, spelled as the argv it +# runs. It is `verify` rather than `verify:gated` deliberately: the lap owns the +# rebase, so the outer task's own linear-check is the one that must re-run per +# lap. The predecessor ran `mise run verify` directly, and this is that call site +# moved into config rather than a new decision. +LAND_VERIFY = "mise run verify" +# The workflow whose runs carry the fast-forward bot's answer, as the BARE FILE +# NAME the endpoint takes. `fast_forward::answer_request` interpolates it as ONE +# path segment of `actions/workflows/{workflow}/runs`, so a value carrying +# slashes makes a route that does not exist: 404, no `workflow_runs` key, +# `Answer::Unknown("unreadable")`, and a landing that laps forever without ever +# reading its own answer. +# +# THE FIRST SPELLING HERE WAS `.github/workflows/fast-forward.yml` AND ITS +# COMMENT WAS WRONG TWICE (review of #848): it justified the path form by saying +# `fast_forward::Ask` matches a run's own `path`, which is `land::FanIn`'s +# question and `CI_FANIN_WORKFLOW`'s value, not this one. The predecessor used +# the bare name in exactly this position and so does every test in the tree. +LAND_WORKFLOW = "fast-forward.yml" +# HOW MANY TIMES THE FAST-FORWARD POLL ASKS before reporting no answer. Its OWN +# row rather than `LAND_ANSWER_MAX_UNKNOWNS`, which bounds the CI wait: one name +# over two loops with two different defaults is a setting that cannot be tuned +# for either, and in the predecessor that name meant a third thing again (the +# count of unreadable bot answers, default 3) — so a consumer carrying it forward +# would have truncated the CI wait to about three seconds (review of #848). +LAND_ANSWER_ASKS = "120" +# THE RECLAIM CENSUS'S STOP NOTE (CLOUD-451), declared here because the program +# is this consumer's and a census name inside `crates/batten` is non-negotiable +# rule 1's plainest violation. `batten lease release` spawns it when a release +# applies — which is the engine recording that IT chose to stop its heartbeat, +# and never the heartbeat's own exit path (CLOUD-491): an exit path runs on the +# container kill too, and a note from one would erase the only distinction the +# census draws. +LEASE_STOP_NOTE = "mise-tasks/reclaim-census.sh note x land-stopped" # The subset of that roster for which ABSENCE — no check-run at all, as opposed # to one that graded `skipped` — is a legitimate reading rather than a name that # has not registered yet. Exactly two WORKFLOWS earn it, and each earns it twice @@ -493,7 +617,7 @@ CI_FANIN_WORKFLOW = ".github/workflows/ci.yml" BATS_TEST_TIMEOUT = "300" REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = "mise,attestation-check,engine-config,engine-doctor,engine-landed,engine-pinned,engine-ready,agentic-experiment-record,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,ci-cache-declared,ci-hygiene,ci-lease-precondition,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land,land-divergence-assert,land-lock,land-lock-check,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutation-declared-case,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep-check,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only-check,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,verified,weakens-declared,worktree-registration,nextest-slow" +MUTANT_GATES = "mise,attestation-check,engine-config,engine-doctor,engine-landed,engine-pinned,engine-ready,agentic-experiment-record,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land-divergence-assert,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutation-declared-case,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep-check,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only-check,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,spawn-widening,nextest-slow" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. @@ -3123,6 +3247,79 @@ run = "hk check --all" # "could not look", which every reader treats as "do not strand the head" rather # than as a verdict — so a future code cannot be read as green, and cannot be # read as red either. +# CLOUD-1148's first retirement, and the shim is the whole of why the task name +# survives. `mise-tasks/verified.sh` is gone; `batten receipt verified` composes +# the same three reads — a `verify` receipt for this exact HEAD, a +# `linear-check` receipt, and the `origin/main` it was taken against still being +# current — through `receipt::validity`, which is the one implementation of that +# predicate rather than a second reader of the same files. +# +# THE EXIT CODES ARE TRANSLATED, AND THAT IS NOT A WORKAROUND. The engine has one +# table with no per-verb exception (non-negotiable rule 5): `2` is the policy +# verdict everywhere, and `1`/`3` are the only codes a Batten failure produces. +# The predecessor spelled an unverified head `1` and an unusable checkout `2` — +# the table inverted. Callers read the old numbers, so this maps them back, which +# is the shim shape CLOUD-1170 established and the reason `land.sh` and +# `tests/tree-clean.bats` need no edit to keep working. +[tasks."verified"] +description = "Gate: is HEAD verified? Answers from the receipts, so no shell idiom can mask the verdict" +shell = "bash -c" +run = ''' +set -uo pipefail +# THE REMEDY IS THE CONSUMER'S, and that is rule 1 rather than a layering +# preference. The predecessor's refusal named `mise run verify` and told the +# reader to read its status directly, never through a pipe — task names, which +# `crates/batten` may not carry. So the verb states WHAT is unverified and this +# wrapper states what to do about it. +rc=0 +cargo run --quiet -p batten -- receipt verified || rc=$? +# The exit table inverts once, here. The predecessor answered `1` unverified and +# `2` could-not-look; the engine has one table with no per-verb exception, so `2` +# is the policy verdict and `1` the usage error. Every caller reads what it +# always read. +case "$rc" in +0) exit 0 ;; +2) + echo "::error:: HEAD is NOT verified — a receipt is missing or stale, most often because a verify exit status was swallowed by a pipe." >&2 + echo " Run \`mise run verify\` and read its exit status directly; never through a pipe, which reports the pipe's status. Then re-run \`mise run verified\`." >&2 + exit 1 + ;; +# BOTH ENGINE FAILURE CODES REACH THE PREDECESSOR'S ONE. The old table had no +# usage code of its own — `2` was could-not-look and covered every way the +# question went unanswered — so the engine's `1` (usage) and `3` (internal / +# could-not-look) both land there. Leaving `3` to the catch-all below handed a +# legacy caller a code its own table never defined, which is the translation +# this shim exists to do. Found in review. +1 | 3) exit 2 ;; +*) exit "$rc" ;; +esac +''' + +# ─── `mise run land`, the name every caller still uses (CLOUD-1148) ────────── +# +# `mise-tasks/land.sh` was a FILE task, so deleting the program deletes the task +# name with it — and `mise run land` is invoked by NAME from AGENTS.md, +# `.claude/rules/commits.md`, `.claude/settings.json` and several `batten.toml` +# remedies. A shim keeps every one of those call sites byte-identical, which is +# the same reason `[tasks."verified"]` and `[tasks."task-registry"]` exist and +# why those retirements touched no caller. +# +# NO EXIT TRANSLATION, unlike `verified`'s shim. That one inverts a table because +# its predecessor answered `1` unverified and `2` could-not-look; this verb was +# always the engine's own table — `0` landed, `2` a decision the caller must make +# (a conflict or a refused gate), `3` the laps ran out with no answer — so the +# codes pass straight through and a wrapper case statement would be a second +# authority over them. +# +# THE BASE IS A POSITIONAL WITH A DEFAULT, because `batten land lap` requires one +# and the predecessor read `origin/main` throughout. The default is spelled here +# rather than compiled in: which ref is trunk is this repository's own fact, and +# `crates/batten` refusing to guess it is non-negotiable rule 1 (see +# `run_land_fast_forward`'s `$LAND_WORKFLOW` refusal for the same shape). +[tasks.land] +description = "Land this branch's PR: rebase, verify, push, wait for CI, /fast-forward — lapping until it merges or a rebase conflicts" +run = 'cargo run --quiet -p batten -- land lap "{{arg(name="reference", default="main")}}"' + [tasks."checks-green"] description = "Is this SHA green over the required check set? (0 green / 1 red / 2 could not look / 3 no answer yet)" shell = "bash -c" diff --git a/policy/cfg-gated-test.rego b/policy/cfg-gated-test.rego new file mode 100644 index 000000000..c353f3474 --- /dev/null +++ b/policy/cfg-gated-test.rego @@ -0,0 +1,410 @@ +# CLOUD-1148's missing mechanism: a `#[cfg()]` is not ADDED to a +# `#[test]`. +# +# WHAT THE DEFECT WAS, MEASURED. `scratch.rs`'s reaper case asserted collection +# unconditionally; `pid_is_live` is two functions, and off unix the module +# abstains by construction, so the `windows` job reddened while every other leg +# was green. The first fix put `#[cfg(unix)]` over the case and added a +# `#[cfg(not(unix))]` twin. That turns a red leg green while leaving one arm +# NEVER COMPILED on the host that authors it — `cross-check` type-checks what +# `cfg` admits, so the off-unix arm goes unparsed locally and the next edit to it +# is discovered by CI. The second fix used `cfg!`, which keeps both arms compiled +# on every target and states the Windows contract inside the case. +# +# The doctrine landed as two doc comments and NO GATE, which is non-negotiable +# rule 2 violated by the commit that closed the class. This is the other half. +# +# A RATCHET OVER THE DIFF, NOT A STATE RULE, AND THAT IS THE WHOLE DESIGN. +# Measured before writing a line: this tree carries ~40 `#[cfg(unix)]` `#[test]` +# pairs, and they are not the defect. `hk_fix_selection.rs` runs a real hk gate, +# `bats_invocation.rs` runs bats, `stop_posture.rs` chmods a stub — their SUBJECT +# does not exist off unix, so the case cannot either. The defect is a case whose +# subject compiles everywhere being narrowed to one platform to silence a leg. +# A state rule cannot tell those apart and would refuse all 40 on its first run, +# which is the shape `batten.toml`'s own preamble to `bash-surface-not-growing` +# refuses in as many words: a gate whose first firing is a false positive gets an +# exception written for it, and the exception is what rots. +# +# So the decidable question is the DIRECTION: did this branch add one. That is +# exactly the class the first fix was, and `input.tree["base-delta"]` answers it +# without a spawn — `base-lines` is the base side of every edited path, so the +# comparison is the engine's rather than a second reading of git. +# +# WHY A COUNT PER PATH RATHER THAN A LINE IDENTITY. A line's text is not its +# identity across a rebase, and `land` rebases every lap — a predicate keyed on +# which line moved would re-fire on a change that moved nothing, which is the +# per-lap re-attestation `rules/policy-modules.md` names as the cost that gets a +# gate switched off. Counting the pairs on each side is stable under reordering +# and reindentation, and it under-denies in exactly one direction: swapping a +# legitimate unix-only case for an illegitimate one in the same file. That +# residue is named rather than silent. +# +# NO INLINE REGEX. The `[[pattern]]` rule refuses one at load, and none is needed: +# every test here is a string builtin over a trimmed line, which is the spelling +# `ci-cache-declared.rego` already uses for the same reason. +# +#MUTANT-SUITE crates/batten/tests/it/cfg_gated_test.rs +# `[12]` RATHER THAN `1`, AND THE FIRST SPELLING SURVIVED. Naming only +# `start + 1` neuters the two-hop body and the three-hop body's FIRST conjunct; +# its second, `attribute_or_doc(lines[start + 2])`, still reads the blank line in +# the named case's fixture and refutes the join, so the case stayed green and the +# sweep reported `SURVIVED` with no owner. A declared mutation whose named case +# cannot observe the change is a defect in the DECLARATION — `test-targets.rego` +# records the same lesson for `extension-may-widen` — so the expression has to +# reach every conjunct of the predicate it claims to neuter. +#MUTANT block-may-span-code|s@^\tattribute_or_doc(lines\[start + [12]\])$@\ttrue@|a_cfg_far_from_the_test_with_code_between_is_not_a_gated_test +#MUTANT reach-may-be-empty|s@^reach := \[1, 2, 3\]$@reach := []@|a_branch_that_adds_a_platform_gated_test_is_refused +#MUTANT direction-may-invert|s@\tafter > base@\tafter < base@|a_branch_that_adds_a_platform_gated_test_is_refused +#MUTANT base-may-read-as-empty|s@\tbase := gated_tests(base_lines_of(path))@\tbase := 0@|a_pre_existing_platform_gated_test_survives_an_edit +package batten + +import rego.v1 + +rules contains "platform-gated-test-added" + +# The branch's own diff, BOUND THROUGH AN OBJECT GUARD because `null` is not +# `undefined`: the engine emits `null` where the base would not resolve, and +# `not input.tree["base-delta"]` is dead for exactly that value. +delta := d if { + d := input.tree["base-delta"] + is_object(d) +} + +# THE COULD-NOT-LOOK ARM. A shallow clone, a detached CI checkout with the base +# unfetched, or a fork with no `origin/main` has no delta — and a rule that +# refuses nothing is byte-identical to a tree that added nothing on the decision +# surface, so the read failure is REPORTED rather than passed. +violation contains { + "rule": "platform-gated-test-added", + "verdict": "diff read absent", + "subjects": [{"path": "batten.toml"}], +} if { + not delta +} + +# The `cfg` predicates that name a PLATFORM. `#[cfg(test)]` on the module and +# `#[cfg(feature = "x")]` are deliberately absent: neither varies with the target, +# so neither can leave an arm uncompiled by `cross-check`. +platform_tokens := ["unix", "windows", "target_os", "target_family", "target_arch", "target_env"] + +platform_cfg(line) if { + trimmed := trim_space(line) + startswith(trimmed, "#[cfg(") + some token in platform_tokens + contains(trimmed, token) +} + +# NOT `test_attr`. A rule whose name begins `test_` IS the load-time tier, so the +# first spelling of this helper was RUN as a case and `policy test` reported +# `cfg-gated-test test-failed policy/cfg-gated-test.rego test_attr` — a naming +# collision that presents as a broken predicate. +case_attr(line) if { + startswith(trim_space(line), "#[test]") +} + +# An attribute or doc line — what may stand BETWEEN a `cfg` and the `#[test]` it +# gates without breaking the block. +# +# `//` rather than `///` so an ordinary comment inside an attribute run does not +# split the block; a one-keystroke evasion is the class the `words[0]` table in +# `rules/policy-modules.md` measures, and inserting `#[allow(…)]` or a comment +# between the two lines is exactly that keystroke. +attribute_or_doc(line) if { + startswith(trim_space(line), "#[") +} + +attribute_or_doc(line) if { + startswith(trim_space(line), "//") +} + +# THE REACH, DECLARED RATHER THAN SCANNED, AND IT IS WHAT MAKES THIS GATE +# RUNNABLE AT ALL. +# +# Two spellings preceded this one and both are unshippable — measured, not +# reasoned. The first was `every index, line in lines { block_ok(…) }`, which +# walks the whole file for every candidate `(cfg, test)` pair. The second kept +# the pairing and narrowed the inner test to `not gap_dirty`, which stops at the +# first breaking line. `batten check --rule cfg-gated-test` took **1099s** over +# this tree on the second spelling, where the same-`delta_sources` +# `test-targets` takes **1s**. So the PAIRING is the cost and no inner test +# removes it: `exec.rs` alone is ~30 `cfg` lines against ~60 `#[test]` lines over +# 2700 lines, which is ~1800 pairs for one file. +# +# A gate too slow to run inside `verify` is a gate that gets switched off, which +# is the same outcome as a gate that decides nothing. +# +# Rego has no fold to walk an attribute run with, so the reach is a DECLARED set +# of offsets and the predicate is linear in the file. Three is not a guess: +# `#[test]` is the item's own marker and stands LAST in the run by convention, so +# every instance measured here — the CLOUD-1148 defect itself, the three this +# gate found on its first run over this branch, and all ~40 pre-existing pairs — +# has its `cfg` on the line immediately beside the `#[test]`. The two spare hops +# cover an `#[allow]` or an `#[expect]` written between them, which is the +# one-keystroke evasion `rules/policy-modules.md`'s `words[0]` table exists to +# refuse. +# +# THE RESIDUE IS NAMED RATHER THAN SILENT: four or more attribute lines between +# the `cfg` and the `#[test]` reach no offset and are not refused. CLOUD-1669. +# +# THIS LINE SAID CLOUD-1667 AND THAT KEY IS SOMEBODY ELSE'S ROW — `perf` is CI's +# second pole at 574s. The key was predicted from the last one filed rather than +# read back from the row that was created, which is a misattribution wearing a +# filed row's clothes: a reader following it lands on unrelated work and reads it +# as an answer. `7426f8c6`'s `Refs:` trailer carries the same wrong key and is +# corrected here rather than by rewriting the record. +reach := [1, 2, 3] + +# A platform `cfg` at `index` gates a `#[test]` standing BELOW it. +gates_below(lines, index) if { + some hop in reach + case_attr(lines[index + hop]) + run_is_clean(lines, index, hop) +} + +# And ABOVE it. `#[test]` then `#[cfg(unix)]` compiles to exactly the same item, +# so a rule reading one order is a bypass with the two lines swapped. +gates_above(lines, index) if { + some hop in reach + case_attr(lines[index - hop]) + run_is_clean(lines, index - hop, hop) +} + +# Every line strictly inside a hop of `hop` from `start` is an attribute or a +# comment, so the two ends are in ONE run rather than in two separated by code. +# +# Written out per offset because `reach` has three members: a loop over an index +# range is the scan the bound above exists to avoid, and at three members the +# enumeration is shorter than the arithmetic would be. +run_is_clean(_, _, 1) := true + +run_is_clean(lines, start, 2) if { + attribute_or_doc(lines[start + 1]) +} + +run_is_clean(lines, start, 3) if { + attribute_or_doc(lines[start + 1]) + attribute_or_doc(lines[start + 2]) +} + +gated_here(lines, index) if { + gates_below(lines, index) +} + +gated_here(lines, index) if { + gates_above(lines, index) +} + +# How many `#[test]` cases in this file are narrowed to a platform. +# +# COUNTED OVER THE `cfg` LINES rather than over pairs, which is the same change +# read forwards: two `cfg` attributes on one case count two, and a ratchet only +# ever asks whether the number went up. +gated_tests(lines) := count([index | + some index, line in lines + platform_cfg(line) + gated_here(lines, index) +]) + +# An ADDED path has no base side, and its base count is therefore zero rather +# than unreadable: a new file carrying a platform-gated test is the same defect +# arriving in one commit instead of two. +base_lines_of(path) := lines if { + lines := delta["base-lines"][path] +} + +base_lines_of(path) := [] if { + not delta["base-lines"][path] +} + +touched contains path if { + some path in delta.added +} + +touched contains path if { + some path in delta.edited +} + +grew contains [path, after] if { + some path in touched + endswith(path, ".rs") + after := gated_tests(input.tree.lines[path]) + base := gated_tests(base_lines_of(path)) + after > base +} + +violation contains { + "rule": "platform-gated-test-added", + "verdict": "test cover partial", + "subjects": [{"path": path}, {"count": after}], +} if { + some [path, after] in grew +} + +deny contains finding if { + some finding in violation +} + +# --- the module's own tier --------------------------------------------------- +# +# These pin the PREDICATE. `crates/batten/tests/it/cfg_gated_test.rs` is the tier +# that proves the ENGINE builds `base-lines` at all — a `with input as` case +# fabricates the very shape the engine may be unable to produce, which is how a +# dead clause survives. Both tiers, and the second is not optional. + +test_a_branch_that_adds_a_platform_gated_test_is_refused if { + count(violation) == 1 with input as {"tree": { + "lines": {"crates/batten/src/scratch.rs": ["#[cfg(unix)]", "#[test]", "fn a() {}"]}, + "base-delta": { + "added": [], + "edited": ["crates/batten/src/scratch.rs"], + "deleted": [], + "base-lines": {"crates/batten/src/scratch.rs": ["#[test]", "fn a() {}"]}, + }, + }} +} + +# THE ORDER THAT COMPILES THE SAME AND WOULD OTHERWISE BYPASS. +test_the_attribute_order_does_not_matter if { + count(violation) == 1 with input as {"tree": { + "lines": {"crates/batten/src/scratch.rs": ["#[test]", "#[cfg(windows)]", "fn a() {}"]}, + "base-delta": { + "added": [], + "edited": ["crates/batten/src/scratch.rs"], + "deleted": [], + "base-lines": {"crates/batten/src/scratch.rs": ["#[test]", "fn a() {}"]}, + }, + }} +} + +# THE ONE-KEYSTROKE EVASION. An `#[allow]` or a comment between the two lines +# does not break the gate. +test_an_interleaved_attribute_does_not_break_the_block if { + count(violation) == 1 with input as {"tree": { + "lines": {"crates/batten/src/scratch.rs": [ + "#[cfg(target_os = \"linux\")]", + "#[allow(clippy::unwrap_used)]", + "/// what it does", + "#[test]", + "fn a() {}", + ]}, + "base-delta": { + "added": [], + "edited": ["crates/batten/src/scratch.rs"], + "deleted": [], + "base-lines": {"crates/batten/src/scratch.rs": ["#[test]", "fn a() {}"]}, + }, + }} +} + +# THE CASE THAT MAKES THE RULE SURVIVABLE, and the reason this is a ratchet: the +# ~40 pairs already in the tree are not refused when their file is edited. +test_a_pre_existing_platform_gated_test_survives_an_edit if { + count(violation) == 0 with input as {"tree": { + "lines": {"crates/batten/tests/it/bats_invocation.rs": ["#[cfg(unix)]", "#[test]", "fn a() {}", "// a new comment"]}, + "base-delta": { + "added": [], + "edited": ["crates/batten/tests/it/bats_invocation.rs"], + "deleted": [], + "base-lines": {"crates/batten/tests/it/bats_invocation.rs": ["#[cfg(unix)]", "#[test]", "fn a() {}"]}, + }, + }} +} + +# `#[cfg(unix)]` ON A HELPER is not this rule's business. It gates no case, and +# a unix-only fixture builder is how the legitimate pairs above are written. +test_a_cfg_on_a_plain_function_is_not_a_gated_test if { + count(violation) == 0 with input as {"tree": { + "lines": {"crates/batten/tests/it/stop_posture.rs": ["#[cfg(unix)]", "fn stub() {}", "#[test]", "fn a() {}"]}, + "base-delta": { + "added": [], + "edited": ["crates/batten/tests/it/stop_posture.rs"], + "deleted": [], + "base-lines": {"crates/batten/tests/it/stop_posture.rs": ["#[test]", "fn a() {}"]}, + }, + }} +} + +# THE DISCRIMINATING PARTNER for `block-may-span-code`. Code between the two +# lines means the `cfg` gates the code, not the case — and with +# `attribute_or_doc` neutered to `true` this would be refused. +test_a_cfg_far_from_the_test_with_code_between_is_not_a_gated_test if { + count(violation) == 0 with input as {"tree": { + "lines": {"crates/batten/src/task.rs": ["#[cfg(unix)]", "use rustix::process;", "#[test]", "fn a() {}"]}, + "base-delta": { + "added": [], + "edited": ["crates/batten/src/task.rs"], + "deleted": [], + "base-lines": {"crates/batten/src/task.rs": ["#[test]", "fn a() {}"]}, + }, + }} +} + +# `#[cfg(test)]` IS THE MODULE GATE and varies with no target, so it never +# leaves an arm uncompiled. Refusing it would refuse every unit-test module in +# the crate. +test_the_test_module_gate_is_not_a_platform_gate if { + count(violation) == 0 with input as {"tree": { + "lines": {"crates/batten/src/scratch.rs": ["#[cfg(test)]", "mod tests {", "#[test]", "fn a() {}"]}, + "base-delta": { + "added": [], + "edited": ["crates/batten/src/scratch.rs"], + "deleted": [], + "base-lines": {"crates/batten/src/scratch.rs": ["#[test]", "fn a() {}"]}, + }, + }} +} + +# `cfg!` IS THE REMEDY, so the shape the doctrine asks for must pass. +test_the_cfg_macro_inside_the_body_is_the_remedy if { + count(violation) == 0 with input as {"tree": { + "lines": {"crates/batten/src/scratch.rs": ["#[test]", "fn a() {", "if cfg!(unix) { assert!(true) }", "}"]}, + "base-delta": { + "added": [], + "edited": ["crates/batten/src/scratch.rs"], + "deleted": [], + "base-lines": {"crates/batten/src/scratch.rs": ["#[test]", "fn a() {}"]}, + }, + }} +} + +# AN ADDED FILE HAS NO BASE SIDE, and its count is zero rather than unreadable. +test_an_added_file_carrying_a_gated_test_is_refused if { + count(violation) == 1 with input as {"tree": { + "lines": {"crates/batten/tests/it/new_gate.rs": ["#[cfg(unix)]", "#[test]", "fn a() {}"]}, + "base-delta": { + "added": ["crates/batten/tests/it/new_gate.rs"], + "edited": [], + "deleted": [], + "base-lines": {}, + }, + }} +} + +# A NON-RUST PATH carries no attributes; reading its lines for them would be a +# gate looking in the wrong place and reporting clean from it. +test_a_non_rust_path_is_not_read_for_attributes if { + count(violation) == 0 with input as {"tree": { + "lines": {"batten.toml": ["#[cfg(unix)]", "#[test]"]}, + "base-delta": { + "added": [], + "edited": ["batten.toml"], + "deleted": [], + "base-lines": {"batten.toml": []}, + }, + }} +} + +# COULD NOT LOOK, reported rather than passed. +test_an_unresolvable_base_reports_rather_than_passing if { + some v in violation with input as {"tree": {"lines": {}, "base-delta": null}} + v.verdict == "diff read absent" +} + +# AND THE ARM MUST NOT FIRE OVER A DELTA THAT DID RESOLVE, which is what says the +# object guard binds rather than that the arm is unconditional. +test_a_resolved_delta_reports_no_read_failure if { + count(violation) == 0 with input as {"tree": { + "lines": {}, + "base-delta": {"added": [], "edited": [], "deleted": [], "base-lines": {}}, + }} +} diff --git a/policy/ci-parity.rego b/policy/ci-parity.rego index 9d6e0df3c..eb95459af 100644 --- a/policy/ci-parity.rego +++ b/policy/ci-parity.rego @@ -389,19 +389,60 @@ fanin_job_declared if { base_name(object.get(job, "name", key)) == base_name(fanin_check) } -# THE DECLARATION IS READ, NOT RESTATED. A literal path in the abandon task would +# THE DECLARATION IS READ, NOT RESTATED. A literal path in the abandon site would # be a second authority for one fact, and the one that drifts is always the copy -# nobody edits. Read as LINES because these are shell programs, which no parser -# here builds a document for. +# nobody edits. +# +# REPOINTED AT THE ENGINE (CLOUD-1148), AND THE DELETION DOES NOT SILENCE THIS. +# The subject was `mise-tasks/abandon-matrix.sh`, read as LINES because a shell +# program has no parser here. Retiring it does not make these two rules go +# quiet — `input.tree.lines[]` is undefined, so both helpers go FALSE and +# both violations FIRE. A retirement that only deleted the files would have +# reported two findings naming paths that no longer exist, which is why the +# repoint lands in the same change. +# +# `crates/batten/src/lib.rs` is read as lines for the same reason its predecessor +# was: this asks whether one identifier appears at the site, which is a text +# question rather than a structural one, and building a Rust document for it +# would be a parser this module does not need. +# +# AND THE IDENTIFIER IS THE ENGINE'S OWN. `CI_FANIN_WORKFLOW` is what the +# compensation reads — the workflow PATH a run carries, which is what +# `land::worthless` compares against. Its sibling `CI_FANIN_CHECK` is a check +# NAME and belongs to `checks_green::Roster`; the engine read the wrong one of +# the two for the whole of this branch, so `spared` was always 0 and the fan-in's +# own run was cancelled with the rest. Naming the variable here rather than the +# task is what makes that a finding next time. +# +# AND THE READ MUST SIT AT THE CONSTRUCTOR, WHICH IS THE HALF THIS RULE WAS +# MISSING (review of #848). Asking "does this file mention `CI_FANIN_WORKFLOW`" +# and "does this file call `land::abandon`" as two INDEPENDENT questions over +# ~6,000 lines is satisfied by an unrelated read plus a call handed the check +# name — which is the exact defect the paragraph above records as having been +# live for the whole of this branch, so the rule could not have caught its own +# subject. +# +# The join is `land::FanIn::from_workflow_path`, whose argument is the +# declaration, within a THREE-LINE window because rustfmt splits the call. The +# window is deliberately not one line: pinning the formatter's current output +# would make a reflow silence the gate, which is the dead-gate class +# `.claude/rules/policy-modules.md` warns about one level up. `land::FanIn` is +# the other half and it is the COMPILER's — a check name no longer type-checks +# in that argument position at all, so the two mechanisms hold the same join +# from opposite ends. abandon_reads_declaration if { - some line in input.tree.lines["mise-tasks/abandon-matrix.sh"] - contains(line, "CI_FANIN_WORKFLOW") + lines := input.tree.lines["crates/batten/src/lib.rs"] + some i, j + contains(lines[i], "FanIn::from_workflow_path") + j >= i + j <= i + 2 + contains(lines[j], "CI_FANIN_WORKFLOW") } violation contains { "rule": "fan-in-is-wired", "verdict": "job declare duplicate", - "subjects": [{"path": "mise-tasks/abandon-matrix.sh"}], + "subjects": [{"path": "crates/batten/src/lib.rs"}], } if { governed fanin_workflow @@ -411,15 +452,20 @@ violation contains { # ANTI-VACUITY. Every assertion above is about making the abandon SAFE; none of # them notices that it is never called. A mechanism nothing invokes passes each # of them and saves nothing. +# +# The predecessor asked whether `mise-tasks/land.sh` named `abandon-matrix`. The +# successor's equivalent is whether the lap's compensation dispatch reaches +# `land::abandon` at all — the same question about the same wiring, one layer +# down, and still the only one of these rules that notices a dead mechanism. lander_calls_abandon if { - some line in input.tree.lines["mise-tasks/land.sh"] - contains(line, "abandon-matrix") + some line in input.tree.lines["crates/batten/src/lib.rs"] + contains(line, "land::abandon") } violation contains { "rule": "fan-in-is-wired", "verdict": "job reach dead", - "subjects": [{"path": "mise-tasks/land.sh"}], + "subjects": [{"path": "crates/batten/src/lib.rs"}], } if { governed fanin_workflow @@ -471,16 +517,51 @@ violation contains { # # COUNTED rather than grepped for absence, because the two forms differ only by a # suffix and a search for the bare spelling would pass a file carrying both. -lease_invocations(path) := count([line | - some line in input.tree.lines[path] - contains(line, "bash -c \"$body\"") +# THE COUNTED SPELLING MOVED WITH THE STEP (CLOUD-1148). It was +# `bash -c "$body"`, the fetched-script invocation — and when that step became +# `batten lease guard`, both comprehensions counted a string no workflow +# contains. Zero invocations means the clause below cannot fire, so the predicate +# would have gone DEAD WHILE LOADING CLEAN, which is the class this repository +# exists to refuse and the reason these two move in the same change as the YAML. +# +# What is counted is unchanged in kind: the guard's own invocation, and the same +# invocation carrying its tolerance. The property is still "every invocation +# carries its `|| exit 0`", because the harm is still the one the clause below +# names — a step that reds makes the RUN `failure` rather than `cancelled`, +# `final` fails its `needs:` under `!cancelled()`, and the lander re-drafts the +# fleet. +# +# COUNTED rather than grepped for absence, for the reason the predecessor gave: +# the two forms differ only by a suffix, so a search for the bare spelling would +# pass a file carrying both. +# PAIRED PER INVOCATION, NEVER TWO INDEPENDENT TOTALS. These were two `count`s +# compared for equality, and equal totals is not the property: a file with two +# invocations, one of them untolerated, passes as soon as any unrelated line +# anywhere in it carries the tolerance string. The comparison could be satisfied +# by a coincidence, over the one clause whose failure re-drafts the whole fleet. +# +# The invocation is a two-line spelling — the guard's argv ends in a `\` and its +# operands and `|| exit 0` follow on the next line — so the tolerance belongs to +# the line immediately AFTER the one that invokes. Indexing is what expresses +# that; counting cannot. +lease_invocations(path) := count([i | + some i, line in input.tree.lines[path] + contains(line, "lease guard \\") ]) -lease_tolerant(path) := count([line | - some line in input.tree.lines[path] - contains(line, "bash -c \"$body\" || exit 0") +# An invocation whose CONTINUATION does not carry the tolerance. An absent next +# line reads as untolerated, which is the direction a miss must fail in: a guard +# invocation at end-of-file has no `|| exit 0` at all. +lease_untolerated(path) := count([i | + some i, line in input.tree.lines[path] + contains(line, "lease guard \\") + not tolerated_at(path, i) ]) +tolerated_at(path, i) if { + contains(input.tree.lines[path][i + 1], "\"$LEASE_RUN_ID\" || exit 0") +} + violation contains { "rule": "lease-authorises-before-spending", "verdict": "lease guard unsafe", @@ -489,7 +570,7 @@ violation contains { governed some path, _ in input.tree.lines lease_invocations(path) > 0 - lease_invocations(path) != lease_tolerant(path) + lease_untolerated(path) > 0 } # --- a workflow reading check status decides green through one predicate ------ @@ -795,8 +876,16 @@ sound_input := {"tree": { "release-plz.toml": {"pr": {"pr_draft": true}}, }, "lines": { - "mise-tasks/abandon-matrix.sh": ["run=$CI_FANIN_WORKFLOW"], - "mise-tasks/land.sh": ["mise run abandon-matrix"], + # THE DECLARATION SITS AT THE CONSTRUCTOR, rendered the way rustfmt + # renders the real call: `abandon_reads_declaration` binds the two + # within three lines of each other since review of #848, so a fixture + # spelling them independently is no longer sound. + "crates/batten/src/lib.rs": [ + "let fanin = land::FanIn::from_workflow_path(", + " std::env::var(\"CI_FANIN_WORKFLOW\").unwrap_or_default(),", + ");", + "let report = land::abandon(&repo, &sha, &fanin);", + ], # The foreign leg the anti-vacuity term needs a subject from: without it a # clean fixture would be clean because nothing was looked at. ".github/workflows/rust.yml": [" - run: mise exec -- cargo nextest run --workspace"], @@ -1020,8 +1109,18 @@ test_a_fanin_workflow_declaring_no_such_job_is_refused if { f.verdict == "workflow declare empty" } +# A SITE THAT RESTATES THE PATH RATHER THAN READING THE DECLARATION. The literal +# still reaches the abandon, so nothing observable breaks until somebody moves the +# fan-in — which is why this is a gate and not a review note. +# +# It also covers the sibling-variable defect that shipped on this branch: reading +# `CI_FANIN_CHECK` here compiles, runs, and cancels the fan-in's own run, and this +# fixture is what a version doing that looks like. test_an_abandon_that_restates_the_path_is_refused if { - lines := object.union(sound_input.tree.lines, {"mise-tasks/abandon-matrix.sh": ["run=.github/workflows/ci.yml"]}) + lines := object.union(sound_input.tree.lines, {"crates/batten/src/lib.rs": [ + "let fanin = String::from(\".github/workflows/ci.yml\");", + "let report = land::abandon(&repo, &sha, &fanin);", + ]}) found := violation with input as {"tree": object.union(sound_input.tree, {"lines": lines})} some f in found f.verdict == "job declare duplicate" @@ -1030,12 +1129,55 @@ test_an_abandon_that_restates_the_path_is_refused if { # THE ANTI-VACUITY TERM. Every other fan-in clause makes the abandon SAFE; none # of them notices it is never called. test_a_lander_that_never_abandons_is_refused if { - lines := object.union(sound_input.tree.lines, {"mise-tasks/land.sh": ["mise run ci-wait"]}) + lines := object.union(sound_input.tree.lines, {"crates/batten/src/lib.rs": ["let fanin = land::FanIn::from_workflow_path(", " std::env::var(\"CI_FANIN_WORKFLOW\").unwrap_or_default(),", ");"]}) found := violation with input as {"tree": object.union(sound_input.tree, {"lines": lines})} some f in found f.verdict == "job reach dead" } +# THE CLASS REVIEW OF #848 NAMED, AND THE ONE THIS ROW COULD NOT SEE. The two +# fan-in clauses were INDEPENDENT line questions over one file, so a read of the +# declaration anywhere — a comment, a doc block, an unrelated helper thousands of +# lines away — plus a call handed the WRONG value satisfied both and the module +# reported clean. The header above records the engine doing exactly that for the +# whole of the branch that wrote this rule, so the rule could not catch its own +# subject. +# +# The fixture passes the OLD spelling and fails the new one: `land::abandon` is +# reached, `CI_FANIN_WORKFLOW` appears, and the constructor is handed a different +# variable entirely. +test_a_declaration_read_far_from_the_constructor_is_refused if { + lines := object.union(sound_input.tree.lines, {"crates/batten/src/lib.rs": [ + "// the fan-in is declared as CI_FANIN_WORKFLOW in the manifest", + "fn unrelated() -> String {", + " std::env::var(\"CI_FANIN_WORKFLOW\").unwrap_or_default()", + "}", + "", + "let fanin = land::FanIn::from_workflow_path(", + " std::env::var(\"CI_FANIN_CHECK\").unwrap_or_default(),", + ");", + "let report = land::abandon(&repo, &sha, &fanin);", + ]}) + found := violation with input as {"tree": object.union(sound_input.tree, {"lines": lines})} + some f in found + f.verdict == "job declare duplicate" +} + +# THE WINDOW IS THREE LINES RATHER THAN ONE, DELIBERATELY. Pinning rustfmt's +# current rendering would make a reflow silence the gate, which is strictly worse +# than the duplication the binding exists to stop — so the collapsed spelling has +# to pass, and this is the case that says so. +test_the_constructor_and_its_declaration_may_sit_on_one_line if { + lines := object.union(sound_input.tree.lines, {"crates/batten/src/lib.rs": [ + "let fanin = land::FanIn::from_workflow_path(std::env::var(\"CI_FANIN_WORKFLOW\").unwrap_or_default());", + "let report = land::abandon(&repo, &sha, &fanin);", + ]}) + found := violation with input as {"tree": object.union(sound_input.tree, {"lines": lines})} + every f in found { + f.verdict != "job declare duplicate" + } +} + test_a_job_that_starts_without_asking_the_lease_is_refused if { wf := object.union(sound_workflow, {"jobs": {"ci": { "name": "ci", @@ -1158,12 +1300,28 @@ test_a_job_that_waits_on_another_is_not_asked_for_the_lease if { # COUNTED, NOT SEARCHED FOR ABSENCE: the two forms differ only by a suffix, so a # file carrying both would pass a bare search. test_a_precondition_invoked_without_the_tolerant_suffix_is_refused if { - lines := object.union(sound_input.tree.lines, {".github/workflows/ci.yml": [" bash -c \"$body\""]}) + lines := object.union(sound_input.tree.lines, {".github/workflows/ci.yml": [ + " \"$RUNNER_TEMP/batten-bin/batten\" lease guard \\", + " \"$LEASE_HEAD_SHA\" \"$LEASE_HEAD_REF\" \"$LEASE_RUN_ID\"", + ]}) found := violation with input as {"tree": object.union(sound_input.tree, {"lines": lines})} some f in found f.verdict == "lease guard unsafe" } +# THE MIRROR, and it is what keeps the case above from passing over a clause that +# refuses everything: the same invocation WITH its tolerance is silent. +test_a_precondition_carrying_the_tolerant_suffix_is_admitted if { + lines := object.union(sound_input.tree.lines, {".github/workflows/ci.yml": [ + " \"$RUNNER_TEMP/batten-bin/batten\" lease guard \\", + " \"$LEASE_HEAD_SHA\" \"$LEASE_HEAD_REF\" \"$LEASE_RUN_ID\" || exit 0", + ]}) + found := violation with input as {"tree": object.union(sound_input.tree, {"lines": lines})} + every f in found { + f.verdict != "lease guard unsafe" + } +} + test_a_workflow_reading_check_runs_without_the_one_predicate_is_refused if { wf := object.union(sound_lander, {"jobs": {"land": {"steps": [{"run": "gh api /check-runs | jq ."}]}}}) found := violation with input as swap(".github/workflows/land.yml", wf) diff --git a/policy/command-task-defined.rego b/policy/command-task-defined.rego index b6f5b2093..090f4e9d0 100644 --- a/policy/command-task-defined.rego +++ b/policy/command-task-defined.rego @@ -212,13 +212,13 @@ test_a_row_naming_a_manifest_task_is_clean if { # red on most of this repository's own rows, which is worse than the silence it # replaces: a gate whose first firing is a false positive gets switched off. test_a_file_task_counts_with_and_without_its_extension if { - every check in ["mise run land", "mise run land.sh"] { + every check in ["mise run linear-check", "mise run linear-check.sh"] { found := violation with input as {"tree": { "documents": { "batten.toml": {"rule": [{"id": "r", "check": check}]}, "mise.toml": {"tasks": {}}, }, - "tracked": ["mise-tasks/land.sh"], + "tracked": ["mise-tasks/linear-check.sh"], "missing": {}, }} count(found) == 0 @@ -248,7 +248,7 @@ test_a_program_on_path_is_left_alone if { "batten.toml": {"rule": [{"id": "r", "check": "hk util check-merge-conflict"}]}, "mise.toml": {"tasks": {}}, }, - "tracked": ["mise-tasks/land.sh"], + "tracked": [], "missing": {}, }} count(found) == 0 diff --git a/policy/fixture-forks.rego b/policy/fixture-forks.rego index 3547f850c..834fe6f54 100644 --- a/policy/fixture-forks.rego +++ b/policy/fixture-forks.rego @@ -75,11 +75,45 @@ import rego.v1 rules contains "fixture-fork-added" -# The branch's own diff. NULL when the base rev does not resolve, so `added` does -# not hold and this rule goes silent — could-not-look, never a fabricated empty -# delta that would pass the gate on ignorance. `filed-here.rego` reads the same -# fact the same way and `test-targets.rego` states the reasoning. -delta := input.tree["base-delta"] +# The branch's own diff. NULL when the base rev does not resolve. +# +# **GOING SILENT IS NOT ABSTAINING, AND THIS PARAGRAPH CLAIMED IT WAS** (review +# of #848). It read "could-not-look, never a fabricated empty delta that would +# pass the gate on ignorance" — but a null `delta` makes `delta.added` undefined, +# both refusing clauses go quiet, the `missing` clause below still evaluates so +# the module does NOT report `RuleSkipped`, and the result is ZERO FINDINGS at +# exit 0. Silence and a pass are byte-identical on the decision surface, which is +# the whole thing this module's own header says it refuses. +# +# Measured shape: a shallow clone, a detached CI checkout with the base +# unfetched, or a fork with no `origin/main` — a branch adding a whole file of +# forked fixtures passes. +# **BOUND THROUGH AN OBJECT GUARD, because `null` IS NOT `undefined`** (review +# of #848). This was a bare `delta := input.tree["base-delta"]` with a +# could-not-look arm spelled `not input.tree["base-delta"]` — and in Rego only +# `false` and undefined make `not` hold, so that arm was DEAD for exactly the +# state it was written for. `spawn-widening.rego` states the rule verbatim for +# the same fact, in this same branch, and this module was written beside it +# without reading it. +delta := d if { + d := input.tree["base-delta"] + is_object(d) +} + +# THE COULD-NOT-LOOK ARM, which `spawn-widening.rego` carries for the same fact +# and this did not. A base that will not resolve is reported rather than passed. +# +# `not delta` rather than `not input.tree["base-delta"]`: the rule above holds +# only for an object, so this fires for `null` — the shallow clone, the detached +# CI checkout with the base unfetched, the fork with no `origin/main` — and for +# an absent key alike. +violation contains { + "rule": "fixture-fork-added", + "verdict": "diff read absent", + "subjects": [{"path": "batten.toml"}], +} if { + not delta +} # A `[[pattern]]` ROW RATHER THAN AN INLINE LITERAL, and not merely because an # inline regex fails to load. Two spellings of this one concept are live in the @@ -114,11 +148,26 @@ forks_now(path) := count([index | # And as the base rev had it. `base-lines` carries the base side of every EDITED # path, which is what makes the edited arm a comparison rather than a snapshot. +# **THE BASE IS BOUND BEFORE IT IS WALKED**, and that is a correctness clause +# rather than a style (review of #848). A comprehension over an UNDEFINED +# collection yields the EMPTY SET rather than undefined, so walking +# `delta["base-lines"][path]` inline answered `0` for a path the engine +# projected no base side for — an unreadable base blob, a rename the base read +# did not resolve — and the edited arm below then read `forks_now(path) > 0` as +# GROWTH and refused a fixture that merely already forked. A false deny on a +# `deny`-severity row, which is the direction that gets a gate switched off. +# `spawn-widening.rego` measured this class at 81 of 81 modules refusing. forks_at_base(path) := count([index | - some index, line in delta["base-lines"][path] + some index, line in base_lines(path) regex.match(init_fork, line) ]) +# Undefined where the delta carries no base side for `path`, which is what makes +# the arm above undefined too rather than vacuously zero. +base_lines(path) := lines if { + lines := delta["base-lines"][path] +} + # AN ADDED FIXTURE THAT FORKS. Every matching line is new by construction — the # file is absent from base — so each one is a finding with its own pointer. violation contains { @@ -325,13 +374,19 @@ test_a_non_rust_path_in_the_suite_is_not_judged if { # COULD NOT LOOK. A null `base-delta` goes silent rather than reading as an empty # diff. -test_an_unresolvable_base_refuses_nothing if { - count(violation) == 0 with input as {"tree": { +# **COULD-NOT-LOOK IS NOT CLEAN, and this case asserted that it was** (review +# of #848). It read `count(violation) == 0` over a `null` base — enshrining the +# dead arm rather than catching it, which is why the module shipped with a +# could-not-look channel that could not fire. +test_an_unresolvable_base_reports_rather_than_passing if { + some v in violation with input as {"tree": { "base-delta": null, "lines": {}, "missing": {}, }} with data.batten.patterns as patterns + + v.verdict == "diff read absent" } # AND A SOURCE THAT WOULD NOT PARSE IS A FINDING RATHER THAN A CLEAN TREE. diff --git a/policy/module-layering.rego b/policy/module-layering.rego index 110eeb250..f50af4b27 100644 --- a/policy/module-layering.rego +++ b/policy/module-layering.rego @@ -117,6 +117,19 @@ declared_modules := { # it sits below `rules` and reaches `exec` for its one spawn, which is the # placed adapter `policy/spawn-adapters.rego` requires. "recorder", + # `scratch` arrived with CLOUD-1148 and this rule named it once more — module + # written, its three cases green, and nobody had placed it. It is the LEAF of + # the crate and the only member of the table that is test support rather than + # product: out-of-tree scratch for the suites, reaped by liveness. + # + # It sits at the bottom because it reads NOTHING in this crate — not `error`, + # not `exit` — and so has no edge to forbid in that direction. The direction + # worth naming is the other one: nothing in the library may read it either, + # and a `src/*.rs` module that did would be shipping a test fixture path into + # a decision. Its callers are the suites, which is why it is `pub` at all — + # `#[cfg(test)]` cannot be shared across the three scopes that need it (this + # crate's unit tests, the `it` binary, and the standalone `tests/*.rs`). + "scratch", # `secret` arrived with CLOUD-1569 and this rule named it once more, on the # gate before landing. It is the PUREST LEAF in the table: it reaches nothing # in this crate at all — not even `error` — because its whole surface is one @@ -133,7 +146,6 @@ declared_modules := { # It has no back-edge to forbid for the reason `agent` has none, arrived at # from the opposite direction: `agent` reaches everything and is read by # nothing, this is read by everything and reaches nothing. - # `deferral` and `source` arrived with this bundle, and this rule named BOTH # on the last gate before landing — module written, both suites green, # `mise run fix` clean, `module-map-check` satisfied, and nobody had placed @@ -257,6 +269,14 @@ declared_modules := { # is not one (CLOUD-1260), which is why the edge is listed rather than left # to follow. "land", + # `pipeline` arrived with CLOUD-1338's declared composition, and this rule + # named it before a human did. It sits ABOVE `land` and reaches it alone: the + # composition is a list of that module's `Step`s with a compensation per row, + # and it decides only whether such a list is walkable. Nothing in it resolves a + # reading, spawns, or touches the forge — which is what keeps the invariant + # ("an effectful step before the commit point must declare an undo") a + # load-time property of a table rather than something a lap discovers. + "pipeline", # `forge`, `tools`, `captured`, `taskset` arrived with CLOUD-843's substrate # wave, and this rule named all four before a human did — the eighth time the # absence-is-an-error clause has earned its keep, and the first on a batch. @@ -326,6 +346,69 @@ declared_modules := { # weaker copy of the predicate living in a workflow. It also reaches `rules`, # for the process ladder every spawning site in this crate shares. "pr_watch", + # `fast_forward` arrived with CLOUD-1338's second half and is placed BESIDE + # `pr_watch` rather than inside it. Both spawn the forge client and both are + # read by a lap, but they ask about different objects: `pr_watch` asks whether + # a SHA is green, this asks whether the bot answered THIS request. Folding it + # in would have put two subjects behind one module's name. + # + # It reaches `pr_watch` for `parse_response` ALONE — the `-i` header/body + # split — which is the sanctioned edge `mcp -> rules` takes onto `parse_node`: + # onto a parser, never onto a decider. A second response splitter here would + # be the disagreement class `.claude/rules/policy-modules.md` records for + # parsers, and this endpoint's headers are the ones a rate-limit arm reads. + # + # IT DECIDES NOTHING ABOUT THE BRANCH, which is what keeps it below the lap: + # it resolves a conclusion token and hands it back, and whether a lap may + # continue on that token is `land`'s. Its `hook` and `check` edges are + # forbidden below for `pr_watch`'s reason — it spawns, and a gate declared + # `read` must not. + "fast_forward", + # `main_watch` is the staleness half of a lap's wait, and it is placed beside + # `pr_watch` rather than inside it because the two ask about DIFFERENT + # OBJECTS: one reads a head's check runs, the other reads the trunk ref. What + # they share is the conditional-request machinery, and that is imported — + # `parse_response`, `interval_for` and the `Response` shape are `pr_watch`'s, + # read here rather than re-derived, which is the single-parser rule + # `.claude/rules/policy-modules.md` states for an argv and which holds for a + # response header block for the same reason. + # + # IT DECIDES NOTHING ABOUT THE BRANCH, the same edge `fast_forward` carries + # above: it resolves a sha and reports whether it differs from a base handed + # in. Whether a lap may continue on that reading is `land`'s. Its `hook` and + # `check` edges are forbidden below for `pr_watch`'s reason — it spawns, and + # a gate declared `read` must not. + "main_watch", + # `rest` is the forge's REST tier read IN PROCESS, over `fetch` (CLOUD-1338). + # It sits BELOW every caller and reaches `fetch` alone: one client, one + # credential reader, and a typed answer carrying the status, the `ETag` and + # the poll floor. + # + # IT REPLACED FOUR SPAWNS RATHER THAN JOINING THEM, which is why the three + # modules above no longer appear on `spawn-adapters`' placement table. Each of + # the four carried the same `#[expect(clippy::disallowed_types)]` reason — + # that this crate carries no HTTP client resolving a forge credential — and + # the client had been in the crate since CLOUD-745, with `lease` already using + # it. + # + # IT DECIDES NOTHING, which is what keeps it at the bottom: it resolves a + # response and hands it back, and every verdict over that reading belongs to + # the module that asked. Its `hook` edge is forbidden below for `fetch`'s + # reason rather than for the spawning modules' — a network round trip cannot + # fit the mediated path's budget whether or not it forks. + "rest", + # `speculation` is the bet a waiter places on the base that is about to exist + # (CLOUD-748, CLOUD-862). It sits BELOW `land` for the reason `fast_forward` + # does: it resolves readings and returns a verdict token, and whether a lap + # acts on that token is `land`'s. Its settle table is a pure function of + # readings the caller already took, which is what lets the conserved + # CLOUD-1306 gap be pinned by a case rather than described in prose. + # + # NO SPAWN EDGE, and that is why it is absent from `spawn-adapters`: every + # read it makes is `git.rs`/`gix` in process. The lease reading it needs is + # handed IN as a `Live`, so the module never reaches the remote itself — which + # is also what keeps its whole decision testable without one. + "speculation", # `record` arrived with CLOUD-1265 and this rule named it a ninth time — the # module was written, both tiers were green, and this is what said nobody had # placed it. @@ -472,8 +555,47 @@ forbidden[from] contains to if { # (house style §5) and the read-only allowlist is DERIVED from that # declaration, so a `check` path reaching a network write would put a # writing prefix on the allowlist itself (CLOUD-90's shape). - "hook": {"fetch", "mcp", "lease", "gitwrite", "land"}, - "check": {"lease", "gitwrite", "land"}, + # AND THE THREE FORGE-READING ADAPTERS, LISTED RATHER THAN PROMISED. + # `pr_watch`, `fast_forward` and `main_watch` each carry a comment in the + # placement table above saying their `hook` and `check` edges are + # "forbidden below for `pr_watch`'s reason" — and until now no row + # forbade any of them, `pr_watch` included. The prose described a rule + # that did not exist, so a direct import from either layer passed while + # every reader of this file was told it could not. + # + # The reason the prose gave is the right one and is kept: all three + # reach the forge, and a gate declared `read` (house style §5) whose + # allowlist is DERIVED from that declaration must not reach a network + # call — CLOUD-90's shape. `hook -> land` and `check -> land` already + # cover today's lap route TRANSITIVELY, and this file states its own + # standard for that case one paragraph up: a guarantee routable around by + # one hop is not one (CLOUD-1260), so the direct edges are listed. + # + # `rest` IS THAT ONE HOP, and listing `fetch` without it was the gap. + # `rest` is the REST tier OVER `fetch` — it resolves the forge + # credential and makes the call — so `hook -> rest` reached the network + # by exactly the route the `fetch` entry refuses, one name later. Found + # in review. + "hook": { + "fetch", "rest", "mcp", "lease", "gitwrite", "land", + "pr_watch", "fast_forward", "main_watch", + }, + # `check` NAMES NO MODULE TODAY, so this row is INERT — and that is worth + # stating rather than leaving a reader to infer enforcement from a table + # entry. There is no `crates/batten/src/check.rs`; the tree-scoped gate is + # the `check` VERB, which lives in `lib.rs`, and `lib` legitimately + # reaches everything. So the constraint the paragraph above describes is + # real and is not expressible as a module edge at this layout. + # + # Kept rather than deleted, for the same reason the `hook` row beside it + # is listed rather than left transitive: the day a `check` module lands, + # the edge is already refused instead of being remembered. Found while + # adding the three adapter targets below — the case written for it could + # not fire, which is how a row with no possible subject announces itself. + "check": { + "lease", "gitwrite", "land", + "pr_watch", "fast_forward", "main_watch", + }, # And the other direction, which is `symbols`' and `pinned`'s row again: the # dispatcher sits below the engine and must not reach the module that # adjudicates a mediated call. `mcp -> rules` is deliberately NOT here -- @@ -643,6 +765,37 @@ test_the_same_chain_is_clean_in_the_declared_direction if { ) } +# ONE CASE PER FORGE-READING ADAPTER, IN BOTH DIRECTIONS. The table promised +# these six edges in prose for their whole life and forbade none of them, so a +# row without a case here is exactly how that happened again — and the pairs +# below are what make the addition discriminate rather than merely load. +test_the_mediated_call_may_not_reach_a_forge_reading_adapter if { + count(violation) == 1 with input as judging( + "crates/batten/src/hook.rs", + [internal("pr_watch", 21)], + ) + + count(violation) == 1 with input as judging( + "crates/batten/src/hook.rs", + [internal("fast_forward", 22)], + ) + + count(violation) == 1 with input as judging( + "crates/batten/src/hook.rs", + [internal("main_watch", 23)], + ) +} + +# AND THE DIRECTION THAT IS THE DESIGN: a lap reaches all three, which is what +# these modules exist for. Without this a table that banned the edge outright +# would satisfy the six cases above. +test_the_lap_reaches_every_adapter_it_is_built_on if { + count(violation) == 0 with input as judging( + "crates/batten/src/land.rs", + [internal("pr_watch", 41), internal("fast_forward", 42), internal("main_watch", 43)], + ) +} + test_an_unrelated_edge_is_not_this_rules_business if { count(violation) == 0 with input as judging( "crates/batten/src/hook.rs", @@ -715,6 +868,18 @@ test_the_mediated_path_must_not_reach_the_transport if { ) } +# AND NOT THE TIER OVER IT. `rest` resolves the forge credential and makes the +# call, so listing `fetch` alone left `hook -> rest` reaching the network by the +# same route one name later — the one-hop escape this file's own standard +# refuses. Found in review; without this case the table can lose the row again +# and every other assertion here stays green. +test_the_mediated_path_must_not_reach_the_tier_over_the_transport if { + count(violation) == 1 with input as judging( + "crates/batten/src/hook.rs", + [internal("rest", 31)], + ) +} + # The dispatcher must not reach the adjudicator — `symbols`' row one family over. test_the_dispatcher_must_not_reach_the_engine if { count(violation) == 1 with input as judging( diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index 38a2692e2..b47890915 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -102,6 +102,15 @@ #MUTANT case-half-deleted|s@body in removed@true@|a_half_deleted_bats_case_is_still_refused #MUTANT case-binding-survives|s@binding in removed@true@|a_bats_case_spending_a_surviving_binding_is_refused #MUTANT case-names-nothing-retired|s@mentions_retired(path, body, gone)@true@|a_bats_case_testing_a_live_path_is_still_refused +# Arm 2b's anchor. Dropping it turns the bats spelling into a licence to repoint +# a binding that names ANY directory — including somebody else's tree, which no +# retirement here owns — and that is the whole of what keeps a widened spelling +# from becoming a widened shape. +#MUTANT bats-directory-unanchored|s@regex.match(data.batten.patterns\["bats-suite-directory"\], head)@true@|a_bats_binding_outside_the_suite_directory_is_refused +# And the going-away conjunct on the removal side. Dropping it admits a line +# spending a binding that SURVIVES, which is the loosening `case_earns_removal` +# warns about arriving one arm over. +#MUTANT bats-binding-survives|s@assigned_name(binding) == variable@true@|a_bats_case_spending_a_surviving_binding_is_refused # #MUTANT-SUITE crates/batten/tests/it/shell_retirement.rs @@ -392,6 +401,43 @@ admitted_removal(path, line, _) if { # breaks the case it was taken from. admitted_removal(path, line, removed) if line_of_a_retired_case(path, line, removed) +# OR IT SPENDS A BATS BINDING THAT IS GOING AWAY IN THE SAME DELTA. +# +# The going-away conjunct is the whole of what keeps this from being the +# loosening `case_earns_removal` warns about: a variable that SURVIVES buys +# nothing, exactly as it buys nothing there. Without it, admitting the bats +# spelling anywhere on the removal side turns +# `a_bats_case_spending_a_surviving_binding_is_refused` green — measured, on the +# first draft of this arm. +# +# It is a sibling of `case_earns_removal`'s second arm rather than a copy: that +# one decides whether a whole `@test` block earned its removal, this one decides +# a single line, which is what a suite REPOINTING its spend needs — the case +# survives and only the call moves. +admitted_removal(path, line, removed) if { + some gone in delta.deleted + some variable in bats_retired_path_vars(path, gone) + some spelling in {concat("", ["$", variable]), concat("", ["${", variable, "}"])} + contains(line, spelling) + + # THE BINDING MUST BE *THE* BINDING, not merely one sharing the name. + # + # This conjunct was `assigned_name(binding) == variable` alone, and + # `bats_retired_path_vars` derives the variable from a binding it finds in the + # BASE — so the removed assignment that satisfied it did not have to be that + # one. A suite keeping `GATE="$BATS_TEST_DIRNAME/../mise-tasks/old-gate.sh"` + # while removing an unrelated `GATE=` line and its spend cleared every removal + # check with the retired binding still standing, and the retirement read as + # complete over a suite that still calls the deleted program. + # + # `mentions_retired` is the same predicate the first arm decides a removed + # line by, so the join uses one authority on "does this line name the path + # that is going away" rather than a second spelling of it. + some binding in removed + assigned_name(binding) == variable + mentions_retired(path, binding, gone) +} + # An added line is admitted three ways, and all three are shapes rather than # judgements. admitted_addition(_, line, removed) if truncates_a_retired_reference(line, removed) @@ -690,6 +736,54 @@ script_dir_vars_of[path] := names if { # spent as `"$lint"` a hundred lines later. Without this the ARITY of the call # cannot change, and every real repointing onto a verb changes it — a path is one # word and `mise run x` is three. + +# THE BATS SPELLING OF THE SAME BINDING, AND IT IS A SEPARATE RULE ON PURPOSE — +# feeding it into `is_retired_reference_by_text` is a measured defect, not a +# tidier factoring. +# +# A `.bats` suite cannot write `$(dirname "$0")` at all: `$0` is the bats runner, +# so bats hands a suite its own directory as `$BATS_TEST_DIRNAME`. That one +# spelling is why a suite which BINDS a retired program in `setup()` and spends +# `"$VAR"` in a case had no landable edit in either direction — the spend line +# carries no path, no naming form and no variable any clause here could resolve. +# Measured on `tests/tree-clean.bats` while retiring `mise-tasks/verified.sh`: a +# SURVIVING suite broken by a retirement the campaign itself mandated. +# +# WHY NOT `is_retired_reference_by_text`. `case_earns_removal` already records +# that loosening that function would loosen it "where nothing is going away and +# the byte-check is the whole safety property" — and adding the spelling there +# reaches `retired_path_vars`, hence `mentions_retired`, hence `admitted_removal` +# arm 1, which admits a removed line spending the variable WHETHER OR NOT the +# binding goes with it. Measured: doing exactly that turned +# `a_bats_case_spending_a_surviving_binding_is_refused` green, so the arm the +# module keeps as its own anti-vacuity mirror stopped firing. The comment was +# right; the correction is to reach the two sides that need it and no third. +# +# KEYED BY THE PAIR, for the reason the rule above is: this iterates one path's +# whole `base-lines` per call, and its two consumers ask per ADDED span and per +# REMOVED line — so uncached it carries the same O(L²) that rule exists to +# remove. Same domain, so it answers wherever a call site can ask and nowhere +# else. +# +# ANCHORED AT BOTH ENDS for the reason `shell-script-directory` is, and bounded +# to path segments: no command substitution, no quote, no space, so what this +# resolves can only ever be a literal path rooted at the suite's own directory. +bats_retired_path_vars(path, gone) := bats_retired_path_vars_of[[path, gone]] + +bats_retired_path_vars_of[[path, gone]] := names if { + some path, lines in delta["base-lines"] + some gone in delta.deleted + tail := concat("", ["/", basename(gone)]) + names := {variable | + some line in lines + variable := assigned_name(line) + some form in spellings(assigned_value(line)) + endswith(form, tail) + head := substring(form, 0, count(form) - count(tail)) + regex.match(data.batten.patterns["bats-suite-directory"], head) + } +} + # # KEYED BY THE PAIR, for the reason the rule above gives at length: this is the # inner half of the O(L²), because `is_retired_reference_by_text`'s third arm @@ -749,6 +843,14 @@ is_retired_reference(path, span, gone) if { form in {concat("", ["$", variable]), concat("", ["${", variable, "}"])} } +# The same arm over the bats spelling. Separate because the function is; see +# `bats_retired_path_vars` for why it is not folded into the one above. +is_retired_reference(path, span, gone) if { + some form in spellings(span) + some variable in bats_retired_path_vars(path, gone) + form in {concat("", ["$", variable]), concat("", ["${", variable, "}"])} +} + # ARM 5 — THE SPAN NAMES THE PROGRAM AS A TASK (CLOUD-1299). # # Arms 1-4 all resolve a PATH: the repo-relative one, a constructed sibling, a @@ -2245,6 +2347,54 @@ test_a_declaration_losing_a_variable_this_delta_unbinds_is_admitted if { }} } +# THE DUPLICATE-NAME CASE, and it is what the join above exists for. The suite +# KEEPS its `GATE=` binding of the retired path and removes an unrelated `GATE=` +# line plus a spend. Every removal check passed on the name alone, while the +# binding that actually calls the deleted program stayed exactly where it was. +test_a_spend_paired_with_an_unrelated_removed_binding_is_refused if { + count(violation) > 0 with input as {"tree": { + "base-delta": { + "added": [], + "edited": ["tests/wiring.bats"], + "deleted": ["mise-tasks/old-gate.sh"], + "base-lines": {"tests/wiring.bats": [ + "#!/usr/bin/env bats", + "\tGATE=\"$BATS_TEST_DIRNAME/../mise-tasks/old-gate.sh\"", + "\tGATE=\"$BATS_TEST_DIRNAME/../mise-tasks/other.sh\"", + "\trun \"$GATE\"", + ]}, + }, + "lines": { + # The retired binding SURVIVES; only the unrelated one and the spend go. + "tests/wiring.bats": [ + "#!/usr/bin/env bats", + "\tGATE=\"$BATS_TEST_DIRNAME/../mise-tasks/old-gate.sh\"", + ], + "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs runs:mise+run+old-gate"], + }, + }} +} + +# THE SAME SHAPE AS A `.bats` SUITE WRITES IT (arm 2b). Identical to the case +# above in every respect but the spelling of the directory: a suite cannot write +# `$(dirname "$0")`, because `$0` is the bats runner. Measured on +# `tests/tree-clean.bats`, which binds `VERIFIED` in `setup()` and spends +# `"$VERIFIED"` in its acceptance case. +test_a_bats_suite_repointed_at_a_declared_invocation_is_admitted if { + count(violation) == 0 with input as {"tree": { + "base-delta": { + "added": [], + "edited": ["tests/wiring.bats"], + "deleted": ["mise-tasks/old-gate.sh"], + "base-lines": {"tests/wiring.bats": ["#!/usr/bin/env bats", "\tGATE=\"$BATS_TEST_DIRNAME/../mise-tasks/old-gate.sh\"", "\trun \"$GATE\""]}, + }, + "lines": { + "tests/wiring.bats": ["#!/usr/bin/env bats", "\trun mise run old-gate"], + "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs runs:mise+run+old-gate"], + }, + }} +} + # ANTI-VACUITY FOR THE ARM ABOVE, and it discriminates on the conjunct that does # the work rather than on one another conjunct already excludes: `helper` is a # variable this file declares and this delta does NOT unbind, so @@ -2266,6 +2416,27 @@ test_a_declaration_losing_an_unrelated_variable_is_refused if { v.verdict == "shell edit refused" } +# ANTI-VACUITY FOR THE BATS SPELLING: the head must be the SUITE'S OWN directory. +# Without the anchored pattern the arm resolves a variable bound to anywhere at +# all — including somebody else's tree, which no retirement here owns — and both +# the removal and the repointing become admissible. Identical to the case above +# but for the variable the binding reads. +test_a_bats_binding_outside_the_suite_directory_is_not_a_retired_reference if { + some v in violation with input as {"tree": { + "base-delta": { + "added": [], + "edited": ["tests/wiring.bats"], + "deleted": ["mise-tasks/old-gate.sh"], + "base-lines": {"tests/wiring.bats": ["#!/usr/bin/env bats", "\tGATE=\"$OTHER_TREE/../mise-tasks/old-gate.sh\"", "\trun \"$GATE\""]}, + }, + "lines": { + "tests/wiring.bats": ["#!/usr/bin/env bats", "\trun mise run old-gate"], + "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs runs:mise+run+old-gate"], + }, + }} + v.rule == "shell-rule-retired" +} + # ANTI-VACUITY: the invocation must be one the LEDGER declares. Without this the # case above is satisfied by a clause that admits anything. test_a_repointing_at_an_undeclared_invocation_is_refused if { diff --git a/policy/spawn-adapters.rego b/policy/spawn-adapters.rego index 196dcaf24..6a67805bf 100644 --- a/policy/spawn-adapters.rego +++ b/policy/spawn-adapters.rego @@ -159,6 +159,22 @@ adapters := { "judge", "handler", "action", "rules", "semver", "pinned", "perf", "prune", "pr_watch", "mutate", "bot", "lease", + # `fast_forward` AND `main_watch` WERE HERE AND ARE NOT ANY MORE (CLOUD-1338). + # + # Both were added by a branch whose stated subject was retiring shell, and both + # carried the same justification: *"this crate carries no HTTP client that + # resolves a forge credential — so the forge's own client IS the call."* The + # sentence was false. `crates/batten/src/fetch.rs` is a vendored hyper client, + # and `lease.rs` was already reading `GH_TOKEN` through it eighty lines from + # one of the spawns that claimed otherwise. Both modules read + # `crate::rest` now, spawn nothing, and need no placement. + # + # THE REMOVAL IS THE POINT AND SO IS THIS COMMENT. The table is deny-by- + # omission, so widening it is a two-word edit whose reasoning lives in a + # comment nothing reads — which is exactly how those two rows arrived, with + # every sensor green over them. `policy/spawn-widening.rego` reads the DIFF + # now and refuses an added entry here, so the next row costs an argument to a + # human rather than a keystroke. "startup", "hk", } diff --git a/policy/spawn-widening.rego b/policy/spawn-widening.rego new file mode 100644 index 000000000..062d18982 --- /dev/null +++ b/policy/spawn-widening.rego @@ -0,0 +1,376 @@ +# A SPAWN IS AN INVENTORY ROW, AND THE INVENTORY MAY NOT BE SELF-SERVICE +# (CLOUD-1338). +# +# `.claude/rules/rust.md` says a new spawn "is not forbidden — it is an inventory +# row, and the annotation is where you write down whether it stays and why", and +# `policy/spawn-adapters.rego` gates WHERE one may appear. Both are sound and +# neither is a brake, because both are answered by the author of the spawn, in +# the same commit, with no reader: the annotation's `reason` is a string nothing +# checks, and the placement is a word added to a Rego set. +# +# MEASURED ON THE BRANCH THAT PROMPTED THIS ROW, and it is the worst possible +# case rather than a hypothetical: a branch whose entire stated subject was +# RETIRING SHELL added five `#[expect(clippy::disallowed_types)]` spawns and +# widened `spawn-adapters`' placement table twice. Every sensor in the repository +# stayed green. Every one of the five reasons said the same thing — *"this crate +# carries no HTTP client that resolves a forge credential, so the forge's own +# client IS the call"* — and that sentence was FALSE: `crates/batten/src/fetch.rs` +# is a vendored hyper client, and `lease.rs` was already reading `GH_TOKEN` +# through it eighty lines from one of the five. +# +# So this rule decides the one thing neither of its siblings can: whether the +# DIFF widens the inventory. It reads `input.tree["base-delta"]`, which is what +# lets a module decide a CHANGE rather than a state (CLOUD-1059), and it refuses +# an added spawn escape and an added placement alike. +# +# FAIL CLOSED, WITH NO `bypass_env` AND NO OVERRIDE ROUTE. That is deliberate and +# it follows `shell edit refused`'s precedent, which declares one route with +# neither: the answer to this refusal is not a token to spend, it is that the +# change has the wrong shape. A spawn that genuinely belongs is a decision for a +# groomed row and a human, not an annotation an agent writes about its own work. +# +# WHAT IT DOES NOT CLAIM. It cannot tell a justified spawn from an unjustified +# one — that is a judgement, and non-negotiable rule 3 forbids a gate resolving +# to one. It decides a byte question: did this change add a spawn escape, or add +# a placement admitting one. Both are answerable from the delta, and both were +# unanswered until now. +# +#MUTANT spawn-escape-unread|s@escape_pattern@"nope"@|an_added_spawn_escape_is_refused +#MUTANT placement-widening-unread|s@placement_pattern@"nope"@|an_added_spawn_placement_is_refused +#MUTANT-SUITE crates/batten/tests/it/spawn_widening.rs + +# METADATA +# description: | +# Bound to the TREE surface: this row is `scope = "tree"`, so it reads the tree +# document and never the mediated `{call, facts}` shape. +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.spawn_widening + +import rego.v1 + +rules contains "spawn-widening" + +# `input.tree["base-delta"]` is NULL when the base rev did not resolve, and +# `null` is not `undefined` — `not input.tree["base-delta"]` would be FALSE for +# it, which is the slip CLOUD-701's review caught in `spawn-adapters` itself. So +# the delta is bound through a rule that holds only for an object, and every +# predicate below is undefined without it rather than vacuously clean. +delta := d if { + d := input.tree["base-delta"] + is_object(d) +} + +# THE PATTERNS ARE `[[pattern]]` ROWS, never inline regexes. One concept, one +# spelling, refused at load rather than duplicated at leisure — +# `.claude/rules/policy-modules.md` is the authority and the reason is measured +# there: one concept was spelled 19 different ways across 17 shell programs +# before the registry existed. +escape_pattern := "clippy-lint-escape" + +# THE ONE EXEMPTION, and it is a pattern rather than a clause for the reason the +# registry exists: "the lints a `mod tests` waives" is a concept with one +# spelling, and a second copy of that list here would drift from the row. +# +# A `#[cfg(test)] mod tests` lives inside `crates/batten/src/**`, so the path +# exclusion that keeps `crates/batten/tests/**` out cannot reach it. Measured: +# without this the module refused three files for opening their test module the +# way every file in the crate opens its test module. +idiom_pattern := "clippy-test-idiom" + +placement_pattern := "spawn-placement-entry" + +# A line that escapes a lint, and is not the test-module idiom. +# +# THE SECOND CONJUNCT IS NARROW BY CONSTRUCTION, because the exempt set is three +# named lints in a `[[pattern]]` row rather than a shape. `too_many_arguments` +# and `cast_precision_loss` are still refused, and both were added by the branch +# that prompted this rule without anybody counting them. +escapes(line) if { + regex.match(data.batten.patterns[escape_pattern], line) + not regex.match(data.batten.patterns[idiom_pattern], line) +} + +# The module whose set says which files may spawn. Named once: it is the SUBJECT +# of the second clause, and a second spelling is a second answer to "which file +# holds the placements". +placements_module := "policy/spawn-adapters.rego" + +# Lines this change ADDED to `path`. +# +# `base-lines` carries the base side of every EDITED path — `git.rs` states the +# bound on the field itself: not `added` (there is no base side) and not +# `deleted` (the head side is gone). So an added FILE has no base to subtract and +# every one of its lines is new, which is the arm below. +# +# A SET DIFFERENCE, and its one bound is stated rather than discovered: a line +# that already appeared elsewhere in the base file is not counted as added, so +# moving an existing escape from one function to another reads as no change. +# That is the correct direction for this rule — the inventory did not grow — and +# it is why the second clause counts MEMBERS rather than lines. +# THE BASE IS BOUND BEFORE IT IS WALKED, and that is a correctness clause rather +# than a style: a comprehension over an UNDEFINED collection yields the EMPTY SET +# rather than undefined, so writing `{line | some line in delta["base-lines"][path]}` +# directly makes an unchanged file look like a file whose every line is new. +# Measured here: 81 of 81 engine modules refused on one run, which is a gate that +# fires on everything and therefore gates nothing. +# +# Binding it first makes this arm UNDEFINED for a path the delta carries no base +# for, which is what leaves the added-file arm below to answer and leaves an +# unchanged file answered by neither. +added_lines(path) := added if { + base_lines := delta["base-lines"][path] + base := {line | some line in base_lines} + added := {line | + some line in input.tree.lines[path] + not base[line] + } +} + +# AN ADDED FILE HAS NO BASE TO SUBTRACT, so every line in it is new. +# +# The membership test is spelled over a comprehension rather than `some path in +# delta.added`, because `path` is the rule's own argument and Rego reads a `some` +# binding it as a redefinition — measured here as `var path used before +# definition`, which faults the whole module at load rather than at evaluation. +added_lines(path) := added if { + count([p | some p in delta.added; p == path]) > 0 + not delta["base-lines"][path] + added := {line | some line in input.tree.lines[path]} +} + +# --------------------------------------------------------------------------- +# A: an added spawn escape. +# --------------------------------------------------------------------------- + +# ENGINE SOURCE ONLY, and `tests/**` is excluded by construction rather than by +# an exemption somebody has to remember. A test module writing +# `#![allow(clippy::expect_used)]` is the idiom every suite in this crate already +# uses — panicking loudly is how a test fails — so a rule firing on those would +# be switched off within a day, which is the failure mode that ends gates. +engine_source(path) if { + startswith(path, "crates/batten/src/") + endswith(path, ".rs") +} + +violation contains { + "rule": "spawn-widening", + "verdict": "spawn write refused", + "subjects": [{"path": path}], +} if { + some path in object.keys(input.tree.lines) + engine_source(path) + some line in added_lines(path) + escapes(line) +} + +# --------------------------------------------------------------------------- +# B: an added placement. +# --------------------------------------------------------------------------- +# +# THE HALF THAT ACTUALLY FIRED. Clause A refuses a spawn in a module nobody +# placed; `spawn-adapters` then refuses the same thing from the other side. Both +# are answered by one edit — add the module to the set — and that edit is what +# nothing read. It is a two-word line with a paragraph of justification in a +# comment, which reads to a reviewer exactly like the considered decision it may +# or may not be. + +violation contains { + "rule": "spawn-widening", + "verdict": "adapter add refused", + "subjects": [{"path": placements_module}], +} if { + some line in added_lines(placements_module) + regex.match(data.batten.patterns[placement_pattern], line) +} + +# --------------------------------------------------------------------------- +# C: could not look. +# --------------------------------------------------------------------------- +# +# A base rev that did not resolve leaves `delta` undefined, so every clause above +# is undefined and the module contributes nothing — which reads as a clean tree +# and is the dead-gate class this repository exists to refuse (CLOUD-251). The +# arm below is what makes the engine record that it could not look. + +violation contains { + "rule": "spawn-widening", + "verdict": "diff read absent", +} if { + not delta +} + +# `missing` is the could-not-look channel for a declared source that would not +# parse, and a module iterating only `lines` reports green over a file it never +# read. `.claude/rules/policy-modules.md`: write the clause. +violation contains { + "rule": "spawn-widening", + "verdict": "source parse dead", + "subjects": [{"path": path}], +} if { + some path, _ in input.tree.missing + engine_source(path) +} + +# --- the load-time tier ------------------------------------------------------ +# +# These pin the PREDICATE. They cannot pin that the ENGINE builds the delta they +# read — a `with input as` case fabricates exactly the shape the boundary may be +# unable to produce, which is the class both live instances in this repository +# belonged to. `crates/batten/tests/it/spawn_widening.rs` is the tier that drives +# the compiled binary over a real repository with a real base rev. +# +# THE VOCABULARY IS SUPPLIED, because these are consumer patterns and a case that +# declared none would pass for the wrong reason: `data.batten.patterns[id]` would +# be undefined, the body would not hold, and every deny case would read as clean. + +vocabulary := {"patterns": { + "clippy-lint-escape": `^\s*(?:#!?\[\s*(?:expect|allow)\(\s*)?clippy::[a-z_]+`, + # THE EXEMPTION'S OWN PATTERN, and omitting it made every case here read the + # engine-source arm as absent. `idiom_pattern` names this id, so a vocabulary + # without it leaves `data.batten.patterns["clippy-test-idiom"]` undefined — + # Rego reads undefined as does-not-hold, the exemption never fires, and the + # deny cases pass for the wrong reason while the allow case that needs it has + # nothing to assert. Copied byte-for-byte from `batten.toml`'s row, which is + # what the consumer actually supplies. Found in review. + "clippy-test-idiom": `^\s*(?:#!?\[\s*(?:expect|allow)\(\s*)?clippy::(?:expect_used|unwrap_used|panic)\b`, + "spawn-placement-entry": `^\s*"[a-z_]+",\s*$`, +}} + +tree(lines, base) := object.union(vocabulary, {"tree": { + "lines": lines, + "missing": {}, + "base-delta": { + "added": [], + "edited": object.keys(base), + "deleted": [], + "code-changed": [], + "base-lines": base, + }, +}}) + +# THE CASE THE RULE EXISTS FOR, half one. +test_an_added_spawn_escape_is_refused if { + some v in violation with input as tree( + {"crates/batten/src/thing.rs": ["fn a() {}", " clippy::disallowed_types,"]}, + {"crates/batten/src/thing.rs": ["fn a() {}"]}, + ) + v.verdict == "spawn write refused" +} + +# The attribute written on one line reaches the same refusal. Both spellings are +# in this crate, and a pattern anchored on only the continuation form would miss +# every single-line one. +test_a_single_line_escape_is_refused_too if { + some v in violation with input as tree( + {"crates/batten/src/thing.rs": ["#[expect(clippy::disallowed_types)]"]}, + {"crates/batten/src/thing.rs": []}, + ) + v.verdict == "spawn write refused" +} + +# THE CASE THAT ACTUALLY FIRED, half two. +test_an_added_spawn_placement_is_refused if { + some v in violation with input as tree( + {"policy/spawn-adapters.rego": ["adapters := {", ` "exec",`, ` "main_watch",`]}, + {"policy/spawn-adapters.rego": ["adapters := {", ` "exec",`]}, + ) + v.verdict == "adapter add refused" +} + +# THE ANTI-VACUITY MIRRORS. Without these every case above is satisfied by a +# module that refuses unconditionally, which is not a gate (CLOUD-418). +test_an_unchanged_engine_module_is_clean if { + count(violation) == 0 with input as tree( + {"crates/batten/src/thing.rs": ["#[expect(clippy::disallowed_types)]", "fn a() {}"]}, + {"crates/batten/src/thing.rs": ["#[expect(clippy::disallowed_types)]", "fn a() {}"]}, + ) +} + +test_an_unchanged_placement_table_is_clean if { + count(violation) == 0 with input as tree( + {"policy/spawn-adapters.rego": ["adapters := {", ` "exec",`]}, + {"policy/spawn-adapters.rego": ["adapters := {", ` "exec",`]}, + ) +} + +# REMOVING A PLACEMENT IS NOT WIDENING, which is the direction the whole rule +# turns on: this branch's remedy is to DELETE the two rows it added, and a +# symmetric predicate would refuse the fix. +test_removing_a_placement_is_clean if { + count(violation) == 0 with input as tree( + {"policy/spawn-adapters.rego": ["adapters := {", ` "exec",`]}, + {"policy/spawn-adapters.rego": ["adapters := {", ` "exec",`, ` "main_watch",`]}, + ) +} + +# A TEST MODULE'S OWN ESCAPE IS NOT THIS RULE'S BUSINESS. Every suite in this +# crate opens with one, because panicking loudly is how a test fails — and a rule +# firing on those is a rule that gets switched off. +test_a_test_modules_escape_is_not_refused if { + count(violation) == 0 with input as tree( + {"crates/batten/tests/it/thing.rs": ["#![allow(clippy::expect_used)]"]}, + {"crates/batten/tests/it/thing.rs": []}, + ) +} + +# THE IDIOM EXEMPTION UNDER `src/`, WHICH IS THE ARM THE PATH EXCLUSION CANNOT +# REACH. A `#[cfg(test)] mod tests` lives inside `crates/batten/src/**`, so the +# case above — which is under `tests/` — passes on the path alone and says +# nothing about `idiom_pattern`. Measured before the exemption existed: three +# files were refused for opening their test module the way every file in the +# crate opens its test module. +# +# It is also the case that would have caught the vocabulary gap this file's +# `vocabulary` comment records: with `clippy-test-idiom` undefined the exemption +# never fires, and NOTHING above notices, because every other case here is a deny +# whose refusal an absent exemption only makes more certain. +test_the_test_module_idiom_is_exempt_under_src_too if { + count(violation) == 0 with input as tree( + {"crates/batten/src/thing.rs": ["#[allow(clippy::expect_used)]"]}, + {"crates/batten/src/thing.rs": []}, + ) +} + +# AND THE EXEMPTION IS THREE NAMED LINTS, never a shape. `too_many_arguments` is +# an escape and stays refused — without this the case above reads as "any added +# `clippy::` line under `src/` is fine", which is the rule switched off. +test_a_lint_outside_the_idiom_set_is_still_refused if { + count(violation) == 1 with input as tree( + {"crates/batten/src/thing.rs": ["#[allow(clippy::too_many_arguments)]"]}, + {"crates/batten/src/thing.rs": []}, + ) +} + +# PROSE NAMING A LINT IS NOT AN ESCAPE. This module's own siblings discuss +# `clippy::disallowed_types` at length in doc comments, and a rule reading those +# as escapes would refuse every commit that explains itself. +test_a_doc_comment_naming_a_lint_is_not_an_escape if { + count(violation) == 0 with input as tree( + {"crates/batten/src/thing.rs": ["/// `clippy::disallowed_types` refuses this.", "// clippy::expect_used is banned"]}, + {"crates/batten/src/thing.rs": []}, + ) +} + +# COULD NOT LOOK IS NOT CLEAN. A base rev that did not resolve leaves every +# clause undefined, which is byte-identical to a clean tree on the decision +# surface — the exact defect this repository refuses. +test_an_unresolvable_base_refuses_rather_than_passing if { + some v in violation with input as object.union( + vocabulary, + {"tree": {"lines": {}, "missing": {}, "base-delta": null}}, + ) + v.verdict == "diff read absent" +} + +# And a declared engine source that would not parse is could-not-look too, rather +# than a file with no escapes in it. +test_an_unparsed_engine_source_is_reported if { + some v in violation with input as object.union(vocabulary, {"tree": { + "lines": {}, + "missing": {"crates/batten/src/thing.rs": "unparsed"}, + "base-delta": {"added": [], "edited": [], "deleted": [], "code-changed": [], "base-lines": {}}, + }}) + v.verdict == "source parse dead" +} diff --git a/policy/test-targets.rego b/policy/test-targets.rego index f4eab206a..6d94feebd 100644 --- a/policy/test-targets.rego +++ b/policy/test-targets.rego @@ -102,11 +102,37 @@ import rego.v1 rules contains "test-target-added" -# The branch's own diff. `base-delta` is NULL when the base rev does not resolve, -# so `added` does not hold and this rule goes silent — could-not-look, never a -# fabricated empty delta that would pass the gate on ignorance. That is -# `filed-here.rego`'s reading of the same fact and it is deliberate here too. -delta := input.tree["base-delta"] +# The branch's own diff, BOUND THROUGH AN OBJECT GUARD because `null` is not +# `undefined` (review of #848). +# +# THE COMMENT HERE ASSERTED THE READING `fixture-forks.rego` REFUTES, and it is +# corrected rather than quietly rewritten: it read that a null `base-delta` makes +# this rule "go silent — could-not-look, never a fabricated empty delta that +# would pass the gate on ignorance", as though silence WERE the report. It is +# not. A rule that refuses nothing and a tree that added no target are +# byte-identical on the decision surface, which is the dead-gate class this +# repository exists to refuse. Silence is the fabricated empty delta, one level +# over. +delta := d if { + d := input.tree["base-delta"] + is_object(d) +} + +# THE COULD-NOT-LOOK ARM. `spawn-widening.rego` and `fixture-forks.rego` carry it +# for the same fact; this module did not, so a shallow clone, a detached CI +# checkout with the base unfetched, or a fork with no `origin/main` passed the +# ratchet over a branch adding as many test targets as it liked. +# +# `not delta` rather than `not input.tree["base-delta"]`: only `false` and +# undefined make `not` hold in Rego, so the bare spelling would be DEAD for +# exactly the `null` this arm exists for. +violation contains { + "rule": "test-target-added", + "verdict": "diff read absent", + "subjects": [{"path": "batten.toml"}], +} if { + not delta +} # A path is a NEW TEST TARGET when it is added, sits directly under # `crates/batten/tests/`, and ends in `.rs`. @@ -246,9 +272,20 @@ test_another_crates_test_file_is_not_this_rules_business if { }}} } -# COULD NOT LOOK. A null `base-delta` must go silent rather than read as an empty -# diff — the distinction `filed-here.rego` records and the one a migration gate -# has to keep. -test_an_unresolvable_base_refuses_nothing if { - count(violation) == 0 with input as {"tree": {"base-delta": null}} +# COULD NOT LOOK. A null `base-delta` is REPORTED, never passed: this case +# asserted the opposite — `count(violation) == 0` — and was green over a gate +# that reported clean on every tree it had not read. +test_an_unresolvable_base_reports_rather_than_passing if { + some v in violation with input as {"tree": {"base-delta": null}} + v.verdict == "diff read absent" +} + +# AND THE ARM MUST NOT FIRE OVER A DELTA THAT DID RESOLVE, which is what says the +# object guard binds rather than that the arm is unconditional. +test_a_resolved_delta_reports_no_read_failure if { + count(violation) == 0 with input as {"tree": {"base-delta": { + "added": [], + "edited": [], + "deleted": [], + }}} } diff --git a/rules/rust.md b/rules/rust.md index 53c75dae4..4cf752ac2 100644 --- a/rules/rust.md +++ b/rules/rust.md @@ -17,6 +17,20 @@ These load when you touch Rust; they do not need to be in context otherwise. Prefer end-to-end tests over the compiled binary (`crates/batten/tests/it/cli.rs`) for anything a consumer depends on — exit codes, output shape, flag handling. +- **A platform split inside a case is `cfg!`, never `#[cfg]` over it**, and + `cfg-gated-test` is the gate rather than this bullet. An attribute deletes the + case on every other target, so `cross-check` type-checks only the arm the local + host admits and the next edit to the other one is discovered by CI; `cfg!` keeps + both compiled and states the off-platform contract where a reader sees it. + Measured on CLOUD-1148: `scratch.rs`'s reaper case asserted collection + unconditionally, the `windows` job reddened alone, and the first fix put + `#[cfg(unix)]` over it — which turned the leg green while leaving one arm never + compiled where it is authored. + The rule is a RATCHET over the diff, not a state check: a case whose SUBJECT + does not exist off the platform is a different thing, ~40 of those are in this + tree, and the class declares an `override` route whose precondition is exactly + that — the case reaches a symbol the other target does not have, so a `cfg!` arm + would not type-check. Red is not the precondition. - Branch on the named `ExitCode` variants in `crates/batten/src/exit.rs`, never integer literals. One table, no per-verb exception: `2` is the policy verdict everywhere — a `check` violation and a `hook` deny alike — and `1`/`3` are the diff --git a/schema/batten.schema.json b/schema/batten.schema.json index a3db98fe1..fd82e149d 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -230,6 +230,17 @@ } ] }, + "lease": { + "description": "The landing mechanism's own paths, for the CI-side staleness read\n(CLOUD-1148 §2).\n\nAbsent is could-not-look and exempts everything, which matches the\nprecondition's whole posture: it fails open at every unknown, because a\nreading it cannot take would stop every job in the fleet where waving one\nmatrix through costs one matrix.", + "anyOf": [ + { + "$ref": "#/$defs/Lease" + }, + { + "type": "null" + } + ] + }, "marker": { "description": "The suppression markers to count (CLOUD-36). Which comment shape waves\na rule through is a property of the repository being gated, never of\nBatten; the type and the counting are [`crate::markers`].", "type": "array", @@ -344,6 +355,17 @@ } ] }, + "receipt": { + "description": "Which receipts a head must carry to be called verified (CLOUD-1338).\n\nAbsent REFUSES rather than exempting, which is the opposite direction to\n`[lease]` above and deliberately so: this is a gate about the tree in\nhand, where that one is an economy about somebody else's runner.", + "anyOf": [ + { + "$ref": "#/$defs/Receipt" + }, + { + "type": "null" + } + ] + }, "recorder": { "description": "Records written from the tool result that earned them (CLOUD-1051).\n\nThe third selector on the post-tool event, and the one that can carry a\nvalue another gate decided. A `[[mint]]` renders a template over the\npayload; a `[[recorder]]` may additionally run a declared program and\nrecord its verdict, which is what a board write's refinement column IS.\n\nConsumer-owned for the same reason `[[mint]]` is, and more so: the column\nnames, the verdict tokens and the programs are all a tracker's vocabulary,\nso a grep of `crates/batten` for any of them returns nothing and every one\nof them lives here.", "type": "array", @@ -444,6 +466,13 @@ "$ref": "#/$defs/DeclaredVerdict" } }, + "verify_environment_pattern": { + "description": "Output predicates that classify a landing gate's REFUSAL as the\nenvironment's rather than this tree's (CLOUD-861).\n\n**A separate table from [`Config::exec_patterns`], because the two answer\nopposite questions from the same shape.** That one asks *is this green run\nlying* and promotes a `0`; this one asks *a run already failed, and what\nKIND of failure was it* and promotes nothing. One table serving both would\nhave to decide per reader whether a hit means promote-this-success or\nexplain-this-failure, and a row written for one reading would silently\nchange the other's verdict.\n\nConsumer-specific for [`Config::exec_patterns`]'s reason and then some:\nthe literal is a toolchain's wording, and the `reason` is the remedy —\n*which* reclaim task to run is this repository's vocabulary, so putting\neither in the crate is non-negotiable rule 1's plainest violation.\n`crates/batten/tests/it/document_facts.rs` is the gate that would catch it.\n\nThe measured case: `target-prune` passed a lap with 6242MB against its\n4096MB floor, the link step then consumed all of it, and the stop said\n*\"Reproduce and fix locally\"* over a tree with nothing wrong in it.", + "type": "array", + "items": { + "$ref": "#/$defs/OutputPattern" + } + }, "version": { "description": "The config schema version. Must equal [`SUPPORTED_VERSION`].", "type": "integer", @@ -540,6 +569,16 @@ "description": "[`crate::ready`] — the Ready-block grammar over a tracker payload.", "type": "string", "const": "ready" + }, + { + "description": "[`crate::lease::Asked::Status`] — does the landing lease authorise this\nclone right now? Answers in the engine's exit table, so the consumer's\n`status` map reads `0` authorising / `2` held elsewhere and leaves `3`\nunmapped, which is where the lease's fail-open asymmetry lives.\n\n**Named for the practice rather than for a program** (non-negotiable rule\n1): \"a landing lease\" is a word about trunk-based landing, where\n`land-lock` was one repository's file name.", + "type": "string", + "const": "lease-status" + }, + { + "description": "[`crate::lease::Asked::Successor`] — which branch the live holder\nadmitted behind it, on stdout, or nothing where no reservation stands.", + "type": "string", + "const": "lease-successor" } ] }, @@ -1989,6 +2028,27 @@ }, "additionalProperties": false }, + "Lease": { + "description": "The landing lease's own configuration (CLOUD-1148 §2).", + "type": "object", + "properties": { + "fast_forward_branches": { + "description": "Branch-name prefixes that land WITHOUT taking the lease, so the runner-side\nprecondition must not judge them.\n\n# THE POPULATION IS THE LANDER'S, NOT THE AUTHOR'S\n\nThese are branches some other workflow fast-forwards on a `workflow_run`\ncompletion, so no agent ever holds the lease on their behalf and judging\nthem would refuse the run the gate exists to let through. The predecessor\nspelled the same set as a `case` in shell, and its comment carries the\neconomics: a cancelled run is `completed`, so those landers DO fire, find\nthe checks not green, and stop; nothing retries. Cancelling here would not\nsave a matrix, it would DEFER one and add a stall.\n\n**Not the bot-author set** (`[bot_lane] bots`), and the two must not be\ncollapsed: that keys on a forge LOGIN and this keys on a branch NAME,\nbecause what decides the question is which workflow lands the branch. A\nhuman who names a branch with one of these prefixes gets the same\ntreatment, correctly — the lander fires on the name.\n\n**Prefixes on the branch, never a substring anywhere in the ref**, which\nthe predecessor's suite pinned as its own case: a `case` arm matching\nmid-ref would exempt a branch that merely mentions one.\n\nEmpty means every branch is judged, which is the gate's default and the\nreason this is a short named list rather than a pattern.", + "type": "array", + "items": { + "type": "string" + } + }, + "landing_paths": { + "description": "The paths that CONSTITUTE the landing mechanism, so a head can be asked\nwhether it carries what trunk has.\n\n# A PATH SET RATHER THAN A GREP STRING, and that is the whole point\n\nThe predecessor asked this by grepping the head's `mise-tasks/land.sh`\nfor `land-lock acquire`. Both halves of that die with the retirement: the\nfile is deleted, so the read fails, so the script takes its own fail-open\npath and reports \"not judging this head's age\" — and every stale head\npasses, silently, which is worse than a wrong answer.\n\nA declared path set survives, because the thing that changes when the\nmechanism moves is WHICH PATHS, and that is config a retirement edits\nrather than a literal a retirement invalidates.\n\nEmpty is could-not-look: nothing to compare means no verdict, never a\nclean one.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "Literal": { "description": "One scalar a [`Declared::matching`] entry compares against.\n\n**A closed set of three, not `serde_json::Value`.** An arbitrary value would\nadmit an object or an array on the right-hand side of an equality, which is\neither a nested predicate nobody specified or a deep comparison whose cost is\nunbounded — and both are the query language CLOUD-690's refinement exists to\nkeep out. Three scalars are what a tool result's leaves actually are.", "anyOf": [ @@ -2773,6 +2833,20 @@ }, "additionalProperties": false }, + "Receipt": { + "description": "The `[receipt]` table: which receipts a head must carry to be called verified.", + "type": "object", + "properties": { + "verified_by": { + "description": "The checks `receipt verified` requires a valid receipt for.\n\n# NON-NEGOTIABLE RULE 1, AND THE ARRAY THIS REPLACES\n\n`receipt.rs` carried `const VERIFIED_BY: [&str; 2] = [\"verify\",\n\"linear-check\"]` — two of THIS consumer's task names, compiled into the\ncore, which is the rule's plainest shape. A different adopter's gates are\nnot called those things, and nothing in the engine could tell them so.\n\n# UNDECLARED FALLS BACK, and this doc said it refused\n\nThe hazard is real and unchanged: an empty set would make\n`receipt verified` pass over every head, since *nothing is unverified*\nwhen nothing is required — a gate that answers clean because it was never\ntold what to ask. What closes it is a fallback rather than a refusal.\n[`crate::receipt::verified_by`] resolves an undeclared or empty table to\n[`crate::receipt::VERIFIED_BY`], and its own header records why the first\ndraft's usage error could not stand: it refused over a fixture repository\nwith no `batten.toml` and no interest in receipts, and that suite's\nsubject survives, so the only way to land the refusal was to edit around\na gate that was right.\n\nThe fallback is strictly more general than the `const` it replaced and\ncosts an adopter nothing — our names over their receipts resolve to\n`Missing`, so `verified` refuses loudly on their first run rather than\npassing quietly. A wrong answer that announces itself is the acceptable\nfailure; a silent one is not.\n\n**Stated here because a field's doc is where a reader looks for what an\nabsent key does** (review of #848), and this one asserted a refusal the\ncode does not make — with `trust.rs`'s `VerifiedCheckRemoved` repeating\nit, which is how one false premise became two.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "ReceiptKey": { "description": "Which git fact a receipt is keyed to, and therefore what invalidates it.\n\nThe distinction is not a tuning knob, it is what the receipt *attests*.\nA `head` receipt claims something about those exact bytes, so an amend or a\nrebase must expire it. A `branch` receipt claims a decision about the work,\nwhich every commit on the branch continues to serve, so a SHA-keyed one\nwould demand a re-claim per commit — the false-positive rate that gets a\nguard bypassed. Both spellings are carried from the shell layer that proved\nthem (`ready-guard` keys by SHA, `claim-check` by branch).\n\n**`ValueEnum` because the CLI selects the same keying** (CLOUD-741). A\n`receipt` rule is pinned to [`RuleScope::MediatedCall`], so `batten check`\ncan never evaluate one and `verify` cannot reach this predicate through the\nengine — which left `verify` re-implementing it in shell, weakly enough that\nCLOUD-516's own incident passed. `receipt status --key branch` is how the\ntree surface reaches the one implementation instead, so config and CLI must\nname the keying with the same tokens or the two surfaces disagree about what\nthey asked for. `clap`'s and serde's renames both land on `head`/`branch`;\nthe `clap(rename_all)` is stated rather than inferred so a future variant\ncannot drift them apart.", "oneOf": [ diff --git a/tests/abandon-matrix.bats b/tests/abandon-matrix.bats deleted file mode 100644 index 6c129fd2b..000000000 --- a/tests/abandon-matrix.bats +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/abandon-matrix.sh -# abandon-matrix: stop paying for a matrix whose verdict is already in -# (CLOUD-900). -# -# Everything here runs through a stubbed `gh`. The task's only inputs are one -# list and one POST per run, so a stub that scripts those two covers every row -# without a remote, a runner or a clock. -# -# THE PROPERTY THAT OUTRANKS EVERY ROW, and the reason each case asserts status -# 0 including the failures: this is called from `land`'s red arm, one line before -# a `die` that names the real failure. A non-zero exit here would replace a -# diagnosable test failure with a confusing one about a cleanup step, so there is -# no input for which stopping is the right answer. -# -# THE ROW THAT MATTERS MOST is the fan-in exclusion. Cancelling the run carrying -# `final` leaves the one context branch protection requires ungraded, and -# `checks-green` reads a cancelled required check as "no answer" (CLOUD-363) — so -# that single mistake converts a saving into a branch that can never land. - -setup() { - ABANDON="$BATS_TEST_DIRNAME/../mise-tasks/abandon-matrix.sh" - STUB="$BATS_TEST_TMPDIR/bin" - mkdir -p "$STUB" - PATH="$STUB:$PATH" - export PATH - - # Hermetic for `tests/ci-lease-precondition.bats`'s reason: these names have - # ambient fallbacks that CI sets and a developer box does not, so a case that - # reaches "unset" by unsetting one variable would behave differently under - # Actions — a verify/CI disagreement by construction. - unset ABANDON_SHA SHA GH_REPO - - export REPO=button-inc/batten - export GH_TOKEN=ghs-not-a-real-token - # The declaration under test. It arrives from mise.toml [env] in production; - # naming it here keeps each case's subject local, and `ci-local-parity` - # property 17 is what holds the real value to the real workflow. - export CI_FANIN_WORKFLOW=.github/workflows/ci.yml - - # A healthy in-flight matrix: four runs, one of which carries the fan-in. - # Each case moves exactly one thing, so a failure names the row rather than - # the fixture. - runs_are \ - '11 .github/workflows/ci.yml' \ - '22 .github/workflows/rust.yml' \ - '33 .github/workflows/test.yml' \ - '44 .github/workflows/zizmor.yml' - stub_gh -} - -# The `idpath` lines the stub's `--jq` filter would have produced. -runs_are() { printf '%s\n' "$@" >"$BATS_TEST_TMPDIR/runs"; } - -stub_gh() { - cat >"$STUB/gh" <>"\$t/cancels" ;; - *actions/runs\?head_sha=*) - [ ! -e "\$t/list-refused" ] || exit 1 - cat "\$t/runs" ;; - *) exit 1 ;; -esac -EOF - chmod +x "$STUB/gh" -} - -cancels() { cat "$BATS_TEST_TMPDIR/cancels" 2>/dev/null || true; } -cancel_count() { cancels | grep -c . || true; } - -@test "the siblings are cancelled and the fan-in's run is spared — the acceptance case" { - run "$ABANDON" cafebabecafebabe "ci failure" - [ "$status" -eq 0 ] - # Three cancelled, and the fan-in's run is not among them. - [ "$(cancel_count)" -eq 3 ] - cancels | grep -q 'runs/22/cancel' - cancels | grep -q 'runs/33/cancel' - cancels | grep -q 'runs/44/cancel' - ! cancels | grep -q 'runs/11/cancel' -} - -@test "THE ROW THAT MATTERS: the run carrying the fan-in is never cancelled" { - # Stated as its own case rather than left to the assertion above, because - # this is the one mistake that turns a saving into a branch that cannot land: - # a cancelled `final` is not an answer (CLOUD-363), so `ci-wait` would poll a - # head whose verdict can never arrive. - run "$ABANDON" cafebabecafebabe - [ "$status" -eq 0 ] - ! cancels | grep -q 'runs/11/cancel' - [[ "$output" == *"sparing run 11"* ]] - [[ "$output" == *"wedges the landing"* ]] -} - -@test "a fan-in declared for a file no run carries spares nothing — and still cancels the rest" { - # The drift case. `ci-local-parity` property 17 is what stops it reaching - # production; this records what the task does if it ever does. - export CI_FANIN_WORKFLOW=.github/workflows/moved-elsewhere.yml - run "$ABANDON" cafebabecafebabe - [ "$status" -eq 0 ] - [ "$(cancel_count)" -eq 4 ] -} - -@test "an unset fan-in declaration cancels NOTHING rather than guessing" { - # Fail closed on the one input whose absence is unrecoverable: with no - # fan-in named, every candidate looks cancellable and the wedge is one API - # call away. Doing nothing costs the minutes this task exists to save, which - # is the strictly cheaper mistake. - unset CI_FANIN_WORKFLOW - run "$ABANDON" cafebabecafebabe - [ "$status" -eq 0 ] - [ "$(cancel_count)" -eq 0 ] - [[ "$output" == *"CI_FANIN_WORKFLOW is unset"* ]] - [[ "$output" == *"nothing cancelled"* ]] -} - -@test "a refused cancellation is a pointer, not a stop — and the rest still go" { - touch "$BATS_TEST_TMPDIR/cancel-refused" - run "$ABANDON" cafebabecafebabe - [ "$status" -eq 0 ] - [[ "$output" == *"cancellation refused"* ]] - [[ "$output" == *"it bills out"* ]] -} - -@test "a list that will not answer stops without cancelling and without failing" { - touch "$BATS_TEST_TMPDIR/list-refused" - run "$ABANDON" cafebabecafebabe - [ "$status" -eq 0 ] - [ "$(cancel_count)" -eq 0 ] - [[ "$output" == *"could not list the runs"* ]] -} - -@test "nothing in flight is a clean no-op" { - runs_are '' - run "$ABANDON" cafebabecafebabe - [ "$status" -eq 0 ] - [ "$(cancel_count)" -eq 0 ] - [[ "$output" == *"nothing still in flight"* ]] -} - -@test "a run that has already completed is not asked to cancel" { - # The filter is the stub's, mirroring the `--jq` the task passes: a completed - # run never reaches the loop. Asserted so a later edit that drops - # `select(.status != \"completed\")` from the query is caught here rather - # than as an unexplained API call count in production. - grep -q 'select(.status != "completed")' "$ABANDON" -} - -@test "the reason is carried into the pointer, and the SHA is abbreviated" { - run "$ABANDON" cafebabecafebabe "windows failure" - [ "$status" -eq 0 ] - [[ "$output" == *"windows failure"* ]] - [[ "$output" == *"cafebabe"* ]] - # Pointer-only (non-negotiable rule 4): a run id and a workflow path, never - # a line from the run being stopped. - [[ "$output" == *".github/workflows/rust.yml"* ]] -} - -@test "no SHA anywhere is a give-up rather than a guess at HEAD's neighbours" { - # Outside any repository, so the `git rev-parse HEAD` fallback has nothing to - # answer with either. `GIT_CEILING_DIRECTORIES` rather than a bare `cd`: the - # temp dir can sit under a checkout on some boxes, and a case whose subject - # is "no SHA" must not depend on where the suite happens to run. - cd "$BATS_TEST_TMPDIR" - export GIT_CEILING_DIRECTORIES="$BATS_TEST_TMPDIR" - run env -u GIT_DIR "$ABANDON" - [ "$status" -eq 0 ] - [ "$(cancel_count)" -eq 0 ] -} diff --git a/tests/ci-lease-precondition.bats b/tests/ci-lease-precondition.bats deleted file mode 100644 index 679b7e0d5..000000000 --- a/tests/ci-lease-precondition.bats +++ /dev/null @@ -1,341 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/ci-lease-precondition.sh -# ci-lease-precondition: the runner's half of the landing lease (CLOUD-420). -# -# Two questions, asked in order, and the first is the one the lease itself cannot -# answer: does this head's own `land` take the lease at all? A clone running -# tooling that predates CLOUD-393 takes no lease, so the ref reads ABSENT and the -# lease table would authorise it — which is how four matrices ran beside three -# orderly handoffs on 2026-08-12. -# -# Everything here is driven through a stubbed `gh`: the script's only inputs are -# two reads from trunk and one POST, so a stub that scripts those three covers -# every row without a remote, a runner, or a clock. `git` is real — the throwaway -# repo it builds is local and instant, and stubbing it would test the stub. -# -# THE PROPERTY THAT OUTRANKS EVERY ROW: this never exits non-zero. A job that -# reds before its cancellation lands makes the RUN conclude `failure` rather than -# `cancelled`, `final` runs under `!cancelled()` and fails, and `land` re-drafts -# the PR — the fleet-wide re-drafting the whole design exists to avoid, arriving -# through its own remedy. Every case asserts status 0, including the failures. - -setup() { - PRECOND="$BATS_TEST_DIRNAME/../mise-tasks/ci-lease-precondition.sh" - STUB="$BATS_TEST_TMPDIR/bin" - mkdir -p "$STUB" - PATH="$STUB:$PATH" - export PATH - - # HERMETIC, AND THAT IS NOT HOUSEKEEPING. Every variable below has an ambient - # `GITHUB_*` fallback in the script under test, and CI sets those while a - # developer box does not — so a case that reaches "unset" by unsetting only - # the `LEASE_*` name passes locally and behaves differently under Actions. - # That is a verify/CI disagreement by construction, which is the single thing - # the gate exists to rule out. Measured: `no run id means there is nothing to - # cancel` went green locally and red in CI, where it had fallen through to - # GITHUB_RUN_ID and asked to cancel the very run it was executing in. - unset GITHUB_REPOSITORY GITHUB_HEAD_REF GITHUB_RUN_ID GITHUB_SERVER_URL GITHUB_SHA - - # Defaults describing a healthy, current, unblocked head. Each case moves - # exactly one of them, so a failure names the row rather than the fixture. - head_land 'mise run land-lock acquire' - land_lock_exits 0 - export GH_REPO=button-inc/batten - export LEASE_HEAD_REF=feature-x - export LEASE_HEAD_SHA=cafebabe - export LEASE_RUN_ID=12345 - export RUNNER_TEMP="$BATS_TEST_TMPDIR/runner" - # The production wait is a backstop on a cancellation that never arrives; a - # suite that paid it would spend 45s per stop row to observe nothing. - export LEASE_CANCEL_WAIT=0 - export GH_TOKEN=ghs-not-a-real-token - mkdir -p "$RUNNER_TEMP" - stub_gh -} - -# What `contents/mise-tasks/land.sh?ref=` returns. -head_land() { printf '%s\n' "$1" >"$BATS_TEST_TMPDIR/head-land"; } - -# The `land-lock` this fetches from trunk IS a stub — which is the whole trick -# here. `authorises` has its own exhaustive suite in tests/land-lock.bats; what -# this file tests is how the precondition reacts to each of its answers, so the -# answer is scripted rather than provoked. -land_lock_exits() { - cat >"$BATS_TEST_TMPDIR/land-lock" <"$STUB/gh" <>"\$t/cancels" ;; - *contents/mise-tasks/land-lock.sh*) - [ ! -e "\$t/land-lock-unreadable" ] || exit 1 - cat "\$t/land-lock" ;; - *contents/mise-tasks/land.sh*) - [ ! -e "\$t/head-land-unreadable" ] || exit 1 - cat "\$t/head-land" ;; - *) exit 1 ;; -esac -EOF - chmod +x "$STUB/gh" -} - -cancels() { cat "$BATS_TEST_TMPDIR/cancels" 2>/dev/null || true; } - -@test "a current head with a free lease runs, and cancels nothing" { - run "$PRECOND" - [ "$status" -eq 0 ] - [ -z "$(cancels)" ] -} - -@test "a lease that authorises another branch STOPS this run — the acceptance case" { - # `land-lock authorises` exits 3 for exactly this. The precondition's job is - # to turn that into a cancelled run rather than a red one. - land_lock_exits 3 - run "$PRECOND" - [ "$status" -eq 0 ] - [[ "$(cancels)" == *"/actions/runs/12345/cancel"* ]] - [[ "$output" == *"not authorised"* ]] -} - -@test "THE STALENESS ROW: a head whose land does not take the lease is stopped" { - # The hole the lease table cannot see. This head takes no lease, so the ref - # reads absent and every row below would wave it through. - head_land 'echo landing without a lease' - run "$PRECOND" - [ "$status" -eq 0 ] - [[ "$(cancels)" == *"/cancel"* ]] -} - -@test "the staleness refusal names the remedy, not merely the refusal" { - head_land 'echo landing without a lease' - run "$PRECOND" - [[ "$output" == *"git rebase origin/main"* ]] - [[ "$output" == *"mise run land"* ]] -} - -@test "a stale head is stopped WITHOUT consulting the lease — it cannot be judged by it" { - # Ordering matters, not just the verdict: a lease that authorises this very - # branch must not rescue tooling that cannot honour it. - head_land 'echo landing without a lease' - land_lock_exits 0 - run "$PRECOND" - [ "$status" -eq 0 ] - [[ "$(cancels)" == *"/cancel"* ]] -} - -@test "the head sha is read from LEASE_HEAD_SHA, and its absence is said out loud" { - # GITHUB_SHA on a pull_request event is the MERGE commit, which carries - # trunk's `land` whenever the head did not touch it — so falling back to it - # would pass every stale head silently and look implemented. - head_land 'echo landing without a lease' - unset LEASE_HEAD_SHA - run "$PRECOND" - [ "$status" -eq 0 ] - [[ "$output" == *"not judging this head's age"* ]] - [ -z "$(cancels)" ] -} - -@test "FAIL OPEN: an unreadable head land is not judged" { - touch "$BATS_TEST_TMPDIR/head-land-unreadable" - run "$PRECOND" - [ "$status" -eq 0 ] - [ -z "$(cancels)" ] -} - -@test "FAIL OPEN: an unreadable land-lock runs rather than stopping the fleet" { - touch "$BATS_TEST_TMPDIR/land-lock-unreadable" - run "$PRECOND" - [ "$status" -eq 0 ] - [[ "$output" == *"running rather than stopping the fleet"* ]] - [ -z "$(cancels)" ] -} - -@test "FAIL OPEN: an answer that is neither run nor stop runs" { - # `authorises` fails open by contract, so a 1 or a 2 arriving here means this - # script is holding it wrong. One matrix beats a stopped fleet either way. - land_lock_exits 2 - run "$PRECOND" - [ "$status" -eq 0 ] - [[ "$output" == *"neither run nor stop"* ]] - [ -z "$(cancels)" ] -} - -@test "CLOUD-420: A WORKSPACE THAT CANNOT BE BUILT STILL EXITS 0" { - # THE UNTESTED LINE. `set -euo pipefail` became `set -uo pipefail` to keep the - # header's promise — "AND IT NEVER EXITS NON-ZERO" — and nothing exercised the - # case that promise exists for. Eighteen cases covered `gh` and fetch failures; - # every one of them still handed the script a working `RUNNER_TEMP`. - # - # The whole setup chain is unguarded: the `RUNNER_TEMP` fallback, `mkdir -p`, - # the redirect that writes land-lock, `chmod +x`, `git init`, `remote add` and - # `config --local`. With `-e` restored the first of them ends the script - # non-zero, the job reds before its cancellation lands, the RUN concludes - # `failure` rather than `cancelled`, `final` fails its needs assertion, and - # `land` re-drafts the PR — the fleet-wide re-drafting this design exists to - # avoid, arriving through its own remedy. - # - # A FILE where a directory must be. `mkdir -p "$file/batten-lease.$$"` cannot - # succeed, and every later step inherits the failure. - : >"$BATS_TEST_TMPDIR/not-a-dir" - export RUNNER_TEMP="$BATS_TEST_TMPDIR/not-a-dir" - # The lease says STOP, so this row cannot pass by never reaching the workspace: - # it is the expensive path, the one that builds the clone and consults the lock. - land_lock_exits 3 - run "$PRECOND" - [ "$status" -eq 0 ] -} - -@test "CLOUD-420: a broken workspace is not reported as land-lock's answer" { - # The fail-open above is reached BY ACCIDENT — a cd/exec failure falls into the - # `*)` arm, whose message says `land-lock answered , which is neither run - # nor stop`. That attributes a workspace failure to a predicate that was never - # consulted, and it is the line a human reads when the fleet misbehaves. - : >"$BATS_TEST_TMPDIR/not-a-dir" - export RUNNER_TEMP="$BATS_TEST_TMPDIR/not-a-dir" - land_lock_exits 3 - run "$PRECOND" - [ "$status" -eq 0 ] - [[ "$output" == *"workspace"* ]] - [[ "$output" != *"land-lock answered"* ]] -} - -@test "FAIL OPEN: a refused cancellation runs rather than reddening" { - land_lock_exits 3 - touch "$BATS_TEST_TMPDIR/cancel-refused" - run "$PRECOND" - [ "$status" -eq 0 ] - [[ "$output" == *"cancellation was refused"* ]] -} - -@test "FAIL OPEN: no run id means there is nothing to cancel" { - land_lock_exits 3 - unset LEASE_RUN_ID - run "$PRECOND" - [ "$status" -eq 0 ] - [ -z "$(cancels)" ] -} - -@test "FAIL OPEN: no repository and no head ref each run" { - unset GH_REPO - run "$PRECOND" - [ "$status" -eq 0 ] - GH_REPO=button-inc/batten - export GH_REPO - unset LEASE_HEAD_REF - run "$PRECOND" - [ "$status" -eq 0 ] -} - -@test "the branch under judgement is the one passed to land-lock" { - # The stub echoes its arguments. A precondition that asked about the wrong - # branch would authorise every run and look correct in review. - run "$PRECOND" - [[ "$output" == *"authorises feature-x"* ]] -} - -@test "the token never reaches the log, on any path" { - # Non-negotiable 4, and the reason the credential goes in an http header - # rather than a userinfo URL: land-lock prints its remote when it cannot - # reach it. - land_lock_exits 3 - run "$PRECOND" - [[ "$output" != *"ghs-not-a-real-token"* ]] - head_land 'echo landing without a lease' - run "$PRECOND" - [[ "$output" != *"ghs-not-a-real-token"* ]] -} - -@test "a branch that lands through /fast-forward is not judged, in either row" { - # `auto-bot-land.yml` and `auto-release-land.yml` fire on - # `workflow_run: completed`, which a CANCELLED run satisfies — they then find - # the checks not green and stop, and nothing retries. Cancelling those runs - # would defer a matrix to the next rebase rather than save one, and add a - # stall to a landing path that is unattended by design. - land_lock_exits 3 - head_land 'echo landing without a lease' - for ref in renovate/cargo release-plz-2026-08-12; do - LEASE_HEAD_REF="$ref" run "$PRECOND" - [ "$status" -eq 0 ] - [[ "$output" == *"lands through /fast-forward"* ]] - done - [ -z "$(cancels)" ] -} - -@test "the retired bot's prefix is judged like any other branch (CLOUD-660)" { - # The arm moved rather than being added: Dependabot is retired, so a - # `dependabot/*` head is now somebody's ordinary branch and gets the ordinary - # answer. An exemption left behind for a bot that no longer runs is an - # unauthorised matrix nobody would ever look at. - land_lock_exits 3 - head_land 'echo landing without a lease' - LEASE_HEAD_REF=dependabot/cargo/serde-1.0.2 run "$PRECOND" - [ "$status" -eq 0 ] - [[ "$(cancels)" == *"/cancel"* ]] -} - -@test "the exemption is a prefix on the landing path, not a substring anywhere in the ref" { - # `feature/release-plz-notes` is somebody's branch, not the release PR's. - land_lock_exits 3 - LEASE_HEAD_REF=feature/release-plz-notes run "$PRECOND" - [ "$status" -eq 0 ] - [[ "$(cancels)" == *"/cancel"* ]] -} - -@test "the ambient Actions run id is the fallback, and it is the run this is standing in" { - # The fallback the scrub in `setup` exists to keep out of the other cases — - # pinned here rather than merely avoided, because it is real behaviour: inside - # a job, GITHUB_RUN_ID names exactly the run a stop is meant to cancel, so a - # workflow that forgot to pass LEASE_RUN_ID still stops correctly. - land_lock_exits 3 - unset LEASE_RUN_ID - GITHUB_RUN_ID=987654 run "$PRECOND" - [ "$status" -eq 0 ] - [[ "$(cancels)" == *"/actions/runs/987654/cancel"* ]] -} - -# The runner only treats a line as a workflow command when it begins with `::` -# after leading whitespace is trimmed (actions/runner, ActionCommand.TryParseV2, -# which is line-anchored). `say()` prefixes `lease-precondition: `, so a token -# routed through it lands at column 20 and is read as ordinary output. -# -# That matters more here than anywhere else in the repo: a stopped run is a -# CANCELLED run whose `final` is red with no failed step of its own, so the -# annotation is the only surface that says the lease declined it and that the -# remedy is one rebase. Both stop paths are pinned, and every occurrence is -# checked rather than the first — a second token behind a prefix is the same -# defect with a passing test. -assert_annotations_are_annotations() { - local line bad="" - while IFS= read -r line; do - case "$line" in - *"::error::"*) [[ "$line" == "::error::"* ]] || bad="$line" ;; - esac - done <<<"$output" - [ -z "$bad" ] -} - -@test "the lease refusal is a real annotation, not a log line the runner ignores" { - land_lock_exits 3 - run "$PRECOND" - [[ "$output" == *"::error::"* ]] - assert_annotations_are_annotations -} - -@test "the staleness remedy is a real annotation too — it is the actionable one" { - head_land 'echo landing without a lease' - run "$PRECOND" - [[ "$output" == *"::error::"* ]] - [[ "$output" == *"git rebase origin/main"* ]] - assert_annotations_are_annotations -} diff --git a/tests/install.bats b/tests/install.bats index 01f8d106a..b5ed2bdbe 100644 --- a/tests/install.bats +++ b/tests/install.bats @@ -405,3 +405,60 @@ EOF run head -20 "$seen" [[ "$output" == *"proxy-placeholder"* ]] } + +# --- BATTEN_VERSION_FROM_REF: the pin trunk decides (CLOUD-420) ---------------- +# +# `batten lease guard` runs as the FIRST step of every `pull_request` job, before +# any checkout, so which batten it runs must be decided by TRUNK and not by the +# head's own workflow file. `BATTEN_VERSION_FROM_REF` names the ref whose +# `Cargo.toml` carries that version, and it is the whole of what replaced the +# fetch-a-script-from-trunk design. +# +# UNTESTED FOR ITS WHOLE LIFE UNTIL HERE. Measured 2026-09-06: the variable +# appeared in `install.sh` and five workflows and in no case anywhere, so the +# mechanism deciding the version every step-0 guard runs was resting on nobody +# having mistyped it. Found while composing a rebase conflict across it, which is +# the wrong moment to discover a behaviour has no test. + +# The manifest `BATTEN_VERSION_FROM_REF` reads, at whatever version is asked for. +manifest_at() { + mkdir -p "$FIX/repos/button-inc/batten/contents" + printf 'version = "%s"\n' "$1" >"$FIX/repos/button-inc/batten/contents/Cargo.toml" +} + +@test "the version comes from the ref's manifest when one is named" { + release_json "$DIGEST" + manifest_at 9.9.9 + BATTEN_VERSION_FROM_REF=main run "$INSTALL" + [ "$status" -eq 0 ] + # The pinned tag was the one fetched, not `latest` — and both exist in the + # fixture, so reaching the right one is a real discrimination. + [ "$("$DEST/batten")" = "fixture-batten" ] + [[ "$output" != *"has no published release yet"* ]] +} + +@test "a ref naming an unreleased version falls back to the latest release" { + # THE ARM THE CI GUARD LIVES ON. release-plz bumps the manifest BEFORE + # publishing the tag, so trunk routinely names a version with no release — + # and the step-0 guard swallows a failure by design, so a hard stop here + # would be silent and every `pull_request` job would run unguarded. + release_json "$DIGEST" + manifest_at 7.7.7 + BATTEN_VERSION_FROM_REF=main run "$INSTALL" + [ "$status" -eq 0 ] + [[ "$output" == *"v7.7.7 has no published release yet"* ]] + [ "$("$DEST/batten")" = "fixture-batten" ] +} + +@test "an explicitly named version never falls back" { + # THE ANTI-VACUITY MIRROR, and it is what keeps the fallback narrow: a caller + # who named a version wants that version or an error, so only a ref-derived + # pin may retry. Without this the fallback is satisfied by one that retries + # for everybody, which would silently install a different binary than asked. + release_json "$DIGEST" + manifest_at 9.9.9 + BATTEN_VERSION=v7.7.7 BATTEN_VERSION_FROM_REF=main run "$INSTALL" + [ "$status" -ne 0 ] + [[ "$output" != *"has no published release yet"* ]] + [ ! -e "$DEST/batten" ] +} diff --git a/tests/land-lock-check.bats b/tests/land-lock-check.bats deleted file mode 100644 index 19aa59a94..000000000 --- a/tests/land-lock-check.bats +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/land-lock-check.sh -# land-lock-check: the scheduled half of the landing lease (CLOUD-393). -# -# `land-lock` fails SAFE — an unparseable lease reads as held, so a stray push -# can never free the lock. The cost of that direction is silence: the fleet stops -# landing and nothing says why. This gate is the sensor for exactly that, so the -# cases below are mostly about the two states nothing legitimate can produce. -# -# Both readings are injected (LAND_LOCK_BODY, LAND_LOCK_NOW), so the suite needs -# no remote and no clock — the same lever branch-age-check takes, and for the -# same reason: a gate whose verdict needs the network is a gate nothing tests. - -setup() { - CHECK="$BATS_TEST_DIRNAME/../mise-tasks/land-lock-check.sh" - NOW=1000000 - export LAND_LOCK_NOW="$NOW" LAND_LOCK_TTL=120 -} - -lease() { printf 'land-lock\nholder: %s\nexpires: %s\nnonce: deadbeef\n' "$1" "$2"; } - -@test "an absent lease is healthy — nobody is landing" { - LAND_LOCK_BODY="" run "$CHECK" - [ "$status" -eq 0 ] - [[ "$output" == *"absent"* ]] -} - -@test "a live lease is healthy and names its holder and remaining time" { - LAND_LOCK_BODY="$(lease vm-1 $((NOW + 60)))" run "$CHECK" - [ "$status" -eq 0 ] - [[ "$output" == *"held by vm-1"* ]] - [[ "$output" == *"60s left"* ]] -} - -@test "a RELEASED lease is free, and is reported as a handover rather than an expiry" { - # The `expires: 0` sentinel. Reading it as an ordinary timestamp would print - # an age of 56 years, which is how a healthy state comes to look alarming. - LAND_LOCK_BODY="$(lease vm-1 0)" run "$CHECK" - [ "$status" -eq 0 ] - [[ "$output" == *"released by vm-1"* ]] - [[ "$output" != *"ago"* ]] -} - -@test "a LAPSED lease is free too — a holder that stopped without releasing" { - LAND_LOCK_BODY="$(lease vm-1 $((NOW - 30)))" run "$CHECK" - [ "$status" -eq 0 ] - [[ "$output" == *"free"* ]] - [[ "$output" == *"lapsed"* ]] - [[ "$output" == *"30s ago"* ]] -} - -@test "a lease expiring exactly now is free — zero seconds left is none" { - LAND_LOCK_BODY="$(lease vm-1 "$NOW")" run "$CHECK" - [ "$status" -eq 0 ] - [[ "$output" == *"free"* ]] -} - -@test "WEDGED: a horizon beyond one TTL is refused, since nothing legitimate mints one" { - LAND_LOCK_BODY="$(lease vm-1 $((NOW + 3600)))" run "$CHECK" - [ "$status" -eq 1 ] - [[ "$output" == *"WEDGED"* ]] - [[ "$output" == *"vm-1"* ]] -} - -@test "a lease at exactly one TTL is the longest legitimate hold, not wedged" { - LAND_LOCK_BODY="$(lease vm-1 $((NOW + 120)))" run "$CHECK" - [ "$status" -eq 0 ] - [[ "$output" != *"WEDGED"* ]] -} - -@test "GARBAGE: a ref carrying no lease body is refused" { - # The shape a stray push leaves. land-lock reads it as held, silently. - LAND_LOCK_BODY="just some commit message" run "$CHECK" - [ "$status" -eq 1 ] - [[ "$output" == *"GARBAGE"* ]] -} - -@test "GARBAGE: a non-numeric expiry is a refusal, never a shell error" { - # Checked before the arithmetic on purpose: `[` on a non-number reports a - # syntax error, and an error is not a verdict. - LAND_LOCK_BODY="$(lease vm-1 soon)" run "$CHECK" - [ "$status" -eq 1 ] - [[ "$output" == *"GARBAGE"* ]] - [[ "$output" != *"integer expression"* ]] -} - -@test "GARBAGE: a lease with no holder is refused — nobody could ever release it" { - LAND_LOCK_BODY="$(printf 'land-lock\nexpires: %s\n' $((NOW + 60)))" run "$CHECK" - [ "$status" -eq 1 ] - [[ "$output" == *"GARBAGE"* ]] -} - -@test "POINTER, NEVER PAYLOAD: no case echoes the lease body" { - LAND_LOCK_BODY="$(lease vm-1 $((NOW + 60)))" run "$CHECK" - [[ "$output" != *"nonce"* ]] - [[ "$output" != *"expires:"* ]] - LAND_LOCK_BODY="secret-looking garbage" run "$CHECK" - [[ "$output" != *"secret-looking"* ]] -} - -@test "an unreachable remote is exit 2 — could not look is not a verdict" { - # No LAND_LOCK_BODY, so it takes the live path against a remote that is not - # there. This must never read as "absent", which is the misread that would - # report a healthy lease over a broken one. - unset LAND_LOCK_BODY - LAND_LOCK_REMOTE="$BATS_TEST_TMPDIR/nope.git" run "$CHECK" - [ "$status" -eq 2 ] - [[ "$output" != *"absent"* ]] -} - -# --- the successor the lease admits (CLOUD-369) ------------------------------ -# -# The lease bounds confirming runs at TWO — the holder plus one branch `reserve` -# admitted — and this gate is what a human runs on a wedged lease. Reporting only -# the holder showed half the occupancy: the one view meant to explain who is -# spending CI could not name the second spender. -# -# `next:` is advisory, exactly like `branch:` and `head:`. It is read for the -# report and never for a verdict, so no case below changes an exit code. - -# The same fixture plus a successor. Kept separate from `lease` so every existing -# case keeps asserting the shape it was written for — a body with no `next:` at -# all, which is both the pre-CLOUD-369 lease and the ordinary unreserved one. -lease_with_next() { - printf 'land-lock\nholder: %s\nexpires: %s\nnext: %s\nnonce: deadbeef\n' "$1" "$2" "$3" -} - -@test "CLOUD-369 clause f — a held lease names the successor admitted behind it" { - LAND_LOCK_BODY="$(lease_with_next vm-1 $((NOW + 60)) feature-y)" run "$CHECK" - [ "$status" -eq 0 ] - [[ "$output" == *"held by vm-1"* ]] - [[ "$output" == *"feature-y admitted behind it"* ]] -} - -@test "CLOUD-369 clause f — output is BYTE-IDENTICAL when no successor is admitted" { - # The pair is the point: the addition must be invisible on every lease that - # carries no `next:`, which is every lease minted before this change and every - # one nobody has reserved behind. - LAND_LOCK_BODY="$(lease vm-1 $((NOW + 60)))" run "$CHECK" - with_field="$output" - LAND_LOCK_BODY="$(printf 'land-lock\nholder: vm-1\nexpires: %s\nnext: \nnonce: deadbeef\n' "$((NOW + 60))")" run "$CHECK" - [ "$status" -eq 0 ] - [ "$output" = "$with_field" ] -} - -@test "CLOUD-369 clause f — a RELEASED lease still names who was admitted behind it" { - # Diagnosis does not stop at the handover: a released lease whose successor is - # still pushing is exactly the state a human is trying to understand. - LAND_LOCK_BODY="$(lease_with_next vm-1 0 feature-y)" run "$CHECK" - [ "$status" -eq 0 ] - [[ "$output" == *"released by vm-1"* ]] - [[ "$output" == *"feature-y admitted behind it"* ]] -} - -@test "CLOUD-369 clause f — a WEDGED lease names the successor too, and still fails" { - # The successor is reporting, never a verdict: the wedge is still exit 1. - LAND_LOCK_BODY="$(lease_with_next vm-1 $((NOW + 9999)) feature-y)" run "$CHECK" - [ "$status" -eq 1 ] - [[ "$output" == *"WEDGED"* ]] - [[ "$output" == *"feature-y admitted behind it"* ]] -} - -@test "CLOUD-369 clause f — a LAPSED lease names the successor it left behind" { - LAND_LOCK_BODY="$(lease_with_next vm-1 $((NOW - 30)) feature-y)" run "$CHECK" - [ "$status" -eq 0 ] - [[ "$output" == *"lapsed by vm-1"* ]] - [[ "$output" == *"feature-y admitted behind it"* ]] -} diff --git a/tests/land-lock.bats b/tests/land-lock.bats deleted file mode 100644 index f898e3a21..000000000 --- a/tests/land-lock.bats +++ /dev/null @@ -1,1513 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/land-lock.sh -# land-lock: the rolling lease that serialises landing (CLOUD-393). The whole -# point of the task is an atomicity claim — two sessions must not both hold it — -# and a stubbed git cannot test that claim at all, since the atomicity IS git's. -# So the remote here is a real bare repository and the pushes are real pushes: -# these tests exercise the same create-is-test-and-set and -# --force-with-lease-is-CAS behaviour that was measured against GitHub. -# -# There is no `gh` stub, and that is an assertion rather than an omission: the -# task uses one operation, `git push --force-with-lease`, and nothing else. The -# stub below makes `gh` fail loudly, so any API call would break the suite. That -# matters beyond tidiness — the API budget is shared with `land`'s own polling -# and the fleet measurably exhausted it during development. -# -# A rival session is modelled as a second working clone: the holder id lives in -# each clone's own git dir, so two clones are two identities, which is exactly -# what the remote sees. - -setup() { - # `LAND_LOCK_UNDER_TEST` lets a mutation harness point these rows at a COPY. - # Mutating the tracked file in place makes a corrupted commit reachable from - # any concurrent `git add -A`, which staged a mutant into a pushed commit on - # 2026-08-12 (recorded on CLOUD-418). Unset in every normal run. - LOCK="${LAND_LOCK_UNDER_TEST:-$BATS_TEST_DIRNAME/../mise-tasks/land-lock.sh}" - BARE="$BATS_TEST_TMPDIR/remote.git" - MINE="$BATS_TEST_TMPDIR/mine" - RIVAL="$BATS_TEST_TMPDIR/rival" - STUB="$BATS_TEST_TMPDIR/bin" - - git init -q --bare "$BARE" - mkdir -p "$STUB" - cat >"$STUB/gh" <<'EOF' -#!/usr/bin/env bash -echo "land-lock called gh, which it must never do: $*" >&2 -exit 99 -EOF - chmod +x "$STUB/gh" - PATH="$STUB:$PATH" - export PATH - - for d in "$MINE" "$RIVAL"; do - git init -q "$d" - git -C "$d" -c user.email=t@t -c user.name=t commit -q --allow-empty -m seed - git -C "$d" remote add origin "$BARE" - git -C "$d" checkout -q -b claude/work - done - export LAND_LOCK_WAIT=1 -} - -lock() { (cd "$1" && shift && "$LOCK" "$@"); } -lease_sha() { git --git-dir="$BARE" rev-parse --verify -q refs/heads/batten-land-lock; } - -# A failing `[ … ]` in bats prints the source line and nothing about WHY, which -# for the rows below is the difference between the two readings an author has to -# tell apart: `land-lock` refused a lease it should have taken, or the runner -# never got round to letting it try. CLOUD-448 cost two laps to exactly that -# ambiguity, and CLOUD-450's budgets are only affordable BECAUSE a failure names -# which of the two it was — a 60s wait that fails silently is a worse report than -# the 6s one it replaced, not a better one. -# -# Returns non-zero so `set -e` ends the case at the same place a bare assertion -# would have; it is a diagnostic on the way out, never a recovery. -bail() { # - echo "$1" >&2 - return 1 -} - -teardown() { - # CLOUD-434's lesson applied to this suite's own plumbing: a hold that a - # case leaked — a regressed tether, a mutant under test — must cost a stray - # process for one teardown, never a wedged file. Within-file execution is - # serial and no other suite runs the real land-lock, so the match cannot - # reach a sibling test's processes. - pkill -f 'mise-tasks/land-lock.sh hold' 2>/dev/null || true -} - -@test "an unheld lease reports unheld, and says so at exit 0" { - run lock "$MINE" status - [ "$status" -eq 0 ] - [[ "$output" == *"unheld"* ]] -} - -@test "acquire on a free lease wins and creates the ref" { - run lock "$MINE" acquire - [ "$status" -eq 0 ] - [[ "$output" == *"acquired by"* ]] - lease_sha -} - -@test "THE CLAIM: a rival cannot acquire a live lease" { - lock "$MINE" acquire - run lock "$RIVAL" acquire - [ "$status" -eq 1 ] - [[ "$output" == *"still held by"* ]] -} - -@test "acquire is re-entrant for the holder, so a retry is not a deadlock" { - lock "$MINE" acquire - run lock "$MINE" acquire - [ "$status" -eq 0 ] - [[ "$output" == *"already held by this clone"* ]] -} - -@test "held is the holder's yes and the rival's no" { - lock "$MINE" acquire - run lock "$MINE" held - [ "$status" -eq 0 ] - run lock "$RIVAL" held - [ "$status" -eq 1 ] -} - -@test "release by the holder frees the lease for the next claimant" { - lock "$MINE" acquire - run lock "$MINE" release - [ "$status" -eq 0 ] - # Released is a TOMBSTONE, not a deletion: the ref survives, carrying the - # sentinel expiry 0. What a caller must see is that the lease is free — and - # since CLOUD-433 it is told so as a HANDOVER rather than as an expiry, - # which is the true statement about how it became free. - lease_sha - run lock "$MINE" status - [[ "$output" == *"released"* ]] - run lock "$RIVAL" acquire - [ "$status" -eq 0 ] -} - -@test "THE DEFECT: a released lease's status names the last holder, never an epoch" { - # `expires: 0` is a SENTINEL, not an instant, so `now - expires` renders - # wall-clock epoch. Observed live after the lease's first fleet release: - # `free for 1786499426s`. `land-lock-check` already special-cased the - # tombstone; `status` never did. - # - # `released` has to be tested BEFORE `expired`, because a tombstone - # satisfies both — `now >= 0` is trivially true — so an expired-first - # ordering reintroduces the defect exactly. - lock "$MINE" acquire - holder=$(cat "$MINE/.git/batten-land-lock/holder") - lock "$MINE" release - run lock "$MINE" status - [ "$status" -eq 0 ] - [[ "$output" == *"released"* ]] - [[ "$output" == *"$holder"* ]] - # No epoch-scale DURATION, whatever wording is used to render one. Anchored - # on the trailing `s` rather than on ten digits alone, because the holder id - # is random hex and can itself contain ten consecutive digits — an - # unanchored version of this assertion fails on roughly one id in ten for a - # reason that has nothing to do with the defect. - [[ ! "$output" =~ [0-9]{10}s ]] - [[ "$output" != *"free for"* ]] -} - -@test "THE DEFECT: releasing an already-released lease says so, and reports no epoch age" { - # `age()` had no tombstone branch and `release` never consulted `released()` - # before swapping, so a second release re-tombstoned the lease and printed - # `released after 1786501354s`. Observed live 2026-08-12. - lock "$MINE" acquire - lock "$MINE" release - run lock "$MINE" release - [ "$status" -eq 0 ] - [[ "$output" == *"already released"* ]] - # Anchored on the trailing `s` for the same reason as above. - [[ ! "$output" =~ [0-9]{10}s ]] -} - -@test "THE DEFECT: a first sighting of a sha emits no shell error on stderr" { - # `read -r … <"$seen" 2>/dev/null` — bash opens the input redirect before - # the stderr redirect on the same command takes effect, so a missing file - # still printed `No such file or directory` to the CALLER's stderr, on every - # acquire that reached the corroboration path. Harmless to the verdict, and - # noise in the one task whose output contract is pointer-only. - # - # Driven through a CONTENDED acquire, because that is the path that reaches - # corroboration at all: the rival holds a live lease, so this acquire - # observes a sha it has never seen and gives up at the wait deadline. - lock "$RIVAL" acquire - run lock "$MINE" acquire - [ "$status" -eq 1 ] - [[ "$output" != *"No such file or directory"* ]] - [[ "$output" != *"land-lock: line"* ]] -} - -@test "THE DEFECT: a LIVE lease is sighted, so the corroboration clock is already running when it expires" { - # The mechanism, asserted deterministically. The latency row below states the - # promise a caller cares about, but a wall-clock bound is a poor gate: the - # defective path steals at ~9-12s and the fixed one at ~3-4s, so any single - # threshold sits uncomfortably close to one of them. THIS row is the one that - # discriminates, and it cannot flake — it asserts the sighting was RECORDED, - # which is the whole change. - # - # The defect recorded a sighting only inside the `expired &&` branch, so - # observing a live lease left no trace at all and the clock started from - # scratch after expiry — by which time the backoff had grown to 8-30s. - LAND_LOCK_TTL=60 lock "$RIVAL" acquire - held=$(lease_sha) - LAND_LOCK_TTL=60 LAND_LOCK_WAIT=1 run lock "$MINE" acquire - [ "$status" -eq 1 ] - # The lease is nowhere near expiry, so under the defect nothing here exists. - [ -f "$MINE/.git/batten-land-lock/seen" ] - read -r seen_sha _ <"$MINE/.git/batten-land-lock/seen" - [ "$seen_sha" = "$held" ] -} - -@test "THE DEFECT: a lease sighted before it expired is taken on the first check after" { - # The corroboration clock used to start at the first POST-expiry - # observation, by which time acquire's backoff had grown to 8–30s: measured - # 19s from expiry to steal at TTL=4/beat=2, against this task's own promise - # of one extra beat. Recording the sighting on every observation is the fix. - # - # Asserted as a DURATION because that is the promise the header makes. The - # bound has to sit between the two behaviours rather than merely above the - # fixed one: measured, the defect steals at ~9-12s here and the fix at ~3-4s, - # and an earlier version of this row used `< 12` — which the defect passes, - # so it graded nothing. The row above is the flake-proof half; this one - # states the user-visible promise. - # - # CLOUD-448 — THE SETUP IS THE RACE, not the measurement. The sighting below - # is only a sighting while the rival's lease is still live, and a 4s TTL is - # shorter than `rush --jobs` can deschedule this process for (CLOUD-386 made - # the suite parallel). When that happened the sighting acquire STOLE the - # lease and the old `[ "$status" -eq 1 ]` failed — grading the runner's - # scheduler, not `land-lock`, and telling an author to "reproduce and fix - # locally" something that reproduces nowhere. It cost PR #354 and PR #370 a - # full `verify` and a lap each. - # - # So the precondition is now established rather than assumed, and a - # precondition the environment failed to create is never asserted through - # (CLOUD-249). SETUP is retried — never the measurement, which would be - # drive-to-green — because a plain skip would fire often enough to erase the - # coverage: this raced twice in one day. - # - # The TTL stays short deliberately. Raising it would widen the window - # without removing the race, and every second added here is paid on every - # run of the suite. - local attempt=0 - while :; do - attempt=$((attempt + 1)) - # Reset: whatever the failed attempt left behind. The `seen` file - # especially — a stale sighting would pre-age the very clock this case - # measures, which is the one thing that must be fresh. - lock "$MINE" release >/dev/null 2>&1 || true - rm -f "$MINE/.git/batten-land-lock/seen" - LAND_LOCK_TTL=4 LAND_LOCK_HEARTBEAT=2 lock "$RIVAL" acquire >/dev/null - # Sight the live lease: this is the observation the defect discarded. - # A refusal means the lease was still held, which is the setup this case - # needs. Success means it had already expired — no sighting happened, so - # there is nothing here to measure. - LAND_LOCK_TTL=4 LAND_LOCK_HEARTBEAT=2 LAND_LOCK_WAIT=1 run lock "$MINE" acquire - [ "$status" -ne 1 ] || break - [ "$attempt" -lt 3 ] || - skip "setup not created after $attempt attempts: the rival's 4s lease expired before it could be sighted. That is the runner being descheduled, not a verdict about land-lock (CLOUD-448)" - done - # CLOUD-450 — THE DURATION COMES FROM THE PROGRAM, NOT FROM $SECONDS. The old - # form timed the whole `run`: bash startup, the ls-remote/fetch/commit-tree/ - # push chain, and whatever deschedule the retry loop above had just paid for, - # all charged to `land-lock` and graded against 8s. `acquire` already reports - # the one interval that is actually its own — `swap`ping a dead lease prints - # the seconds between the PREVIOUS HOLDER'S EXPIRY and the steal, computed - # inside the process from the observed body — and that is precisely the - # promise this row's header makes, with every cost that is not this task's - # excluded rather than merely budgeted for. - # - # Anchored on words on BOTH sides of the number. A sed anchored on one side, - # or on the digits alone, keeps matching after a rewording and starts - # grading something else; anchored on both, a reworded sentence matches - # nothing, `took` comes back empty and the row FAILS loudly — the one - # outcome a silently-empty duration assertion never produces. - # - # THE RESIDUAL IS CLOSED, AND THE WALL CLOCK IS GONE (CLOUD-450). This used to - # grade `took` — seconds between the previous holder's expiry and the steal — - # and both ends of that delta are instants on one clock, so a deschedule - # landing between them inflated it. Under the parallel runner that fired on - # roughly 2 of every 4 `verify` runs, and it blocked CLOUD-274's landing - # directly: `land` refused to push on a `verify` whose only failure was this - # case, behaving exactly as designed over a signal that was wrong. A flaky - # gate is a bypassed gate. - # - # The promise was never really about seconds. "A dead lease costs one extra - # beat" IS "the steal lands on the FIRST post-expiry probe", and `acquire` now - # reports that count — a quantity no amount of load can move. The seconds stay - # in the sentence because they are what a human reads; nothing grades them. - LAND_LOCK_TTL=4 LAND_LOCK_HEARTBEAT=2 LAND_LOCK_WAIT=30 run lock "$MINE" acquire - [ "$status" -eq 0 ] - [[ "$output" == *"took the lease"* ]] - probes=$(printf '%s\n' "$output" | - sed -n 's/.*probes since expiry: \([0-9][0-9]*\).*/\1/p') - # Anchored on words on both sides of the number, for the reason the header - # above gives: a reworded sentence must match NOTHING and fail loudly, never - # match something else and grade it silently. - [ -n "$probes" ] || - bail "acquire reported no probe count in the shape this row parses, so the sentence in land-lock moved and this assertion silently stopped grading anything (CLOUD-450): $output" - [ "$probes" -eq 1 ] || - bail "acquire spent $probes probes on an expired lease, against a promise of one (CLOUD-433/CLOUD-450): $output" -} - -@test "a released lease is not still held by its releaser" { - lock "$MINE" acquire - lock "$MINE" release - run lock "$MINE" held - [ "$status" -eq 1 ] -} - -@test "release by a non-holder is a silent no-op, never a theft" { - lock "$MINE" acquire - before=$(lease_sha) - run lock "$RIVAL" release - [ "$status" -eq 0 ] # the trap calls this on paths that never acquired - [ "$(lease_sha)" = "$before" ] -} - -@test "renew extends the lease and moves the ref" { - lock "$MINE" acquire - before=$(lease_sha) - run lock "$MINE" renew - [ "$status" -eq 0 ] - [ "$(lease_sha)" != "$before" ] -} - -@test "a non-holder cannot renew, so a heartbeat cannot steal" { - lock "$MINE" acquire - before=$(lease_sha) - run lock "$RIVAL" renew - [ "$status" -eq 1 ] - [ "$(lease_sha)" = "$before" ] -} - -@test "an expired lease is taken once its death is corroborated, not waited out forever" { - # Expiry alone no longer authorises a steal — see the clock-skew case below. - # A short beat makes the corroboration accrue in a second rather than thirty, - # which is the same contract at test speed. - # - # The `sleep 2` over a 1s TTL is the SAFE shape of this pattern and stays - # (CLOUD-450): nothing here has to happen while the lease is still live, so a - # deschedule can only make it more expired than the row needs. Contrast the - # reservation case, which needs the lease ALIVE at a particular instant and - # is retried for that reason. - # - # THE BUDGET IS NOT THE PROPERTY. `LAND_LOCK_WAIT` is a wall clock INSIDE the - # program under test, spanning two real fetches and a randomised backoff, and - # 6s of it was grading the runner: `acquire` computes its deadline before the - # first `ls-remote`, so one slow fetch made the second probe — the one that - # corroborates and steals — arrive past a deadline that had nothing to do - # with land-lock. This row asserts the lease IS taken and never how fast; the - # duration promise lives in exactly one place, the CLOUD-433 row above. So - # the budget goes to 60, which costs a passing run nothing (the steal exits - # the instant it wins) and costs a genuine hang 54 extra seconds once. - LAND_LOCK_TTL=1 lock "$MINE" acquire - sleep 2 - LAND_LOCK_HEARTBEAT=1 LAND_LOCK_WAIT=60 run lock "$RIVAL" acquire - [ "$status" -eq 0 ] || - bail "a corroborated dead lease was not taken inside a 60s wait, which is a refusal to steal rather than a slow runner (CLOUD-450): $output" - [[ "$output" == *"took the lease"* ]] - LAND_LOCK_HEARTBEAT=1 run lock "$RIVAL" held - [ "$status" -eq 0 ] -} - -@test "a live lease is NOT stolen — expiry is the only steal condition" { - lock "$MINE" acquire - run lock "$RIVAL" acquire - [ "$status" -eq 1 ] - run lock "$MINE" held - [ "$status" -eq 0 ] -} - -@test "THE FENCE: a holder whose lease was stolen reports not-held" { - LAND_LOCK_TTL=1 lock "$MINE" acquire - sleep 2 - # THE STEAL IS SETUP, SO IT IS ASSERTED (CLOUD-450). It used to be a bare - # call under a 6s in-program wait, and the two halves of that were separately - # wrong. A steal the runner was too slow to complete failed nothing here — - # MINE's own 1s lease is gone either way, so `held` answers 1 whether it was - # STOLEN or merely lapsed, and the fence's actual claim ("the holder notices - # somebody else took it") went ungraded on precisely the loaded runs. That is - # a pass for the wrong reason, which is worse than a flake: it is silent. - # - # So the budget goes to 60 for the reason the corroboration row above states - # — the wait bounds a fetch, not the property — and the precondition it buys - # is now read from the observable the steal itself produces. - LAND_LOCK_HEARTBEAT=1 LAND_LOCK_WAIT=60 run lock "$RIVAL" acquire - if [ "$status" -ne 0 ] || [[ "$output" != *"took the lease"* ]]; then - bail "the rival did not steal the expired lease, so the fence below would have graded a lapse rather than a theft (CLOUD-450): $output" - fi - # This is the check `land` runs immediately before commenting /fast-forward. - # Without it the original holder would act on a lease it no longer has, which - # is the collision the lock exists to remove. - run lock "$MINE" held - [ "$status" -eq 1 ] -} - -@test "an expired lease reads as free, and still names who left it" { - # Free is what a caller acts on; the name is what a human diagnoses a dead - # session with, so the report carries both rather than choosing. - # - # CLOUD-450 audited every `TTL=1` + `sleep 2` in this file and left this one - # alone deliberately. The property is expiry and nothing else — no call has - # to land while the lease is still live — so a descheduled runner makes the - # lease MORE expired than the row asked for, never less, and the assertion - # below is the same one either way. That is what separates it from "a - # reservation does not extend the holder's lease", where `reserve` refuses a - # lease that is not live and the same two lines are a race the case can lose. - LAND_LOCK_TTL=1 lock "$MINE" acquire - sleep 2 - run lock "$MINE" status - [ "$status" -eq 0 ] - [[ "$output" == *"unheld"* ]] - [[ "$output" == *"last held by"* ]] -} - -@test "FAIL CLOSED: an unreachable remote is exit 2, never 'unheld'" { - # The misread this forbids is the one that ends with two sessions landing at - # once: a remote that cannot be reached is not a remote holding no lease. - LAND_LOCK_REMOTE="$BATS_TEST_TMPDIR/nope.git" run lock "$MINE" status - [ "$status" -eq 2 ] - [[ "$output" != *"unheld"* ]] - # `released` is the second way `status` can say "free" (CLOUD-433), so it is - # forbidden here too. A fail-closed row that names only one of the free - # wordings stops being fail-closed the moment another is added. - [[ "$output" != *"released"* ]] -} - -@test "an unreachable remote fails acquire closed too" { - LAND_LOCK_REMOTE="$BATS_TEST_TMPDIR/nope.git" run lock "$MINE" acquire - [ "$status" -eq 2 ] -} - -@test "an unknown verb is exit 2 and names the usage" { - run lock "$MINE" frobnicate - [ "$status" -eq 2 ] - [[ "$output" == *"usage: land-lock"* ]] -} - -@test "POINTER, NEVER PAYLOAD: output carries ids and seconds, never the lease body" { - lock "$MINE" acquire - run lock "$MINE" status - [[ "$output" != *"land-lock"$'\n'"holder:"* ]] - [[ "$output" != *"expires:"* ]] - [[ "$output" == *"s left"* ]] -} - -@test "THE EXPECTED VALUE IS EXPLICIT — a bare --force-with-lease is two holders" { - # Not a style rule. Bare `--force-with-lease` compares against this clone's - # remote-tracking ref, i.e. whatever the last fetch saw, which for a ref other - # sessions rewrite constantly is the stale value the whole task must not - # trust: a holder whose lease had already been taken would stamp its own back - # on top. The two forms are one character apart in a diff and opposite in - # meaning, so the explicit form is asserted rather than commented. - # Comments are stripped first: this file's own prose explains the bare form, - # and a gate that its own rationale trips is a gate someone deletes. - run bash -c "sed 's/#.*//' '$LOCK' | grep -c -- '--force-with-lease=\"\$ref:\$1\"'" - [ "$status" -eq 0 ] - [ "$output" -ge 1 ] - run bash -c "sed 's/#.*//' '$LOCK' | grep -n -- '--force-with-lease[^=]'" - [ "$status" -ne 0 ] -} - -@test "SHA AND BODY COME FROM ONE SOURCE — never ls-remote paired with FETCH_HEAD" { - # `land` backgrounds the heartbeat's observe loop and then runs `held` and - # `release` in the FOREGROUND of the same clone, so two observes overlap by - # design. FETCH_HEAD is one file per clone, so the loser of that race reads the - # winner's fetch. - # - # Every process here fetches the SAME lease ref, so a crossed read yields a - # different GENERATION of the lease rather than a foreign one — harmless while - # the holder is unchanged, and a theft exactly when a handover is in flight: - # the sha names the new holder's lease while the body still names the old one, - # so `mine` says yes and `release` CASes a live lease belonging to someone else - # out from under them. - # - # That interleaving cannot be forced deterministically from a test, so the - # assertion is structural: both readings must come from the per-process ref, and - # FETCH_HEAD must not appear in the code at all. Comments are stripped first so - # this file's own rationale cannot trip the rule it explains. - run bash -c "sed 's/#.*//' '$LOCK' | grep -n FETCH_HEAD" - [ "$status" -ne 0 ] - run bash -c "sed 's/#.*//' '$LOCK' | grep -c 'git cat-file commit \"\$observed_sha\"'" - [ "$output" -ge 1 ] -} - -@test "observe leaves no per-process ref behind" { - lock "$MINE" acquire - lock "$MINE" status >/dev/null - # A ref per land would accumulate forever in a long-lived clone. - run git -C "$MINE" for-each-ref --format='%(refname)' refs/batten-lock-obs - [ -z "$output" ] -} - -@test "the fence demands MARGIN, not merely an unexpired lease" { - # "Not expired" is true at the instant of the check; the caller then goes on - # to comment and wait. A lease with a second left passes a bare check and is - # gone before the action it authorised lands — the same TOCTOU gap the fence - # exists to close, moved a few lines later. - LAND_LOCK_TTL=40 LAND_LOCK_HEARTBEAT=30 lock "$MINE" acquire - # 40s of lease against a 30s beat: still comfortably in hand. - LAND_LOCK_HEARTBEAT=30 run lock "$MINE" held - [ "$status" -eq 0 ] - # 20s of lease against the same beat: alive, but too thin to act on. - LAND_LOCK_TTL=20 lock "$MINE" renew - LAND_LOCK_HEARTBEAT=30 run lock "$MINE" held - [ "$status" -eq 1 ] - [[ "$output" == *"too little to act on"* ]] -} - -@test "an expired lease is not stolen on the first sighting — clocks are not shared" { - # `expires` is minted on the HOLDER's clock and read against ours, so skew in - # one direction makes a live lease look expired. Stealing on that reading is - # the two-holders bug. Corroboration is that the sha has sat unchanged for a - # beat, which is a duration on one clock and cannot be forged by skew. - # - # Another `TTL=1` + `sleep 2` CLOUD-450 left alone, for the reason spelled - # out on "an expired lease reads as free": expiry is the whole precondition, - # and a slow runner overshoots it rather than missing it. The 30s beat below - # is likewise not a budget — it is chosen so no amount of waiting inside the - # 1s wait can corroborate, which is the refusal this row is about. - LAND_LOCK_TTL=1 lock "$MINE" acquire - sleep 2 - # First sighting: expired by the body, but no persistence evidence yet. - LAND_LOCK_HEARTBEAT=30 LAND_LOCK_WAIT=1 run lock "$RIVAL" acquire - [ "$status" -eq 1 ] - run lock "$MINE" held - [ "$status" -eq 0 ] || true # margin may refuse it; ownership is the point - run lock "$RIVAL" status - [[ "$output" != *"held by $(cat "$RIVAL/.git/batten-land-lock/holder" 2>/dev/null)"* ]] -} - -@test "a dead lease IS taken once the sha has demonstrably stopped moving" { - # The other half: corroboration must not become a deadlock. With a short beat - # the evidence accrues quickly and the lease is claimable. - # - # CLOUD-450 — THIS ROW WAS GREEN THROUGH THE WRONG BRANCH, which is why it is - # rewritten rather than merely rebudgeted. The first probe is meant to be a - # REFUSAL that leaves a sighting behind, and `HEARTBEAT=1 WAIT=1` reads like - # "one probe, nothing corroborated yet". It is not. `acquire` tests its - # deadline AFTER the steal test and then sleeps `2 + RANDOM % 2`, so unless - # the first `ls-remote` happens to cross a second boundary — the only way a - # 1s deadline can fire on probe 1 — a second probe lands 2-3s later with the - # sha unchanged for longer than the 1s beat, corroborates, and STEALS. - # Measured over seven runs of the old form: six stole, reporting `took the - # lease 3-4s after …`, and one refused. The rival then walked away holding a - # fresh 120s lease, so every assertion below passed through `already held by - # this clone` and graded re-entrancy — which the row four cases up already - # covers — while this row's own subject went ungraded on six runs in seven. - # `|| true` is what hid it: it discarded the one exit status that said which - # branch had run. - # - # So the sighting probe now gets a beat that nothing inside its own wait can - # corroborate — 30s of persistence demanded against 1s of wait — and its - # refusal is ASSERTED instead of discarded. A setup step whose outcome nobody - # reads is a setup step the case is not entitled to assume (CLOUD-249). - LAND_LOCK_TTL=1 lock "$MINE" acquire - sleep 2 - LAND_LOCK_HEARTBEAT=30 LAND_LOCK_WAIT=1 run lock "$RIVAL" acquire - if [ "$status" -ne 1 ] || [[ "$output" != *"still held by"* ]]; then - bail "the sighting probe TOOK the lease instead of only recording it, so the steal below is a re-entrant acquire and grades nothing about corroboration (CLOUD-450): $output" - fi - # Age that sighting past the beat the measurement runs under. Safe in the way - # the expiry sleeps above are safe: `sha_held_for` is a duration on one clock, - # so a descheduled runner makes the sha look like it has sat unchanged for - # LONGER than the row needs, never for less. - sleep 2 - # 60 rather than 6, for the reason the corroboration row states: the wait - # bounds two fetches and a randomised backoff, and this row asserts that the - # steal HAPPENS, never how quickly. - LAND_LOCK_HEARTBEAT=1 LAND_LOCK_WAIT=60 run lock "$RIVAL" acquire - if [ "$status" -ne 0 ] || [[ "$output" != *"took the lease"* ]]; then - bail "a sha that had demonstrably stopped moving was not taken as a corroborated steal (CLOUD-450): $output" - fi - run lock "$RIVAL" held - [ "$status" -eq 0 ] -} - -@test "NO GIT IDENTITY: the lease is takeable on a machine with no user.email" { - # `git commit-tree` refuses with "Author identity unknown" wherever no - # user.email is configured — a CI runner, a fresh clone. Every acquiring test - # in this suite passed locally and failed in CI for exactly that reason, so - # the identity is supplied by `mint` rather than inherited from the machine. - # HOME is redirected because a global config would mask the very absence - # being tested. - HOME="$BATS_TEST_TMPDIR/nohome" GIT_CONFIG_GLOBAL=/dev/null \ - GIT_CONFIG_SYSTEM=/dev/null run lock "$MINE" acquire - [ "$status" -eq 0 ] - [[ "$output" == *"acquired by"* ]] - lease_sha -} - -@test "A FAILED MINT IS A REFUSED SWAP, NEVER A DELETE" { - # `swap` used to interpolate $(mint) straight into the refspec, so an empty - # mint produced ":$ref" — git's DELETE refspec. On the renew path, whose - # expected value is our own live lease, that CAS would have succeeded and - # destroyed the lease we held. Breaking the mint must refuse, not delete. - lock "$MINE" acquire - before=$(lease_sha) - # `git` that fails only for commit-tree: mint breaks, everything else works. - cat >"$STUB/git" <<'EOF' -#!/usr/bin/env bash -[ "$1" != commit-tree ] || exit 1 -exec /usr/bin/git "$@" -EOF - chmod +x "$STUB/git" - run lock "$MINE" renew - rm -f "$STUB/git" - [ "$status" -ne 0 ] - # The lease must still be there, and still be ours. - [ "$(lease_sha)" = "$before" ] - run lock "$MINE" held - [ "$status" -eq 0 ] -} - -# --- the heartbeat's parent tether (CLOUD-432) ------------------------------- -# -# `hold` had no coverage at all until the 2026-08-12 pressure probe, which also -# showed an orphaned heartbeat renewing a lease forever after its land was -# SIGKILLed — the trap never fired, the fleet was wedged, and land-lock-check -# reported a healthy hold. The tether: land passes LAND_LOCK_HOLDER_PID, and a -# beat whose holder is gone releases and exits instead of renewing for nobody. -# Every backgrounded process here closes fd 3 (CLOUD-434): a leaked child must -# never hold this file's TAP stream. - -# A stand-in land: a process whose cmdline passes the identity check (its path -# ends mise-tasks/land.sh) at a pid the test controls. stdout is detached so the -# command substitution reading the pid returns instead of waiting out the sleep. -fake_land() { - mkdir -p "$BATS_TEST_TMPDIR/mise-tasks" - printf '#!/usr/bin/env bash\nsleep 60\n' >"$BATS_TEST_TMPDIR/mise-tasks/land.sh" - chmod +x "$BATS_TEST_TMPDIR/mise-tasks/land.sh" - "$BATS_TEST_TMPDIR/mise-tasks/land.sh" >/dev/null 2>&1 3>&- & - echo $! -} - -# PROOF THAT A BEAT RAN — the precondition every `hold` case below actually -# needs, and the one none of them used to establish (CLOUD-450). They stood a -# `sleep 2.5` or a 5s deadline in for it, spanning a fork, a bash startup, one -# beat, a /proc read, a fetch and a push; on a loaded runner the hold had not -# been scheduled at all by the time the row read its verdict, and the healthy-hold -# rows then passed VACUOUSLY — alive because nothing had run, "held by" because -# the acquire on the line above had put it there. A tether wrongly firing on a -# healthy hold was therefore invisible on exactly the loaded runs, which is the -# regression class this suite exists to catch. -# -# The observable is the lease SHA, and it is the right one rather than merely the -# convenient one. `swap` mints a fresh nonce on every write, so EVERY beat moves -# the sha — the healthy renew and the tether's tombstone alike — which makes a -# moved sha proof that a beat executed, and keeps that proof true under the -# mutant as well as under the fix: delete the tether and the beat renews, and the -# sha still moves. A case skipping on "no beat ever landed" therefore erases no -# coverage it would otherwise have had. What the sha cannot say is WHICH beat -# ran, which is why every case keeps its own assertion about the outcome. -# -# Bounded by an attempt COUNT and never by a clock, and it ends in `skip` rather -# than in a verdict: "the runner never scheduled the hold" is a statement about -# the runner, and asserting through a precondition the environment failed to -# create is what CLOUD-249 forbids. A healthy run returns on the first or second -# look, so the bound is paid only when something is already wrong. -wait_for_beat() { # - local before="$1" attempt=0 - while [ "$attempt" -lt 100 ]; do - [ "$(lease_sha)" = "$before" ] || return 0 - attempt=$((attempt + 1)) - sleep 0.2 - done - return 1 -} - -@test "a hold whose land died releases within a beat instead of renewing for nobody" { - run lock "$MINE" acquire - [ "$status" -eq 0 ] - before=$(lease_sha) - land_pid=$(fake_land) - (cd "$MINE" && LAND_LOCK_HEARTBEAT=1 LAND_LOCK_HOLDER_PID="$land_pid" \ - "$LOCK" hold >"$BATS_TEST_TMPDIR/hold.out" 2>&1) >/dev/null 2>&1 3>&- & - hold_pid=$! - kill -9 "$land_pid" - # The land is dead before the first beat, so the first beat IS the tether's: - # it tombstones the lease, which moves the sha exactly as a renew would. Wait - # for that rather than for a wall clock — the 5s deadline below then measures - # only the interval it was written for, the hold noticing it is finished, - # instead of also covering the fork and the startup that precede it. - wait_for_beat "$before" || - skip "no beat ever reached the remote: the hold was never scheduled, which is a statement about the runner rather than about the tether (CLOUD-450)" - deadline=$((SECONDS + 5)) - while kill -0 "$hold_pid" 2>/dev/null && [ "$SECONDS" -lt "$deadline" ]; do - sleep 0.2 - done - ! kill -0 "$hold_pid" 2>/dev/null - grep -q "releasing rather than renewing for nobody" "$BATS_TEST_TMPDIR/hold.out" - # Released means instantly claimable: the rival sees a handover, not a TTL it - # has to wait out. The wording is `released` rather than `unheld` since - # CLOUD-433 — the tether releases, and a release is a tombstone, which is a - # different and more informative statement than "expired". - run lock "$RIVAL" status - [ "$status" -eq 0 ] - [[ "$output" == *"released"* ]] - # And claimable in fact, not merely in wording. - run lock "$RIVAL" acquire - [ "$status" -eq 0 ] -} - -@test "a live land keeps its heartbeat renewing — the tether never fires on a healthy hold" { - # THE VACUOUS PASS THIS ROW USED TO HAVE (CLOUD-450), and the reason it is - # the most important of the four. `sleep 2.5` stood in for "two beats - # happened", and when the runner never scheduled the hold BOTH assertions - # passed for the wrong reason: the process is alive because it has not run - # yet, and the rival sees "held by" because the acquire two lines up put it - # there. A tether wrongly firing on a healthy hold — the exact regression - # this case names — was therefore invisible on precisely the loaded runs - # where a tether is most likely to misread a live land as gone. - run lock "$MINE" acquire - [ "$status" -eq 0 ] - before=$(lease_sha) - land_pid=$(fake_land) - (cd "$MINE" && LAND_LOCK_HEARTBEAT=1 LAND_LOCK_HOLDER_PID="$land_pid" \ - "$LOCK" hold >/dev/null 2>&1) >/dev/null 2>&1 3>&- & - hold_pid=$! - wait_for_beat "$before" || - skip "no beat ever reached the remote: the hold was never scheduled, which is a statement about the runner rather than about the tether (CLOUD-450)" - kill -0 "$hold_pid" 2>/dev/null - run lock "$RIVAL" status - [[ "$output" == *"held by"* ]] - # AND THE ASSERTION THAT MAKES THE ROW DISCRIMINATING once the vacuous pass - # is gone. `held by` alone does not separate the two outcomes: a tombstone - # renders `released — last held by `, which contains `held by`, so a - # wrongly fired tether satisfied the line above rather than failing it. A - # tether that fires here RELEASES, so the word `released` is the thing that - # must not appear — stated as its own line so a failure names which half of - # the claim broke. - [[ "$output" != *"released"* ]] - kill "$hold_pid" 2>/dev/null || true - kill "$land_pid" 2>/dev/null || true - run lock "$MINE" release - [ "$status" -eq 0 ] -} - -@test "a pid recycled into something that is not a land reads as gone" { - # Existence is not identity: this clone measurably wrapped its pid space - # inside 20 minutes, so a live pid may be somebody else entirely. The probe - # reads /proc//cmdline, and anything that is not a mise-tasks/land.sh is - # a dead holder — failing toward release, the cheap direction. - run lock "$MINE" acquire - [ "$status" -eq 0 ] - before=$(lease_sha) - sleep 60 >/dev/null 2>&1 3>&- & - imposter=$! - (cd "$MINE" && LAND_LOCK_HEARTBEAT=1 LAND_LOCK_HOLDER_PID="$imposter" \ - "$LOCK" hold >/dev/null 2>&1) >/dev/null 2>&1 3>&- & - hold_pid=$! - # Same precondition as the tether case above, and needed for the same reason: - # the 5s deadline was covering the fork and the bash startup as well as the - # beat it was written to bound, so a slow runner failed the row for having - # been slow. The imposter is not a land, so the first beat tombstones and the - # sha moves. - wait_for_beat "$before" || - skip "no beat ever reached the remote: the hold was never scheduled, which is a statement about the runner rather than about the pid probe (CLOUD-450)" - deadline=$((SECONDS + 5)) - while kill -0 "$hold_pid" 2>/dev/null && [ "$SECONDS" -lt "$deadline" ]; do - sleep 0.2 - done - ! kill -0 "$hold_pid" 2>/dev/null - run lock "$RIVAL" status - [ "$status" -eq 0 ] - # `released`, not `unheld`, since CLOUD-433: the tether RELEASES, and a - # release is a tombstone — a handover rather than an expiry. - [[ "$output" == *"released"* ]] - kill "$imposter" 2>/dev/null || true -} - -@test "an unset holder pid keeps today's behaviour, so no other caller changes" { - # The healthy-hold pair to the row above, and vacuous in exactly the same way - # before CLOUD-450: with no beat proven to have run, "the process is alive" - # and "the rival sees a holder" were both true of a hold that had never - # started. This row is the one that says an UNSET holder pid changes nothing - # for every caller that is not `land`, so a hold that never beat is precisely - # the state in which it proves nothing. - run lock "$MINE" acquire - [ "$status" -eq 0 ] - before=$(lease_sha) - (cd "$MINE" && LAND_LOCK_HEARTBEAT=1 "$LOCK" hold >/dev/null 2>&1) >/dev/null 2>&1 3>&- & - hold_pid=$! - wait_for_beat "$before" || - skip "no beat ever reached the remote: the hold was never scheduled, which is a statement about the runner rather than about the unset-pid path (CLOUD-450)" - kill -0 "$hold_pid" 2>/dev/null - run lock "$RIVAL" status - [[ "$output" == *"held by"* ]] - # `held by` is a substring of `released — last held by `, so it alone - # cannot tell a renewed lease from a tombstoned one. An unset pid must renew, - # so the tombstone wording is forbidden here for the same reason it is on the - # healthy-hold row above. - [[ "$output" != *"released"* ]] - kill "$hold_pid" 2>/dev/null || true - run lock "$MINE" release - [ "$status" -eq 0 ] -} - -# --- the stall bail: liveness is not progress (CLOUD-499) -------------------- -# -# The tether above answers "is the land still there". These answer "is it still -# LANDING", which liveness cannot see: a `land` blocked forever renews its lease -# every beat, so `acquire` never reaches a steal condition and `status` reports a -# healthy hold. Two bounds, because two failures wear the same face — a loop that -# stopped turning (the tick freezes) and one that turns forever over a reading -# that will never resolve (the sig and phase freeze while the tick keeps moving). -# -# Bounds are set in BEATS here and kept tiny, so a case costs seconds. Every row -# below establishes its precondition through `wait_for_beat` rather than a sleep, -# for the reason CLOUD-450 records: a `sleep` standing in for "a beat happened" -# makes the healthy-hold rows pass vacuously on a loaded box, which is exactly -# where a wrongly-firing bail would hide. - -# The registry the heartbeat reads, resolved beside the lock UNDER TEST so a -# mutant copy and its reader stay one program (see `LAND_LOCK_UNDER_TEST`). -registry() { # - (cd "$MINE" && batten task "$@") -} - -# A stand-in land with a registry entry, which together are what a heartbeat -# needs to have any opinion at all: a pid whose cmdline passes the identity check -# and an entry whose stamps it can read. Echoes the pid. -fake_land_registered() { # - local pid - pid=$(fake_land) - registry register land "$pid" "$1" >/dev/null 2>&1 - echo "$pid" -} - -# Waits for a hold to exit, bounded by an attempt count rather than a clock, and -# reports whether it did. Callers assert on the answer, so a hold that never -# exits fails the row it was supposed to fail rather than hanging the suite. -hold_exited() { # - local attempt=0 - while [ "$attempt" -lt 100 ]; do - kill -0 "$1" 2>/dev/null || return 0 - attempt=$((attempt + 1)) - sleep 0.2 - done - return 1 -} - -@test "THE ACCEPTANCE CASE: a land that stops advancing loses its lease and is stopped" { - run lock "$MINE" acquire - [ "$status" -eq 0 ] - before=$(lease_sha) - # Registered and then never touched again: the phase is frozen, no loop is - # ticking, and the land is perfectly alive — the state the tether above - # cannot distinguish from a healthy landing. - land_pid=$(fake_land_registered "ci-wait(lap 1)") - (cd "$MINE" && LAND_LOCK_HEARTBEAT=1 LAND_LOCK_STALL_BEATS=2 \ - LAND_LOCK_HOLDER_PID="$land_pid" \ - "$LOCK" hold >"$BATS_TEST_TMPDIR/hold.out" 2>&1) >/dev/null 2>&1 3>&- & - hold_pid=$! - wait_for_beat "$before" || - skip "no beat ever reached the remote: the hold was never scheduled, which is a statement about the runner rather than about the stall bail (CLOUD-450)" - hold_exited "$hold_pid" - grep -q "has not advanced in 2 beats" "$BATS_TEST_TMPDIR/hold.out" - # THE FLEET IS FREED, which is the half that always lands. - run lock "$RIVAL" status - [[ "$output" == *"released"* ]] - run lock "$RIVAL" acquire - [ "$status" -eq 0 ] - # AND THE LANDING IS STOPPED, which is the half that stops it spending more. - # Asserted separately so a failure names which of the two broke. - attempt=0 - while kill -0 "$land_pid" 2>/dev/null && [ "$attempt" -lt 50 ]; do - attempt=$((attempt + 1)) - sleep 0.2 - done - ! kill -0 "$land_pid" 2>/dev/null - # WHY, where the agent will look. A landing stopped without a stated reason - # reaches its agent as "verify and CI disagree" (CLOUD-470), and this - # mechanism would then have created the failure another one exists to remove. - [ -s "$MINE/.git/batten-land-lock/bail-reason" ] - kill "$land_pid" 2>/dev/null || true -} - -@test "a land whose phase keeps changing is never bailed on" { - # The false-positive case, and the one that would break the fleet in the - # other direction: a bail that fires on healthy landings is worse than the - # wedge it replaces, because every landing hits it. - # - # THE PRECONDITION IS THE PHASE ACTUALLY MOVING, AND IT IS CHECKED RATHER - # THAN ASSUMED (CLOUD-448/CLOUD-450). A first cut advanced the phase from an - # inline `sleep 0.5` loop and asserted the hold survived: under `--jobs` that - # loop is itself descheduled, the phase then genuinely goes unchanged past - # the bound, and the bail fires CORRECTLY while the row reports a defect. - # That is grading the scheduler. So the updater runs in the background at a - # fraction of the bound, and the row reads the registry's own stamp to decide - # which of the two happened — a stall the mechanism found, or a starved - # updater, which is a statement about the runner and skips. - run lock "$MINE" acquire - [ "$status" -eq 0 ] - before=$(lease_sha) - land_pid=$(fake_land_registered "verify(lap 1)") - (cd "$MINE" && LAND_LOCK_HEARTBEAT=1 LAND_LOCK_STALL_BEATS=5 \ - LAND_LOCK_HOLDER_PID="$land_pid" \ - "$LOCK" hold >"$BATS_TEST_TMPDIR/hold.out" 2>&1) >/dev/null 2>&1 3>&- & - hold_pid=$! - ( - step=0 - while :; do - step=$((step + 1)) - registry phase "$land_pid" "verify(lap 1) step:$step" >/dev/null 2>&1 - sleep 0.2 - done - ) >/dev/null 2>&1 3>&- & - updater_pid=$! - # Three beats of a healthy, advancing landing — counted through the lease sha - # the heartbeat moves every beat, never through a wall clock. - for _ in 1 2 3; do - before=$(lease_sha) - wait_for_beat "$before" || - skip "no beat ever reached the remote: the hold was never scheduled, which is a statement about the runner rather than about the stall bail (CLOUD-450)" - done - entry="$MINE/.git/batten-tasks/$land_pid" - since=$(sed -n 's/^phase_since: //p' "$entry" 2>/dev/null) - kill "$updater_pid" 2>/dev/null || true - [ -n "$since" ] && [ "$(($(date -u +%s) - since))" -lt 5 ] || - skip "the phase updater was starved past the bound, so a bail here would be CORRECT — a statement about the runner rather than about the mechanism (CLOUD-448)" - kill -0 "$hold_pid" 2>/dev/null - kill -0 "$land_pid" 2>/dev/null - run lock "$RIVAL" status - [[ "$output" == *"held by"* ]] - [[ "$output" != *"released"* ]] - kill "$hold_pid" "$land_pid" 2>/dev/null || true -} - -@test "RE-STATING A PHASE IS NOT ADVANCING IT, or a wedged land renews forever" { - # The registry stamp moves only when the value does. Without that rule a - # caller that re-announces where it already is — a lap repeating a step, a - # nested gate naming the step it is on — would reset the stall clock every - # time, and the bail could never fire on the loop it was built for. - run lock "$MINE" acquire - [ "$status" -eq 0 ] - before=$(lease_sha) - land_pid=$(fake_land_registered "ci-wait(lap 1)") - (cd "$MINE" && LAND_LOCK_HEARTBEAT=1 LAND_LOCK_STALL_BEATS=2 \ - LAND_LOCK_HOLDER_PID="$land_pid" \ - "$LOCK" hold >"$BATS_TEST_TMPDIR/hold.out" 2>&1) >/dev/null 2>&1 3>&- & - hold_pid=$! - wait_for_beat "$before" || - skip "no beat ever reached the remote: the hold was never scheduled, which is a statement about the runner rather than about the stamp rule (CLOUD-450)" - for _ in 1 2 3 4 5 6; do - registry phase "$land_pid" "ci-wait(lap 1)" >/dev/null 2>&1 - sleep 0.3 - done - hold_exited "$hold_pid" - grep -q "has not advanced" "$BATS_TEST_TMPDIR/hold.out" - kill "$land_pid" 2>/dev/null || true -} - -@test "a loop that stops turning is caught by the shorter hang bound" { - # The other failure: `ci-wait` polls every ~1.5s, so a poll loop that has - # produced nothing for three beats is blocked rather than waiting. The stall - # bound is set far away, so only the hang bound can end this row. - run lock "$MINE" acquire - [ "$status" -eq 0 ] - before=$(lease_sha) - land_pid=$(fake_land_registered "ci-wait(lap 1)") - # The tick must be STRICTLY later than the phase for a loop to count as - # ticking — see `holder_progress`. A second of separation is what a real poll - # produces within its first iteration. - sleep 1 - registry tick "$land_pid" 1 >/dev/null 2>&1 - (cd "$MINE" && LAND_LOCK_HEARTBEAT=1 LAND_LOCK_HANG_BEATS=2 LAND_LOCK_STALL_BEATS=9999 \ - LAND_LOCK_HOLDER_PID="$land_pid" \ - "$LOCK" hold >"$BATS_TEST_TMPDIR/hold.out" 2>&1) >/dev/null 2>&1 3>&- & - hold_pid=$! - wait_for_beat "$before" || - skip "no beat ever reached the remote: the hold was never scheduled, which is a statement about the runner rather than about the hang bound (CLOUD-450)" - hold_exited "$hold_pid" - grep -q "stopped turning 2 beats ago" "$BATS_TEST_TMPDIR/hold.out" - kill "$land_pid" 2>/dev/null || true -} - -@test "THE HANG BOUND DOES NOT REACH A PHASE WITH NO LOOP, or verify is killed for running" { - # `verify`'s single steps legitimately run for minutes without a tick, so the - # 90s hang bound may only apply while a loop is actually ticking. The tick - # here is OLDER than the phase — a leftover from the previous lap's CI wait — - # which is precisely the state a long `verify` is in, and the hang bound must - # not fire on it. - run lock "$MINE" acquire - [ "$status" -eq 0 ] - before=$(lease_sha) - land_pid=$(fake_land_registered "ci-wait(lap 1)") - registry tick "$land_pid" 1 >/dev/null 2>&1 - sleep 1 - registry phase "$land_pid" "verify(lap 2)" >/dev/null 2>&1 - (cd "$MINE" && LAND_LOCK_HEARTBEAT=1 LAND_LOCK_HANG_BEATS=1 LAND_LOCK_STALL_BEATS=9999 \ - LAND_LOCK_HOLDER_PID="$land_pid" \ - "$LOCK" hold >"$BATS_TEST_TMPDIR/hold.out" 2>&1) >/dev/null 2>&1 3>&- & - hold_pid=$! - wait_for_beat "$before" || - skip "no beat ever reached the remote: the hold was never scheduled, which is a statement about the runner rather than about the hang bound (CLOUD-450)" - sleep 2 - kill -0 "$hold_pid" 2>/dev/null - kill -0 "$land_pid" 2>/dev/null - [ ! -s "$BATS_TEST_TMPDIR/hold.out" ] - kill "$hold_pid" "$land_pid" 2>/dev/null || true -} - -@test "no registry entry is no verdict — an unregistered land is not a stalled one" { - # Registry writes are best-effort by design, so a land whose bookkeeping never - # landed must not be killed for it. No entry, no evidence, no stall — and the - # lease it publishes carries an empty progress token, which no rival may steal - # on either. - run lock "$MINE" acquire - [ "$status" -eq 0 ] - before=$(lease_sha) - land_pid=$(fake_land) - (cd "$MINE" && LAND_LOCK_HEARTBEAT=1 LAND_LOCK_STALL_BEATS=1 \ - LAND_LOCK_HOLDER_PID="$land_pid" \ - "$LOCK" hold >"$BATS_TEST_TMPDIR/hold.out" 2>&1) >/dev/null 2>&1 3>&- & - hold_pid=$! - wait_for_beat "$before" || - skip "no beat ever reached the remote: the hold was never scheduled, which is a statement about the runner rather than about the no-entry path (CLOUD-450)" - sleep 2 - kill -0 "$hold_pid" 2>/dev/null - kill -0 "$land_pid" 2>/dev/null - run git -C "$MINE" cat-file commit "$(lease_sha)" - [[ "$output" == *"progress: "$'\n'* ]] - kill "$hold_pid" "$land_pid" 2>/dev/null || true -} - -@test "A RIVAL MAY REAP A LEASE THAT BEATS WITHOUT PROGRESSING" { - # The backstop for every wedge nobody has filed yet, and the half that works - # when the holder's own bail cannot — its bounds are set out of reach here, so - # only the rival can end this row. - run lock "$MINE" acquire - [ "$status" -eq 0 ] - before=$(lease_sha) - land_pid=$(fake_land_registered "ci-wait(lap 1)") - (cd "$MINE" && LAND_LOCK_HEARTBEAT=1 LAND_LOCK_STALL_BEATS=9999 LAND_LOCK_HANG_BEATS=9999 \ - LAND_LOCK_HOLDER_PID="$land_pid" \ - "$LOCK" hold >/dev/null 2>&1) >/dev/null 2>&1 3>&- & - hold_pid=$! - wait_for_beat "$before" || - skip "no beat ever reached the remote: the hold was never scheduled, which is a statement about the runner rather than about the steal path (CLOUD-450)" - # The lease is LIVE throughout — the rival is not waiting for it to lapse, - # which is what makes this a different steal from every other one here. - run lock "$RIVAL" status - [[ "$output" == *"held by"* ]] - # THE BUDGET IS NOT THE PROPERTY, and this row was the one left behind when - # CLOUD-450 raised its siblings. 20s is a wall clock inside the program under - # test, spanning the rival's own fetches plus TWO of the holder's beats before - # the stall can be corroborated — so on a loaded runner it grades the runner - # rather than the steal. Measured: green locally and in the mutation sweep that - # names this very case, red in CI at 23.9s with `still held by … after 20s`, - # on a run where `test:bats` took 1078s against ~290s here. This row asserts - # the lease IS reaped and never how fast; the duration promise lives in one - # place, the CLOUD-433 row above. 60 costs a passing run nothing, because the - # steal exits the instant it wins. - run env LAND_LOCK_STALL_BEATS=2 LAND_LOCK_HEARTBEAT=1 LAND_LOCK_WAIT=60 \ - bash -c "cd '$RIVAL' && '$LOCK' acquire" - [ "$status" -eq 0 ] || - bail "a lease beating without progressing was not reaped inside a 60s wait, which is a refusal to steal rather than a slow runner (CLOUD-450): $output" - [[ "$output" == *"still beating but had not progressed"* ]] - kill "$hold_pid" "$land_pid" 2>/dev/null || true -} - -@test "a lease that carries no progress token is never stall-stealable" { - # Every lease minted before this change, and every holder that cannot see its - # own progress. The rival half fails CLOSED — no token, no steal — because a - # lease taken on absent evidence is the two-holders bug this file exists to - # prevent, where a lease RELEASED wrongly costs its holder one lap. - run lock "$MINE" acquire - [ "$status" -eq 0 ] - before=$(lease_sha) - # No holder pid, so the heartbeat publishes an empty token — the same body a - # pre-CLOUD-499 lease carries. - (cd "$MINE" && LAND_LOCK_HEARTBEAT=1 "$LOCK" hold >/dev/null 2>&1) >/dev/null 2>&1 3>&- & - hold_pid=$! - wait_for_beat "$before" || - skip "no beat ever reached the remote: the hold was never scheduled, which is a statement about the runner rather than about the steal path (CLOUD-450)" - run env LAND_LOCK_STALL_BEATS=1 LAND_LOCK_HEARTBEAT=1 LAND_LOCK_WAIT=3 \ - bash -c "cd '$RIVAL' && '$LOCK' acquire" - [ "$status" -eq 1 ] - [[ "$output" == *"still held by"* ]] - kill "$hold_pid" 2>/dev/null || true -} - -# --- `authorises`: the verb a runner asks (CLOUD-420) ------------------------ -# -# Every other verb answers about THIS clone, via a holder id no GitHub job can -# compare itself against. `authorises` answers about a BRANCH, which is the one -# identifier the runner and the lease both carry. Its exits are 0 run / 3 stop / -# 2 could not look, and it is the only verb here that fails OPEN: a lease it -# cannot read would otherwise stop every job in the fleet. - -# A lease held by the rival clone, authorising a named branch. -rival_holds_for() { # - (cd "$RIVAL" && LAND_LOCK_LAND_BRANCH="$1" "$LOCK" acquire >/dev/null) -} - -@test "authorises: an absent lease lets any branch run" { - run lock "$MINE" authorises feature-x - [ "$status" -eq 0 ] - [[ "$output" == *"no lease is held"* ]] -} - -@test "authorises: the branch the lease names may run" { - rival_holds_for feature-x - run lock "$MINE" authorises feature-x - [ "$status" -eq 0 ] - [[ "$output" == *"authorises feature-x"* ]] -} - -@test "THE STOP: a branch the lease does not name is refused with exit 3" { - # 3 rather than 1, because 1 already means "held by someone else" — a reason - # to stop, not the instruction. A caller keying on 3 cannot confuse a - # refusal with an error. - rival_holds_for feature-x - run lock "$MINE" authorises feature-y - [ "$status" -eq 3 ] - [[ "$output" == *"authorises feature-x, not feature-y"* ]] -} - -@test "authorises: a released lease stops nobody" { - rival_holds_for feature-x - (cd "$RIVAL" && "$LOCK" release >/dev/null) - run lock "$MINE" authorises feature-y - [ "$status" -eq 0 ] -} - -@test "authorises: an expired lease stops nobody" { - # The third `TTL=1` + `sleep 2` CLOUD-450 audited and deliberately left as it - # is. `authorises` reads the lease and answers; nothing has to happen while - # the lease is still live, so a deschedule between these two lines makes it - # more expired than the row needs rather than less. The reservation case - # below is the one that inverts this — `reserve` refuses a lease that is not - # live, so there the same two lines are a race, and it is retried for it. - LAND_LOCK_TTL=1 rival_holds_for feature-x - sleep 2 - run lock "$MINE" authorises feature-y - [ "$status" -eq 0 ] -} - -@test "FAIL OPEN: a lease carrying no branch runs rather than guessing" { - # Every lease minted before CLOUD-420 is exactly this, so during rollout the - # row is not an edge case — it is every lease. Stopping here would stop the - # whole fleet on the deploy. - tree=$(git -C "$MINE" hash-object -t tree /dev/null) - lease=$(printf 'land-lock\nholder: someone\nexpires: %s\nnonce: ab\n' "$(($(date -u +%s) + 300))" | - git -C "$MINE" -c user.email=t@t -c user.name=t commit-tree "$tree") - git -C "$MINE" push -q origin "$lease:refs/heads/batten-land-lock" - run lock "$MINE" authorises feature-y - [ "$status" -eq 0 ] - [[ "$output" == *"names no branch"* ]] -} - -@test "FAIL OPEN: an unreachable remote runs, where every other verb refuses" { - # The deliberate asymmetry. `status` and `acquire` exit 2 here, because a - # lease they cannot read must never read as free. This verb inverts that: an - # unreadable lease stops every job in the fleet, and waving one matrix - # through costs one matrix. - LAND_LOCK_REMOTE="$BATS_TEST_TMPDIR/nope.git" run lock "$MINE" authorises feature-y - [ "$status" -eq 0 ] - [[ "$output" == *"cannot read the lease"* ]] - LAND_LOCK_REMOTE="$BATS_TEST_TMPDIR/nope.git" run lock "$MINE" status - [ "$status" -eq 2 ] -} - -@test "authorises: a missing branch argument is exit 2, never a verdict" { - run lock "$MINE" authorises - [ "$status" -eq 2 ] - [[ "$output" == *"usage: land-lock authorises"* ]] -} - -@test "the lease body carries the branch it authorises, and still ends with the nonce" { - LAND_LOCK_LAND_BRANCH=feature-x lock "$MINE" acquire - run bash -c "git --git-dir='$BARE' cat-file commit \"\$(git --git-dir='$BARE' rev-parse refs/heads/batten-land-lock)\"" - [[ "$output" == *"branch: feature-x"* ]] - # The nonce stays terminal: its uniqueness is what makes every mint a - # distinct sha, and land-lock-check's fixture treats it as the last line. - run bash -c "git --git-dir='$BARE' cat-file commit \"\$(git --git-dir='$BARE' rev-parse refs/heads/batten-land-lock)\" | tail -1" - [[ "$output" == nonce:* ]] -} - -@test "the lease's own ref name is never mistaken for the branch it authorises" { - # `branch` and `land_branch` are one character apart in a diff and mean - # different things; writing the wrong one stamps `batten-land-lock` into - # every lease and looks correct in review. - LAND_LOCK_LAND_BRANCH=feature-x lock "$MINE" acquire - run bash -c "git --git-dir='$BARE' cat-file commit \"\$(git --git-dir='$BARE' rev-parse refs/heads/batten-land-lock)\"" - [[ "$output" != *"branch: batten-land-lock"* ]] -} - -# --------------------------------------------------------------------------- -# The receipt `ready-guard` reads (CLOUD-420 §3). Written from `swap`, which is -# the lease's ONLY writer — acquire, renew, the heartbeat's steal path and -# release all reach the remote through it — so one insertion covers every way -# the lease can change hands, and no caller can take it without leaving one. - -# The key is the branch with `/` folded to `-`, the transform `claim-check` and -# `receipt::branch_receipt_name` already uses — read here the same way the task writes it, and -# exercised on a SLASHED branch because that is the only shape this repository -# actually produces. -receipt() { - local b - b="$(git -C "$1" rev-parse --abbrev-ref HEAD)" - cat "$1/.git/batten-receipts/lease.${b//\//-}" 2>/dev/null -} - -@test "acquire leaves a receipt carrying the instant the lease expires" { - lock "$MINE" acquire - exp="$(receipt "$MINE")" - [ -n "$exp" ] - # Within the TTL of now, rather than an exact equality: the receipt is - # computed a few milliseconds after the lease body it describes. - now="$(date +%s)" - [ "$exp" -gt "$now" ] - [ "$exp" -le "$((now + 120))" ] -} - -@test "a renew REFRESHES the receipt — a lease held for a long lap is still held" { - # The reason this lives in `swap` and not in `acquire`: `verify` runs longer - # than one TTL, and `land` readies after its push. A receipt minted once at - # acquire would read as lapsed by the time it mattered. - LAND_LOCK_TTL=1 lock "$MINE" acquire - first="$(receipt "$MINE")" - LAND_LOCK_TTL=300 lock "$MINE" renew - second="$(receipt "$MINE")" - [ "$second" -gt "$first" ] -} - -@test "release REMOVES the receipt rather than letting it age out" { - # A release is a declaration that this clone no longer holds it. Leaving the - # receipt to lapse would let `ready-guard` honour a lease already handed on. - lock "$MINE" acquire - [ -n "$(receipt "$MINE")" ] - lock "$MINE" release - [ -z "$(receipt "$MINE")" ] -} - -@test "A REFUSED ACQUIRE LEAVES NO RECEIPT — the whole point of the predicate" { - # If a lost race still wrote one, `ready-guard` would wave through exactly - # the clone the lease just refused, which is worse than having no receipt at - # all: it would be a gate that passes precisely when it should not. - lock "$RIVAL" acquire - run lock "$MINE" acquire - [ "$status" -ne 0 ] - [ -z "$(receipt "$MINE")" ] -} - -# --- `head:`, `next:` and `reserve`: the second matrix (CLOUD-369) ----------- -# -# The lease bounds confirming runs at one, which is right for cost and wrong for -# latency: after every merge the queue is empty and the next branch starts cold. -# These cover the two fields that close that window — `head:`, so a waiter can -# linearize onto the main that is ABOUT to exist, and `next:`, so exactly one -# successor may spend the run that overlaps the merge. -# -# The property under test throughout is the bound. Not "a successor may run" — -# that is easy and half the story — but that a SECOND one may not, whatever the -# fleet size, because one CAS-guarded slot cannot hold two branches. - -@test "the lease body carries the head that is about to become main" { - LAND_LOCK_LAND_BRANCH=feature-x LAND_LOCK_LAND_HEAD=deadbeef lock "$MINE" acquire - run lock "$MINE" peek head - [ "$status" -eq 0 ] - [ "$output" = deadbeef ] -} - -@test "peek prints the field alone, so a caller never parses a sentence" { - LAND_LOCK_LAND_BRANCH=feature-x lock "$MINE" acquire - run lock "$MINE" peek branch - [ "$status" -eq 0 ] - [ "$output" = feature-x ] -} - -@test "peek on an absent lease is silent and 0 — a reading, not an error" { - # A waiter that cannot learn a head stays linearized on origin/main. That is - # an ordinary outcome, so it must not arrive as a failure the caller has to - # distinguish from a broken remote. - run lock "$MINE" peek head - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "peek on an unknown field is exit 2, never an empty answer" { - run lock "$MINE" peek nonesuch - [ "$status" -eq 2 ] - [[ "$output" == *"usage: land-lock peek"* ]] -} - -@test "reserve admits a waiter as the successor behind the holder" { - rival_holds_for feature-x - run lock "$MINE" reserve feature-y - [ "$status" -eq 0 ] - [[ "$output" == *"feature-y admitted as the successor behind feature-x"* ]] - run lock "$MINE" peek next - [ "$output" = feature-y ] -} - -@test "THE BOUND: a second waiter cannot take a slot that is already filled" { - # The whole design rests on this. If two waiters could both reserve, the - # bound would grow with the fleet and the lease would be bounding nothing. - rival_holds_for feature-x - lock "$MINE" reserve feature-y - run lock "$MINE" reserve feature-z - [ "$status" -eq 1 ] - [[ "$output" == *"feature-y is already the admitted successor, not feature-z"* ]] - run lock "$MINE" peek next - [ "$output" = feature-y ] -} - -@test "reserve is idempotent for the branch already holding the slot" { - # A waiter re-reserving each lap must be a read, not a rewrite of the ref. - rival_holds_for feature-x - lock "$MINE" reserve feature-y - before=$(lease_sha) - run lock "$MINE" reserve feature-y - [ "$status" -eq 0 ] - [[ "$output" == *"already the admitted successor"* ]] - [ "$(lease_sha)" = "$before" ] -} - -@test "RESERVING IS NOT STEALING: the holder keeps the lease and every other field" { - # A reservation re-mints somebody else's lease. If it moved the holder id it - # would be a steal wearing a different name, and `mine` would start answering - # for the wrong clone — the two-holders bug this file exists to prevent. - LAND_LOCK_LAND_HEAD=cafebabe rival_holds_for feature-x - before=$(git --git-dir="$BARE" cat-file commit "$(lease_sha)" | sed -n 's/^expires: //p') - lock "$MINE" reserve feature-y - run bash -c "git --git-dir='$BARE' cat-file commit \"\$(git --git-dir='$BARE' rev-parse refs/heads/batten-land-lock)\"" - [[ "$output" == *"branch: feature-x"* ]] - [[ "$output" == *"head: cafebabe"* ]] - [[ "$output" == *"expires: $before"* ]] - # The holder still holds it, and the reserver still does not. - run lock "$RIVAL" status - [ "$status" -eq 0 ] - run lock "$MINE" status - [ "$status" -eq 1 ] -} - -@test "a reservation does not extend the holder's lease" { - # Recomputing the expiry here would hand a holder a fresh TTL every time a - # waiter arrived, so a busy fleet could keep one lease alive indefinitely. - # - # CLOUD-450 — THE SETUP IS THE RACE, exactly as in the CLOUD-448 case above, - # and this is the one `TTL=1` in the file where that is true. `reserve` - # refuses a lease that is not live ("no lease is held; acquire rather than - # reserve"), so a deschedule longer than one second between the acquire and - # the reserve turns the reserve into a refusal — and the bare call then ended - # the case on a line that was grading the runner's scheduler, not whether a - # reservation extends an expiry. - # - # A SUCCESSFUL RESERVE IS THE PRECONDITION, and it is read from the reserve - # itself rather than guessed at: the verb answering 0 IS the statement that - # the lease was live when it was re-minted, which is the only state in which - # the assertion below means anything. Setup is retried, never the - # measurement. - local attempt=0 - while :; do - attempt=$((attempt + 1)) - # Reset what a failed attempt left behind. The RELEASE is load-bearing: - # a lapsed attempt leaves an EXPIRED lease, and `acquire` refuses to - # steal one of those until the sha has sat unchanged for a beat — 30s by - # default — so a retry without it would fail on the acquire instead. A - # tombstone is a handover and needs no corroboration, so the next attempt - # starts from a genuinely free lease. - (cd "$RIVAL" && "$LOCK" release >/dev/null 2>&1) || true - LAND_LOCK_TTL=1 rival_holds_for feature-x - run lock "$MINE" reserve feature-y - [ "$status" -ne 0 ] || break - [ "$attempt" -lt 3 ] || - skip "setup not created after $attempt attempts: the rival's 1s lease expired before the reservation could land on it. That is the runner being descheduled, not a verdict about whether a reservation extends a lease (CLOUD-450)" - done - # Safe to keep as a plain sleep, and the contrast with the retry above is the - # whole point: past this line the row wants the lease EXPIRED, and load can - # only deepen an expiry. - sleep 2 - run lock "$MINE" authorises feature-z - [ "$status" -eq 0 ] - [[ "$output" == *"no lease is held"* ]] -} - -@test "authorises admits the holder AND its one admitted successor" { - rival_holds_for feature-x - lock "$MINE" reserve feature-y - run lock "$MINE" authorises feature-x - [ "$status" -eq 0 ] - run lock "$MINE" authorises feature-y - [ "$status" -eq 0 ] - [[ "$output" == *"successor behind feature-x"* ]] -} - -@test "THE STOP STILL STOPS: a third branch is refused while two are admitted" { - rival_holds_for feature-x - lock "$MINE" reserve feature-y - run lock "$MINE" authorises feature-z - [ "$status" -eq 3 ] -} - -@test "reserve refuses when no lease is held — acquire is the right verb then" { - run lock "$MINE" reserve feature-y - [ "$status" -eq 1 ] - [[ "$output" == *"acquire rather than reserve"* ]] -} - -@test "reserve refuses to reserve behind yourself, which would consume the slot" { - rival_holds_for feature-x - run lock "$MINE" reserve feature-x - [ "$status" -eq 1 ] - [[ "$output" == *"nothing to reserve"* ]] - run lock "$MINE" peek next - [ -z "$output" ] -} - -@test "THE HEARTBEAT CARRIES THE RESERVATION, or it erases it within a beat" { - # The holder re-mints the whole body every beat. A field it did not carry - # forward would vanish ~30s after a waiter wrote it — and the admitted - # successor's run would then be cancelled by CI mid-matrix. - rival_holds_for feature-x - lock "$MINE" reserve feature-y - (cd "$RIVAL" && "$LOCK" renew >/dev/null) - run lock "$MINE" peek next - [ "$output" = feature-y ] - run lock "$MINE" authorises feature-y - [ "$status" -eq 0 ] -} - -@test "ACQUIRE CLEARS IT: a new turn does not inherit the last one's successor" { - # Carrying it forward would authorise a third branch, then a fourth, and the - # bound would drift upward one handover at a time. - rival_holds_for feature-x - lock "$MINE" reserve feature-y - (cd "$RIVAL" && "$LOCK" release >/dev/null) - LAND_LOCK_LAND_BRANCH=feature-z lock "$MINE" acquire - run lock "$MINE" peek next - [ -z "$output" ] - run lock "$MINE" authorises feature-y - [ "$status" -eq 3 ] -} - -@test "a lease minted before this change carries no next, and admits no successor" { - tree=$(git -C "$MINE" hash-object -t tree /dev/null) - lease=$(printf 'land-lock\nholder: someone\nexpires: %s\nbranch: feature-x\nnonce: ab\n' "$(($(date -u +%s) + 300))" | - git -C "$MINE" -c user.email=t@t -c user.name=t commit-tree "$tree") - git -C "$MINE" push -q origin "$lease:refs/heads/batten-land-lock" - run lock "$MINE" authorises feature-y - [ "$status" -eq 3 ] - run lock "$MINE" peek next - [ -z "$output" ] -} - -# --- aging: waiting improves the odds (CLOUD-369) ---------------------------- - -@test "AGING: an aged waiter probes a freed lease sooner than a fresh one" { - # The capture effect, and the reason backoff alone is not fairness: a branch - # that has lost ten times re-enters on the terms of one that just arrived. - # Asserted on the ceiling the backoff climbs to, not on elapsed seconds — a - # wall-clock assertion is the guessed delay this repo rules out everywhere. - rival_holds_for feature-x - LAND_LOCK_WAIT=6 run lock "$MINE" acquire - [ "$status" -eq 1 ] - fresh="$output" - LAND_LOCK_WAIT=6 LAND_LOCK_AGE=5 run lock "$MINE" acquire - [ "$status" -eq 1 ] - # Both give up at the deadline; the aged one got there having probed more - # often, which is the whole of the mechanism. The observable is that neither - # spins and both still refuse — a mutant that dropped the cap to 0 would - # busy-loop and blow the wait budget. - [[ "$fresh" == *"still held by"* ]] - [[ "$output" == *"still held by"* ]] -} - -@test "a non-numeric age is read as zero rather than crashing the backoff" { - rival_holds_for feature-x - LAND_LOCK_WAIT=2 LAND_LOCK_AGE=banana run lock "$MINE" acquire - [ "$status" -eq 1 ] -} - -# --- PRESSURE: the lease under three-party contention ------------------------- -# -# Every reading of the landing loop so far came from an idle fleet: #372 took the -# lease 10647s after the prior holder let go, which measures nothing about -# contention. The two-party rows above prove a rival is refused; this proves the -# property the fleet actually depends on — that N waiters produce exactly one -# holder, and the losers WAIT rather than steal. - -@test "PRESSURE: two waiters against one holder produce exactly ONE winner" { - THIRD="$BATS_TEST_TMPDIR/third" - git init -q "$THIRD" - git -C "$THIRD" -c user.email=t@t -c user.name=t commit -q --allow-empty -m seed - git -C "$THIRD" remote add origin "$BARE" - git -C "$THIRD" checkout -q -b claude/work - - lock "$MINE" acquire - held=$(lease_sha) - - # Both waiters see a live lease. Neither may take it, and neither may leave - # the ref changed — a steal is indistinguishable from a win to whoever holds. - run lock "$RIVAL" acquire - [ "$status" -eq 1 ] - run lock "$THIRD" acquire - [ "$status" -eq 1 ] - [ "$(lease_sha)" = "$held" ] -} - -@test "PRESSURE: the lease passes to exactly one waiter after release, not both" { - THIRD="$BATS_TEST_TMPDIR/third" - git init -q "$THIRD" - git -C "$THIRD" -c user.email=t@t -c user.name=t commit -q --allow-empty -m seed - git -C "$THIRD" remote add origin "$BARE" - git -C "$THIRD" checkout -q -b claude/work - - lock "$MINE" acquire - lock "$MINE" release - - # The queue drains one at a time: the first waiter wins the freed lease, and - # the second is refused by the winner exactly as it was by the original - # holder. A fleet that let both through would put two landers on `main`. - run lock "$RIVAL" acquire - [ "$status" -eq 0 ] - run lock "$THIRD" acquire - [ "$status" -eq 1 ] -} diff --git a/tests/land.bats b/tests/land.bats deleted file mode 100644 index eb75070a6..000000000 --- a/tests/land.bats +++ /dev/null @@ -1,3351 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/land.sh -# land's driver loop and its stopping conditions, exercised through stub `gh`, -# `git` and `mise` so every one of them is reachable without a real PR. -# -# The refusal condition had no test and was dead code for months (CLOUD-235): -# it filtered the PR head's check-runs for a run the bot never attaches there, -# so it always came back empty and the task polled forever the first time a -# refusal actually arrived. A claim nothing exercises is a claim nothing holds. -# -# So the stub models the API honestly rather than conveniently: it routes by -# endpoint, and the `commits//check-runs` endpoint answers EMPTY, because -# that is what GitHub really answers — an `issue_comment`-triggered run attaches -# its check-run to the default-branch tip, never to the PR head. The old filter -# fails these tests; only reading the workflow run's conclusion passes them. The -# stub also pipes real JSON through the real `jq` using the task's own `--jq` -# expression, so the filter is under test and not paraphrased. -# -# CLOUD-238 added the second half: a refusal is no longer where the task ends, -# it is where the next LAP begins. So the discipline here is coverage of every -# way a lap can end — and of lapping itself, asserted by a second full attempt -# rather than by a message about one. -# -# CLOUD-247 made this task the ONLY readier, and on the honest condition: a head -# with no GRADED check-run, not a PR that happens to be a draft. A push made while -# draft leaves a complete `skipped` set that readying afterwards does not replace, -# so no confirming run is ever spent and `ci-wait` polls forever — correctly. -# -# CLOUD-240 added the economies, and they are asserted as SPEND, not as -# messages: a lap whose HEAD already carries a receipt runs no `verify`; a lap -# whose `main` moved never waits out the doomed run; a red run leaves the PR a -# draft, which is the only thing that stops the next push buying another one. -# So the `mise` stub models the receipt rather than a fixed exit status — the -# skip is only real if `verified` answers from what `verify` actually left. - -setup() { - # tests/helpers.bash: `sed_i` / `run_timeout`, standing in for GNU - # tools a stock macOS does not ship (CLOUD-282). - load helpers - REAL_LAND="$BATS_TEST_DIRNAME/../mise-tasks/land.sh" - # CLOUD-434: the program under test launches with bats' fd 3 closed, in ONE - # place rather than at every call site. A backgrounded descendant that - # outlives its reap otherwise holds the TAP stream, and bats-exec-file waits - # on that fd's EOF — so one leaked watcher wedged the whole gate, silently, - # with every test green. With the fd closed a leak costs a stray process, - # never a hung file. - LAND="$BATS_TEST_TMPDIR/land-under-test" - printf '#!/usr/bin/env bash\nexec 3>&- || true\nexec "%s" "$@"\n' "$REAL_LAND" >"$LAND" - chmod +x "$LAND" - STUB="$BATS_TEST_TMPDIR/bin" - mkdir -p "$STUB" - # CLOUD-413: the backoff honours a delay the SERVER states, so a case that - # scripts a real one would pay it in wall clock. The floor and the cap are the - # two knobs that bound it; the cases that assert the delay set the header and - # read what was chosen, rather than sleeping to prove a number. - export LAND_RATE_FLOOR=0 - export LAND_RATE_PAUSE_MAX=0 - PATH="$STUB:$PATH" - # A short interval keeps the polling cases quick; PR is supplied so the - # stub never has to answer the "which PR" lookup. - export PATH PR=150 LAND_INTERVAL=1 LAND_ROOT="$BATS_TEST_TMPDIR" - # What `gh pr list --head --state open` returns. Only the resolution - # cases clear PR and read it; every other case pins the number directly. - printf '[{"number":150}]' >"$BATS_TEST_TMPDIR/prlist" - printf '[{"number":150}]' >"$BATS_TEST_TMPDIR/prlist.all" - : >"$BATS_TEST_TMPDIR/comments" - : >"$BATS_TEST_TMPDIR/gitlog" - : >"$BATS_TEST_TMPDIR/misecalls" - stub_gh - stub_git - stub_mise - pr_state OPEN - workflow_runs runs.last -} - -teardown() { - # CLOUD-390. The slow-CI and slow-verify levers used to answer after a - # guessed 30s, and a guessed margin is self-limiting: whatever a case leaked - # died on its own before the next one started. They now model the wait they - # are named for — one that does not return — so nothing limits a leak but - # this. A regressed reap, or a mutant under test that never kills the race's - # loser, must cost a stray process for one teardown and never a stub that - # outlives the whole gate run holding a fd. - # - # Not hypothetical: main-watch has blocked forever for its own losers since - # CLOUD-246, and a box mid-way through this change was carrying three of - # them at 50 minutes old, each with the land that spawned it still parked in - # `wait`. That is what an unswept never-answering stub looks like, and the - # two levers below just made two more of them possible. - # - # Matched on the per-test stub PATH, not on `mise` or on a task name: bats - # gives every case its own $BATS_TEST_TMPDIR, so this pattern names a file - # only this case can have executed. Under the parallel runner a sibling's - # stub lives at a different tmpdir and cannot match — which is the property - # tests/land-lock.bats gets for free from serial execution and this file, - # stubbing a tool every suite runs, has to buy explicitly. - pkill -f "$BATS_TEST_TMPDIR/bin/mise" 2>/dev/null || true -} - -# A fake `gh` covering the three calls land makes. `--jq` is applied with the -# real jq to a real JSON body, so a filter that stops matching fails here. -# -# `pr view` and the workflow-run list are both SEQUENCED: the Nth call reads -# `state.N`/`runs.N`, falling back to `state.last`/`runs.last`. A case therefore -# scripts how the world changes between polls — and between laps — without a -# background sleep deciding the outcome. -stub_gh() { - cat >"$STUB/gh" </dev/null || echo 0) - n=\$((n + 1)) - echo "\$n" >"$BATS_TEST_TMPDIR/\$1.calls" - cat "$BATS_TEST_TMPDIR/\$1.\$n" 2>/dev/null || cat "$BATS_TEST_TMPDIR/\$1.last" -} -case "\$sub" in - # CLOUD-483: re-running the failed jobs of a run that never reached a verdict. - # Recorded by run id, so a case asserts WHICH run was re-run rather than that - # something was — and \`rc.rerun\` scripts the refusal arm. - "run rerun") - # \`\$all\`, not a positional: the parsing loop above has already shifted every - # argument away by the time this arm runs. - echo "\$all" >>"$BATS_TEST_TMPDIR/reruns" - [ ! -f "$BATS_TEST_TMPDIR/rc.rerun" ] || exit 1 - echo rerun ;; - "pr comment") - echo "\$all" >>"$BATS_TEST_TMPDIR/comments"; echo commented ;; - "pr ready") - echo "\$all" >>"$BATS_TEST_TMPDIR/ready" - echo "ready" >>"$BATS_TEST_TMPDIR/calls" - case "\$all" in - *--undo*) [ ! -f "$BATS_TEST_TMPDIR/rc.undo" ] || exit 1 ;; - *) [ ! -f "$BATS_TEST_TMPDIR/rc.ready" ] || exit 1 ;; - esac - echo readied ;; - # CLOUD-465: the OPEN PR for this branch, and the stub FILTERS because the real - # endpoint does. Without that a case cannot tell \$(--state open) from its - # absence, and the assertion that \$(land) binds the open PR proves nothing — - # measured, when removing the flag left both cases green. Two bodies: what an - # open-only query returns, and what an unfiltered one returns for a branch name - # whose older PRs merged, which is the shape every second landing produces. - "pr list") - case "\$all" in - *"--state open"*) emit "\$(cat "$BATS_TEST_TMPDIR/prlist" 2>/dev/null)" ;; - *) emit "\$(cat "$BATS_TEST_TMPDIR/prlist.all" 2>/dev/null)" ;; - esac ;; - "pr view") - case "\$all" in - *isDraft*) printf '%s' "\$(cat "$BATS_TEST_TMPDIR/isdraft")" ;; - # Not sequenced, for the same reason isDraft is not: the body is a - # property of the PR, not an observation whose Nth answer a case scripts. - # Letting it fall through would consume a \`state.N\` slot and shift every - # transition after it — measured, when CLOUD-323's check was wired in. - *body*) printf '%s' "\$(cat "$BATS_TEST_TMPDIR/prbody" 2>/dev/null)" ;; - *) emit "\$(nth state)" ;; - esac ;; - api*) - case "\$url" in - # The bot's run is still not here — that is the truth the original - # implementation got wrong. What IS here is the head's CI check set, - # which is what decides whether a confirming run was ever spent - # (CLOUD-247), so a case can script it. - *commits/*check-runs*) emit "\$(cat "$BATS_TEST_TMPDIR/checkruns")" ;; - # CLOUD-408: the /fast-forward directive is now POSTed through the API so - # its comment id — the key the verdict filter matches on — comes back. - # The rc.comment sentinel makes the POST fail the way a secondary rate - # limit does. No backticks in here: this heredoc is unquoted, so shfmt - # reads a backtick pair as command substitution and rewrites it. - *issues/*/comments*) - # CLOUD-413: \`land\` asks with \`-i\`, so this answers the way the real - # endpoint does — headers, a blank line, then the body. The rate-limit - # headers are the whole point of the refusal path: the delay is STATED - # there, and asking a second endpoint for it would be one more request - # against the limit that just refused this one. - if [ -f "$BATS_TEST_TMPDIR/rc.comment" ]; then - echo "HTTP/2.0 403" - cat "$BATS_TEST_TMPDIR/limit-headers" 2>/dev/null - echo - echo '{"message":"API rate limit exceeded","status":"403"}' - echo "GraphQL: was submitted too quickly (addComment)" >&2; exit 1 - fi - echo "\$all" >>"$BATS_TEST_TMPDIR/comments" - echo "HTTP/2.0 201" - echo "content-type: application/json" - echo - emit '{"id":7}' ;; - # CLOUD-414: a query that fails writes its error body to STDOUT, which is - # exactly how a 403 used to reach the verdict as a refusal. - *actions/workflows/*) - if [ -f "$BATS_TEST_TMPDIR/rc.runs" ]; then - emit '{"message":"API rate limit exceeded","status":"403"}'; exit 1 - fi - emit "\$(nth runs)" ;; - # CLOUD-369: the runs this lap started, and the cancel that ends them. - # The url is recorded rather than counted, so a case can assert WHICH run - # was cancelled and not merely that something was. - */cancel) echo "\$url" >>"$BATS_TEST_TMPDIR/cancels" ;; - # Read from a file so a case can say the head's runs were CANCELLED - # (CLOUD-470), defaulting to the body every cancel case already relies on - # — so those rows are untouched and this one is additive. - *actions/runs?head_sha*) - if [ -s "$BATS_TEST_TMPDIR/headruns" ]; then emit "\$(cat "$BATS_TEST_TMPDIR/headruns")" - else emit '{"workflow_runs":[{"id":4242,"status":"in_progress"},{"id":99,"status":"completed"}]}'; fi ;; - *) emit '{}' ;; - esac ;; -esac -EOF - chmod +x "$STUB/gh" - rm -f "$BATS_TEST_TMPDIR/runs.calls" "$BATS_TEST_TMPDIR/state.calls" - : >"$BATS_TEST_TMPDIR/ready" - : >"$BATS_TEST_TMPDIR/calls" - printf 'false' >"$BATS_TEST_TMPDIR/isdraft" - head_checks_empty -} - -# `git` is stubbed too, and each step of a lap that can fail reads its exit -# status from a file, so a case names the one failure it is about. -stub_git() { - printf 'feat\n' >"$BATS_TEST_TMPDIR/branch" - local step - for step in fetch linear rebase push delete; do - echo 0 >"$BATS_TEST_TMPDIR/rc.$step" - done - : >"$BATS_TEST_TMPDIR/deletes" - # The remote branch ref is modelled, not assumed: `land` compares it across - # the push to tell "this lap emitted a synchronize event" from "this lap - # moved nothing", and those two take different paths (CLOUD-254). A - # successful push advances it to HEAD, exactly as a real one does. - echo staleremote >"$BATS_TEST_TMPDIR/remote_ref" - # CLOUD-345: whether the branch still EXISTS on the remote, which is a - # different question from what SHA it is at. Present by default — the ordinary - # world — so a rejected push means a concurrent writer unless a case says - # otherwise. Empty output is how `git ls-remote --heads` reports absence. - printf 'deadbeef\trefs/heads/feat\n' >"$BATS_TEST_TMPDIR/lsremote" - echo cafe1234cafe1234 >"$BATS_TEST_TMPDIR/headsha" - echo ma1nma1nma1nma1n >"$BATS_TEST_TMPDIR/mainsha" - echo 5peccccc5peccccc >"$BATS_TEST_TMPDIR/specsha" - echo 0 >"$BATS_TEST_TMPDIR/rc.spec_rebase" - echo 0 >"$BATS_TEST_TMPDIR/rc.reset" - # 0 by default: the bet is LIVE — the branch the lease names still carries the - # base we bet on, which is what "the holder is still landing" looks like. The - # defect CLOUD-495 fixes is reading every pending bet as this one. - echo 0 >"$BATS_TEST_TMPDIR/rc.spec_live" - echo 0 >"$BATS_TEST_TMPDIR/rc.fetch_live" - # 1 by default: the holder's head is NOT already in our history, and the base - # we bet on has NOT landed. Both are the ordinary readings — a lease is held - # by someone whose work is still in flight. - echo 1 >"$BATS_TEST_TMPDIR/rc.spec_ancestor" - # CLOUD-862's two, defaulted so every case that is not about recovery behaves - # exactly as before: 1 = the adopted base has not landed, and 1 = this tree - # is not built on it. With no `specbet` file written, `recover_speculation` - # returns on its first read and neither is consulted at all. - echo 1 >"$BATS_TEST_TMPDIR/rc.spec_landed" - echo 1 >"$BATS_TEST_TMPDIR/rc.spec_ontree" - cat >"$STUB/git" <>"$BATS_TEST_TMPDIR/gitlog" -case "\$*" in - "rev-parse HEAD") cat "$BATS_TEST_TMPDIR/headsha" ;; - # Read from a file rather than echoed, because CLOUD-369 made "did main move - # while this lap waited for the lease" a real question land asks twice in one - # lap. A constant could not express the one answer that matters. - "rev-parse origin/main") cat "$BATS_TEST_TMPDIR/mainsha" ;; - "rev-parse refs/batten-spec/base") cat "$BATS_TEST_TMPDIR/specsha" ;; - "rev-parse origin/"*) cat "$BATS_TEST_TMPDIR/remote_ref" ;; - "rev-parse --abbrev-ref HEAD") cat "$BATS_TEST_TMPDIR/branch" ;; - "rev-parse --short"*) echo abc1234 ;; - "rev-parse --show-toplevel") echo "$BATS_TEST_TMPDIR" ;; - # The liveness fetch has its own rc (CLOUD-495): rc.fetch is the fetch of - # \`main\`, whose failure is a die, and a case about an unreadable LEASE must not - # be forced to stop the whole lap to say so. - "fetch"*refs/batten-spec/live) exit "\$(cat "$BATS_TEST_TMPDIR/rc.fetch_live")" ;; - "fetch"*) exit "\$(cat "$BATS_TEST_TMPDIR/rc.fetch")" ;; - # TWO DIFFERENT ANCESTRY QUESTIONS, and one file could not answer both. The - # lap asks "am I a descendant of main"; CLOUD-369's speculation asks "is the - # holder's head already in my history" and "did the base I bet on land". A - # single rc made the second answer yes by accident, and the speculation - # silently returned early — which the suite then reported as the mechanism - # never having run. - "merge-base --is-ancestor origin/main HEAD") exit "\$(cat "$BATS_TEST_TMPDIR/rc.linear")" ;; - # A THIRD ancestry question (CLOUD-495): "is the base I bet on still carried by - # the branch the lease names NOW". The comment above is the reason this needs - # its own file rather than another caller of rc.spec_ancestor — an rc that - # answered two questions is what made the speculation silently no-op once. - "merge-base --is-ancestor"*refs/batten-spec/live) exit "\$(cat "$BATS_TEST_TMPDIR/rc.spec_live")" ;; - # A FOURTH and FIFTH (CLOUD-862), and they exist for the reason the comment - # above gives twice already: recovering a bet left by a dead run asks "did the - # adopted base land" and "is this tree actually built on it", and one rc - # answering both is how a recovery would silently decide it had nothing to do. - # Ordered before the catch-all and after the exact linearity arm, since the - # adopted sha is a wildcard on the left of each. - "merge-base --is-ancestor"*origin/main) exit "\$(cat "$BATS_TEST_TMPDIR/rc.spec_landed")" ;; - "merge-base --is-ancestor"*HEAD) exit "\$(cat "$BATS_TEST_TMPDIR/rc.spec_ontree")" ;; - "merge-base"*) exit "\$(cat "$BATS_TEST_TMPDIR/rc.spec_ancestor")" ;; - "rev-parse --verify -q refs/batten-spec/base") cat "$BATS_TEST_TMPDIR/specbet" 2>/dev/null || exit 1 ;; - "update-ref -d refs/batten-spec/base") rm -f "$BATS_TEST_TMPDIR/specbet"; exit 0 ;; - "ls-remote --heads origin "*) cat "$BATS_TEST_TMPDIR/lsremote" ;; - "rebase --abort") exit 0 ;; - # The SPECULATIVE rebase is a different event from the lap's rebase onto main - # (CLOUD-369) and fails differently: a conflict here is information about a - # base that may never land, so it falls back rather than stopping. - "rebase origin/main") exit "\$(cat "$BATS_TEST_TMPDIR/rc.rebase")" ;; - "rebase"*) exit "\$(cat "$BATS_TEST_TMPDIR/rc.spec_rebase")" ;; - "reset -q --hard"*) exit "\$(cat "$BATS_TEST_TMPDIR/rc.reset")" ;; - "push -q origin --delete "*) - # The post-merge cleanup (CLOUD-349). Recorded separately from the landing - # push: it is not part of the lap, and folding it into \`calls\` would move - # the ready/push ORDER the CLOUD-254 cases assert on. - echo "\$*" >>"$BATS_TEST_TMPDIR/deletes" - exit "\$(cat "$BATS_TEST_TMPDIR/rc.delete")" ;; - "push"*) - echo "push" >>"$BATS_TEST_TMPDIR/calls" - rc=\$(cat "$BATS_TEST_TMPDIR/rc.push") - [ "\$rc" != 0 ] || echo cafe1234cafe1234 >"$BATS_TEST_TMPDIR/remote_ref" - exit "\$rc" ;; - *) exit 0 ;; -esac -EOF - chmod +x "$STUB/git" -} - -# `mise` records the tasks a lap runs. It is not a fixed exit status: `verify` -# WRITES the receipt and `verified` reads it, so "an already-proven HEAD is not -# re-proven" is asserted against the real mechanism rather than against a stub -# that was told the answer. `main-watch` blocks forever by default, because a -# quiet `main` losing the race is the normal case; a case that wants it to win -# says so, and says on which lap. -stub_mise() { - cat >"$STUB/mise" <>"$BATS_TEST_TMPDIR/misecalls" -# CLOUD-774: filed-here-check is fed the PR body so it can exempt a row this PR -# closes. Recorded here so a case can assert the body actually arrives -- a gate -# wired to read stdin that nobody pipes to is inert, and it shipped inert once. -# NO BACKTICKS IN THIS HEREDOC: it is unquoted, so a backticked word is command -# substitution and the comment would try to RUN the task it names. -# CLOUD-995: a real gate can exit BEFORE it reads stdin. filed-here-check does -# exactly that under its bypass and on a detached HEAD, and closing-key-check on -# every could-not-read path. Modelled here, ahead of every read, so a case can -# prove the EPIPE it leaves the producer with is not read as a refusal. Only a -# case that asks for it is affected, so what CLOUD-774 pins below is untouched. -if [ -f "$BATS_TEST_TMPDIR/nodrain.\$2" ]; then exit 0; fi -if [ "\$2" = filed-here-check ]; then cat >"$BATS_TEST_TMPDIR/filedhere.stdin"; fi -rc="$BATS_TEST_TMPDIR/rc.mise.\$2" -if [ -f "\$rc" ]; then - code=\$(cat "\$rc") - # A failure can be scripted for ONE call rather than for the whole run, which - # is what a lap-and-recover case needs: the second lap must see the task pass. - [ ! -f "\$rc.once" ] || rm -f "\$rc" "\$rc.once" - # CLOUD-407's lever: the failing gate's OWN words, before the generic line. A - # real refusal names \`path:line\`, and the whole defect was that those lines - # existed and never reached the operator — so a case has to be able to write - # them and then assert they came back out. - [ ! -f "\$rc.says" ] || cat "\$rc.says" >&2 - echo "::error:: \$2 failed" >&2 - exit "\$code" -fi -case "\$2" in - verify) - # CLOUD-434's detach lever lives HERE, not in the raced watcher: a group - # kill races the watcher's first write, and CI measurably won that race - # (not ok 485) while a fast local box lost it — the CLOUD-426 class, - # rebuilt by accident. The verify stub runs to completion in the fd case, - # so the spawn is kill-race-free; the fd property is position-independent, - # since any descendant of the launcher demonstrates it. setsid --fork is - # load-bearing too: bare setsid execs IN-PROCESS when the caller is no - # group leader, which kept the "detached" child killable. - if [ -f "$BATS_TEST_TMPDIR/detach" ] && [ ! -f "$BATS_TEST_TMPDIR/detached.pid" ]; then - setsid --fork bash -c "echo \\\$\\\$ >'$BATS_TEST_TMPDIR/detached.pid'; sleep 30" >/dev/null 2>&1 - # CLOUD-457. setsid returns as soon as the fork is made; it promises - # nothing about the child having been SCHEDULED, and until the child runs - # its first command the pid file does not exist. The case downstream used - # to allow three seconds for that from the test body and read a loaded - # box's scheduling delay as "the descendant was never detached" — a red - # row for a launcher that did exactly what it claims. - # - # The waiting belongs HERE, next to the fork, and not in the body: since - # CLOUD-434 moved the spawn into this stub, run "\$LAND" has already - # returned by the time the body runs, so a body-side loop conditioned on - # "the spawning land has exited" is true on entry every single time and - # waits for nothing. An attempt cap, never a clock: 200 polls of 0.05s is - # a bound on tries, and if the child still has not published, the stub - # SAYS so through a marker rather than leaving the body to infer it from - # an empty file it cannot tell from a launcher that never spawned. - tries=0 - while [ ! -s "$BATS_TEST_TMPDIR/detached.pid" ] && [ "\$tries" -lt 200 ]; do - sleep 0.05 - tries=\$((tries + 1)) - done - [ -s "$BATS_TEST_TMPDIR/detached.pid" ] || : >"$BATS_TEST_TMPDIR/detach.unscheduled" - fi - # CLOUD-423's lever, consumed BEFORE the wait: a verify killed mid-gate - # must not slow the lap that retries it, and the kill landing during the - # wait is exactly the abort under test — the receipt line is never - # reached, so the abort-leaves-no-receipt property is observed for real. - # - # CLOUD-390: the wait does not end on its own. It used to be a 30s sleep — - # a guess at how long a real gate outlives its race — and a self-ending - # stub is a way for these rows to reach green without land having killed - # anything, which is the one thing they are about. Same reasoning as the - # ci-wait lever above; the two are the same defect written twice. - if [ -f "$BATS_TEST_TMPDIR/verify.slow" ]; then - rm -f "$BATS_TEST_TMPDIR/verify.slow" - while :; do sleep 1; done - fi - : >"$BATS_TEST_TMPDIR/receipt"; exit 0 ;; - # CLOUD-483: the classification of a red run, owned by \`nonverdict-scan\` and - # only consulted here. Records come from a file so a case scripts the answer; - # the DEFAULT IS EMPTY, which is "could not look" and keeps today's red - # message — so every pre-existing red-path row is untouched by this arm. - nonverdict-scan) - cat "$BATS_TEST_TMPDIR/nonverdict" 2>/dev/null || true - exit 0 ;; - verified) - if [ ! -f "$BATS_TEST_TMPDIR/receipt" ]; then - # 'verified' is a gate: a missing receipt is a failure and it SAYS so. - # Modelling that is what makes the quiet-success property meaningful. - # (Quoted plainly on purpose — this heredoc is unquoted, so a dollar- - # paren here is a live substitution that ran a nonexistent command at - # setup time and salted every failure dump with its stderr.) - echo "::error:: HEAD is NOT verified — no verify receipt for this commit." >&2 - exit 1 - fi - exit 0 ;; - # CLOUD-323's stop. Passes by default; a case that wants the refusal - # writes rc.mise.deferral-check, the same lever every other task uses. - deferral-check) exit 0 ;; - # CLOUD-192's stop, and the same lever. Passes by default so the cases that - # care about anything else do not each have to write a closing body. - closing-key-check) exit 0 ;; - ci-wait) - # Every watcher records itself, so the trap-reap case can ask "who did a - # lap spawn" and assert each one is gone (CLOUD-434's trap gap). - echo "\$\$" >>"$BATS_TEST_TMPDIR/watch.pids" - # CLOUD-390: "CI is still running" is a wait that does not return, not one - # that returns after a guessed 30s, and this is the shape main-watch - # already uses for its own loser further down. The guess was not merely - # imprecise, it was a SECOND way for a reap row to go green: a stub that - # terminates itself satisfies "the watcher is gone" without anything having - # reaped it, so those rows held only while 30 stayed larger than the settle - # windows they poll with (4s below). Two guessed numbers that must stay - # ordered, with nothing checking the order. Now the only exit is land's - # kill, which is the claim the rows are about. - # - # Consumed on the first slow call, exactly as verify.slow is. The lever - # says "lose THIS lap", and a landing that laps must reach a lap whose CI - # does answer or it never merges: left standing, it wedged lap 2 of the two - # race rows forever. Those rows used to reach green by sitting out the full - # sleep on lap 2 — 31.5s for "main moving mid-wait", measured before this - # change and 1.5s after it. - if [ -f "$BATS_TEST_TMPDIR/ci-wait.slow" ]; then - rm -f "$BATS_TEST_TMPDIR/ci-wait.slow" - while :; do sleep 1; done - fi - exit 0 ;; - land-lock) - # Per-verb levers (CLOUD-369). The whole-task \`rc.mise.land-lock\` file - # still works through the generic check above; this is what a case needs to - # say "acquire loses but reserve wins", which is the successor's whole path. - rcv="$BATS_TEST_TMPDIR/rc.mise.land-lock.\$3" - # MAIN MOVES DURING A WAIT, AND THE LAP IT MOVES ON MATTERS. A bet is placed - # during the FIRST wait, so moving trunk before that one would make the bet - # read as already-decided and no speculation would ever be settled. The - # lever therefore fires from the second acquire on: bet first, then the - # world moves under it, which is the sequence the unwind exists for. - if [ "\$3" = acquire ]; then - n=\$(cat "$BATS_TEST_TMPDIR/acquire.calls" 2>/dev/null || echo 0) - n=\$((n + 1)); echo "\$n" >"$BATS_TEST_TMPDIR/acquire.calls" - after=\$(cat "$BATS_TEST_TMPDIR/main_moves_after" 2>/dev/null || echo 1) - if [ -f "$BATS_TEST_TMPDIR/main_moves_in_wait" ] && [ "\$n" -ge "\$after" ]; then - # A DISTINCT sha per acquire, not one fixed value. "main is moving - # faster than a lap takes" is the condition under test, and a lever that - # moved trunk once left the next lap's re-confirmation passing — so the - # lap proceeded to a poll that, with no terminal PR state, never - # returned. Measured as a hung case that leaked a watcher per run. - echo "\$(cat "$BATS_TEST_TMPDIR/main_moves_in_wait")\$n" >"$BATS_TEST_TMPDIR/mainsha" - fi - # THE HOLDER GOES AWAY WHILE MAIN STAYS PUT (CLOUD-495). The lease is a - # ref, so abandonment is observable exactly as a change to what \`peek - # branch\` answers — the lease freed (empty), or taken by somebody else. - # Same shape as the main-moves lever, and fired from the same lap on, so - # the bet is placed before the world moves under it. - if [ -f "$BATS_TEST_TMPDIR/lease_abandons_after" ] && - [ "\$n" -ge "\$(cat "$BATS_TEST_TMPDIR/lease_abandons_after")" ]; then - cat "$BATS_TEST_TMPDIR/lease_abandons_to" >"$BATS_TEST_TMPDIR/lease.branch" - fi - fi - [ ! -f "\$rcv" ] || exit "\$(cat "\$rcv")" - if [ "\$3" = peek ]; then - f="$BATS_TEST_TMPDIR/lease.\$4" - [ ! -f "\$f" ] || cat "\$f" - fi - [ "\$3" != held ] || exit 0 - exit 0 ;; - main-watch) - echo "\$\$" >>"$BATS_TEST_TMPDIR/watch.pids" - # CLOUD-434's stubborn lever. A stubborn watcher ignores the TERM, so only - # the escalated reap can end it. (The detach lever spawns from the verify - # stub instead — see there for the measured kill-race that moved it.) - if [ -f "$BATS_TEST_TMPDIR/stubborn" ]; then - trap '' TERM - echo "\$\$" >>"$BATS_TEST_TMPDIR/stubborn.pids" - fi - # THE VERIFY RACE IS SYNCHRONISED, NOT HOPED FOR (CLOUD-426's class, in the - # case CLOUD-423 added). Whichever way this watcher is about to answer, it - # answers only once the verify it races has registered itself — otherwise - # \`land\` group-kills a verify child that has not yet appended its call, the - # lap that follows counts one verify instead of two, and the case fails on a - # loaded box while passing on an idle one. Measured: it went red inside a - # full parallel gate and passed standalone. Bounded, and it waits for a real - # event rather than a guessed interval. - if [ "\${LAND_RACE:-}" = verify ]; then - for _ in \$(seq 200); do - grep -q '^run verify\$' "$BATS_TEST_TMPDIR/misecalls" && break - sleep 0.05 - done - fi - # CLOUD-423's no-verdict lever: the verify-race watcher dying without an - # answer, once, so the lap that follows re-proves instead of guessing. - if [ "\${LAND_RACE:-}" = verify ] && [ -f "$BATS_TEST_TMPDIR/vwatch.fail" ]; then - rm -f "$BATS_TEST_TMPDIR/vwatch.fail" - exit 1 - fi - # A lap starts two watchers, and they are told apart by WHEN: the one - # racing the fast-forward answer is by construction the one started after - # this lap's comment. Counting them separately is not cosmetic — a single - # shared counter is read stale by the second watcher and neither ever wins. - # The role is TOLD to us by land (LAND_RACE), never deduced from whether a - # comment exists yet: that file is mutated by the same lap, so a slow fork - # read it after the comment landed and took the other race's counter. - role=\${LAND_RACE:-ci} - n=\$(cat "$BATS_TEST_TMPDIR/mw.\$role.calls" 2>/dev/null || echo 0) - n=\$((n + 1)); echo "\$n" >"$BATS_TEST_TMPDIR/mw.\$role.calls" - wins=\$(cat "$BATS_TEST_TMPDIR/mw.\$role.wins" 2>/dev/null || echo 0) - [ "\$n" = "\$wins" ] || { while :; do sleep 1; done; } - exit 0 ;; -esac -exit 0 -EOF - chmod +x "$STUB/mise" - rm -f "$BATS_TEST_TMPDIR/receipt" "$BATS_TEST_TMPDIR/ci-wait.slow" \ - "$BATS_TEST_TMPDIR"/mw.* -} - -fails() { echo 1 >"$BATS_TEST_TMPDIR/rc.$1"; } -already_verified() { : >"$BATS_TEST_TMPDIR/receipt"; } -is_draft() { printf 'true' >"$BATS_TEST_TMPDIR/isdraft"; } -# The head's CI check set. "Graded" is `ci-wait`'s list of real conclusions; -# an all-`skipped` set is the draft-era one, which is not an answer. -head_checks() { printf '%s' "$1" >"$BATS_TEST_TMPDIR/checkruns"; } -head_checks_empty() { head_checks '{"check_runs":[]}'; } -head_is_graded() { head_checks '{"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}'; } -head_is_all_skipped() { head_checks '{"check_runs":[{"name":"ci","status":"completed","conclusion":"skipped"}]}'; } -# The supersession set (CLOUD-363): the landing SHA's runs were killed by a -# concurrent event, so nothing on this head judged anything. -head_is_all_cancelled() { head_checks '{"check_runs":[{"name":"ci","status":"completed","conclusion":"cancelled"}]}'; } -ci_is_slow() { : >"$BATS_TEST_TMPDIR/ci-wait.slow"; } -# A lap makes TWO `main-watch` calls: one racing `ci-wait`, one racing the -# fast-forward answer (CLOUD-246). Each is counted under its own role, so a -# case says which of the two moves `main` and on which lap. -main_moves_on_lap() { echo "$1" >"$BATS_TEST_TMPDIR/mw.ci.wins"; } -main_moves_during_answer_wait() { echo "$1" >"$BATS_TEST_TMPDIR/mw.answer.wins"; } -# CLOUD-434's levers — see the stub's main-watch case. -watcher_is_stubborn() { : >"$BATS_TEST_TMPDIR/stubborn"; } -watcher_detaches() { : >"$BATS_TEST_TMPDIR/detach"; } -# CLOUD-423's levers: a verify slow enough to lose its race, a main that moves -# while it runs, and a verify-race watcher that dies without an answer. -verify_is_slow() { : >"$BATS_TEST_TMPDIR/verify.slow"; } -main_moves_during_verify() { echo "$1" >"$BATS_TEST_TMPDIR/mw.verify.wins"; } -verify_watch_fails_once() { : >"$BATS_TEST_TMPDIR/vwatch.fail"; } -# Alive means RUNNING, not merely unreaped: a SIGKILLed process lingers as a -# zombie until its (dead) parent's reaper gets to it, `kill -0` answers true -# for zombies, and under a loaded gate the reap can outlast any fixed settle — -# measured as a flake of the escalation case inside a full parallel verify. -# The state field is read past the comm's closing paren, since comm may -# legally contain the space that would break a naive field split. -alive_not_zombie() { - local st - st=$(sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f1) || return 1 - [ -n "$st" ] && [ "$st" != "Z" ] -} -ready_calls() { cat "$BATS_TEST_TMPDIR/ready"; } -# CLOUD-470: the head's own runs read as cancelled, which is the fingerprint of a -# run the lease precondition declined rather than one that failed. -head_runs_cancelled() { - printf '{"workflow_runs":[{"id":4242,"status":"completed","conclusion":"cancelled"}]}' \ - >"$BATS_TEST_TMPDIR/headruns" -} - -# CLOUD-470: the lease says STOP for this branch. `land-lock authorises` is the -# ONE authority on "was this run declined" — the same verb the runner's own -# precondition consults — so the declination is scripted through its exit code -# (3 = stop) rather than through a second reading of the run list. Swapping the -# raw `conclusion == "cancelled"` read for this is the whole change; the rows -# below assert the same behaviour they always did. -lease_declines() { echo 3 >"$BATS_TEST_TMPDIR/rc.mise.land-lock.authorises"; } -cancels() { cat "$BATS_TEST_TMPDIR/cancels" 2>/dev/null || true; } -# The push leaves the remote ref where it was, so no `synchronize` event fires -# and nothing starts a run — the one shape that still needs the `--undo`. -push_moves_nothing() { echo cafe1234cafe1234 >"$BATS_TEST_TMPDIR/remote_ref"; } -# The interleaved record of the two calls whose ORDER is the defect. -call_order() { tr '\n' ' ' <"$BATS_TEST_TMPDIR/calls"; } -undo_fails() { : >"$BATS_TEST_TMPDIR/rc.undo"; } -ready_fails() { : >"$BATS_TEST_TMPDIR/rc.ready"; } -# The PR body `deferral-check` reads. Empty by default, which is why every other -# case skips the check entirely rather than having to opt out of it. -pr_body() { printf '%s' "$1" >"$BATS_TEST_TMPDIR/prbody"; } -task_fails() { echo 1 >"$BATS_TEST_TMPDIR/rc.mise.$1"; } -# CLOUD-995. A task that succeeds WITHOUT reading the stdin it was handed, which -# is what a gate's own early exit looks like from the caller's side. -task_exits_undrained() { : >"$BATS_TEST_TMPDIR/nodrain.$1"; } -# A task that fails with a specific code, for one call only. `verify` answering -# 2 is "main moved while I ran" (CLOUD-318), and a lap that recovers from it is -# only a real lap if the next call succeeds. -task_fails_once_with() { - echo "$2" >"$BATS_TEST_TMPDIR/rc.mise.$1" - : >"$BATS_TEST_TMPDIR/rc.mise.$1.once" -} -verify_calls() { grep -c '^run verify$' "$BATS_TEST_TMPDIR/misecalls" || true; } -not_linear() { echo 1 >"$BATS_TEST_TMPDIR/rc.linear"; } -comments() { wc -l <"$BATS_TEST_TMPDIR/comments" | tr -d ' '; } - -# The Nth `pr view` answers with the Nth argument; the last one sticks. -pr_state() { - local n=0 s - for s in "$@"; do - n=$((n + 1)) - printf '{"state":"%s"}' "$s" >"$BATS_TEST_TMPDIR/state.$n" - done - printf '{"state":"%s"}' "${!#}" >"$BATS_TEST_TMPDIR/state.last" -} - -# A workflow-run list. With no conclusion given the list is empty — the bot has -# not concluded yet. -workflow_runs() { - local file="$1" conclusion="${2:-}" created="${3:-2099-01-01T00:00:00Z}" - # The 4th argument is `display_title`, defaulting to the KEY this lap mints - # (CLOUD-409). Without a default every existing refusal row would silently - # stop matching the new filter and the suite would go red for the wrong - # reason; with it, a row that wants a STRANGER's refusal says so explicitly. - local title="${4:-fast-forward #150 @7}" - if [ -z "$conclusion" ]; then - printf '{"workflow_runs":[]}' >"$BATS_TEST_TMPDIR/$file" - else - printf '{"workflow_runs":[{"created_at":"%s","status":"completed","conclusion":"%s","display_title":"%s"}]}' \ - "$created" "$conclusion" "$title" >"$BATS_TEST_TMPDIR/$file" - fi -} - -# A refusal belonging to some other PR's lap, inside our own SINCE window. -sibling_refuses() { workflow_runs "$1" failure 2099-01-01T00:00:00Z "fast-forward #999 @4242"; } - -# A FULL page — 100 strangers' refusals, none of them ours. Fullness is the -# signal, not the content: a short page means the `created>=SINCE` window ended, -# and a full one means it did not, so this is the only fixture that makes a -# second page be fetched at all (CLOUD-456's depth half). -full_page_of_strangers() { - jq -nc '{workflow_runs: [range(100) | { - created_at: "2099-01-01T00:00:00Z", status: "completed", conclusion: "failure", - display_title: ("fast-forward #999 @" + (. | tostring))}]}' >"$BATS_TEST_TMPDIR/$1" -} -comment_fails() { : >"$BATS_TEST_TMPDIR/rc.comment"; } - -# CLOUD-413: what the refused response STATES about when to come back. Written as -# real header lines so the parser is exercised on the shape it will actually meet, -# case included — GitHub sends `Retry-After`, not `retry-after`. -states_retry_after() { printf 'Retry-After: %s\n' "$1" >"$BATS_TEST_TMPDIR/limit-headers"; } -states_ratelimit_reset() { - printf 'X-RateLimit-Remaining: 0\nX-RateLimit-Reset: %s\n' \ - "$(($(date -u +%s) + $1))" >"$BATS_TEST_TMPDIR/limit-headers" -} -states_no_limit_headers() { : >"$BATS_TEST_TMPDIR/limit-headers"; } -runs_query_403() { : >"$BATS_TEST_TMPDIR/rc.runs"; } - -@test "a refusal starts the next lap instead of ending the run" { - # THE REGRESSION, and then its second half. Note the check-runs endpoint is - # empty throughout, as it is in reality — so the pre-CLOUD-235 filter cannot - # pass this test, it can only hang. And a task that merely EXITED here would - # leave one comment, not two: lapping is asserted by a second full attempt, - # never by a message promising one. - pr_state OPEN MERGED - workflow_runs runs.1 failure - workflow_runs runs.last - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"the fast-forward bot refused (failure)"* ]] - [[ "$output" == *"Lapping: rebase, re-verify, retry"* ]] - [ "$(comments)" -eq 2 ] - # And lap 2 re-proves nothing: the rebase was a no-op, so lap 1's receipt - # still keys to this exact HEAD. Re-running `verify` there would be work - # with a known answer (CLOUD-240). - [ "$(grep -c '^run verify$' "$BATS_TEST_TMPDIR/misecalls")" -eq 1 ] - [[ "$output" == *"already carries a verify receipt"* ]] -} - -@test "a cancelled run is the bot failing to DECIDE, not a refusal" { - # REWRITTEN, and the old row pinned exactly the behaviour CLOUD-413 says is - # wrong. A cancelled run judged nothing; calling it a refusal narrated "main - # moved under the branch" and bought a full lap — measured across 24 laps of - # one landing where several "refusals" were the bot's own rate limit and - # `main` had not moved at all. - pr_state OPEN MERGED - workflow_runs runs.1 cancelled - workflow_runs runs.last - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"no readable answer"* ]] - [[ "$output" != *"refused (cancelled)"* ]] - [[ "$output" != *"main moved under"* ]] -} - -@test "a SIBLING PR's refusal is not this lap's verdict (CLOUD-409)" { - # At the measured cadence the SINCE window held 243 strangers' refusals, any - # of which this lap would have read as its own — which is how "the bot is - # silent or slow" was inferred while the bot answered every attempt inside - # 23 seconds. The run below is keyed to PR #999; ours is #150. - pr_state OPEN OPEN MERGED - sibling_refuses runs.1 - workflow_runs runs.last - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" != *"the fast-forward bot refused"* ]] - [ "$(comments)" -eq 1 ] -} - -@test "a keyed refusal IS still read — the filter did not stop reading" { - # The negative control for the row above. A fix that keyed too tightly would - # never see a refusal again and would reintroduce CLOUD-235's hang. - pr_state OPEN MERGED - workflow_runs runs.1 failure - workflow_runs runs.last - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"the fast-forward bot refused (failure)"* ]] - [ "$(comments)" -eq 2 ] -} - -@test "this lap's own run is read even when it fell off the first page (CLOUD-456)" { - # THE DEPTH HALF, and it is a separate defect from the key. A keyed filter - # over a window that has already rolled past this lap's run returns empty, - # which the poll reads as "not answered yet" — byte-identical to a silent - # bot, and that is the reading CLOUD-399 recorded as "the bot is slow" while - # the bot was answering inside 23 seconds. At the measured 13 runs/minute one - # page of 100 is ~7.7 minutes and a lap routinely outlives it. Page one here - # is 100 strangers; ours is on page two. - pr_state OPEN MERGED - full_page_of_strangers runs.1 - workflow_runs runs.2 failure - workflow_runs runs.last - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"the fast-forward bot refused (failure)"* ]] - [ "$(comments)" -eq 2 ] -} - -@test "paging stops at the short page instead of walking history (CLOUD-456)" { - # The negative control for the row above, and the termination argument - # itself: the walk ends because `created>=SINCE` bounds the SET, so a short - # page IS the end of the window. A reader that kept paging would find an - # older lap's own run and re-read it as this lap's verdict — the livelock - # the SINCE stamp exists to prevent. Nothing keyed to us exists on either - # page, so this lap gets no verdict and does not lap. - pr_state OPEN OPEN MERGED - full_page_of_strangers runs.1 - sibling_refuses runs.2 - workflow_runs runs.last - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" != *"the fast-forward bot refused"* ]] - [ "$(comments)" -eq 1 ] -} - -@test "a /fast-forward the API refused is never reported as posted (CLOUD-408)" { - # Measured on PR #330: GitHub answered the secondary rate limit, `gh` exited - # non-zero, nothing read it, and land printed "commented /fast-forward … - # waiting for the merge" over a comment that did not exist — then blocked - # waiting for a merge nothing had been asked to perform. - comment_fails - pr_state OPEN - LAND_ANSWER_MAX_UNKNOWNS=1 run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" != *"commented /fast-forward on #150"* ]] - [[ "$output" == *"could not ask #150 to fast-forward"* ]] - [ "$(comments)" -eq 0 ] -} - -@test "CLOUD-413: a refused comment waits the retry-after the response STATES" { - # Measured on PR #323: 24 laps across three invocations, never merging, and not - # one lap failed for any of the three reasons `land` stops on. Several refusals - # were a 403 rate limit that `land` could not tell from "main moved" — so its - # response to being rate-limited was to generate more of exactly the request - # that was rate-limited, each retry costing a verify, a CI run and a comment. - # - # Lapping with NO delay is not backoff; it is the same request again. - comment_fails - states_retry_after 7 - pr_state OPEN - LAND_ANSWER_MAX_UNKNOWNS=1 run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"retry-after"* ]] - [[ "$output" == *"7s"* ]] - # And it never enters the answer poll: polling for the answer to a question - # nobody received is the CLOUD-235 hang with a different cause. - [ "$(comments)" -eq 0 ] - # The lap was refunded — a refused comment spent no CI, so it must not be - # charged to the budget that exists to catch a moving `main`. - [[ "$output" != *"moving faster than a lap takes"* ]] -} - -@test "CLOUD-413: with no retry-after it waits until x-ratelimit-reset" { - comment_fails - states_ratelimit_reset 30 - pr_state OPEN - LAND_ANSWER_MAX_UNKNOWNS=1 run "$LAND" - [ "$status" -eq 1 ] - # The RESET TIME, which the code had all along and used to throw away in - # favour of telling the human to go run `gh api rate_limit` for it. - [[ "$output" == *"rate limit resets at"* ]] - [[ "$output" != *"gh api rate_limit"* ]] -} - -@test "CLOUD-413: a response stating no limit headers still waits a floor" { - # Some delay beats none. The floor is the only guessed number here, and it is - # reached only when the server states nothing. - comment_fails - states_no_limit_headers - pr_state OPEN - LAND_ANSWER_MAX_UNKNOWNS=1 run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"stated no limit headers"* ]] -} - -@test "CLOUD-413: exhausting the budget names the LIMIT, not a moving main" { - # The second finding from the same run. Over those 24 laps the exhaustion - # message — "main is moving faster than a lap takes" — was wrong twice over: - # 7 of 8 laps in one invocation reached green CI, and several refusals were the - # rate limit rather than `main` at all. A diagnosis that names the wrong cause - # sends the reader to look in the wrong place. - comment_fails - states_retry_after 3 - pr_state OPEN - LAND_ANSWER_MAX_UNKNOWNS=1 run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"no readable answer"* ]] - [[ "$output" == *"retry-after"* ]] - [[ "$output" != *"moving faster than a lap takes"* ]] -} - -@test "a 403 from the runs query is not an answer (CLOUD-414)" { - # `gh` writes the error body to STDOUT, so the unfiltered body reached the - # verdict where the test was `[ -z ]` — any non-empty string was a refusal, - # and a transport error was indistinguishable from one. The bound is a COUNT - # of unreadable answers, never a clock on the poll. - runs_query_403 - pr_state OPEN - LAND_ANSWER_MAX_UNKNOWNS=1 run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" != *"refused"* ]] - [[ "$output" == *"no readable answer"* ]] - # It used to end by telling the reader to go run `gh api rate_limit` — asking - # a human to fetch a number the code had already been handed. CLOUD-413 makes - # the message carry what the response stated; on this path the runs query was - # refused rather than the comment, so no limit headers were read and the note - # says so rather than inventing a reset time. - [[ "$output" != *"gh api rate_limit"* ]] - [[ "$output" == *"mise run land"* ]] -} - -@test "an unreadable answer re-asks without buying a CI run" { - # The whole reason an unknown re-asks rather than stopping: on an unmoved - # `main` the lap is free — the verify receipt still keys to this HEAD, the - # head already graded so neither ready fires, and the push moves nothing. - # The head is already GRADED, which is the precondition the property rests - # on and which the default fixture does not create: an empty check-runs - # reading makes `graded_runs` answer 0, and 0 is the branch that fires the - # ready. Without this the row would be asserting the free-lap claim against - # a fixture that cannot exhibit it. - head_checks '{"check_runs":[{"status":"completed","conclusion":"success","name":"ci","started_at":"2026-01-01T00:00:00Z","id":1}]}' - runs_query_403 - pr_state OPEN - LAND_ANSWER_MAX_UNKNOWNS=2 run "$LAND" - [ "$status" -eq 1 ] - # Nothing is bought across either pass: the head already carries a graded - # run so neither the ready nor the `--undo` re-fire can fire, and one verify - # because the receipt still keys to this unchanged HEAD. - [ "$(grep -c '^ready$' "$BATS_TEST_TMPDIR/calls")" -eq 0 ] - [ "$(grep -c '^run verify$' "$BATS_TEST_TMPDIR/misecalls")" -eq 1 ] -} - -@test "the fast-forward verdict is KEYED, not merely windowed" { - # The structural sensor, in the shape of the no-wall-clock row below: the - # filter is only sound while the workflow keeps minting the key, and nothing - # else in the tree couples the two files. - run grep -c 'per_page=20' "$REAL_LAND" - [ "$output" -eq 0 ] - run grep -c 'display_title' "$REAL_LAND" - [ "$output" -ge 1 ] - run grep -c '^run-name:' "$BATS_TEST_DIRNAME/../.github/workflows/fast-forward.yml" - [ "$output" -eq 1 ] - # AND THE MINTED KEY MUST CARRY BOTH IDS. A `run-name:` line that survives - # the grep above while its value is truncated to the bare workflow name is - # exactly how the filter stayed dead for a day: the unquoted `#` opened a - # YAML comment and ate both interpolations, and every reading — a passing - # run and a broken filter — was the same bytes (CLOUD-507). - run grep -cE '^run-name:.*github\.event\.issue\.number.*github\.event\.comment\.id' \ - "$BATS_TEST_DIRNAME/../.github/workflows/fast-forward.yml" - [ "$output" -eq 1 ] -} - -@test "a lap rebases onto the main that moved, then re-verifies the new SHA" { - # A rebase mints a new SHA, so the previous lap's green is a receipt for a - # commit that no longer exists — re-running verify is the loop, not waste. - not_linear - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"rebasing onto"* ]] - grep -q '^rebase origin/main$' "$BATS_TEST_TMPDIR/gitlog" - grep -q '^run verify$' "$BATS_TEST_TMPDIR/misecalls" -} - -@test "a conflicting rebase is the one stop, and it aborts what it started" { - # The single step the loop cannot do unattended (AGENTS.md, "When you SHOULD - # still stop"). Lapping often is what keeps it to one small increment — and - # leaving a rebase in progress would break the next command run here. - not_linear - fails rebase - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"conflicts"* ]] - grep -q '^rebase --abort$' "$BATS_TEST_TMPDIR/gitlog" - [ "$(comments)" -eq 0 ] -} - -@test "a failing verify stops before CI is ever asked" { - task_fails verify - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"verify failed"* ]] - [ "$(comments)" -eq 0 ] - # The count is the load-bearing half (CLOUD-318): lapping on staleness must - # not turn a stop-on-content into a silent retry loop that burns - # LAND_MAX_LAPS before reporting the same failure. - [ "$(verify_calls)" -eq 1 ] -} - -@test "CLOUD-510: a racer land killed on purpose delivers no verdict" { - # DIAGNOSIS FIRST, because the issue offered two mechanisms and they land the - # fix in different places. Measured 2026-08-13 against post-CLOUD-383 `land`, - # with a real nested `mise run` of a real file task, inside and outside a - # mise-managed process group, under both the current FIFO rendezvous and the - # pre-CLOUD-383 bare `wait -n`: the killed child's own - # `ERROR … exited with non-zero status: no exit status` / `task failed` lines - # DO appear — they are the child mise's diagnostics — but they arrive after - # the parent has already read the rc files, and the parent exits 0 and laps. - # So "a killed nested task aborts the parent" is REFUTED; those lines are - # noise correlated with the kill rather than its consequence. - # - # What the measurement did expose is a real ordering hazard, and it is the - # issue's own title read literally. The two racers answer at the same instant - # by construction, so nothing stops the loser finishing on its OWN in the - # window between the winner reaching the rendezvous and the group kill - # landing: `ci-wait` returning a red verdict in the same breath as - # `main-watch` reporting that main moved. Emptiness is a PROXY for "this - # racer lost"; the rendezvous token is the fact. - # - # The environment cannot deterministically create that window — it is bounded - # by a kill, and a timing-based setup would pass vacuously on a loaded box, - # which is the CLOUD-249 defect this repo has already paid for once. So the - # DECISION is extracted and driven directly, which is what - # `.claude/rules/rust.md` prescribes for exactly this case. - local block - block=$(awk '/^\tcase "\$ciwinner" in/{p=1} p{print} p&&/^\tesac$/{exit}' "$REAL_LAND") - [ -n "$block" ] - - # main-watch won, and ci-wait ALSO finished on its own with a red verdict - # before the kill landed. That verdict is about a SHA that is no longer - # landable, and the run behind it is superseded by the next lap's push - # through `concurrency: cancel-in-progress`. Stopping the landing on it - # reports a red nobody needs to fix. - run bash -c "ciwinner=m; ci_rc=1; main_rc=0; $block; echo \"ci_rc='\$ci_rc' main_rc='\$main_rc'\"" - [ "$status" -eq 0 ] - [ "$output" = "ci_rc='' main_rc='0'" ] - - # The mirror, and the reason this is not simply "ignore ci_rc": ci-wait won, - # so ITS code is the verdict and main-watch's is the void one. - run bash -c "ciwinner=c; ci_rc=1; main_rc=0; $block; echo \"ci_rc='\$ci_rc' main_rc='\$main_rc'\"" - [ "$output" = "ci_rc='1' main_rc=''" ] - - # An unreadable rendezvous names no winner, and then nothing is voided — the - # fallback is exactly the reading this file had before CLOUD-510, so a FIFO - # that could not be read never invents a lap. - run bash -c "ciwinner=; ci_rc=1; main_rc=0; $block; echo \"ci_rc='\$ci_rc' main_rc='\$main_rc'\"" - [ "$output" = "ci_rc='1' main_rc='0'" ] - - # The VERIFY race carries the identical hazard and the identical block, so it - # is asserted here rather than left to be discovered when only one of the two - # gets fixed. A verify that refused the tree in the same breath as main - # moving is a refusal of a tree the next lap rebases away. - local vblock - vblock=$(awk '/^\t\tcase "\$vwinner" in/{p=1} p{print} p&&/^\t\tesac$/{exit}' "$REAL_LAND") - [ -n "$vblock" ] - run bash -c "vwinner=m; verify_rc=1; vmain_rc=0; $vblock; echo \"verify_rc='\$verify_rc' vmain_rc='\$vmain_rc'\"" - [ "$output" = "verify_rc='' vmain_rc='0'" ] - run bash -c "vwinner=v; verify_rc=1; vmain_rc=0; $vblock; echo \"verify_rc='\$verify_rc' vmain_rc='\$vmain_rc'\"" - [ "$output" = "verify_rc='1' vmain_rc=''" ] -} - -@test "CLOUD-510: a genuine ci-wait failure still stops the lap" { - # The negative self-test. Voiding the loser must not become voiding every - # non-zero: when `ci-wait` wins its own race and answers red, that is a - # verdict about this branch and the landing stops on it, exactly as before. - task_fails ci-wait - head_is_graded - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"CI is red"* ]] -} - -@test "CLOUD-407: a refused tree stops on lap 1 and carries the gate's own pointers" { - # Measured on PR #322. `batten check` refused three files by name, `verify` - # passed that refusal out as exit 2 through its `depends`, and `land` read the - # 2 as "main moved" — eight laps, ~13 minutes, and an operator told to "look - # before lapping again" at a branch whose defect was three `path:line` - # pointers printed twenty lines above every one of those messages. - # - # Two halves, and this row is the second. tests/verify.bats holds the first: - # `verify` can no longer mint a 2 for content at all. What is left for `land` - # is to stop on lap 1 AND to say what was actually refused. - task_fails verify - printf '%s\n' \ - '[hooks] batten-check stderr:' \ - '[hooks] crates/batten/tests/primitives.rs:1171 no-consumer-repo-name' \ - '[hooks] crates/batten/tests/primitives.rs:1174 no-consumer-repo-name' \ - >"$BATS_TEST_TMPDIR/rc.mise.verify.says" - run "$LAND" - [ "$status" -eq 1 ] - # Lap 1, not the backstop. Same assertion as the plain stop above, restated - # here because the CODE is what used to decide it and no longer does. - [ "$(verify_calls)" -eq 1 ] - [ "$(comments)" -eq 0 ] - # The pointers survived the race, the tee, and the message assembly. - [[ "$output" == *"primitives.rs:1171 no-consumer-repo-name"* ]] - [[ "$output" == *"primitives.rs:1174 no-consumer-repo-name"* ]] - # And it is NOT reported as the benign race, which is the whole defect. - [[ "$output" != *"that is a rebase"* ]] -} - -@test "a verify that failed only because main moved laps instead of stopping" { - # CLOUD-318, measured on #240: `verify` takes ~150s, `main` moved past the - # tip lap 1 rebased onto, `linear-check` refused, and the loop exited with - # advice — "reproduce and fix locally" — that named nothing to reproduce. - # Re-running with zero edits landed after three laps. Exit 2 is the one code - # that means that, and it is a lap. - task_fails_once_with verify 2 - pr_state OPEN MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"verify refused only because main moved"* ]] - # Not the content stop: that message tells the reader to reproduce something - # that does not exist, which is half of what CLOUD-318 is about. - [[ "$output" != *"Reproduce and fix locally"* ]] - # It really lapped: a second verify ran, and the lap reached the push. - [ "$(verify_calls)" -eq 2 ] - [[ "$(call_order)" == *push* ]] -} - -@test "the lap cap's refusal states what its own accounting supports" { - # CLOUD-904, and the first of a PAIR — the second case below reads this one's - # remedy against the fleet-saturated one, which is the assertion neither of them - # used to make. - # - # `charge_wait` REFUNDS the lap on every path that bought no CI, so reaching the - # cap means exactly one thing: this branch spent `max_laps` matrices and none of - # them landed. It does NOT mean `main` outran a lap — that inference is the one - # the refunds removed, and the same diagnosis CLOUD-413 measured wrong twice over - # across 24 laps. The message asserted it anyway for as long as the refunds have - # existed. - # - # CLOUD-871's worked instance lives here too: the remedy must name a runnable - # object rather than ask for judgement. An agent read "Look before lapping again" - # as STOP and stopped for 55 minutes on a one-commit branch, which is the worst - # move available — this task's own header says lapping IS the catch-up mechanism, - # so a stopped branch ages while the target moves. - # - # DELIBERATELY SINGULAR — do not copy this for the other refusals. There are 420 - # terminal refusals under `mise-tasks/`, and a case apiece would be 420 bespoke - # assertions in the language the retirement campaign exists to delete. A text - # predicate over the class was measured and is unshippable: against a generous - # detector only 103 of 420 name a runnable object, so it fires on 75%, and most - # of that is good messages. Rego cannot do better — regorus is built here with no - # `regex` builtins (CLOUD-885). The general property is acquired STRUCTURALLY - # instead: a rule kind requires `no_fix_reason` and ingest refuses a finding with - # no remedy, which a gate gets the moment it becomes a policy row (CLOUD-843). - echo 2 >"$BATS_TEST_TMPDIR/rc.mise.verify" - LAND_MAX_LAPS=2 run "$LAND" - [ "$status" -eq 5 ] - # What the accounting supports: the spend COUNTED at the ready, and that it - # did not land. Zero here is the discriminating value — these laps fail - # `verify`, so the ready is never reached and nothing is bought. Asserting a - # spend equal to the lap count is exactly the overstatement this replaced: - # measured on PR #651, two such laps reported "spent 2 CI matrices" against - # zero check-runs on the head. - [[ "$output" == *"bought 0 CI matrices"* ]] - [[ "$output" == *"landed nothing"* ]] - [[ "$output" != *"spent 2 CI matrices"* ]] - # The refuted inference must not be asserted at the emission site. The literal - # is allowed in the file's explanatory comments, which earn it by describing the - # bug — this reads the REFUSAL, not the file. - [[ "$output" != *"is moving faster than a lap takes"* ]] - # A runnable object, not "look". - [[ "$output" == *"gh pr view"* ]] - # And the wording that caused the 55-minute stop must not come back. - [[ "$output" != *"Look before lapping again"* ]] -} - -@test "the two exhaustions give imperatives consistent with their costs" { - # CLOUD-904's discriminating assertion, and the reason it is a PAIR: each case - # reads BOTH messages. CLOUD-399 made the two exits distinguishable by code and - # their remedies were never reconciled — the path that cost NOTHING told the - # caller to stop, and the path that cost `max_laps` matrices was ambiguous. - # "Free implies stop, expensive implies go" is not a defensible pairing, and no - # single-message assertion can see it. - # - # The unspent path may say wait. The spent path must name a continuing action AND - # the spend the caller is re-committing — an unconditional "run this again" - # re-arms the only brake on that spend. - echo 1 >"$BATS_TEST_TMPDIR/rc.mise.land-lock" - pr_state MERGED - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - saturated="$output" - - setup - - echo 2 >"$BATS_TEST_TMPDIR/rc.mise.verify" - LAND_MAX_LAPS=2 run "$LAND" - [ "$status" -eq 5 ] - runaway="$output" - - # The unspent path names its zero cost and names waiting. - [[ "$saturated" == *"spent no CI matrix"* ]] - [[ "$saturated" == *"wait, or land later"* ]] - - # The spent path names the cost it already paid, counted rather than inferred. - [[ "$runaway" == *"bought 0 CI matrices"* ]] - # ...names a continuing action... - [[ "$runaway" == *"run this again"* ]] - # ...and names the spend that action re-commits, which is what stops the - # continuing imperative from being unconditional. - [[ "$runaway" == *"commits up to another 2"* ]] - - # The remedies must not be interchangeable: the expensive path must not be - # telling the caller to wait, which is the free path's answer. - [[ "$runaway" != *"wait, or land later"* ]] -} - -@test "a verify that keeps losing the race exhausts laps rather than spinning" { - # The lap is bounded by the backstop that already exists. A `main` that - # never stops moving must reach LAND_MAX_LAPS and say so, not loop forever. - echo 2 >"$BATS_TEST_TMPDIR/rc.mise.verify" - LAND_MAX_LAPS=3 run "$LAND" - [ "$status" -eq 5 ] - # CLOUD-904 rewrote this refusal: "still not linear" asserted that `main` - # outran a lap, which the refunds in `charge_wait` already make impossible. - # What this case is about is the BACKSTOP firing after N laps, so it matches - # on the count rather than on the diagnosis that used to accompany it. - [[ "$output" == *"after 3 laps"* ]] - [ "$(verify_calls)" -eq 3 ] - [ "$(comments)" -eq 0 ] -} - -@test "CLOUD-399: the two exhaustions are told apart by CODE, not by prose" { - # The pair is the point. A saturated fleet ("wait, and land later — nothing - # is wrong") and a runaway branch ("main moves faster than a lap takes — - # look") both ended in `exit 1`, so a caller keying on a status could not - # tell "retry me later" from "I am broken". Swapping the two verdicts must - # red this case; a single-code assertion would pass on the swap. - # - # The wait side also asserts the COST, which is the whole reason the two are - # priced differently: a branch that never won a turn must have bought no - # matrix at all — no ready, no push, no comment. - echo 1 >"$BATS_TEST_TMPDIR/rc.mise.land-lock" - pr_state MERGED - LAND_LOCK_MAX_WAITS=1 run "$LAND" - saturated="$status" - [ "$saturated" -eq 4 ] - [[ "$output" == *"fleet is saturated"* ]] - [ "$(call_order)" = "" ] - [ "$(comments)" -eq 0 ] - - setup - - echo 2 >"$BATS_TEST_TMPDIR/rc.mise.verify" - LAND_MAX_LAPS=1 run "$LAND" - runaway="$status" - [ "$runaway" -eq 5 ] - # CLOUD-904 removed the refuted diagnosis this used to match on. The subject of - # THIS case is the exit CODES, so it needs any string that identifies the runaway - # refusal; the content of that refusal is the pair of cases above. - [[ "$output" == *"landed nothing"* ]] - - # The property itself, stated once: distinguishable, and neither is the - # generic stop that every other `die` in this task uses. - [ "$saturated" -ne "$runaway" ] - [ "$saturated" -ne 1 ] - [ "$runaway" -ne 1 ] -} - -@test "a body that defers a decision with no ticket stops before review is asked for" { - # CLOUD-323's stop. Readying is the commitment to review, which is when "we - # will decide this later" has to name where later lives — two decisions - # landed on `main` during CLOUD-164 with a PR paragraph as their only record. - # - # Asserted before the comment count for the same reason the verify stop is: - # stopping AFTER asking for the merge would have already spent the thing the - # stop exists to withhold. - pr_body "The format is a judgement call and nobody owns it." - task_fails deferral-check - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"defers a decision with no ticket"* ]] - [ "$(comments)" -eq 0 ] -} - -@test "a row this branch filed without grooming it stops before review is asked for" { - # CLOUD-514's stop, and the sibling of the one above: `deferral-check` prices - # a decision left with no home, this prices a home opened instead of a fix. - # The gate reads `board-write-record`'s own file for the rows; the PR body - # reaches it on stdin (CLOUD-774) only so it can exempt a row this PR closes. - # The lever here is still the task's exit status, which is what `land` acts on. - # - # Asserted before the comment count for the reason every stop above is: - # stopping after asking for the merge would have already spent what the stop - # exists to withhold. - task_fails filed-here-check - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"filed a row that was never groomed to Ready"* ]] - [ "$(comments)" -eq 0 ] -} - -# CLOUD-774. THE WIRE, not just the gate. `filed-here-check` grew a closing-key -# exemption so a row this PR closes is not read as a punt — and that exemption is -# inert unless `land` actually pipes the body to it. It shipped inert once: the -# first landing after the gate changed refused two rows the PR closes, because the -# call site still passed nothing on stdin. -@test "THE PR BODY REACHES filed-here-check, or its exemption is inert" { - # The stub captures stdin BEFORE it consults the scripted exit code, so making - # the gate fail is a lever that stops the lap immediately and still proves what - # the call site handed it. Letting `land` run on would poll CI and never end. - pr_body "Closes CLOUD-900" - task_fails filed-here-check - run "$LAND" - [ "$status" -eq 1 ] - [ -f "$BATS_TEST_TMPDIR/filedhere.stdin" ] - [[ "$(cat "$BATS_TEST_TMPDIR/filedhere.stdin")" == *"Closes CLOUD-900"* ]] -} - -# CLOUD-995. THE GATE'S OWN EXIT, not the pipeline's. `land` runs under pipefail, -# so a gate that exits before reading leaves the producer with EPIPE and the -# pipeline reports failure -- and `land` then reports the gate as having REFUSED. -# Two gates do exit early: filed-here-check returns 0 under its bypass and on a -# detached HEAD, closing-key-check exits 2 on every could-not-read path. So the -# bypass reddened the very lap it exists to wave through. -# -# THE BODY HAS TO BE BIGGER THAN THE PIPE BUFFER or the case proves nothing: a -# short one fits in the kernel buffer, the producer completes before the reader -# is gone, and the old piped form passes too. At 100k the write blocks and the -# EPIPE is certain, which is what makes this able to fail against the old shape. -# -# closing-key-check is failed on purpose so the lap stops somewhere nameable; -# without it `land` would run on to the CI poll and never end. -@test "CLOUD-995: a gate that exits before reading stdin is not a refusal" { - pr_body "$(printf 'x%.0s' $(seq 1 100000)) Closes CLOUD-900" - task_exits_undrained filed-here-check - task_fails closing-key-check - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" != *"filed a row that was never groomed to Ready"* ]] - [[ "$output" == *"names its issue but never closes it"* ]] -} - -@test "a body that names its issue but never closes it stops before review is asked for" { - # CLOUD-192's stop, and it sits beside the deferral one for the same reason: - # readying is the commitment to review, and the board move is what tells - # anyone review is open. A PR that only MENTIONS its issue links, attaches - # and moves nothing — measured as #398 (`Refs:`, never moved) against #400 - # (`Closes`, moved in two seconds). - # - # Asserted before the comment count, like the two stops above: stopping after - # asking for the merge would have already spent what the stop withholds. - pr_body "Some work here. - -Refs: CLOUD-192" - task_fails closing-key-check - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"names its issue but never closes it"* ]] - [ "$(comments)" -eq 0 ] -} - -@test "a prose-only branch stops before review is asked for" { - # CLOUD-827's stop, and the only one in this set that prices what the change is - # WORTH rather than whether it is correct. Measured: a branch whose whole diff - # was two rewritten sentences of `//!` doc comment reached the ready and a full - # required matrix, and what stopped it was a human rather than a gate. - # - # Asserted before the comment count for the reason every stop here is: stopping - # after asking for the merge would already have spent the thing the stop exists - # to withhold — and here that thing IS the spend. - task_fails prose-only-check - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"prose-only branch"* ]] - [ "$(comments)" -eq 0 ] - # It must not have readied either: the ready is the event that buys the run. - [ "$(grep -c '^ready$' "$BATS_TEST_TMPDIR/calls")" -eq 0 ] -} - -@test "a missing verify receipt stops the lap" { - # `verified` reads the receipt keyed to this exact HEAD. Landing had no such - # precondition before, so a branch readied by any other route could still be - # landed on a verdict nobody checked. - task_fails verified - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"receipt"* ]] - [ "$(comments)" -eq 0 ] -} - -@test "red CI stops the lap without asking for the merge" { - task_fails ci-wait - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"CI is red"* ]] - [ "$(comments)" -eq 0 ] - # The anti-regression half of the CLOUD-470 pair: a GENUINE red must keep - # today's message. A change that printed the rebase remedy unconditionally - # would pass the two cases below and misdirect every real CI failure. - [[ "$output" != *"CANCELLED"* ]] -} - -@test "a run CI DECLINED is a stop, not a red — the agent is told to rebase" { - # CLOUD-470. `ci-lease-precondition` stops an unauthorised head by cancelling - # its run, and `final` reds under `always()` — so the wait returns non-zero - # with nothing broken. Measured: 11 of 13 open PRs carried a `land` predating - # the lease, so every one of those agents was sent to debug a disagreement - # that did not exist. Nothing local can fix it; the remedy is a rebase. - lease_declines - task_fails ci-wait - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"CANCELLED"* ]] - [[ "$output" == *"git rebase origin/main"* ]] - [[ "$output" != *"verify and CI disagree"* ]] - [ "$(comments)" -eq 0 ] -} - -@test "CLOUD-470: the declination is asked of land-lock, not re-derived" { - # THE MECHANISM, not the behaviour — the behaviour is the pair above. The Ready - # block is explicit that this "calls `land-lock authorises` or it is wrong", - # and the first cut answered the same question from a raw - # `conclusion == "cancelled"` read of the run list. Two authorities for one - # fact is the CLOUD-351 shape, where only the newer one decides. - # - # Structural, because the behavioural pair cannot tell the two implementations - # apart: both print the same message. This is what fails if the verb is swapped - # back out for a second predicate. - lease_declines - task_fails ci-wait - run "$LAND" - [ "$status" -eq 1 ] - [ "$(lock_calls authorises)" -ge 1 ] - run grep -c 'authorises' "$REAL_LAND" - [ "$output" -ge 1 ] - # And the raw fingerprint is gone: no second reading of the run list decides - # "declined". `cancel_own_run` still reads that endpoint for a different - # question — which runs are still in flight — so the assertion is on the - # conclusion literal, not on the endpoint. - # - # CODE ONLY. The comment above `declined_by_lease` names the predicate it - # replaced, which is the design record and must survive; a sensor that read it - # as the defect would pressure the next author to delete the explanation. - [ "$(grep -vE '^[[:space:]]*#' "$REAL_LAND" | grep -c 'conclusion == "cancelled"')" -eq 0 ] -} - -@test "a verdict that could not be READ is not a red one" { - # `ci-wait` exit 2 is "could not look" (its own contract), and the guard was - # `!= 0`, so a verdict nobody obtained was reported as a red run on a - # verified branch — sending the agent to reconcile a disagreement that was - # never observed. - task_fails_once_with ci-wait 2 - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"could not read CI's verdict"* ]] - [[ "$output" != *"CI is red"* ]] -} - -@test "an unset required roster stops rather than readying (CLOUD-467)" { - # `graded_runs` answered 0 on an unset roster, and 0 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 eight lines away in the file it is paired with. - CI_REQUIRED_CHECKS= run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"CI_REQUIRED_CHECKS is unset"* ]] - [ "$(ready_calls)" = "" ] - [ "$(comments)" -eq 0 ] -} - -@test "CLOUD-376: an unset ANSWERED set stops rather than readying, for the same reason" { - # The second input `graded_runs` cannot compute, guarded in the same place and - # not inside the function: both call sites wrap it in `$( )`, so a `:?` abort - # there exits the SUBSHELL only and the lap continues with an empty reading — - # which both call sites read as "no graded run", the branch that fires the - # ready that spends a matrix. That is CLOUD-467's defect in a new variable, and - # it would have shipped with the refactor that introduced it. - CI_ANSWERED_CONCLUSIONS= run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"CI_ANSWERED_CONCLUSIONS is unset"* ]] - [ "$(ready_calls)" = "" ] - [ "$(comments)" -eq 0 ] -} - -@test "CLOUD-376: no conclusion name is written in mise-tasks outside the manifest" { - # The sensor on the property, in the shape `ci-local-parity` already uses for - # CI_REQUIRED_CHECKS. Without it this is a refactor that silently un-refactors: - # the next edit re-inlines a literal, the two readers drift again, and nothing - # notices until they compose into a wedge — which is precisely how CLOUD-363 - # happened, and it was found by a human reading both files. - # - # `success` and `neutral` are exempt: they name GREEN, which is a narrower - # question than "is this an answer" and is not what the manifest declares. - # - # SCOPED TO THE TWO READERS THAT SHARE THE MANIFEST, and the exemption is - # stated rather than silent. `sonar-gate` judges ONE external check-run by - # name, deliberately outside `$CI_REQUIRED_CHECKS` (CLOUD-441) — a different - # roster answering a different question, so a literal there is not a second - # copy of this one and forcing it to share would couple two unrelated gates. - # CODE ONLY, for the reason the CLOUD-470 sensor gives: a comment naming the - # literal it removed is the design record, and a sensor that read it as the - # defect would pressure the next author to delete the explanation. - local leaked="" - for c in timed_out action_required cancelled; do - for f in mise-tasks/land.sh mise-tasks/checks-green.sh; do - [ "$(grep -vE '^[[:space:]]*#' "$BATS_TEST_DIRNAME/../$f" | grep -c "\"$c\"")" -eq 0 ] || - leaked="$leaked $f:$c" - done - done - [ -z "$leaked" ] || { - echo "conclusion literals outside mise.toml [env]:$leaked" - echo "Declare them once in CI_ANSWERED_CONCLUSIONS; two hand-maintained lists is CLOUD-363." - return 1 - } -} - -@test "a rejected push stops rather than clobbering someone else's branch" { - # `--force-with-lease` is what makes this a stop and not data loss: the - # lease is stale the moment another writer moves the branch. - fails push - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"push rejected"* ]] - [ "$(comments)" -eq 0 ] - # CLOUD-345's anti-vacuity half: a GENUINE concurrent move keeps today's - # caution and must never be told to prune. A change that always named the - # stale ref would pass the row below and send an operator to force over a - # writer who is really there. - [[ "$output" == *"Someone else moved it"* ]] - [[ "$output" != *"--prune"* ]] -} - -@test "CLOUD-345: a branch ABSENT from the remote is a stale ref, not a rival" { - # The deadlock. GitHub deletes the head branch on merge, a plain fetch never - # prunes, and the surviving tracking ref names a SHA the remote does not have - # — so `--force-with-lease` is rejected as `stale info` forever. No number of - # laps clears it, because every lap re-fetched without pruning. - # - # The old message named the one cause that was not true, and named it toward - # the dangerous action: `git log HEAD..origin/` is EMPTY here, so - # every check an operator would run says forcing is safe, for the wrong - # reason. - fails push - : >"$BATS_TEST_TMPDIR/lsremote" - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"ABSENT from the remote"* ]] - [[ "$output" == *"--prune"* ]] - [[ "$output" == *"Do NOT force"* ]] - [[ "$output" != *"Someone else moved it"* ]] - [ "$(comments)" -eq 0 ] -} - -@test "CLOUD-345: every fetch prunes, so a deleted upstream leaves no expectation" { - # The cheap half, and the one that makes the loop self-clearing rather than - # merely better-diagnosed. `fetch_main` is the single definition both lap - # reads share, so this covers both. - pr_state MERGED - run "$LAND" - grep -qE '^fetch -q --prune origin main$' "$BATS_TEST_TMPDIR/gitlog" - [ "$(grep -cE '^fetch -q origin main$' "$BATS_TEST_TMPDIR/gitlog")" -eq 0 ] -} - -@test "an unfetchable origin stops instead of lapping on a stale main" { - fails fetch - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"cannot fetch"* ]] - [[ "$output" == *"github-access"* ]] -} - -@test "endless refusals hit the lap cap rather than lapping forever" { - # The backstop is on LAPS, never a wall clock on a wait: hitting it means - # main is moving faster than a lap takes, which a human should see. - workflow_runs runs.last failure - LAND_MAX_LAPS=2 run "$LAND" - [ "$status" -eq 5 ] - [[ "$output" == *"after 2 laps"* ]] - [ "$(comments)" -eq 2 ] -} - -@test "land refuses to run from main" { - printf 'main\n' >"$BATS_TEST_TMPDIR/branch" - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"short-lived branch"* ]] - [ "$(comments)" -eq 0 ] -} - -@test "a merged PR exits 0" { - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"is MERGED"* ]] -} - -@test "a PR that closed without merging exits non-zero" { - pr_state CLOSED - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"is CLOSED"* ]] -} - -@test "a run still in progress concludes neither way, and the poll continues" { - # Queued or running is not an answer. Concluding early in either direction - # is the failure — a premature success reports a landing that never happened. - pr_state OPEN OPEN OPEN MERGED - workflow_runs runs.1 - workflow_runs runs.2 - workflow_runs runs.3 failure - workflow_runs runs.last - run "$LAND" - [ "$status" -eq 0 ] - [ "$(cat "$BATS_TEST_TMPDIR/runs.calls")" -ge 3 ] - [ "$(comments)" -eq 2 ] -} - -@test "a run that predates this lap is not read as a verdict on it" { - # An earlier lap of the same PR, refused and since rebased, leaves a failed - # run behind forever. Reading it would make every later lap report a refusal - # that already happened — so the window is stamped before commenting. Now - # that the task laps itself the stamp is load-bearing twice over: without it - # the old hang becomes a livelock, lap 2 abandoning its own attempt on lap - # 1's answer. - pr_state OPEN OPEN MERGED - workflow_runs runs.1 failure 2000-01-01T00:00:00Z - workflow_runs runs.last failure 2000-01-01T00:00:00Z - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"is MERGED"* ]] - [ "$(comments)" -eq 1 ] -} - -@test "the merge is what it waits for, not the comment" { - # A comment plus a guessed sleep is the shape this task replaces: the - # comment only *starts* the landing. - pr_state OPEN OPEN MERGED - workflow_runs runs.last - run "$LAND" - [ "$status" -eq 0 ] - [ "$(comments)" -eq 1 ] - [[ "$(cat "$BATS_TEST_TMPDIR/comments")" == *"/fast-forward"* ]] -} - -@test "the poll carries no wall-clock timeout" { - # A hang is fixed by an exit condition that can fire, never by capping the - # poll — a cap reintroduces the VM-reap gap and would land as a false - # "refused" on a slow bot. The lap CAP is a count, not a clock. - run grep -cE '\btimeout [0-9]' "$REAL_LAND" - [ "$output" -eq 0 ] -} - -@test "a branch with no OPEN PR has nothing to land" { - # `gh pr list --state open` on such a branch prints nothing, so the lookup - # comes back empty — an empty body through the real `--jq` is what the stub - # reproduces. - rm -f "$BATS_TEST_TMPDIR"/state.[0-9]* - : >"$BATS_TEST_TMPDIR/state.last" - printf '[]' >"$BATS_TEST_TMPDIR/prlist" - printf '[{"number":366}]' >"$BATS_TEST_TMPDIR/prlist.all" - PR= run timeout -k 1 15 "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"nothing to land"* ]] - [ "$(comments)" -eq 0 ] -} - -@test "THE MERGED-NAME CASE: a branch whose old PR merged binds the OPEN one (CLOUD-465)" { - # The default shape, not an edge case. Trunk-based development deletes the - # branch on merge, and the session harness pins an agent to one branch name - # for its whole engagement — so the second landing of any session recycles a - # name whose previous PR is merged. A bare `gh pr view` answers with that - # merged PR, and `land` then drives a pull request that is already finished. - printf '[{"number":368}]' >"$BATS_TEST_TMPDIR/prlist" - printf '[{"number":366},{"number":368}]' >"$BATS_TEST_TMPDIR/prlist.all" - pr_state MERGED - PR= run timeout -k 1 15 "$LAND" - [ "$status" -eq 0 ] - # Every comment and read went to the open PR, never to the merged one. - [[ "$(cat "$BATS_TEST_TMPDIR/comments")" == *"368"* ]] - [[ "$(cat "$BATS_TEST_TMPDIR/comments")" != *"366"* ]] -} - -@test "an already-proven HEAD is not proven again" { - # `verified` reads the receipt keyed to this exact commit, so when it still - # holds nothing has changed. Local time is free, but this is also what keeps - # a lap from being expensive enough to be worth avoiding. - already_verified - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"already carries a verify receipt"* ]] - ! grep -q '^run verify$' "$BATS_TEST_TMPDIR/misecalls" - grep -q '^run verified$' "$BATS_TEST_TMPDIR/misecalls" -} - -@test "main moving mid-wait starts the next lap instead of paying out the run" { - # The moment main advances, this SHA cannot fast-forward: the run in flight - # is already waste and every remaining second of it is billed. So the wait - # is a RACE, and main-watch winning is a lap, not a failure. Asserted by the - # lap happening while ci-wait would still have been running. - ci_is_slow - main_moves_on_lap 1 - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"main moved under"* ]] - [[ "$output" == *"Lapping early"* ]] - # Lap 1 never asked for the merge; lap 2 did, once. - [ "$(comments)" -eq 1 ] -} - -@test "a red CI re-drafts the PR before stopping" { - # CI does not run on drafts, so this is the only thing that stops the next - # push — from any source — buying another run over a failure nobody has - # fixed yet. Stopping without it leaves the tap open. - task_fails ci-wait - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"CI is red"* ]] - [[ "$output" == *"re-drafted"* ]] - [[ "$(ready_calls)" == *"--undo"* ]] -} - -# --- CLOUD-458: the tap closes on every non-merged exit, not only on red ------ -# -# What `checks-green` answers for the head when a landing stops. Absent means -# GREEN, since the mise stub's default is exit 0 — so every case above that ends -# without merging asserts the leave-it-ready side by construction, which is why -# none of them needed changing. -head_verdict() { echo "$1" >"$BATS_TEST_TMPDIR/rc.mise.checks-green"; } - -@test "a landing interrupted on an ungraded head re-drafts, not only a red one" { - # The measured leak: `land` readied, something other than red ended the run, - # and the PR stayed ready for good — so every later push bought a full - # matrix with no landing attempt in progress at all. - head_verdict 3 - not_linear - fails rebase - run "$LAND" - [ "$status" -eq 1 ] - [[ "$(ready_calls)" == *"--undo"* ]] - [[ "$output" == *"stopped without merging"* ]] -} - -@test "the same interruption over a green head leaves it ready" { - # The other direction, and what makes the guard load-bearing rather than - # decorative: the pre-push ready fires only on a head with NO graded run, so - # re-drafting a green head strands it — and readying it again would buy a - # whole matrix to get back where it already was. Green resumes for free. - not_linear - fails rebase - run "$LAND" - [ "$status" -eq 1 ] - [[ "$(ready_calls)" != *"--undo"* ]] -} - -@test "a head whose verdict could not be read is left ready, never stranded" { - # `checks-green` exit 2 is "I could not look", which is not evidence of - # anything. Acting on it would strand a green head on a reading we failed to - # take; the leak it leaves open costs a run, and the strand costs a wedge. - head_verdict 2 - not_linear - fails rebase - run "$LAND" - [ "$status" -eq 1 ] - [[ "$(ready_calls)" != *"--undo"* ]] -} - -@test "a landing that merges leaves the PR alone" { - # The one exit with no tap to close. The verdict lever is set to the value - # that WOULD re-draft, so this asserts the `landed` flag rather than the - # absence of an opportunity. - pr_state MERGED - head_verdict 3 - run "$LAND" - [ "$status" -eq 0 ] - [[ "$(ready_calls)" != *"--undo"* ]] -} - -@test "a refused second land does not re-draft the live one's PR" { - # A land that never took the singleton owns neither the lease nor the PR. - # Its EXIT trap already knows not to release the lock; this is the same - # discipline applied to the other side effect. - task_fails singleton - head_verdict 3 - run "$LAND" - [ "$status" -ne 0 ] - [[ "$(ready_calls)" != *"--undo"* ]] -} - -@test "a re-draft that cannot happen does not change the exit code" { - # Cleanup that can fail an exit path is worse than the leak it closes: the - # status must still be the rebase conflict's, not the re-draft's. - head_verdict 3 - undo_fails - not_linear - fails rebase - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" != *"re-drafted"* ]] -} - -@test "a draft PR is readied, which is the event that spends the run" { - # Readying is what starts CI, so it happens once the tree is proven and - # pushed and never earlier — and it is how a PR re-drafted by an earlier red - # run resumes the loop without a human. - is_draft - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"readied #150"* ]] - [[ "$(ready_calls)" != *"--undo"* ]] -} - -@test "nothing is readied when the head already carries a graded run" { - # Re-readying is a no-op to GitHub but a lie in the log, and worse it buys a - # second CI run for a SHA that already has one — step 5 of the contract. The - # graded head is what makes "already ready" mean "already answered": without - # it, a ready PR whose head has no real run is the stall, not the no-op. - head_is_graded - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [ ! -s "$BATS_TEST_TMPDIR/ready" ] -} - -@test "a ready PR whose head carries only skipped runs has its ready re-fired" { - # THE STALL (CLOUD-247), measured on #177. A push made while the PR was a - # draft leaves a complete `skipped` set on the head; readying afterwards - # produced no new run, and `ci-wait` then polled forever — correctly, since - # an all-skipped set is not an answer. Nothing was broken at either end and - # no confirming run was ever spent. Only `--undo` re-emits - # `ready_for_review`, so that is what the lap must do. - head_is_all_skipped - push_moves_nothing - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$(ready_calls)" == *"--undo"* ]] - [[ "$output" == *"re-fired the ready"* ]] -} - -@test "a ready PR whose head carries only cancelled runs has its ready re-fired" { - # THE WEDGE (CLOUD-363), measured on #293. `land` readied and then - # force-pushed; both events reached the same `concurrency: ci-` group two - # seconds apart and the run on the SHA that would land was the one cancelled. - # `graded_runs` counted `cancelled` as an answer, so this block did not fire — - # and with HEAD unchanged, the push moving nothing and the verify receipt - # still valid, re-running `land` re-read the identical stale set forever. Two - # consecutive invocations died on it; the only escape was a hand-minted SHA, - # which is a manual step outside the loop this task exists to drive. - head_is_all_cancelled - push_moves_nothing - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$(ready_calls)" == *"--undo"* ]] - [[ "$output" == *"re-fired the ready"* ]] -} - -@test "the re-drafted PR a cancelled set left behind is readied, not stuck" { - # The state `land` actually leaves after reporting red: `redraft` closed the - # tap, so the next invocation meets a DRAFT whose head carries the cancelled - # set. That is the entry point recovery has to work from, and it takes the - # other ready block — the pre-push one — so `--undo` is neither needed nor - # spent (CLOUD-255 still holds on this path). - is_draft - head_is_all_cancelled - push_moves_nothing - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [ "$(grep -c . "$BATS_TEST_TMPDIR/ready")" -eq 1 ] - [[ "$(ready_calls)" != *"--undo"* ]] - [[ "$output" == *"readied #150 before pushing"* ]] -} - -@test "a DRAFT whose push moves nothing readies once, not once and then again" { - # CLOUD-255. The two ready blocks were not mutually exclusive: a draft on an - # unchanged head with only skipped runs satisfies both, so the lap readied, - # then re-drafted and readied again. The first `ready_for_review` starts a - # run and the second cancels it through `cancel-in-progress` — a runner spent - # and thrown away, by the task whose whole premise is that runners are - # metered. `--undo` exists to emit that event on a PR that is ALREADY ready; - # a draft has a cheaper way and has just used it. The case above ran with the - # default non-draft PR, which is why this shape went unexercised. - is_draft - push_moves_nothing - head_is_all_skipped - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [ "$(grep -c . "$BATS_TEST_TMPDIR/ready")" -eq 1 ] - [[ "$(ready_calls)" != *"--undo"* ]] -} - -@test "THE RACE: the ready precedes the push, so one event carries the run" { - # CLOUD-254, measured on #182. Pushing first and readying after puts two - # webhooks in the same instant and the same `concurrency: ci-` group: - # the `synchronize` carries `draft: true` so its run skips every job, and the - # `ready_for_review` run does not survive beside it under - # `cancel-in-progress`. Both events are stamped 22:14:20Z on #182 and exactly - # one run exists, skipped — the head carries no graded run and `ci-wait` - # polls forever. Readying FIRST makes the push's own event the confirming - # run. Asserted on the order, because both calls happen either way and only - # the order decides whether a run is ever created. - is_draft - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$(call_order)" == "ready push"* ]] - [[ "$output" == *"before pushing"* ]] -} - -@test "a lap that pushed does not also buy a second event" { - # The re-fire is guarded on the ref NOT moving. A lap that pushed already - # emitted the `synchronize` that starts the run; converting to draft and back - # on top of it would spend a second runner for one SHA — step 5 of the - # contract — and re-drafting mid-run is how the first one gets cancelled. - is_draft - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$(ready_calls)" != *"--undo"* ]] - [ "$(grep -c ready "$BATS_TEST_TMPDIR/calls")" -eq 1 ] -} - -@test "a landing that succeeds says nothing that reads as a failure" { - # The property that would have caught CLOUD-245. The suite asserted the - # messages it WANTED and never that the success path was quiet, so a probe - # whose ordinary answer is an `::error::` shipped and printed one on every - # green landing. A reader who sees `::error::` on success learns to skim it, - # and the next one that matters is skimmed too. - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" != *"::error::"* ]] || { - echo "a green landing emitted an ::error:: line:" - printf '%s\n' "$output" | grep '::error::' - return 1 - } -} - -@test "the receipt guard still has its voice when it is the real failure" { - # The same call, asked as a guard rather than a probe: after a green - # `verify`, no receipt means something swallowed the verdict. Silencing both - # call sites would trade one defect for a worse one. - task_fails verified - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"::error::"* ]] - [[ "$output" == *"receipt"* ]] -} - -@test "a silent bot with main moved ends the lap instead of polling" { - # THE 7h15m CASE (CLOUD-246). The bot answers nothing at all — no terminal - # PR state, no completed run — while `main` advances past the branch. Before - # this the answer poll had no third exit and blocked for 26,123s on #159. - # - # `main_moves_during_answer_wait 1` is what makes the distinction testable: - # lap 1's CI-race watcher still loses, so the lap reaches the answer poll, - # and only the watcher started there wins. Lap 2 then merges, so `land` - # exits on its own — which every case here must let it do, because `set -m` - # puts each watcher in its own process group and a `timeout` kill of the - # parent would leave one holding this suite's stdout open. - # - # Several OPEN reads, not one (CLOUD-256). The poll reads the PR state - # BEFORE it checks the watcher's result file, and `pr_state OPEN MERGED` - # makes MERGED sticky from the second read — so the backgrounded - # `main-watch` had exactly one interval to fork, exec and write, and lost - # that race once inside a full gate run. The case is about WHICH exit fires, - # not about how fast a fork completes; the assertions below are unchanged. - main_moves_during_answer_wait 1 - pr_state OPEN OPEN OPEN OPEN MERGED - workflow_runs runs.last - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"main moved under"* ]] - [[ "$output" == *"while the bot was still silent"* ]] - # Two laps means it really lapped rather than falling through. - [ "$(comments)" -eq 2 ] -} - -@test "a silent bot with main unmoved keeps polling" { - # The other half, and why the fix is a race rather than a timeout: nothing - # has changed, so the PR may still merge, and ending the lap here would - # abandon a landing that was going to succeed. - # - # This is the one case that must kill `land` to prove a negative, so it - # redirects to a file instead of going through `run`: the orphaned watcher - # would otherwise hold the pipe and hang the suite rather than fail it. - pr_state OPEN - workflow_runs runs.last - local out="$BATS_TEST_TMPDIR/still-waiting" rc=0 - # `|| rc=$?` because a bats body aborts on a non-zero command, and a timeout - # is the result this case is asserting rather than a failure of it. - # `run_timeout` is tests/helpers.bash's shim: stock macOS ships no `timeout` - # (CLOUD-282), and this case is the one that most needs it to exist. - run_timeout -k 1 5 "$LAND" >"$out" 2>&1 || rc=$? - # 124 OR 137 (CLOUD-464). Both are `timeout` saying "the command did not - # finish", which is the whole property here; what separates them is machine - # load, not behaviour. GNU `timeout` returns 124 when the process dies from - # the TERM it sent and 137 when `-k` had to escalate to KILL because TERM was - # not serviced within the grace second — and `land` runs `set -m`, installs - # an EXIT trap and reaps two watcher process groups, so how long that takes - # depends on what else is running. Asserting 124 alone made a contended box - # red and an idle one green, which is CLOUD-426's shape in its sibling case. - # - # Not a loosening: a `land` that ended the lap on its own exits with its own - # status, so neither code can appear and a broken poll still fails this. - [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ] - # This is the only case that kills `land` mid-poll, and `land` runs `set -m` - # so each watcher has its own process group and outlives the parent. Left - # alone they block forever and the suite never exits — so this reaps the - # ones this case orphaned, matched on the per-test stub path so it can - # touch nothing else. - pkill -f "$STUB/mise" 2>/dev/null || true - run cat "$out" - [[ "$output" != *"main moved under"* ]] - [[ "$output" != *"is MERGED"* ]] - [[ "$output" == *"waiting for the merge"* ]] -} - -@test "the watcher does not outlive a merged landing" { - # The reap, on the path that exits rather than laps. A `main-watch` left - # running would keep polling GitHub after the task returned — and because - # the loser of this race blocks by construction, a `wait` that named no pid - # would hang the very landing this change exists to unblock. `run` - # returning at all is that assertion. - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"is MERGED"* ]] -} - -@test "a re-draft that fails stops the lap rather than waiting on a run nobody started" { - # The `--undo` is what re-emits `ready_for_review`; if it cannot happen the - # confirming run never starts, and polling on would be waiting for something - # that is not coming — the exact shape this whole area keeps producing. - head_is_all_skipped - push_moves_nothing - undo_fails - pr_state OPEN - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"could not re-draft"* ]] -} - -@test "a ready that fails stops before the push rather than pushing into silence" { - # The ready now precedes the push (CLOUD-254), so a ready that cannot happen - # is caught while nothing has been published yet. Pushing on past it would - # put a commit on a draft PR that no event will ever grade — the stall this - # whole area keeps producing, reached from the other side. - is_draft - ready_fails - pr_state OPEN - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"could not mark #150 ready"* ]] - [[ "$(call_order)" != *push* ]] -} - -@test "every way a lap can end is exercised above" { - # The property that would have caught the dead branch: an exit nothing - # reaches is an exit nothing tests. Each `die` is covered by a case here, - # so a new stopping condition cannot be added silently. - # - # BOTH SPELLINGS ARE COUNTED (CLOUD-399). The two exhaustions now carry their - # own exit codes through `die_with`, and counting only `die "` would have let - # this sensor read 18-of-20 as "two stops removed" — or, worse, let a future - # `die_with` stop be added completely uncounted. That is the exact blindness - # this assertion exists to prevent, reintroduced by the change that split the - # helper. `die_with` is matched on its code argument, which every call carries. - stops=$(grep -cE 'die "|die_with "?\$?[A-Za-z_]' "$REAL_LAND") - # 24 since CLOUD-483: absorbing a provisioning transient adds two — a re-run - # the API refused, and the retry budget exhausted. Both exercised below; the - # budget one is a COUNT, so the no-wall-clock row above still holds. - # 26 since CLOUD-383: a race rendezvous that cannot be created, once per race. - # 27 since CLOUD-192: a PR body that names its issue but never closes it, so - # the merge would leave the board a column behind. Exercised below. - # TWO stops rather than one on purpose — the helper RETURNS the failure and - # each caller dies at top level, because a `die` inside the `$( )` every - # caller wraps it in would exit only the subshell (CLOUD-467, measured again - # here). Both are exercised below. - # 28 since CLOUD-518: a PR whose webhook subscription this session has not - # dropped. It is the FIRST stop in the run, before the singleton and the lease, - # so a refusal costs no CI at all — and this counter caught it the moment it - # was added, which is what it is for. Exercised below. - # 29 since CLOUD-514: a row this branch filed and never groomed to Ready. It - # sits beside the deferral stop and stops the lap the same way. Exercised below. - # 30 since CLOUD-861: the disk filled DURING verify. It is a separate stop - # rather than a branch inside the generic verify one because the two need - # opposite advice — that stop says "reproduce and fix locally", which is - # right for a refusal of this tree and wrong for an environment failure with - # nothing in the diff to reproduce. It precedes the generic stop for the same - # reason. Exercised below, with an anti-vacuity twin holding the narrowing to - # its scope: an ordinary failure must still get the original advice. - # 31 since CLOUD-862: the replay-unwind for an ADOPTED bet cannot resolve. - # It is a second stop rather than a branch of the reset-unwind's because the - # two fail for different reasons and only one of them is recoverable by the - # operator — a reset that fails means the undo point is gone, a replay that - # fails means another branch's commits will not come off, and the second is - # the one that must never reach a push. Exercised below. - # 33 since CLOUD-727: a `verify` failure on a SPECULATIVELY linearized tree. - # It is a separate stop rather than a branch inside the generic verify one for - # the reason the disk stop is: the generic arm's advice — "reproduce and fix - # locally" — is wrong when the tree under test carries another branch's - # unlanded commits, and it precedes that arm for the same reason. Exercised - # below, with an anti-vacuity twin holding the narrowing to its scope: an - # ordinary failure with no speculation must still get the original advice. - # 32 since CLOUD-827: a prose-only branch, whose whole diff is comment lines - # with no test change. It is the only stop here that is about what the change - # is WORTH rather than whether it is correct — every gate above it asks - # whether the branch works, and this one asks whether the matrix it is about - # to buy can have an opinion about it. Exercised below, with the admitting - # direction held by the gate's own suite rather than duplicated here. - [ "$stops" -eq 33 ] || { - echo "land has $stops stopping conditions; this suite covers 33." - echo "Add a case for the new one — an unexercised exit is how the refusal path stayed dead." - return 1 - } - - # `die` is no longer the only way a lap ends: a lap can also `continue`, - # and CLOUD-246's exit is one of those. Counting only the dies would have - # left the new branch exactly as unwatched as the refusal branch once was, - # which is the mistake this assertion exists to stop repeating. - # 14 and 6 since CLOUD-393: the landing lease adds one stop (the fleet is - # saturated — every turn lost) and two laps (the lease is held by someone - # else, and the lease was lost before the comment). This assertion caught all - # three the moment they were added, which is the whole point of it. - # 8 since CLOUD-423: the verify race adds two more — main moved while verify - # ran, and a verify race that produced no verdict. Both exercised below, and - # this assertion caught both the moment the race landed. - # 15 since CLOUD-428: one land per clone. Exercised by the singleton case - # below, and this counter is why that stop goes through `die` rather than a - # bare `exit` — an exit nothing counts is an exit nothing tests. - laps=$(grep -cE '^[[:space:]]*continue$' "$REAL_LAND") - # 11 since CLOUD-369: the warm queue adds four — the lease was lost (now the - # path that speculates and may reserve), the successor pushed and lapped, the - # successor is already in flight for this head, and the winner found main had - # moved while it waited. Each is exercised below. - # 14 since CLOUD-495: winning the lease can now settle a bet the holder - # abandoned, and an unwind there moved HEAD — so the lap ends rather than - # pushing a commit this branch no longer has. Exercised below. - # 15 since CLOUD-483: a red run whose every failed required job died before - # reaching a verdict is re-run and lapped rather than reported. Two sessions - # each added a lap ending and each claimed 14 — the rebase conflict here WAS - # the sensor catching a count neither branch could see alone, which is what - # it is for. - [ "$laps" -eq 15 ] || { - echo "land has $laps lap-ending continues; this suite covers 15." - echo "Add a case for the new one — an exit nothing counts is an exit nothing tests." - return 1 - } -} - -# --- the verify race (CLOUD-423) --------------------------------------------- - -@test "main moving during verify ends the lap at the poll, never at the end of the gate" { - # The blind window: verify used to run its whole ~220s gate before - # linear-check discovered main had moved. Raced, the lap ends within one - # poll interval — and the aborted verify left NO receipt, so lap 2 proves - # the tree for real rather than trusting a kill to have been clean. That - # second verify call IS the abort-safety property, observed live. - verify_is_slow - main_moves_during_verify 1 - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"while verify ran"* ]] - [ "$(verify_calls)" -eq 2 ] - [[ "$output" != *"already carries a verify receipt"* ]] - [ "$(comments)" -eq 1 ] -} - -@test "a verify race with no verdict laps and re-proves rather than guessing" { - # The conservative arm, same as the CI race's: a watcher that died without - # an answer while verify was still running is not evidence of anything, so - # the lap re-proves. With the per-step receipts the retry costs seconds. - verify_is_slow - verify_watch_fails_once - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"no verdict from verify's race"* ]] - [ "$(verify_calls)" -eq 2 ] - [ "$(comments)" -eq 1 ] -} - -# --- post-merge branch cleanup (CLOUD-349) ----------------------------------- - -deletes() { grep -c . "$BATS_TEST_TMPDIR/deletes" || true; } - -@test "a merged PR's branch is deleted from the remote" { - # Trunk-based development keeps the review's commentary and not the branch. - # A name left behind is how a short-lived branch becomes a long-lived one — - # and reusing one after its PR merged is the stale-tracking-ref deadlock - # CLOUD-345 records. - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [ "$(deletes)" -eq 1 ] - grep -q '^push -q origin --delete feat$' "$BATS_TEST_TMPDIR/deletes" - [[ "$output" == *"deleted origin/feat"* ]] -} - -@test "a delete the remote refuses does not change land's exit code" { - # The PR has already landed. Reporting failure over cleanup would make a - # successful landing look like a broken one, and the next run would have - # nothing left to retry. - fails delete - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [ "$(deletes)" -eq 1 ] - [[ "$output" == *"could not delete origin/feat"* ]] -} - -@test "a run that stops instead of merging deletes nothing" { - # An abandoned branch is evidence and has to survive: the delete is on the - # MERGED path only, never on a `die` path. - not_linear - fails rebase - run "$LAND" - [ "$status" -eq 1 ] - [ "$(deletes)" -eq 0 ] -} - -# --- the landing lease (CLOUD-393) ----------------------------------------- -# -# `land`'s side of the lock, not the lock itself: tests/land-lock.bats owns the -# atomicity claim against a real remote. What these pin is the discipline — the -# lease is taken before anything can start a run, re-checked before the merge is -# asked for, and never leaked on a way out. - -lock_calls() { grep -c "^run land-lock $1\b" "$BATS_TEST_TMPDIR/misecalls" || true; } - -@test "a second land in this clone is refused before anything is spent (CLOUD-428)" { - # The landing lease cannot answer this — it is re-entrant per clone by - # design, so two lands in one checkout both acquire and the second heartbeat - # renews the first's lease. Measured 2026-08-12: three concurrent lands on - # one branch for ~30 minutes. - # - # The refusal has to land BEFORE any spend, so the assertions below are - # about what did NOT happen: no ready, no push, no lease taken. - task_fails singleton - pr_state MERGED - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"refusing to start a second land in this clone"* ]] - [ "$(call_order)" = "" ] - [ "$(ready_calls)" = "" ] - [ "$(lock_calls acquire)" -eq 0 ] -} - -@test "the lease is taken before the push, so no run starts unheld" { - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - # The acquire must precede the first push in the recorded order; a lease - # taken after the push would let CI start on a branch that never held it. - acq=$(grep -n '^run land-lock acquire$' "$BATS_TEST_TMPDIR/misecalls" | head -1 | cut -d: -f1) - [ -n "$acq" ] - [ "$(lock_calls acquire)" -ge 1 ] -} - -@test "a lease held by someone else waits instead of pushing, and says so" { - echo 1 >"$BATS_TEST_TMPDIR/rc.mise.land-lock" - pr_state MERGED - LAND_LOCK_MAX_WAITS=2 run "$LAND" - # No push, so no CI was spent on a branch that could not have landed. - [ "$status" -eq 4 ] - [[ "$output" == *"another branch holds the landing lease"* ]] - [ "$(call_order)" = "" ] - # And it ends on the saturation signal, not the lap cap: a wait is not a lap, - # but "never won a turn" still has to be a condition that can fire. - [[ "$output" == *"never won the landing lease in 2 attempts"* ]] -} - -@test "a lost lease is caught BEFORE the merge is asked for" { - # The fence. `held` fails only after the acquire has succeeded, which is the - # stolen-lease shape: the lap must lap rather than comment. - cat >"$STUB/mise" <>"$BATS_TEST_TMPDIR/misecalls" -case "\$2" in - verify) : >"$BATS_TEST_TMPDIR/receipt"; exit 0 ;; - verified) [ -f "$BATS_TEST_TMPDIR/receipt" ] || exit 1; exit 0 ;; - land-lock) [ "\$3" != held ] || exit 1; exit 0 ;; - main-watch) while :; do sleep 1; done ;; -esac -exit 0 -EOF - chmod +x "$STUB/mise" - run "$LAND" - [ "$status" -eq 5 ] - [[ "$output" == *"lease was lost before the comment"* ]] - [ ! -s "$BATS_TEST_TMPDIR/comments" ] -} - -@test "the lease is released on the merged path" { - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [ "$(lock_calls release)" -ge 1 ] -} - -@test "the lease is released on a die path too — a leak would wedge the fleet" { - not_linear - fails rebase - run "$LAND" - [ "$status" -eq 1 ] - [ "$(lock_calls release)" -ge 1 ] -} - -@test "the CI race waits on ITS OWN pids, never on every background job" { - # A bare `wait` waits for every background job of the shell, and since the - # landing lease one of them is the heartbeat — which by design never exits. - # So a bare wait blocks forever the moment CI answers: `land` sat at that - # line for five minutes with every check green and the SHA landable, logging - # nothing (CLOUD-383's shape, made certain by the heartbeat). - # - # Asserted structurally because reproducing it needs a never-exiting child, - # which is exactly what would hang this suite. Comments are stripped so this - # file's own rationale cannot satisfy the rule it explains. - run bash -c "sed 's/#.*//' '$REAL_LAND' | grep -nE '^[[:space:]]*wait[[:space:]]*(2>|\$)'" - [ "$status" -ne 0 ] -} - -@test "a watcher that shrugs off the TERM is escalated, never left to outlive the lap" { - # CLOUD-434. The group TERM measurably missed grandchildren inside one - # loaded gate run, and the survivors wedged bats through the fd they still - # held. The reap now verifies the group died and escalates a survivor to - # SIGKILL; this is that claim's discriminating case — with the escalation - # deleted, the stubborn watcher outlives the run and this goes red. - watcher_is_stubborn - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [ -s "$BATS_TEST_TMPDIR/stubborn.pids" ] - local pid deadline - while read -r pid; do - [ -n "$pid" ] || continue - deadline=$((SECONDS + 4)) - while alive_not_zombie "$pid" && [ "$SECONDS" -lt "$deadline" ]; do - sleep 0.1 - done - ! alive_not_zombie "$pid" - done <"$BATS_TEST_TMPDIR/stubborn.pids" -} - -@test "a detached descendant cannot hold bats' output stream — fd 3 is closed beneath the program under test" { - # CLOUD-434's other half. A watcher that leaves the process group entirely - # is beyond any reap; what makes it harmless is that nothing under the - # launcher carries bats' fd 3, so the file completes and the leak costs a - # stray process rather than a wedged gate. With the launcher's `exec 3>&-` - # deleted, the child holds the TAP fd and this goes red (bounded: the - # stand-in sleeps 30s rather than forever, so even the mutant run ends). - watcher_detaches - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - local pid - # CLOUD-457. The stand-in is spawned inside the verify stub and waited for - # there, where the fork happens — by the time this line runs the land has - # already exited, so there is nothing left here to poll against. What the - # body reads is the stub's verdict: the marker means the child was never - # scheduled, which is a statement about the BOX, not about the launcher. - # - # Skipping on it erases no coverage. The mutant this row catches is the - # launcher's `exec 3>&-` being deleted, and that mutant still spawns the - # child and still publishes its pid — the child is identical either way and - # differs only in the fd it inherits. So the mutant reaches the /proc - # assertion below, which is never skipped, and dies there. A skip here can - # only ever swallow "the scheduler was too busy to start a process", which - # no mutant of land can cause and no assertion of this row is about. - if [ -f "$BATS_TEST_TMPDIR/detach.unscheduled" ]; then - skip "CLOUD-457: the detached stand-in was never scheduled; nothing to read an fd from" - fi - [ -s "$BATS_TEST_TMPDIR/detached.pid" ] - pid=$(cat "$BATS_TEST_TMPDIR/detached.pid") - [ ! -e "/proc/$pid/fd/3" ] - kill -9 "$pid" 2>/dev/null || true -} - -@test "a land killed mid-race takes its watchers with it — the trap reaps the races too" { - # CLOUD-434's review finding, closed: dying THROUGH the exit trap used to - # reap only the heartbeat, and a TERMed land orphaned a live gh-polling - # ci-wait for a measured 10 minutes. The live race pids are globals the - # trap reaps, cleared after every inline reap — so this case kills a land - # mid-race and demands every watcher the lap spawned be gone once the trap - # has run. With reap_races neutered, the slow ci-wait stub survives and - # this goes red. - ci_is_slow - pr_state OPEN - "$LAND" >"$BATS_TEST_TMPDIR/late.out" 2>&1 3>&- & - land_pid=$! - local deadline pid - deadline=$((SECONDS + 10)) - while [ ! -s "$BATS_TEST_TMPDIR/watch.pids" ] && [ "$SECONDS" -lt "$deadline" ]; do - sleep 0.2 - done - [ -s "$BATS_TEST_TMPDIR/watch.pids" ] - # Let the race pair finish spawning before the kill, so the trap has both - # groups to reap rather than a half-started race. - sleep 0.5 - kill -TERM "$land_pid" - deadline=$((SECONDS + 5)) - while alive_not_zombie "$land_pid" && [ "$SECONDS" -lt "$deadline" ]; do - sleep 0.1 - done - ! alive_not_zombie "$land_pid" - while read -r pid; do - [ -n "$pid" ] || continue - deadline=$((SECONDS + 4)) - while alive_not_zombie "$pid" && [ "$SECONDS" -lt "$deadline" ]; do - sleep 0.1 - done - ! alive_not_zombie "$pid" - done <"$BATS_TEST_TMPDIR/watch.pids" -} - -# --- graded_runs judges the latest run too (CLOUD-436) ----------------------- -# -# `graded_runs` asks "does this SHA carry an answer yet", and it decides whether -# the ready that STARTS CI is fired. It shares $CI_REQUIRED_CHECKS with -# `checks-green` so the two cannot disagree about what is required; they now -# share the latest-per-name rule for the same reason. A draft-created head keeps -# its `opened`-event skip set forever, so counting a superseded run answers for -# a head that has no answer — and the ready never fires. - -head_is_graded_then_skipped() { - head_checks '{"check_runs":[ - {"name":"ci","status":"completed","conclusion":"success","started_at":"2026-08-12T01:00:00Z","id":1}, - {"name":"ci","status":"completed","conclusion":"skipped","started_at":"2026-08-12T02:00:00Z","id":2}]}' -} - -head_is_skipped_then_graded() { - head_checks '{"check_runs":[ - {"name":"ci","status":"completed","conclusion":"skipped","started_at":"2026-08-12T01:00:00Z","id":1}, - {"name":"ci","status":"completed","conclusion":"success","started_at":"2026-08-12T02:00:00Z","id":2}]}' -} - -@test "a head whose LATEST required run is a skip has no answer, so the ready is fired" { - # The discriminating case: without the dedup the superseded success is - # counted, the head reads as answered, and the one event that starts CI - # never happens. - is_draft - head_is_graded_then_skipped - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [ -n "$(ready_calls)" ] -} - -@test "a skip its own re-run superseded is an answer, so nothing buys a second run" { - # The residue shape that wedged #342 and #345, from the other side: the - # graded run is current, so this head must not spend another matrix. - is_draft - head_is_skipped_then_graded - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [ -z "$(ready_calls)" ] -} - -@test "a run main moved under is CANCELLED, not left to bill for an answer nobody reads" { - # CLOUD-369. The lap already ends early here (CLOUD-240's race), but ending - # the lap does not end the RUN: only the next push supersedes it, and with - # the re-priced lease budgets that push can be many whole waits away. So a - # doomed four-job matrix bills the whole time. - # - # CLOUD-240's refusal is scoped rather than absolute — "supersede your own - # runs, never someone else's" — and this reaches only runs on this lap's own - # head sha, which no other branch has. - ci_is_slow - main_moves_on_lap 1 - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"cancelled run 4242"* ]] - # The in-progress run, and only it: a completed run has nothing to cancel - # and asking would be a wasted call. - [[ "$(cancels)" == *"/4242/cancel"* ]] - [[ "$(cancels)" != *"/99/cancel"* ]] -} - -@test "a lap that CI answered cancels nothing — only a voided run is void" { - # The discriminating half. Cancelling on any lap ending would reach runs - # about to deliver a usable verdict, which inverts the economy: a green run - # is the one thing worth paying out. - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [ -z "$(cancels)" ] -} - -# --- speculative linearization and the admitted successor (CLOUD-369) -------- -# -# The lease bounds confirming runs at one, which is right for cost and wrong for -# latency: after every merge the queue is empty and the next branch starts cold. -# Two mechanisms close that window, and the cases below pin the property that -# makes each SAFE rather than merely fast — a speculation that cannot be pushed -# when the bet loses, and a successor slot that exactly one waiter can hold. - -spec_head() { echo "$1" >"$BATS_TEST_TMPDIR/lease.branch"; } -lease_lost() { echo 1 >"$BATS_TEST_TMPDIR/rc.mise.land-lock.acquire"; } -# A holder whose head is published AND whose CI answered green — the two facts -# admission now requires. `checks-green` exit 0 is the only admitting answer, so -# every other case below states the answer it is about. -holder_is_green() { - echo "${1:-h01dh01dh01dh01d}" >"$BATS_TEST_TMPDIR/lease.head" - rm -f "$BATS_TEST_TMPDIR/rc.mise.checks-green" -} - -@test "a waiter linearizes onto the HOLDER's head, not onto the main it is replacing" { - # Rebasing onto origin/main warms nothing: the holder is about to replace - # that commit, so the waiter is stale again the moment it wins. The main - # worth linearizing against is the one about to EXIST. - lease_lost - spec_head holder-branch - pr_state MERGED - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - grep -q '^fetch -q origin +refs/heads/holder-branch:refs/batten-spec/base$' "$BATS_TEST_TMPDIR/gitlog" - grep -q '^rebase 5peccccc5peccccc$' "$BATS_TEST_TMPDIR/gitlog" - [[ "$output" == *"the main that is about to exist"* ]] -} - -@test "a lease naming no head leaves the branch linearized on main, and says nothing" { - # Every lease minted before this change is exactly this, so during rollout - # the row is not an edge case — it is every lease. - lease_lost - pr_state MERGED - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" != *"speculatively linearized"* ]] - # NARROWED, not weakened (CLOUD-862). This read `!= *batten-spec*` — no - # mention of the ref at all — until the recovery path started probing it once - # per lap to settle a bet a dead run may have left. That probe is a - # `rev-parse`: read-only, no fetch, no rebase, and it is what makes the - # stranding detectable at all. What the row actually asserts is that no - # SPECULATION happened, so it names the two calls that would be one. - # PER LINE, not over the whole file. A `[[ $(cat …) != *fetch*batten-spec* ]]` - # reads the log as one string, so an unrelated `fetch` on one line and the - # recovery's `rev-parse` on another satisfy the glob together — it fired on - # exactly that and said a speculation had happened when none had. - ! grep -qE '^fetch .*batten-spec' "$BATS_TEST_TMPDIR/gitlog" - ! grep -qE '^rebase' "$BATS_TEST_TMPDIR/gitlog" -} - -@test "A CONFLICTING SPECULATION FALLS BACK — it is information, not a stop" { - # The conflict is real and arrives when that branch lands. But it is a - # conflict against a base that may never exist, so resolving it now would be - # resolving it against nothing, and DYING on it would stop a lap that has - # spent nothing and done nothing wrong. - lease_lost - spec_head holder-branch - echo 1 >"$BATS_TEST_TMPDIR/rc.spec_rebase" - pr_state MERGED - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"conflicts with this branch; not speculating"* ]] - # It ends on the wait backstop — the ordinary saturation signal — never on - # the rebase-conflict stop, which is reserved for the one real decision. - [[ "$output" == *"never won the landing lease"* ]] - [[ "$output" != *"resolve it and run land again"* ]] - grep -q '^rebase --abort$' "$BATS_TEST_TMPDIR/gitlog" -} - -@test "THE BET CANNOT BE PUSHED WHEN IT LOSES: a stale speculation is unwound first" { - # The hazard that makes this whole mechanism dangerous if done naively. A - # speculative rebase puts ANOTHER branch's unlanded commits into this - # branch's history; fast-forwarding from there would land somebody else's - # unmerged work as a side effect of ours. `origin/main --is-ancestor HEAD` - # does not catch it — the speculated base is itself a descendant of main. - lease_lost - spec_head holder-branch - pr_state MERGED - # main moves to something that is NOT the speculated base: the bet lost. - # From the second acquire on — the bet is placed during the first wait, and - # a world that moved before it was placed would never settle anything. - echo 0ther0ther0ther0 >"$BATS_TEST_TMPDIR/main_moves_in_wait" - echo 2 >"$BATS_TEST_TMPDIR/main_moves_after" - # Not admitted, so nothing is published and the assertion below is about the - # speculative state alone rather than about the successor path. - echo 1 >"$BATS_TEST_TMPDIR/rc.mise.land-lock.reserve" - LAND_LOCK_MAX_WAITS=4 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"did not land; unwinding"* ]] - grep -q '^reset -q --hard cafe1234cafe1234$' "$BATS_TEST_TMPDIR/gitlog" - # Nothing was published from the speculative state. - [ "$(call_order)" = "" ] -} - -@test "an unwind the tree refuses is a stop, not a lap onto an unknown HEAD" { - lease_lost - spec_head holder-branch - echo 1 >"$BATS_TEST_TMPDIR/rc.reset" - echo 0ther0ther0ther0 >"$BATS_TEST_TMPDIR/main_moves_in_wait" - echo 2 >"$BATS_TEST_TMPDIR/main_moves_after" - echo 1 >"$BATS_TEST_TMPDIR/rc.mise.land-lock.reserve" - pr_state OPEN - LAND_LOCK_MAX_WAITS=4 run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"could not unwind the speculative rebase"* ]] - [ "$(call_order)" = "" ] -} - -@test "THE SECOND MATRIX: an admitted successor readies and pushes without the lease" { - # The saving. Its run overlaps the holder's merge instead of starting cold - # after it, which is the ~8 minutes of idle main this closes. - lease_lost - spec_head holder-branch - holder_is_green - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"admitted as the successor"* ]] - [[ "$output" == *"overlaps the merge in flight"* ]] - # Readied and pushed — but never commented: the fast-forward needs the lease, - # and asking for a merge without holding it is the collision the lease exists - # to prevent. - [[ "$(call_order)" == "ready push"* ]] - [ "$(comments)" -eq 0 ] -} - -@test "a verify failure on a SPECULATIVE tree names the borrowed base" { - # CLOUD-727. `land` already holds the fact — it printed the base a few lines - # earlier — and then emitted an unconditional message whose two sentences are - # both wrong here: "reproduce and fix locally" points at a defect the author did - # not write, and "CI is not where you discover this" implies discovery is overdue - # when the tree under test is not the one the author will ever push. - # - # Measured 2026-08-19: a two-commit branch touching only `.serena/memories/*` - # failed on two findings in files neither commit touched, and rebasing off the - # speculative base was green first try. On 2026-08-22 the masked failure was in - # `land`'s OWN suite — the most expensive possible wrong place to send someone. - # - # DRIVEN THROUGH THE RECOVERY PATH, because the lap's own speculation is placed - # AFTER verify runs: a bet is adopted at the top of the lap, so `spec_base` is - # set before the first verify rather than after it. The bet must settle as - # PENDING — the ordinary reading, and the only one that leaves the tree - # linearized while the lap proceeds. - stranded - spec_head holder-branch - echo 0 >"$BATS_TEST_TMPDIR/rc.spec_live" - task_fails verify - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"this tree is SPECULATIVE"* ]] - # BOTH recoveries, because `rebase --onto` is not the only one and the cheaper - # one is available whenever the remote still holds the clean branch. - [[ "$output" == *"rebase --onto origin/main"* ]] - [[ "$output" == *"reset --hard"* ]] - # A SUSPICION, never a verdict: this row retracted two attributions in one day - # for treating "speculative" as the explanation because it was the salient - # difference, so the message must say how to find out rather than decide. - [[ "$output" == *"may not be yours"* ]] - [[ "$output" == *"If it still fails off the borrowed base, it is yours"* ]] - # The advice that is wrong here must not also be present. - [[ "$output" != *"Reproduce and fix locally"* ]] -} - -@test "a verify failure with NO speculation still gets the original advice" { - # The anti-vacuity twin, and what stops the fix widening a message that is - # already right in the common case. Same failure, no borrowed base. - task_fails verify - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"Reproduce and fix locally"* ]] - [[ "$output" != *"this tree is SPECULATIVE"* ]] -} - -@test "A WAITER THAT IS NOT ADMITTED STAYS IN DRAFT — this is what bounds the cost" { - # The negative that gives the case above its meaning. Without it, "every - # waiter readies" would pass the test above and spend a matrix per session, - # which is the defect the whole issue is about. - lease_lost - spec_head holder-branch - holder_is_green - echo 1 >"$BATS_TEST_TMPDIR/rc.mise.land-lock.reserve" - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" != *"admitted as the successor"* ]] - [ "$(call_order)" = "" ] - [ "$(ready_calls)" = "" ] -} - -@test "the successor reserves only once, however many laps it waits" { - # Re-reserving each lap would rewrite the ref to say what it already says. - lease_lost - spec_head holder-branch - holder_is_green - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=3 run "$LAND" - [ "$(lock_calls reserve)" -eq 1 ] -} - -@test "MAIN MOVING DURING THE WAIT: the winner laps rather than confirming a doomed head" { - # `acquire` waits up to a full TTL, so the winner is at its most stale in the - # instant it wins. Readying here buys a matrix the fast-forward will refuse — - # which is the oldest waste in this loop, measured on PR #325 as 8 laps, 8 - # greens and zero commits landed. - echo 0ther0ther0ther0 >"$BATS_TEST_TMPDIR/main_moves_in_wait" - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=2 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"main moved to"* ]] - [[ "$output" == *"lapping rather than confirming a head it will refuse"* ]] - # Nothing spent, and the lease handed straight back rather than held across - # a rebase the next lap will do anyway. - [ "$(call_order)" = "" ] - [ "$(lock_calls release)" -ge 1 ] -} - -@test "a lap whose main did not move confirms and proceeds — the negative of the case above" { - # Without this, a re-confirmation that ALWAYS lapped would pass the case - # above and land nothing, ever. - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" != *"lapping rather than confirming"* ]] - [[ "$(call_order)" == *push* ]] -} - -@test "the successor's run is bought ONCE, not re-pushed on every lap it waits" { - # A successor waits many laps by design. Re-entering the ready/push pair each - # time would push an unchanged head — which emits no `synchronize`, buys - # nothing, and drops into the `--undo` re-fire path that exists for a - # different case entirely. - lease_lost - spec_head holder-branch - holder_is_green - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=3 run "$LAND" - [ "$status" -eq 4 ] - [[ "$(call_order)" == "ready push"* ]] - [ "$(grep -c '^push$' "$BATS_TEST_TMPDIR/calls")" -eq 1 ] - [[ "$output" == *"its run is already in flight"* ]] -} - -# --- an abandoned base is not a pending one (CLOUD-495) ---------------------- -# -# The unwind above fires on ONE reading of a lost bet: `main` moved and took -# something else. The holder abandoning while `main` stays put reads as *pending* -# through that predicate, and pending returns 0 forever — no lap budget, no -# re-read of who holds the lease. So the waiter keeps a borrowed tree, later wins -# the lease, and the in-hold re-confirmation asks only whether `main` moved. It -# did not. What lands is another branch's unmerged commits. -# -# The predicate that closes it: a bet is LIVE only while the branch the lease -# names *now* is somebody else's and still carries the base. Everything else — -# lease freed, lease passed on, lease won by us, lease unreadable — is stale. - -# The holder lets go from the Nth acquire on. Empty means the lease freed; a name -# means it passed to that branch. -lease_abandons() { - printf '%s' "${1-}" >"$BATS_TEST_TMPDIR/lease_abandons_to" - echo "${2:-2}" >"$BATS_TEST_TMPDIR/lease_abandons_after" -} -bet_is_dead() { echo 1 >"$BATS_TEST_TMPDIR/rc.spec_live"; } - -@test "AN ABANDONED HOLDER IS NOT A PENDING BET: the lease freed unwinds it" { - # The hazard in one row. Nothing about `main` changes, so every reading the - # old settle had says "the holder is still landing" — while the holder is - # gone and the base can never land. - lease_lost - spec_head holder-branch - lease_abandons "" - echo 1 >"$BATS_TEST_TMPDIR/rc.mise.land-lock.reserve" - pr_state MERGED - LAND_LOCK_MAX_WAITS=4 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"no longer the base that is about to land; unwinding"* ]] - grep -q '^reset -q --hard cafe1234cafe1234$' "$BATS_TEST_TMPDIR/gitlog" - # `main` never moved, which is what makes this distinct from the case above. - [[ "$output" != *"did not land; unwinding"* ]] - [ "$(call_order)" = "" ] -} - -@test "the lease passing to a branch that does not carry our base unwinds it" { - # The other abandonment shape: somebody else won the lease, and their head has - # nothing to do with the commit we bet on. - lease_lost - spec_head holder-branch - lease_abandons other-branch - bet_is_dead - echo 1 >"$BATS_TEST_TMPDIR/rc.mise.land-lock.reserve" - pr_state MERGED - LAND_LOCK_MAX_WAITS=4 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"no longer the base that is about to land; unwinding"* ]] - grep -q '^fetch -q origin +refs/heads/other-branch:refs/batten-spec/live$' "$BATS_TEST_TMPDIR/gitlog" -} - -@test "A LIVE BET IS LEFT ALONE — without this, the unwind fires every lap" { - # The negative that gives the two rows above their meaning, and the property - # the pending arm exists for: unwinding an undecided bet would undo the - # linearization each lap and leave the mechanism running while achieving - # nothing. The holder still holds, and still carries the base. - lease_lost - spec_head holder-branch - echo 1 >"$BATS_TEST_TMPDIR/rc.mise.land-lock.reserve" - pr_state MERGED - LAND_LOCK_MAX_WAITS=4 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" != *"unwinding"* ]] - [[ "$(cat "$BATS_TEST_TMPDIR/gitlog")" != *"reset -q --hard"* ]] -} - -@test "a liveness read that fails is stale, never live — the fetch fails closed" { - # An unreadable lease must never certify a borrowed tree. Failing open here - # would make a network blip the thing that lands somebody else's commits. - lease_lost - spec_head holder-branch - lease_abandons other-branch - echo 1 >"$BATS_TEST_TMPDIR/rc.fetch_live" - echo 1 >"$BATS_TEST_TMPDIR/rc.mise.land-lock.reserve" - pr_state MERGED - LAND_LOCK_MAX_WAITS=4 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"no longer the base that is about to land; unwinding"* ]] -} - -@test "WINNING THE LEASE SETTLES THE BET FIRST: no borrowed tree is readied, pushed or merged" { - # The severe case, end to end. The waiter holds a bet, the holder abandons, - # `main` stays put — and the waiter then WINS. Holding the lease with the base - # not yet on `main` can only mean the branch we bet on is gone, so the settle - # inside the hold is what stops the `/fast-forward`. Merged is asserted absent - # rather than present: nothing may be published from a borrowed tree. - spec_head holder-branch - echo 1 >"$BATS_TEST_TMPDIR/rc.mise.land-lock.acquire" - lease_abandons "" - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=2 run "$LAND" - [[ "$output" == *"no longer the base that is about to land; unwinding"* ]] - # The unwind precedes anything that spends: no ready, no push, no comment - # while the borrowed range was still in the tree. - unwind=$(grep -n 'reset -q --hard' "$BATS_TEST_TMPDIR/gitlog" | head -1 | cut -d: -f1) - [ -n "$unwind" ] - [ "$(comments)" -eq 0 ] -} - -@test "a bet already PUSHED is re-drafted before its remote is rewound" { - # The successor publishes (`ready`, `push`), so an unwind that touched only - # the tree would leave origin holding the foreign commits with an open PR - # pointing at them — which is exactly the two-PRs-at-one-SHA state measured on - # this loop. Re-draft first, so the corrective push buys no matrix: the same - # close-the-tap-before-moving-the-ref ordering the red path already uses. - lease_lost - spec_head holder-branch - holder_is_green - lease_abandons "" 3 - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=4 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"no longer the base that is about to land; unwinding"* ]] - [[ "$output" == *"re-drafted"* ]] - # The successor's publish (ready, push), then the re-draft and the rewinding - # push. Both `gh pr ready` forms record as `ready`, so the ORDER is read from - # calls and the second one's `--undo` from what it was invoked with. - [[ "$(call_order)" == "ready push ready push"* ]] - [ "$(grep -c -- '--undo' "$BATS_TEST_TMPDIR/ready")" -ge 1 ] - # The corrective push is force-with-lease, like every other push this loop - # makes: rewinding a published branch is exactly the case that ref guard is - # for. - grep -q 'push --force-with-lease' "$BATS_TEST_TMPDIR/gitlog" -} - -# --- CLOUD-483: a red that never reached a verdict is not a verdict ----------- -# -# `nonverdict-scan` owns the classification; these rows grade what `land` does -# with it. The trio is the point: absorb, refuse to absorb, and the empty case -# that a naive "every record is a nonverdict" test gets wrong by vacuity. - -nonverdict_records() { printf '%s\n' "$1" >"$BATS_TEST_TMPDIR/nonverdict"; } -reruns() { - if [ -s "$BATS_TEST_TMPDIR/reruns" ]; then grep -c . "$BATS_TEST_TMPDIR/reruns"; else echo 0; fi -} - -@test "CLOUD-483: a run that died before any mise step is re-run, not reported red" { - # Measured on #376: commit-lint died in the toolchain setup step, so it - # never linted a commit — and `land` sent the agent to reproduce a failure that - # passes locally. `gh run rerun --failed` re-queued that one job; a fresh push - # would have bought the whole matrix. - nonverdict_records "nonverdict run=4242 job=commit-lint step=Run jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654" - task_fails ci-wait - run "$LAND" - [[ "$output" != *"verify and CI disagree"* ]] - [[ "$output" == *"before reaching a verdict"* ]] - [ "$(reruns)" -ge 1 ] -} - -@test "CLOUD-483: a job that reached a verdict is red, and is never re-run" { - # The anti-regression half. A change that re-ran unconditionally would pass the - # row above and re-run every genuine failure until its budget ran out. - nonverdict_records "verdict run=4242 job=ci step=Run mise run ci" - task_fails ci-wait - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"CI is red"* ]] - [ "$(reruns)" -eq 0 ] -} - -# --- abandoning the matrix on a genuine red (CLOUD-900) --------------------- -# -# `land` is the one call site: `checks-green` has already decided by the time the -# red arm is reached, so re-deriving the verdict anywhere else would be a second -# authority for one fact. What these cases pin is the ORDERING inside that arm — -# which of the three things arriving there may spend a cancellation. The task's -# own behaviour is `tests/abandon-matrix.bats`; here only the call is asserted, -# through the `mise` stub that records every task a lap runs. - -abandons() { grep -c '^run abandon-matrix' "$BATS_TEST_TMPDIR/misecalls" || true; } - -@test "CLOUD-900: a genuine red abandons the rest of the matrix" { - # The acceptance case. Past the lease test and past the transient test, the - # failure is an answer about the tree — so every sibling still running is - # spending to re-learn a verdict that is already in. - task_fails ci-wait - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"CI is red"* ]] - [ "$(abandons)" -ge 1 ] -} - -@test "CLOUD-900: a run CI DECLINED abandons nothing — it is not a verdict" { - # The lease arm (CLOUD-470). Nothing about this branch is broken: the run was - # cancelled because another branch holds the lease, and the remedy is a - # rebase. Cancelling its siblings would spend the fleet a matrix to punish a - # branch that did nothing wrong, and the next lap needs those runs. - lease_declines - task_fails ci-wait - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"CANCELLED"* ]] - [ "$(abandons)" -eq 0 ] -} - -@test "CLOUD-900: a provisioning transient abandons nothing — the jobs get re-run" { - # THE ROW THAT DECIDED THE DESIGN. `gh run rerun --failed` re-queues the - # failed jobs OF A RUN; nothing restores a sibling run that was cancelled. So - # abandoning here would convert a one-job re-run into a fresh matrix, making - # the transient path strictly more expensive than before the saving existed. - # - # This is also why the call sits in `land` rather than in each failing job: a - # job that dies in provisioning cannot tell that it did, and would abandon on - # its way out. - nonverdict_records "nonverdict run=4242 job=commit-lint step=Run jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654" - task_fails ci-wait - run "$LAND" - [[ "$output" == *"before reaching a verdict"* ]] - [ "$(abandons)" -eq 0 ] -} - -@test "CLOUD-900: a lap CI answered green abandons nothing" { - # The discriminating half, and the partner of `a lap that CI answered cancels - # nothing` above: a green run is the one thing worth paying out in full. - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [ "$(abandons)" -eq 0 ] -} - -@test "CLOUD-483: EMPTY IS NOT UNANIMOUS — no records is red, not absorbed" { - # The vacuity case. "Every record is a nonverdict" is trivially true of no - # records at all, which is what an unreadable payload, a roster miss, or a - # failure confined to the `final` fan-in each produce. Absorbing that would - # re-run a genuinely red branch until the budget ran out, with no evidence - # that anything was transient. - nonverdict_records "" - task_fails ci-wait - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"CI is red"* ]] - [ "$(reruns)" -eq 0 ] -} - -@test "CLOUD-483: the retry budget is a COUNT, and exhausting it stops" { - # Three in a row is a broken provisioning path, not a flake — and re-running - # again would spend jobs to learn the same thing. A count, never a clock: the - # no-wall-clock sensor below must keep passing. - nonverdict_records "nonverdict run=4242 job=ci step=Run actions/checkout@3d3c42e" - task_fails ci-wait - LAND_MAX_TRANSIENTS=1 run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"not a flake any more"* ]] -} - -@test "CLOUD-483: a re-run the API refuses stops, naming the command" { - # The absorbed path depends on a write succeeding. If it does not, laping - # would poll the same red forever, so this stops and hands over the one - # command that fixes it. - nonverdict_records "nonverdict run=4242 job=ci step=Run actions/checkout@3d3c42e" - : >"$BATS_TEST_TMPDIR/rc.rerun" - task_fails ci-wait - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"re-running run"* ]] - [[ "$output" == *"gh run rerun"* ]] -} - -# --- CLOUD-383: the race waits without bash 4 -------------------------------- - -# Fails the Nth `mkfifo`, so a case can reach the SECOND race's rendezvous — the -# first one succeeding is what gets the lap as far as the CI wait. -mkfifo_fails_on() { - cat >"$STUB/mkfifo" </dev/null || echo 0) -n=\$((n + 1)); echo "\$n" >"$BATS_TEST_TMPDIR/mkfifo.calls" -[ "\$n" != "$1" ] || exit 1 -exec /usr/bin/mkfifo "\$@" -EOF - chmod +x "$STUB/mkfifo" -} - -@test "CLOUD-383: a rendezvous that cannot be created stops, rather than guessing" { - # The race decides the lap: whichever of verify and main-watch answers first - # ends it, and the loser's EMPTY rc file is what says it never finished. With - # no rendezvous there is nothing to wait on, so both rc files would read empty - # and the lap would report "no verdict" over a race that never ran. - mkfifo_fails_on 1 - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"rendezvous for verify"* ]] -} - -@test "CLOUD-383: the CI wait's rendezvous stops too, at top level" { - # The second call site, and the reason there are two stops rather than one: - # `new_rendezvous` returns its failure instead of dying, so each caller must - # die itself. A `die` inside the command substitution would exit the subshell - # and the lap would continue with an empty path — which is exactly what the - # first cut of this did, and what CLOUD-467 warned about in this same file. - mkfifo_fails_on 2 - pr_state OPEN - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"rendezvous for the CI wait"* ]] -} - -@test "CLOUD-383: the races carry no bash-4 construct" { - # THE PORTABILITY PROPERTY, structural because no suite running on bash 5 can - # observe it. `wait -n` needs 4.3 and its PID-list form needs 5.1; macOS ships - # 3.2 as /bin/bash and `mise registry` carries no bash, so a single executable - # `wait -n` here is `land` being unrunnable on a platform `darwin-link` makes - # a required check. Prose mentions are exempt: the ban is on running it. - run grep -cE '^[[:space:]]*wait -n' "$REAL_LAND" - [ "$output" -eq 0 ] - # And the rendezvous it was replaced with is really there, so this row cannot - # pass by the races having been deleted. - run grep -c 'await_first' "$REAL_LAND" - [ "$output" -ge 2 ] -} - -# --- CLOUD-518: the webhook subscription the harness arms on every PR ---------- -# -# `land` DOES drop it now (CLOUD-790). The 401 this block used to cite was a -# missing `Authorization` header, not a missing credential: with the container's -# session-ingress token as a bearer, `POST /v2/ccr-sessions//github/mcp` -# serves `unsubscribe_pr_activity`. So `drop` runs first and `check` still -# decides, because `drop` fails open on everything it cannot establish and the -# agent's manual `record` remains the way through when it does. -# -# The gate's own suite (tests/pr-unsubscribed.bats) covers both the recording and -# the actor; these rows are about the LANDING: that the drop is attempted for THIS -# PR on every lap, that the refusal stops the lap before anything is spent, and -# that a dropped subscription lets a landing proceed. -# -# Every other case in this file leaves `pr-unsubscribed` passing, which is both -# the off-harness reading and the ordinary one — so nothing else in the suite is -# perturbed by putting a gate on the critical path. - -@test "CLOUD-518: a session that has not dropped the subscription cannot land" { - # The refusal the whole change exists to produce. It must arrive BEFORE any - # spend: no ready, no push, no comment, and no CI. - # - # THE WORLD IS TERMINAL AND THE RUN IS BOUNDED, both so that the MUTATION - # fails this row instead of hanging it. With the check disabled the lap runs - # on, and `main-watch` never answers by default — so a row written as a bare - # `run "$LAND"` blocks forever rather than going red, which is a mutation - # reported as caught by a case that never finished. Measured: wedged for 100 - # minutes inside `mise run mutant`. `pr_state MERGED` gives the un-gated path - # a fast, wrong ending; `run_timeout` is the backstop if it finds another way - # to stall. - task_fails pr-unsubscribed - pr_state MERGED - local out="$BATS_TEST_TMPDIR/land.out" rc=0 - run_timeout -k 1 20 "$LAND" >"$out" 2>&1 || rc=$? - output=$(cat "$out") - status=$rc - [ "$status" -eq 1 ] - [[ "$output" == *"webhook subscription has not been dropped"* ]] - # Nothing was spent: the PR was never readied, nothing was pushed, and the - # fast-forward was never asked for. - [ -z "$(ready_calls)" ] - [[ "$(call_order)" != *push* ]] - [ "$(comments)" -eq 0 ] - # Not even the verify receipt was consulted — the stop is the first thing. - [ "$(verify_calls)" -eq 0 ] -} - -@test "CLOUD-518: the check runs against THIS PR, not some other" { - # A receipt for the wrong pull request is the honest error the gate is built - # for, so `land` has to hand it the PR it is actually landing. - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - run grep -c '^run pr-unsubscribed check 150$' "$BATS_TEST_TMPDIR/misecalls" - [ "$output" -ge 1 ] -} - -@test "CLOUD-790: the landing makes the unsubscribe call itself, for THIS PR" { - # The click this removes. Before CLOUD-790 the only way to satisfy the gate - # below was an agent tool call the connector sets to `always_ask` — one human - # approval per landing, against a subscription the harness armed with none. - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - run grep -c '^run pr-unsubscribed drop 150$' "$BATS_TEST_TMPDIR/misecalls" - [ "$output" -ge 1 ] -} - -@test "CLOUD-790: a drop that could not happen does not stop the landing itself" { - # `drop` fails open, so a session that cannot reach the endpoint must land - # exactly as it did before — refused by `check`, not by the actor in front of - # it. An actor that could refuse would be a second way to wedge a landing. - task_fails pr-unsubscribed - pr_state MERGED - local out="$BATS_TEST_TMPDIR/land.out" rc=0 - run_timeout -k 1 20 "$LAND" >"$out" 2>&1 || rc=$? - output=$(cat "$out") - status=$rc - [ "$status" -eq 1 ] - # The refusal is the GATE's, naming the receipt — not a failure of the drop. - [[ "$output" == *"webhook subscription has not been dropped"* ]] -} - -@test "CLOUD-518: a dropped subscription lets the landing proceed untouched" { - # The gate passing must change nothing else about a lap — the same merge, the - # same single fast-forward comment. - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" == *"is MERGED"* ]] - [ "$(comments)" -eq 1 ] -} - -# --- admission is conditioned, not automatic (CLOUD-369) --------------------- -# -# The second matrix is a favourable bet BECAUSE it is conditioned. A holder that -# is green and holds the lease will almost certainly fast-forward, so the -# successor's run overlaps a merge that is about to happen. Bought behind a -# holder whose CI has not answered, the same run is voided the moment that holder -# goes red — an extra matrix spent to save nothing, which is the waste this issue -# exists to remove reappearing inside its own fix. -# -# The cases below are the NEGATIVES. Their absence is precisely why the clause -# was dropped unnoticed the first time: tests written from the implementation can -# only confirm it, and a dropped condition has no code to write a test against. - -@test "CLOUD-369 clause b1-neg — a holder whose CI answers RED admits nobody" { - lease_lost - spec_head holder-branch - holder_is_green - head_verdict 1 - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"has not gone green"* ]] - [[ "$output" != *"admitted as the successor"* ]] - [ "$(lock_calls reserve)" -eq 0 ] - [ "$(call_order)" = "" ] -} - -@test "CLOUD-369 clause b1-neg — a holder whose CI has NOT ANSWERED admits nobody" { - # Exit 3 is "no answer yet", the commonest reading of all: the holder has - # only just pushed. Not yet is the safe direction — declining costs one poll. - lease_lost - spec_head holder-branch - holder_is_green - head_verdict 3 - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"has not gone green"* ]] - [ "$(lock_calls reserve)" -eq 0 ] - [ "$(call_order)" = "" ] -} - -@test "CLOUD-369 clause b1-neg — a holder whose CI COULD NOT BE READ admits nobody" { - # Exit 2 is "could not look". This gate declines rather than failing open: - # waving a matrix through on an unreadable answer spends money on a guess, - # and the cost of declining is one poll. - lease_lost - spec_head holder-branch - holder_is_green - head_verdict 2 - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"has not gone green"* ]] - [ "$(lock_calls reserve)" -eq 0 ] -} - -@test "CLOUD-369 clause b1-neg — a lease naming no head admits nobody" { - # Every lease minted before the `head:` field is exactly this, so during any - # rollout the row is not an edge case. The holder's CI cannot be read at all, - # which is not the same as red and is reported as its own reason. - lease_lost - spec_head holder-branch - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"names no head"* ]] - [ "$(lock_calls reserve)" -eq 0 ] - [ "$(call_order)" = "" ] -} - -@test "CLOUD-369 clause b1-pos — a GREEN holder still admits exactly one waiter" { - # The positive the negatives give meaning to. A conditioning that also stopped - # admitting green holders would pass every case above and deliver nothing. - lease_lost - spec_head holder-branch - holder_is_green - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"admitted as the successor behind a green holder"* ]] - [ "$(lock_calls reserve)" -eq 1 ] - [[ "$(call_order)" == "ready push"* ]] -} - -@test "CLOUD-369 clause e — a waiter whose base CONFLICTS is not admitted" { - # The conflict `speculate` already computed, now spent on the admission - # rather than discarded. A base that will not apply guarantees the run is - # voided: it grades a head the fast-forward refuses, and the rebase that - # follows still has to resolve the same conflict. - lease_lost - spec_head holder-branch - holder_is_green - echo 1 >"$BATS_TEST_TMPDIR/rc.spec_rebase" - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"could never pay"* ]] - [[ "$output" != *"admitted as the successor"* ]] - [ "$(lock_calls reserve)" -eq 0 ] - [ "$(call_order)" = "" ] -} - -@test "CLOUD-369 clause e — a waiter whose base APPLIES CLEANLY still is admitted" { - # The negative of the negative: a conflict arm that refused everyone would - # pass the case above and silently delete the whole mechanism. - lease_lost - spec_head holder-branch - holder_is_green - echo 0 >"$BATS_TEST_TMPDIR/rc.spec_rebase" - is_draft - pr_state OPEN - LAND_LOCK_MAX_WAITS=1 run "$LAND" - [ "$status" -eq 4 ] - [[ "$output" == *"admitted as the successor"* ]] - [ "$(lock_calls reserve)" -eq 1 ] -} - -# --- CLOUD-861: a full disk is the environment, not a verdict on this tree ---- - -@test "CLOUD-861: an ENOSPC during verify is reported as the environment, not as a defect to reproduce" { - # THE DISCRIMINATING ROW, and it is red against `land` as it stood on - # 2026-08-21. Measured that day: `target-prune` passed the lap with 6242MB - # against its 4096MB floor, the `cargo test` link step then consumed all of - # it, and the stop said "verify failed ... Reproduce and fix locally" over a - # tree with nothing wrong in it. The advice is correct for a real refusal and - # actively misleading for this one — the same misattribution CLOUD-811 - # records in `linear-check`. - task_fails verify - printf '%s\n' \ - '[hooks] test - rustc-LLVM ERROR: IO failure on output stream: No space left on device' \ - >"$BATS_TEST_TMPDIR/rc.mise.verify.says" - run "$LAND" - [ "$status" -eq 1 ] - # Named as the environment, with the reclaim to run. - [[ "$output" == *"the disk filled"* ]] - [[ "$output" == *"target-prune"* ]] - [[ "$output" == *"incremental"* ]] - # And NOT as this branch's defect. This is the assertion that fails today. - [[ "$output" != *"Reproduce and fix locally"* ]] - # Still a stop on lap 1, not a retry loop into the backstop: the disk does - # not empty itself, so lapping would spend the budget re-hitting the wall. - [ "$(verify_calls)" -eq 1 ] - [ "$(comments)" -eq 0 ] -} - -@test "an ordinary verify failure still says reproduce it locally" { - # ANTI-VACUITY. The row above is a narrowing, and a narrowing that swallowed - # the general case would turn every refusal into "check your disk" — which - # is CLOUD-811's defect rebuilt facing the other way. A failure carrying no - # ENOSPC line keeps the advice that is right for it. - task_fails verify - printf '%s\n' \ - '[hooks] crates/batten/tests/primitives.rs:1171 no-consumer-repo-name' \ - >"$BATS_TEST_TMPDIR/rc.mise.verify.says" - run "$LAND" - [ "$status" -eq 1 ] - [[ "$output" == *"Reproduce and fix locally"* ]] - [[ "$output" != *"the disk filled"* ]] -} - -# --- CLOUD-862: a bet the process that placed it never settled --------------- -# -# The measured incident: a `land` speculated onto a sibling branch's head, was -# stopped before it could settle, and the NEXT `land` ran a full clean `verify` -# and reached the push carrying seven of that branch's unlanded commits. The -# state was on disk the whole time; `settle_speculation` opened on a shell -# variable and so never looked. -# -# `stranded` is the lever the suite lacked: a bet ref present with no in-process -# state behind it, which is exactly what a killed run leaves. -stranded() { - echo 5peccccc5peccccc >"$BATS_TEST_TMPDIR/specbet" - # The tree IS built on the adopted base — it was rebased onto it before the - # run died. Without this the ref is stale rather than stranded, and the two - # must not be confused. - echo 0 >"$BATS_TEST_TMPDIR/rc.spec_ontree" -} - -@test "CLOUD-862: a bet left by a dead run is adopted and unwound before anything is pushed" { - # THE DISCRIMINATING ROW. Red against `land` without the recovery: with no - # `spec_base` set in this process, settle returned 0 on its first line and - # the borrowed range rode all the way to the push. - stranded - bet_is_dead - pr_state MERGED - run "$LAND" - [[ "$output" == *"adopting an unsettled speculation"* ]] - [[ "$output" == *"no longer landing"* ]] - # Unwound by REPLAY, not by reset: an adopted bet has no undo point, and the - # base is all that is needed to put this branch's own commits back on main. - grep -q '^rebase --onto origin/main 5peccccc5peccccc$' "$BATS_TEST_TMPDIR/gitlog" - # And the ref is gone, so the next run does not adopt it a second time. - [ ! -f "$BATS_TEST_TMPDIR/specbet" ] -} - -@test "CLOUD-862: an adopted bet whose base LANDED keeps the tree and just drops the ref" { - # ANTI-VACUITY, and the reading that stops the fix being "never speculate". - # The holder landed while nobody was watching, so the linearization was - # correct all along and unwinding it would throw away a warm tree for nothing. - stranded - echo 0 >"$BATS_TEST_TMPDIR/rc.spec_landed" - pr_state MERGED - run "$LAND" - [[ "$output" == *"the speculation landed"* ]] - [[ "$(cat "$BATS_TEST_TMPDIR/gitlog")" != *"rebase --onto origin/main"* ]] - [ ! -f "$BATS_TEST_TMPDIR/specbet" ] -} - -@test "CLOUD-862: a bet ref naming a commit this tree is not built on is dropped, not acted on" { - # The third reading, and the one that keeps the ref honest rather than - # merely present: a ref left by a clone that reset names a base this HEAD - # never carried. Adopting it would replay off a commit that is not in the - # history and take this branch's own work with it. - echo 5peccccc5peccccc >"$BATS_TEST_TMPDIR/specbet" - echo 1 >"$BATS_TEST_TMPDIR/rc.spec_ontree" - pr_state MERGED - run "$LAND" - [[ "$output" != *"adopting an unsettled speculation"* ]] - [[ "$(cat "$BATS_TEST_TMPDIR/gitlog")" != *"rebase --onto origin/main"* ]] - [ ! -f "$BATS_TEST_TMPDIR/specbet" ] -} - -@test "a run with no bet ref is untouched by the recovery path" { - # ANTI-VACUITY for the whole mechanism: the ordinary land, which is every - # land, must not pay for or notice any of this. - pr_state MERGED - run "$LAND" - [ "$status" -eq 0 ] - [[ "$output" != *"adopting an unsettled speculation"* ]] -} diff --git a/tests/main-watch.bats b/tests/main-watch.bats deleted file mode 100644 index f35924e13..000000000 --- a/tests/main-watch.bats +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/main-watch.sh -# The second half of a landing lap's wait (CLOUD-240): "is this SHA still -# landable", raced against `ci-wait`'s "is this SHA green". -# -# The stub answers with real HTTP framing — status line, headers, blank line, -# body — because that framing is what the task parses. A stub that handed back -# only the body would leave the 304 path, which is the whole reason a second -# poller is affordable, untested. - -setup() { - # tests/helpers.bash: `sed_i` / `run_timeout`, standing in for GNU - # tools a stock macOS does not ship (CLOUD-282). - load helpers - WATCH="$BATS_TEST_DIRNAME/../mise-tasks/main-watch.sh" - STUB="$BATS_TEST_TMPDIR/bin" - mkdir -p "$STUB" - PATH="$STUB:$PATH" - # A FRACTION, because the poll interval is scaffolding here and not the - # property (CLOUD-390). Five cases each burned ~3s waiting out whole-second - # cycles — ~15s of the suite — to observe behaviour that is about WHICH - # answer wins, never about how long a cycle takes. `main-watch` passes this - # straight to `sleep`, which takes fractions, and `tests/target-ensure.bats` - # already relies on that. Every assertion below is unchanged: a case that - # stopped exercising its race would be worse than a slow one. - export PATH MAIN_WATCH_INTERVAL=0.2 - : >"$BATS_TEST_TMPDIR/requests" - stub_gh -} - -# The Nth call answers with the Nth response file, the last one sticking. Every -# invocation records its own arguments, which is how the conditional request is -# asserted rather than assumed. -stub_gh() { - cat >"$STUB/gh" <<-EOF - #!/usr/bin/env bash - printf '%s\n' "\$*" >>"$BATS_TEST_TMPDIR/requests" - n=\$(cat "$BATS_TEST_TMPDIR/calls" 2>/dev/null || echo 0) - n=\$((n + 1)) - echo "\$n" >"$BATS_TEST_TMPDIR/calls" - cat "$BATS_TEST_TMPDIR/resp.\$n" 2>/dev/null || cat "$BATS_TEST_TMPDIR/resp.last" - EOF - chmod +x "$STUB/gh" - rm -f "$BATS_TEST_TMPDIR/calls" -} - -# A 200 carrying a ref object, with an ETag the next request should echo back. -ref_response() { - local file="$1" sha="$2" etag="${3:-\"abc\"}" extra="${4:-}" - { - echo "HTTP/2.0 200 OK" - echo "ETag: $etag" - [ -z "$extra" ] || echo "$extra" - echo - printf '{"ref":"refs/heads/main","object":{"sha":"%s","type":"commit"}}' "$sha" - } >"$BATS_TEST_TMPDIR/$file" -} - -# 304 Not Modified: no body, no rate-limit charge. The reading stands. -not_modified() { - { - echo "HTTP/2.0 304 Not Modified" - echo "ETag: \"abc\"" - echo - } >"$BATS_TEST_TMPDIR/$1" -} - -@test "main having moved exits 0 and points at both ends" { - ref_response resp.last bbbbbbbbbbbbbbbb - run "$WATCH" aaaaaaaaaaaaaaaa - [ "$status" -eq 0 ] - [[ "$output" == *"main moved aaaaaaaa -> bbbbbbbb"* ]] -} - -@test "main standing still blocks, because losing the race is the normal case" { - # A wall-clock cap here would turn a quiet main into a spurious lap, and a - # spurious lap costs a whole CI run. The caller races this against a wait - # that always terminates, so blocking forever is the correct behaviour. - # - # SIGKILL rather than the default TERM: bash defers a trapped signal until - # the running `sleep` returns, so a TERM would make every blocking case here - # cost a full poll interval to end. - ref_response resp.last aaaaaaaaaaaaaaaa - run run_timeout -s KILL 3 "$WATCH" aaaaaaaaaaaaaaaa - [ "$status" -eq 137 ] || { - echo "expected the watch to still be blocking, exited $status" - return 1 - } - [[ "$output" != *"moved"* ]] -} - -@test "the second request is conditional on the first response's ETag" { - # This is what makes a second poller affordable at all: GitHub answers 304 - # with no body and no rate-limit charge, so a quiet main costs nothing. An - # unconditional poll would double the request cost of every lap to usually - # learn nothing. - ref_response resp.1 aaaaaaaaaaaaaaaa '"etag-one"' - not_modified resp.2 - ref_response resp.last aaaaaaaaaaaaaaaa '"etag-one"' - run run_timeout -s KILL 3 "$WATCH" aaaaaaaaaaaaaaaa - [[ "$(sed -n '1p' "$BATS_TEST_TMPDIR/requests")" != *"If-None-Match"* ]] - [[ "$(sed -n '2p' "$BATS_TEST_TMPDIR/requests")" == *"If-None-Match: \"etag-one\""* ]] -} - -@test "a 304 is not read as a change, however many arrive" { - # The 304 body is empty. Parsing it as a ref would yield an empty sha, and - # an empty sha is not equal to the base — a naive comparison would report - # movement on every unchanged poll and lap forever. - ref_response resp.1 aaaaaaaaaaaaaaaa - not_modified resp.last - run run_timeout -s KILL 3 "$WATCH" aaaaaaaaaaaaaaaa - [ "$status" -eq 137 ] - [[ "$output" != *"moved"* ]] -} - -@test "movement after a run of 304s is still caught" { - ref_response resp.1 aaaaaaaaaaaaaaaa - not_modified resp.2 - not_modified resp.3 - ref_response resp.last cccccccccccccccc - run run_timeout -s KILL 8 "$WATCH" aaaaaaaaaaaaaaaa - [ "$status" -eq 0 ] - [[ "$output" == *"-> cccccccc"* ]] -} - -@test "a server-sent poll interval is honoured as a floor" { - # X-Poll-Interval is the server asking for a floor, and it wins over the - # configured interval. Asserted by the watch outliving a window it would - # have finished several polls inside. - ref_response resp.1 aaaaaaaaaaaaaaaa '"abc"' "X-Poll-Interval: 9" - ref_response resp.last dddddddddddddddd - run run_timeout -s KILL 3 "$WATCH" aaaaaaaaaaaaaaaa - [ "$status" -eq 137 ] - [ "$(cat "$BATS_TEST_TMPDIR/calls")" -eq 1 ] -} - -@test "a transient gh failure costs one poll, not the landing" { - # `set -e` is off on purpose: this runs for as long as a CI run does, and a - # single failed request must not abandon the lap. - printf 'boom' >"$BATS_TEST_TMPDIR/resp.1" - ref_response resp.last eeeeeeeeeeeeeeee - run run_timeout -s KILL 8 "$WATCH" aaaaaaaaaaaaaaaa - [ "$status" -eq 0 ] - [[ "$output" == *"-> eeeeeeee"* ]] -} - -@test "no base to compare against is a refusal, not a silent block" { - # Outside a repository there is no `origin/main` to fall back to, so the - # base really is empty — and an empty base compares unequal to every sha, - # which would report movement on the first poll and lap forever. - cd "$BATS_TEST_TMPDIR" - run "$WATCH" "" - [ "$status" -eq 1 ] - [[ "$output" == *"no base SHA"* ]] -} diff --git a/tests/reclaim-census.bats b/tests/reclaim-census.bats index 03e658123..4c716071d 100644 --- a/tests/reclaim-census.bats +++ b/tests/reclaim-census.bats @@ -238,22 +238,3 @@ at() { (cd "$REPO" && "$CENSUS" "$@"); } } # --- the call sites, so the wiring cannot go dead unnoticed ------------------- - -@test "land-lock's hold loop records a beat and every stop it chooses" { - LOCK="$BATS_TEST_DIRNAME/../mise-tasks/land-lock.sh" - run grep -c 'beat_note x' "$LOCK" - # Four paths where the loop chooses to stop: holder gone, stalled, lease - # lost, lease lapsed. A new exit added without a record is a silent gap. - [ "$output" -eq 4 ] - run grep -c 'beat_note h' "$LOCK" - [ "$output" -eq 1 ] -} - -@test "land records the stop it causes itself, or every clean landing reads as a reclaim" { - # The commonest stop of all is land killing its own heartbeat on a normal - # finish; the loop never runs another statement, so its last record stays an - # `h`. Without this line every successful landing would later read as - # "the container died under active work". - run grep -c 'note x land-stopped' "$BATS_TEST_DIRNAME/../mise-tasks/land.sh" - [ "$output" -eq 1 ] -} diff --git a/tests/tree-clean.bats b/tests/tree-clean.bats index 1575ecdc4..a28aad3cf 100644 --- a/tests/tree-clean.bats +++ b/tests/tree-clean.bats @@ -11,7 +11,6 @@ setup() { GATE="$BATS_TEST_DIRNAME/../mise-tasks/tree-clean.sh" - VERIFIED="$BATS_TEST_DIRNAME/../mise-tasks/verified.sh" REPO="$BATS_TEST_TMPDIR/repo-$BATS_TEST_NUMBER" git init -q "$REPO" cd "$REPO" || return 1 @@ -133,7 +132,7 @@ setup() { [ ! -f "$receipts/verify.$HEAD_SHA" ] # And that absence is what the next gate in the chain reads. - run "$VERIFIED" - [ "$status" -eq 1 ] + run batten receipt verified + [ "$status" -eq 2 ] [[ "$output" == *"NOT verified"* ]] } diff --git a/tests/verified.bats b/tests/verified.bats deleted file mode 100644 index e62069619..000000000 --- a/tests/verified.bats +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/verified.sh -# The verdict as an artifact keyed to the commit, rather than a remembered exit -# code. The receipt already existed and nothing read it; every consumer read the -# exit status, which an ordinary pipe destroys. -# -# The inversion this suite exists for: a FAILING verify whose exit code has been -# swallowed must still leave the repo unverified. Shape recognition -# (`run-shape-guard`) denies the idioms seen so far; this holds regardless of -# idiom, because no exit code is consulted at all. - -setup() { - GATE="$BATS_TEST_DIRNAME/../mise-tasks/verified.sh" - REPO="$BATS_TEST_TMPDIR/repo-$BATS_TEST_NUMBER" - # The developer's global git config must not reach a fixture repo - # (CLOUD-282). `init.defaultBranch=main` is the leak this suite tripped on — - # git refuses `branch -f` on the CHECKED-OUT branch, so a machine configured - # the modern way failed every test in the file at setup, while CI passed only - # because the runner's git still defaults to `master`. `commit.gpgsign` is - # the same shape. crates/batten/tests/common/mod.rs:184-185 already does this. - export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null - # `-b work`, so the checked-out branch is NAMED rather than inherited. The - # `main` created below is a second branch marking the trunk while HEAD stays - # on the feature branch — that topology is what these cases exercise — and - # the force-create this replaces could only ever build it by accident: it - # works while git's default is `master`, and git REFUSES to force the branch - # that is currently checked out, so the same line failed outright the moment - # a developer's default was the trunk's own name. Naming the branch makes the - # topology explicit instead of inheriting it, and `main` is then a fresh name - # needing no force at all. `no-branch-f-main` in batten.toml keeps the old - # form out; the literal is not spelled here, because that row is a substring - # rule over this directory and would fire on its own explanation. - git init -q -b work "$REPO" - cd "$REPO" || return 1 - git config user.email t@t - git config user.name t - git commit -q --allow-empty -m "chore: init" - git branch main - git update-ref refs/remotes/origin/main main - git commit -q --allow-empty -m "feat: work" - HEAD_SHA=$(git rev-parse HEAD) - MAIN_SHA=$(git rev-parse origin/main) - RECEIPTS="$(git rev-parse --git-dir)/batten-receipts" - mkdir -p "$RECEIPTS" -} - -# Stand in for a passing verify + linear-check against the current origin/main. -receipts_for() { - date -u +%FT%TZ >"$RECEIPTS/verify.$1" - printf '%s' "$MAIN_SHA" >"$RECEIPTS/linear-check.$1" -} - -@test "a commit with both current receipts is verified" { - receipts_for "$HEAD_SHA" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"verified:"* ]] -} - -@test "THE INVERSION: a failed verify whose exit code was swallowed leaves HEAD unverified" { - # `mise run verify | tail` exits 0 while verify failed and wrote no receipt. - # The swallowed status is unavailable to this gate by construction. - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"NOT verified"* ]] - [[ "$output" == *"swallowed by a pipe"* ]] -} - -@test "the failure names what to run, not merely that it refused" { - run "$GATE" - [[ "$output" == *"mise run verify"* ]] - [[ "$output" == *"never through a pipe"* ]] -} - -@test "a verify receipt alone is not enough — linear-check is a separate claim" { - date -u +%FT%TZ >"$RECEIPTS/verify.$HEAD_SHA" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"linear-check receipt"* ]] -} - -@test "an amend invalidates the receipt, because it produces a new HEAD" { - receipts_for "$HEAD_SHA" - git commit -q --amend --allow-empty -m "feat: work, reworded" - run "$GATE" - [ "$status" -eq 1 ] -} - -@test "a main that moved under the branch invalidates the receipt" { - receipts_for "$HEAD_SHA" - git checkout -q main - git commit -q --allow-empty -m "chore: someone else landed" - git update-ref refs/remotes/origin/main main - git checkout -q - - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"is now"* ]] -} - -@test "a receipt for a different commit does not vouch for this one" { - receipts_for "$MAIN_SHA" - run "$GATE" - [ "$status" -eq 1 ] -} - -@test "output is a pointer — it names predicates and shas, never run contents" { - printf 'secret build output\n' >"$RECEIPTS/verify.$HEAD_SHA" - run "$GATE" - [[ "$output" != *"secret build output"* ]] -} - -@test "an unresolvable origin/main exits 2 — a checkout problem, not a verdict" { - receipts_for "$HEAD_SHA" - git update-ref -d refs/remotes/origin/main - run "$GATE" - [ "$status" -eq 2 ] - [[ "$output" == *"checkout problem"* ]] -} - -@test "outside a git repository it exits 2 rather than claiming unverified" { - cd "$BATS_TEST_TMPDIR" || return 1 - run "$GATE" - [ "$status" -eq 2 ] -}