Step 9c gate-fleet arbitration counts a session's own detached ledger refresh as a foreign contender, deadlocking that session's own gate
Highlight text on any card (body, plan, or an event) to anchor a comment, or leave a whole-task comment here. Mention @claude to summon a reply.
No comments yet.
scripts/step9c_baseline.py probe --fleet counts DISTINCT foreign issues with
live gate trees — including the ledger-refresh pseudo-issue — and exits 3
when the count reaches EPM_GATE_FLEET_MAX (default 2).
The refresh entry is frequently the CALLER'S OWN detached process: /issue
Step 9c kicks a detached step9c_baseline.py refresh when the compare reports a
stale ledger, and that refresh carries a 4650s (~77 min) bound. So for the next
~77 minutes the session that kicked it has an effective foreign-contender budget
of one, and a single genuine foreign gate saturates the cap and blocks the
session's own next gate round.
There is no escape hatch: --exclude-issue is typed as an int
(--exclude-issue refresh → invalid int value: 'refresh', exit 2), and it is
documented as dropping "the caller's own issue" — which the refresh pseudo-issue
is not, structurally, even when the caller kicked it.
Measured incident (issue #2654, 2026-08-31)
After the #2311 root-cause merge, the Step 9c relaunch chain waited on
probe --fleet for its full 3600s ceiling and never got a slot:
- 30 of 30 probes over the window saw exactly two contenders:
issue=2650(a genuine foreign lint gate) andissue=refresh— this issue's OWN prior-round refresh, launched at 07:24:57Z with a 4650s bound. - The chain exited FATAL 90, gate NOT launched. One full hour of wall lost.
- The slot opened within seconds of the refresh's bound expiring (probe rc=0
with
issue=2650still live and alone), confirming the refresh was the binding contender, not fleet pressure.
The relaunch then succeeded on its first probe (arbitration slot OPEN after 0s), so nothing about the gate itself was at fault.
Aggravating factor: that same refresh had already blown its 4350s internal
pytest bound three times historically (refresh pytest timed out ... NO ledger write x3 in logs/step9c_baseline_refresh.log), so the process that consumed
the arbitration slot for 77 minutes frequently produces no ledger at all.
Why this is worth fixing rather than working around
The refresh is REPORT-ONLY and already single-flight-locked, so it is not the kind of contender the cap exists to bound — the cap exists to stop N concurrent full-suite gate runs melting the shared VM. Counting it costs a gate round while protecting nothing that its own lock does not already protect.
Note also that the compare's authoritative classifier is the per-node
--run-pristine scratch oracle, not the ledger (in this issue's prior round it
stripped 8 of 9 failures via: pristine-scratch while the ledger read
stale: true without blocking the verdict). So the refresh's output is advisory
for the very gate it was blocking.
Candidate fix directions (for the spawned session's planner to adjudicate)
- Attribute the refresh pseudo-issue to the issue that kicked it and drop it
under
--exclude-issue <caller>. - Accept the literal
refreshas an--exclude-issuevalue (widen the type). - Do not count a report-only, single-flight-locked refresh toward the cap at all — bound it by its own lock.
- Ordering fix at the call site: do not kick a detached refresh that will immediately contend with the same issue's next gate round; kick it after the round's terminal transition, or make the kick conditional on no pending relaunch.
Whichever direction is chosen, keep the cap's real purpose intact (bounding concurrent full-suite GATE runs) and keep the refresh's single-flight lock.
Files of record
scripts/step9c_baseline.py (probe --fleet, --exclude-issue, the refresh
pseudo-issue in the fixed internal signature union; EPM_GATE_FLEET_MAX,
default 2, #1962); .claude/skills/issue/SKILL.md Step 9c (the refresh kick);
issue #2654 events.jsonl (the 30/30 contender census + the FATAL 90 chain
record).