From 33fa9e12983e0be0f7e7ad19efebb4257b781d59 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Tue, 10 Nov 2026 20:57:35 +0800 Subject: [PATCH] fix(dispatcher): a re-armed issue must not be blocked by its old PR Found by running the documented recovery path: `ai-decide retry` on a conflicting AI PR re-armed the issue, but the dispatcher skipped it with "handled/running/pr open" because an open PR existed for ai/issue-N. The maintainer had no way out except closing the PR by hand. Labels are the source of truth: `retry` removes ai-pr (and friends) and adds `ai`, so "ai present, no handled label" means "run again". The rerun force-pushes onto the same branch, which updates the existing PR in place (createPr already falls back to the open PR), and auto-merge is armed on it. `handledByLabels()` is exported and covered by a test. --- agent-runner/src/dispatcher.ts | 21 +++++++++++++++++++-- agent-runner/test/dispatcher.test.ts | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 agent-runner/test/dispatcher.test.ts diff --git a/agent-runner/src/dispatcher.ts b/agent-runner/src/dispatcher.ts index e77c738..b336fef 100644 --- a/agent-runner/src/dispatcher.ts +++ b/agent-runner/src/dispatcher.ts @@ -166,13 +166,30 @@ function candidates(): Candidate[] { return [...set.values()]; } +/** + * Labels are the source of truth for "this issue is already being/done handled". + * An open PR alone is *not*: `ai-decide retry` (or a maintainer re-adding `ai`) + * removes `ai-pr` on purpose, and the rerun force-pushes onto the same branch, + * which updates the existing PR instead of opening a second one. + */ +export function handledByLabels(labels: string[]): boolean { + return labels.some((label) => HANDLED.includes(label)); +} + function isHandled(repo: string, number: number): boolean { + let labels: string[]; try { - const labels = JSON.parse(gh(["issue", "view", String(number), "-R", repo, "--json", "labels", "--jq", "[.labels[].name]"])) as string[]; - if (labels.some((l) => HANDLED.includes(l))) return true; + labels = JSON.parse( + gh(["issue", "view", String(number), "-R", repo, "--json", "labels", "--jq", "[.labels[].name]"]), + ) as string[]; } catch { return true; // issue gone / inaccessible -> treat as handled } + if (handledByLabels(labels)) return true; + // Explicitly re-armed (`ai` present, no handled label): reuse the open PR and + // let the rerun force-push onto its branch. + if (labels.includes("ai")) return false; + const prs = gh(["pr", "list", "-R", repo, "--head", `ai/issue-${number}`, "--state", "open", "--json", "number", "--jq", "length"]).trim(); return Number(prs) > 0; } diff --git a/agent-runner/test/dispatcher.test.ts b/agent-runner/test/dispatcher.test.ts new file mode 100644 index 0000000..933a9c1 --- /dev/null +++ b/agent-runner/test/dispatcher.test.ts @@ -0,0 +1,18 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { handledByLabels } from "../src/dispatcher.ts"; + +describe("dispatcher: re-armed issues", () => { + it("treats in-flight and finished labels as handled", () => { + for (const label of ["ai-running", "ai-pr", "ai-failed", "ai-done"]) { + assert.equal(handledByLabels(["ai", label]), true, label); + } + }); + + it("does not treat a freshly armed issue as handled", () => { + // `ai-decide retry` removes ai-pr/ai-failed/... and re-adds `ai`; the rerun + // must be allowed even though an (old) PR may still be open. + assert.equal(handledByLabels(["ai"]), false); + assert.equal(handledByLabels(["ai", "documentation"]), false); + }); +});