feat(runtime): pre-copy settled harness history while serving

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-04 22:19:11 -06:00
1 parent a0250284e3
commit 93e2f348cf
7 files changed
+323 -59

No files matched your search

@@ -214,3 +214,39 @@ source, export-failure cause or unknown recovery kind retains the existing
blocker unless the importer already records that exact legacy recovery source.
Live migration keeps its recovery exclusion. Recheck recovery evidence before
publishing the saved workspace.
### Online preparation for large provider histories
Parallel offline transfer alone did not fit the copied staging dataset in the
maintenance window. A resumed attempt reached 1,389 of 1,953 sources. Keep the
offline deadline and move successful released history before draining the server.
The inventory contains 1,802 such backups, approximately 48.5 GB of the 54.8 GB
declared source bytes. This is preparatory publication under recorded runtime
authority, not permission to detach a volume.
Run the finite operator entry from a normal released image:
```sh
PAPERCLIP_NATIVE_HARNESS_PRESTAGE=true node --max-old-space-size=512 server/dist-runtime/native-harness-prestage.mjs
```
The entry loads the stored Cloud configuration and requires shared object storage.
It processes one source at a time, has a 90-minute deadline, and stops on shutdown.
It starts no scheduler, does not drain tasks, and always reports
`enrollmentReady: false`. Check serving memory headroom before running a separate
process on a tenant. Keep the deployment on normal releases.
Only a successful, finished source with a released committed coordinator and an
accepted result can publish through this path. At both database fences, hold the
task share lock and recheck its checkout, related queued/running executions,
recorded session and Runner, exact backup-stamp lease, and unchanged local
manifest and suspended state. A concurrent turn, moved source, revoked stamp or
changed owner defers that source. Unrelated executions continue unchanged.
No lease, result, task or lifecycle state is rewritten to make a source eligible.
The final offline migration still stops all writers, verifies every durable
object, imports deferred sources and all other data, and exports only its normal
complete eligibility receipt. Qualification starts from the saved pristine
database, records the separate preparation phase, and then proves the normal
bundled offline entry and empty-home restoration. Preparation alone cannot pass
enrollment or active-agent release acceptance.
+5 -3
View File
@@ -71,11 +71,12 @@ function externalizeThirdParty(isBundled, runtimeDir) {
};
}
export async function bundleRuntime({ entry, outdir, maintenanceEntry, ephemeralMigrationEntry, isBundled = (specifier) => specifier.startsWith("@paperclipai/") }) {
export async function bundleRuntime({ entry, outdir, maintenanceEntry, ephemeralMigrationEntry, nativeHarnessPrestageEntry, isBundled = (specifier) => specifier.startsWith("@paperclipai/") }) {
await rm(outdir, { recursive: true, force: true });
const result = await build({
entryPoints: { index: "paperclip-runtime-entry", server: entry, ...(maintenanceEntry ? { "agent-files": maintenanceEntry } : {}),
...(ephemeralMigrationEntry ? { "ephemeral-migration": ephemeralMigrationEntry } : {}) },
...(ephemeralMigrationEntry ? { "ephemeral-migration": ephemeralMigrationEntry } : {}),
...(nativeHarnessPrestageEntry ? { "native-harness-prestage": nativeHarnessPrestageEntry } : {}) },
outdir,
outExtension: { ".js": ".mjs" },
chunkNames: "chunk-[hash]",
@@ -105,7 +106,8 @@ async function main() {
const started = performance.now();
const summary = await bundleRuntime({ entry: join(serverDir, "dist", "index.js"), outdir: join(serverDir, "dist-runtime"),
maintenanceEntry: join(serverDir, "dist", "maintenance", "agent-files.js"),
ephemeralMigrationEntry: join(serverDir, "dist", "maintenance", "ephemeral-migration.js") });
ephemeralMigrationEntry: join(serverDir, "dist", "maintenance", "ephemeral-migration.js"),
nativeHarnessPrestageEntry: join(serverDir, "dist", "maintenance", "native-harness-prestage.js") });
console.log(`[bundle-runtime] ${summary.inputs} inputs -> ${summary.chunks} chunks in ${Math.round(performance.now() - started)} ms: ${summary.outfile}`);
}
+9
View File
@@ -89,6 +89,15 @@ describe("bundleRuntime", () => {
expect(JSON.parse(stdout)).toEqual({ migrated: 42 });
});
it("packages online harness preparation as a standalone plain-Node entry", async () => {
const nativeHarnessPrestageEntry = path.join(root, "server", "dist", "prestage.js");
write(nativeHarnessPrestageEntry, 'import { values } from "@paperclipai/fixture"; console.log(JSON.stringify({ copied: values.doubled, enrollmentReady: false }));');
const outdir = path.join(root, "server", "dist-runtime");
await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir, nativeHarnessPrestageEntry });
const { stdout } = await execFileAsync(process.execPath, [path.join(outdir, "native-harness-prestage.mjs")], { timeout: 30_000 });
expect(JSON.parse(stdout)).toEqual({ copied: 42, enrollmentReady: false });
});
it("bundles workspace TypeScript and runs with plain node", async () => {
const outdir = path.join(root, "server", "dist-runtime");
const summary = await bundleRuntime({ entry: path.join(root, "server", "dist", "index.js"), outdir });
@@ -0,0 +1,36 @@
import { closeRegisteredClients, createDb } from "@paperclipai/db";
import { cloudRuntimeConfigService } from "../services/cloud-runtime-config.js";
import { setCloudRuntimeConfigSnapshot } from "../services/cloud-runtime-config-snapshot.js";
import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { getStorageService } from "../storage/index.js";
import { prestageNativeHarnessBackups } from "../services/native-runtime/native-harness-backup-migration.js";
// Operator-only finite preparation. No task drain, scheduler, app routes,
// enrollment export, or assertion that other executions have stopped.
if (process.env.PAPERCLIP_NATIVE_HARNESS_PRESTAGE !== "true") throw new Error("Explicit harness pre-copy mode is required");
const url = process.env.DATABASE_URL;
if (!url) throw new Error("Harness pre-copy requires an explicit database");
const cancellation = new AbortController();
const stop = () => cancellation.abort(new Error("native_harness_prestage_stopped"));
const deadline = setTimeout(() => cancellation.abort(new Error("native_harness_prestage_deadline")), 90 * 60_000);
process.once("SIGINT", stop); process.once("SIGTERM", stop);
const db = createDb(url, { maxConnections: 2 });
try {
if (isCloudManagedInstance()) {
const config = await cloudRuntimeConfigService(db).inspectStored();
if (!config) throw new Error("cloud_runtime_configuration_missing");
setCloudRuntimeConfigSnapshot(config);
}
const storage = getStorageService();
if (storage.provider !== "s3") throw new Error("shared_object_storage_required");
const counts = await prestageNativeHarnessBackups(db, { storage, signal: cancellation.signal });
console.log(JSON.stringify({ schema: "paperclip.native-harness-prestage.v1", counts, enrollmentReady: false }));
} catch (error) {
const message = error instanceof Error ? error.message : "native_harness_prestage_failed";
const failure = /^[a-z_]{1,80}$/.test(message) ? message : "native_harness_prestage_failed";
console.error(JSON.stringify({ schema: "paperclip.native-harness-prestage.v1", state: "blocked", failure, enrollmentReady: false }));
process.exitCode = 1;
} finally {
clearTimeout(deadline); process.off("SIGINT", stop); process.off("SIGTERM", stop);
await closeRegisteredClients(url, { scope: "database" });
}
@@ -1,11 +1,11 @@
import { createHash } from "node:crypto";
import { lstat, readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { environmentLeases, heartbeatRuns, nativeHarnessBackups, type Db } from "@paperclipai/db";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import { environmentLeases, heartbeatRuns, issues, nativeHarnessBackups, nativeRunFinalizations, type Db } from "@paperclipai/db";
import type { StorageService } from "../../storage/types.js";
import { nativeHarnessStateBase, readNativeHarnessBackupStamp, verifyNativeHarnessBackupStampAsync } from "./native-harness-backup-stamp.js";
import { persistNativeHarnessBackup, verifyDurableNativeHarnessBackup } from "./native-harness-backup-storage.js";
import { nativeHarnessBackupOwner, persistNativeHarnessBackup, verifyDurableNativeHarnessBackup } from "./native-harness-backup-storage.js";
import { MAX_NATIVE_HARNESS_BYTES, MAX_NATIVE_HARNESS_OBJECT_BYTES, nativeHarnessArchiveSizeLimit } from "./native-harness-backup-archive.js";
import { offlineMigrationExecutionWhere } from "../offline-migration-execution.js";
@@ -61,16 +61,34 @@ async function prepareSource(base: string, scopeDigest: string) {
type Source = NonNullable<Awaited<ReturnType<typeof prepareSource>>>;
async function migrateSource(db: Db, input: { storage: StorageService; signal: AbortSignal }, source: Source) {
input.signal.throwIfAborted();
const { root, scopeDigest, manifestSha256 } = source;
async function sourceRun(db: Db, source: Source) {
const [run] = await db.select({ companyId: heartbeatRuns.companyId, issueId: heartbeatRuns.nativeIssueId,
sessionId: heartbeatRuns.nativeSessionId, runnerId: heartbeatRuns.runnerInstanceId }).from(heartbeatRuns)
sessionId: heartbeatRuns.nativeSessionId, runnerId: heartbeatRuns.runnerInstanceId,
status: heartbeatRuns.status, finishedAt: heartbeatRuns.finishedAt }).from(heartbeatRuns)
.where(eq(heartbeatRuns.id, source.runId));
if (!run) return 0;
if (!run.issueId || run.sessionId !== source.sessionId || run.runnerId !== source.runnerId) {
if (!run) return null;
if (!run.issueId || !run.sessionId || !run.runnerId || run.sessionId !== source.sessionId || run.runnerId !== source.runnerId) {
throw new Error("native_harness_migration_binding_mismatch");
}
return { ...run, issueId: run.issueId, sessionId: run.sessionId, runnerId: run.runnerId };
}
async function verifiedSourceLease(db: Db, source: Source, run: NonNullable<Awaited<ReturnType<typeof sourceRun>>>, signal: AbortSignal) {
const leases = await db.select({ stamp: sql<unknown>`${environmentLeases.metadata}->'nativeHarnessBackup'`,
providerLeaseId: environmentLeases.providerLeaseId }).from(environmentLeases).where(and(
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.issueId, run.issueId),
sql`${environmentLeases.metadata}->'nativeHarnessBackup'->>'manifestSha256' = ${source.manifestSha256}`));
for (const lease of leases) {
const stamp = readNativeHarnessBackupStamp(lease.stamp, lease.providerLeaseId ?? "");
if (stamp?.sessionScopeSha256 === source.scopeDigest && await verifyNativeHarnessBackupStampAsync(stamp, lease.providerLeaseId!, signal)) return lease.providerLeaseId!;
}
return null;
}
async function migrateSource(db: Db, input: { storage: StorageService; signal: AbortSignal }, source: Source) {
input.signal.throwIfAborted();
const { root, scopeDigest, manifestSha256 } = source, run = await sourceRun(db, source);
if (!run) return 0;
const [current] = await db.select().from(nativeHarnessBackups).where(and(eq(nativeHarnessBackups.companyId, run.companyId),
eq(nativeHarnessBackups.scopeDigest, scopeDigest), eq(nativeHarnessBackups.status, "current")));
if (current) {
@@ -79,57 +97,120 @@ async function migrateSource(db: Db, input: { storage: StorageService; signal: A
scopeDigest, manifestSha256: current.manifestSha256, signal: input.signal });
return 1;
}
const leases = await db.select({ stamp: sql<unknown>`${environmentLeases.metadata}->'nativeHarnessBackup'`,
providerLeaseId: environmentLeases.providerLeaseId }).from(environmentLeases).where(and(
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.issueId, run.issueId),
sql`${environmentLeases.metadata}->'nativeHarnessBackup'->>'manifestSha256' = ${manifestSha256}`));
let valid = false;
for (const lease of leases) {
const stamp = readNativeHarnessBackupStamp(lease.stamp, lease.providerLeaseId ?? "");
if (stamp?.sessionScopeSha256 === scopeDigest && await verifyNativeHarnessBackupStampAsync(stamp, lease.providerLeaseId!, input.signal)) { valid = true; break; }
}
if (!valid) throw new Error("native_harness_migration_backup_unverified");
if (!await verifiedSourceLease(db, source, run, input.signal)) throw new Error("native_harness_migration_backup_unverified");
await persistNativeHarnessBackup({ db, storage: input.storage, root, scopeDigest, signal: input.signal,
maxArchiveBytes: source.archiveLimit,
owner: { kind: "offline_migration", writersStopped: true, companyId: run.companyId, issueId: run.issueId, runId: source.runId } });
return 1;
}
async function mapSources<T>(input: { signal: AbortSignal; concurrency?: number;
unavailableSource?: (error: unknown) => T | undefined }, visit: (source: Source, signal: AbortSignal) => Promise<T>) {
const base = nativeHarnessStateBase();
const directories = await readdir(base).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return [] as string[]; throw error;
});
let reserved = 0, wave: Source[] = [];
const results: T[] = [];
const flush = async () => {
const cancellation = new AbortController();
const signal = AbortSignal.any([input.signal, cancellation.signal]);
const settled = await Promise.allSettled(wave.map(async source => {
try { return await visit(source, signal); }
catch (error) { cancellation.abort(error); throw error; }
}));
// Never return failure while a peer can still upload or publish. Partial
// completed transfers remain valid for the next offline attempt.
if (cancellation.signal.aborted) throw cancellation.signal.reason;
for (const result of settled) {
if (result.status === "rejected") throw result.reason;
results.push(result.value);
}
wave = []; reserved = 0;
};
for (const scopeDigest of directories.filter(name => /^[a-f0-9]{64}$/.test(name)).sort()) {
input.signal.throwIfAborted();
let source;
try { source = await prepareSource(base, scopeDigest); }
catch (error) {
input.signal.throwIfAborted();
const deferred = input.unavailableSource?.(error);
if (deferred === undefined) throw error;
results.push(deferred); continue;
}
if (!source) continue;
const reservation = 2 * source.archiveLimit;
if (wave.length && (wave.length === (input.concurrency ?? MAX_PARALLEL_BACKUPS) || reserved + reservation > MAX_SCRATCH_BYTES)) await flush();
wave.push(source); reserved += reservation;
}
if (wave.length) await flush();
return results;
}
export async function migrateNativeHarnessBackups(db: Db, input: {
storage: StorageService; writersStopped: true; signal: AbortSignal;
}): Promise<number> {
if (!input.writersStopped) throw new Error("native_harness_migration_requires_offline_writers");
const [active] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(offlineMigrationExecutionWhere()).limit(1);
if (active) throw new Error("native_harness_migration_requires_offline_writers");
const base = nativeHarnessStateBase();
const directories = await readdir(base).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return [] as string[]; throw error;
const counts = await mapSources(input, (source, signal) => migrateSource(db, { storage: input.storage, signal }, source));
return counts.reduce<number>((total, count) => total + count, 0);
}
/** Copy settled successful history using its released runtime owner. This is
* preparatory work, never an offline-writer assertion or enrollment receipt. */
export async function prestageNativeHarnessBackups(db: Db, input: { storage: StorageService; signal: AbortSignal }) {
// Keep preparatory work below offline throughput so the serving process can
// continue handling real work. This finite operation has no idle schedule.
const outcomes = await mapSources({ ...input, concurrency: 1, unavailableSource: error =>
(error as NodeJS.ErrnoException)?.code === "ENOENT" || error instanceof Error && error.message === "native_harness_migration_source_unsettled"
? "source_unavailable" : undefined }, async (source, signal) => {
signal.throwIfAborted();
const run = await sourceRun(db, source).catch(error => {
if (error instanceof Error && error.message === "native_harness_migration_binding_mismatch") return null;
throw error;
});
if (!run) return "missing_run";
const [current] = await db.select().from(nativeHarnessBackups).where(and(eq(nativeHarnessBackups.companyId, run.companyId),
eq(nativeHarnessBackups.scopeDigest, source.scopeDigest), eq(nativeHarnessBackups.status, "current")));
if (current) {
if (current.manifestSha256 !== source.manifestSha256) return "durable_source_changed";
await verifyDurableNativeHarnessBackup({ db, storage: input.storage, companyId: run.companyId,
scopeDigest: source.scopeDigest, manifestSha256: source.manifestSha256, signal });
return "current";
}
if (run.status !== "succeeded" || !run.finishedAt) return "source_not_successful";
const [coordinator] = await db.select().from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.companyId, run.companyId), eq(nativeRunFinalizations.issueId, run.issueId),
eq(nativeRunFinalizations.runId, source.runId)));
if (coordinator?.phase !== "committed" || coordinator.leaseOwner || !coordinator.resultId || !coordinator.controllerBootId) return "owner_not_released";
const [task] = await db.select({ executionRunId: issues.executionRunId, checkoutRunId: issues.checkoutRunId }).from(issues)
.where(and(eq(issues.companyId, run.companyId), eq(issues.id, run.issueId)));
const [active] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, run.companyId), inArray(heartbeatRuns.status, ["queued", "running"]),
or(eq(heartbeatRuns.nativeIssueId, run.issueId), eq(heartbeatRuns.issueId, run.issueId), eq(heartbeatRuns.nativeSessionId, run.sessionId)))).limit(1);
if (!task || task.executionRunId || task.checkoutRunId || active) return "source_active";
const providerLeaseId = await verifiedSourceLease(db, source, run, signal);
if (!providerLeaseId) return "source_unverified";
try {
const owner = await nativeHarnessBackupOwner(db, { companyId: run.companyId, issueId: run.issueId,
runId: source.runId, bootId: coordinator.controllerBootId });
await persistNativeHarnessBackup({ db, storage: input.storage, root: source.root, scopeDigest: source.scopeDigest,
owner, signal, maxArchiveBytes: source.archiveLimit,
inactiveSource: { normalizedSessionId: run.sessionId, runnerInstanceId: run.runnerId, providerLeaseId } });
return "published";
} catch (error) {
signal.throwIfAborted();
if ((error as NodeJS.ErrnoException)?.code === "ENOENT" || error instanceof Error && ["native_runner_checkpoint_owner_lost", "native_harness_storage_owner_missing",
"native_harness_storage_prestage_source_active", "native_harness_storage_prestage_source_changed",
"native_harness_storage_owner_superseded", "native_harness_archive_invalid"].includes(error.message)) return "source_changed";
throw error;
}
});
let count = 0, reserved = 0, wave: Source[] = [];
const flush = async () => {
const cancellation = new AbortController();
const signal = AbortSignal.any([input.signal, cancellation.signal]);
const settled = await Promise.allSettled(wave.map(async source => {
try { return await migrateSource(db, { ...input, signal }, source); }
catch (error) { cancellation.abort(error); throw error; }
}));
// Never return failure while a peer can still upload or publish. Partial
// completed transfers remain valid for the next offline attempt.
if (cancellation.signal.aborted) throw cancellation.signal.reason;
for (const result of settled) {
if (result.status === "rejected") throw result.reason;
count += result.value;
}
wave = []; reserved = 0;
};
for (const scopeDigest of directories.filter(name => /^[a-f0-9]{64}$/.test(name)).sort()) {
input.signal.throwIfAborted();
const source = await prepareSource(base, scopeDigest);
if (!source) continue;
const reservation = 2 * source.archiveLimit;
if (wave.length && (wave.length === MAX_PARALLEL_BACKUPS || reserved + reservation > MAX_SCRATCH_BYTES)) await flush();
wave.push(source); reserved += reservation;
const counts = { inspected: outcomes.length, published: 0, current: 0, deferred: {} as Record<string, number> };
for (const outcome of outcomes) {
if (outcome === "published" || outcome === "current") counts[outcome]++;
else counts.deferred[outcome] = (counts.deferred[outcome] ?? 0) + 1;
}
if (wave.length) await flush();
return count;
return counts;
}
@@ -12,7 +12,7 @@ import { nativeHarnessBackupOwner, persistNativeHarnessBackup, restoreDurableNat
import { removeNativeHarnessBackup } from "./native-harness-backup-cleanup.js";
import { buildNativeHarnessBackupManifest } from "./native-session-executor.js";
import { createNativeHarnessBackupStamp, verifyNativeHarnessBackupStamp, verifyNativeHarnessBackupStampAsync } from "./native-harness-backup-stamp.js";
import { hasLocalNativeHarnessObligations, migrateNativeHarnessBackups } from "./native-harness-backup-migration.js";
import { hasLocalNativeHarnessObligations, migrateNativeHarnessBackups, prestageNativeHarnessBackups } from "./native-harness-backup-migration.js";
import { nativeHarnessBackupExpirationService } from "./native-harness-backup-expiration.js";
import { legacyControllerBootId } from "../legacy-controller-lease.js";
@@ -67,7 +67,7 @@ describeEmbeddedPostgres("durable native provider backups", () => {
await f.db.update(heartbeatRuns).set({ nativeSessionId: f.identity.normalizedSessionId, runnerInstanceId }).where(eq(heartbeatRuns.id, f.runId));
await f.db.insert(environmentLeases).values({ companyId: f.companyId, issueId: f.issueId, heartbeatRunId: f.runId,
providerLeaseId, status: "expired", metadata: { nativeHarnessBackup: stamp } });
return { ...f, home, root };
return { ...f, home, root, runnerInstanceId, providerLeaseId };
}
async function offlineFixtures(count: number) {
const base = join(environment.current.runtimeRoot, randomUUID()); ownedHomes.push(base); await mkdir(base);
@@ -179,6 +179,73 @@ describeEmbeddedPostgres("durable native provider backups", () => {
leaseExpiresAt: null }).where(eq(nativeRunFinalizations.runId, f.runId));
return result;
}
async function inactiveFixture() {
const [f] = await offlineFixtures(1);
await commitWarmRun(f);
await f.db.update(issues).set({ executionRunId: null, checkoutRunId: null }).where(eq(issues.id, f.issueId));
const owner = await nativeHarnessBackupOwner(f.db, { companyId: f.companyId, issueId: f.issueId,
runId: f.runId, bootId: environment.current.successor.bootId });
return { ...f, owner, inactiveSource: { normalizedSessionId: f.identity.normalizedSessionId,
runnerInstanceId: f.runnerInstanceId, providerLeaseId: f.providerLeaseId } };
}
it("pre-copies released history and restores its bytes while an unrelated execution stays active", async () => {
const f = await inactiveFixture(), active = await environment.seedRun(randomUUID());
const input = { storage: f.storage, signal: AbortSignal.timeout(15_000) };
expect(await prestageNativeHarnessBackups(f.db, input)).toEqual({ inspected: 1, published: 1, current: 0, deferred: {} });
expect(await prestageNativeHarnessBackups(f.db, input)).toEqual({ inspected: 1, published: 0, current: 1, deferred: {} });
await expect(migrateNativeHarnessBackups(f.db, { ...input, writersStopped: true })).rejects.toThrow("requires_offline_writers");
expect((await active.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, active.runId)))[0].status).toBe("running");
await removeNativeHarnessBackup(f.home);
const replacement = join(environment.current.runtimeRoot, randomUUID()); ownedHomes.push(replacement);
expect(await restoreDurableNativeHarnessBackup({ ...f, backupRoot: replacement })).toBe(true);
expect(JSON.parse(await readFile(join(replacement, "current", "codex-home", "sessions", "history.json"), "utf8"))).toEqual(f.identity);
});
it.each(["queued", "running"] as const)("defers pre-copy when the same task has a %s execution", async status => {
const f = await inactiveFixture(), put = vi.spyOn(f.storage, "putFile");
await f.db.insert(heartbeatRuns).values({ id: randomUUID(), companyId: f.companyId, agentId: environment.current.agentId,
issueId: f.issueId, nativeIssueId: f.issueId, nativeSessionId: f.identity.normalizedSessionId, status, runtimeMode: "native" });
expect(await prestageNativeHarnessBackups(f.db, { storage: f.storage, signal: AbortSignal.timeout(15_000) }))
.toEqual({ inspected: 1, published: 0, current: 0, deferred: { source_active: 1 } });
await expect(persistNativeHarnessBackup(f)).rejects.toThrow("native_harness_storage_prestage_source_active");
expect(put).not.toHaveBeenCalled();
expect(await f.db.select().from(nativeHarnessBackups).where(eq(nativeHarnessBackups.scopeDigest, f.scopeDigest))).toEqual([]);
});
it("defers failed history without treating pre-copy as offline authority", async () => {
const [f] = await offlineFixtures(1);
expect(await prestageNativeHarnessBackups(f.db, { storage: f.storage, signal: AbortSignal.timeout(15_000) }))
.toEqual({ inspected: 1, published: 0, current: 0, deferred: { source_not_successful: 1 } });
expect(await f.db.select().from(nativeHarnessBackups).where(eq(nativeHarnessBackups.scopeDigest, f.scopeDigest))).toEqual([]);
});
it("defers a source being replaced online while offline migration still refuses it", async () => {
const f = await inactiveFixture();
await writeFile(join(f.root, "runner", "runner-state.json"), JSON.stringify({ runId: f.runId, lifecycle: "running" }));
expect(await prestageNativeHarnessBackups(f.db, { storage: f.storage, signal: AbortSignal.timeout(15_000) }))
.toEqual({ inspected: 1, published: 0, current: 0, deferred: { source_unavailable: 1 } });
await expect(migrateNativeHarnessBackups(f.db, { storage: f.storage, writersStopped: true, signal: AbortSignal.timeout(15_000) }))
.rejects.toThrow("native_harness_migration_source_unsettled");
});
it.each(["source_advanced", "queued_turn", "lease_revoked", "owner_changed"])("keeps the published source when %s happens during pre-copy upload", async change => {
const f = await inactiveFixture(); await persistNativeHarnessBackup(f);
const [before] = await f.db.select().from(nativeHarnessBackups).where(eq(nativeHarnessBackups.scopeDigest, f.scopeDigest));
const put = f.storage.putFile.bind(f.storage);
vi.spyOn(f.storage, "putFile").mockImplementation(async input => {
const result = await put(input);
if (change === "source_advanced") await writeFile(join(f.root, "manifest.json"), (await readFile(join(f.root, "manifest.json"), "utf8")) + "\n");
if (change === "queued_turn") await f.db.insert(heartbeatRuns).values({ id: randomUUID(), companyId: f.companyId,
agentId: environment.current.agentId, issueId: f.issueId, status: "queued" });
if (change === "lease_revoked") await f.db.update(environmentLeases).set({ metadata: {} })
.where(eq(environmentLeases.providerLeaseId, f.providerLeaseId));
if (change === "owner_changed") await f.db.update(nativeRunFinalizations).set({ controllerGeneration: 2 })
.where(eq(nativeRunFinalizations.runId, f.runId));
return result;
});
await expect(persistNativeHarnessBackup(f)).rejects.toThrow(change === "owner_changed" ? "native_runner_checkpoint_owner_lost"
: change === "queued_turn" ? "native_harness_storage_prestage_source_active" : "native_harness_storage_prestage_source_changed");
const rows = await f.db.select().from(nativeHarnessBackups).where(eq(nativeHarnessBackups.scopeDigest, f.scopeDigest));
expect(rows.filter(row => row.status === "current").map(row => row.id)).toEqual([before.id]);
expect(rows.filter(row => row.status === "pending")).toHaveLength(1);
expect(await readFile(join(f.root, "manifest.json"), "utf8")).toContain("paperclip.native-harness-backup.v1");
});
it.each(["idle", "preparing", "native"])("preserves a released committed warm session with a %s successor", async state => {
const f = await fixture();
const result = await commitWarmRun(f);
@@ -6,7 +6,7 @@ import { join } from "node:path";
import os from "node:os";
import { pipeline } from "node:stream/promises";
import { Transform } from "node:stream";
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import { environmentLeases, heartbeatRuns, issues, nativeHarnessBackups, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { createLocalEncryptedCipher } from "../../secrets/local-encrypted-provider.js";
import type { StorageService } from "../../storage/types.js";
@@ -28,6 +28,8 @@ type Reference = Binding & {
type Row = typeof nativeHarnessBackups.$inferSelect;
type OfflineOwner = { kind: "offline_migration"; companyId: string; issueId: string; runId: string;
writersStopped: true; terminalFailureOnly?: true };
type InactiveSource = { root: string; manifestSha256: string; scopeDigest: string;
normalizedSessionId: string; runnerInstanceId: string; providerLeaseId: string };
const fail = (reason: string): never => { throw new Error(`native_harness_storage_${reason}`); };
const aad = (binding: Binding) => Buffer.from(JSON.stringify([SCHEMA, binding.companyId, binding.scopeDigest, binding.id]));
const scope = (binding: Pick<Binding, "companyId" | "scopeDigest">) => and(
@@ -89,9 +91,11 @@ export async function nativeHarnessBackupOwner(db: Db, input: { companyId: strin
if (!row || !row.leaseOwner && (row.phase !== "committed" || !row.resultId)) fail("owner_missing");
return { ...input, authorityRunId: input.runId, leaseOwner: row!.leaseOwner, generation: row!.controllerGeneration } satisfies NativeRunnerCheckpointFenceOwner;
}
async function fence(tx: Parameters<Parameters<Db["transaction"]>[0]>[0], owner: NativeRunnerCheckpointFenceOwner | OfflineOwner) {
async function fence(tx: Parameters<Parameters<Db["transaction"]>[0]>[0], owner: NativeRunnerCheckpointFenceOwner | OfflineOwner,
inactiveSource?: InactiveSource) {
if (inactiveSource && "kind" in owner) fail("prestage_requires_runtime_owner");
const checkpointOwner = "kind" in owner ? null : await lockNativeRunnerCheckpointOwner(tx, owner);
const [task] = await tx.select({ executionRunId: issues.executionRunId }).from(issues)
const [task] = await tx.select({ executionRunId: issues.executionRunId, checkoutRunId: issues.checkoutRunId }).from(issues)
.where(and(eq(issues.companyId, owner.companyId), eq(issues.id, owner.issueId))).for("share");
const authorityRunId = "kind" in owner ? owner.runId : owner.authorityRunId;
if (!task) fail("owner_superseded");
@@ -138,15 +142,44 @@ async function fence(tx: Parameters<Parameters<Db["transaction"]>[0]>[0], owner:
if (owner.leaseOwner === null && !committed) fail("owner_missing");
if (task.executionRunId && task.executionRunId !== authorityRunId && !committed) fail("owner_superseded");
await validateNativeRunnerCheckpointOwner(tx, owner, owned, false);
if (inactiveSource) {
if (!committed || task.executionRunId || task.checkoutRunId) fail("prestage_source_active");
const [active] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, owner.companyId), inArray(heartbeatRuns.status, ["queued", "running"]),
or(eq(heartbeatRuns.nativeIssueId, owner.issueId), eq(heartbeatRuns.issueId, owner.issueId),
eq(heartbeatRuns.nativeSessionId, inactiveSource.normalizedSessionId)))).limit(1);
if (active) fail("prestage_source_active");
const [binding] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, owner.authorityRunId), eq(heartbeatRuns.companyId, owner.companyId),
eq(heartbeatRuns.nativeIssueId, owner.issueId), eq(heartbeatRuns.nativeSessionId, inactiveSource.normalizedSessionId),
eq(heartbeatRuns.runnerInstanceId, inactiveSource.runnerInstanceId)));
const [lease] = await tx.select({ stamp: sql<unknown>`${environmentLeases.metadata}->'nativeHarnessBackup'` })
.from(environmentLeases).where(and(eq(environmentLeases.companyId, owner.companyId),
eq(environmentLeases.issueId, owner.issueId), eq(environmentLeases.providerLeaseId, inactiveSource.providerLeaseId),
sql`${environmentLeases.metadata}->'nativeHarnessBackup'->>'manifestSha256' = ${inactiveSource.manifestSha256}`)).limit(1);
const stamp = readNativeHarnessBackupStamp(lease?.stamp, inactiveSource.providerLeaseId);
if (!binding || stamp?.sessionScopeSha256 !== inactiveSource.scopeDigest
|| stamp.normalizedSessionId !== inactiveSource.normalizedSessionId || stamp.runnerInstanceId !== inactiveSource.runnerInstanceId) {
fail("prestage_source_changed");
}
// The task share lock serializes publication with execution checkout. A
// turn that ran during upload also changes the immutable backup manifest.
const manifest = await readFile(join(inactiveSource.root, "manifest.json"));
const state = JSON.parse(await readFile(join(inactiveSource.root, "runner", "runner-state.json"), "utf8"));
if (`sha256:${createHash("sha256").update(manifest).digest("hex")}` !== inactiveSource.manifestSha256
|| state.runId !== owner.authorityRunId || state.lifecycle !== "suspended") fail("prestage_source_changed");
}
}
export async function persistNativeHarnessBackup(input: {
db: Db; storage: StorageService; owner: NativeRunnerCheckpointFenceOwner | OfflineOwner;
scopeDigest: string; root: string; signal?: AbortSignal; maxArchiveBytes?: number;
inactiveSource?: { normalizedSessionId: string; runnerInstanceId: string; providerLeaseId: string };
}): Promise<void> {
if (!/^[a-f0-9]{64}$/.test(input.scopeDigest)) fail("scope_invalid");
const manifestBytes = await readFile(join(input.root, "manifest.json"));
const manifestSha256 = `sha256:${createHash("sha256").update(manifestBytes).digest("hex")}`;
const inactiveSource = input.inactiveSource ? { ...input.inactiveSource, root: input.root, manifestSha256, scopeDigest: input.scopeDigest } : undefined;
const binding = { companyId: input.owner.companyId, scopeDigest: input.scopeDigest, id: randomUUID() };
const scratch = await mkdtemp(join(os.tmpdir(), "pc-harness-upload-"));
const key = randomBytes(32);
@@ -163,7 +196,7 @@ export async function persistNativeHarnessBackup(input: {
key: key.toString("base64"), iv: iv.toString("base64"), tag: cipher.getAuthTag().toString("base64"), plaintext })) };
if (ref.byteSize > MAX_NATIVE_HARNESS_OBJECT_BYTES) fail("size_limit_exceeded");
await input.db.transaction(async tx => {
await fence(tx, input.owner);
await fence(tx, input.owner, inactiveSource);
await tx.insert(nativeHarnessBackups).values({ ...binding, issueId: input.owner.issueId, runId: input.owner.runId,
manifestSha256, reference: ref });
});
@@ -172,7 +205,7 @@ export async function persistNativeHarnessBackup(input: {
byteSize: ref.byteSize, sha256: ref.sha256, signal: input.signal });
await receive(input.storage, ref, undefined, input.signal);
await input.db.transaction(async tx => {
await fence(tx, input.owner);
await fence(tx, input.owner, inactiveSource);
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`${ref.companyId}:${ref.scopeDigest}`}, 0))`);
const [pending] = await tx.select({ status: nativeHarnessBackups.status }).from(nativeHarnessBackups)
.where(eq(nativeHarnessBackups.id, ref.id)).for("update");
@@ -182,7 +215,7 @@ export async function persistNativeHarnessBackup(input: {
eq(nativeHarnessBackups.status, "current"), sql`${nativeHarnessBackups.runId} <> ${input.owner.runId}::uuid`,
sql`${heartbeatRuns.createdAt} >= (select created_at from heartbeat_runs where id = ${input.owner.runId}::uuid)`)).limit(1);
if (successor) fail("owner_superseded");
await fence(tx, input.owner);
await fence(tx, input.owner, inactiveSource);
await tx.update(nativeHarnessBackups).set({ status: "superseded", updatedAt: new Date() })
.where(and(scope(ref), eq(nativeHarnessBackups.status, "current")));
await tx.update(nativeHarnessBackups).set({ status: "current", updatedAt: new Date() })