From 2d848cb5d54a392e7fd4515a2e2c6513e86d4091 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Tue, 16 Jun 2026 20:53:11 -0600 Subject: [PATCH 1/4] feat: add harness-agnostic AgentTask contract (#393) Define discriminated union AgentTask with four task types: - idle: no work available - implement: claim or work an issue, open/update one PR - followup-pr: fix an existing PR - groom: enrich issues with labels, lane, and status Include helper builders (createIdleTask, createImplementTask, createFollowupPrTask, createGroomTask) with strict default instructions and forbidden actions. No API routes, no DB changes. --- src/lib/agent-task.test.ts | 309 +++++++++++++++++++++++++++++++++++++ src/lib/agent-task.ts | 173 +++++++++++++++++++++ 2 files changed, 482 insertions(+) create mode 100644 src/lib/agent-task.test.ts create mode 100644 src/lib/agent-task.ts diff --git a/src/lib/agent-task.test.ts b/src/lib/agent-task.test.ts new file mode 100644 index 0000000..abdd82e --- /dev/null +++ b/src/lib/agent-task.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "vitest"; +import { + createIdleTask, + createImplementTask, + createFollowupPrTask, + createGroomTask, +} from "./agent-task"; + +describe("createIdleTask", () => { + it("has shouldRun false", () => { + const task = createIdleTask("No work available"); + expect(task.shouldRun).toBe(false); + }); + + it("has type idle", () => { + const task = createIdleTask("No work available"); + expect(task.type).toBe("idle"); + }); + + it("preserves reason", () => { + const task = createIdleTask("All issues are backlog"); + expect(task.reason).toBe("All issues are backlog"); + }); + + it("does not contain agentName or issue fields", () => { + const task = createIdleTask("idle"); + expect("agentName" in task).toBe(false); + expect("issue" in task).toBe(false); + expect("instructions" in task).toBe(false); + expect("forbiddenActions" in task).toBe(false); + }); +}); + +describe("createImplementTask", () => { + const baseIssue = { + repoFullName: "misospace/dispatch", + number: 393, + title: "Add AgentTask contract", + url: "https://github.com/misospace/dispatch/issues/393", + }; + + it("has shouldRun true", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.shouldRun).toBe(true); + }); + + it("has type implement", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.type).toBe("implement"); + }); + + it("includes agentName", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.agentName).toBe("alpha"); + }); + + it("includes lane when provided", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue, lane: "escalated" }); + expect(task.lane).toBe("escalated"); + }); + + it("omits lane when not provided", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.lane).toBeUndefined(); + }); + + it("includes issue fields", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.issue.repoFullName).toBe("misospace/dispatch"); + expect(task.issue.number).toBe(393); + expect(task.issue.title).toBe("Add AgentTask contract"); + expect(task.issue.url).toBe("https://github.com/misospace/dispatch/issues/393"); + }); + + it("includes stopAfter", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.stopAfter).toContain("PR"); + }); + + it("includes forbiddenActions by default", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.forbiddenActions).toContain("Merging any pull request"); + expect(task.forbiddenActions).toContain("Grooming unrelated issues"); + expect(task.forbiddenActions).toContain("Claiming another issue while this one is open"); + }); + + it("includes default instructions", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.instructions).toContain("Claim or work the assigned issue"); + expect(task.instructions).toContain("Open or update exactly one PR"); + }); + + it("preserves custom instructions", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue, instructions: "Custom instruction" }); + expect(task.instructions).toBe("Custom instruction"); + }); + + it("preserves custom stopAfter", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue, stopAfter: "When done" }); + expect(task.stopAfter).toBe("When done"); + }); + + it("preserves custom forbiddenActions", () => { + const custom = ["No merging"]; + const task = createImplementTask({ agentName: "alpha", issue: baseIssue, forbiddenActions: custom }); + expect(task.forbiddenActions).toEqual(custom); + }); + + it("does not require harness-specific fields", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect("harness" in task).toBe(false); + expect("workflowRepo" in task).toBe(false); + }); +}); + +describe("createFollowupPrTask", () => { + const baseInput = { + agentName: "beta", + lane: "normal", + pullRequest: { + repoFullName: "misospace/dispatch", + number: 456, + url: "https://github.com/misospace/dispatch/pull/456", + }, + reasons: ["Review feedback", "CI failure"], + }; + + it("has shouldRun true", () => { + const task = createFollowupPrTask(baseInput); + expect(task.shouldRun).toBe(true); + }); + + it("has type followup-pr", () => { + const task = createFollowupPrTask(baseInput); + expect(task.type).toBe("followup-pr"); + }); + + it("includes pullRequest fields", () => { + const task = createFollowupPrTask(baseInput); + expect(task.pullRequest.repoFullName).toBe("misospace/dispatch"); + expect(task.pullRequest.number).toBe(456); + expect(task.pullRequest.url).toBe("https://github.com/misospace/dispatch/pull/456"); + }); + + it("includes reasons", () => { + const task = createFollowupPrTask(baseInput); + expect(task.reasons).toContain("Review feedback"); + expect(task.reasons).toContain("CI failure"); + }); + + it("preserves optional issue when provided", () => { + const task = createFollowupPrTask({ + ...baseInput, + issue: { + repoFullName: "misospace/dispatch", + number: 393, + title: "Related issue", + url: "https://github.com/misospace/dispatch/issues/393", + }, + }); + expect(task.issue?.number).toBe(393); + }); + + it("omits issue when not provided", () => { + const task = createFollowupPrTask(baseInput); + expect(task.issue).toBeUndefined(); + }); + + it("includes default instructions about fixing existing PR", () => { + const task = createFollowupPrTask(baseInput); + expect(task.instructions).toContain("Fix the existing pull request"); + }); + + it("includes forbiddenActions by default", () => { + const task = createFollowupPrTask(baseInput); + expect(task.forbiddenActions).toContain("Merging any pull request"); + expect(task.forbiddenActions).toContain("Opening a new pull request"); + }); + + it("preserves custom instructions", () => { + const task = createFollowupPrTask({ ...baseInput, instructions: "Fix lint errors" }); + expect(task.instructions).toBe("Fix lint errors"); + }); + + it("preserves repo and PR fields from input", () => { + const input = { + agentName: "gamma", + pullRequest: { + repoFullName: "other/repo", + number: 99, + }, + reasons: ["Trivial fix"], + }; + const task = createFollowupPrTask(input); + expect(task.pullRequest.repoFullName).toBe("other/repo"); + expect(task.pullRequest.number).toBe(99); + expect(task.reasons).toEqual(["Trivial fix"]); + }); + + it("does not require harness-specific fields", () => { + const task = createFollowupPrTask(baseInput); + expect("harness" in task).toBe(false); + expect("workflowRepo" in task).toBe(false); + }); +}); + +describe("createGroomTask", () => { + const baseInput = { + agentName: "gamma", + }; + + it("has shouldRun true", () => { + const task = createGroomTask(baseInput); + expect(task.shouldRun).toBe(true); + }); + + it("has type groom", () => { + const task = createGroomTask(baseInput); + expect(task.type).toBe("groom"); + }); + + it("includes agentName", () => { + const task = createGroomTask(baseInput); + expect(task.agentName).toBe("gamma"); + }); + + it("includes default groom-specific instructions", () => { + const task = createGroomTask(baseInput); + expect(task.instructions).toContain("Enrich the issue"); + expect(task.instructions).toContain("labels"); + expect(task.instructions).toContain("lane"); + }); + + it("includes default forbidden actions for grooming", () => { + const task = createGroomTask(baseInput); + expect(task.forbiddenActions).toContain("Writing implementation code"); + expect(task.forbiddenActions).toContain("Opening pull requests"); + expect(task.forbiddenActions).not.toContain("Merging any pull request"); + }); + + it("preserves optional issue when provided", () => { + const task = createGroomTask({ + ...baseInput, + issue: { + repoFullName: "misospace/dispatch", + number: 100, + title: "Needs grooming", + url: "https://github.com/misospace/dispatch/issues/100", + }, + }); + expect(task.issue?.number).toBe(100); + expect(task.issue?.title).toBe("Needs grooming"); + }); + + it("omits issue when not provided", () => { + const task = createGroomTask(baseInput); + expect(task.issue).toBeUndefined(); + }); + + it("preserves custom instructions", () => { + const task = createGroomTask({ ...baseInput, instructions: "Custom grooming" }); + expect(task.instructions).toBe("Custom grooming"); + }); + + it("preserves custom forbiddenActions", () => { + const custom = ["No code changes"]; + const task = createGroomTask({ ...baseInput, forbiddenActions: custom }); + expect(task.forbiddenActions).toEqual(custom); + }); + + it("does not require harness-specific fields", () => { + const task = createGroomTask(baseInput); + expect("harness" in task).toBe(false); + expect("workflowRepo" in task).toBe(false); + }); +}); + +describe("AgentTask discriminated union", () => { + it("idle task is distinguishable by type", () => { + const task = createIdleTask("no work"); + if (task.type !== "idle") throw new Error("should be idle"); + expect(task.reason).toBe("no work"); + }); + + it("implement task is distinguishable by type", () => { + const task = createImplementTask({ + agentName: "a", + issue: { repoFullName: "r", number: 1, title: "t", url: "u" }, + }); + if (task.type !== "implement") throw new Error("should be implement"); + expect(task.issue.number).toBe(1); + }); + + it("followup-pr task is distinguishable by type", () => { + const task = createFollowupPrTask({ + agentName: "a", + pullRequest: { repoFullName: "r", number: 2 }, + reasons: ["r"], + }); + if (task.type !== "followup-pr") throw new Error("should be followup-pr"); + expect(task.pullRequest.number).toBe(2); + }); + + it("groom task is distinguishable by type", () => { + const task = createGroomTask({ agentName: "a" }); + if (task.type !== "groom") throw new Error("should be groom"); + expect(task.agentName).toBe("a"); + }); +}); diff --git a/src/lib/agent-task.ts b/src/lib/agent-task.ts new file mode 100644 index 0000000..acd3981 --- /dev/null +++ b/src/lib/agent-task.ts @@ -0,0 +1,173 @@ +interface IssueRef { + repoFullName: string; + number: number; + title: string; + url: string; +} + +interface OptionalIssueRef { + repoFullName: string; + number: number; + title?: string; + url?: string; +} + +interface PullRequestRef { + repoFullName: string; + number: number; + url?: string; +} + +export interface IdleTask { + type: "idle"; + shouldRun: false; + reason: string; +} + +export interface ImplementTask { + type: "implement"; + shouldRun: true; + agentName: string; + lane?: string; + issue: IssueRef; + instructions: string; + stopAfter: string; + forbiddenActions: string[]; +} + +export interface FollowupPrTask { + type: "followup-pr"; + shouldRun: true; + agentName: string; + lane?: string; + issue?: OptionalIssueRef; + pullRequest: PullRequestRef; + reasons: string[]; + instructions: string; + stopAfter: string; + forbiddenActions: string[]; +} + +export interface GroomTask { + type: "groom"; + shouldRun: true; + agentName: string; + issue?: IssueRef; + instructions: string; + stopAfter: string; + forbiddenActions: string[]; +} + +export type AgentTask = IdleTask | ImplementTask | FollowupPrTask | GroomTask; + +const IMPLEMENT_INSTRUCTIONS = + "Claim or work the assigned issue. Open or update exactly one PR, then stop. Do not merge, groom unrelated issues, or claim another issue."; + +const IMPLEMENT_STOP_AFTER = + "One PR is open or updated for the issue. Push remaining work to a follow-up issue."; + +const FOLLOWUP_PR_INSTRUCTIONS = + "Fix the existing pull request. Update it with the requested changes, then stop. Do not merge, open new PRs, or claim another issue."; + +const FOLLOWUP_PR_STOP_AFTER = + "The queued PR has been updated with the requested fixes. Push remaining work to a follow-up."; + +const GROOM_INSTRUCTIONS = + "Enrich the issue with labels, lane classification, and status assignment. Close completed work. Do not implement or open PRs."; + +const GROOM_STOP_AFTER = + "The issue has been enriched with labels, lane, and status. Close if completed."; + +const IMPLEMENT_FORBIDDEN = [ + "Merging any pull request", + "Grooming unrelated issues", + "Claiming another issue while this one is open", +]; + +const FOLLOWUP_PR_FORBIDDEN = [ + "Merging any pull request", + "Opening a new pull request", + "Claiming another issue while this PR is queued", +]; + +const GROOM_FORBIDDEN = [ + "Writing implementation code", + "Opening pull requests", + "Modifying production configuration", +]; + +export function createIdleTask(reason: string): IdleTask { + return { + type: "idle", + shouldRun: false, + reason, + }; +} + +export interface ImplementTaskInput { + agentName: string; + lane?: string; + issue: IssueRef; + instructions?: string; + stopAfter?: string; + forbiddenActions?: string[]; +} + +export function createImplementTask(input: ImplementTaskInput): ImplementTask { + return { + type: "implement", + shouldRun: true, + agentName: input.agentName, + lane: input.lane, + issue: input.issue, + instructions: input.instructions ?? IMPLEMENT_INSTRUCTIONS, + stopAfter: input.stopAfter ?? IMPLEMENT_STOP_AFTER, + forbiddenActions: input.forbiddenActions ?? IMPLEMENT_FORBIDDEN, + }; +} + +export interface FollowupPrTaskInput { + agentName: string; + lane?: string; + issue?: OptionalIssueRef; + pullRequest: PullRequestRef; + reasons: string[]; + instructions?: string; + stopAfter?: string; + forbiddenActions?: string[]; +} + +export function createFollowupPrTask(input: FollowupPrTaskInput): FollowupPrTask { + return { + type: "followup-pr", + shouldRun: true, + agentName: input.agentName, + lane: input.lane, + issue: input.issue, + pullRequest: input.pullRequest, + reasons: input.reasons, + instructions: input.instructions ?? FOLLOWUP_PR_INSTRUCTIONS, + stopAfter: input.stopAfter ?? FOLLOWUP_PR_STOP_AFTER, + forbiddenActions: input.forbiddenActions ?? FOLLOWUP_PR_FORBIDDEN, + }; +} + +export interface GroomTaskInput { + agentName: string; + issue?: IssueRef; + instructions?: string; + stopAfter?: string; + forbiddenActions?: string[]; +} + +export function createGroomTask(input: GroomTaskInput): GroomTask { + return { + type: "groom", + shouldRun: true, + agentName: input.agentName, + issue: input.issue, + instructions: input.instructions ?? GROOM_INSTRUCTIONS, + stopAfter: input.stopAfter ?? GROOM_STOP_AFTER, + forbiddenActions: input.forbiddenActions ?? GROOM_FORBIDDEN, + }; +} From 2e1ee5c0de898439b5cd74c5458d37b955f3effd Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Tue, 16 Jun 2026 20:57:06 -0600 Subject: [PATCH 2/4] fix: copy forbiddenActions arrays and export ref types in agent-task --- src/lib/agent-task.test.ts | 45 ++++++++++++++++++++++++++++++++++++++ src/lib/agent-task.ts | 12 +++++----- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/lib/agent-task.test.ts b/src/lib/agent-task.test.ts index abdd82e..1e04ba9 100644 --- a/src/lib/agent-task.test.ts +++ b/src/lib/agent-task.test.ts @@ -307,3 +307,48 @@ describe("AgentTask discriminated union", () => { expect(task.agentName).toBe("a"); }); }); + +describe("forbiddenActions mutation isolation", () => { + it("mutating one implement task's forbiddenActions does not affect another", () => { + const issue = { repoFullName: "r", number: 1, title: "t", url: "u" }; + const taskA = createImplementTask({ agentName: "a", issue }); + const taskB = createImplementTask({ agentName: "b", issue }); + + taskA.forbiddenActions.push("Injected action"); + + expect(taskA.forbiddenActions).toContain("Injected action"); + expect(taskB.forbiddenActions).not.toContain("Injected action"); + }); + + it("mutating one followup-pr task's forbiddenActions does not affect another", () => { + const pr = { repoFullName: "r", number: 1 }; + const taskA = createFollowupPrTask({ agentName: "a", pullRequest: pr, reasons: ["r"] }); + const taskB = createFollowupPrTask({ agentName: "b", pullRequest: pr, reasons: ["r"] }); + + taskA.forbiddenActions.push("Injected action"); + + expect(taskA.forbiddenActions).toContain("Injected action"); + expect(taskB.forbiddenActions).not.toContain("Injected action"); + }); + + it("mutating one groom task's forbiddenActions does not affect another", () => { + const taskA = createGroomTask({ agentName: "a" }); + const taskB = createGroomTask({ agentName: "b" }); + + taskA.forbiddenActions.push("Injected action"); + + expect(taskA.forbiddenActions).toContain("Injected action"); + expect(taskB.forbiddenActions).not.toContain("Injected action"); + }); + + it("mutating caller's input array does not affect created task", () => { + const custom = ["Custom forbidden"]; + const issue = { repoFullName: "r", number: 1, title: "t", url: "u" }; + const task = createImplementTask({ agentName: "a", issue, forbiddenActions: custom }); + + custom.push("Added after creation"); + + expect(task.forbiddenActions).not.toContain("Added after creation"); + expect(task.forbiddenActions).toEqual(["Custom forbidden"]); + }); +}); diff --git a/src/lib/agent-task.ts b/src/lib/agent-task.ts index acd3981..6f3d161 100644 --- a/src/lib/agent-task.ts +++ b/src/lib/agent-task.ts @@ -1,18 +1,18 @@ -interface IssueRef { +export interface IssueRef { repoFullName: string; number: number; title: string; url: string; } -interface OptionalIssueRef { +export interface OptionalIssueRef { repoFullName: string; number: number; title?: string; url?: string; } -interface PullRequestRef { +export interface PullRequestRef { repoFullName: string; number: number; url?: string; @@ -122,7 +122,7 @@ export function createImplementTask(input: ImplementTaskInput): ImplementTask { issue: input.issue, instructions: input.instructions ?? IMPLEMENT_INSTRUCTIONS, stopAfter: input.stopAfter ?? IMPLEMENT_STOP_AFTER, - forbiddenActions: input.forbiddenActions ?? IMPLEMENT_FORBIDDEN, + forbiddenActions: input.forbiddenActions ? [...input.forbiddenActions] : [...IMPLEMENT_FORBIDDEN], }; } @@ -148,7 +148,7 @@ export function createFollowupPrTask(input: FollowupPrTaskInput): FollowupPrTask reasons: input.reasons, instructions: input.instructions ?? FOLLOWUP_PR_INSTRUCTIONS, stopAfter: input.stopAfter ?? FOLLOWUP_PR_STOP_AFTER, - forbiddenActions: input.forbiddenActions ?? FOLLOWUP_PR_FORBIDDEN, + forbiddenActions: input.forbiddenActions ? [...input.forbiddenActions] : [...FOLLOWUP_PR_FORBIDDEN], }; } @@ -168,6 +168,6 @@ export function createGroomTask(input: GroomTaskInput): GroomTask { issue: input.issue, instructions: input.instructions ?? GROOM_INSTRUCTIONS, stopAfter: input.stopAfter ?? GROOM_STOP_AFTER, - forbiddenActions: input.forbiddenActions ?? GROOM_FORBIDDEN, + forbiddenActions: input.forbiddenActions ? [...input.forbiddenActions] : [...GROOM_FORBIDDEN], }; } From e0577f2e96020fef40562e35ae60c0732000e0b8 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Tue, 16 Jun 2026 21:10:50 -0600 Subject: [PATCH 3/4] feat: add agent next-task endpoint --- .../[agentName]/next-task/route.test.ts | 332 ++++++++++++++++++ .../api/agents/[agentName]/next-task/route.ts | 131 +++++++ 2 files changed, 463 insertions(+) create mode 100644 src/app/api/agents/[agentName]/next-task/route.test.ts create mode 100644 src/app/api/agents/[agentName]/next-task/route.ts diff --git a/src/app/api/agents/[agentName]/next-task/route.test.ts b/src/app/api/agents/[agentName]/next-task/route.test.ts new file mode 100644 index 0000000..3870886 --- /dev/null +++ b/src/app/api/agents/[agentName]/next-task/route.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { mocks } = vi.hoisted(() => ({ + mocks: { + issueFindMany: vi.fn(), + prFixFindMany: vi.fn(), + findLeasedIssueIds: vi.fn(), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + issue: { findMany: mocks.issueFindMany }, + prFixQueueItem: { findMany: mocks.prFixFindMany }, + }, + asPrFixQueueClient: (client: any) => client, +})); + +vi.mock("@/lib/lease", () => ({ + findLeasedIssueIds: mocks.findLeasedIssueIds, +})); + +import { GET } from "./route"; + +describe("GET /api/agents/[agentName]/next-task", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.prFixFindMany.mockResolvedValue([]); + mocks.issueFindMany.mockResolvedValue([]); + mocks.findLeasedIssueIds.mockResolvedValue([]); + }); + + it("returns idle when the queue is empty", async () => { + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.type).toBe("idle"); + expect(body.shouldRun).toBe(false); + expect(body.reason).toBe("No work available"); + }); + + it("returns idle when queue is empty (not an array)", async () => { + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(Array.isArray(body)).toBe(false); + }); + + it("returns one implement task for a normal issue", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 42, + title: "Fix login bug", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.type).toBe("implement"); + expect(body.shouldRun).toBe(true); + expect(body.agentName).toBe("example-agent"); + expect(body.issue.number).toBe(42); + expect(body.issue.title).toBe("Fix login bug"); + expect(body.issue.repoFullName).toBe("org/repo"); + expect(body.issue.url).toBe("https://github.com/org/repo/issues/42"); + }); + + it("returns exactly one task, not an array", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 42, + title: "First issue", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + { + id: "issue-2", + number: 43, + title: "Second issue", + url: "https://github.com/org/repo/issues/43", + labels: ["priority/p1", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(Array.isArray(body)).toBe(false); + expect(body.type).toBe("implement"); + }); + + it("returns followup-pr task when a PR-fix item is ahead of issue work", async () => { + mocks.prFixFindMany.mockResolvedValue([ + { + id: "prfix-1", + repo: "org/repo", + pr: 12, + issue: 67, + branch: "fix/issue-67", + url: "https://github.com/org/repo/pull/12", + title: "Fix issue 67", + lane: "NORMAL", + status: "QUEUED", + reason: "review changes requested", + feedback: ["please update tests"], + evidenceKeys: ["review:1"], + author: "itsmiso-ai", + queuedAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + ]); + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 99, + title: "Regular issue", + url: "https://github.com/org/repo/issues/99", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.type).toBe("followup-pr"); + expect(body.shouldRun).toBe(true); + expect(body.agentName).toBe("example-agent"); + expect(body.pullRequest.repoFullName).toBe("org/repo"); + expect(body.pullRequest.number).toBe(12); + expect(body.pullRequest.url).toBe("https://github.com/org/repo/pull/12"); + expect(Array.isArray(body.reasons)).toBe(true); + }); + + it("preserves lane filtering", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-normal", + number: 10, + title: "Normal issue", + url: "https://github.com/org/repo/issues/10", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + { + id: "issue-escalated", + number: 20, + title: "Escalated issue", + url: "https://github.com/org/repo/issues/20", + labels: ["priority/p0", "status/ready"], + currentLane: "escalated", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("implement"); + expect(body.issue.number).toBe(10); + }); + + it("passes through includeClaimed behavior", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-claimed-other", + number: 30, + title: "Claimed by other agent", + url: "https://github.com/org/repo/issues/30", + labels: ["agent/other-agent", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("idle"); + }); + + it("includes claimed issues when includeClaimed=true", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-claimed-other", + number: 30, + title: "Claimed by other agent", + url: "https://github.com/org/repo/issues/30", + labels: ["agent/other-agent", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request( + "http://localhost/api/agents/example-agent/next-task?lane=normal&includeClaimed=true", + ), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("implement"); + expect(body.issue.number).toBe(30); + }); + + it("does not require harness-specific fields", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 42, + title: "Test issue", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect("harness" in body).toBe(false); + expect("workflowRepo" in body).toBe(false); + }); + + it("followup-pr task uses reason when feedback is empty", async () => { + mocks.prFixFindMany.mockResolvedValue([ + { + id: "prfix-1", + repo: "org/repo", + pr: 12, + issue: null, + branch: "fix/something", + url: "https://github.com/org/repo/pull/12", + title: "Fix something", + lane: "NORMAL", + status: "QUEUED", + reason: "CI failure on main", + feedback: [], + evidenceKeys: ["ci:1"], + author: "bot", + queuedAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("followup-pr"); + expect(body.reasons).toEqual(["CI failure on main"]); + }); + + it("does not mutate issue or claim state", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 42, + title: "Test issue", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + expect(mocks.issueFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ state: "open" }), + }), + ); + }); +}); diff --git a/src/app/api/agents/[agentName]/next-task/route.ts b/src/app/api/agents/[agentName]/next-task/route.ts new file mode 100644 index 0000000..77d81d9 --- /dev/null +++ b/src/app/api/agents/[agentName]/next-task/route.ts @@ -0,0 +1,131 @@ +import { NextResponse } from "next/server"; +import { prisma, asPrFixQueueClient } from "@/lib/prisma"; +import { buildAgentQueue } from "@/lib/agent-queue"; +import { listQueuedPrFixItems, toAgentQueuePrFixItem } from "@/lib/pr-fix-queue"; +import { findLeasedIssueIds } from "@/lib/lease"; +import { parseExcludedLabels } from "@/lib/config"; +import { + createIdleTask, + createImplementTask, + createFollowupPrTask, +} from "@/lib/agent-task"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ agentName: string }> }, +) { + const { agentName } = await params; + const { searchParams } = new URL(request.url); + const lane = searchParams.get("lane"); + const excludeDecomposed = searchParams.get("exclude_decomposed"); + const includeClaimed = searchParams.get("includeClaimed") === "true"; + const includeRenovate = searchParams.get("includeRenovate") === "true"; + + try { + const issues = await prisma.issue.findMany({ + where: { + state: "open", + repository: { enabled: true }, + }, + select: { + id: true, + number: true, + title: true, + url: true, + labels: true, + currentLane: true, + decomposed: true, + repository: { select: { fullName: true } }, + linkedPrNumber: true, + linkedPrUrl: true, + linkedPrNeedsFollowup: true, + linkedPrFollowupReasons: true, + linkedPrReviewDecision: true, + linkedPrMergeState: true, + linkedPrHealthCheckedAt: true, + }, + }); + + const issueLane = lane?.toLowerCase() as "normal" | "escalated" | "backlog" | undefined; + const prFixLane = lane; + + const leasedIssueIds = await findLeasedIssueIds(agentName); + + const prFixItems = await listQueuedPrFixItems( + asPrFixQueueClient(prisma), + { lane: prFixLane }, + ); + + const filteredIssues = issues.filter( + (issue) => !leasedIssueIds.includes(issue.id), + ); + + const queue = buildAgentQueue( + filteredIssues.map((issue) => ({ + ...issue, + lane: issue.currentLane ?? undefined, + issueId: issue.id, + repoFullName: issue.repository.fullName, + linkedPrHealth: { + number: issue.linkedPrNumber, + url: issue.linkedPrUrl, + needsFollowup: issue.linkedPrNeedsFollowup, + followupReasons: issue.linkedPrFollowupReasons, + reviewDecision: issue.linkedPrReviewDecision, + mergeState: issue.linkedPrMergeState, + checkedAt: issue.linkedPrHealthCheckedAt?.toISOString() ?? null, + }, + })), + agentName, + { + lane: issueLane, + excludeDecomposed: excludeDecomposed === "true", + includeClaimed, + includeRenovate, + excludedLabels: parseExcludedLabels(process.env.DISPATCH_EXCLUDED_LABELS), + }, + ); + + const prFixQueueItems = prFixItems.map(toAgentQueuePrFixItem); + + if (prFixQueueItems.length > 0) { + const first = prFixQueueItems[0]; + const reasons = + first.feedback.length > 0 ? first.feedback : [first.reason]; + const task = createFollowupPrTask({ + agentName, + lane: first.lane ?? undefined, + pullRequest: { + repoFullName: first.repo, + number: first.pr, + url: first.url ?? undefined, + }, + reasons, + }); + return NextResponse.json(task); + } + + if (queue.length > 0) { + const first = queue[0]; + const task = createImplementTask({ + agentName, + lane: first.lane ?? undefined, + issue: { + repoFullName: first.repoFullName ?? "", + number: first.number, + title: first.title, + url: first.url, + }, + }); + return NextResponse.json(task); + } + + return NextResponse.json(createIdleTask("No work available")); + } catch (error) { + console.error("Failed to fetch next task:", error); + return NextResponse.json( + { error: "Failed to fetch next task" }, + { status: 500 }, + ); + } +} From 7ecf8fb162fac74c0640cf5dc1e75cc6c9d0bf5f Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Tue, 16 Jun 2026 21:15:29 -0600 Subject: [PATCH 4/4] feat: add agent next-task endpoint --- .../[agentName]/next-task/route.test.ts | 397 ++++++++++++++++++ .../api/agents/[agentName]/next-task/route.ts | 135 ++++++ 2 files changed, 532 insertions(+) create mode 100644 src/app/api/agents/[agentName]/next-task/route.test.ts create mode 100644 src/app/api/agents/[agentName]/next-task/route.ts diff --git a/src/app/api/agents/[agentName]/next-task/route.test.ts b/src/app/api/agents/[agentName]/next-task/route.test.ts new file mode 100644 index 0000000..a6a7de7 --- /dev/null +++ b/src/app/api/agents/[agentName]/next-task/route.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { mocks } = vi.hoisted(() => ({ + mocks: { + issueFindMany: vi.fn(), + prFixFindMany: vi.fn(), + findLeasedIssueIds: vi.fn(), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + issue: { findMany: mocks.issueFindMany }, + prFixQueueItem: { findMany: mocks.prFixFindMany }, + }, + asPrFixQueueClient: (client: any) => client, +})); + +vi.mock("@/lib/lease", () => ({ + findLeasedIssueIds: mocks.findLeasedIssueIds, +})); + +import { GET } from "./route"; + +describe("GET /api/agents/[agentName]/next-task", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.prFixFindMany.mockResolvedValue([]); + mocks.issueFindMany.mockResolvedValue([]); + mocks.findLeasedIssueIds.mockResolvedValue([]); + }); + + it("returns idle when the queue is empty", async () => { + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.type).toBe("idle"); + expect(body.shouldRun).toBe(false); + expect(body.reason).toBe("No work available"); + }); + + it("returns idle when queue is empty (not an array)", async () => { + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(Array.isArray(body)).toBe(false); + }); + + it("returns one implement task for a normal issue", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 42, + title: "Fix login bug", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.type).toBe("implement"); + expect(body.shouldRun).toBe(true); + expect(body.agentName).toBe("example-agent"); + expect(body.issue.number).toBe(42); + expect(body.issue.title).toBe("Fix login bug"); + expect(body.issue.repoFullName).toBe("org/repo"); + expect(body.issue.url).toBe("https://github.com/org/repo/issues/42"); + }); + + it("returns exactly one task, not an array", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 42, + title: "First issue", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + { + id: "issue-2", + number: 43, + title: "Second issue", + url: "https://github.com/org/repo/issues/43", + labels: ["priority/p1", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(Array.isArray(body)).toBe(false); + expect(body.type).toBe("implement"); + }); + + it("returns followup-pr task when a PR-fix item is ahead of issue work", async () => { + mocks.prFixFindMany.mockResolvedValue([ + { + id: "prfix-1", + repo: "org/repo", + pr: 12, + issue: 67, + branch: "fix/issue-67", + url: "https://github.com/org/repo/pull/12", + title: "Fix issue 67", + lane: "NORMAL", + status: "QUEUED", + reason: "review changes requested", + feedback: ["please update tests"], + evidenceKeys: ["review:1"], + author: "itsmiso-ai", + queuedAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + ]); + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 99, + title: "Regular issue", + url: "https://github.com/org/repo/issues/99", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.type).toBe("followup-pr"); + expect(body.shouldRun).toBe(true); + expect(body.agentName).toBe("example-agent"); + expect(body.pullRequest.repoFullName).toBe("org/repo"); + expect(body.pullRequest.number).toBe(12); + expect(body.pullRequest.url).toBe("https://github.com/org/repo/pull/12"); + expect(Array.isArray(body.reasons)).toBe(true); + }); + + it("includes linked issue context when PR-fix has an issue number", async () => { + mocks.prFixFindMany.mockResolvedValue([ + { + id: "prfix-1", + repo: "org/repo", + pr: 12, + issue: 67, + branch: "fix/issue-67", + url: "https://github.com/org/repo/pull/12", + title: "Fix issue 67", + lane: "NORMAL", + status: "QUEUED", + reason: "review changes requested", + feedback: ["please update tests"], + evidenceKeys: ["review:1"], + author: "itsmiso-ai", + queuedAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("followup-pr"); + expect(body.issue.repoFullName).toBe("org/repo"); + expect(body.issue.number).toBe(67); + }); + + it("includes both reason and feedback in reasons", async () => { + mocks.prFixFindMany.mockResolvedValue([ + { + id: "prfix-1", + repo: "org/repo", + pr: 12, + issue: null, + branch: "fix/something", + url: "https://github.com/org/repo/pull/12", + title: "Fix something", + lane: "NORMAL", + status: "QUEUED", + reason: "CI failure on main", + feedback: ["update tests", "fix lint"], + evidenceKeys: ["ci:1"], + author: "bot", + queuedAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("followup-pr"); + expect(body.reasons).toContain("CI failure on main"); + expect(body.reasons).toContain("update tests"); + expect(body.reasons).toContain("fix lint"); + }); + + it("preserves lane filtering", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-normal", + number: 10, + title: "Normal issue", + url: "https://github.com/org/repo/issues/10", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + { + id: "issue-escalated", + number: 20, + title: "Escalated issue", + url: "https://github.com/org/repo/issues/20", + labels: ["priority/p0", "status/ready"], + currentLane: "escalated", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("implement"); + expect(body.issue.number).toBe(10); + }); + + it("passes through includeClaimed behavior", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-claimed-other", + number: 30, + title: "Claimed by other agent", + url: "https://github.com/org/repo/issues/30", + labels: ["agent/other-agent", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("idle"); + }); + + it("includes claimed issues when includeClaimed=true", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-claimed-other", + number: 30, + title: "Claimed by other agent", + url: "https://github.com/org/repo/issues/30", + labels: ["agent/other-agent", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request( + "http://localhost/api/agents/example-agent/next-task?lane=normal&includeClaimed=true", + ), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("implement"); + expect(body.issue.number).toBe(30); + }); + + it("does not require harness-specific fields", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 42, + title: "Test issue", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect("harness" in body).toBe(false); + expect("workflowRepo" in body).toBe(false); + }); + + it("followup-pr task uses reason when feedback is empty", async () => { + mocks.prFixFindMany.mockResolvedValue([ + { + id: "prfix-1", + repo: "org/repo", + pr: 12, + issue: null, + branch: "fix/something", + url: "https://github.com/org/repo/pull/12", + title: "Fix something", + lane: "NORMAL", + status: "QUEUED", + reason: "CI failure on main", + feedback: [], + evidenceKeys: ["ci:1"], + author: "bot", + queuedAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("followup-pr"); + expect(body.reasons).toEqual(["CI failure on main"]); + }); + + it("does not mutate issue or claim state", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "issue-1", + number: 42, + title: "Test issue", + url: "https://github.com/org/repo/issues/42", + labels: ["priority/p0", "status/ready"], + currentLane: "normal", + decomposed: false, + repository: { fullName: "org/repo" }, + }, + ]); + + await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + expect(mocks.issueFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ state: "open" }), + }), + ); + }); +}); diff --git a/src/app/api/agents/[agentName]/next-task/route.ts b/src/app/api/agents/[agentName]/next-task/route.ts new file mode 100644 index 0000000..8e2d33e --- /dev/null +++ b/src/app/api/agents/[agentName]/next-task/route.ts @@ -0,0 +1,135 @@ +import { NextResponse } from "next/server"; +import { prisma, asPrFixQueueClient } from "@/lib/prisma"; +import { buildAgentQueue } from "@/lib/agent-queue"; +import { listQueuedPrFixItems, toAgentQueuePrFixItem } from "@/lib/pr-fix-queue"; +import { findLeasedIssueIds } from "@/lib/lease"; +import { parseExcludedLabels } from "@/lib/config"; +import { + createIdleTask, + createImplementTask, + createFollowupPrTask, +} from "@/lib/agent-task"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ agentName: string }> }, +) { + const { agentName } = await params; + const { searchParams } = new URL(request.url); + const lane = searchParams.get("lane"); + const excludeDecomposed = searchParams.get("exclude_decomposed"); + const includeClaimed = searchParams.get("includeClaimed") === "true"; + const includeRenovate = searchParams.get("includeRenovate") === "true"; + + try { + const issues = await prisma.issue.findMany({ + where: { + state: "open", + repository: { enabled: true }, + }, + select: { + id: true, + number: true, + title: true, + url: true, + labels: true, + currentLane: true, + decomposed: true, + repository: { select: { fullName: true } }, + linkedPrNumber: true, + linkedPrUrl: true, + linkedPrNeedsFollowup: true, + linkedPrFollowupReasons: true, + linkedPrReviewDecision: true, + linkedPrMergeState: true, + linkedPrHealthCheckedAt: true, + }, + }); + + const issueLane = lane?.toLowerCase() as "normal" | "escalated" | "backlog" | undefined; + const prFixLane = lane; + + const leasedIssueIds = await findLeasedIssueIds(agentName); + + const prFixItems = await listQueuedPrFixItems( + asPrFixQueueClient(prisma), + { lane: prFixLane }, + ); + + const filteredIssues = issues.filter( + (issue) => !leasedIssueIds.includes(issue.id), + ); + + const queue = buildAgentQueue( + filteredIssues.map((issue) => ({ + ...issue, + lane: issue.currentLane ?? undefined, + issueId: issue.id, + repoFullName: issue.repository.fullName, + linkedPrHealth: { + number: issue.linkedPrNumber, + url: issue.linkedPrUrl, + needsFollowup: issue.linkedPrNeedsFollowup, + followupReasons: issue.linkedPrFollowupReasons, + reviewDecision: issue.linkedPrReviewDecision, + mergeState: issue.linkedPrMergeState, + checkedAt: issue.linkedPrHealthCheckedAt?.toISOString() ?? null, + }, + })), + agentName, + { + lane: issueLane, + excludeDecomposed: excludeDecomposed === "true", + includeClaimed, + includeRenovate, + excludedLabels: parseExcludedLabels(process.env.DISPATCH_EXCLUDED_LABELS), + }, + ); + + const prFixQueueItems = prFixItems.map(toAgentQueuePrFixItem); + + if (prFixQueueItems.length > 0) { + const first = prFixQueueItems[0]; + const reasons = [ + ...new Set([first.reason, ...first.feedback].filter(Boolean)), + ]; + const task = createFollowupPrTask({ + agentName, + lane: first.lane ?? undefined, + pullRequest: { + repoFullName: first.repo, + number: first.pr, + url: first.url ?? undefined, + }, + issue: first.issue + ? { repoFullName: first.repo, number: first.issue } + : undefined, + reasons, + }); + return NextResponse.json(task); + } + + if (queue.length > 0) { + const first = queue[0]; + const task = createImplementTask({ + agentName, + lane: first.lane ?? undefined, + issue: { + repoFullName: first.repoFullName ?? "", + number: first.number, + title: first.title, + url: first.url, + }, + }); + return NextResponse.json(task); + } + + return NextResponse.json(createIdleTask("No work available")); + } catch (error) { + console.error("Failed to fetch next task:", error); + return NextResponse.json( + { error: "Failed to fetch next task" }, + { status: 500 }, + ); + } +}