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); + }); +});