workflow: stop parallel content cycles from colliding - #175
Merged
Conversation
Four PRs (#165, #166, #168, #172) sat unmergeable at once. None of them had failing CI; they were blocked by the workflow itself, in three ways. 1. Every cycle re-verified 'the canons with the oldest last_confirmed'. That rule is deterministic, so parallel cycles selected the same files and the first PR to merge left the rest conflicting on precisely the date fields they came to refresh. generator/reverify.py hands out disjoint blocks instead: aging canons sorted oldest-first, cut into fixed blocks, each seed hashing to one. Two seeds get the same block or share nothing - never the partial overlap that conflicts on merge. --exclude takes the IDs open PRs already touch, turning 'unlikely to collide' into 'cannot collide'. 2. Nothing told a cycle to look at open PRs before choosing a target, so three sessions independently picked New Zealand off the same view of main. Step 2 of the workflow now claims a target against the open PR list first. 3. The duplicate check was 'rg your-slug data/canons/', which only catches an identical slug. The actual duplicates arrived under different slugs describing the same dead end. The check is now by topic - read the country's existing signatures and summaries, and for each planned canon name its nearest neighbour and why it differs. Partial overlap gets narrowed and cross-linked, or folded into the existing canon, rather than shipped as a competing page. Also documents the re-verification contract: re-read every source before bumping any date. A refreshed date on an unchecked canon tells every downstream agent the entry was confirmed when it was not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ksw62KukVY7Ft7e8zZGhrp
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…lean Follow-up to the previous commit, from reviewing it. Selection cut the aging list into positional blocks. That made the central promise - two cycles never partially overlap - true only against a frozen corpus. Two cycles never see the same corpus: each branches from a different main, and any merge that ages canons in or refreshes them shifts every later block boundary, so two cycles could land on overlapping-but-unequal slices. Exactly the shape that conflicts on merge, and exactly what the module exists to prevent. Buckets are now a pure function of the canon ID, so a canon stays in its bucket no matter what else changed. Two seeds own the same bucket (identical picks, obvious at once) or share nothing. DEFAULT_BUCKETS has to stay constant for that agreement to hold, which is now documented at the constant and in both docs. Replaces the --pool knob with --buckets. Claim-scan fixes, all in the half the docs tell operators to trust: - A per-branch `git diff` failure was swallowed, so unrelated histories or a shallow CI clone produced ok=True with an empty set - the exact false clean bill of health the ClaimScan docstring forbids. Those branches are now collected and reported as INCOMPLETE, keeping the claims that were readable. - The short refname format renders refs/remotes/origin/HEAD as bare "origin", so the /HEAD guard never fired: HEAD was scanned as a branch, inflating the count and importing its canons as claims. Uses full refnames now. - An empty bucket was reported as "already claimed" whenever the scan had found anything, which with auto-exclude on is almost always. The two cases are now told apart by asking the corpus. - --count 0 / --buckets 0 ran the whole scan before dying on an uncaught ValueError; argparse rejects them up front. - The DEFAULT_BUCKETS comment claimed collisions among ~30 country seeds were unlikely. Across all seeds a collision is near-certain; the number that justifies the constant is per-concurrency (~2.3% for 3 at once). Tests: 32 for this module, including that no two seeds partially overlap across two *different* corpus states, that an unreadable branch marks the scan incomplete, and that origin/HEAD is not counted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ksw62KukVY7Ft7e8zZGhrp
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Four PRs sat unmergeable at the same time — #165, #166, #168, #172. None of them had a failing check;
validate-and-buildwas green on all four. They were blocked by the content workflow itself, in three separate ways, all of which would recur on the next batch.1. Everyone re-verified the same three files
Every content PR refreshes a few aging canons alongside its new pages, and every cycle picked "the canons with the oldest
last_confirmed". That rule is deterministic, so parallel cycles selected the same files. Whichever PR merged first left the rest conflicting on precisely the date fields they came to refresh:No disagreement about anything — just two cycles doing identical work.
Fix:
generator/reverify.pyassigns each cycle a bucket. Every canon belongs to a bucket determined solely by hashing its ID; a seed (the target country code) hashes to one bucket, and the cycle takes the oldest canons in it.Bucketing by ID rather than by position in the aging list is the whole point. Two cycles never see the same list — each branches from a different
main, and any merge that ages canons in or refreshes them shifts every later boundary — so positional blocks would let two cycles land on overlapping-but-unequal slices. That is exactly the shape that conflicts on merge. A canon's ID does not move.So two seeds either own the same bucket (identical picks, obvious immediately) or share nothing at all. A partial overlap cannot occur.
DEFAULT_BUCKETSmust stay constant for that agreement to hold; that constraint is documented at the constant and in both docs.The command additionally excludes every canon another pushed branch already touches, on by default — a guarantee nobody has to remember beats one that needs the right flag. It always reports which state the scan is in: excluded N, clean, or incomplete. A scan that could not read every branch never reads as a clean bill of health.
2. Nothing said to check open PRs before picking a target
Three sessions independently chose New Zealand because each looked only at
main, where the other two branches were invisible. Step 2 now claims a target against the open-PR list first.3. The duplicate check only caught identical slugs
The check was
rg -l "your-slug" data/canons/. Every duplicate that actually shipped arrived under a different slug describing the same dead end —banking/no-ird-number-45-percent-withholding/nzvsbanking/rwt-non-declaration-rate/nz,legal/undeclared-risk-goods-instant-fine/nzvsfood-safety/undeclared-biosecurity-goods/nz. A slug grep cannot see those, and near-duplicate pages are what the workflow's own hard rules say hurt indexing.The check is now by topic: read the country's existing signatures and summaries, then for each planned canon name its nearest neighbour and say why it differs. Partial overlap gets narrowed to the new angle and cross-linked, or folded into the existing canon — not shipped as a competing page.
Also: the re-verification contract
Re-verification now means re-reading the sources, in order — open every
sources[]URL, fix moved URLs and broken claims, and only then bumplast_confirmed/verdict.last_updated/metadata.last_verification. A refreshed date on a canon nobody re-read tells every downstream agent the entry was confirmed when it was not, so that is a hard rule rather than an implication.Known limits, stated rather than papered over
git merge-base --is-ancestorreports long-merged branches as unmerged and there is no reliable local signal to replace it. Over-excluding costs one canon in your own bucket; under-excluding brings the conflicts back.--exclude.Changes
generator/reverify.py— new module + CLI. Reuses the validator'sAGING_THRESHOLD_DAYSand age calculation rather than reimplementing them. Useshashlib.sha256rather thanhash(), which is salted per process and would make two sessions disagree about bucket ownership.tests/test_reverify.py— 32 tests. Notably: no two seeds partially overlap across two different corpus states (the property positional blocks could not provide); an unreadable branch marks the scan incomplete rather than clean;origin/HEADis not counted as a branch; exclusion applies even on a corpus smaller than one bucket..claude/commands/add-canon.md— new step 2 (claim against open PRs), topic-based duplicate check in step 3, new step 6 (assigned bucket), two new hard rules, steps renumbered.CLAUDE.md— module listing, key commands, a "Re-verifying Aging Canons" section, and the new test file.No canon data changes; canon count is unchanged.
Two review passes ran over this; the second commit is what they turned up — the positional-vs-ID bucketing flaw above, plus four claim-scan defects (a swallowed per-branch
git difffailure that produced a false "clean", a/HEADguard that never fired because the short refname format renders it as bareorigin, an "already claimed" message on a bucket that was merely empty, and--count 0dying on an uncaughtValueErrorafter running the whole scan).Checks
ruff check generator/ tests/— cleanpython -m pytest tests/ -q— 369 passed (337 existing + 32 new)python -m generator.validate --data-only— PASSED (2486 canons, 0 stale)python -m generator.build_site+python -m generator.validate --site-only— PASSED