Repository navigation
fix(next-task): make the PR-fix queue own linked-PR follow-up - #1192
Conversation
The linked-PR-health scan emitted its own `followup-pr` task after the PR-fix queue had already skipped the same PR (already handed to this agent, or BLOCKED awaiting a human). That derived task carried no persistent identity, so a polling worker dropped it every ~30s and the stale candidate kept winning the lane ahead of ordinary issue work. Converge the linked path onto the queue instead: - defer to any existing queue row for the PR, in any status and lane, so a BLOCKED/needs-human verdict is never bypassed; - materialize a linked-only PR as a real queue row (create-only against the (repo, pr) unique key, so a concurrent enqueue wins without being mutated) and hand it out through the same per-agent generation flow; - skip PRs carrying the Dispatch `needs-human` label; - fall through to the next candidate when one is non-dispatchable, and only serve the derived work to lanes that can consume PR-fix work. Materialized rows capture the discovered head SHA as the attempt baseline (#1074) so the #940 no-progress guard applies to them too.
|
Blocking review finding: The linked-PR path now defers correctly to the PR-fix queue, but a deferred linked candidate remains in That breaks #1145's settled behavior in several important cases:
The new tests currently pin this bug: e.g. the repeat-poll test and needs-human test expect the second result to be Please suppress any issue whose linked PR Everything else I checked in the ownership/materialization direction looks sound: create-only |
Closes the side door left open by the previous commit: when follow-up for a linked PR was deferred — already handed to this agent, BLOCKED, needs-human, or a lane that cannot consume PR-fix work — the issue still fell through to `rankedQueue[0]` as a fresh implement task. A worker would then push to the very PR the queue was holding back, bypassing the needs-human verdict and re-creating the loop. Track deferred issues by `<repo>#<number>` (repo case-folded, since the queue row's `repo` and the issue's `repository.fullName` are not normalized against each other) and pick the first ranked issue that is not deferred. When nothing is left, the idle reason distinguishes a queue-owned deferral from a grooming-admission hold instead of re-serving the deferred issue. Also correct the queue docs: linked-PR follow-up now always carries a `prFixItem` token, and the deferral reason is documented for operators.
Pin the combinations the reviewer asked for: needs-human label, label-read unavailable, concurrent-create race, and the no-PR-fix-equivalent lane, each with an independent ranked issue behind the deferred linked candidate so the lane must drain to it rather than re-serve the deferred issue.
|
Fixed in 1d74fb2 + 906e585 — you were right, and my own tests were pinning the bug. Thanks for the sharp read. The fix. A
The implement pick is now the first ranked issue that is not deferred. When nothing is left, { "type": "idle", "shouldRun": false,
"reason": "No work available (1 ready issue deferred: linked PR follow-up is owned by the PR-fix queue)" }Tests — all five combos you listed, each with an independent ranked issue behind the deferred linked candidate, asserting the lane drains to it: already-handed (two tests, incl. the case-mismatch variant), the 5-status × 3-lane defer matrix, needs-human label, label-read-unavailable ( 3916 tests pass, typecheck and lint clean. Two things worth your eye on the re-review:
Also corrected a now-stale line in |
|
Re-review on I did find two remaining ownership gaps worth fixing before merging:
These are separate from the earlier fallback fix, which looks good. No objection to the two explicit operator-policy decisions. |
Two ownership-boundary gaps, both ways a second identity could shadow a human verdict: 1. The unique key is case-sensitive while GitHub is not, so a row written as `Org/Repo` and a lookup for `org/repo` were different records. A linked materialization deriving its repo from the issue cache could therefore create a second QUEUED row alongside an existing BLOCKED one and hand the work out anyway. Fold the repo on every write and identity lookup (`normalizeQueueRepo`), and merge any existing casing collisions in a migration — a BLOCKED row wins the merge and history rows are repointed before the loser is deleted. 2. The linked scan's deferral was gated on the cached `linkedPrNeedsFollowup` flag. That column is refreshed on a reconcile cadence and can lag the row's creation, so a stale false let the issue through to implement pickup on a PR the queue was holding back (BLOCKED rows never appear in the pr-fix list, so nothing else stopped it). The check now runs for every linked PR and only the *materialization* decision consults the flag.
|
Both closed in c4e4c87. 1. Repo casing. You're right that this was a live bypass, and it was worse than the deferral set: the unique key is case-sensitive while GitHub is not, so
I didn't just eyeball that SQL — I ran it against a real Postgres 18 with fixtures covering: a casing collision with a 2. Stale PR health. Also right, and it's the mirror of the gap I just fixed: the deferral was gated on Tests: casing round-trips (enqueue/mark/requeue/materialize across three different casings, asserting one row), a route test where the issue cache says 3923 tests pass, typecheck and lint clean. One thing I want to flag rather than bury: the migration deletes duplicate rows when it finds them. If a deployment has casing duplicates where the non-BLOCKED row was the one a worker was mid-flight on, that attempt's token dies with the row (its report lands as |
There was a problem hiding this comment.
AI Automated Review
Full PR review.
Analysis engine: MiniMax-M3 (anthropic) — primary route · pr-reviewer-action v3.4.1
Requirement traceability gap: the review itself completed, but one or more in-scope requirements could not be verified against enforcement or test evidence. Approval is withheld until that requirement is covered.
Recommendation: Approve
This PR cleanly resolves PR 1145 (the p1 bug where two producers emitted work for the same linked PR, causing a stale followup-pr task to win the lane ahead of ordinary issue work) and adds a defensive case-insensitive (repo, pr) ownership boundary after the initial review surfaced a case-bypass path. The implementation matches the issue's "Settled behavior" and "Done when" criteria, with explicit tests covering each acceptance bullet.
Change-by-change findings
src/app/api/agents/[agentName]/next-task/route.ts
- The queue is now the single owner: the route builds a
deferredIssuesset and every implement-fallback path (rankedQueue.find(!isDeferred), the new idle reason, the implement loop) consults it.dispatchPrFixCandidatefactors the per-candidate confirm + hand-out + task-build path so both the existingprFixItemsqueue and the freshly materialized linked row go through identical hand-out logic, including thestatus: "QUEUED"andgenerationpins added inconfirmPrFixHandOut. - The linked-PR loop now does an explicit
findUniquefor any issue whoselinkedPrHealth.numberis set, independent oflinkedPrNeedsFollowup— that's the fix for the stale-cache gap flagged in the second review comment (the testdefers a linked issue whose PR has a queue row even when cached health says no follow-upcovers it). needs-humanis read from the PR (fetchPullRequestLabels) and a label-read failure (null) fails closed by deferring — matches the PR's documented judgement call and the related-issue's "human-intervention verdict must not be bypassed" requirement. Both branches are explicitly tested.- Lane gating uses
prFixLaneForRequest: the caller's lane decides whether it can consume PR-fix work at all (nullforcloud-like lanes). The route materializes the row (so a capable lane picks it up) but does not hand the work to the lane that discovered it — this matches claim 4 and thecloudlane test.
src/lib/pr-fix-queue.ts
normalizeQueueRepo(trim().toLowerCase()) is applied at every queue write andrepo_pridentity lookup:enqueuePrFixItem,createLinkedPrFixItem,markPrFixItem,requeuePrFixItem,resolvePrFixFromAgentReport, plus the two existing callers (pr-followup-ingestion's merge-conflict clear, the history route) and the new linked-scan lookup.parseEnqueuePrFixInputdeliberately does NOT fold so the HTTP body validation preserves what callers submitted (the runtime functions do the canonicalization); this is consistent with the existing trim-only treatment of the HTTP parser.createLinkedPrFixItemdoes a singleprisma.$transactioncreate+history; onP2002it reads back the winner and returns{ item, created: false }without mutation, so a concurrentenqueuePrFixItemwins untouched. A non-P2002failure rethrows to the caller, which logs and moves on to the next candidate (the route's outertry/catchkeeps a transient DB failure from 500-ing the poll).- The
headShabaseline is captured viafetchPullRequestHeadShaand stored on bothheadShaandattemptHeadShaso the PR 940/PR 1074 no-progress guard fires on a worker that reportsFIXEDwithout pushing.
prisma/migrations/20261008000000_lowercase_pr_fix_repo/migration.sql
- The migration does the correct ordering: build a temp loser→winner map, repoint
PrFixHistory.itemIdfrom losers to the winner, then delete the losers, then fold survivors. Winner selection isBLOCKED> most-recently-updated >id— a human verdict cannot be dropped. History repointing before delete is required becausePrFixHistory.itemIdcascades on delete (verified from the model definition). After the merge, at most one row remains per(lower(btrim(repo)), pr)so the unique constraint holds for the final fold. The author flagged that mid-flight tokens on a non-survivor will report asskipped— that's an acknowledged destructive edge but the trade against leaving two owners of one PR is the right one. The CI gateDatabase migrationspasses against this migration.
src/lib/pr-fix-queue.test.ts and src/app/api/agents/[agentName]/next-task/route.test.ts
- The new tests cover: dual qualification (the queue's own row wins over the linked scan), the 5-status × 3-lane defer matrix (15 cases), linked-only materialization, repeat polling (the issue is not re-served as implement work), the concurrent-create P2002 race,
needs-humanlabel + read failure, the unmappablecloudlane, per-candidate fail-soft on a non-constraint DB error, head-SHA baseline on materialize, deferred-identity fromqueuedLinkedPrs, deferral driven by the queue row not the stale health flag, and case-insensitive(repo, pr)round-trips for write and materialize. The test client was upgraded to emitP2002on duplicate(repo, pr)so the convergence tests are actually testing the contract.
Claims to Falsify
- Claim 1 (
BLOCKEDfollow-up emission /followup-pridentity-less starvation) — held. TheprFixItemsqueue still beats issue cards, everyprFixItemsscan adds the owning issue todeferredIssues, andconfirmPrFixHandOutnow pins onstatus: "QUEUED"and re-reads the row'slaneso a BLOCKED hand-out is refused at commit time. - Claim 2 (linked-only materialization via
createLinkedPrFixItem) — held. All twelve listed call sites point at the new symbol (src/lib/pr-fix-queue.ts:738, called only fromsrc/app/api/agents/[agentName]/next-task/route.ts:309). TheenqueuePrFixItemconcurrent-winner path returns it untouched (created: false); no row is mutated, no history row is added. - Claim 3 (per-candidate fall-through;
prFixLaneForRequestgating) — held.prFixLaneForRequestis used twice in the route (line 255 for the caller's lane, line 295 for the item's lane); one consumer each inagent-queue-fetch.ts:111andlane-config.ts:263. Thecloudlane test verifies the queue-only path. - Claim 4 (linked-only materialization as
NORMAL; non-capable caller not handed the task) — held. The route computesqueueLane = prFixLaneForRequest(followupItem.lane) ?? "NORMAL"(line 295) andif (requestPrFixLane === null) continue(line 333). Testmaterializes linked-only work for a lane without a PR-fix equivalent, without handing it to that lanecovers this exactly. - Claim 5 (
needs-humanskip; dual qualification + 5-status × 3-lane matrix; linked-only materialization; repeat polling; concurrent-create race;needs-human; unmappable lane; per-candidate fail-soft on a DB error) — held. Each named scenario has a dedicatedit(...)block:needs-humanskip (does not create linked follow-up for a PR with the needs-human label), dual qualification (the PR-fix queue owns a linked PR that also qualifies as a queued issue), 5×3 matrix (linked path defers to existing %s/%s queue row), materialization (returns followup-pr when issue has linked PR needing follow-up), repeat polling (linked-only work is materialized once), concurrent-create race (does not dispatch or mutate a concurrent queue winner during linked materialization), unmappable lane (materializes linked-only work for a lane without a PR-fix equivalent), and fail-soft (does not fail the poll when linked materialization errors).
Linked Issue Fit (PR 1145)
Acceptance criteria from the issue:
- ✅ "a PR qualifying through both discovery paths yields exactly one work identity" —
confirmPrFixHandOutnow pinsstatus: "QUEUED"and re-readsstatus/lane/agentHandouts; the existing-row check runs before any materialization; concurrent-create race returns the winner untouched. - ✅ "a queue-owned PR is never separately emitted by the linked-PR path" — the
deferredIssuesset is checked in the implement fallback; thelinked scanconsultsfindUniqueon every iteration regardless ofneedsFollowup. - ✅ "
BLOCKED/needs-humanPRs are not handed out through the linked path" — both branches (needs-humanlabel, label-read error) defer the issue;BLOCKEDrows cause the linked scan tocontinueafter adding the issue todeferredIssues. - ✅ "a linked-only PR is materialized into persistent queue identity before hand-out" —
createLinkedPrFixItemreturns{ id, generation }that flows intodispatchPrFixCandidateand the resultingfollowup-prtask. - ✅ "a worker that has already received that attempt is not offered it again on every poll" —
confirmPrFixHandOutnow consultsagentAlreadyHandedon the re-read row before confirming, and theprFixItemsloop adds the item's issue todeferredIssueswhether the hand-out was confirmed or skipped. - ✅ "the next ranked issue is returned when the linked-PR candidate is non-dispatchable" —
rankedQueue.find((item) => !isDeferred(item))(line 380). - ✅ "regression tests cover dual qualification, linked-only materialization, already-handed-out work, and blocked/needs-human work" — see Claim 5 above; all eight named cases plus the 15-case status × lane matrix.
Standards Compliance
- Repository-specific conventions from AGENTS.md: the
next-taskworker loop contract is preserved (oneAgentTaskreturned, idle as{ type: "idle", shouldRun: false, reason: "..." }); bearer-auth is unchanged. NoDISPATCH_AGENT_TOKEN/GITHUB_TOKENis logged or persisted by this change. Label hygiene (umbrella only, noblocked/infra-attempt/*) is unaffected. - Migration risk (must_check:
review migration for data loss risk): the migration is destructive only in the narrow case of an existing casing collision where the loser is the only row a worker was issued against. The author flagged this explicitly in the thread and the trade against leaving two owners is documented; CI'sDatabase migrationscheck has run against the migration. Production review should confirm no PR is currently being worked by a worker at the time of deploy (a brief deploy-time stalemate that would cause that one in-flight report to come back asskipped). - High-priority verification (
treat as high priority — verify correctness carefully): the deferral-set logic, the materialize/concurrent-race path, and the case-fold enforcement are each pinned to a dedicated test, all green perTestsCI.
Unknowns / Needs Verification
- None blocking merge. The two judgement calls the author flagged in the PR body — that BLOCKED deferral does not expire automatically, and that a failed label read fails closed — are explicitly tested and consistent with the linked issue's settled behavior ("
BLOCKEDmeans human intervention is required and must not be bypassed"). The one operational item is a deploy-time check that no worker is currently mid-flight against a row that the migration would delete (i.e., a casing-collision loser), so no in-flighttasks/reportlands asskippedfor the wrong reason at deploy.
Requirement trace
1 of 3 requirement(s) not fully traced to enforcement and a test:
req-1b681c69493f— unverifiable (src/app/api/agents/[agentName]/next-task/route.ts:279): The route consults an existing PrFixQueueItem for the linked (repo, pr) before materializing any new follow-up task and defers the issue to implement pickup without bypassing BLOCKED rows. The parametrized it.each suite over every status/lane combination (including BLOCKED) asserts the deferral and
Approval withheld: the review completed, but an in-scope requirement lacks verifiable enforcement or test evidence, so it is publishing as an advisory comment rather than an approval.
|
Re-review of Both findings on
The original Operational note, not a blocker: if the migration merges pre-existing duplicate rows, an in-flight worker holding a deleted row's ID will have a stale token; its settlement is rejected/skipped rather than being allowed to mutate the survivor. That is preferable to retaining two owners for the same PR, but worth being aware of during rollout. This is a review disposition in a conversation comment only, not a GitHub approval or merge. |
Closes #1145.
next-taskhad two producers for the same linked PR: the PR-fix queue and the older linked-PR-health scan. After the queue skipped a PR (already handed to this agent, orBLOCKED), the health scan still emitted its ownfollowup-prtask — no persistent identity, so a polling worker dropped it every ~30s while the stale candidate kept winning the lane ahead of ordinary issue work.The queue is now the single owner:
BLOCKED/needs-human verdict can't be bypassed;createLinkedPrFixItem(create-only against the(repo, pr)unique key, so a concurrentenqueuePrFixItemwins without being mutated or reopened) and handed out through the same per-agent generation flow as ordinary PR-fix work;needs-humanlabel are skipped (read from the PR, since that's wheresurfacePrFixBlockedapplies it);prFixLaneForRequest).Materialized rows also capture the discovered head SHA as the attempt baseline (#1074), so the #940 no-progress guard refuses a
FIXEDtombstone for a worker that pushed nothing — the same protection queue-enqueued rows get.Two judgement calls worth flagging:
needs-humanis a human-intervention verdict, and a transient GitHub failure shouldn't be what bypasses it. The candidate is retried on the next poll.cloudlane with no role) is materialized asNORMALrather than dropped, and the non-capable caller is not handed the task itself — the row is picked up by a capable lane on its next poll.Tests cover dual qualification, the 5-status × 3-lane defer matrix, linked-only materialization plus repeat polling, the concurrent-create race,
needs-human, the unmappable-lane case, and per-candidate fail-soft on a DB error.