Skip to content

[cross-repo] fsgg-coord: "is this item startable?" is computed in five places and agrees in none — six scheduler legs, one god-file #485

Description

@EHotwagner

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.

The five legs

(a) next recommends what batch refuses — was #431

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 —

Paths: `src/FS.GG.Audio.Elmish/**`, `tests/FS.GG.Audio.Elmish.Tests/**`

— 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, batch does 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 null Status 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 PRsPullRequestState 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_items does 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=true permanently.

Fix: (1) a blocker is resolved when CLOSED or MERGED — 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 #84 overlap 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/next assert 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.

Paths: scripts/fsgg-coord tests/fsgg-coord/ docs/coordination/parallel-work.md

Closes #431. Closes #435. Closes #437. Closes #445. Closes #476. Closes #343.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcross-repoTouches more than one FS-GG repocross-repo:requestIncoming request from another repo

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions