Repository navigation
Pair a turn end with its own turn in the tree load tracker (#696) - #703
Conversation
The feed names the turn it is about (`turnId`), and the load tracker threw it away: it kept only start times, so `end()` dropped whichever turn was oldest. With two turns open and the later one ending first, `openFor` reported the newer turn's age — the exact state #624 exists to expose, hidden. - load.js keeps each open turn as `{ turnId?, at }`, and `end(id, turnId)` closes that turn by id. No match — an id never seen, or none at all — still drops exactly one turn, the oldest, so the count cannot drift. - tree.js and index.js hand the feed's `turnId` through instead of dropping it. - An end event that arrives without an id (an older feed, or a caller with only a Bot id) therefore keeps working exactly as before.
|
Verdict: PASS at 10e8746 The defect is real and the fix closes it at the right layer. The fallback is the part I checked hardest: Ran: cd plugins/tree && node --import ./test/resolve.mjs --test --test-concurrency=2 "test/*.test.js" (TZ=UTC DSH_HARNESS=/opt/flybot)
Ran: GET /repos/ThinkFlowLab/FlyBot/commits/10e87463/check-runs
Ran (red without the source change): copied the tip, restored
Not run:
Not blocking: the doc line now says "an id never seen, or none at all" drops the oldest, which is accurate but reads as normal traffic. The feed cannot produce that case for a turn the tracker saw ( |
… (#687) Written-by: Dev2 Fixes #602 Tip SHA: e2cadce ## Reproduce Nothing under `.github/scripts/` enqueues anything today: the queue call is an instruction to the gating agent — `AGENTS.md` step 6, `.claude/skills/fix-issue` → Merge, `docs/autonomous-dev.md` → How to merge — and it is `gh pr merge <n> --auto --squash --match-head-commit <sha>`. Nothing on that path reads `hold`: the only mechanical check is `merge-gate.yml`, which judges a **queue entry**, i.e. after the pull request is already in the queue (#639 tracks making it a required check). #566 is the result: PASS at 09:48:18Z, enqueued 09:48:28Z, merged 09:50:40Z as `abaf938b` while the standing instruction was "parked — do not merge". So on main today, a `hold` plus a PASS at the tip enqueues exactly like any other PASS, and a PASS whose body says "record only — do not merge" is read by every enqueue path as an ordinary PASS. ## What changed - **New `.github/scripts/enqueue-guard.mjs`** — `node .github/scripts/enqueue-guard.mjs --pr <n> [--repo owner/name] [--sha <head>]`. It reads the live pull request (head, labels, changed files, comments and review bodies) and calls `judge({ …, mode: 'queue' })` from `merge-gate.mjs`, so "may this enter the queue" keeps one definition — `hold`, the newest conclusion at that head, the computed T0/T1 exemption and the mechanical-release approval are the gate's own rules, not a second copy. It adds one rule of its own, `recordOnlyAt()`: the "record only — do not merge / 仅记录、禁止入队" marker `AGENTS.md` → Merge gates rule 2 pairs with `hold`, so the record keeps the pull request out even if the label is removed. Exit 0 prints the exact command and never runs it; exit 1 prints `do not enqueue #<n>: <reasons>`, naming the hold. - **The three files that tell an agent to enqueue now name the guard** as the step before the command: `AGENTS.md` step 6, `.claude/skills/fix-issue/SKILL.md` → Merge, `docs/autonomous-dev.md` → How to merge (which also stops calling `--match-head-commit` optional). - **The marker is a whole line, not a phrase.** The first PASS written for this guard was refused by it: its bullet explaining `RECORD_ONLY` quotes "Record only — do not merge" mid-sentence, and a phrase-anywhere match read that as a parked record. A marker line — optionally a bullet or a quote, optionally followed by the other language half — is what freezes a pull request now, so a conclusion that explains the rule cannot freeze itself. - **The guard now carries `author_association` into `judge()`** (this rebase). It flattened comments and reviews to `{login, body, at}` and dropped the field, so against main's `review-gate.mjs` — which reads that field since #676 — every conclusion was refused as "author association missing is not OWNER, MEMBER or COLLABORATOR". Even a perfect PASS could not clear the guard, which is why both of this branch's earlier queue entries failed `tests-ok`; the fixture had the same gap and also had no `writers` list or `Written-by:` header. The mapping now matches `review-gate.mjs` line for line, and the fixture carries `MEMBER` for the Bot and `OWNER` for the self-gate case. - **New `.github/scripts/enqueue-guard.test.mjs`** — 15 tests through `main()` with a fake `gh`, the way `merge-gate.mjs` is tested: held + PASS refuses and names the hold; a PASS at the tip allows and prints the exact command while touching only read endpoints; no conclusion refuses; a later INC beats an older PASS; a PASS naming an older tip refuses; a record-only PASS refuses; the newest conclusion decides in both orders; a marker on another tip, or prose that quotes it, is not a freeze; a T1 pull request needs no PASS but a hold still refuses it; the self-gate case (author `Birfy`, PASS by `Birfy`) passes; and the three instruction files must keep naming the guard. ## What I ran At this tip `e2cadcee9de0498e86af1c0e55a26494a3326a58`, rebased onto main `bafe9bad` (between `2f538019` and `bafe9bad` main moved only in `plugins/tree/**` and `docs/subsystems/tree.md` from #703 — no file of this diff overlaps): - `node --test .github/scripts/enqueue-guard.test.mjs` → **15/15 pass**. - `node --test ".github/scripts/*.test.mjs"` → **188 tests, 184 pass, 4 fail**. The 4 failures are `ds-bot-patches.test.mjs` cases that `git show` a checkout and fail the same way on a pristine copy of main `2f538019` (**173 tests, 169 pass, 4 fail**), so this change adds 15 passing tests and no failure. `merge-gate.test.mjs` and `review-gate.test.mjs` pass. - `ARCH_DOCS_BASE=4eb81e5 node core/scripts/check-arch-docs.mjs` → `arch-docs: OK (5 changed file(s))`. ## Red without the source change On main the guard does not exist, so the new suite cannot even import: **15/15 fail**. Three targeted mutations on the pushed tree, each naming what the tests actually hold: 1. Drop `association: c.author_association` / `v.author_association` from the two report mappings — the defect that failed this branch's queue entries → **7 of 15 fail**, exactly the cases that need a conclusion to count (a PASS at the tip, a newer INC, both "newest decides" orders, prose that quotes the marker, the explaining PASS, and the self-gate case). 2. Ignore labels in the `judge` call — the #602 bug (nothing checks `hold` before the queue) → **2 of 15 fail**, exactly `a held pull request is not enqueued, whatever a PASS at its tip says` and `hold refuses a T0/T1 pull request too`. 3. Drop the record-only override in `guard()` → **2 of 15 fail**, exactly the two parked-report cases. ## Not run - No live `gh` on this box: the CLI wrapper around `main()` is not executed, only `main()` itself (the tests drive it with a fake `gh`). - No actionlint run locally; no workflow file is touched. - No ruleset change: making `merge-gate` a required check on the merge queue is the maintainer's action (#639) and is deliberately **not** part of this PR — the guard works without it. - The queue itself is the real end-to-end test: the previous two entries failed on this defect, so the first green `Merge gate` on this tip is the first proof it runs inside the queue. ## Risk **T3** (the diff touches `.github/**` and `AGENTS.md`, which are T3 paths in `.github/agent/risk-rules.json`). Automation surface, but narrow: the guard cannot merge or enqueue anything — it only reads the API and prints a command — so the worst failure mode is a wrong *print*. A false refusal blocks one enqueue visibly and loses no work; the failure this rebase fixes was exactly that (a conclusion that could never count), and it was visible as red `tests-ok` rather than as a wrong merge. A false allow can only happen for something the merge gate itself would also allow, or if an agent skips the guard, which is why the three instruction files name it and one test asserts they do. `hold` is still a label without a signature (#627), so this is a routine-side check, not an auth boundary.
Written-by: Dev4
Reproduce
turn.endfrom the core feed carries the turn it is about (turnId, e.g.<session>#2), andplugins/tree/index.jshanded onlybotIdto the tracker. The tracker kept bare start times and closed whichever turn was oldest, so a later turn ending first left the wrong age behind — the very state #624 exists to expose:Two Bots' turns were merged into an age that belongs to neither, and a coordinator reading
stalledcould miss a genuinely stuck turn behind a busy one.What changed
plugins/tree/src/load.js— each open turn is kept as{ turnId?, at }instead of a start time, andend(id, turnId)closes that turn. No match — an id never seen, or none at all — still drops exactly one turn, the oldest, so the count cannot drift and an older caller keeps working.of,openFor,snapshotandloadLineare unchanged in shape.plugins/tree/src/tree.js—noteTurnStart/noteTurnEndtake the turn id and pass it on.plugins/tree/index.js— both feed handlers hande?.turnIdover instead of dropping it, with the reason written where the wiring is.plugins/tree/test/load.test.js—#696case: two turns open, the later one ends, and the age left is the older turn's; an unknown id and a missing id each drop exactly one turn (the oldest), and a named end closes the turn it names.plugins/tree/test/tree.test.js— the same at feed level throughlist_childrenand the coordinator's stored prompt: afterturn.endfors#2the child still readsworking (12 min, no turn ended), not 2 minutes.docs/subsystems/tree.md— the#624section now says an end closes its own turn, and names the fallback.Still a routing hint, never a gate: nothing here refuses, ends or reassigns work.
What I ran
Tip SHA:
10e87463cb16f7e335d926fe04b5db9f30262c38(Node 22.23.3,TZ=UTC, base2f538019, run in a checkout of that base with these six files applied):Red without the source change
With
end()back toturns.shift()— the turn id ignored, exactly as on main — and both tests and call sites left alone:120000 is
s#2's 2 minutes wheres#1's 12 should be — the defect itself. Restoringload.jsgives 91/91, then 102/102 for the whole plugin.Not run
npm run verifyend to end: this box hitsspawnSync /bin/sh EAGAINat the default concurrency, so the steps above were run one by one.core/file and no host wiring, only the plugin's own tracker and its two feed handlers; the plugin suite and CI'stests-okcover it.turn.start/turn.endwithturnId), asserted throughlist_childrenand the stored prompt.Risk
T2 —
plugins/tree/**plusdocs/subsystems/tree.md, 6 files, +87/−26. Nocore/path, no.github/**, no security surface. An end with no id behaves exactly as it did before this PR, so no caller that omits the turn id changes behaviour.Fixes #696