From 2a612864a346b32556f9a21d81f3119c10845c3f Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Tue, 30 Jun 2026 21:49:17 -0600 Subject: [PATCH] fix(groomer): keep ready issues in a claimable lane (#492) The groomer LLM sometimes returns actionability=ready with lane=backlog, conflating low priority with the non-claimable backlog lane. Ready issues then sit in a lane no worker queue can see. Enforce the invariant deterministically: when actionability is 'ready', coerce a non-claimable lane to the default claimable lane (recorded as an 'invariant' resolution), and tighten the groomer prompt to say ready => claimable worker lane, never backlog. Lane-agnostic via lane-config helpers (respects DISPATCH_LANE_CONFIG_JSON). Fixes #492 --- src/lib/groomer/enum-config.ts | 2 +- src/lib/groomer/llm.ts | 5 ++++- src/lib/groomer/schema.test.ts | 35 ++++++++++++++++++++++++++++++++++ src/lib/groomer/schema.ts | 23 ++++++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/lib/groomer/enum-config.ts b/src/lib/groomer/enum-config.ts index 8b5bb28e..71682709 100644 --- a/src/lib/groomer/enum-config.ts +++ b/src/lib/groomer/enum-config.ts @@ -46,7 +46,7 @@ export interface ResolutionEvent { /** The canonical value after alias resolution */ resolvedValue: string; /** How this resolution happened */ - source: "alias"; + source: "alias" | "invariant"; } /** diff --git a/src/lib/groomer/llm.ts b/src/lib/groomer/llm.ts index 6e965805..22d77c27 100644 --- a/src/lib/groomer/llm.ts +++ b/src/lib/groomer/llm.ts @@ -1,5 +1,5 @@ import type { GroomerOutput } from "./schema"; -import { getConfiguredLanes } from "@/lib/lane-config"; +import { getConfiguredLanes, getClaimableLanes, getBacklogLane } from "@/lib/lane-config"; import { STATUS_LABELS, PRIORITY_LABELS } from "@/types"; export interface CallLlmOptions { @@ -14,6 +14,8 @@ const VALID_TYPE_LABELS = ["type/bug", "type/feature", "type/chore", "type/resea function buildSystemPrompt(): string { const laneIds = getConfiguredLanes().map((lane) => lane.id).join("|"); + const claimableIds = getClaimableLanes().map((lane) => lane.id).join("|"); + const backlogLane = getBacklogLane(); const statusLabels = STATUS_LABELS.join(", "); const priorityLabels = PRIORITY_LABELS.join(", "); const typeLabels = VALID_TYPE_LABELS.join(", "); @@ -42,6 +44,7 @@ Rules: - Valid type labels: ${typeLabels} - Never remove agent/* labels - Lane must be one of the configured lane ids +- When actionability is "ready", lane.id MUST be a claimable worker lane (${claimableIds})${backlogLane ? `, NEVER "${backlogLane.id}"` : ""}${backlogLane ? `. The "${backlogLane.id}" lane is non-claimable — use it only when actionability is not "ready" (needs_info/blocked/backlog/already_done). Priority (P2/P3/low) does NOT mean backlog: a low-priority but ready issue still goes to a claimable lane.` : ""} - Be concise in summary and reason fields Title rewriting rules: diff --git a/src/lib/groomer/schema.test.ts b/src/lib/groomer/schema.test.ts index 489b74e7..d6483a01 100644 --- a/src/lib/groomer/schema.test.ts +++ b/src/lib/groomer/schema.test.ts @@ -179,6 +179,41 @@ describe("groomer schema validation", () => { expect(result.valid).toBe(true); }); + // ── dispatch#492: a "ready" issue must never land in the non-claimable backlog lane. + it("coerces a ready issue out of the non-claimable backlog lane (dispatch#492)", () => { + const result = validateGroomerOutput({ + ...validOutput, + actionability: "ready", + lane: { id: "backlog", confidence: "high", reason: "P2 but low priority" }, + }); + expect(result.valid).toBe(true); + expect(result.parsed?.lane.id).toBe("local"); // default claimable lane + const invariant = result.resolutions?.find((r) => r.source === "invariant"); + expect(invariant).toMatchObject({ field: "lane.id", rawValue: "backlog", resolvedValue: "local" }); + }); + + it("leaves a ready issue already in a claimable lane untouched", () => { + const result = validateGroomerOutput({ + ...validOutput, + actionability: "ready", + lane: { id: "cloud", confidence: "high", reason: "ci work" }, + }); + expect(result.valid).toBe(true); + expect(result.parsed?.lane.id).toBe("cloud"); + expect(result.resolutions?.some((r) => r.source === "invariant")).toBe(false); + }); + + it("leaves a non-ready issue in the backlog lane", () => { + const result = validateGroomerOutput({ + ...validOutput, + actionability: "needs_info", + lane: { id: "backlog", confidence: "medium", reason: "missing acceptance criteria" }, + }); + expect(result.valid).toBe(true); + expect(result.parsed?.lane.id).toBe("backlog"); + expect(result.resolutions?.some((r) => r.source === "invariant")).toBe(false); + }); + it("accepts valid frontier lane", () => { const result = validateGroomerOutput({ ...validOutput, diff --git a/src/lib/groomer/schema.ts b/src/lib/groomer/schema.ts index 6d41c24f..c3e92e6b 100644 --- a/src/lib/groomer/schema.ts +++ b/src/lib/groomer/schema.ts @@ -3,6 +3,7 @@ import { STATUS_LABELS, PRIORITY_LABELS, type GroomAction } from "@/types"; import { resolveEnumConfig, type ResolutionEvent } from "./enum-config"; import type { EnumConfig } from "./enum-config"; import { GROOMER_ENUM_CONFIGS } from "./enum-configs"; +import { isClaimableLane, getDefaultClaimableLane } from "@/lib/lane-config"; // ─── Public Types ───────────────────────────────────────────────────────────── @@ -349,5 +350,27 @@ export function validateGroomerOutput(data: unknown): ValidationResult { } } + // ── Cross-field invariant: a "ready" issue must land in a claimable worker + // lane, never the non-claimable backlog lane (dispatch#492). The groomer LLM + // sometimes conflates "backlog" (low priority) with the non-claimable backlog + // lane, stranding ready issues where no worker queue can see them. Coerce to + // the default claimable lane and record it as an invariant resolution. + if (parsed.actionability === "ready" && !isClaimableLane(parsed.lane.id)) { + const target = getDefaultClaimableLane(); + if (target) { + resolutions.push({ + field: "lane.id", + rawValue: parsed.lane.id, + resolvedValue: target.id, + source: "invariant", + }); + parsed.lane = { + ...parsed.lane, + id: target.id, + reason: `${parsed.lane.reason} [auto: ready issue reassigned from non-claimable "${parsed.lane.id}" to "${target.id}"]`, + }; + } + } + return { valid: true, parsed, resolutions }; }