Skip to content

Commit 7632bae

Browse files
committed
fix(heartbeat): guard finalizeAgentStatus against clobbering a concurrent pause/terminate (RBR-932)
finalizeAgentStatus read the agent, checked paused/terminated in JS against that snapshot, then awaited countRunningRunsForAgent, then wrote status back with an unguarded where(eq(agents.id, agentId)). An operator pause (or terminate) landing in that window was silently clobbered back to idle/running/error. Move the guard into the WHERE clause of the write itself, matching the already-correct finalizeAgentAfterSourceResolvedRun in recovery/service.ts: .where(and(eq(agents.id, agentId), notInArray(agents.status, ["paused", "terminated"]))) The JS check stays as a cheap early-out; it is documented as non-authoritative. The compare-and-set predicate is exercised directly against real rows in the new regression test, asserting final persisted agent status (not call shapes), per AC3. Refs: RBR-932, RBR-923 AC6
1 parent 42c7356 commit 7632bae

2 files changed

Lines changed: 155 additions & 1 deletion

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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+
});

server/src/services/heartbeat.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12854,6 +12854,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
1285412854
? "idle"
1285512855
: "error";
1285612856

12857+
// RBR-932: guard belongs in the WHERE, not in JS. The status === "paused" /
12858+
// "terminated" check above reads a snapshot taken before the two awaits
12859+
// (isFirstHeartbeat resolution, countRunningRunsForAgent) below it, so an
12860+
// operator pause/terminate landing in that window was previously clobbered
12861+
// by this write. Mirror recovery/service.ts's
12862+
// finalizeAgentAfterSourceResolvedRun and make the write itself a
12863+
// compare-and-set: it only applies when the row is still not paused/terminated
12864+
// at write time. The early-return above stays as a cheap, non-authoritative
12865+
// early-out.
1285712866
const updated = await db
1285812867
.update(agents)
1285912868
.set({
@@ -12865,7 +12874,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
1286512874
lastHeartbeatAt: new Date(),
1286612875
updatedAt: new Date(),
1286712876
})
12868-
.where(eq(agents.id, agentId))
12877+
.where(and(eq(agents.id, agentId), notInArray(agents.status, ["paused", "terminated"])))
1286912878
.returning()
1287012879
.then((rows) => rows[0] ?? null);
1287112880

0 commit comments

Comments
 (0)