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 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-09 12:54:46 +02:00
parent 4c437cd320
commit 82c6f95878
2 changed files with 14 additions and 16 deletions

View file

@ -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());

View file

@ -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<Permission>(["process"]) };
mcpJobs = await tickMcpJobs(await createPurchasingContext(principal), { limit: 1, deadlineMs: 120000 });
} catch (error) {