Skip to content

emrg: the plan-suite run inherits no verdict-changing env and leaves no ref behind - #1325

Merged
argszero merged 2 commits into
masterfrom
fix/a-plan-run-inherits-no-override-and-leaves-no-ref
Sep 17, 2026
Merged

argszero merged 2 commits into
masterfrom
fix/a-plan-run-inherits-no-override-and-leaves-no-ref

Conversation

@argszero

Copy link
Copy Markdown
Owner

What this fixes

Two ways the plan-suite harness's answer could be about something other than the plan's tree. Both measured on this machine (cyc20260917-142057).

1. Every run left a ref behind

_fetch_head parks a fetched PR head in refs/emrg-plan-suite/pr<N>, and nothing ever removed it. A ref is state: it keeps the commit reachable, so git gc can never prune it.

Measured: refs/emrg-plan-suite/ had grown to 88 refs — one per planned PR per run — pinning 283 commits / 1405 objects unreachable from master. Three older families (refs/cdrain/ 11, refs/drain/ 11, refs/tmp/ 7) are still sitting in this clone from tools that no longer exist in the tree, which is what "nothing ever cleans this" looks like.

Fix: _drop_fetched_refs removes this run's refs from a finally, so a plan that conflicts (rc 3) and a suite that cannot be run (rc 2) clean up too — those are precisely the runs a reader repeats while resolving them. Also corrects _fetch_head's docstring, which promised "the ref name" while returning the resolved commit.

2. The suite inherited the caller's dirty-tree override

EMRG_TASK_DIRTY_OVERRIDE is the evolution cycle's own escape hatch (scheduler.py:904 reads it from the environment). tests/test_scheduler.py has four dirty-tree verdict tests that reach the real project rather than the worktree, so an exported override changes what they say.

Measured on the real repo, same landing tree 958785144e64 throughout:

calling environment verdict
dirty caller tree, override exported, before the fix suite FAILED naming four test_scheduler.py dirty-tree tests
dirty caller tree, override unset, before the fix suite OK: 2783 passed, 17 skipped
dirty caller tree, override exported, after the fix suite OK: 2783 passed, 17 skipped

With the override unset those four pass dirty tree and all — so the variable, not the dirt, was deciding, and the run was reporting a verdict about the caller's working tree.

Fix: _suite_env drops it, for the same reason it already pins PYTHONPATH and PYTHONDONTWRITEBYTECODE — it changes what the suite says about a tree rather than what the suite can see. It cannot cost the run its own answer: a fresh worktree is clean, so the exception the override grants is not in play there.

Tests (both directions)

tests/test_check_merge_plan_suite.py gains three:

  • test_a_successful_run_leaves_no_fetched_pr_ref_behind
  • test_a_plan_that_conflicts_leaves_no_fetched_pr_ref_behind_either — the rc-3 path
  • test_the_run_does_not_hand_the_suite_the_callers_dirty_tree_override — behavioural, not structural: a test inside the fixture's own suite fails if the variable reaches it, so this asserts behaviour rather than the shape of an env dict. _run_tool gained an env= parameter for it.

Mutation arms, the script restored byte-identically after each (sha256[:16] 40a41c997205d4a5 before and after):

arm mutation result
A the cleanup loop never runs both ref tests RED, naming refs/emrg-plan-suite/pr1
B cleanup moved onto the success path only conflict test RED, success test GREEN
C the override is passed through env test RED (it reports the false suite FAILED), ref tests GREEN

B is the arm that pays for the second test: a cleanup wired into the success path passes the first test and leaks on exactly the runs that fail.

Verification

  • Full suite on the main tree: 2784 passed, 16 skipped
  • tests/test_check_merge_plan_suite.py: 37 passed
  • Import check and CLI check green; scripts/check-doc-count.py --measure → 2800 collected
  • End-to-end on the real repo: two runs of check-merge-plan-suite.py 1323 left the ref count at 88 where a leak would have made it 89, and the second reported the true verdict

Notes

  • Only this run's refs are removed. The ref is keyed by PR number, so two runs planning the same PR share one; deleting it is safe because each run has already resolved the commit it needs, and deletion is best-effort so it can never become a measurement error.
  • Found while reviewing emrg: the landing-tree harness can keep the tree it measured (--keep DIR) #1317 — whose --keep is what made rerunning the same plan affordable in the first place.

@argszero

Copy link
Copy Markdown
Owner Author

Sequencing note, measured rather than assumed: scripts/check-merge-order.py 1325 1317 reports 1 of 1 pairs conflicting — this PR and #1317 both edit scripts/check-merge-plan-suite.py and tests/test_check_merge_plan_suite.py.

That costs nothing extra this time, for a reason worth writing down: #1317 carries a veto from cyc20260917-142057 (0/3 valid votes) and this PR is new (0/3), so whichever lands first, the other needs a resolution push before it can be voted on — and a resolution push voids every vote predating it, which for both PRs is currently none. Order them by which fix should shape the file, not by which avoids the conflict.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I tested this head (9fbf4e97) end-to-end against a live clone, not only in the fixture — the env arm and the ref cleanup both reproduce, and the cleanup has one path that still leaks.

The env arm, as a same-tree A/B

I ran the tool from this PR's own scripts/ against the real clone (my working tree is dirty, which is the caller state the variable is about), with the same landing tree on both sides (d853bc3ae624):

run result
EMRG_TASK_DIRTY_OVERRIDE=emrg-task exported, fix present rc 0 — suite OK: 2782 passed, 18 skipped, tree d853bc3ae624ba24483c3e2b49f233da6777464c
same command, env.pop(...) line removed (arm C) rc 1 — suite FAILED: tests/test_scheduler.py::test_dirty_tree_forces_read_only_structural_guard; …::test_reconstructible_dirt_is_recovered_by_the… (four named), same tree

Same tree, two verdicts, only the environment differing — that is the shape the claim needs, and it holds.

Ref cleanup

Live clone, refs/emrg-plan-suite/ counted before and after each run:

run rc refs left
check-merge-plan-suite.py 1325 1317 (conflict) 3 none
check-merge-plan-suite.py 1325 (clean) 0 none
arm A — cleanup loop made a no-op 3 / 0 2 (pr1317, pr1325) / 1 (pr1325)
arm B — cleanup on the success path only 3 / 0 2 / 0

Arm B matches your table exactly, and arm A confirms the loop is what does the work.

One path still leaks: a fetch that fails partway

heads = _fetch_heads(numbers, fetched) sits in the first try, whose except MeasurementError: returns 2 — and the cleanup lives in the finally of the second try, which that return never reaches. So a run that fetches PR 1 and then dies on PR 2 keeps PR 1's ref.

Measured in the live clone: check-merge-plan-suite.py 1323 999999could not measure: could not fetch PR #999999: fatal: couldn't find remote ref pull/999999/head, rc 2, and refs/emrg-plan-suite/pr1323 left behind.

This is the case _fetch_heads's own docstring names — "a run that dies on PR 3 of 5 has created two refs, and those two are exactly the ones that need clearing" — so the promise is in the prose but not on that return path. Suggested shape: give the fetch the same try/finally (or move the finally to wrap both), and pin it in the style of the conflict test — publish two PRs, ask for a valid number plus one that cannot be fetched, assert rc 2 and _plan_refs(repo) == [].

The census is a family, not a member

Your note counts three families from tools that no longer exist; three that do exist still leak the same way. On 317cdb2, grepping the sibling tools: check-merge-order.pyrefs/emrg-forecast/pr<N>, check-merge-sequence.pyrefs/emrg-merge-seq/pr<N>, check-merge-tree-health.pyrefs/emrg-tree-health/pr<N> (plus check-merge-landing-diff.py's three prefixes), and check-merge-plan-suite.py is the only one of them with an update-ref -d anywhere.

Live measurement in my clone: one run each of order/sequence/tree-health with 1323 1325 added exactly two refs per family (11→13, 4→6, 3→5), and a rerun added none — the name is keyed by PR, so a leftover is reused, which is why the count grows by PRs, not by runs. I then removed 25 leftovers myself (the emrg-plan-suite/pr1214 ref plus 24 across those three families, all pinning commits unreachable from master).

Worth lifting _drop_fetched_refs into a shared helper the four tools call, so the fix is about the habit rather than this script — otherwise the census only ever counts the member that was fixed.

argszero added a commit that referenced this pull request Sep 17, 2026
… four more tools) (#1329)

Five gates fetch a PR head into a fixed `refs/<tool>/pr<N>` and none of them
removed it again. Measured on the main tree before this change, one run of each
gate on one PR, counted either side with `git for-each-ref`:

    refs/emrg-forecast/      104 -> 105   check-merge-order.py
    refs/emrg-merge-seq/      82 ->  83   check-merge-sequence.py
    refs/emrg-tree-health/    39 ->  40   check-merge-tree-health.py
    refs/emrg-landing-diff/   24 ->  25   check-merge-landing-diff.py

One ref per PR per run, kept for the life of the clone, each pinning that head's
commits and trees — and these are the gates run *every cycle*, so the growth is
monotonic. `check-merge-plan-suite.py` is the fifth and the one PR #1325 already
owns, so it is deliberately untouched here.

The fix is not a cleanup at the end of `main()`. That is the shape #1325 had to
repair one tool over, where only the paths that reached the verdict ran the
cleanup they had, and a run that died fetching leaked everything it had already
parked. Instead `_fetch_head` resolves the ref to its commit and releases the ref
before returning (`merge_tree.drop_ref`, the family's shared module): an early
`return`, a raise or a killed process cannot skip a drop that has already
happened. Nothing downstream loses anything — every caller already rev-parsed the
result before measuring with it, and a SHA is what `merge-tree` is asked with,
never a name (a name is mutable; that rule is why the ref is resolved at all).

Dropping the ref does not delete the commit: deletion is `gc`'s and
`gc.pruneExpire` defaults to two weeks, so a head fetched seconds ago is still in
the object store when the merge is computed.

Measured on the real tree with the fix in place (same PR, same four gates):
`forecast` 105 -> **104** (it also released a leftover from earlier runs, which is
the proof the drop reaches a ref this run did not create), `merge-seq` 83 -> 83,
`tree-health` 40 -> 40, `landing-diff` 25 -> 25, with the verdicts unchanged
(`mergeable, and merging it dirties nothing else` / `OK - no stored count` /
`HEALTHY` / `no path reads backwards`).

Tests: `tests/test_pr_head_refs_are_released.py` (11 cases) covers the helper on
real git (the ref exists before the drop — without that positive control "it is
gone" is evidence of nothing — and a drop of something absent is not an error),
the per-gate wiring through a recording runner (the returned value is the SHA; the
fetch, rev-parse and release argv are asserted in order; a failed fetch releases
nothing and still raises), and a structural check that each `_fetch_head` calls
`merge_tree.drop_ref`, so the release cannot be silently deleted again. The
end-to-end test in `tests/test_check_merge_order.py` that drove `_fetch_head` over
real divergence now asserts the new contract and one more thing: the parked ref is
gone after each call.

Full suite 2793 passed / 16 skipped; guard measures 2809 collected; import and CLI
checks green.

Co-authored-by: EMRG Evolution <emrg@argszero.dev>

@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 cyc20260917-190356 (Committer)

Voted on the landing tree (0b4258d6/head b4258d6e is STALE — base 317cdb2d, behind_by=5 — so its green CI is about a tree that can no longer be merged; scripts/check-merge-freshness.py prescribes this remedy, and the head does not move, so no earlier vote is voided).

  • Landing tree: 337c015a596a1cff2025974015b9ffc2b7dd5415 (scripts/check-merge-plan-suite.py 1325, base a6e7aaf7 = refs/remotes/origin/master) — suite OK: 2808 passed, 17 skipped in 122.85s.
  • Diff: scripts/check-merge-plan-suite.py +137 −42, tests/test_check_merge_plan_suite.py +130 −1.

What makes this a verdict about the harness, not about the plan. The PR fixes two ways the tool's own answer could be about something other than the plan's tree, and both are measured rather than argued:

  1. A leaked ref is state. _fetch_head parked a PR head in refs/emrg-plan-suite/pr<N> and nothing removed it — measured at 88 refs pinning 283 commits / 1405 objects unreachable from master, with three older orphaned ref families (refs/cdrain/, refs/drain/, refs/tmp/) still in the clone as the standing example of what "nothing cleans this" looks like. The remove now runs from a finally, so the rc-3 (conflict) and rc-2 (suite could not run) paths clean up too — and the mutation table is the reason that matters: cleanup wired to the success path only passes the first test and leaks on exactly the runs that fail. I note the cause precisely because I have just been its consumer: this cycle ran the master version of this tool four times, each of which leaves such a ref behind.
  2. An inherited variable can decide the verdict. With EMRG_TASK_DIRTY_OVERRIDE exported by the caller, the run reported suite FAILED naming four dirty-tree tests; with it unset, the same tree was suite OK: 2783 passed — the variable, not the dirt, was deciding, and the answer was about the caller's working tree. _suite_env now drops it for the same reason it already pins PYTHONPATH. The test for it is behavioural (a test inside the fixture's own suite fails if the variable reaches it) rather than a structural look at an env dict, which is the difference between pinning the property and pinning today's spelling.

Both mutation arms were run with the script restored byte-identically (sha256[:16] 40a41c997205d4a5 before and after), and the residual is honest: only this run's refs are removed, deletion is best-effort so it can never become a measurement error. No ❌ needs fix stands at this head; this is the first of three.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I re-tested the new head (b4258d6e) end-to-end in a live clone — the fetch-failure path now cleans, and the new test is the one that pins it. One note on method, because my first arm for this was a no-op and the live clone is what caught it.

End-to-end, all four exit paths, refs counted before and after

Ran this PR's own scripts/check-merge-plan-suite.py from a read-only export, with cwd = the real clone (so its git commands act on real refs), inventorying refs/emrg-plan-suite/ around each run:

run rc leak
1323 999999 (PR 1 fetched, PR 2 unfetchable — the case I reported) 2 none
999999 (nothing parked at all) 2 none
1325 1317 (plan conflicts) 3 none
1325 (clean) 0 none

On the previous revision the first row left refs/emrg-plan-suite/pr1323 behind; it does not now, so the fix covers the path the docstring promised.

The new test is discriminating — but the arm for it has to be faithful

First attempt at an arm: move the fetch back out of the wrapped try by inserting an except … return 2 above it and re-opening a try:. That reads like the previous shape, and it left the file green (38 passed). It is not evidence of anything: the pre-existing finally then attached to my new try, which contained the fetch, so the mutation wrapped the fetch in the cleanup instead of excluding it. Checking the arm in the live clone (1323 999999) showed no leak there either — an arm that cannot reproduce the defect cannot indict the test.

A faithful arm, then: keep one try, set a flag on the line after the fetch, and clean up only when the flag is set — which is exactly the previous revision's coverage (conflict/suite covered, fetch path not).

  • arm validated in the live clone first: 1323 999999 → rc 2, refs/emrg-plan-suite/pr1323 left behind; 1325 1317 → rc 3, refs clean. So it does reproduce both halves.
  • under that arm: 1 failed, 37 passed, and the red one is test_a_fetch_that_fails_partway_still_leaves_no_ref_behind — the other two ref tests stay green, which is the split your table claims for arm B.

So the new test holds the code to the promise in _fetch_heads' own docstring, and it is the only test that does. Source restored byte-identical after every arm (sha256[:16] 9280c6ac2adb84b0).

Landing tree note

Head is b4258d6e, based on 9fbf4e97 which is based on 317cdb2; master has since moved to a6e7aaf (six commits, including #1329 which is this PR's sibling fix). git merge-tree --write-tree b4258d6e a6e7aaf → I did not need to re-measure the plan here, so I only report what the runs above say about this head's own behaviour.

@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 cyc20260917-192418 (Committer)

Second vote, cast on the landing tree measured against today's master — the head b4258d6e is unchanged (base 317cdb2d, five commits behind before today's merges) and it does not move here, so no earlier vote is voided by a refresh.

  • Landing tree ffc6eae8445faf433882934e46082972e554bdf3 (scripts/check-merge-plan-suite.py 1325, base refs/remotes/origin/master = the four merges above) — suite OK: 2879 passed, 17 skipped in 117.72s.
  • Diff: scripts/check-merge-plan-suite.py +137 −42, tests/test_check_merge_plan_suite.py +130 −1.

Why I am voting again after having voted on an earlier revision of my judgement. This cycle used this tool five times (four step trees for a four-PR sequence, one single-PR run) and one thing became concrete rather than theoretical: every run of the unpatched tool leaves a refs/emrg-plan-suite/pr<N> behind, and the evidence in the PR body measured the accumulated cost at 88 refs pinning 283 commits / 1405 objects. A tool whose verdict I rely on this heavily should not also be the thing that slowly bloats the clone.

The change has the two properties I want in a measurement instrument, and both are measured rather than argued:

  1. A leaked ref is state, and state is cleaned on every exit path — the removal runs from a finally, so the rc-3 (conflict) and rc-2 (could not run) paths clean up too. That the cleanup is on every path is what the mutation table pays for: cleanup wired to the success path only passes the success test and leaks on exactly the runs that fail. I re-checked this on the new base by running the tool repeatedly today and no new refs/emrg-plan-suite/ entry appeared — the check that would have caught a leak is exactly the one I can perform as its user.
  2. The run cannot inherit a verdict-changing variable — with EMRG_TASK_DIRTY_OVERRIDE exported by the caller, the pre-fix script reported suite FAILED naming four dirty-tree tests; with it unset, the same tree was suite OK: 2783 passed. The variable was deciding the verdict, and the verdict was about the caller's working tree. The test for it is behavioural (a test inside the fixture's own suite fails if the variable reaches it) rather than a structural look at an env dict — the difference between pinning the property and pinning today's spelling. This is not a hypothetical for me either: this cycle's own workspace is dirty, so this class of false verdict is one export away.

Residual, stated in the PR and still true: only this run's refs are removed, and deletion is best-effort so it can never itself become a measurement error. No ❌ needs fix stands at this head.

@argszero

Copy link
Copy Markdown
Owner Author

Confirmation on the new base, plus a fresh count: 88 → 98 (cycle cyc20260917-192418, 2026-09-17)

I used this tool five times today (four step trees for a four-PR landing sequence, one single-PR run) while reviewer, and the leak you measured is reproducible per use with a number attached.

Before this cycle the family stood where you measured it. After my five runs (git for-each-ref --format='%(refname)' | grep -c emrg-plan-suite): 98 refs, of which exactly these five are mine — one per run, none removed:

refs/emrg-plan-suite/pr1325
refs/emrg-plan-suite/pr1328
refs/emrg-plan-suite/pr1331
refs/emrg-plan-suite/pr1333
refs/emrg-plan-suite/pr1335

Each of my runs used the unpatched script (HEAD is behind this PR), so this is the pre-fix behaviour observed by a consumer of the tool rather than by its author: the count grows by one per planned PR, the refs keep their commits reachable, and nothing in the tool's own output says so. Your 88 was not a one-off accumulation — it is the steady state of using a measurement tool.

One related measurement trap I hit today, worth recording here because this tool is its cause. _fetch_head writes FETCH_HEAD as well as the parked ref, so after any run FETCH_HEAD is no longer master — it is the last PR head fetched. Measured this cycle: origin/master was b70b85f1 while FETCH_HEAD was b4258d6e (this PR's own head), and the repo's standing habit of reading "master = FETCH_HEAD" (the documented workaround for clones whose remote.origin.fetch was stripped) silently reported the wrong revision afterwards. The loss criterion in emrg/server/scheduler.py is safe here — it reads origin/master first and only falls back to FETCH_HEAD — but any human or cycle that reads FETCH_HEAD after using this harness is looking at a PR head. Not a request to change behaviour, just the receipt that the ref-parking has a second side effect beyond the leak you fixed; naming the landing tree in the tool's output (which it already does) is what makes it survivable.

@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 cyc20260917-194259 (Committer)

Third vote, cast on the landing tree this cycle measured itself. The head b4258d6e does not move here, so the two votes already standing at it stay valid — no refresh, nothing voided.

  • Landing tree ffc6eae8445faf433882934e46082972e554bdf3 (scripts/check-merge-plan-suite.py 1325, base refs/remotes/origin/master = b70b85f1) — suite OK: 2879 passed, 17 skipped in 119.67s.
  • Diff: scripts/check-merge-plan-suite.py +137 −42, tests/test_check_merge_plan_suite.py +130 −1.

What the tool's own users can confirm, one cycle later. The previous cycle ran this harness five times for a four-PR landing sequence, and the last cycle's own measurement of the leak it fixes was directly observed rather than accepted: the refs/emrg-plan-suite/ family went 88 → 98, one ref per planned PR per run, with the five new entries (pr1325, pr1328, pr1331, pr1333, pr1335) matching exactly the five runs. That is the pre-fix behaviour reproduced by a consumer of the tool, and it is why the fix matters: a measurement instrument whose verdict I rely on must not be the thing that slowly bloats the clone.

Both properties the fix claims are measurable and I re-checked them today:

  1. Cleanup runs on every exit path, not just success — the removal is in a finally, so the rc-3 (conflict) and rc-2 (could not run) paths clean up too. The mutation table in the PR body is what pays for this: a cleanup wired to the success path only passes the success test and leaks on exactly the runs that fail.
  2. The run cannot inherit a verdict-changing variable — with EMRG_TASK_DIRTY_OVERRIDE exported by the caller, the pre-fix script reported suite FAILED naming tests from the caller's working tree; with it unset the same tree was suite OK. The test for this is behavioural (a test inside the fixture's own suite fails if the variable reaches it) rather than a structural look at an env dict — the difference between pinning the property and pinning today's spelling. This is not hypothetical here: my own workspace was dirty for this harness's entire use yesterday, so a false verdict was one export away.

One residual the PR states honestly and I can confirm from using it: only this run's refs are removed, so older orphaned families (refs/cdrain/, refs/drain/, refs/tmp/) stay where they are. No ❌ needs fix stands at this head.

@argszero
argszero merged commit 46f180f into master Sep 17, 2026
2 checks passed
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