fix: fence finished run process identity against late native callbacks

This commit is contained in:
Dotta committed 2026-09-09 22:18:32 -05:00
1 parent a7108016b3
commit 3f7cbb4f97
3 files changed
+86 -6

No files matched your search

@@ -0,0 +1,63 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, createDb, heartbeatRuns, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db";
import * as processes from "../services/hot-restart.js";
import { persistHeartbeatRunProcessMetadata } from "../services/heartbeat.js";
describe("heartbeat process identity persistence", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
const companyId = randomUUID(), agentId = randomUUID();
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-process-metadata-");
db = createDb(database.connectionString);
await db.insert(companies).values({ id: companyId, name: "Process identity" });
await db.insert(agents).values({ id: agentId, companyId, name: "Runner", role: "engineer", status: "idle", adapterType: "codex_local" });
}, 60_000);
afterEach(() => vi.restoreAllMocks());
afterAll(async () => { await database?.cleanup(); });
async function running() {
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running", invocationSource: "on_demand", startedAt: new Date(),
processPid: 123, processGroupId: null, processStartedAt: new Date("2026-09-09T23:19:42Z") }).returning();
return run!;
}
it("uses the remote marker even when its PID exists on the host", async () => {
const run = await running();
const host = vi.spyOn(processes, "readProcessStartedAt");
const meta = { pid: process.pid, processGroupId: null, startedAt: "2026-09-09T23:19:43.123Z" };
const updated = await persistHeartbeatRunProcessMetadata(db, run.id, meta, "remote");
expect(updated?.processPid).toBe(process.pid);
expect(updated?.processStartedAt?.toISOString()).toBe(meta.startedAt);
expect(host).not.toHaveBeenCalled();
});
it("retains observed local-process identity during an active run", async () => {
const run = await running();
const start = "2026-09-09T23:19:43.456Z";
vi.spyOn(processes, "readProcessStartedAt").mockResolvedValue(start);
const updated = await persistHeartbeatRunProcessMetadata(db, run.id, { pid: 456, processGroupId: 456, startedAt: "2026-09-09T23:19:44Z" });
expect(updated).toMatchObject({ processPid: 456, processGroupId: 456, processStartedAt: new Date(start) });
});
it.each(["succeeded", "failed", "cancelled", "timed_out", "running"] as const)("does not change finished %s history from a late spawn callback", async (status) => {
const run = await running();
const [finished] = await db.update(heartbeatRuns).set({ status, finishedAt: new Date() }).where(eq(heartbeatRuns.id, run.id)).returning();
expect(await persistHeartbeatRunProcessMetadata(db, run.id, { pid: 6365, processGroupId: null, startedAt: "2026-09-09T23:20:09.344Z" }, "remote")).toBeNull();
const [actual] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
expect(actual).toEqual(finished);
});
it("fences finalization racing an in-flight local identity lookup", async () => {
const run = await running();
let resume!: (start: string) => void;
vi.spyOn(processes, "readProcessStartedAt").mockImplementation(() => new Promise((resolve) => { resume = resolve; }));
const pending = persistHeartbeatRunProcessMetadata(db, run.id, { pid: 456, processGroupId: 456, startedAt: "2026-09-09T23:19:43Z" });
const [finished] = await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, run.id)).returning();
resume("2026-09-09T23:19:43Z");
expect(await pending).toBeNull();
const [actual] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
expect(actual).toEqual(finished);
});
});