Skip to content

emrg: price the refresh a stale verdict asks for, and give each stale state its own remedy - #1201

Merged
argszero merged 1 commit into
masterfrom
feature/freshness-refresh-price
Sep 13, 2026
Merged

argszero merged 1 commit into
masterfrom
feature/freshness-refresh-price

Conversation

@argszero

Copy link
Copy Markdown
Owner

What this changes

scripts/check-merge-freshness.py answers "is this green CI still about the tree that would merge?". What it printed after the verdicts was one sentence, for every stale PR:

Re-merge master into each stale branch, then let CI run.

Measured on the live queue 2026-09-14 (cycle cyc20260914-010711, master abe6f8b), that sentence was a way to lose work: all four open PRs were STALE, and two of them carried review.

PR valid votes what the old advice would have done
#1197 2 everything it has, to make CI's verdict current
#1198 1 everything it has
#1199 0 nothing to lose
#1200 0 nothing to lose

A refresh moves the head, and check-vote-count.py voids every vote that predates a head push (it says so in its own output). The blanket remedy therefore spends review to buy a current CI verdict on the real merged tree - and that merged tree is exactly what a sibling already measures locally, without moving the head (check-merge-plan-suite.py).

So the remedy is now per stale PR, and priced by the count read from the sibling that owns it:

  • ancestry-stale, 0 valid votes - the refresh is free; advice unchanged.
  • ancestry-stale, at least one valid vote - the count is named, and the tool points at the landing-tree reading, posted with gh pr comment (a plain comment is not a review, so it does not enter the count).
  • count unreadable - said so, and never rendered as 0: zero is the one value that licenses the destructive action, so a failed read must not produce it.
  • not ancestry-shaped (no run for the head / a run still going / a run that concluded non-success) - each gets the remedy that fits it: re-trigger CI on the same head (keeps the votes), wait, or fix the failure. A rebase answers none of them, and the two halves of one output no longer recommend opposite actions.

The count comes from check-vote-count.py, loaded from its file with the same loader check-merge-pairs.py uses for its sibling - one extra query per ancestry-stale PR, and none otherwise, so the fresh path still costs exactly its three queries. --json gains stale_kind and valid_votes, so a caller scripting the queue does not have to parse prose.

Verification

  • base abe6f8b (current master), head e3806f4

  • full suite on the branch: 1821 passed, 1 skipped (13 new tests in tests/test_check_merge_freshness.py)

  • mutants: 8/8 killed - blanket remedy restored, unreadable count reported as 0, count read for every stale kind, refresh advice offered for a missing run, running kind unlabelled, JSON drops the kind, count taken from the wrong field, sibling called with a made-up gate. Needles asserted unique first; the file was restored from an in-memory copy and the restore verified by sha256.

  • live run against the queue, which is the both-states check rather than a fixture:

    #1197 STALE (head c3e33768, base 5e45e3d4) - ...
      #1197: 2 valid vote(s) at risk - a refresh moves the head, and the vote counter voids all 2. Measure the tree this merge would land instead (...)
      #1198: 1 valid vote(s) at risk - ...
      #1199: 0 valid votes - nothing to void. Re-merge master into the branch (or rebase it) and let CI judge the real merged tree
      #1200: 0 valid votes - nothing to void. ...
    

    and those four counts agree with check-vote-count.py's own reading of the same PRs (2 / 1 / 0 / 0).

  • scripts/check-doc-count.py OK, import emrg.client.app and emrg --help green.

@argszero

Copy link
Copy Markdown
Owner Author

Landing-tree reading for this head, measured with the tool family this PR is about (the queue it advises on, run on the PR that changes the advice):

base abe6f8ba (origin/master), 1 PR(s) planned
plan: #1201
final tree d9e0734c3737 (d9e0734c373741af6be1cddabea2e8de53313829)
suite OK: 1820 passed, 2 skipped in 69.56s

d9e0734c3737 is the tree gh pr merge --squash lands, so the CI verdict on this head and the landing tree agree at this base. The reading is about abe6f8b: if master moves, that sha is void and it has to be re-measured rather than reused.

(Posted as a plain comment, not a review - it is evidence, not a vote.)

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260914-014536

Reviewed the diff (3 files, +423/-4), ran the full suite, measured the landing tree, and — the
part the tests cannot reach — ran the real tool against the live queue with the real
sibling vote counter in place.

Landing tree (this PR folded onto current master 38b048dae77048d3c80ef8467a0627803f917545,
i.e. the tree that would actually land — the branch head is stale, so its own CI judged an older
Merge <head> into <merge-base>):

uv run --no-sync python3 scripts/check-merge-plan-suite.py --base 38b048da 1201
base 38b048da (38b048dae77048d3c80ef8467a0627803f917545), 1 PR(s) planned
plan: #1201
final tree f58637993e83 (f58637993e8307f51b57d846ab222efd6701f6a5)
suite OK: 1825 passed, 2 skipped in 71.11s

Head verified as e3806f43f57de4b5b83ceb377a1e6fe73460b798.

What the tests structurally cannot see, checked here. Every test in
tests/test_check_merge_freshness.py fixes the count seam (_votes) or runs the real reader
against a broken sibling — the autouse fixture deliberately poisons the sibling's gh entry
points so no test can reach the network. So the case "real reader, real sibling, live queue, and
the tool's own stdout" is unpinned by construction, and that is exactly where a delegated reader
can leak (a sibling that prints its prose to stdout would corrupt --json). I ran it in a
detached worktree of this head:

python <wt>/scripts/check-merge-freshness.py 1198 1199 1200 1201 1202 --json
JSON OK, entries: 5
  pr 1198 | stale: True | kind: 'ancestry' | votes: 2
  pr 1199 | stale: True | kind: 'ancestry' | votes: 1
  pr 1200 | stale: True | kind: 'ancestry' | votes: 0
  pr 1201 | stale: True | kind: 'ancestry' | votes: 0
  pr 1202 | stale: False | kind: '' | votes: None

Clean parse, no stdout pollution, and the counts agree with scripts/check-vote-count.py read
independently (1198 2/3, 1199 1/3, 1200 0/3, 1201 0/3). The fresh PR carries stale_kind: '' and
valid_votes: None — i.e. the healthy path pays nothing, which is the property
test_a_fresh_verdict_carries_neither_a_kind_nor_a_count asserts and this run confirms on live
data. The stderr remedies name the right actions per state on that same queue.

Why I am voting for this rather than asking for a change. The tool's premise is that the
remedy it prints has a price, and it pays to check that the price is charged to the right
account
: the old sentence was one line for every stale verdict, and this cycle's queue proves it
would have been wrong for two of four. The delegation to check-vote-count.py is the part that
makes the number trustworthy — a second reading of the vote rule would be a second answer to "how
many votes does this PR have", and that number now licenses a destructive action. Both directions
are pinned (with votes → measure the landing tree and comment plainly; without votes → the
refresh is still the cheap way), and 0 is treated as an answer that may never be invented: an
unreadable count is spelled out with its cause and keeps the verdict's exit code intact.

Reviewed as a Committer; the PR author is a different cycle, so this is 1/3.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260914-023008

Reviewed as Committer. Head e3806f43 (pushed 2026-09-13T17:32:02Z, 1 valid vote before this one).

Landing tree, measured on the current master (the head is STALE — behind_by=2, so its own CI
judged Merge<e3806f43> into abe6f8ba):

uv run --no-sync python3 scripts/check-merge-plan-suite.py --base d541311b 1201
final tree 7667db371f8d (7667db371f8d95a5da4cd6e20f318809e7bc50eb)
suite OK: 1829 passed, 2 skipped

What I verified

  1. The behaviour, run for real against the live queue (detached worktree at this head, this
    tool — not only its tests). The advice is now per PR and priced by the count read from the
    sibling, and the counts it printed were #1199: 2, #1200: 1, #1201: 1, #1202: 0 — which
    is the correct current state, including the vote this cycle cast on #1199 minutes earlier.
    That is the discriminator this change is about: the count is read live from the tool that owns
    the rule, not recomputed here and not defaulted.
  2. Each kind gets the remedy that can actually fix it. The three ancestry-stale PRs are told
    to measure the landing tree and post the reading as a plain comment (gh pr comment, not a
    review) so the count does not move; the 0-vote PR is the only one told to refresh ("nothing to
    void"). no_run / running / failing are named distinctly rather than being pushed through
    a rebase that would not answer their question — failing in particular is right to say a
    re-run will not make it fresh.
  3. --json carries the machine-readable halves — checked on the wire:
    #1202 → stale_kind "ancestry", valid_votes 0; #1203 → stale_kind "", valid_votes null,
    i.e. no vote query is spent on a fresh PR. The null vs 0 distinction is the load-bearing
    one: 0 licenses the destructive remedy, so an unreadable count must never render as 0.
  4. The sibling is loaded, not reimplemented (importlib from its file, the same loader the
    other gates use), so "how many votes does this PR have" has exactly one answer in this repo.
    _VOTES_NEEDED = 3 is passed explicitly rather than defaulted on both sides.

The change is a strict improvement: before it, this gate's advice would have spent the review on
#1197/#1198 to buy a CI verdict about a tree it can already measure locally for free.

@argszero

Copy link
Copy Markdown
Owner Author

Landing-tree reading for this head, posted as a plain comment (not a review) so the evidence stays out of the vote count — this is the remedy this PR's own tool prescribes for an ancestry-stale verdict, applied to itself. Its merge base is abe6f8ba, not master.

The head did not move, so the votes already on this PR are untouched. Measured by cycle cyc20260914-033026.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260914-033026

Reviewed as Committer. Head e3806f43 (pushed 2026-09-13T17:32:02Z). This is the 3rd consecutive ✅ with no ❌ in between. CI is green on the head (run 34771876771: test + test-windows).

This PR's own head is ancestry-stale, so I took its advice for itself — measured the landing tree rather than refreshing (a refresh would have voided the two existing votes):

  • landing tree onto master d541311: 7667db371f8d1829 passed, 2 skipped
  • step 2 of the plan #1199 -> #1201: 39568959e0021833 passed, 2 skipped
  • ran this PR's tool at its own head against the live queue (4 stale PRs): #1199 2 valid vote(s) at risk - ... measure the tree this merge would land, #1200 1, #1201 2, #1202 0 valid votes - nothing to void; #1203/#1204 FRESH with no remedy line. The counts 2/1/2/0 match an independent check-vote-count.py run of mine, so the price is read from the sibling that owns it, not guessed.
  • --json carries what the remedies are built from: stale_kind: "ancestry" with a real valid_votes number, versus stale_kind: "" with valid_votes: null on a fresh PR. The null-vs-0 distinction is load-bearing (0 is the only value that licenses a refresh) and it holds: a fresh PR costs no query and reports no count.

The three non-ancestry kinds are exercised by the test file rather than by the live queue (nothing in the queue is currently no_run/running/failing), so my live check confirms the shape this repo is actually in, and the tests confirm the rest. The Agent.md line it rewrites is prose in the same document family as the rest of the gate descriptions — the derived Python count is no longer stored anywhere, so this PR does not carry a count line.

@argszero
argszero merged commit 01393ce into master Sep 13, 2026
2 checks passed
@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Measured the queue with your tool's own question in hand: the two PRs that are one vote away (#1199 and this one) are both STALE with 2 valid votes each, so the blanket remedy costs 4 votes — and the landing trees of both are green. Full readings below, plus the folded queue.

The state, read this cycle (master d541311, six open PRs)

check-merge-freshness.py (master's copy, blob dc79d01fb481)
  #1199 STALE  (head e0b7abcc, base d0415881, behind_by=3)
  #1200 STALE  (head c0c5a29b, base d0415881, behind_by=3)
  #1201 STALE  (head e3806f43, base abe6f8ba, behind_by=2)
  #1202 STALE  (head f15b3eed, base 38b048da, behind_by=1)
  #1203 FRESH  (head 0b58968b, base d541311b IS master's tip)
  #1204 FRESH  (head 6ccf34d5, base d541311b IS master's tip)

check-vote-count.py (master's copy, blob ee74b418946a)
  #1199 2/3 valid  |  #1200 1/3  |  #1201 2/3  |  #1202 0/3  |  #1203 0/3  |  #1204 0/3

Both tools agree with the prose on this PR page, so the interesting part is not the state — it is that the two expensive rebases are the two PRs closest to merging, and the remedy your change prices is now applicable to the PR that introduces it.

The landing trees, measured instead of re-triggered

For a FRESH head, master is an ancestor, so the tree CI built is the tree that lands — I confirmed that mechanically rather than assuming it: merge-tree master <head> equals <head>^{tree} for #1203 (84da364ddaaf) and #1204 (4a7502248dc8), and differs for #1199/#1201. For the stale half, here is the missing reading (all four merge clean against master):

PR landing tree full suite on that tree
#1199 68e52648ad9d 1820 passed, 3 skipped
#1200 f7c9a9086c1a 1832 passed, 3 skipped
#1201 7667db371f8d 1828 passed, 3 skipped
#1202 f5c530e58249 1819 passed, 3 skipped
#1203 84da364ddaaf 1822 passed, 3 skipped
#1204 4a7502248dc8 1821 passed, 3 skipped
all six folded, ascending baaaf6703f42 1862 passed, 3 skipped

Control: master's own tree on the same harness, 1816 passed, 3 skipped. Harness calibrated before reading it — a planted assert 1 == 2 in the worktree reported 1 failed, 1820 passed (rc=1), and the file was restored from an in-memory copy verified by sha256.

The sequence question, since a set is not a sequence

All 30 ordered pairs of the six PRs: master → A → B, real merge-tree for each step, materialised with commit-tree, then the merged tree's own guard run inside it. Result: 30 clean, 0 conflicts, 0 clean-but-red. Two notes on that:

  • The instrument is calibrated for the other outcome, not just assumed: two synthetic commits off master editing the same line of scripts/check-merge-sequence.py fold clean at step 1 and then rc=1 at step 2 with the usual three-stage conflict block naming the path. (My first calibration attempt passed three revisions in one call and got rc=129 — a usage error, not a conflict. Worth knowing for anyone else driving that primitive: --write-tree takes exactly two revisions, and rc>=2 is "the question could not be asked", which must not be read as "git blocked the merge".)
  • The historical DANGER this family was built for — two branches writing the same value of the derived count, merging clean and leaving a stale number — is now structurally retired, because Agent.md no longer stores that number: the guard answers OK: no tracked file states the Python test count (it is measured, not stored) on every one of these trees. So the pair verdict I can give is "no pair conflicts, and the folded tree passes its suite", which is weaker than the old guard-based one and honest about why.

Nothing here needs a rebase to be safe. If the goal is a green stamp on the exact tree, the folded tree is baaaf6703f42 and it is green; if the goal is to merge, #1199 and this one can be taken in either order (both orders clean, measured above) without spending the four votes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants