From 82c6f9587848d1ea72b78bb27cada718ef00db7d Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 9 Oct 2026 12:54:46 +0200 Subject: [PATCH] fix(dn): run a queued agent job only on ticks with no inbox batch Both are page reads; two in one tick can pass the 300s ceiling and get a batch reclaimed and read twice. The job waits for the next tick instead. Co-Authored-By: Claude Opus 5.5 --- src/app/api/dn-batches/tick/route.test.ts | 25 +++++++++-------------- src/app/api/dn-batches/tick/route.ts | 5 ++++- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/app/api/dn-batches/tick/route.test.ts b/src/app/api/dn-batches/tick/route.test.ts index bfd2950..7bb6e1f 100644 --- a/src/app/api/dn-batches/tick/route.test.ts +++ b/src/app/api/dn-batches/tick/route.test.ts @@ -695,36 +695,31 @@ describe("POST /api/dn-batches/tick — queued MCP jobs", () => { expect(optionsArg).toEqual({ limit: 1, deadlineMs: 120000 }); }); - it("drains after the inbox batches have run", async () => { - const order: string[] = []; - runBatchMock.mockImplementation(async () => { - order.push("inbox"); - return { status: "processed", notesDetected: 1, notesPromoted: 1, checks: [] }; - }); - tickMcpJobsMock.mockImplementation(async () => { - order.push("mcp"); - return { claimed: 1, completed: 1, failed: 0 }; - }); - + it("leaves the job for the next tick when an inbox batch already ran", async () => { + // One unit of work per tick: an inbox read plus a batch job could pass the + // 300s ceiling and get the batch reclaimed and read twice. await POST(postWithValidAuth()); - expect(order).toEqual(["inbox", "mcp"]); + expect(runBatchMock).toHaveBeenCalled(); + expect(tickMcpJobsMock).not.toHaveBeenCalled(); }); - it("reports the drain alongside the batch results so the n8n log shows it ran", async () => { + it("reports the drain so the n8n log shows it ran", async () => { + dnBatchesGetFullList.mockImplementation(async () => []); tickMcpJobsMock.mockResolvedValue({ claimed: 1, completed: 1, failed: 0 }); const body = await (await POST(postWithValidAuth())).json(); expect(body.mcpJobs).toEqual({ claimed: 1, completed: 1, failed: 0 }); // The drain is not a batch: it must not inflate claimed or the results. - expect(body.claimed).toBe(1); - expect(body.results).toHaveLength(1); + expect(body.claimed).toBe(0); + expect(body.results).toHaveLength(0); }); // Without MINIMAX_API_KEY tickMcpJobs throws processing_setup_required, // and without the n8n secret this route is not the scheduler at all. it("still returns 200 when the drain throws", async () => { + dnBatchesGetFullList.mockImplementation(async () => []); tickMcpJobsMock.mockRejectedValue(new Error("processing_setup_required")); const res = await POST(postWithValidAuth()); diff --git a/src/app/api/dn-batches/tick/route.ts b/src/app/api/dn-batches/tick/route.ts index edf80a5..6b1c3dd 100644 --- a/src/app/api/dn-batches/tick/route.ts +++ b/src/app/api/dn-batches/tick/route.ts @@ -131,8 +131,11 @@ export async function POST(request: Request) { // Deferred, never fatal: a missing MINIMAX_API_KEY makes tickMcpJobs throw // processing_setup_required, and a drain failure must not turn a successful // inbox tick into an error for the operator. + // + // Only when no inbox batch ran in this tick: both are page reads, and two in + // one tick can pass the 300s ceiling and get a batch reclaimed and re-read. let mcpJobs: { claimed: number; completed: number; failed: number } | undefined; - try { + if (results.length === 0) try { const principal = { id: "dn-tick", permissions: new Set(["process"]) }; mcpJobs = await tickMcpJobs(await createPurchasingContext(principal), { limit: 1, deadlineMs: 120000 }); } catch (error) {