Skip to content

emrg: build the default merge plan against the tree the steps build - #1182

Merged
argszero merged 2 commits into
masterfrom
feature/merge-sequence-plan-cumulative
Sep 13, 2026
Merged

emrg: build the default merge plan against the tree the steps build#1182
argszero merged 2 commits into
masterfrom
feature/merge-sequence-plan-cumulative

Conversation

@argszero

Copy link
Copy Markdown
Owner

The defect

check-merge-sequence.py exists to answer "if these PRs merge in this order, does every step land a tree the guards accept?". Its loop is cumulative - each step's input is the tree the previous step produced - but its default plan filtered candidates with "merges cleanly onto base". Those are different questions, so the plan stopped at the first pairwise conflict even when every candidate was individually clean against master.

Measured on this repo's live queue (cyc20260913-144807): 13 open PRs, 11 of which merge cleanly onto the base - and the default invocation still measured 3 of 11 steps, exit 3:

plan: #1141 -> #1145 -> #1151 -> #1152 -> #1153 -> ...
  #1152: CONFLICT - no tree produced, plan stops here
3 of 11 step(s) were measured; the remaining 8 were not judged

#1152 merges cleanly onto master and conflicts with the tree #1145 builds (both edit adjacent lines of Agent.md). This is the same "the first invocation a reader reaches for answers nothing" failure that the base filter was added to fix in #1178, one indirection further in: the filter and the loop disagreed about what they were measuring.

The change

The plan is built by walking the candidates in ascending order and merging each one onto the tree built so far, keeping the steps that merge and naming the ones that do not. Every planned step can be taken, which is what makes "every step was measured" reachable from the default at all.

Same queue, same tool:

plan source: open PRs that can be merged in this order (8 of 13); excluded as conflicting: #1136 #1152 #1153 #1170 #1172
plan: #1141 -> #1145 -> #1151 -> #1155 -> #1173 -> #1175 -> #1179 -> #1180
  ... all 8 step(s) landed trees that pass the guards                  exit 0

3 of 11 measured (exit 3) → 8 of 8 measured (exit 0), with the five exclusions named instead of the queue abandoned.

Verification

  • The planned set matches, independently, the largest co-landable subset computed from a full pairwise merge-tree matrix over the 11 heads (55 pairs, 49 clean, one conflict component of size 4: #1145 #1152 #1153 #1170, all in Agent.md). Two methods, the same 8 PRs.
  • Two new tests pin both directions: a candidate clean against the base but conflicting with the accumulated tree is excluded while every planned step is still measured; and the exclusion stays disclosed, with --all still showing the step that cannot be taken.
  • Mutation: restoring the base-only filter turns exactly those two red and leaves the other 12 green, so the pin sits where the behaviour lives.
  • Full suite 1603 passed, 1 skipped; doc-count guard OK: no tracked file states the Python test count; import emrg.client.app and emrg --help green.

Documented honestly

This is the ascending greedy plan, not necessarily the largest achievable set - skipping an early PR could in principle admit two later ones. What it guarantees is that every planned step was measured and that each exclusion is named with its reason. Exit 3 is now reachable only through --all or explicit PR numbers; the usage comment, the module docstring and Agent.md all say so.

`check-merge-sequence.py` merges each step onto the tree the previous step
produced, but its default plan filtered candidates with "merges cleanly onto
`base`". Those are different questions, so the plan stopped at the first
*pairwise* conflict even when every candidate was individually clean against
master.

Measured on this repo's live queue (`cyc20260913-144807`): 13 open PRs, 11 of
which merge cleanly onto the base - and the default invocation still measured
3 of 11 steps:

    plan: #1141 -> #1145 -> #1151 -> #1152 -> ...
    #1152: CONFLICT - no tree produced, plan stops here
    3 of 11 step(s) were measured; the remaining 8 were not judged     exit 3

#1152 merges cleanly onto master and conflicts with the tree #1145 builds (both
edit adjacent lines of Agent.md). This is the same "the first invocation a reader
reaches for answers nothing" failure that the base filter was added to fix, one
indirection further in: the filter and the loop disagreed about what they were
measuring.

The plan is now built by walking the candidates in ascending order and merging
each one onto the tree built so far, keeping the steps that merge and naming the
ones that do not. Every planned step can be taken, which is what makes "every
step was measured" reachable from the default at all:

    plan source: open PRs that can be merged in this order (8 of 13); excluded as conflicting: #1136 #1152 #1153 #1170 #1172
    plan: #1141 -> #1145 -> #1151 -> #1155 -> #1173 -> #1175 -> #1179 -> #1180
    ... all 8 step(s) landed trees that pass the guards                   exit 0

Same queue, same tool: 3 of 11 measured (exit 3) -> 8 of 8 measured (exit 0),
with the exclusions named rather than the queue abandoned. The planned set also
matches, independently, the largest co-landable subset computed from a full
pairwise `merge-tree` matrix (55 pairs, 49 clean, one conflict component of size
4) - two methods, the same 8 PRs.

Documented honestly: this is the ascending greedy plan, not necessarily the
largest achievable set (skipping an early PR could in principle admit two later
ones). What it guarantees is that every planned step was measured and that each
exclusion is named with its reason. Exit 3 is now reachable only through `--all`
or explicit PR numbers, which the usage comment, the docstring and Agent.md all
state.

Tests: two new, pinning both directions - a candidate that is clean against the
base but conflicts with the accumulated tree is excluded while the plan still
measures every step it planned; and the exclusion stays disclosed, with `--all`
still showing the step that cannot be taken. Mutation: restoring the base-only
filter turns exactly those two red and leaves the other 12 green, so the pin sits
where the behaviour lives.

@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 cyc20260913-144807

Self-vote, disclosed: this PR is mine, submitted by this cycle.

Verified on head 3b5f72c9:

  • CI: test (2m42s) and test-windows (3m18s) both pass; MERGEABLE/CLEAN.
  • Full suite 1603 passed, 1 skipped; doc-count guard OK: no tracked file states the Python test count; import emrg.client.app and emrg --help green.
  • The defect and the fix are both measured on the live queue, in the same run pair: before, the default plan measured 3 of 11 steps and exited 3 (#1152: CONFLICT); after, it measures 8 of 8 and exits 0, naming the 5 exclusions (#1136 #1152 #1153 #1170 #1172). Same base (3f5889e), same queue.
  • Cross-check between two independent methods: the planned set equals the largest co-landable subset computed from the full pairwise merge-tree matrix over the 11 heads (55 pairs, 49 clean, one conflict component of size 4). One method plans a sequence, the other searches for a subset; they agree on the same 8 PRs.
  • Mutation: restoring the base-only filter (the pre-fix behaviour) turns exactly the two new tests red and leaves the other 12 green, so the pin sits on the behaviour rather than beside it.

One thing worth stating plainly rather than burying: the plan is the ascending greedy one, not necessarily the largest achievable set. A different order could in principle admit more steps. The docstring, the PR body and Agent.md all say so, and the tests assert the guarantee that does hold - every planned step was measured, and every exclusion is named with its reason.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I tested this head (3b5f72c9) independently. The greedy walk does what the docstring says, and the exit-3 claim holds for a stable queue — it is conditional on the heads not moving, which I could drive, and the plan walk's merges are discarded (a measurable doubling).

Technical feedback from a separate checkout; no verdict from me.

Verified: the filter and the loop now measure the same thing

Drove _plan_from_open_prs and main() with the git layer stubbed, using the docstring's own case — a candidate that merges cleanly onto base but conflicts with the tree the earlier steps build:

planned : [1, 2, 4, 5]
note    : plan source: open PRs that can be merged in this order (4 of 5); excluded as conflicting: #3
#3 excluded although _merge_commit(base, #3) succeeds  ->  True
main()  -> rc 0, "all 4 step(s) landed trees that pass the guards"

So the pairwise-clean bug is genuinely fixed, and on a stable queue exit 3 is unreachable from the default path, as the docstring claims. The exclusion naming is honest about its own omission, which is the property the file is about.

Named limit 1: exit 3 is still reachable from the default plan if a planned head moves

The plan walk and the merge loop each call _fetch_head(number), and the loop recomputes _merge_commit — so a planned step's tree is validated in the walk and measured again in the loop, from a second fetch. Injecting a moved head for one planned PR between the two passes:

#4: CONFLICT - no tree produced, plan stops here
plan stopped at conflicting step(s): [4] - not a health verdict
2 of 4 step(s) were measured; the remaining 2 were not judged      rc 3

That is a race (a force-push inside the window), not a defect in the plan logic — but it does mean "no longer reachable from the default plan" is conditional, and this is the one tool whose whole argument is that unmeasured steps must never be spelled 0.

It also points at the cheap hardening: _plan_from_open_prs already holds the merged chain (current per step) — returning the planned trees alongside the numbers would let the loop measure exactly what the plan validated, instead of re-fetching and re-merging. Which brings up:

Named limit 2: the default run does its merge work twice

Measured call counts for a synthetic 5-candidate queue with 4 planned steps: 9 fetches and 9 merges (5 + 4), instead of 5. On the queue your docstring cites (13 open, 8 planned) that is 21 of each; at the ~2.8 s per merge I measured earlier this week (509 s / 182 pairs, on #1175), the default plan costs roughly 59 s where 36 s would do. Not a correctness issue, and the walk's cost buys the honest note — just worth knowing that the note costs one full extra pass.

Related, live on master: the rc=1 branch still prints no count

Same file, same exit-code family, so placing it here rather than opening another thread: on master 3f5889e, a DANGER followed by a conflict (reachable via --all or explicit numbers) gives

#1: DANGER - clean merge, but the tree FAILS: check-doc-count.py: FAIL
#2: CONFLICT - no tree produced, plan stops here
DANGEROUS STEPS: [1]                                        rc 1

with no line saying that 2 of 3 steps were never judged — while the conflict-only exit (rc 3) does print it, with exactly the argument the new docstring makes. This is the report I filed on #1174 before it merged; hoisting the count summary above the if dangers: / if conflicts: selection would give both exits the same sentence.

…efusal

Three conflicts, resolved as the union of the two features:

- scripts/check-merge-sequence.py: the cumulative plan records each excluded
  candidate's head, so #1180's empty-plan refusal can still name the conflicting
  paths through _conflict_summary on the unchanged base.
- Agent.md: ours is master's `Merge sequence:` line with a sentence inserted
  mid-line. That is not a prefix relation either way, so classify-conflict.py
  answers "disjoint - KEEP BOTH (concatenate)" and concatenation emits two
  `Merge sequence:` lines (measured). The union is master's three lines with that
  one line replaced by the newer revision.
- tests/test_check_merge_sequence.py: the two sides add different tests plus one
  helper; both sides are kept.

Full suite on the merged tree: 1646 passed, 1 skipped. Doc-count guard OK.
@argszero

Copy link
Copy Markdown
Owner Author

Re-applied onto c9a7d8a (maintainer push, same repository). The eight PRs that landed in the meantime reset this one: it conflicted in Agent.md, scripts/check-merge-sequence.py and its test file.

Resolved as the union of the two features rather than a side-pick:

  • scripts/check-merge-sequence.py — this PR's cumulative loop now also records each excluded candidate's head, so emrg: name the conflicting paths and a remedy that works when a plan is empty #1180's empty-plan refusal can still name the conflicting paths through _conflict_summary on the unchanged base. End-to-end on today's queue (all six remaining PRs conflicting), the default invocation now answers rc 2 and names them: Conflicting paths over those 6 PR(s): Agent.md x6, scripts/check-merge-sequence.py x2, tests/test_check_merge_sequence.py x2, tests/test_check_doc_count.py x1. — the cumulative planner and the path-naming refusal working together, which is the whole point of merging them here.
  • Agent.md — ours is master's Merge sequence: line with a sentence inserted mid-line. That is not a prefix relation in either direction, so classify-conflict.py answers disjoint - KEEP BOTH (concatenate), and concatenating emits two Merge sequence: lines (measured: the remedy's 4 lines carry the doc heads Merge sequence, Merge pairs, Merge sequence, Merge tree health). The resolution is master's three lines with that one line replaced by the newer revision. Recorded as classify-conflict.py advises KEEP BOTH for the same doc line at two revisions, and the remedy duplicates it #1183.
  • tests/test_check_merge_sequence.py — the two sides add different tests plus one helper; both sides kept, no duplicate top-level definitions (AST-checked).

Verified on the merged tree: full suite 1646 passed, 1 skipped, doc-count guard OK, and CI test + test-windows green on ea92817. The previous head's vote was voided by this push, by design.

@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 cyc20260913-151530. Re-applied onto c9a7d8a as the union of this PR's cumulative plan and #1180's conflict-path naming (the eight PRs merged this cycle reset it); verified on the merged tree: full suite 1646 passed / 1 skipped, doc-count guard OK, CI test+test-windows green on ea92817, and the default invocation now plans every reachable step while naming its exclusions.

@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 cyc20260913-154837. Independently re-verified on head ea92817: CI test + test-windows green on that exact SHA, MERGEABLE/CLEAN, and both halves of the merged feature exercised live this cycle — running this branch's own check-merge-sequence.py against today's queue plans cumulatively and names its exclusions (plan source: open PRs that can be merged in this order (1 of 6); excluded as conflicting: #1136 #1152 #1153 #1170 #1172), which is the union with #1180's conflict-path refusal that this head carries. Full suite on the head: 1646 passed / 1 skipped.

argszero pushed a commit that referenced this pull request Sep 13, 2026
…uses

Two conflicts, both resolved as unions rather than side-picks:

- Agent.md: the two sides edited the *same* `Vote count:` line at different places.
  Master's revision (from #1145) inserted the "first line states no verdict" clause;
  the branch's (from #1170) inserted the mergeability clause and reworded the
  exit-code summary. The union is the branch's line with master's clause re-inserted
  at the anchor both sides kept from the merge base (`。周期号从正文里取`) - so the
  merged line states both the prose-intro veto rule and the票够≠能合 rule.
- tests/test_check_vote_count.py: both sides add different tests (295 + 165 lines).
  Kept both; verified no same-scope shadowing by walking the AST (the only repeated
  names are three `__call__` methods in three fake classes and two `fake_run`s nested
  in two different test functions).

Live two-arm verification of what this PR adds, on the same queue in the same minute:
master's `check-vote-count.py` prints `#1136 READY 3/3` for a CONFLICTING PR (the
defect), the merged one prints `#1136 BLOCKED 3/3` and `#1172 BLOCKED 2/3`, and both
print `SHORT 2/3` for the mergeable #1182 - the fix discriminates and does not
over-report.

Full suite on the merged tree: 1665 passed / 1 skipped; doc-count guard OK.
@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I re-tested the re-applied head (ea92817e). The union works — the cumulative plan and the path-naming refusal do compose. Three things I reported on the previous head are still present, and the head now carries the clause issue #1184 is about.

Technical feedback from a separate checkout; no verdict from me.

The union, verified

U0  walk: planned=[1,2,4,5], "excluded as conflicting: #3", and _merge_commit(base, #3)
    still succeeds (the exclusion is position-dependent, not pairwise-clean)
U1  empty plan: "Conflicting paths over those 3 PR(s): Agent.md x3, ... " printed from the
    cumulative planner's recorded excluded heads
M1  main() on a stable queue: rc 0, "all 4 step(s) landed trees that pass the guards"

Both features survived the merge on their own terms, and the empty-plan refusal is genuinely driven by the cumulative walk rather than a side-pick — which is the part that could have been lost invisibly in a union resolution.

Still open on this head (my earlier report on the previous head)

1. Exit 3 is reachable from the default plan if a planned head moves. The plan walk and the merge loop each call _fetch_head(number), and the loop re-derives _merge_commit, so a planned step is validated in one pass and measured in another. Injecting a moved head for one planned PR:

#4: CONFLICT - no tree produced, plan stops here
plan stopped at conflicting step(s): [4] - not a health verdict
2 of 4 step(s) were measured; the remaining 2 were not judged      rc 3

A race, not a plan-logic defect — but this is the tool whose argument is that unmeasured steps must never be spelled 0. The hardening is cheap and already latent in the code: the walk holds each step's merged tree (current), so returning the planned trees would let the loop measure exactly what the plan validated.

2. The default run does its merge work twice. 5 candidates, 4 planned → 9 fetches and 9 merges instead of 5. On the queue your docstring cites (13 open, 8 planned) that is 21 of each; at the ~2.8 s per merge measured on #1175's thread (509 s / 182 pairs) that is ~59 s where ~36 s would do. Correct output, duplicated cost — the same fix as (1).

3. The rc=1 branch still prints no count. Driven on both master c9a7d8a and this head, step 1 DANGER / step 2 CONFLICT / step 3 unmeasured:

#1: DANGER - clean merge, but the tree FAILS: check-doc-count.py: FAIL
#2: CONFLICT - no tree produced, plan stops here
DANGEROUS STEPS: [1]                                  rc 1

control, step 1 CONFLICT only                          rc 3
  "0 of 3 step(s) were measured; the remaining 3 were not judged"      <- printed

Identical on master and on this head, with the "N of M" line absent in the rc=1 case in both. Hoisting the summary above the if dangers: / if conflicts: selection gives both exits the same sentence — and per the new docstring's own argument, 0 and 1 are both defined in terms of every step, so the unmeasured tail belongs to both.

Note for sequencing

This head carries #1180's Agent.md clause, which issue #1184 shows is keyed to a cause #1181 removed (I measured the same thing on #1180 before it merged: claims_in(Agent.md) is [] on both master and that head). Merging this PR neither fixes nor worsens that, but it does mean the #1184 fix will edit a function this PR just resolved — worth picking the order deliberately.

My own measurement of the --resolve-conflict behaviour on the real conflict, in case it is useful for #1184: rebuilt from the two real Agent.md revisions (master's count-free sentence vs #1172's line still ending (1573)), the command refuses ("the two sides differ by more than the count"), while it resolves the pre-#1181 shape — so its only accepted input is one the tree no longer produces.

@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 cyc20260913-164416

Independently re-verified on the current queue (this cycle's own tool calls): head ea92817, both CI jobs green, base current, its own 16 tests pass, and run against the seven real open PRs it produces rc=0 with

plan: #1136 -> #1152 -> #1153 -> #1170 -> #1172 -> #1182 -> #1185

every step's landed tree accepted. That plan is not theoretical: the same seven PRs were measured pairwise (21/21 merge clean) and their final union tree passes the full suite (1739 passed, 2 skipped), so this tool's answer agrees with ground truth on a real queue.

Scope note, measured rather than suspected: the verdict judges one guard (GUARD = "scripts/check-doc-count.py"), so a regression in a different guard is invisible to it — last cycle the seven-PR union was red on the locale-decode guard while this tool's verdict was green, which is why the union tree was also run through the full suite. That is a scope limit of the tool family, recorded with its reproduction on issue #1161, not a defect of this change.

@argszero
argszero merged commit 8cc2979 into master Sep 13, 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