You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
From:FS-GG/.github. Consolidates #431, #435, #437, #445, #476 and #343, which are closed as duplicates of this item. Protocol: ADR-0021 / ADR-0027. Family: epic #266 (gates that fail open).
Filed as one item rather than six because they are six symptoms of one root cause, they all edit one file, and #428 already established that filing them separately makes the repo's effective fan-out 1. Every original's evidence is carried below verbatim — nothing here should require reading a closed issue.
The root cause
"Is this item startable?" is computed in five places, and no two of them agree.
next, take, batch, reap and who each apply a different, inconsistent subset of the schedulability checks, over a blocked annotation that can be wrong in both directions. The result is a scheduler that reports "nothing to do" over real work, and hands out work it should have withheld.
The consequence is always the same, and it is the one thing the protocol exists to prevent: an unschedulable board and an empty board produce identical output. That is #266's rule — "nothing to check" and "checked, and it's fine" must not produce the same exit code — one layer down, in the scheduler.
next() (scripts/fsgg-coord:1000-1036) filters on exactly two things: Status (Ready, then Backlog) and .blocked. It never calls paths_of, never calls conflicts_between, and never looks at live claim markers. Meanwhile batch_cmd (:2464) explicitly passes such items over:
skipped="$skipped $id — no 'Paths:' declared (cannot schedule)"
So an undeclared touch-set fails open. The script has already named this exact hazard one level up — TOUCHSET_GRAMMAR's own comment (:1917-1920) argues it for unmatchable tokens:
a token that matches nothing CONFLICTS WITH NOTHING: overlap reports DISJOINT, batch hands the item out, and two workers edit the same files while the tool tells them both they are disjoint.
An absent declaration has the same shape as an unmatchable one. (Note claim deliberately does allow a no-Paths: item — :1586 — and that is coherent on its own. What is not coherent is next presenting such an item as startable.)
Live repro (FS.GG.Rendering, 2026-07-11, 6 workers in flight). All 20 candidates were undeclared, so take --repo rendering returned "no schedulable item" while the board looked full. next --repo rendering then recommended FS.GG.Rendering#436 — which was demonstrably a collision:
$ fsgg-coord overlap FS.GG.Rendering#436 FS.GG.Rendering#453
OVERLAP — share touch-set; sequence them, do NOT parallelize:
tests/Package.Tests/AudioProfileWiringTests.fs ⇄ tests/Package.Tests
#453 was held by a live claim (heron-7c2) at that moment. Second recorded instance; FS.GG.Rendering#464 documents the first.
Fix:next must not present a no-Paths: item as startable (skip it, matching batch, or print a loud "declares no touch-set — reserves nothing"), and must run the same disjointness check against live claims that batch already runs. Today the two commands answer "what can I start?" with contradictory logic — batch is right, and next is the one a human reads.
(b) A backticked Paths: line is silently mis-parsed — was #435
paths_from_body does not strip markdown inline-code backticks. A Paths: line written the way everyone naturally writes one —
— normalizes to tokens with the backticks still attached. The trailing-glob strip is anchored at end-of-string:
sed -E 's#^\./##; s#/\*\*$##; s#/\*$##; s#/+$##'
The token ends in a backtick, not *, so /\*\*$ never matches. The ** survives, invalid_paths (grep -E '[*?[]') flags it, and the item is refused as unmatchable.
body as written
resulting token
Paths: `src/FS.GG.Audio.Elmish/**`
`src/FS.GG.Audio.Elmish/**` → unmatchable
Paths: src/FS.GG.Audio.Elmish/**
src/FS.GG.Audio.Elmish → fine
The refusal itself is correct and fail-closed (#273 is why an unmatchable token must not answer DISJOINT). The defect is that a grammar-legal declaration is rejected for its markdown formatting, and nothing says so — the item never appears in batch, and take reports an empty queue. Worse, batchdoes print unmatchable 'Paths:' token(s): … but renders the offending token inside backticks in its own output, which makes the author's backticks invisible in the one place they are named.
Live instances:FS-GG/FS.GG.Audio#29 (repaired by hand with widen) and FS-GG/FS.GG.Audio#31 (same form; note it also declares a leading **/packages.lock.json, which the grammar genuinely refuses — that one is a real author error).
Fix: strip backticks during normalization (| tr -d '\'before the sed) — aPaths:token can never legitimately contain one, so the strip is unambiguous.widensharespaths_from_body`, so both sides stay coherent. Then name the cause in the refusal, and show the token raw rather than re-wrapping it in backticks.
(c) A null Status is invisible, forever, silently — was #437
next/batch/take only consider items whose Status is Ready or Backlog. A nullStatus is not "unscheduled" — it is unschedulable, permanently. The item is on the board, ready even prints it (with — in the STATUS column), and no scheduler will ever hand it out.
Live repro:take --repo sdd reported an empty queue while FS.GG.SDD#349–#352 sat open, well-specified, unclaimed and roadmap-labelled, with {"status": null, "phase": null}. Setting Phase: P2 SDD + Status: Backlog made them schedulable immediately; two merged within the hour.
Why it happens: the filing protocol is a four-step sequence with no atomicity and no guard (item-add, then three set-field calls). Step 1 alone puts the item on the board. If steps 2–4 are skipped, interrupted, or rate-limited away, the item lands looking filed and is never schedulable. Nothing detects it: lint checks epic invariants, and check-board's finding table has no code for it.
Fix: (1) check-board gains an ON-BOARD-NO-STATUS finding, auto-fixable under --apply — this is squarely in its charter, since the fix only ever writes to the board. (2) fsgg-coord grows a single file verb doing all four steps atomically; the four-step recipe in the skill is the bug's delivery mechanism. (3) next/batch surface it as a diagnosis rather than skipping it silently.
(d) Lease expiry is treated as proof of abandonment — was #445
take --repo rendering handed out FS.GG.Rendering#429 while shrike-a91 was actively working it, with PR #433 open on item/429-interactive-audio-sink. It did so because the claim was past its 120m lease: the box was loaded (5 concurrent workers, 40+ dotnet processes, an F# Interactive OutOfMemoryException from contention), so a long build/test cycle silently outran the lease while the worker was very much alive.
Lease expiry is only ever evidence of abandonment, never proof, and it has a systematic false-positive mode: work that takes longer than the lease. The protocol's own remedy (heartbeat) is what a busy worker forgets precisely when the work is long — i.e. exactly when the lease is most likely to lapse. Meanwhile a far stronger, cheap, server-side signal sits right there: an open PR whose head branch is item/<n>-*. Nothing consults it.
This compounds a sharp edge: an expired lease cannot be renewed (heartbeat refuses, correctly), so a worker who overruns cannot recover their own claim — they must re-claim and race a take that is now actively offering their item to others.
Fix: (1) take/batch must not offer an item whose item/<n>-* branch has an open PR — skip with a reason. (2) reap --apply refuses (or requires --force) for such items. (3) who marks it: STALE (PR #433 open) reads very differently from a bare STALE.
"Workers should heartbeat" is true and insufficient. The protocol is designed so that the lock, not worker diligence, prevents collisions — that is the entire argument of ADR-0027. A lock that releases itself while the holder is demonstrably still working is a lock with a liveness bug, and the fix belongs in the scheduler.
(e) Blocked by naming a PR never clears — MERGED ≠ CLOSED — was #476
board_annotate decides blockedness with:
blocked: ($blockers|any(.state!="CLOSED"))
IssueState is only OPEN | CLOSED, so != "CLOSED" is right for issues and silently wrong for PRs — PullRequestState is OPEN | CLOSED | MERGED. Two independent legs both fail closed, permanently:
blocker ref
resolved state
blocks?
PR, not on the board (the normal case)
UNKNOWN (not in $idx)
yes — forever. A PR is never added to the board, so the ref can never resolve.
PR, on the board, merged
MERGED
yes — forever.MERGED != "CLOSED".
PR, on the board, closed unmerged
CLOSED
no — unblocks
The gate opens exactly when the blocking work is abandoned, and stays shut forever when it is completed. Verified against the live API — this is the enum, not a guess:
$ gh api graphql -f query='query { repository(owner:"FS-GG", name:".github") { merged: pullRequest(number: 371) { state merged } open: pullRequest(number: 449) { state merged } } }'{"merged":{"state":"MERGED","merged":true},"open":{"state":"OPEN","merged":false}}
board_itemsdoes select ... on PullRequest{ state }, so MERGED genuinely reaches board_annotate. The write side does not catch it either: canon_blocked_by validates only the shape (owner/repo#n), never that the number is an issue rather than a PR, and makes no API call.
Live on a critical path:FS-GG/FS.GG.SDD#350 is Blocked by: FS-GG/.github#449 — the open PR carrying ADR-0031, the very decision that resolves #350. When #449 merges, #350 stays blocked=truepermanently.
Fix: (1) a blocker is resolved when CLOSEDorMERGED — a one-character-class change, strictly correct for issues, which are never MERGED. (2) Resolve the refs $idx misses via issueOrPullRequest(number:) in a single batched query per scan, rather than assuming they block; this also fixes the issue-not-on-the-board UNKNOWN case, which starves the same way. (3) canon_blocked_by should decide, not shrug — either reject a PR ref on write, or accept it knowingly now that (1)/(2) make it resolvable.
Do not fix this by hand-clearing #350's field. That hides the defect and loses the author's real dependency.
(f) The fail-open inverse: batch handed out an item with an open blocker — was #343
Mechanism not pinned — this is filed as evidence, not as a diagnosis, and it is the one leg that still needs root-causing.
Observed 2026-07-10, repo FS.GG.Game. PR #85 merged at 13:41:57Z closing #74; #84 remained open (Backlog, unclaimed). At ~13:45Z:
75 Backlog blockedBy=FS-GG/FS.GG.Game#74, FS-GG/FS.GG.Game#84 blocked=false <-- #84 is OPEN
84 In progress blockedBy=null blocked=false
and batch handed out #75. #75 and #84overlap on src/Game.Core/Physics.fs and tests/Game.Core.Tests/PhysicsTests.fs (overlap agrees, exit 1). Had a second worker taken #75, two workers would have been editing the same two files with no Blocked by between them — stopped only because the reporter had just claimed #84.
The jq is provably correct on the exact inputs: blocked: ($blockers | any(.state != "CLOSED")) reproduces the correct answer when fed those rows verbatim. Every failure mode considered (item absent from scan, unparseable ref, pagination gap) yields UNKNOWN, which counts as blocking — the safe direction. The observed false could not be reproduced. board_items, board_annotate, ready and batch were byte-identical between kit 25d24aec and c4290894, so it is not a fixed-in-flight artifact.
Where to look:content.state as populated by the paginated items(first:100) scan in board_items — whether some page can deliver an issue node whose state is stale, or a from_entries last-wins collision on "\(.repo)#\(.number)" (the index is built from PR nodes too, which is also leg (e)'s territory — these two may be the same bug).
Fix, regardless of cause:batch/next must assert that no item they hand out has an open blocker, and refuse loudly rather than trusting the annotated boolean. That converts a silent double-assignment into a crash.
Acceptance
next never recommends an item that take/batch would refuse to schedule — the two commands agree on "startable".
A Paths: line that is legal under TOUCHSET_GRAMMAR schedules regardless of markdown formatting; a token that still fails is named, raw, with the reason.
An open, roadmap-labelled board item with no Status is reported by check-board and fixed under --apply.
take/reap do not collect an item whose item/<n>-* branch has an open PR.
A Blocked by ref naming a merged PR does not block; one naming an open PR does; one naming an issue not on the board resolves to its true state rather than blocking by default.
batch/nextassert no handed-out item has an open blocker, and crash rather than trust the annotation.
take/next reporting "nothing to do" implies there is genuinely nothing to do — or names what it could not schedule, and why.
Regression tests in tests/fsgg-coord/ for every leg above, including a merged-PR fixture — the MERGED != CLOSED leg is invisible to any test whose fixtures only contain issues, and per [epic] Coherence gates that fail open — a missing subject reports green #266 an untested failure leg is how this class survives.
Why one item and not six
#428 measured the cost of the alternative: scripts/fsgg-coord is a ~2,900-line god-file, Paths: touch-sets are compared file-wise, so the moment one worker claims any of these, the rest become unschedulable — the repo's effective fan-out drops to 1, in the repo that owns the fan-out protocol. Legs (e) and (f) are plausibly the same defect in board_items' index; (a) and (c) both land in the same next predicate. Splitting them across six workers would serialize them anyway and lose the shared diagnosis.
Splitting the god-file is the real fix and is tracked separately in #428.
From:
FS-GG/.github. Consolidates #431, #435, #437, #445, #476 and #343, which are closed as duplicates of this item. Protocol: ADR-0021 / ADR-0027. Family: epic #266 (gates that fail open).Filed as one item rather than six because they are six symptoms of one root cause, they all edit one file, and #428 already established that filing them separately makes the repo's effective fan-out 1. Every original's evidence is carried below verbatim — nothing here should require reading a closed issue.
The root cause
"Is this item startable?" is computed in five places, and no two of them agree.
next,take,batch,reapandwhoeach apply a different, inconsistent subset of the schedulability checks, over ablockedannotation that can be wrong in both directions. The result is a scheduler that reports "nothing to do" over real work, and hands out work it should have withheld.The consequence is always the same, and it is the one thing the protocol exists to prevent: an unschedulable board and an empty board produce identical output. That is #266's rule — "nothing to check" and "checked, and it's fine" must not produce the same exit code — one layer down, in the scheduler.
The five legs
(a)
nextrecommends whatbatchrefuses — was #431next()(scripts/fsgg-coord:1000-1036) filters on exactly two things:Status(Ready, thenBacklog) and.blocked. It never callspaths_of, never callsconflicts_between, and never looks at live claim markers. Meanwhilebatch_cmd(:2464) explicitly passes such items over:So an undeclared touch-set fails open. The script has already named this exact hazard one level up —
TOUCHSET_GRAMMAR's own comment (:1917-1920) argues it for unmatchable tokens:An absent declaration has the same shape as an unmatchable one. (Note
claimdeliberately does allow a no-Paths:item —:1586— and that is coherent on its own. What is not coherent isnextpresenting such an item as startable.)Live repro (FS.GG.Rendering, 2026-07-11, 6 workers in flight). All 20 candidates were undeclared, so
take --repo renderingreturned "no schedulable item" while the board looked full.next --repo renderingthen recommendedFS.GG.Rendering#436— which was demonstrably a collision:#453was held by a live claim (heron-7c2) at that moment. Second recorded instance; FS.GG.Rendering#464 documents the first.Fix:
nextmust not present a no-Paths:item as startable (skip it, matchingbatch, or print a loud "declares no touch-set — reserves nothing"), and must run the same disjointness check against live claims thatbatchalready runs. Today the two commands answer "what can I start?" with contradictory logic —batchis right, andnextis the one a human reads.(b) A backticked
Paths:line is silently mis-parsed — was #435paths_from_bodydoes not strip markdown inline-code backticks. APaths:line written the way everyone naturally writes one —— normalizes to tokens with the backticks still attached. The trailing-glob strip is anchored at end-of-string:
sed -E 's#^\./##; s#/\*\*$##; s#/\*$##; s#/+$##'The token ends in a backtick, not
*, so/\*\*$never matches. The**survives,invalid_paths(grep -E '[*?[]') flags it, and the item is refused as unmatchable.Paths: `src/FS.GG.Audio.Elmish/**``src/FS.GG.Audio.Elmish/**`→ unmatchablePaths: src/FS.GG.Audio.Elmish/**src/FS.GG.Audio.Elmish→ fineThe refusal itself is correct and fail-closed (#273 is why an unmatchable token must not answer
DISJOINT). The defect is that a grammar-legal declaration is rejected for its markdown formatting, and nothing says so — the item never appears inbatch, andtakereports an empty queue. Worse,batchdoes printunmatchable 'Paths:' token(s): …but renders the offending token inside backticks in its own output, which makes the author's backticks invisible in the one place they are named.Live instances:
FS-GG/FS.GG.Audio#29(repaired by hand withwiden) andFS-GG/FS.GG.Audio#31(same form; note it also declares a leading**/packages.lock.json, which the grammar genuinely refuses — that one is a real author error).Fix: strip backticks during normalization (
| tr -d '\'before the sed) — aPaths:token can never legitimately contain one, so the strip is unambiguous.widensharespaths_from_body`, so both sides stay coherent. Then name the cause in the refusal, and show the token raw rather than re-wrapping it in backticks.(c) A null
Statusis invisible, forever, silently — was #437next/batch/takeonly consider items whoseStatusisReadyorBacklog. A nullStatusis not "unscheduled" — it is unschedulable, permanently. The item is on the board,readyeven prints it (with—in the STATUS column), and no scheduler will ever hand it out.Live repro:
take --repo sddreported an empty queue while FS.GG.SDD#349–#352 sat open, well-specified, unclaimed androadmap-labelled, with{"status": null, "phase": null}. SettingPhase: P2 SDD+Status: Backlogmade them schedulable immediately; two merged within the hour.Why it happens: the filing protocol is a four-step sequence with no atomicity and no guard (
item-add, then threeset-fieldcalls). Step 1 alone puts the item on the board. If steps 2–4 are skipped, interrupted, or rate-limited away, the item lands looking filed and is never schedulable. Nothing detects it:lintchecks epic invariants, andcheck-board's finding table has no code for it.Fix: (1)
check-boardgains anON-BOARD-NO-STATUSfinding, auto-fixable under--apply— this is squarely in its charter, since the fix only ever writes to the board. (2)fsgg-coordgrows a singlefileverb doing all four steps atomically; the four-step recipe in the skill is the bug's delivery mechanism. (3)next/batchsurface it as a diagnosis rather than skipping it silently.(d) Lease expiry is treated as proof of abandonment — was #445
take --repo renderinghanded out FS.GG.Rendering#429 whileshrike-a91was actively working it, with PR #433 open onitem/429-interactive-audio-sink. It did so because the claim was past its 120m lease: the box was loaded (5 concurrent workers, 40+dotnetprocesses, an F# InteractiveOutOfMemoryExceptionfrom contention), so a long build/test cycle silently outran the lease while the worker was very much alive.Lease expiry is only ever evidence of abandonment, never proof, and it has a systematic false-positive mode: work that takes longer than the lease. The protocol's own remedy (heartbeat) is what a busy worker forgets precisely when the work is long — i.e. exactly when the lease is most likely to lapse. Meanwhile a far stronger, cheap, server-side signal sits right there: an open PR whose head branch is
item/<n>-*. Nothing consults it.This compounds a sharp edge: an expired lease cannot be renewed (
heartbeatrefuses, correctly), so a worker who overruns cannot recover their own claim — they must re-claimand race atakethat is now actively offering their item to others.Fix: (1)
take/batchmust not offer an item whoseitem/<n>-*branch has an open PR — skip with a reason. (2)reap --applyrefuses (or requires--force) for such items. (3)whomarks it:STALE (PR #433 open)reads very differently from a bareSTALE.(e)
Blocked bynaming a PR never clears — MERGED ≠ CLOSED — was #476board_annotatedecides blockedness with:IssueStateis onlyOPEN | CLOSED, so!= "CLOSED"is right for issues and silently wrong for PRs —PullRequestStateisOPEN | CLOSED | MERGED. Two independent legs both fail closed, permanently:UNKNOWN(not in$idx)MERGEDMERGED != "CLOSED".CLOSEDThe gate opens exactly when the blocking work is abandoned, and stays shut forever when it is completed. Verified against the live API — this is the enum, not a guess:
board_itemsdoes select... on PullRequest{ state }, soMERGEDgenuinely reachesboard_annotate. The write side does not catch it either:canon_blocked_byvalidates only the shape (owner/repo#n), never that the number is an issue rather than a PR, and makes no API call.Live on a critical path:
FS-GG/FS.GG.SDD#350isBlocked by: FS-GG/.github#449— the open PR carrying ADR-0031, the very decision that resolves #350. When #449 merges, #350 staysblocked=truepermanently.Fix: (1) a blocker is resolved when
CLOSEDorMERGED— a one-character-class change, strictly correct for issues, which are neverMERGED. (2) Resolve the refs$idxmisses viaissueOrPullRequest(number:)in a single batched query per scan, rather than assuming they block; this also fixes the issue-not-on-the-boardUNKNOWNcase, which starves the same way. (3)canon_blocked_byshould decide, not shrug — either reject a PR ref on write, or accept it knowingly now that (1)/(2) make it resolvable.Do not fix this by hand-clearing #350's field. That hides the defect and loses the author's real dependency.
(f) The fail-open inverse:
batchhanded out an item with an open blocker — was #343Mechanism not pinned — this is filed as evidence, not as a diagnosis, and it is the one leg that still needs root-causing.
Observed 2026-07-10, repo FS.GG.Game. PR #85 merged at 13:41:57Z closing #74; #84 remained open (Backlog, unclaimed). At ~13:45Z:
and
batchhanded out #75. #75 and #84 overlap onsrc/Game.Core/Physics.fsandtests/Game.Core.Tests/PhysicsTests.fs(overlapagrees, exit 1). Had a second worker taken #75, two workers would have been editing the same two files with noBlocked bybetween them — stopped only because the reporter had just claimed #84.The jq is provably correct on the exact inputs:
blocked: ($blockers | any(.state != "CLOSED"))reproduces the correct answer when fed those rows verbatim. Every failure mode considered (item absent from scan, unparseable ref, pagination gap) yieldsUNKNOWN, which counts as blocking — the safe direction. The observedfalsecould not be reproduced.board_items,board_annotate,readyandbatchwere byte-identical between kit25d24aecandc4290894, so it is not a fixed-in-flight artifact.Where to look:
content.stateas populated by the paginateditems(first:100)scan inboard_items— whether some page can deliver an issue node whosestateis stale, or afrom_entrieslast-wins collision on"\(.repo)#\(.number)"(the index is built from PR nodes too, which is also leg (e)'s territory — these two may be the same bug).Fix, regardless of cause:
batch/nextmust assert that no item they hand out has an open blocker, and refuse loudly rather than trusting the annotated boolean. That converts a silent double-assignment into a crash.Acceptance
nextnever recommends an item thattake/batchwould refuse to schedule — the two commands agree on "startable".Paths:line that is legal underTOUCHSET_GRAMMARschedules regardless of markdown formatting; a token that still fails is named, raw, with the reason.roadmap-labelled board item with noStatusis reported bycheck-boardand fixed under--apply.take/reapdo not collect an item whoseitem/<n>-*branch has an open PR.Blocked byref naming a merged PR does not block; one naming an open PR does; one naming an issue not on the board resolves to its true state rather than blocking by default.batch/nextassert no handed-out item has an open blocker, and crash rather than trust the annotation.take/nextreporting "nothing to do" implies there is genuinely nothing to do — or names what it could not schedule, and why.tests/fsgg-coord/for every leg above, including a merged-PR fixture — theMERGED != CLOSEDleg is invisible to any test whose fixtures only contain issues, and per [epic] Coherence gates that fail open — a missing subject reports green #266 an untested failure leg is how this class survives.Why one item and not six
#428 measured the cost of the alternative:
scripts/fsgg-coordis a ~2,900-line god-file,Paths:touch-sets are compared file-wise, so the moment one worker claims any of these, the rest become unschedulable — the repo's effective fan-out drops to 1, in the repo that owns the fan-out protocol. Legs (e) and (f) are plausibly the same defect inboard_items' index; (a) and (c) both land in the samenextpredicate. Splitting them across six workers would serialize them anyway and lose the shared diagnosis.Splitting the god-file is the real fix and is tracked separately in #428.
Paths: scripts/fsgg-coord tests/fsgg-coord/ docs/coordination/parallel-work.md
Closes #431. Closes #435. Closes #437. Closes #445. Closes #476. Closes #343.