Preserve recovery backups and use database lease time

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-04 01:25:45 -06:00
1 parent ad07e681ba
commit e9aae9cfb9
4 files changed
+27 -5

No files matched your search

@@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agentTaskSessions, agents, companies, createDb, environmentLeases, environments, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults } from "@paperclipai/db"; import { agentTaskSessions, agents, companies, createDb, environmentLeases, environments, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults } from "@paperclipai/db";
import { DurablePrpControlPlane, parseNativeExecutionInput, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js"; import { DurablePrpControlPlane, parseNativeExecutionInput, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
import { startRecoveryPostgres, recoveryPostgresSupport } from "../../__tests__/helpers/recovery-postgres.js"; import { startRecoveryPostgres, recoveryPostgresSupport } from "../../__tests__/helpers/recovery-postgres.js";
@@ -92,6 +92,18 @@ describe.skipIf(!recoveryPostgresSupport.supported)("native controller durable d
expect(await resolveNativeControllerRecovery(db, { ...fixture.scope, currentRunId })).toEqual({ kind: "waiting_for_owner", deadline }); expect(await resolveNativeControllerRecovery(db, { ...fixture.scope, currentRunId })).toEqual({ kind: "waiting_for_owner", deadline });
}); });
it("uses the database clock when an import finds an expired owner", async () => {
const fixture = await seed();
const now = Date.now();
await db.update(nativeRunFinalizations).set({ leaseOwner: "expired-owner", leaseExpiresAt: new Date(now - 60_000) })
.where(eq(nativeRunFinalizations.runId, fixture.scope.runId));
vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(now - 3_600_000);
try {
expect(await importNativeControllerRecovery(db, { ...fixture.scope, expectedConfigDigest: compatibility.configDigest,
supervisor: fixture.supervisor })).toEqual({ kind: "missing_evidence", reason: "normal_ownership_recovery_required" });
} finally { vi.useRealTimers(); }
});
it("holds mismatched snapshots and runner bindings instead of falling back to fresh execution", async () => { it("holds mismatched snapshots and runner bindings instead of falling back to fresh execution", async () => {
const fixture = await seed(), currentRunId = randomUUID(); const fixture = await seed(), currentRunId = randomUUID();
const metadata = createNativeControllerRecovery(fixture.snapshot, compatibility, { runId: fixture.scope.runId, const metadata = createNativeControllerRecovery(fixture.snapshot, compatibility, { runId: fixture.scope.runId,
@@ -245,7 +245,7 @@ export async function importNativeControllerRecovery(db: Db, input: {
const [clock] = await tx.execute<{ now: Date }>(sql`select clock_timestamp() as now`); const [clock] = await tx.execute<{ now: Date }>(sql`select clock_timestamp() as now`);
const liveOwner = owner.leaseOwner && (!owner.leaseExpiresAt || owner.leaseExpiresAt > new Date(clock.now)); const liveOwner = owner.leaseOwner && (!owner.leaseExpiresAt || owner.leaseExpiresAt > new Date(clock.now));
if (input.offline ? liveOwner : owner.leaseOwner || task.executionRunId) { if (input.offline ? liveOwner : owner.leaseOwner || task.executionRunId) {
return owner.leaseExpiresAt && owner.leaseExpiresAt > new Date() return owner.leaseExpiresAt && owner.leaseExpiresAt > new Date(clock.now)
? { kind: "waiting_for_owner" as const, deadline: owner.leaseExpiresAt } ? { kind: "waiting_for_owner" as const, deadline: owner.leaseExpiresAt }
: { kind: "missing_evidence" as const, reason: "normal_ownership_recovery_required" }; : { kind: "missing_evidence" as const, reason: "normal_ownership_recovery_required" };
} }
@@ -53,6 +53,15 @@ describe("native harness backup publication", () => {
expect(await fs.readdir(backupRoot)).toEqual(["current"]); expect(await fs.readdir(backupRoot)).toEqual(["current"]);
expect(changes).toEqual([{ companyId: input.companyId, scopeDigest: input.scopeDigest }]); expect(changes).toEqual([{ companyId: input.companyId, scopeDigest: input.scopeDigest }]);
}); });
it("restores the old current directory when the pending rename fails", async () => {
const input = await fixture(); const changes: unknown[] = [];
unsubscribe = onNativeHarnessBackupChange(input.db, change => changes.push(change));
await fs.rm(input.pendingRoot, { recursive: true });
await expect(publishNativeHarnessBackup(input)).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(path.join(input.current, "manifest.json"), "utf8")).toBe("saved");
expect(changes).toEqual([]);
});
it("serves an unrelated request while a retired backup is being removed", async () => { it("serves an unrelated request while a retired backup is being removed", async () => {
const input = await fixture(); const input = await fixture();
for (let index = 0; index < 40; index++) { for (let index = 0; index < 40; index++) {
@@ -1,6 +1,7 @@
import { publishNativeHarnessBackup } from "./native-harness-backup-publication.js";
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto"; import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
import { createReadStream, createWriteStream } from "node:fs"; import { createReadStream, createWriteStream } from "node:fs";
import { mkdtemp, readFile, rename } from "node:fs/promises"; import { mkdtemp, readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
@@ -199,8 +200,8 @@ export async function restoreDurableNativeHarnessBackup(input: {
const pending = join(input.backupRoot, `.durable-${randomUUID()}`); const pending = join(input.backupRoot, `.durable-${randomUUID()}`);
try { try {
await cp(restored, pending, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, errorOnExist: true, force: false }); await cp(restored, pending, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, errorOnExist: true, force: false });
await removeNativeHarnessBackup(current); await publishNativeHarnessBackup({ db: input.db, companyId: input.companyId, scopeDigest: input.scopeDigest,
await rename(pending, current); backupRoot: input.backupRoot, pendingRoot: pending });
} finally { await removeNativeHarnessBackup(pending); } } finally { await removeNativeHarnessBackup(pending); }
return true; return true;
} finally { key.fill(0); await removeNativeHarnessBackup(scratch); } } finally { key.fill(0); await removeNativeHarnessBackup(scratch); }