/** * A terminal job row must survive a late progress frame. * * Tool progress is published fire and forget (`void updateSingleFileProgress` * in the worker), so a nonterminal frame can still be in flight when the job * reaches a terminal state. persistSingleFileProgress wrote * `status: "processing", completedAt: null, error: null` unconditionally, so a * frame that landed late resurrected a finished row. * * Observed on the release container: cancelling a stabilize-video job just * after submit left the row permanently `status = processing`, * `completed_at = null`, `error = null`, `duration_ms = 59`, with * `progress = {"stage":"Analyzing","percent":5}` -- the tool's first frame, * written after the worker's cancel row. One in three attempts reproduced it, * and the row never recovered because nothing else revisits it. * * The completion path is already safe: it goes through * updateSingleFileProgressAtomically, whose per-job queue drains earlier * nonterminal writes first. The cancel and failure paths in the worker use a * plain db.update outside that queue, so the guard has to live in the write * itself. */ import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { db, schema } from "../../../apps/api/src/db/index.js"; import { updateSingleFileProgress } from "../../../apps/api/src/routes/progress.js"; import { buildTestApp, type TestApp } from "../test-server.js"; let testApp: TestApp; beforeAll(async () => { testApp = await buildTestApp(); }, 30_000); afterAll(async () => { await testApp.cleanup(); }, 10_000); async function seedJob( status: "canceled" | "failed" | "completed" | "processing", ): Promise { const id = randomUUID(); await db.insert(schema.jobs).values({ id, type: "single", status, inputRefs: [], completedAt: status === "processing" ? null : new Date(), durationMs: 59, error: status === "failed" ? { message: "boom" } : null, }); return id; } async function readJob(id: string) { const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, id)); return row; } describe("late progress frames against a terminal job row", () => { for (const terminal of ["canceled", "failed", "completed"] as const) { it(`leaves a ${terminal} row terminal`, async () => { const id = await seedJob(terminal); // The frame the worker had already queued before the job ended. await updateSingleFileProgress({ jobId: id, phase: "processing", percent: 5, stage: "Analyzing", }); const row = await readJob(id); expect(row.status, `${terminal} row was rewritten to ${row.status}`).toBe(terminal); expect(row.completedAt, "completedAt was cleared by a late progress frame").not.toBeNull(); if (terminal === "failed") { expect(row.error, "the failure reason was cleared by a late progress frame").not.toBeNull(); } }); } it("still advances a job that is genuinely still running", async () => { const id = await seedJob("processing"); await updateSingleFileProgress({ jobId: id, phase: "processing", percent: 42, stage: "Working", }); const row = await readJob(id); expect(row.status).toBe("processing"); expect((row.progress as { percent?: number } | null)?.percent).toBe(42); }); it("still records a terminal frame on a running job", async () => { const id = await seedJob("processing"); await updateSingleFileProgress({ jobId: id, phase: "complete", percent: 100 }); const row = await readJob(id); expect(row.status).toBe("completed"); expect(row.completedAt).not.toBeNull(); }); });