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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ The `tasks/report` endpoint accepts these outcomes:
| `failed` | The task failed unexpectedly |
| `no_changes_needed` | No action was required |

Queue-backed `followup-pr` reports must echo the `prFixItem.{id, generation}` token issued by `next-task` on the task. Reports without it still record the AgentRun, but never settle PR-fix queue state — the item stays queued and the report is a no-op against the queue (#1074).

#### Idempotent reporting (optional)

`tasks/report` accepts an optional `idempotencyKey` (opaque string, trimmed, at most 200 characters). When present, `(agentName, idempotencyKey)` uniquely identifies the logical report: a retried request with the same key and the same payload returns the original `agentRunId` (with `duplicate: true`) without creating another `AgentRun` or re-running PR-fix resolution — the stored resolution is replayed when it has been persisted, and while the original report is still completing (or if its persistence failed) the retry receives an explicit `action: "skipped"` resolution instead; side effects are never repeated either way. Reusing the same key with a different payload is rejected with `409`, as is a claim that exists without a recorded result (only possible if the referenced run was deleted). An unexpected persistence failure returns a structured `500` — a worker that retries after it lands in the duplicate branch above. If a referenced `AgentRun` is later deleted, its claim stays behind and further retries with that key return `409`; the recovery is for the worker to proceed under a fresh key, or for an operator to delete the stale `AgentReportDedupe` row. Reports that omit the key keep the current at-least-once behavior. Workers that may crash between reporting and recording the result locally should derive the key from a durable per-run identity (for example `"<work-item-id>:<lifecycle-event>"`); Dispatch treats the value as fully opaque. Keys are persisted in Dispatch's database — do not embed secrets or personal data in them.
Expand Down
12 changes: 11 additions & 1 deletion docs/pr-review-fix-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,13 @@ Items in the `NEEDS_HUMAN` lane receive `BLOCKED` status and are excluded from t
"repo": "org/repo",
"pr": 42,
"status": "fixed",
"note": "Pushed fix and validation passed"
"note": "Pushed fix and validation passed",
"generation": 2
}
```

`generation` is **required for bearer (agent/bridge) marks** — a bearer request without it is rejected with `400` (`generation is required for agent/bridge marks (#1074)`) — and optional for operator paths (OIDC session, basic auth, disabled mode), which keep the unconditional behavior for compatibility. When supplied, the write is generation-conditional: if the item's current generation no longer matches, the request is rejected with `409` and nothing is mutated (the caller's read predates a re-issued attempt). A `FIXED` mark additionally re-reads the item's per-attempt `attemptHeadSha` baseline before writing; if the PR head has not moved since the attempt was recorded, the item is refused `FIXED` (back to `QUEUED`) — and the refusal never transiently stores `FIXED`. A mark for an unknown item returns `404`.

## Assignment queue behavior

`GET /api/agents/:agentName/queue` prepends queued PR review-fix items before ranked issue work. This preserves the worker contract: review-fix work is consumed before selecting new board work.
Expand Down Expand Up @@ -159,6 +162,13 @@ Together the pair answers "which distinct unit of PR-fix work is this". Consumer

For downstream consumers that also report results through `POST /api/agents/{agentName}/tasks/report`: reports accept an optional opaque `idempotencyKey` that makes retries safe — a retry with the same key and payload returns the original `agentRunId` (with `duplicate: true`) and never re-runs PR-fix resolution; the stored resolution is replayed when it has been persisted, and otherwise the response carries an explicit `action: "skipped"` resolution. The same key with a different payload is rejected with `409`, as is a claim whose referenced run was deleted; an unexpected persistence failure returns a structured `500` whose retry lands in the duplicate branch. Full contract: "Idempotent reporting" in AGENTS.md.

**Attempt-token settlement.** `tasks/report` accepts the attempt token as `prFixItem: { id, generation }` — the same pair `next-task` issued on the `followup-pr` task the worker was dispatched. The token is the settlement authority for the PR-fix queue:

- **Token present** → the report settles exactly that item + generation. A missing item, a report repo/PR that doesn't match the token's item, a stale generation (the attempt was re-issued after dispatch: new evidence, requeue, refused-`FIXED` rollback), or an already-settled status all produce an `action: "skipped"` resolution with no mutation. Every status write in settlement is generation-conditional, so a concurrent re-issue between the check and the write still lands as a no-op (commit-time revalidation).
- **Token absent** (legacy report) → the queue is **never** mutated: the report matches the item (so the response stays informative) but takes no action and makes no GitHub calls. A worker that was never issued an attempt can no longer settle an item.

The token is part of the report's canonical payload, so it participates in `idempotencyKey` replay semantics: a retry with the same key and a different `prFixItem` is a `409`, exactly like any other payload change.

## Status lifecycle

```
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- AlterTable: immutable per-attempt head baseline for PR-fix queue items (#1074).
-- Captured when an attempt becomes dispatchable (fresh enqueue, requeue, or a
-- mark back to QUEUED) and used by the no-progress FIXED guard as its
-- comparison baseline. Deliberately distinct from the mutable `headSha`
-- column (latest known head at enqueue time, used by the #940 reopen check):
-- the enqueue-time headSha is overwritten by every re-observation, so a
-- worker pushing to exactly that SHA made the guard compare a SHA against
-- itself and refuse a real fix. NULL (the default for pre-existing rows and
-- for fresh attempts with no head record) means "no baseline recorded" and
-- the guard accepts, matching the legacy no-record behavior.
ALTER TABLE "PrFixQueueItem" ADD COLUMN IF NOT EXISTS "attemptHeadSha" TEXT;
4 changes: 4 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ model PrFixQueueItem {
url String?
title String?
headSha String?
// Immutable head baseline for the current attempt/generation (#1074),
// captured when the attempt becomes dispatchable. The FIXED guard compares
// the PR head against this baseline, not the mutable enqueue-time headSha.
attemptHeadSha String?
author String?
history PrFixHistory[]

Expand Down
23 changes: 22 additions & 1 deletion src/app/api/agents/[agentName]/next-task/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ function makeQueueStore() {
$transaction: async (fn: any) => fn(client),
prFixQueueItem: {
findUnique: async ({ where }: any) =>
items.find((i) => i.repo === where.repo_pr.repo && i.pr === where.repo_pr.pr) ?? null,
items.find((i) =>
where.id !== undefined
? i.id === where.id
: i.repo === where.repo_pr.repo && i.pr === where.repo_pr.pr,
) ?? null,
create: async ({ data }: any) => {
const item = { id: `prfix-${++seq}`, generation: 1, queuedAt: new Date("2026-01-01T00:00:00Z"), updatedAt: new Date("2026-01-01T00:00:00Z"), ...data };
items.push(item);
Expand All @@ -69,6 +73,23 @@ function makeQueueStore() {
items[idx] = { ...items[idx], ...patch, updatedAt: new Date("2026-01-02T00:00:00Z") };
return items[idx];
},
updateMany: async ({ where, data }: any) => {
const idx = items.findIndex((i) =>
where.id !== undefined
? i.id === where.id && (where.generation === undefined || i.generation === where.generation)
: i.repo === where.repo_pr.repo && i.pr === where.repo_pr.pr,
);
if (idx === -1) return { count: 0 };
const patch: Record<string, any> = { ...data };
for (const key of Object.keys(patch)) {
const value = patch[key];
if (value && typeof value === "object" && typeof value.increment === "number") {
patch[key] = (items[idx][key] ?? 0) + value.increment;
}
}
items[idx] = { ...items[idx], ...patch, updatedAt: new Date("2026-01-02T00:00:00Z") };
return { count: 1 };
},
findMany: async ({ where }: any) => {
let result = items.slice();
if (where?.status) {
Expand Down
206 changes: 206 additions & 0 deletions src/app/api/agents/[agentName]/tasks/report/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1066,3 +1066,209 @@ describe("POST /api/agents/[agentName]/tasks/report — idempotencyKey", () => {
});
});
});

describe("POST /api/agents/[agentName]/tasks/report — prFixItem attempt token (#1074)", () => {
beforeEach(() => {
delete process.env.DISPATCH_AUTH_MODE;
resetAuthCaches();
vi.clearAllMocks();
});

const validReport = {
taskType: "followup-pr",
outcome: "pr_updated",
repoFullName: "org/repo",
pullRequestNumber: 10,
};

it("returns 200 for a report carrying a valid prFixItem token", async () => {
const res = await postRequest({
...validReport,
prFixItem: { id: "cktz-abc", generation: 2 },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.report.prFixItem).toEqual({ id: "cktz-abc", generation: 2 });
});

it("returns 400 when prFixItem is not an object", async () => {
const res = await postRequest({ ...validReport, prFixItem: "cktz-abc" });
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/prFixItem must be an object/);
});

it("returns 400 when prFixItem is an array", async () => {
const res = await postRequest({ ...validReport, prFixItem: ["cktz-abc"] });
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/prFixItem must be an object/);
});

it("returns 400 when prFixItem.id is missing", async () => {
const res = await postRequest({ ...validReport, prFixItem: { generation: 2 } });
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/prFixItem.id must be a non-empty string/);
});

it("returns 400 when prFixItem.id is empty/whitespace", async () => {
const res = await postRequest({ ...validReport, prFixItem: { id: " ", generation: 2 } });
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/prFixItem.id must be a non-empty string/);
});

it("returns 400 when prFixItem.generation is missing", async () => {
const res = await postRequest({ ...validReport, prFixItem: { id: "cktz-abc" } });
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/prFixItem.generation must be an integer >= 1/);
});

it("returns 400 when prFixItem.generation is not an integer", async () => {
const res = await postRequest({ ...validReport, prFixItem: { id: "cktz-abc", generation: 1.5 } });
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/prFixItem.generation must be an integer >= 1/);
});

it("returns 400 when prFixItem.generation is below 1", async () => {
const res = await postRequest({ ...validReport, prFixItem: { id: "cktz-abc", generation: 0 } });
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/prFixItem.generation must be an integer >= 1/);
});

it("validation failures with a bad prFixItem create no AgentRun and resolve nothing", async () => {
await postRequest({ ...validReport, prFixItem: { id: "", generation: 2 } });
expect(mockAgentRun.create).not.toHaveBeenCalled();
expect(prFixResolveMock).not.toHaveBeenCalled();
});

it("passes the attempt token through to PR-fix settlement", async () => {
const res = await postRequest({
...validReport,
prFixItem: { id: " cktz-abc ", generation: 3 },
});
expect(res.status).toBe(200);
expect(prFixResolveMock).toHaveBeenCalledTimes(1);
expect(prFixResolveMock).toHaveBeenCalledWith(
expect.objectContaining({ attempt: { itemId: "cktz-abc", generation: 3 } }),
);
});

it("passes a null attempt when no prFixItem is provided (legacy report)", async () => {
const res = await postRequest(validReport);
expect(res.status).toBe(200);
expect(prFixResolveMock).toHaveBeenCalledTimes(1);
expect(prFixResolveMock).toHaveBeenCalledWith(expect.objectContaining({ attempt: null }));
});

it("a prFixItem change alters the report payload identity (idempotency)", async () => {
const withToken = { ...validReport, idempotencyKey: "key-a", prFixItem: { id: "cktz", generation: 1 } };
const first = await postRequest(withToken);
expect(first.status).toBe(200);

// Same key, same coordinates, but a different attempt token → different
// payload → conflict, because settlement identity differs (#1074).
const other = { ...validReport, idempotencyKey: "key-a", prFixItem: { id: "cktz", generation: 2 } };
mockDedupe.create.mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
}),
);
mockDedupe.findUnique.mockResolvedValueOnce({
id: "claim-1",
agentName: "test-agent",
idempotencyKey: "key-a",
payloadHash: "hash-of-a-different-payload",
agentRunId: "run-1",
prFixResolution: null,
});
const conflict = await postRequest(other);
expect(conflict.status).toBe(409);
expect((await conflict.json()).error).toContain("different report payload");
});
});

describe("POST /api/agents/[agentName]/tasks/report — late/duplicate settlement (#1074)", () => {
beforeEach(() => {
delete process.env.DISPATCH_AUTH_MODE;
resetAuthCaches();
vi.clearAllMocks();
});

const tokenReport = {
taskType: "followup-pr",
outcome: "pr_updated",
repoFullName: "org/repo",
pullRequestNumber: 10,
prFixItem: { id: "cktz-abc", generation: 1 },
};

it("first keyed report persists the settlement resolution on the dedupe row for replay", async () => {
prFixResolveMock.mockResolvedValueOnce({
matched: true,
action: "fixed",
itemId: 7,
reason: "pr merge state verified",
attemptGeneration: 1,
});

const res = await postRequest({
...tokenReport,
idempotencyKey: "worker-run-9:report",
});

expect(res.status).toBe(200);
const body = await res.json();
expect(body.prFixResolution.action).toBe("fixed");
// The in-transaction agentRunId stamp is the first update; the
// resolution store is the second — this is what a retry replays.
expect(mockDedupe.update).toHaveBeenNthCalledWith(1, {
where: { id: "claim-1" },
data: { agentRunId: "run-1" },
});
expect(mockDedupe.update).toHaveBeenNthCalledWith(2, {
where: { agentName_idempotencyKey: { agentName: "test-agent", idempotencyKey: "worker-run-9:report" } },
data: { prFixResolution: expect.objectContaining({ action: "fixed" }) },
});
});

it("a second report with a NEW key against the now-FIXED item is skipped and the skip is stored", async () => {
prFixResolveMock
.mockResolvedValueOnce({
matched: true,
action: "fixed",
itemId: 7,
reason: "pr merge state verified",
attemptGeneration: 1,
})
.mockResolvedValueOnce({
matched: true,
action: "skipped",
itemId: 7,
reason: "pr-fix item already FIXED",
attemptGeneration: 1,
});

const first = await postRequest({
...tokenReport,
idempotencyKey: "worker-run-9:report",
});
expect(first.status).toBe(200);

// A different logical report (new key) for the same repo/PR lands after
// the first one settled the item.
const second = await postRequest({
...tokenReport,
idempotencyKey: "worker-run-9:report-2",
prFixItem: { id: "cktz-abc", generation: 2 },
});
expect(second.status).toBe(200);
const body = await second.json();
expect(body.duplicate).toBeUndefined();
expect(body.prFixResolution.action).toBe("skipped");
expect(body.prFixResolution.reason).toContain("already FIXED");
// The skip is persisted for the second key's future retries too.
expect(mockDedupe.update).toHaveBeenNthCalledWith(4, {
where: { agentName_idempotencyKey: { agentName: "test-agent", idempotencyKey: "worker-run-9:report-2" } },
data: { prFixResolution: expect.objectContaining({ action: "skipped" }) },
});
});
});
31 changes: 31 additions & 0 deletions src/app/api/agents/[agentName]/tasks/report/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export interface TaskReportBody {
pullRequestUrl?: string;
summary?: string;
error?: string;
// The (id, generation) attempt token next-task issued on this followup-pr
// task, echoed back by the worker (#1074). Required for PR-fix queue
// settlement; part of the report's payload identity (idempotency hash).
prFixItem?: { id: string; generation: number } | null;
}

function deriveStatus(outcome: ValidOutcome): string {
Expand Down Expand Up @@ -147,6 +151,21 @@ export async function POST(
}
}

// Optional attempt token for PR-fix queue settlement (#1074). The worker
// echoes the prFixItem { id, generation } that next-task issued.
if (raw.prFixItem !== undefined && raw.prFixItem !== null) {
if (typeof raw.prFixItem !== "object" || Array.isArray(raw.prFixItem)) {
return errorResponse("prFixItem must be an object", 400);
}
const prFixItem = raw.prFixItem as Record<string, unknown>;
if (typeof prFixItem.id !== "string" || prFixItem.id.trim().length === 0) {
return errorResponse("prFixItem.id must be a non-empty string", 400);
}
if (typeof prFixItem.generation !== "number" || !Number.isInteger(prFixItem.generation) || prFixItem.generation < 1) {
return errorResponse("prFixItem.generation must be an integer >= 1", 400);
}
}

// Optional worker-chosen opaque key. Present → the report becomes
// idempotently retryable; absent → current at-least-once behavior (#1044).
// Trimmed and capped so the unique index cannot be abused with unbounded
Expand All @@ -165,6 +184,14 @@ export async function POST(
return errorResponse(`idempotencyKey must be at most ${MAX_IDEMPOTENCY_KEY_LENGTH} characters`, 400);
}

const prFixItem =
raw.prFixItem && typeof raw.prFixItem === "object" && !Array.isArray(raw.prFixItem)
? {
id: (raw.prFixItem as { id: string }).id.trim(),
generation: (raw.prFixItem as { generation: number }).generation,
}
: undefined;

const report: TaskReportBody = {
taskType: taskType as ValidTaskType,
outcome: outcome as ValidOutcome,
Expand All @@ -174,6 +201,7 @@ export async function POST(
pullRequestUrl: raw.pullRequestUrl as string | undefined,
summary: raw.summary as string | undefined,
error: raw.error as string | undefined,
prFixItem,
};

try {
Expand Down Expand Up @@ -267,6 +295,9 @@ export async function POST(
pullRequestUrl: report.pullRequestUrl,
outcome: report.outcome,
summary: report.summary,
// #1074: settlement is authorized by the attempt token the worker was
// issued; a report without one never mutates the queue.
attempt: prFixItem ? { itemId: prFixItem.id, generation: prFixItem.generation } : null,
});
if (typeof idempotencyKey === "string") {
// Persist the resolution before responding so a retry replays the real
Expand Down
Loading
Loading