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
5 changes: 3 additions & 2 deletions src/app/api/agents/[agentName]/next-task/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createFollowupPrTask,
createGroomTask,
} from "@/lib/agent-task";
import { isBacklogLane } from "@/lib/lane-config";

export async function GET(
request: Request,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/agents/[agentName]/queue/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 40 additions & 1 deletion src/lib/agent-queue.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
});
});
});
14 changes: 9 additions & 5 deletions src/lib/agent-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion src/lib/issue-lane.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, afterEach } from "vitest";
import {
isValidLane,
isValidConfidence,
Expand All @@ -8,6 +8,7 @@ import {
buildLaneClassificationPrompt,
serializeLaneData,
} from "./issue-lane";
import { setLaneConfig, resetLaneConfig } from "./lane-config";

describe("isValidLane", () => {
it("returns true for valid lanes", () => {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
14 changes: 8 additions & 6 deletions src/lib/issue-lane.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand All @@ -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);
}

/**
Expand Down
9 changes: 6 additions & 3 deletions src/lib/issue-reconciliation.ts
Original file line number Diff line number Diff line change
@@ -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 ──────────────────────────────────────────────

Expand Down Expand Up @@ -82,7 +83,7 @@ export function shouldReclassifyStaleBacklog(
body: string | null,
currentLabels: string[],
): "normal" | "escalated" | null {
if (existingLane !== "backlog") {
if (!existingLane || !isBacklogLane(existingLane)) {
return null;
}

Expand All @@ -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 ─────────────────────────────────────────────────────
Expand Down
27 changes: 27 additions & 0 deletions src/lib/lane-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getConfiguredLanes,
getLaneById,
getLaneIds,
isBacklogLane,
isClaimableLane,
isValidLane,
resetLaneConfig,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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: [
Expand Down
8 changes: 8 additions & 0 deletions src/lib/lane-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────

/**
Expand Down
3 changes: 2 additions & 1 deletion src/lib/mc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 3 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down