Skip to content
Open
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
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,25 @@ GITHUB_REPOSITORIES="myorg/myrepo1,myorg/myrepo2"
# GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"

# Agent API authentication
#
# Bearer tokens come in two tiers. The maintainer tier keeps full rights
# (force claims, releasing other agents' claims, PR-fix queue requeue and
# QUEUED/IGNORED marks, groomer, lanes, admission overrides, automation,
# syncs); the worker tier is an explicit allowlist for autonomous executors
# (next-task, tasks/report, heartbeat, active-work, queue, work-summary,
# agent-work start/checkpoint/finish, non-force claim and unclaim,
# issue state/status, PR-fix queue reads and FIXED/BLOCKED/STALE marks).
# A worker token calling a maintainer route gets a 403 naming the required
# tier. See "Token Tiers" in docs/worker-execution-contract.md.
#
# DISPATCH_AGENT_TOKEN is the maintainer-tier token.
DISPATCH_AGENT_TOKEN="your_agent_token_here"
# Optional alias for the maintainer-tier token (same rights as
# DISPATCH_AGENT_TOKEN).
# DISPATCH_MAINTAINER_TOKEN="your_maintainer_token_here"
# Worker-tier bearer token for autonomous executors (e.g. worker harnesses).
# Unset by default; use a different value from DISPATCH_AGENT_TOKEN.
# DISPATCH_WORKER_TOKEN="your_worker_token_here"

# Operator / UI authentication (optional)
#
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ npm run db:deploy # Deploy migrations (prod)
|----------|----------|-------------|
| `DATABASE_URL` | Yes | PostgreSQL connection string (canonical) |
| `GITHUB_TOKEN` | Yes | GitHub Personal Access Token |
| `DISPATCH_AGENT_TOKEN` | Yes | Bearer token for agent API |
| `DISPATCH_AGENT_TOKEN` | Yes | Bearer token for agent API (maintainer tier — full rights) |
| `DISPATCH_MAINTAINER_TOKEN` | No | Optional alias for the maintainer-tier bearer token (same rights as `DISPATCH_AGENT_TOKEN`) |
| `DISPATCH_WORKER_TOKEN` | No | Worker-tier bearer token for autonomous executors (explicit allowlist: next-task, tasks/report, heartbeat, active-work, queue, work-summary, agent-work start/checkpoint/finish + GET listing, non-force claim / unclaim (assignment-scoped), issue state/status, PR-fix queue reads + FIXED/BLOCKED/STALE marks); agent-work operator release/reassign and sweep stay maintainer-only; other routes return 403 |
| `GITHUB_REPOSITORIES` | Yes | **One-time** bootstrap seed for tracked repos (comma or newline separated). Read only when `AutomationRepo` is empty. After first seed, manage via `/automation` UI or `POST /api/repos` / `POST /api/automation/repos`. Seeded repos carry `source: "env"`; UI-added repos carry `source: "user"`. |
| `DISPATCH_URL` | No | Base URL of your Dispatch instance (used by outbound clients and MCP bridge) |
| `DISPATCH_DATABASE_URL` | No | Alternative database URL alias — used if `DATABASE_URL` is not set |
Expand Down
2 changes: 2 additions & 0 deletions docs/generic-harness-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def worker_heartbeat(agent_name, dispatch_url):
# Stop
```

Autonomous workers may authenticate the loop above with `DISPATCH_WORKER_TOKEN` (worker tier); operator and MCP-bridge agents keep the maintainer token (`DISPATCH_AGENT_TOKEN`).

**Optional preflight sync:** Agents may call `POST /api/sync` before fetching their next task to refresh Dispatch's issue cache. This is a best-effort, out-of-band operation — not required for the worker loop and not something agents depend on before every task. Sync failures should be logged as freshness warnings and must not block task execution.

## Generic Groomer Loop
Expand Down
2 changes: 1 addition & 1 deletion docs/opencode-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ npx prisma generate
| `DISPATCH_AGENT_TOKEN` | Yes | Bearer token for agent API authentication |
| `DISPATCH_AGENT_NAME` | No | Default agent identity used when MCP tools omit `agentName`. Set this to a stable operator identity such as `jory-opencode` for manual OpenCode usage. **Do not use generic identities like `Dispatch MCP`.** |

The token is **never** printed or logged. Missing variables produce a clear error on startup.
The token is **never** printed or logged. Missing variables produce a clear error on startup. The MCP bridge is an operator/bridge client, so it keeps the maintainer token; autonomous workers can use `DISPATCH_WORKER_TOKEN` (worker tier) for their own loop.

## Running the Server

Expand Down
17 changes: 17 additions & 0 deletions docs/worker-execution-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This document defines the generic execution contract for any agent worker consum

## Table of Contents

- [Token Tiers](#token-tiers)
- [One Item Per Run](#one-item-per-run)
- [PR Fix Queue Precedence](#pr-fix-queue-precedence)
- [Duplicate PR Avoidance](#duplicate-pr-avoidance)
Expand All @@ -18,6 +19,21 @@ This document defines the generic execution contract for any agent worker consum

---

## Token Tiers

Dispatch bearer tokens have two tiers. A **worker** token (`DISPATCH_WORKER_TOKEN`) may call exactly:

- `GET /api/agents/{agentName}/next-task`, `POST /api/agents/{agentName}/tasks/report`, `POST /api/agents/{agentName}/heartbeat`, `GET /api/agents/{agentName}/active-work`, `GET /api/agents/{agentName}/queue`, `GET /api/agents/{agentName}/work-summary`
- `GET /api/agent-work`, `POST /api/agent-work/start`, `POST /api/agent-work/checkpoint`, `POST /api/agent-work/finish`
- `POST /api/issues/claim` (without `force`) and `POST /api/issues/unclaim` (bounded by the assignment check — the issue must be assigned to the `agentName` in the request body)
- `GET /api/issues/state`, `POST /api/issues/status`
Comment thread
itsmiso-ai marked this conversation as resolved.
- `GET /api/issues`, `GET /api/pr-fix-queue/queued`, `GET /api/pr-fix-queue/history`
- `POST /api/pr-fix-queue/mark` with `FIXED`, `BLOCKED`, or `STALE` (generation required, as today)

The **maintainer** token (`DISPATCH_AGENT_TOKEN`, or the `DISPATCH_MAINTAINER_TOKEN` alias) keeps full rights. A worker token calling a maintainer-only route gets an HTTP 403 naming the required tier: force claims, `QUEUED`/`IGNORED` marks, and `POST /api/pr-fix-queue/requeue` are maintainer-only. Unclaim is not tier-gated — the target agent comes from the request body and is only bounded by the assignment check; cryptographic token→agent-name binding is a known follow-up.

---

## One Item Per Run

A worker must handle **exactly one** queue item per execution:
Expand Down Expand Up @@ -224,6 +240,7 @@ Workers using the canonical `next-task` endpoint automatically receive PR-fix it

## History

- **2026-09-28** — Added Token Tiers section: worker-tier endpoint allowlist and maintainer-only actions (Issue #1111).
- **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-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).
Expand Down
9 changes: 5 additions & 4 deletions src/app/api/agent-runs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { errorResponse, handleApiError } from "@/lib/api-errors";
import { prisma } from "@/lib/prisma";
import { isValidEscalatedOutcome, VALID_ESCALATED_OUTCOMES } from "@/types";
import { authorizeRequest } from "@/lib/auth";
import { authorizeRequest, authErrorResponse } from "@/lib/auth";
import { enforceRateLimit } from "@/lib/rate-limit";

// Generous per-actor rate limit — agents report runs frequently, so this is
Expand All @@ -15,8 +15,9 @@ const RATE_LIMIT = { limit: 120, windowMs: 60_000 };
const TOUCHED_ISSUE_URL_PATTERN = /^https?:\/\//;

export async function GET(request: Request) {
if (!(await authorizeRequest(request)).authorized) {
return errorResponse("Unauthorized", 401);
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return authErrorResponse(auth);
}
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get("limit") || "50");
Expand All @@ -35,7 +36,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return errorResponse("Unauthorized", 401);
return authErrorResponse(auth);
}

const limited = enforceRateLimit(`agent-runs:${auth.actor}`, RATE_LIMIT);
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/agent-work/checkpoint/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { errorResponse, handleApiError } from "@/lib/api-errors";
import { prisma, asAgentWorkClient } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { authorizeRequest, authErrorResponse } from "@/lib/auth";
import { parseCheckpointAgentWorkInput, checkpointAgentWork } from "@/lib/agent-work";

/**
Expand Down Expand Up @@ -52,8 +52,9 @@ import { parseCheckpointAgentWorkInput, checkpointAgentWork } from "@/lib/agent-
* { "error": "Failed to checkpoint agent work" }
*/
export async function POST(request: Request) {
if (!(await authorizeRequest(request)).authorized) {
return errorResponse("Unauthorized", 401);
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return authErrorResponse(auth);
}

try {
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/agent-work/finish/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { errorResponse, handleApiError } from "@/lib/api-errors";
import { prisma, asAgentWorkClient } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { authorizeRequest, authErrorResponse } from "@/lib/auth";
import { parseFinishAgentWorkInput, finishAgentWork } from "@/lib/agent-work";

/**
Expand Down Expand Up @@ -47,8 +47,9 @@ import { parseFinishAgentWorkInput, finishAgentWork } from "@/lib/agent-work";
* { "error": "Failed to finish agent work" }
*/
export async function POST(request: Request) {
if (!(await authorizeRequest(request)).authorized) {
return errorResponse("Unauthorized", 401);
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return authErrorResponse(auth);
}

try {
Expand Down
10 changes: 8 additions & 2 deletions src/app/api/agent-work/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { makeDispatchEnvMock } from "@/test/route-helpers";

vi.mock("@/lib/auth", () => ({
authorizeRequest: vi.fn(),
authErrorResponse: vi.fn((auth: { forbidden?: boolean }) =>
new Response(JSON.stringify({ error: auth.forbidden ? "Forbidden" : "Unauthorized" }), {
status: auth.forbidden ? 403 : 401,
headers: { "content-type": "application/json" },
}),
),
}));

const mockAgentWork = {
Expand Down Expand Up @@ -108,7 +114,7 @@ function makeGetRequest(url: string) {
describe("GET /api/agent-work", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAuthorizeRequest.mockResolvedValue({ authorized: true, type: "disabled", actor: "test-agent" });
mockAuthorizeRequest.mockResolvedValue({ authorized: true, type: "disabled", actor: "test-agent", tier: "maintainer" });
agentWork.findMany.mockResolvedValue([]);
lease.findMany.mockResolvedValue([]);
});
Expand Down Expand Up @@ -727,7 +733,7 @@ describe("POST /api/agent-work", () => {
describe("POST auth", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAuthorizeRequest.mockResolvedValue({ authorized: true, type: "disabled", actor: "test-agent" });
mockAuthorizeRequest.mockResolvedValue({ authorized: true, type: "disabled", actor: "test-agent", tier: "maintainer" });
});

it("returns 401 when token is invalid", async () => {
Expand Down
6 changes: 3 additions & 3 deletions src/app/api/agent-work/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { errorResponse, handleApiError } from "@/lib/api-errors";
import { prisma } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { authorizeRequest, authErrorResponse } from "@/lib/auth";
import { releaseLeaseByAgentAndIssue, releaseAllLeasesByAgent, releaseAgentWorkByAgentAndIssue } from "@/lib/lease";
import { enforceRateLimit } from "@/lib/rate-limit";

Expand Down Expand Up @@ -52,7 +52,7 @@ function toItem(w: any): AgentWorkItem {
export async function GET(request: Request) {
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return errorResponse("Unauthorized", 401);
return authErrorResponse(auth);
}

const { searchParams } = new URL(request.url);
Expand Down Expand Up @@ -129,7 +129,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return errorResponse("Unauthorized", 401);
return authErrorResponse(auth);
}

const limited = enforceRateLimit(`agent-work:${auth.actor}`, { limit: 30, windowMs: 10_000 });
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/agent-work/start/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { errorResponse, handleApiError } from "@/lib/api-errors";
import { prisma, asAgentWorkClient } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { authorizeRequest, authErrorResponse } from "@/lib/auth";
import { parseStartAgentWorkInput, startAgentWork } from "@/lib/agent-work";
import { enforceRateLimit } from "@/lib/rate-limit";

Expand Down Expand Up @@ -49,7 +49,7 @@ const RATE_LIMIT = { limit: 30, windowMs: 10_000 } as const;
export async function POST(request: Request) {
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return errorResponse("Unauthorized", 401);
return authErrorResponse(auth);
}

const limited = enforceRateLimit(`route:agent-work/start:${auth.actor}`, RATE_LIMIT);
Expand Down
10 changes: 9 additions & 1 deletion src/app/api/agent-work/sweep/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@ const mocks = vi.hoisted(() => ({
sweepStaleWork: vi.fn(),
}));

vi.mock("@/lib/auth", () => ({ authorizeRequest: mocks.authorizeRequest }));
vi.mock("@/lib/auth", () => ({
authorizeRequest: mocks.authorizeRequest,
authErrorResponse: vi.fn((auth: { forbidden?: boolean }) =>
new Response(JSON.stringify({ error: auth.forbidden ? "Forbidden" : "Unauthorized" }), {
status: auth.forbidden ? 403 : 401,
headers: { "content-type": "application/json" },
}),
),
}));
vi.mock("@/lib/dispatch-env", () => makeDispatchEnvMock());
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/sync-lock", () => ({
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/agent-work/sweep/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { authorizeRequest } from "@/lib/auth";
import { authorizeRequest, authErrorResponse } from "@/lib/auth";
import { errorResponse } from "@/lib/api-errors";
import { prisma } from "@/lib/prisma";
import { acquireLock, releaseLock } from "@/lib/sync-lock";
Expand All @@ -12,7 +12,7 @@ const BATCH_SIZE = DEFAULT_STALE_WORK_BATCH_SIZE;
export async function POST(request: Request) {
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return errorResponse("Unauthorized", 401);
return authErrorResponse(auth);
}

const lock = await acquireLock("stale-work");
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/agents/[agentName]/active-work/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ const { mocks } = vi.hoisted(() => ({

vi.mock("@/lib/auth", () => ({
authorizeRequest: vi.fn(),
authErrorResponse: vi.fn((auth: { forbidden?: boolean }) =>
new Response(JSON.stringify({ error: auth.forbidden ? "Forbidden" : "Unauthorized" }), {
status: auth.forbidden ? 403 : 401,
headers: { "content-type": "application/json" },
}),
),
}));

vi.mock("@/lib/prisma", () => ({
Expand Down Expand Up @@ -47,7 +53,7 @@ function makeActiveWorkRequest(agentName: string) {
describe("GET /api/agents/:agentName/active-work", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAuthorizeRequest.mockResolvedValue({ authorized: true, type: "disabled", actor: "test-agent" });
mockAuthorizeRequest.mockResolvedValue({ authorized: true, type: "disabled", actor: "test-agent", tier: "maintainer" });
// Default: return the same lease for both findFirst calls (resolveActiveWork and leaseId fetch)
mocks.leaseFindFirst.mockResolvedValue({
id: "l-1",
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/agents/[agentName]/active-work/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { NextResponse } from "next/server";
import { errorResponse, handleApiError } from "@/lib/api-errors";
import { resolveActiveWork } from "@/lib/lease";
import type { ActiveWorkResult } from "@/lib/next-action";
import { authorizeRequest } from "@/lib/auth";
import { authorizeRequest, authErrorResponse } from "@/lib/auth";

export async function GET(request: Request, { params }: { params: Promise<{ agentName: string }> }) {
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return errorResponse("Unauthorized", 401);
return authErrorResponse(auth);
}

const { agentName } = await params;
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/agents/[agentName]/heartbeat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { NextResponse } from "next/server";
import { errorResponse } from "@/lib/api-errors";
import { prisma } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { authorizeRequest, authErrorResponse } from "@/lib/auth";
import { runSyncBestEffort, runReconcileBestEffort } from "@/lib/heartbeat";

export type AgentHeartbeatResponse = {
Expand All @@ -39,8 +39,9 @@ export async function POST(
const { agentName } = await params;

// Authenticate
if (!(await authorizeRequest(request)).authorized) {
return errorResponse("Unauthorized", 401);
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return authErrorResponse(auth);
}

const startedAt = new Date();
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/agents/[agentName]/next-task/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { errorResponse, handleApiError } from "@/lib/api-errors";
import { authorizeRequest } from "@/lib/auth";
import { authorizeRequest, authErrorResponse } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import {
createIdleTask,
Expand All @@ -18,8 +18,9 @@ export async function GET(
) {
const { agentName } = await params;

if (!(await authorizeRequest(request)).authorized) {
return errorResponse("Unauthorized", 401);
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return authErrorResponse(auth);
}

const { searchParams } = new URL(request.url);
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/agents/[agentName]/queue/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { NextResponse } from "next/server";
import { errorResponse, handleApiError } from "@/lib/api-errors";
import { authorizeRequest } from "@/lib/auth";
import { authorizeRequest, authErrorResponse } from "@/lib/auth";
import { fetchAgentQueueData } from "@/lib/agent-queue-fetch";

export async function GET(request: Request, { params }: { params: Promise<{ agentName: string }> }) {
const { agentName } = await params;

if (!(await authorizeRequest(request)).authorized) {
return errorResponse("Unauthorized", 401);
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return authErrorResponse(auth);
}

const { searchParams } = new URL(request.url);
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/agents/[agentName]/tasks/report/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
import { Prisma } from "@prisma/client";
import { errorResponse, handleApiError } from "@/lib/api-errors";
import { prisma } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { authorizeRequest, authErrorResponse } from "@/lib/auth";
import { resolvePrFixFromAgentReport, type ResolvePrFixFromAgentReportResult } from "@/lib/pr-fix-queue";

const VALID_TASK_TYPES = ["implement", "followup-pr", "groom"] as const;
Expand Down Expand Up @@ -109,8 +109,9 @@ export async function POST(
const { agentName } = await params;

// Authenticate
if (!(await authorizeRequest(request)).authorized) {
return errorResponse("Unauthorized", 401);
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return authErrorResponse(auth);
}

let body: unknown;
Expand Down
Loading
Loading