|
| 1 | +import { randomUUID } from "node:crypto"; |
| 2 | +import { and, eq, notInArray } from "drizzle-orm"; |
| 3 | +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; |
| 4 | +import { |
| 5 | + agents, |
| 6 | + companies, |
| 7 | + createDb, |
| 8 | +} from "@paperclipai/db"; |
| 9 | +import { |
| 10 | + getEmbeddedPostgresTestSupport, |
| 11 | + startEmbeddedPostgresTestDatabase, |
| 12 | +} from "./helpers/embedded-postgres.js"; |
| 13 | + |
| 14 | +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); |
| 15 | +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; |
| 16 | + |
| 17 | +if (!embeddedPostgresSupport.supported) { |
| 18 | + console.warn( |
| 19 | + `Skipping embedded Postgres agent-status CAS tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, |
| 20 | + ); |
| 21 | +} |
| 22 | + |
| 23 | +// RBR-932: finalizeAgentStatus (server/src/services/heartbeat.ts) used to |
| 24 | +// guard a paused/terminated agent purely in JS, against a snapshot read |
| 25 | +// well before the destructive write several awaits later. An operator |
| 26 | +// pause/terminate landing in that window was silently clobbered back to |
| 27 | +// idle/running/error. The fix moves the guard into the WHERE clause of the |
| 28 | +// write itself -- mirroring finalizeAgentAfterSourceResolvedRun in |
| 29 | +// recovery/service.ts (`notInArray(agents.status, ["paused", "terminated"])`). |
| 30 | +// |
| 31 | +// These tests exercise that exact compare-and-set predicate against a real |
| 32 | +// row, asserting the final persisted agent status -- not call shapes -- |
| 33 | +// per RBR-932 AC #3. |
| 34 | +describeEmbeddedPostgres("finalizeAgentStatus compare-and-set guard (RBR-932)", () => { |
| 35 | + let db!: ReturnType<typeof createDb>; |
| 36 | + let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null; |
| 37 | + |
| 38 | + beforeAll(async () => { |
| 39 | + tempDb = await startEmbeddedPostgresTestDatabase("heartbeat-agent-status-cas-"); |
| 40 | + db = createDb(tempDb.connectionString); |
| 41 | + }, 60_000); |
| 42 | + |
| 43 | + afterEach(async () => { |
| 44 | + await db.delete(agents); |
| 45 | + await db.delete(companies); |
| 46 | + }); |
| 47 | + |
| 48 | + afterAll(async () => { |
| 49 | + await tempDb?.cleanup(); |
| 50 | + }); |
| 51 | + |
| 52 | + async function seedAgent(status: "running" | "paused" | "terminated" | "idle") { |
| 53 | + const companyId = randomUUID(); |
| 54 | + const agentId = randomUUID(); |
| 55 | + |
| 56 | + await db.insert(companies).values({ |
| 57 | + id: companyId, |
| 58 | + name: "Paperclip", |
| 59 | + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, |
| 60 | + requireBoardApprovalForNewAgents: false, |
| 61 | + }); |
| 62 | + |
| 63 | + await db.insert(agents).values({ |
| 64 | + id: agentId, |
| 65 | + companyId, |
| 66 | + name: "Finalizer", |
| 67 | + role: "engineer", |
| 68 | + status, |
| 69 | + adapterType: "process", |
| 70 | + adapterConfig: {}, |
| 71 | + runtimeConfig: {}, |
| 72 | + permissions: {}, |
| 73 | + }); |
| 74 | + |
| 75 | + return { companyId, agentId }; |
| 76 | + } |
| 77 | + |
| 78 | + // Applies the exact WHERE-clause shape now used at the finalizeAgentStatus |
| 79 | + // write site: heartbeat.ts's finalization update, guarded the same way as |
| 80 | + // recovery/service.ts's finalizeAgentAfterSourceResolvedRun. |
| 81 | + async function attemptFinalizeWrite(agentId: string, nextStatus: string) { |
| 82 | + return db |
| 83 | + .update(agents) |
| 84 | + .set({ |
| 85 | + status: nextStatus, |
| 86 | + lastHeartbeatAt: new Date(), |
| 87 | + updatedAt: new Date(), |
| 88 | + }) |
| 89 | + .where(and(eq(agents.id, agentId), notInArray(agents.status, ["paused", "terminated"]))) |
| 90 | + .returning({ id: agents.id, status: agents.status }); |
| 91 | + } |
| 92 | + |
| 93 | + it("a run finalization write is a no-op against an agent an operator paused concurrently", async () => { |
| 94 | + const { agentId } = await seedAgent("running"); |
| 95 | + |
| 96 | + // Simulate the race: an operator pause lands after finalizeAgentStatus's |
| 97 | + // stale JS-level snapshot read (existing.status === "running") but before |
| 98 | + // its write several awaits later. |
| 99 | + await db.update(agents).set({ status: "paused", updatedAt: new Date() }).where(eq(agents.id, agentId)); |
| 100 | + |
| 101 | + const result = await attemptFinalizeWrite(agentId, "idle"); |
| 102 | + expect(result).toHaveLength(0); |
| 103 | + |
| 104 | + const agent = await db |
| 105 | + .select({ status: agents.status }) |
| 106 | + .from(agents) |
| 107 | + .where(eq(agents.id, agentId)) |
| 108 | + .then((rows) => rows[0] ?? null); |
| 109 | + |
| 110 | + expect(agent?.status).toBe("paused"); |
| 111 | + }); |
| 112 | + |
| 113 | + it("a run finalization write is a no-op against an agent an operator terminated concurrently", async () => { |
| 114 | + const { agentId } = await seedAgent("running"); |
| 115 | + |
| 116 | + await db.update(agents).set({ status: "terminated", updatedAt: new Date() }).where(eq(agents.id, agentId)); |
| 117 | + |
| 118 | + const result = await attemptFinalizeWrite(agentId, "error"); |
| 119 | + expect(result).toHaveLength(0); |
| 120 | + |
| 121 | + const agent = await db |
| 122 | + .select({ status: agents.status }) |
| 123 | + .from(agents) |
| 124 | + .where(eq(agents.id, agentId)) |
| 125 | + .then((rows) => rows[0] ?? null); |
| 126 | + |
| 127 | + expect(agent?.status).toBe("terminated"); |
| 128 | + }); |
| 129 | + |
| 130 | + it("a run finalization write still applies normally when no pause/terminate raced it", async () => { |
| 131 | + const { agentId } = await seedAgent("running"); |
| 132 | + |
| 133 | + const result = await attemptFinalizeWrite(agentId, "idle"); |
| 134 | + expect(result).toHaveLength(1); |
| 135 | + expect(result[0]?.status).toBe("idle"); |
| 136 | + |
| 137 | + const agent = await db |
| 138 | + .select({ status: agents.status }) |
| 139 | + .from(agents) |
| 140 | + .where(eq(agents.id, agentId)) |
| 141 | + .then((rows) => rows[0] ?? null); |
| 142 | + |
| 143 | + expect(agent?.status).toBe("idle"); |
| 144 | + }); |
| 145 | +}); |
0 commit comments