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
12 changes: 4 additions & 8 deletions docs/worker-execution-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,19 +199,14 @@ BACKLOG issues are excluded from the normal agent queue by default.

### Renovate Issue Exclusion

Renovate-created issues (dependency dashboards, update PRs, etc.) are **visible in Dispatch** but **excluded from agent queues by default**. This prevents agents from consuming cycles on dependency bookkeeping instead of normal issue work.
Renovate-created issues (dependency dashboards, update PRs, etc.) are **filtered out of Dispatch issue surfaces**. This keeps the Board, Projects, lane summaries, grooming intake, and agent queues focused on normal issue work instead of dependency bookkeeping.

Detection heuristics (author detection is not available since the Issue model does not store author):
- Title contains `Dependency Dashboard`
- Title starts with `Update dependency`, `Update image`, or similar Renovate patterns
- Labels: `renovate`, `dependencies`, `automated`

To explicitly include Renovate issues in queue results, pass `includeRenovate=true`:
```bash
GET /api/agents/<name>/queue?lane=normal&includeRenovate=true
```

Renovate exclusion applies to issue queue items only, not PR review-fix queue items. Issues excluded as Renovate remain visible on the Board and Projects pages.
Renovate exclusion applies to issue queue items only, not PR review-fix queue items.

---

Expand All @@ -230,6 +225,7 @@ Workers using the canonical `next-task` endpoint automatically receive PR-fix it
## History

- **2026-05-16** — Created to document generic worker execution contract and PR completion gates (Issue #65). Consolidates existing normal-worker behavior into a reusable, agent-agnostic specification.
- **2026-05-19** — Added Renovate issue exclusion section: Renovate issues are visible in Dispatch but excluded from agent queues by default; `includeRenovate=true` opt-in available (Issue #129).
- **2026-06-19** — Updated Renovate issue exclusion: Renovate issues are filtered from Dispatch issue surfaces, including Board, Projects, lane summaries, grooming intake, and agent queues.
- **2026-05-19** — Added Renovate issue exclusion section: Renovate issues are excluded from agent queues by default (Issue #129).
- **2026-05-19** — Added five-column workflow with Ready status (Issue #140): Backlog → Ready → In Progress → In Review → Done. Agents pick from Ready by default; Backlog excluded unless explicitly requested.
- **2026-05-20** — Marked `lane=gpt` as deprecated compatibility alias in canonical docs; linked openclaw-agent-mc-workflow.md as historical (Issue #117).
9 changes: 4 additions & 5 deletions src/app/agents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Table, TableBody, TableCell, TableHead, TableRow } from "@/components/u
import { Badge } from "@/components/ui/badge";
import { AGENT_PREFIX } from "@/types";
import AgentWorkPanel from "@/app/agents/agent-work-panel";
import { applyRenovateIssueExclusion } from "@/lib/issue-filters";

export const dynamic = "force-dynamic";

Expand All @@ -25,14 +26,12 @@ interface AgentStats {
}

async function getAgentStats() {
const issues = await prisma.issue.findMany({
where: { state: "open", repository: { enabled: true } },
});

const agentMap: Record<string, AgentStats> = {};
const where: Record<string, unknown> = { repository: { enabled: true } };
applyRenovateIssueExclusion(where);

const agentIssues = await prisma.issue.findMany({
where: { repository: { enabled: true } },
where,
select: { labels: true },
});

Expand Down
12 changes: 8 additions & 4 deletions src/app/api/agents/[agentName]/next-task/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { isBacklogLane, getBacklogLane } from "@/lib/lane-config";
import { isRenovateIssue } from "@/lib/agent-queue";
import { fetchAgentQueueData } from "@/lib/agent-queue-fetch";
import { applyRenovateIssueExclusion } from "@/lib/issue-filters";

export async function GET(
request: Request,
Expand All @@ -31,11 +32,14 @@ export async function GET(
try {
// Groom mode: return exactly one issue to triage/enrich
if (mode === "groom") {
const issueWhere: Record<string, unknown> = {
state: "open",
repository: { enabled: true },
};
applyRenovateIssueExclusion(issueWhere);

const issues = await prisma.issue.findMany({
where: {
state: "open",
repository: { enabled: true },
},
where: issueWhere,
select: {
id: true,
number: true,
Expand Down
12 changes: 8 additions & 4 deletions src/app/api/agents/[agentName]/work-summary/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { authorizeRequest } from "@/lib/auth";
import { prisma, asPrFixQueueClient } from "@/lib/prisma";
import { listQueuedPrFixItems } from "@/lib/pr-fix-queue";
import { getConfiguredLanes, getDefaultClaimableLane, resolveLaneId } from "@/lib/lane-config";
import { applyRenovateIssueExclusion } from "@/lib/issue-filters";

type WorkSummaryLaneCounts = { queued: number; inProgress: number };
type PrFixLaneCounts = { total: number; blocked: number };
Expand Down Expand Up @@ -32,11 +33,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen
}

try {
const issueWhere: Record<string, unknown> = {
state: "open",
repository: { enabled: true },
};
applyRenovateIssueExclusion(issueWhere);

const issues = await prisma.issue.findMany({
where: {
state: "open",
repository: { enabled: true },
},
where: issueWhere,
select: {
labels: true,
currentLane: true,
Expand Down
58 changes: 44 additions & 14 deletions src/app/api/issues/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,32 +172,45 @@ describe("GET /api/issues — visible issue filtering", () => {
await makeRequest("http://localhost/api/issues?untriaged=true");

const call = mocks.findManyIssues.mock.calls[0][0];
expect(call.where.labels).toBeDefined();
expect(call.where.labels.hasNone).toBeDefined();
expect(call.where.labels.hasNone).toContain("status/backlog");
expect(call.where.labels.hasNone).toContain("status/ready");
expect(call.where.labels.hasNone).toContain("status/in-progress");
expect(call.where.labels.hasNone).toContain("status/in-review");
expect(call.where.labels.hasNone).toContain("status/done");
expect(call.where.AND).toEqual(
expect.arrayContaining([
expect.objectContaining({
NOT: { labels: { hasSome: expect.arrayContaining([
"status/backlog",
"status/ready",
"status/in-progress",
"status/in-review",
"status/done",
]) } },
}),
]),
);
});

it("does not add hasNone filter when untriaged is not true", async () => {
it("does not add no-status filter when untriaged is not true", async () => {
await makeRequest("http://localhost/api/issues?untriaged=false");

const call = mocks.findManyIssues.mock.calls[0][0];
// labels may have other filters but should not have hasNone from noStatus
if (call.where.labels) {
expect(call.where.labels.hasNone).toBeUndefined();
}
const andClauses = call.where.AND ?? [];
expect(andClauses).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ NOT: { labels: { hasSome: expect.arrayContaining(["status/ready"]) } } }),
]),
);
});

it("combines untriaged filter with agent filter", async () => {
await makeRequest("http://localhost/api/issues?untriaged=true&agent=agent/alpha");

const call = mocks.findManyIssues.mock.calls[0][0];
expect(call.where.labels.has).toBe("agent/alpha");
expect(call.where.labels.hasNone).toBeDefined();
expect(call.where.labels.hasNone).toContain("status/ready");
expect(call.where.AND).toEqual(
expect.arrayContaining([
expect.objectContaining({
NOT: { labels: { hasSome: expect.arrayContaining(["status/ready"]) } },
}),
]),
);
});

it("orders by updatedAt descending", async () => {
Expand Down Expand Up @@ -268,6 +281,23 @@ describe("GET /api/issues — visible issue filtering", () => {
const call = mocks.findManyIssues.mock.calls[0][0];
expect(call.where.currentLane).toEqual({ in: ["backlog"] });
});

it("excludes Renovate issues from API results", async () => {
await makeRequest("http://localhost/api/issues");

const call = mocks.findManyIssues.mock.calls[0][0];
expect(call.where.AND).toEqual(
expect.arrayContaining([
expect.objectContaining({
NOT: expect.objectContaining({
OR: expect.arrayContaining([
{ labels: { hasSome: ["renovate", "dependencies", "automated"] } },
]),
}),
}),
]),
);
});
});

describe("GET /api/issues — lane aliases", () => {
Expand Down
17 changes: 12 additions & 5 deletions src/app/api/issues/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { NextResponse } from "next/server";
import { authorizeRequest } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { buildLabelWhere, buildVisibleIssueWhere, toProjectLabel, buildExcludedLabelWhere, buildNoStatusWhere } from "@/lib/issue-filters";
import {
appendIssueWhere,
applyRenovateIssueExclusion,
buildExcludedLabelWhere,
buildLabelWhere,
buildNoStatusWhere,
buildVisibleIssueWhere,
toProjectLabel,
} from "@/lib/issue-filters";
import { parseExcludedLabels } from "@/lib/config";
import { isValidLane, getLaneIds, resolveRequestLane, getLaneAliases } from "@/lib/lane-config";

Expand All @@ -25,6 +33,7 @@ export async function GET(request: Request) {
const where: Record<string, unknown> = { repository: { enabled: true } };

buildVisibleIssueWhere(where, { includeClosed: includeClosed === "true" });
applyRenovateIssueExclusion(where);

if (repo) {
where.repository = { ...(where.repository as object), fullName: repo };
Expand All @@ -41,13 +50,11 @@ export async function GET(request: Request) {

const excludedLabels = parseExcludedLabels(process.env.DISPATCH_EXCLUDED_LABELS);
const excludedLabelFilter = buildExcludedLabelWhere(excludedLabels);
if (excludedLabelFilter) {
where.labels = { ...(where.labels as object), ...excludedLabelFilter };
}
appendIssueWhere(where, excludedLabelFilter);

// Filter for untriaged issues (no status/* label) — grooming intake
const noStatusFilter = buildNoStatusWhere(untriaged === "true");
if (noStatusFilter) where.labels = { ...(where.labels as object), ...noStatusFilter };
appendIssueWhere(where, noStatusFilter);

// Filter by execution lane
if (lane) {
Expand Down
Loading