From a6405204996ddd54541708974f240c2920109627 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Wed, 17 Jun 2026 12:15:07 -0600 Subject: [PATCH 1/2] refactor: replace hardcoded lane constants with lane helpers Replace scattered hardcoded lane assumptions (normal/escalated/backlog) with centralized lane-config helpers throughout the codebase. Changes: - types/index.ts: deprecate VALID_LANES, point to lane-config - issue-lane.ts: delegate isValidLane to lane-config isValidLane - agent-queue.ts: use isBacklogLane() for backlog filtering, accept any configured lane id - next-task route: use isBacklogLane() for groom mode backlog check - queue route: remove hardcoded lane type cast - issue-reconciliation.ts: use isBacklogLane() for stale backlog reclassification checks - mc-client.ts: use getClaimableLanes() for default lane fallback - lane-config.ts: add isBacklogLane() helper Tests: - Add isBacklogLane tests to lane-config.test.ts - Add custom lane acceptance test to lane-config.test.ts - Add custom lane validation test to issue-lane.test.ts - Add custom lane queue filtering test to agent-queue.test.ts --- .../api/agents/[agentName]/next-task/route.ts | 5 ++- src/app/api/agents/[agentName]/queue/route.ts | 2 +- src/lib/agent-queue.test.ts | 41 ++++++++++++++++++- src/lib/agent-queue.ts | 14 ++++--- src/lib/issue-lane.test.ts | 30 +++++++++++++- src/lib/issue-lane.ts | 14 ++++--- src/lib/issue-reconciliation.ts | 9 ++-- src/lib/lane-config.test.ts | 27 ++++++++++++ src/lib/lane-config.ts | 8 ++++ src/lib/mc-client.ts | 3 +- src/types/index.ts | 3 ++ 11 files changed, 136 insertions(+), 20 deletions(-) diff --git a/src/app/api/agents/[agentName]/next-task/route.ts b/src/app/api/agents/[agentName]/next-task/route.ts index 6b2cd0a..c44e7c7 100644 --- a/src/app/api/agents/[agentName]/next-task/route.ts +++ b/src/app/api/agents/[agentName]/next-task/route.ts @@ -11,6 +11,7 @@ import { createFollowupPrTask, createGroomTask, } from "@/lib/agent-task"; +import { isValidLane, isBacklogLane } from "@/lib/lane-config"; export async function GET( request: Request, @@ -54,7 +55,7 @@ export async function GET( const hasPriority = issue.labels.some((l) => l.startsWith("priority/")); const hasAgent = issue.labels.some((l) => l.startsWith("agent/")); const hasLane = !!issue.currentLane; - const isBacklog = issue.currentLane === "backlog"; + const isBacklog = issue.currentLane ? isBacklogLane(issue.currentLane) : false; const isUnlabeled = issue.labels.length === 0; // Eligible if missing any key metadata @@ -118,7 +119,7 @@ export async function GET( }, }); - const issueLane = lane?.toLowerCase() as "normal" | "escalated" | "backlog" | undefined; + const issueLane = lane?.toLowerCase(); const prFixLane = lane; const leasedIssueIds = await findLeasedIssueIds(agentName); diff --git a/src/app/api/agents/[agentName]/queue/route.ts b/src/app/api/agents/[agentName]/queue/route.ts index d83d8dc..06bd44b 100644 --- a/src/app/api/agents/[agentName]/queue/route.ts +++ b/src/app/api/agents/[agentName]/queue/route.ts @@ -39,7 +39,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen }, }); - const issueLane = lane?.toLowerCase() as "normal" | "escalated" | "backlog" | undefined; + const issueLane = lane?.toLowerCase(); const prFixLane = lane; // Find issues that have active leases from OTHER agents — exclude them diff --git a/src/lib/agent-queue.test.ts b/src/lib/agent-queue.test.ts index c0cde90..b09e881 100644 --- a/src/lib/agent-queue.test.ts +++ b/src/lib/agent-queue.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, afterEach } from "vitest"; import { buildAgentQueue, isRenovateIssue } from "./agent-queue"; +import { setLaneConfig, resetLaneConfig } from "./lane-config"; const makeIssue = (overrides: Partial<{ number: number; title: string; url: string; labels: string[]; lane?: string }> = {}) => ({ number: overrides.number ?? 1, @@ -719,4 +720,42 @@ describe("buildAgentQueue excludes non-worker-actionable issues (issue #369)", ( const result = buildAgentQueue(issues, "worker-agent"); expect(result).toHaveLength(0); }); + + describe("custom lanes", () => { + afterEach(() => { + resetLaneConfig(); + }); + + it("accepts custom configured lanes and filters by them", () => { + setLaneConfig({ + lanes: [ + { id: "fast", title: "Fast Lane", claimable: true }, + { id: "slow", title: "Slow Lane", claimable: true }, + { id: "parked", title: "Parked", claimable: false }, + ], + }); + + const issues = [ + makeIssue({ number: 1, labels: ["priority/p1", "status/ready"], lane: "fast" }), + makeIssue({ number: 2, labels: ["priority/p1", "status/ready"], lane: "slow" }), + // Note: parked issue uses status/ready (not status/backlog) to test lane filtering + // separately from status-based filtering + makeIssue({ number: 3, labels: ["priority/p1", "status/ready"], lane: "parked" }), + ]; + + // Filter by custom lane + const fastOnly = buildAgentQueue(issues, "worker-agent", { lane: "fast" }); + expect(fastOnly).toHaveLength(1); + expect(fastOnly[0].number).toBe(1); + + // Default (no lane filter) excludes parked (non-claimable) lane + const allClaimable = buildAgentQueue(issues, "worker-agent"); + expect(allClaimable).toHaveLength(2); + expect(allClaimable.map((i) => i.number)).toEqual([1, 2]); + + // Include parked with claimableOnly=false + const allIncludingParked = buildAgentQueue(issues, "worker-agent", { claimableOnly: false }); + expect(allIncludingParked).toHaveLength(3); + }); + }); }); diff --git a/src/lib/agent-queue.ts b/src/lib/agent-queue.ts index 8d1880c..938e45a 100644 --- a/src/lib/agent-queue.ts +++ b/src/lib/agent-queue.ts @@ -7,6 +7,7 @@ import { getPriorityFromLabels, } from "@/types"; import { isIssueExcludedByLabels } from "@/lib/issue-filters"; +import { isBacklogLane } from "@/lib/lane-config"; const DONE_STATUS: string = "status/done"; const IN_PROGRESS_STATUS: string = "status/in-progress"; @@ -156,7 +157,7 @@ function isClaimableStatus(labels: string[]): boolean { /** * Build the agent queue: filter, rank, and return issues for a given agent. - * Optionally filters by execution lane (normal | escalated | backlog). + * Optionally filters by execution lane. By default excludes backlog lane items. * Optionally excludes decomposed audit parents. * Excludes claimed issues by default; pass includeClaimed to include agent/* labels. * Note: includeClaimed and claimableOnly are independent options — not a rename. @@ -181,7 +182,7 @@ export function buildAgentQueue( }>, agentName: string, options?: { - lane?: "normal" | "escalated" | "backlog" | "NORMAL" | "ESCALATED" | "BACKLOG"; + lane?: string; excludeDecomposed?: boolean; includeClaimed?: boolean; includeRenovate?: boolean; @@ -190,7 +191,7 @@ export function buildAgentQueue( }, ): RankedIssue[] { // Normalize lane to lowercase for consistent comparison - const normalizedLane = options?.lane?.toLowerCase() as "normal" | "escalated" | "backlog" | undefined; + const normalizedLane = options?.lane?.toLowerCase(); // Default claimableOnly to true per the worker contract (backlog is triage-only) const claimableOnly = options?.claimableOnly ?? true; @@ -234,10 +235,13 @@ export function buildAgentQueue( actionable = actionable.filter((issue) => !isIssueExcludedByLabels(issue.labels, excludedLabels)); } - // Lane filter: exclude BACKLOG lane items from normal agent queue + // Lane filter: exclude backlog lane items from normal agent queue + // When claimableOnly=false, include all lanes (including backlog/non-claimable) const filtered = normalizedLane ? actionable.filter((issue) => issue.lane?.toLowerCase() === normalizedLane) - : actionable.filter((issue) => issue.lane?.toLowerCase() !== "backlog"); + : claimableOnly + ? actionable.filter((issue) => !isBacklogLane(issue.lane?.toLowerCase() ?? "")) + : actionable; // Rank and filter out excluded items const ranked = filtered diff --git a/src/lib/issue-lane.test.ts b/src/lib/issue-lane.test.ts index 0bda32f..7df7bec 100644 --- a/src/lib/issue-lane.test.ts +++ b/src/lib/issue-lane.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, afterEach } from "vitest"; import { isValidLane, isValidConfidence, @@ -8,6 +8,7 @@ import { buildLaneClassificationPrompt, serializeLaneData, } from "./issue-lane"; +import { setLaneConfig, resetLaneConfig } from "./lane-config"; describe("isValidLane", () => { it("returns true for valid lanes", () => { @@ -23,6 +24,21 @@ describe("isValidLane", () => { expect(isValidLane(null)).toBe(false); expect(isValidLane(undefined)).toBe(false); }); + + it("accepts custom configured lanes", () => { + setLaneConfig({ + lanes: [ + { id: "fast", title: "Fast Lane", claimable: true }, + { id: "slow", title: "Slow Lane", claimable: true }, + { id: "parked", title: "Parked", claimable: false }, + ], + }); + expect(isValidLane("fast")).toBe(true); + expect(isValidLane("slow")).toBe(true); + expect(isValidLane("parked")).toBe(true); + expect(isValidLane("normal")).toBe(false); + resetLaneConfig(); + }); }); describe("isValidConfidence", () => { @@ -73,6 +89,18 @@ describe("parseLaneClassification", () => { const result = parseLaneClassification({ lane: "normal", confidence: "high", reason: " too long " + "x".repeat(495) }); expect(result!.reason.length).toBeLessThanOrEqual(500); }); + + it("parses custom configured lane", () => { + setLaneConfig({ + lanes: [ + { id: "fast", title: "Fast Lane", claimable: true }, + { id: "parked", title: "Parked", claimable: false }, + ], + }); + const result = parseLaneClassification({ lane: "fast", confidence: "high", reason: "Custom lane work" }); + expect(result).toEqual({ lane: "fast", confidence: "high", reason: "Custom lane work" }); + resetLaneConfig(); + }); }); describe("validateLaneRecord", () => { diff --git a/src/lib/issue-lane.ts b/src/lib/issue-lane.ts index edea142..962e048 100644 --- a/src/lib/issue-lane.ts +++ b/src/lib/issue-lane.ts @@ -1,11 +1,12 @@ -import { VALID_LANES, VALID_CONFIDENCE } from "@/types"; +import { VALID_CONFIDENCE } from "@/types"; +import { isValidLane as isValidLaneConfig } from "@/lib/lane-config"; /** * A lane classification result for an issue. */ export interface LaneClassification { - /** The assigned execution lane */ - lane: "normal" | "escalated" | "backlog"; + /** The assigned execution lane id (e.g. "normal", "escalated", "backlog", or custom) */ + lane: string; /** Confidence in the classification */ confidence: "high" | "medium" | "low"; /** Human-readable reason for the classification */ @@ -15,10 +16,11 @@ export interface LaneClassification { } /** - * Validate a lane value against known lanes. + * Validate a lane value against configured lanes. + * Delegates to lane-config which respects custom lane configuration. */ -export function isValidLane(lane: unknown): lane is "normal" | "escalated" | "backlog" { - return typeof lane === "string" && VALID_LANES.includes(lane as "normal" | "escalated" | "backlog"); +export function isValidLane(lane: unknown): lane is string { + return typeof lane === "string" && isValidLaneConfig(lane); } /** diff --git a/src/lib/issue-reconciliation.ts b/src/lib/issue-reconciliation.ts index bf14d83..05f973e 100644 --- a/src/lib/issue-reconciliation.ts +++ b/src/lib/issue-reconciliation.ts @@ -1,5 +1,6 @@ import { GitHubIssue } from "@/types"; import { GithubPR, closeIssue as githubCloseIssue, addIssueLabel as githubAddIssueLabel, removeIssueLabel as githubRemoveIssueLabel } from "@/lib/github"; +import { isBacklogLane } from "@/lib/lane-config"; // ─── Lane Classification Helpers ────────────────────────────────────────────── @@ -82,7 +83,7 @@ export function shouldReclassifyStaleBacklog( body: string | null, currentLabels: string[], ): "normal" | "escalated" | null { - if (existingLane !== "backlog") { + if (!existingLane || !isBacklogLane(existingLane)) { return null; } @@ -105,11 +106,13 @@ export function shouldReclassifyStaleBacklog( // If classifier still says backlog (stale text mentions), fall back to normal. // An issue with an active status label must never remain in the backlog lane. - if (classification.lane === "backlog") { + if (isBacklogLane(classification.lane)) { return "normal"; } - return classification.lane; + // classifyLaneByHeuristics returns one of "normal", "escalated", or backlog lane id. + // Since we've ruled out backlog above, this is safe to cast. + return classification.lane as "normal" | "escalated"; } // ─── Merged PR Detection ───────────────────────────────────────────────────── diff --git a/src/lib/lane-config.test.ts b/src/lib/lane-config.test.ts index 2d06318..07c3a17 100644 --- a/src/lib/lane-config.test.ts +++ b/src/lib/lane-config.test.ts @@ -5,6 +5,7 @@ import { getConfiguredLanes, getLaneById, getLaneIds, + isBacklogLane, isClaimableLane, isValidLane, resetLaneConfig, @@ -44,6 +45,13 @@ describe("lane-config defaults", () => { expect(isValidLane("unknown")).toBe(false); }); + it("isBacklogLane identifies the backlog lane", () => { + expect(isBacklogLane("backlog")).toBe(true); + expect(isBacklogLane("normal")).toBe(false); + expect(isBacklogLane("escalated")).toBe(false); + expect(isBacklogLane("unknown")).toBe(false); + }); + it("getLaneById returns the lane or undefined", () => { expect(getLaneById("normal")?.title).toBe("Normal"); expect(getLaneById("nonexistent")).toBeUndefined(); @@ -136,6 +144,25 @@ describe("lane-config custom config", () => { ).toThrow("Lane config must contain at least one claimable lane"); }); + it("custom lanes are accepted by isValidLane and isClaimableLane", () => { + setLaneConfig({ + lanes: [ + { id: "fast", title: "Fast Lane", claimable: true }, + { id: "slow", title: "Slow Lane", claimable: true }, + { id: "parked", title: "Parked", claimable: false }, + ], + }); + + expect(isValidLane("fast")).toBe(true); + expect(isValidLane("slow")).toBe(true); + expect(isValidLane("parked")).toBe(true); + expect(isValidLane("normal")).toBe(false); + expect(isClaimableLane("fast")).toBe(true); + expect(isClaimableLane("parked")).toBe(false); + expect(isBacklogLane("parked")).toBe(true); + expect(isBacklogLane("fast")).toBe(false); + }); + it("supports optional fields (description, color, defaultAgent)", () => { setLaneConfig({ lanes: [ diff --git a/src/lib/lane-config.ts b/src/lib/lane-config.ts index 7c80a71..0388a8e 100644 --- a/src/lib/lane-config.ts +++ b/src/lib/lane-config.ts @@ -143,6 +143,14 @@ export function getLaneIds(): string[] { return getConfiguredLanes().map((lane) => lane.id); } +/** + * Check whether a lane id is the non-claimable (backlog) lane. + */ +export function isBacklogLane(id: string): boolean { + const backlog = getBacklogLane(); + return backlog !== undefined && backlog.id === id; +} + // ─── Validation ─────────────────────────────────────────────────────────────── /** diff --git a/src/lib/mc-client.ts b/src/lib/mc-client.ts index e6bc169..568efb4 100644 --- a/src/lib/mc-client.ts +++ b/src/lib/mc-client.ts @@ -79,6 +79,7 @@ export class DispatchClientError extends Error { } import { getDispatchUrl, getDispatchAgentToken, getDispatchAgentName } from "./dispatch-env"; +import { getClaimableLanes } from "./lane-config"; export function getDispatchConfig(): { baseUrl: string; token: string } { const baseUrl = getDispatchUrl(); @@ -293,7 +294,7 @@ export async function claimWork( Issue: ${resolved.title} URL: ${resolved.url} -Lane: ${resolved.lane || "normal"} +Lane: ${resolved.lane || getClaimableLanes()[0]?.id || "normal"} Status: ${status} Labels: ${resolved.labels.join(", ") || "none"} Agent: ${resolvedAgent} diff --git a/src/types/index.ts b/src/types/index.ts index 475d8fe..0347f53 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -128,9 +128,12 @@ export const AGENT_PREFIX = "agent/"; export const OWNER_PREFIX = "owner/"; // Lane classification types and constants +// NOTE: These represent the default configuration. For runtime validation, +// use isValidLane() from "@/lib/lane-config" which respects custom lane config. export type IssueLaneValue = "normal" | "escalated" | "backlog"; export type ConfidenceValue = "high" | "medium" | "low"; +/** @deprecated Use getLaneIds() from "@/lib/lane-config" for runtime validation */ export const VALID_LANES: IssueLaneValue[] = ["normal", "escalated", "backlog"]; export const VALID_CONFIDENCE: ConfidenceValue[] = ["high", "medium", "low"]; From 5326ba5aae6a1960a472f6665f8d89cdff3f4af0 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Wed, 17 Jun 2026 12:19:59 -0600 Subject: [PATCH 2/2] fix: remove unused isValidLane import from next-task route --- src/app/api/agents/[agentName]/next-task/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/agents/[agentName]/next-task/route.ts b/src/app/api/agents/[agentName]/next-task/route.ts index c44e7c7..4a0cb90 100644 --- a/src/app/api/agents/[agentName]/next-task/route.ts +++ b/src/app/api/agents/[agentName]/next-task/route.ts @@ -11,7 +11,7 @@ import { createFollowupPrTask, createGroomTask, } from "@/lib/agent-task"; -import { isValidLane, isBacklogLane } from "@/lib/lane-config"; +import { isBacklogLane } from "@/lib/lane-config"; export async function GET( request: Request,