Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/lib/groomer/enum-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export interface ResolutionEvent {
/** The canonical value after alias resolution */
resolvedValue: string;
/** How this resolution happened */
source: "alias";
source: "alias" | "invariant";
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/lib/groomer/llm.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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(", ");
Expand Down Expand Up @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions src/lib/groomer/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions src/lib/groomer/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 };
}