mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 18:56:05 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A native per-turn session that continues an earlier conversation first restores its failover backup from durable storage. This happens before the coordinator claim, so no provider has started. > - That restore ran under a fixed 300 s total deadline (`AbortSignal.timeout(300_000)`). The deadline covers the download and the local decrypt, digest, extraction and provider-artifact steps. > - On staging, restore time for resumed sessions grew during the day. It reached 300 s, and then every resumed run of one review agent failed with the generic "The operation was aborted". Fresh sessions were not affected. > - A failure at this point also enters the saved-work reconciliation path, so the user request stopped. > - This pull request bounds each durable read by inactivity instead of total time. A slow transfer continues while bytes arrive. A read that stops sending bytes fails with a named code. > - The benefit is that resumed sessions no longer fail only because their backup is large or the store is slow. ## Linked Issues or Issue Description Refs #19894 (adds the same idle-deadline rule for workspace snapshot reads; this PR covers the separate native failover-backup reads). **What happened?** On staging, a PR-review agent had 17 runs fail in about 80 minutes. Each failure had the message "The operation was aborted" and the code `native_continuation_requires_reconciliation`. Every failing run resumed an existing session (`sessionIdBefore` set, per-turn lifecycle). Every fresh-session run in the same window succeeded. In each failing run, the error came 304–309 s after the `environment.startup` span, and no `native.coordinator.claim` span was recorded. The run result had `executionRecovery: { kind: "bootstrap", providerWorkStarted: false }`. Earlier resumed runs of the same agent show the time from `environment.startup` to `native.coordinator.claim` growing from about 30 s to about 200 s before the failures started. The only fixed 300 s deadline on that path is the failover-backup restore in `restoreDurableHarnessForExecution`. **Expected behavior** A resumed session restores its backup when the store keeps sending bytes, even when the total time is more than five minutes. A read that stalls fails with a named, diagnosable code. A failed restore publishes no partial backup and keeps the saved backup. **Steps to reproduce** 1. Persist a native failover backup with provider artifacts. 2. Restore it through a storage reader that sends bytes slowly, so the full restore takes longer than the deadline. 3. Before this change, the restore fails with "The operation was aborted". A reader that stops sending bytes, with no caller deadline, waits indefinitely. ## What Changed - `native-harness-backup-storage.ts`: add `downloadIdleTimeoutMs` to `restoreDurableNativeHarnessBackup` and `verifyDurableNativeHarnessBackup`. When set, each durable `getObject` (the backup object and the provider artifacts) runs under an idle timer. Each received chunk renews the timer. The timer is active only while a read is in flight, so local decrypt, digest, extraction and copy are never timed. On expiry the whole restore aborts and throws `native_harness_storage_download_stalled`. Caller cancellation keeps its own error. - `verifyRecoverableNativeHarnessBackup` and the executor's `restoreDurableHarnessForExecution` use the five-minute idle limit (`NATIVE_HARNESS_DOWNLOAD_IDLE_TIMEOUT_MS`) instead of `AbortSignal.timeout(300_000)`. - The whole restore or verification keeps a 30-minute outer limit. It fails with `native_harness_storage_restore_deadline_exceeded`, so a hung local step stays bounded and named. - The executor passes the startup Stop signal (`providerPackSourceAbort`) into the restore, so Stop (also a Stop that came before the restore began) ends it. Only a startup that already completed does not reuse its signal. The restore checks the signal after the local copy and before it publishes `current`. - The wrapped storage is a `Proxy` that replaces only `getObject` and forwards every other member to the real service. - `doc/DEVELOPING.md`: document the idle limit and the named code. Previous-release story (`rules/upgrade-safe-run-state.mdc`): no persisted shape changes. Backups, artifact rows, receipts and checkpoints saved by the previous release restore unchanged. A deploy that lands during a restore stops the process. The new process starts the restore again from the saved durable backup under the new limit. The existing hash, size, encryption and atomic publication checks are unchanged. ## Verification Run from `server/`: - `pnpm exec vitest run src/services/native-runtime/native-harness-backup-download-deadline.integration.test.ts` — 4 tests passed (embedded PostgreSQL, local-disk storage, an OpenCode backup with provider artifacts). The tests are in a focused suite, so the existing 990-line storage suite does not grow. - `pnpm exec vitest run src/services/native-runtime/native-harness-backup-storage.integration.test.ts` — 68 existing tests passed. - New: "keeps restoring a backup whose download outlasts the idle window while bytes keep arriving". With the former total-deadline policy, the same slow transfer fails with "The operation was aborted" and publishes nothing (the staging message). With the idle limit, the restore completes after more than twice the window, restores the session and the OpenCode launch artifacts, and verification also completes. - New: "names a download that stops sending bytes and publishes no partial backup". It checks restore and verify, checks that no `current` backup is published, and then restores the same saved backup successfully. - New: "stops at the outer bound during local copy and publishes nothing". - New: "lets Stop end a progressing download with its own reason, including a Stop before it starts". - RED: with the production files reverted to `origin/master`, the stall test does not finish and times out after 10 s, because no stall is detected. The other two tests pass on the old code, because the total deadline was the executor's caller policy. The first test reproduces that policy directly. - `pnpm exec vitest run` on `native-harness-online-preservation`, `native-harness-filesystem-independence` and `native-controller-migration` integration suites passed. `native-warm-checkpoint-ownership.integration.test.ts` fails locally on master as well, because the local Rust `paperclip-runnerd` binary is not built. - `node scripts/typecheck-layers.mjs` passed for all layers. Not verified: a live resumed run on staging with this build. The staging evidence shows where the time goes (before the coordinator claim, on resumed sessions only) but does not show which restore step was slowest. The fix removes the total deadline for every step on that path. ## Risks - A restore that progresses very slowly can now take longer than five minutes. Before, it failed. The run still stops on caller cancellation. - A local step that hangs (for example a stalled disk) is now cut off by the 30-minute outer limit instead of 5 minutes. A stalled local writer also pauses the read through backpressure, so it can be reported as `native_harness_storage_download_stalled`. - A failure before the coordinator claim still enters the existing saved-work reconciliation path. This PR does not change that classification. It removes the cause of these failures. ## Model Used - Provider: Anthropic. Model: Claude Opus 5.5 (`claude-opus-5-5`), with tool use (shell, file edits, read-only browser for API inspection) in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
442 lines
31 KiB
TypeScript
442 lines
31 KiB
TypeScript
import { publishNativeHarnessBackup } from "./native-harness-backup-publication.js";
|
|
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
|
|
import { createReadStream, createWriteStream } from "node:fs";
|
|
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
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, isNotNull, 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";
|
|
import { lockNativeRunnerCheckpointOwner, validateNativeRunnerCheckpointOwner, type NativeRunnerCheckpointFenceOwner } from "./native-runner-checkpoint.js";
|
|
import { loadConfig } from "../../config.js";
|
|
import { getStorageService } from "../../storage/index.js";
|
|
import { readNativeHarnessBackupStamp, verifyNativeHarnessBackupStamp } from "./native-harness-backup-stamp.js";
|
|
import { captureNativeHarnessArchive, restoreNativeHarnessArchive, MAX_NATIVE_HARNESS_OBJECT_BYTES } from "./native-harness-backup-archive.js";
|
|
import { removeNativeHarnessBackup } from "./native-harness-backup-cleanup.js";
|
|
import { hasRemoteTerminationReceipt, resolveRemoteTerminationLease } from "../remote-execution-termination.js";
|
|
import { offlineMigrationExecutionWhere } from "../offline-migration-execution.js";
|
|
import { readNativeHarnessBackupSource } from "./native-harness-backup-source.js";
|
|
import { pinNativeProviderArtifactsForBackup, releaseRetiredNativeProviderArtifacts, restoreNativeProviderArtifacts, retainNativeProviderArtifacts,
|
|
type NativeProviderArtifactReceipt, type NativeProviderArtifactSource } from "./native-provider-artifacts.js";
|
|
import { canonicalProviderPackJson } from "./remote-provider-pack-verification.js";
|
|
import type { SettledControllerArchiveOwner } from "./native-controller-archive-source.js";
|
|
import type { SettledNativeHarnessBackupOwner } from "./native-harness-preservation-source.js";
|
|
import { logActivity, publishActivity, type ActivityPublication } from "../activity-log.js";
|
|
|
|
const SCHEMA = "paperclip.native-harness-object.v1";
|
|
type Binding = { companyId: string; scopeDigest: string; id: string };
|
|
type Reference = Binding & {
|
|
schema: typeof SCHEMA; objectKey: string; sha256: string; byteSize: number;
|
|
material: Record<string, unknown>; manifestSha256: string;
|
|
providerArtifacts?: NativeProviderArtifactReceipt;
|
|
};
|
|
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 };
|
|
function 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(
|
|
eq(nativeHarnessBackups.companyId, binding.companyId), eq(nativeHarnessBackups.scopeDigest, binding.scopeDigest));
|
|
|
|
export function durableNativeHarnessStorage(): StorageService | null {
|
|
if (loadConfig().storageProvider === "s3") return getStorageService();
|
|
if (process.env.PAPERCLIP_EPHEMERAL_SERVER === "true") fail("durable_storage_required");
|
|
return null;
|
|
}
|
|
|
|
export async function verifyRecoverableNativeHarnessBackup(input: {
|
|
db: Db; companyId: string; stamp: unknown; providerLeaseId: string;
|
|
}): Promise<boolean> {
|
|
const stamp = readNativeHarnessBackupStamp(input.stamp, input.providerLeaseId);
|
|
if (!stamp) return false;
|
|
const storage = durableNativeHarnessStorage();
|
|
if (!storage) return verifyNativeHarnessBackupStamp(stamp, input.providerLeaseId);
|
|
const durable = await verifyDurableNativeHarnessBackup({ db: input.db, storage, companyId: input.companyId,
|
|
scopeDigest: stamp.sessionScopeSha256, manifestSha256: stamp.manifestSha256, downloadIdleTimeoutMs: NATIVE_HARNESS_DOWNLOAD_IDLE_TIMEOUT_MS });
|
|
return durable || process.env.PAPERCLIP_EPHEMERAL_SERVER !== "true" && verifyNativeHarnessBackupStamp(stamp, input.providerLeaseId);
|
|
}
|
|
function reference(row: Row): Reference {
|
|
const ref = row.reference as Reference;
|
|
if (ref.schema !== SCHEMA || ref.id !== row.id || ref.companyId !== row.companyId || ref.scopeDigest !== row.scopeDigest
|
|
|| !/^[a-f0-9]{64}$/.test(ref.scopeDigest) || !/^[a-f0-9]{64}$/.test(ref.sha256)
|
|
|| ref.manifestSha256 !== row.manifestSha256 || !/^sha256:[a-f0-9]{64}$/.test(ref.manifestSha256)
|
|
|| ref.objectKey !== `${row.companyId}/native-harness-backups/${row.scopeDigest}/${row.id}.pchn`
|
|
|| !Number.isSafeInteger(ref.byteSize) || ref.byteSize <= 0 || ref.byteSize > MAX_NATIVE_HARNESS_OBJECT_BYTES
|
|
|| !ref.material || typeof ref.material !== "object") fail("reference_invalid");
|
|
return ref;
|
|
}
|
|
async function digest(file: string, signal?: AbortSignal) {
|
|
const hash = createHash("sha256"); let byteSize = 0;
|
|
for await (const bytes of createReadStream(file, { signal })) { hash.update(bytes); byteSize += bytes.length; }
|
|
return { sha256: hash.digest("hex"), byteSize };
|
|
}
|
|
async function receive(storage: StorageService, ref: Reference, target?: string, signal?: AbortSignal) {
|
|
const object = await storage.getObject(ref.companyId, ref.objectKey, { signal });
|
|
const hash = createHash("sha256"); let byteSize = 0;
|
|
const observed = new Transform({ transform(bytes, _encoding, callback) {
|
|
hash.update(bytes); byteSize += bytes.length;
|
|
callback(byteSize > ref.byteSize ? new Error("native_harness_storage_size_mismatch") : null, bytes);
|
|
} });
|
|
try {
|
|
if (target) await pipeline(object.stream, observed, createWriteStream(target, { flags: "wx", mode: 0o600 }), { signal });
|
|
else for await (const bytes of object.stream) {
|
|
signal?.throwIfAborted(); hash.update(bytes); byteSize += bytes.length;
|
|
if (byteSize > ref.byteSize) fail("size_mismatch");
|
|
}
|
|
if (byteSize !== ref.byteSize || hash.digest("hex") !== ref.sha256) fail("hash_mismatch");
|
|
} finally { object.stream.destroy(); }
|
|
}
|
|
|
|
export async function nativeHarnessBackupOwner(db: Db, input: { companyId: string; issueId: string; runId: string; bootId: string }) {
|
|
const [row] = await db.select().from(nativeRunFinalizations).where(and(
|
|
eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.issueId, input.issueId),
|
|
eq(nativeRunFinalizations.runId, input.runId), eq(nativeRunFinalizations.controllerBootId, input.bootId)));
|
|
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 | SettledControllerArchiveOwner | SettledNativeHarnessBackupOwner,
|
|
inactiveSource?: InactiveSource, signal?: AbortSignal) {
|
|
if (inactiveSource && "kind" in owner) fail("prestage_requires_runtime_owner");
|
|
if ("kind" in owner && owner.kind === "settled_native_harness_backup") {
|
|
await (await import("./native-harness-preservation-source.js")).assertSettledNativeHarnessBackupSource(tx as unknown as Db, owner, signal);
|
|
return;
|
|
}
|
|
if ("kind" in owner && owner.kind === "settled_controller_archive") {
|
|
await (await import("./native-controller-archive-source.js")).assertSettledControllerArchiveSource(tx as unknown as Db, owner);
|
|
return;
|
|
}
|
|
const checkpointOwner = "kind" in owner ? null : await lockNativeRunnerCheckpointOwner(tx, owner);
|
|
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");
|
|
if ("kind" in owner) {
|
|
if (!owner.writersStopped) fail("offline_writers_required");
|
|
const [active] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(offlineMigrationExecutionWhere()).limit(1);
|
|
if (active) fail("offline_writers_required");
|
|
const [run] = await tx.select({ finishedAt: heartbeatRuns.finishedAt, startedAt: heartbeatRuns.startedAt, createdAt: heartbeatRuns.createdAt, status: heartbeatRuns.status }).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.id, owner.runId), eq(heartbeatRuns.companyId, owner.companyId),
|
|
eq(heartbeatRuns.nativeIssueId, owner.issueId), eq(heartbeatRuns.runtimeMode, "native"))).for("share");
|
|
const [coordinator] = await tx.select({ leaseOwner: nativeRunFinalizations.leaseOwner,
|
|
leaseExpiresAt: nativeRunFinalizations.leaseExpiresAt, phase: nativeRunFinalizations.phase,
|
|
resultId: nativeRunFinalizations.resultId, nextAttemptAt: nativeRunFinalizations.nextAttemptAt })
|
|
.from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, owner.runId)).for("share");
|
|
const [clock] = await tx.execute<{ now: Date }>(sql`select clock_timestamp() as now`);
|
|
if (!run?.finishedAt || !["succeeded", "failed", "cancelled", "interrupted", "timed_out"].includes(run.status)
|
|
|| !coordinator || coordinator.leaseOwner && (!coordinator.leaseExpiresAt || coordinator.leaseExpiresAt > new Date(clock.now))) {
|
|
fail("offline_owner_unsettled");
|
|
}
|
|
if (owner.terminalFailureOnly) {
|
|
if (run.status !== "failed") fail("offline_owner_unsettled");
|
|
if (coordinator.phase !== "terminal_failure") {
|
|
// Evidence archival does not settle or resume this stale coordinator.
|
|
// Lease expiry alone is insufficient: every remote writer needs its
|
|
// exact host-owned stop receipt, recorded after execution began.
|
|
if (coordinator.phase !== "observed" || coordinator.resultId || coordinator.nextAttemptAt
|
|
|| task.executionRunId === owner.runId) fail("offline_owner_unsettled");
|
|
const leases = await tx.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, owner.companyId), eq(environmentLeases.heartbeatRunId, owner.runId))).for("share");
|
|
const stopped = await Promise.all(leases.map(lease => resolveRemoteTerminationLease(tx as unknown as Db, lease)));
|
|
if (!stopped.length || stopped.some(lease => {
|
|
const receipt = lease.metadata?.remoteExecutionTermination as { confirmedAt?: unknown } | undefined;
|
|
const confirmedAt = Date.parse(String(receipt?.confirmedAt));
|
|
return lease.issueId !== owner.issueId || !hasRemoteTerminationReceipt(lease)
|
|
|| !Number.isFinite(confirmedAt) || confirmedAt < (run.startedAt ?? run.createdAt).getTime() || confirmedAt > new Date(clock.now).getTime();
|
|
})) fail("offline_owner_unsettled");
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
const [clock] = await tx.execute<{ now: Date }>(sql`select clock_timestamp() as now`);
|
|
const owned = { ...checkpointOwner!, now: clock.now };
|
|
const committed = owned.phase === "committed" && owned.resultId !== null && owned.leaseOwner === null;
|
|
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), eq(heartbeatRuns.status, "succeeded"),
|
|
isNotNull(heartbeatRuns.finishedAt)));
|
|
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 || stamp.sessionScopeSha256 !== inactiveSource.scopeDigest
|
|
|| stamp.normalizedSessionId !== inactiveSource.normalizedSessionId || stamp.runnerInstanceId !== inactiveSource.runnerInstanceId) {
|
|
fail("prestage_source_changed");
|
|
}
|
|
const source = await readNativeHarnessBackupSource(inactiveSource.root);
|
|
if (!source) fail("prestage_source_changed");
|
|
const { manifestBytes: manifest, manifest: identity, state } = source;
|
|
if (`sha256:${createHash("sha256").update(manifest).digest("hex")}` !== inactiveSource.manifestSha256
|
|
|| identity.normalizedSessionId !== stamp.normalizedSessionId || identity.runnerInstanceId !== stamp.runnerInstanceId
|
|
|| identity.sourceProviderLeaseId !== stamp.sourceProviderLeaseId || identity.completedAt !== stamp.completedAt
|
|
|| state.runId !== owner.authorityRunId || state.lifecycle !== "suspended") fail("prestage_source_changed");
|
|
}
|
|
}
|
|
|
|
export async function assertNativeHarnessBackupOwner(db: Db, owner: NativeRunnerCheckpointFenceOwner): Promise<void> {
|
|
await db.transaction(tx => fence(tx, owner));
|
|
}
|
|
|
|
export async function persistNativeHarnessBackup(input: {
|
|
db: Db; storage: StorageService; owner: NativeRunnerCheckpointFenceOwner | OfflineOwner | SettledControllerArchiveOwner | SettledNativeHarnessBackupOwner;
|
|
scopeDigest: string; root: string; signal?: AbortSignal; maxArchiveBytes?: number;
|
|
inactiveSource?: { normalizedSessionId: string; runnerInstanceId: string; providerLeaseId: string };
|
|
providerArtifacts?: NativeProviderArtifactSource;
|
|
}): Promise<void> {
|
|
if (!/^[a-f0-9]{64}$/.test(input.scopeDigest)) fail("scope_invalid");
|
|
if ("kind" in input.owner && input.owner.kind === "settled_native_harness_backup"
|
|
&& (input.scopeDigest !== input.owner.scopeDigest || input.root !== input.owner.root)) fail("preservation_source_changed");
|
|
if (input.providerArtifacts) {
|
|
if ("kind" in input.owner) fail("provider_artifacts_require_runtime_owner");
|
|
const receipt = await retainNativeProviderArtifacts({ ...input, owner: input.owner, source: input.providerArtifacts });
|
|
const manifestPath = join(input.root, "manifest.json");
|
|
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
await writeFile(manifestPath, JSON.stringify({ ...manifest, providerArtifacts: receipt }), { mode: 0o600 });
|
|
}
|
|
const source = input.inactiveSource ? await readNativeHarnessBackupSource(input.root) : undefined;
|
|
if (source === null) fail("prestage_source_changed");
|
|
const manifestBytes = source?.manifestBytes ?? await readFile(join(input.root, "manifest.json"));
|
|
const providerArtifacts = JSON.parse(manifestBytes.toString("utf8")).providerArtifacts as NativeProviderArtifactReceipt | undefined;
|
|
const manifestSha256 = `sha256:${createHash("sha256").update(manifestBytes).digest("hex")}`;
|
|
if ("kind" in input.owner && input.owner.kind === "settled_native_harness_backup"
|
|
&& manifestSha256 !== input.owner.manifestSha256) fail("preservation_source_changed");
|
|
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);
|
|
const publications: ActivityPublication[] = [];
|
|
try {
|
|
const archive = join(scratch, "archive"), encrypted = join(scratch, "encrypted");
|
|
await captureNativeHarnessArchive(input.root, archive, input.signal, input.maxArchiveBytes, providerArtifacts ? ["provider-artifacts"] : []);
|
|
const plaintext = await digest(archive, input.signal), iv = randomBytes(12);
|
|
const cipher = createCipheriv("aes-256-gcm", key, iv); cipher.setAAD(aad(binding));
|
|
await pipeline(createReadStream(archive), cipher, createWriteStream(encrypted, { flags: "wx", mode: 0o600 }), { signal: input.signal });
|
|
const ref: Reference = { schema: SCHEMA, ...binding, manifestSha256, ...(providerArtifacts ? { providerArtifacts } : {}),
|
|
objectKey: `${binding.companyId}/native-harness-backups/${binding.scopeDigest}/${binding.id}.pchn`,
|
|
...await digest(encrypted, input.signal),
|
|
material: createLocalEncryptedCipher("write").encrypt(JSON.stringify({ schema: SCHEMA, ...binding,
|
|
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, inactiveSource, input.signal);
|
|
await tx.insert(nativeHarnessBackups).values({ ...binding, issueId: input.owner.issueId, runId: input.owner.runId,
|
|
manifestSha256, reference: ref });
|
|
if (providerArtifacts) await pinNativeProviderArtifactsForBackup(tx, { companyId: input.owner.companyId,
|
|
runId: input.owner.runId, backupId: binding.id, receipt: providerArtifacts });
|
|
});
|
|
await input.storage.putFile({ companyId: ref.companyId, namespace: "native-harness-backups", objectKey: ref.objectKey,
|
|
originalFilename: null, contentType: "application/octet-stream", body: createReadStream(encrypted),
|
|
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, inactiveSource, input.signal);
|
|
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");
|
|
if (pending?.status !== "pending") fail("publication_retired");
|
|
const [successor] = await tx.select({ id: nativeHarnessBackups.id }).from(nativeHarnessBackups)
|
|
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, nativeHarnessBackups.runId)).where(and(scope(ref),
|
|
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, inactiveSource, input.signal);
|
|
if ("kind" in input.owner && input.owner.kind === "settled_controller_archive" && input.owner.filesystemIndependence) {
|
|
const [existing] = await tx.select({ id: nativeHarnessBackups.id }).from(nativeHarnessBackups).where(and(scope(ref),
|
|
eq(nativeHarnessBackups.runId, input.owner.runId), eq(nativeHarnessBackups.manifestSha256, manifestSha256),
|
|
eq(nativeHarnessBackups.status, "current"))).limit(1);
|
|
if (existing) {
|
|
await tx.update(nativeHarnessBackups).set({ status: "superseded", updatedAt: new Date() })
|
|
.where(and(eq(nativeHarnessBackups.id, ref.id), eq(nativeHarnessBackups.status, "pending")));
|
|
return;
|
|
}
|
|
}
|
|
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() })
|
|
.where(and(eq(nativeHarnessBackups.id, ref.id), eq(nativeHarnessBackups.status, "pending")));
|
|
if ("kind" in input.owner && input.owner.kind === "settled_native_harness_backup") {
|
|
await logActivity(tx as unknown as Db, { companyId: input.owner.companyId, actorType: "system", actorId: "native-harness-preservation",
|
|
action: "native_harness_backup_preserved", entityType: "heartbeat_run", entityId: input.owner.runId,
|
|
agentId: input.owner.agentId, runId: input.owner.runId,
|
|
details: { scopeDigest: ref.scopeDigest, manifestSha256, backupId: ref.id, originalFilesRetained: true } }, publications);
|
|
}
|
|
if ("kind" in input.owner && input.owner.kind === "settled_controller_archive" && input.owner.filesystemIndependence) {
|
|
await logActivity(tx as unknown as Db, { companyId: input.owner.companyId, actorType: "system", actorId: "native-harness-preservation",
|
|
action: "native_controller_source_evidence_preserved", entityType: "heartbeat_run", entityId: input.owner.runId,
|
|
agentId: input.owner.agentId, runId: input.owner.runId, details: { scopeDigest: ref.scopeDigest, manifestSha256,
|
|
backupId: ref.id, sourceCapabilityFingerprint: input.owner.filesystemIndependence.source.fingerprint } }, publications);
|
|
}
|
|
});
|
|
for (const publication of publications) publishActivity(publication);
|
|
} finally { key.fill(0); await removeNativeHarnessBackup(scratch); }
|
|
}
|
|
|
|
/** How long a durable read may go without progress. */
|
|
export const NATIVE_HARNESS_DOWNLOAD_IDLE_TIMEOUT_MS = 300_000;
|
|
/** Outer bound for one whole restore or verification, including local steps. */
|
|
export const NATIVE_HARNESS_RESTORE_MAX_MS = 30 * 60_000;
|
|
export const NATIVE_HARNESS_DOWNLOAD_STALLED = "native_harness_storage_download_stalled";
|
|
export const NATIVE_HARNESS_RESTORE_DEADLINE_EXCEEDED = "native_harness_storage_restore_deadline_exceeded";
|
|
|
|
/**
|
|
* Bound each durable read by inactivity, not by a short total time. A large or
|
|
* slow backup keeps restoring while bytes flow through the read; a read that
|
|
* makes no progress for the idle window aborts the whole restore with a named
|
|
* error. A generous outer bound keeps a hung local step diagnosable. Caller
|
|
* cancellation (Stop) keeps its own reason.
|
|
*/
|
|
async function withDownloadProgressDeadline<T>(
|
|
input: { storage: StorageService; signal?: AbortSignal; downloadIdleTimeoutMs?: number; restoreMaxMs?: number },
|
|
run: (storage: StorageService, signal: AbortSignal | undefined) => Promise<T>,
|
|
): Promise<T> {
|
|
const idleMs = input.downloadIdleTimeoutMs;
|
|
if (idleMs === undefined) return run(input.storage, input.signal);
|
|
if (!Number.isSafeInteger(idleMs) || idleMs <= 0) throw new Error("native_harness_storage_idle_timeout_invalid");
|
|
const stall = new AbortController();
|
|
const outer = setTimeout(() => stall.abort(new Error(NATIVE_HARNESS_RESTORE_DEADLINE_EXCEEDED)),
|
|
input.restoreMaxMs ?? Math.max(idleMs, NATIVE_HARNESS_RESTORE_MAX_MS));
|
|
outer.unref?.();
|
|
const signal = input.signal ? AbortSignal.any([input.signal, stall.signal]) : stall.signal;
|
|
const base = input.storage;
|
|
const getObject: StorageService["getObject"] = async (companyId, objectKey, options) => {
|
|
let timer: NodeJS.Timeout | undefined;
|
|
const disarm = () => { if (timer) clearTimeout(timer); timer = undefined; };
|
|
const arm = () => {
|
|
disarm();
|
|
timer = setTimeout(() => stall.abort(new Error(NATIVE_HARNESS_DOWNLOAD_STALLED)), idleMs);
|
|
timer.unref?.();
|
|
};
|
|
arm();
|
|
let object: Awaited<ReturnType<StorageService["getObject"]>>;
|
|
try {
|
|
object = await base.getObject(companyId, objectKey, {
|
|
...options, signal: options?.signal ? AbortSignal.any([options.signal, signal]) : signal });
|
|
} catch (error) { disarm(); throw error; }
|
|
arm();
|
|
// Chunks pass through as the consumer reads them, so a read is idle when
|
|
// neither the store nor the local writer makes progress.
|
|
const observed = new Transform({
|
|
transform(bytes, _encoding, callback) { arm(); callback(null, bytes); },
|
|
flush(callback) { disarm(); callback(); },
|
|
});
|
|
observed.once("close", disarm);
|
|
pipeline(object.stream, observed, { signal }).catch(() => disarm());
|
|
return { ...object, stream: observed };
|
|
};
|
|
// Forward every other member to the real service so its own state stays intact.
|
|
const storage = new Proxy(base, {
|
|
get(target, property) {
|
|
if (property === "getObject") return getObject;
|
|
const value = Reflect.get(target, property, target) as unknown;
|
|
return typeof value === "function" ? (value as (...args: unknown[]) => unknown).bind(target) : value;
|
|
},
|
|
});
|
|
try {
|
|
return await run(storage, signal);
|
|
} catch (error) {
|
|
if (stall.signal.aborted && !input.signal?.aborted) throw stall.signal.reason;
|
|
throw error;
|
|
} finally { clearTimeout(outer); }
|
|
}
|
|
|
|
export async function restoreDurableNativeHarnessBackup(input: {
|
|
db: Db; storage: StorageService; companyId: string; scopeDigest: string;
|
|
backupRoot: string; signal?: AbortSignal; downloadIdleTimeoutMs?: number; restoreMaxMs?: number;
|
|
}): Promise<boolean> {
|
|
return withDownloadProgressDeadline(input, (storage, signal) => restoreCurrentNativeHarnessBackup({ ...input, storage, signal }));
|
|
}
|
|
|
|
async function restoreCurrentNativeHarnessBackup(input: {
|
|
db: Db; storage: StorageService; companyId: string; scopeDigest: string;
|
|
backupRoot: string; signal?: AbortSignal;
|
|
}): Promise<boolean> {
|
|
const [row] = await input.db.select().from(nativeHarnessBackups).where(and(scope(input), eq(nativeHarnessBackups.status, "current")));
|
|
if (!row) return false;
|
|
const ref = reference(row);
|
|
const wrapped = JSON.parse(createLocalEncryptedCipher("read").decrypt(ref.material));
|
|
if (wrapped.schema !== SCHEMA || wrapped.id !== ref.id || wrapped.companyId !== ref.companyId
|
|
|| wrapped.scopeDigest !== ref.scopeDigest) fail("binding_mismatch");
|
|
const current = join(input.backupRoot, "current");
|
|
const localManifest = await readFile(join(current, "manifest.json")).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return null; throw error;
|
|
});
|
|
if (localManifest && `sha256:${createHash("sha256").update(localManifest).digest("hex")}` === ref.manifestSha256) {
|
|
if (canonicalProviderPackJson(JSON.parse(localManifest.toString("utf8")).providerArtifacts)
|
|
!== canonicalProviderPackJson(ref.providerArtifacts)) fail("provider_artifacts_mismatch");
|
|
if (ref.providerArtifacts) await restoreNativeProviderArtifacts({ ...input, receipt: ref.providerArtifacts,
|
|
targetRoot: join(current, "provider-artifacts") });
|
|
return true;
|
|
}
|
|
const scratch = await mkdtemp(join(os.tmpdir(), "pc-harness-restore-"));
|
|
const key = Buffer.from(wrapped.key, "base64");
|
|
try {
|
|
if (key.length !== 32) fail("key_invalid");
|
|
const encrypted = join(scratch, "encrypted"), archive = join(scratch, "archive");
|
|
await receive(input.storage, ref, encrypted, input.signal);
|
|
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(wrapped.iv, "base64"));
|
|
decipher.setAAD(aad(ref)); decipher.setAuthTag(Buffer.from(wrapped.tag, "base64"));
|
|
await pipeline(createReadStream(encrypted), decipher, createWriteStream(archive, { flags: "wx", mode: 0o600 }), { signal: input.signal });
|
|
const actual = await digest(archive, input.signal);
|
|
if (actual.sha256 !== wrapped.plaintext?.sha256 || actual.byteSize !== wrapped.plaintext?.byteSize) fail("plaintext_mismatch");
|
|
const restored = join(scratch, "restored");
|
|
await restoreNativeHarnessArchive(archive, restored, input.signal);
|
|
const manifest = await readFile(join(restored, "manifest.json"));
|
|
if (`sha256:${createHash("sha256").update(manifest).digest("hex")}` !== ref.manifestSha256) fail("manifest_mismatch");
|
|
const restoredManifest = JSON.parse(manifest.toString("utf8"));
|
|
if (canonicalProviderPackJson(restoredManifest.providerArtifacts) !== canonicalProviderPackJson(ref.providerArtifacts)) fail("provider_artifacts_mismatch");
|
|
if (ref.providerArtifacts) await restoreNativeProviderArtifacts({ ...input, receipt: ref.providerArtifacts,
|
|
targetRoot: join(restored, "provider-artifacts") });
|
|
const { mkdir, cp } = await import("node:fs/promises");
|
|
await mkdir(input.backupRoot, { recursive: true, mode: 0o700 });
|
|
const pending = join(input.backupRoot, `.durable-${randomUUID()}`);
|
|
try {
|
|
await cp(restored, pending, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true, errorOnExist: true, force: false });
|
|
// A deadline or Stop that fired during the local copy must not publish.
|
|
input.signal?.throwIfAborted();
|
|
await publishNativeHarnessBackup({ db: input.db, companyId: input.companyId, scopeDigest: input.scopeDigest,
|
|
backupRoot: input.backupRoot, pendingRoot: pending });
|
|
} finally { await removeNativeHarnessBackup(pending); }
|
|
return true;
|
|
} finally { key.fill(0); await removeNativeHarnessBackup(scratch); }
|
|
}
|
|
|
|
export async function verifyDurableNativeHarnessBackup(input: {
|
|
db: Db; storage: StorageService; companyId: string; scopeDigest: string; manifestSha256: string; backupId?: string;
|
|
signal?: AbortSignal; downloadIdleTimeoutMs?: number;
|
|
}): Promise<boolean> {
|
|
const [row] = await input.db.select().from(nativeHarnessBackups).where(and(scope(input),
|
|
eq(nativeHarnessBackups.manifestSha256, input.manifestSha256), input.backupId ? eq(nativeHarnessBackups.id, input.backupId) : undefined,
|
|
inArray(nativeHarnessBackups.status, ["current", "superseded"]))).limit(1);
|
|
if (!row) return false;
|
|
await withDownloadProgressDeadline(input, (storage, signal) => receive(storage, reference(row), undefined, signal));
|
|
return true;
|
|
}
|
|
|
|
export async function deleteRetiredNativeHarnessBackup(db: Db, storage: StorageService, id: string, signal?: AbortSignal) {
|
|
const [row] = await db.select().from(nativeHarnessBackups).where(and(
|
|
eq(nativeHarnessBackups.id, id), eq(nativeHarnessBackups.status, "retired")));
|
|
if (!row) return false;
|
|
const ref = reference(row);
|
|
if (ref.providerArtifacts) await releaseRetiredNativeProviderArtifacts({ db, storage, companyId: row.companyId,
|
|
runId: row.runId, backupId: row.id, receipt: ref.providerArtifacts, signal });
|
|
await storage.deleteObject(ref.companyId, ref.objectKey, { allVersions: true, signal });
|
|
await db.delete(nativeHarnessBackups).where(and(eq(nativeHarnessBackups.id, id), eq(nativeHarnessBackups.status, "retired")));
|
|
return true;
|
|
}
|