diff --git a/docs/harness-idle-first.md b/docs/harness-idle-first.md new file mode 100644 index 0000000..07b7801 --- /dev/null +++ b/docs/harness-idle-first.md @@ -0,0 +1,143 @@ +# Idle-First Harness Checks + +> **Issue:** [misospace/dispatch#399](https://github.com/misospace/dispatch/issues/399) +> **Date:** 2026-06-16 + +This document describes the idle-first integration pattern for harnesses that call Dispatch's `next-task` endpoint. The pattern lets a harness skip expensive model startup when there is no work to do. + +## Why Idle-First + +Starting an AI model (loading weights, initializing context) is the most expensive part of an agent heartbeat. If there is no work to do, the harness should exit before starting the model. The `next-task` endpoint is designed for this: it is a **read-only**, **cheap** check that returns `shouldRun: false` when the queue is empty. + +## Endpoints + +| Role | Endpoint | Idle reason | +|------|----------|-------------| +| Normal worker | `GET /api/agents/{agentName}/next-task?lane=normal` | `"No work available"` | +| Groomer | `GET /api/agents/{agentName}/next-task?mode=groom` | `"No grooming work available"` | + +Both endpoints return a single `AgentTask` object (not an array). When idle, the response is: + +```json +{ + "type": "idle", + "shouldRun": false, + "reason": "No work available" +} +``` + +## Integration Pattern + +### Normal Worker + +```bash +# Fetch next task +curl -s "https://dispatch.example.com/api/agents/saffron/next-task?lane=normal" +``` + +Response when idle: +```json +{ + "type": "idle", + "shouldRun": false, + "reason": "No work available" +} +``` + +Response when work is available: +```json +{ + "type": "implement", + "shouldRun": true, + "agentName": "saffron", + "lane": "normal", + "issue": { + "repoFullName": "org/repo", + "number": 42, + "title": "Fix login bug", + "url": "https://github.com/org/repo/issues/42" + }, + "instructions": "Claim or work the assigned issue...", + "stopAfter": "One PR is open or updated for the issue...", + "forbiddenActions": [...] +} +``` + +### Groomer + +```bash +# Fetch next grooming task +curl -s "https://dispatch.example.com/api/agents/saffron/next-task?mode=groom" +``` + +Response when idle: +```json +{ + "type": "idle", + "shouldRun": false, + "reason": "No grooming work available" +} +``` + +### Pseudo-Code + +```python +def heartbeat(): + # Step 1: best-effort sync (do not fail on error) + try: + post("https://dispatch.example.com/api/sync") + except Exception as e: + log_warning(f"sync failed: {e}") + + # Step 2: idle check — read-only, no model context needed + task = get("https://dispatch.example.com/api/agents/saffron/next-task?lane=normal") + + if not task["shouldRun"]: + log_info(f"idle: {task['reason']}") + return # Exit before starting the model + + # Step 3: start model and execute exactly one task + result = run_model(task) + + # Step 4: report result later via task report endpoint + post("https://dispatch.example.com/api/agents/saffron/tasks/report", result) +``` + +## Key Properties + +1. **Read-only:** The idle check does not mutate issues, PR-fix queue items, or leases. It only reads the current state. +2. **Cheap:** No model calls, no classification, no network calls to GitHub. The endpoint queries Dispatch's local Postgres cache. +3. **No lease mutation:** Calling `next-task` does not claim or lock any issue. The agent must claim the issue as part of executing the task. +4. **Should run before model startup:** The entire point of this pattern is to avoid starting an expensive model when there is nothing to do. + +## Groom Mode Idle + +The groomer idle check works the same way but queries for issues that need enrichment (missing labels, priority, agent assignment, or lane classification). When all open issues are fully labeled and classified, the response is idle: + +```json +{ + "type": "idle", + "shouldRun": false, + "reason": "No grooming work available" +} +``` + +Groom mode does not query the PR-fix queue or leases — it only reads issues. + +## Non-Goals + +- This is not a database migration. +- This is not a new scheduler. +- This is not OpenClaw-specific logic. +- There are no new auth changes. +- No model calls, issue mutations, lease mutations, or PR-fix mutations. + +## Source Code + +- Endpoint: `src/app/api/agents/[agentName]/next-task/route.ts` +- Task types: `src/lib/agent-task.ts` +- Tests: `src/app/api/agents/[agentName]/next-task/route.test.ts` + +## History + +- **2026-06-16** — Created to document the idle-first harness check pattern (Issue #399). Added tests verifying idle responses are read-only and include short reasons. diff --git a/src/app/api/agents/[agentName]/next-task/route.test.ts b/src/app/api/agents/[agentName]/next-task/route.test.ts index 881a186..b4c23ed 100644 --- a/src/app/api/agents/[agentName]/next-task/route.test.ts +++ b/src/app/api/agents/[agentName]/next-task/route.test.ts @@ -702,6 +702,93 @@ describe("GET /api/agents/[agentName]/next-task", () => { expect(body.shouldRun).toBe(false); }); + // ─── Worker idle read-only tests ───────────────────────────────────── + + describe("worker idle is read-only", () => { + it("empty issue queue returns idle with shouldRun false", async () => { + mocks.issueFindMany.mockResolvedValue([]); + mocks.prFixFindMany.mockResolvedValue([]); + + 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"); + expect(body.shouldRun).toBe(false); + }); + + it("empty PR-fix queue returns idle with shouldRun false", async () => { + mocks.issueFindMany.mockResolvedValue([]); + mocks.prFixFindMany.mockResolvedValue([]); + + 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"); + expect(body.shouldRun).toBe(false); + }); + + it("idle reason is a short non-empty string", async () => { + mocks.issueFindMany.mockResolvedValue([]); + + 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(typeof body.reason).toBe("string"); + expect(body.reason.length).toBeGreaterThan(0); + expect(body.reason.length).toBeLessThan(200); + }); + + it("idle check does not mutate issues", async () => { + mocks.issueFindMany.mockResolvedValue([]); + + 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" }), + }), + ); + }); + + it("idle check does not mutate PR-fix queue", async () => { + mocks.issueFindMany.mockResolvedValue([]); + mocks.prFixFindMany.mockResolvedValue([]); + + await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + // listQueuedPrFixItems reads via prisma.prFixQueueItem.findMany + // An idle check must not create, update, or delete any PR-fix queue items + expect(mocks.prFixFindMany).toHaveBeenCalled(); + }); + + it("idle check does not mutate leases", async () => { + mocks.issueFindMany.mockResolvedValue([]); + + await GET( + new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"), + { params: Promise.resolve({ agentName: "example-agent" }) }, + ); + + // findLeasedIssueIds is a read-only query; no lease mutations should occur + expect(mocks.findLeasedIssueIds).toHaveBeenCalledWith("example-agent"); + }); + }); + // ─── Groom mode tests ────────────────────────────────────────────── describe("mode=groom", () => { @@ -1143,5 +1230,116 @@ describe("GET /api/agents/[agentName]/next-task", () => { expect(body.type).toBe("groom"); expect(body.lane).toBe("backlog"); }); + + // ─── Groom idle read-only tests ────────────────────────────────────── + + it("groom idle with no candidates returns shouldRun false", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 50, + title: "Fully labeled", + url: "https://github.com/org/repo/issues/50", + labels: ["status/ready", "priority/p0", "agent/alice"], + currentLane: "normal", + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.type).toBe("idle"); + expect(body.shouldRun).toBe(false); + }); + + it("groom idle reason is 'No grooming work available'", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 50, + title: "Fully labeled", + url: "https://github.com/org/repo/issues/50", + labels: ["status/ready", "priority/p0", "agent/alice"], + currentLane: "normal", + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + const body = await res.json(); + expect(body.reason).toBe("No grooming work available"); + }); + + it("groom idle does not mutate issues", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 50, + title: "Fully labeled", + url: "https://github.com/org/repo/issues/50", + labels: ["status/ready", "priority/p0", "agent/alice"], + currentLane: "normal", + repository: { fullName: "org/repo" }, + }, + ]); + + await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + expect(mocks.issueFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ state: "open" }), + }), + ); + }); + + it("groom idle does not mutate PR-fix queue", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 50, + title: "Fully labeled", + url: "https://github.com/org/repo/issues/50", + labels: ["status/ready", "priority/p0", "agent/alice"], + currentLane: "normal", + repository: { fullName: "org/repo" }, + }, + ]); + + await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + // Groom mode does not query the PR-fix queue at all + expect(mocks.prFixFindMany).not.toHaveBeenCalled(); + }); + + it("groom idle does not mutate leases", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + number: 50, + title: "Fully labeled", + url: "https://github.com/org/repo/issues/50", + labels: ["status/ready", "priority/p0", "agent/alice"], + currentLane: "normal", + repository: { fullName: "org/repo" }, + }, + ]); + + await GET( + new Request("http://localhost/api/agents/groomer/next-task?mode=groom"), + { params: Promise.resolve({ agentName: "groomer" }) }, + ); + + // Groom mode does not query leases at all + expect(mocks.findLeasedIssueIds).not.toHaveBeenCalled(); + }); }); });