Dedup: PR-merged-for-tooling vs operational-payload-executed (#6222)

Applies to: the pre-dispatch dedup guard — infra-src/meta-dispatcher/src/dedup.ts, scripts/queue_dispatcher.py, and the mirrored inline guard in the /issues-queue skill (.claude/commands/issues-queue.md, Phase 11).

Refines #6137 (#6019 option 2). Must not regress #1383 (a genuinely-shipped done row still blocks re-dispatch).


The problem

The dedup “already shipped” signal (has_merged_pr / hasMergedPr) treats a merged referencing PR as proof that an issue is fully resolved, so it skips re-dispatch. That is correct for a normal code-change (dev-issue) issue: one PR ships the fix, the issue is done.

It is wrong for an infra-task-style issue that mixes two kinds of work:

  1. a one-time code deliverable — “write and merge a backfill script” — shipped by a PR that, per this repo’s own convention (CLAUDE.md §Branching), uses Implements: #N / Partially implements: #N; and
  2. a later, repeatable operational action — “run that script against production” — which usually has no PR of its own (you just execute the script on a server).

The dedup guard conflates the two: it finds the merged tooling PR and declares the whole issue resolved, so the operational re-dispatch is silently skipped even though the operation never ran.

Observed (#5649): the full production backfill run was blocked three times in a row. The guard found PR #5652 — which shipped the backfill script weeks earlier — and treated #5649 as fully resolved. Each block required manually finding and cancelling every terminal-state dev_r_worker_queue row for the issue, and one attempt also hit the requirement-2 symptom below (milestone flipped to Queue with no row actually inserted), which ultimately needed a direct queue-row INSERT to unstick.

This shape is common: most non-trivial infra-task issues cite the PR that built whatever tool they later run.


The fix (job-type-aware closing-keyword strictness)

When the incoming dispatch’s job_type is infra-task (see INFRA_TASK_JOB_TYPES in both dedup.ts and queue_dispatcher.py), a merged PR counts as “already shipped / resolved” only if it carries a genuine GitHub closing keyword — closes / fixes / resolves — referencing this exact issue. A PR that used Implements: / Partially implements: (this repo’s convention for shipping code, not completing an operation) no longer counts, so the operational re-dispatch proceeds; the worker self-detects if there is genuinely nothing left to do.

For every other job_type the behaviour is unchanged (Implements: still counts as shipped, preserving the #6137 / #1383 semantics).

Implementation:

  • dedup.ts — hasMergedPr(..., strictClosing); shouldSkipDispatchPure passes strictClosing = INFRA_TASK_JOB_TYPES.has(jobType).
  • queue_dispatcher.py — should_skip_dispatch(..., job_type); for infra-task it uses merged_pr_info(require_closing_keyword=True) (its pr_closes_issue verb set is clos|fix|resolv and already excludes implements).
  • /issues-queue skill Phase 11 — the inline PowerShell guard must apply the same strictness for $jobType -eq 'infra-task'. This file is protected: true; the mirror edit is deferred to a human (see the #6222 PR body). Keep it in sync when applied.

Why option 1a (closing keyword), not 1b (result_summary parsing)

Issue #6222 offered a choice: (a) require a genuine closing keyword, or (b) inspect the done row’s result_summary for evidence the operational payload actually executed. We chose (a). result_summary is free-form text with no reliable “executed” marker — parsing it for intent is brittle and silently wrong the moment a worker phrases its summary differently. The closing-keyword rule is deterministic, already the repo’s own convention, and needs no new data contract. If a future need arises, (b) can be layered on top; it is not required to fix #5649’s class.


Requirement 2 — known limitation: re-milestone → Queue with no row inserted

Status: documented known limitation (not auto-fixed here).

Re-milestoning an issue to Queue fires a GitHub issues.milestoned event, which dispatch-to-queue.yml turns into a dev_r_worker_queue INSERT — normally. During #5649’s unstick it was observed that the milestone flipped to Queue without a corresponding queue row being inserted, and with no error or comment trail explaining why. This is distinct from the pre-#6137 stale-milestone bug but shares its caveat: a Queue milestone is not proof a queue row exists.

Detection (the milestone lies; check the table):

# Is there actually an active/any row for the issue? (needs SUPABASE_URL + service key)
curl -sf "$SUPABASE_URL/rest/v1/dev_r_worker_queue?or=(issue_number.eq.$N,github_issue_number.eq.$N)&select=id,status,job_type,created_at&order=id.desc" \
  -H "apikey: $KEY" -H "Authorization: Bearer $KEY" | jq .

If the milestone is Queue but the query returns [] (or only terminal rows), the dispatch chain no-op’d — apply the manual unstick below. A proper fix (have dispatch-to-queue.yml emit an error/comment when it flips the milestone but skips the INSERT) is tracked for a follow-up; it is GH-Actions-workflow surface, out of scope for the app-level dedup change in #6222.


Requirement 3 — manual unstick procedure

Precedent: #6137’s own playbook requirement documented the same “find + cancel terminal rows” shape; this extends it with the direct-INSERT last resort proven on #5649.

  1. Find every queue row for the issue (all states, both id columns):

    curl -sf "$SUPABASE_URL/rest/v1/dev_r_worker_queue?or=(issue_number.eq.$N,github_issue_number.eq.$N)&select=id,status,job_type,created_at&order=id.desc" \
      -H "apikey: $KEY" -H "Authorization: Bearer $KEY" | jq .
  2. Cancel every terminal-state row (done / failed / cancelled / oom_killed) that is masking the re-dispatch. A done row that shipped only tooling is the usual culprit:

    curl -sf -X PATCH "$SUPABASE_URL/rest/v1/dev_r_worker_queue?id=eq.$ROW_ID" \
      -H "apikey: $KEY" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
      -d '{"status":"cancelled","failure_reason":"manual unstick #6222 — done row masked operational re-dispatch"}'
  3. Re-milestone to Queue and confirm a row was actually inserted (requirement-2 check above). If a row appears, you are done.

  4. Last resort — direct queue-row INSERT (only when re-milestoning no-ops, i.e. the GH-Actions chain did not insert). This bypasses the CF Worker dedup guard and the GH Actions chain entirely, so use it only after confirming steps 1–3. Match the column shape the dispatcher expects — set BOTH issue_number and github_issue_number (#5745), the correct job_type, repo, and server_preference when the job type has hard host-affinity:

    curl -sf -X POST "$SUPABASE_URL/rest/v1/dev_r_worker_queue" \
      -H "apikey: $KEY" -H "Authorization: Bearer $KEY" \
      -H "Content-Type: application/json" -H "Prefer: return=representation" \
      -d '{"status":"queued","job_type":"infra-task","issue_number":'"$N"',"github_issue_number":'"$N"',"repo":"radieu/p24-infra","server_preference":"bms-4"}' | jq .

    server_preference is REQUIRED for infra-task (hard host-affinity — only bms-4 carries the production SSH keys; a NULL preference lets any host claim the row and strand it, #3746).


  • #6137 (the fix this refines) · #6019 (the original done-dedup design) · #5649 (discovered here) · #5652 (the tooling PR that falsely blocked it) · #1383 (why done blocks re-dispatch at all — must not regress) · #5745 (both id columns on INSERT).