mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native sessions retain provider checkpoints and recovery state across turns. > - Server replacement must preserve every remaining local data dependency. > - A legacy backup stamp can outlive a destroyed provider and an absent local backup. > - Surviving skill files can already have exact immutable package copies. > - Raw controller files also contain stream events that database checkpoints omit. > - This pull request records verified filesystem independence under the original-volume maintenance owner. > - Replacement startup checks durable bindings while existing resume failures and policies remain intact. ## Linked Issues or Issue Description Refs #19715, #19729, #19733. **What happened?** Startup treats every retained legacy backup stamp without a current backup row as a local filesystem dependency. This also blocks sessions whose provider was already destroyed and whose remaining local skill bytes have exact durable copies. **Expected behavior** An authorized maintenance owner can prove that an exact retained source has no uncovered local harness payload. Startup can use that proof after replacement. The proof must preserve failed recovery state and must not invent a provider backup. **Steps to reproduce** 1. Retain a terminal native session, its checkpoint, and its legacy backup stamp. 2. Record exact canonical destroyed-provider receipts. Keep only admitted immutable skill files on the original host. 3. Publish the owner-verified independence receipt. The old startup predicate still blocks; the updated predicate validates its durable bindings. ## What Changed - Add an internal original-volume capability. It requires a private nonce anchor, independently reviewed attachment evidence, current reserved runtime identity, and a witness that is at most 120 seconds old. - Verify retained and selected-source inputs, checkpoints, leases, destroyed-provider receipts, released ownership, and exact immutable skill references. - Inspect canonical persistence roots and compare surviving skill bytes and modes. Refuse any provider directory present outside the current persistence profile, including an old Codex home after a preconfigured-home switch. Unknown data, backups, history, owners, or source changes still block publication. - Preserve raw controller and supervisor bytes through the existing encrypted evidence archive owner. Match recovery records to the database checkpoint, authenticate the exact archive row, and compare the original bytes again before publication. The archive grants no executable authority. - Publish one audited receipt with source locks and a final comparison. Keep original files, stamps, warnings, statuses, and session pointers. A late capture retains a valid published winner and refuses an invalid receipt. - Validate published receipts from current database bindings on replacement hosts. Add no startup filesystem probe, object read, provider call, or recurring worker. ## Verification - Genuine baseline regression: restore the original startup predicate while keeping the actual receipt owner. The positive PostgreSQL/filesystem case fails with expected `false`, actual `true` after valid publication. Restore the changed predicate; the case passes. - Final rebased head `e78f4b4424`: 42 affected PostgreSQL/filesystem cases pass. This includes 32 owner guards, three paired continuation cases, six existing failed-source archive controls, and one existing lease-verified backup migration control. The other 113 tests in the selected existing files were explicitly unselected. - Genuine controller baseline: the unchanged prior owner publishes no raw archive and accepts missing checkpoint parts. Both new regressions fail. The repair passes encrypted byte-for-byte controller/supervisor restore, missing checkpoint, foreign source, active source, and changed-after-upload controls. - Genuine omitted-provider baseline: the unchanged owner accepts old Codex, OpenCode, and ACPX history after a preconfigured-home switch. All three refusal cases fail. The fix passes those cases and permits preconfigured execution with no omitted directory. - Genuine late-publication baseline: both concurrent winner tests fail before the repair. The repair keeps the existing valid receipt, refuses an invalid one, and keeps one audit event. A delayed raw upload also retains the first authenticated archive and its receipt. - Capability controls: 12 passed, including empty replacement refusal, stale or future evidence, changed deployment, missing volume, path aliases, changed anchor, and current remount identity. - The actual server-to-Runner boundary preserves explicit recovery policy on original and empty homes. The separate actual CREATED-sandbox preparation callback preserves checkpoint identity and policy without a backup. Provider I/O and final transport construction are controlled. - Warm sessions retain the same canonical refusal on both homes, make no backend/provider attempt, and retain the saved checkpoints. - Server TypeScript, module boundary checks, diff checks, and a private-data scan pass. The Runner TypeScript build was refreshed after rebase to include two new upstream exports. - Fresh Greptile review is 5/5 with no unresolved threads on `e78f4b44240b0fc33c2fd7cae2d5b4a2578e3153`; check `128330103712` completed successfully. Required CI remains pending. No live preservation or enrollment action ran. Focused command from `server/`: ```sh node ../cli/node_modules/tsx/dist/cli.mjs ../packages/db/src/test-postgres-command.ts -- node ../node_modules/vitest/vitest.mjs run src/services/native-runtime/native-harness-filesystem-independence.integration.test.ts src/services/native-runtime/native-harness-filesystem-independence-continuation.integration.test.ts src/services/native-runtime/native-controller-migration.integration.test.ts src/services/native-runtime/native-harness-backup-storage.integration.test.ts --testNamePattern 'owner-verified|legacy harness continuation|preserves a failed source online|migrates a lease-verified' --hookTimeout 90000 --maxWorkers 1 --fsModuleCache ``` ## Risks - This proves that no uncovered harness payload remains local. It does not recover provider history or certify that no other copy exists. Existing governed-wait and warm-session failures remain. - Unknown providers, retained owners, extra payloads, missing references, or changed source bindings fail closed. Invalid receipts require investigation; this change does not repair or replace them automatically. - The maintenance capability requires independently reviewed Railway attachment evidence. The original anchor remains stable across normal releases; each new staging invocation needs current deployment and mount evidence. - Startup validation adds bounded per-scope database reads. It pages candidate scopes and caps each source history at 500 rows. Measure the affected cohort's actual startup latency on the qualified serving build before enrollment. - Raw archives use the existing backup ledger, encryption, retention, and restore machinery. They preserve evidence only. They do not repair missing provider history or grant a new resume policy. - Local tests control provider and transport boundaries. Live staging remains a separate step after review, merge, normal delivery, and a fresh original-volume capability. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, and local code/test execution. The exact runtime variant and context-window limit are not exposed in this session. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
255 lines
16 KiB
TypeScript
255 lines
16 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readdir } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
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 { 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";
|
|
import { NativeHarnessBackupSourceError, readNativeHarnessBackupSource } from "./native-harness-backup-source.js";
|
|
import { settledNativeHarnessBackupOwner } from "./native-harness-preservation-source.js";
|
|
import { hasValidNativeHarnessFilesystemIndependence } from "./native-harness-filesystem-independence.js";
|
|
|
|
export async function hasLocalNativeHarnessObligations(db: Db): Promise<boolean> {
|
|
let afterSession = "", afterScope = "";
|
|
for (;;) {
|
|
const rows = await db.execute<{ company_id: string; selected_session_id: string; scope_digest: string; has_receipt: boolean }>(sql`select distinct
|
|
s.company_id, s.id::text as selected_session_id, coalesce(e.metadata->'nativeHarnessBackup'->>'sessionScopeSha256', '') as scope_digest,
|
|
r.runner_profile_json ? 'nativeHarnessFilesystemIndependence' as has_receipt from agent_task_sessions s
|
|
join heartbeat_runs r on r.id = s.last_run_id and r.company_id = s.company_id and r.agent_id = s.agent_id
|
|
join environment_leases e on e.company_id = r.company_id and e.issue_id = r.native_issue_id
|
|
and e.metadata->'nativeHarnessBackup'->>'normalizedSessionId' = r.native_session_id::text
|
|
where s.adapter_type = 'paperclip_runner'
|
|
and e.metadata->'nativeHarnessBackup'->>'schema' = 'paperclip.native-harness-backup-stamp.v2'
|
|
and not exists (select 1 from native_harness_backups b where b.company_id = r.company_id
|
|
and b.scope_digest = e.metadata->'nativeHarnessBackup'->>'sessionScopeSha256' and b.status = 'current')
|
|
and (s.id::text, coalesce(e.metadata->'nativeHarnessBackup'->>'sessionScopeSha256', '')) > (${afterSession}, ${afterScope})
|
|
order by selected_session_id, scope_digest limit 100`);
|
|
for (const row of rows) if (!row.has_receipt || !await hasValidNativeHarnessFilesystemIndependence(db, {
|
|
companyId: row.company_id, selectedSessionId: row.selected_session_id, scopeDigest: row.scope_digest,
|
|
})) return true;
|
|
if (rows.length < 100) return false;
|
|
afterSession = rows.at(-1)!.selected_session_id; afterScope = rows.at(-1)!.scope_digest;
|
|
}
|
|
}
|
|
|
|
const MAX_PARALLEL_BACKUPS = 4;
|
|
// An archive and its encrypted copy coexist. Keep the same worst-case scratch
|
|
// bound as one maximum-size serial transfer, including tar header overhead.
|
|
const MAX_SCRATCH_BYTES = 2 * MAX_NATIVE_HARNESS_OBJECT_BYTES;
|
|
|
|
async function prepareSource(base: string, scopeDigest: string) {
|
|
const root = join(base, scopeDigest, "failover-backups", "current");
|
|
const files = await readNativeHarnessBackupSource(root);
|
|
if (!files) return null;
|
|
const { manifestBytes, manifest, state } = files;
|
|
if (state.lifecycle !== "suspended" || typeof state.runId !== "string"
|
|
|| manifest.schema !== "paperclip.native-harness-backup.v1") throw new Error("native_harness_migration_source_unsettled");
|
|
const names = new Set<string>();
|
|
let bytes = manifestBytes.length, closed = Array.isArray(manifest.directories) && manifest.directories.length > 0;
|
|
for (const value of Array.isArray(manifest.directories) ? manifest.directories : []) {
|
|
const entry = value as { name?: unknown; bytes?: unknown } | null;
|
|
if (!entry || typeof entry.name !== "string" || !/^[A-Za-z0-9._-]+$/.test(entry.name)
|
|
|| [".", ".."].includes(entry.name) || !Number.isSafeInteger(entry.bytes) || Number(entry.bytes) < 0) { closed = false; break; }
|
|
names.add(entry.name); bytes += Number(entry.bytes);
|
|
}
|
|
const entries = await readdir(root, { withFileTypes: true });
|
|
closed &&= entries.length === names.size + 1 && entries.every(entry => entry.name === "manifest.json"
|
|
? entry.isFile() : names.has(entry.name) && entry.isDirectory() && !entry.isSymbolicLink());
|
|
// Unknown extra files are preserved with an exclusive full-size reservation.
|
|
const archiveLimit = closed && Number.isSafeInteger(bytes) && bytes <= MAX_NATIVE_HARNESS_BYTES
|
|
? nativeHarnessArchiveSizeLimit(bytes)
|
|
: MAX_NATIVE_HARNESS_OBJECT_BYTES;
|
|
return { root, scopeDigest, runId: state.runId as string, archiveLimit,
|
|
sessionId: manifest.normalizedSessionId, runnerId: manifest.runnerInstanceId,
|
|
manifestSha256: `sha256:${createHash("sha256").update(manifestBytes).digest("hex")}` };
|
|
}
|
|
|
|
type Source = NonNullable<Awaited<ReturnType<typeof prepareSource>>>;
|
|
|
|
async function sourceRun(db: Db, source: Source) {
|
|
const [run] = await db.select({ companyId: heartbeatRuns.companyId, issueId: heartbeatRuns.nativeIssueId,
|
|
sessionId: heartbeatRuns.nativeSessionId, runnerId: heartbeatRuns.runnerInstanceId,
|
|
status: heartbeatRuns.status, finishedAt: heartbeatRuns.finishedAt }).from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, source.runId));
|
|
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) {
|
|
if (current.manifestSha256 !== manifestSha256) throw new Error("native_harness_migration_durable_source_conflict");
|
|
await verifyDurableNativeHarnessBackup({ db, storage: input.storage, companyId: run.companyId,
|
|
scopeDigest, manifestSha256: current.manifestSha256, signal: input.signal });
|
|
return 1;
|
|
}
|
|
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, scopeDigest: string) => 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, scopeDigest);
|
|
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 counts = await mapSources(input, (source, signal) => migrateSource(db, { storage: input.storage, signal }, source));
|
|
return counts.reduce<number>((total, count) => total + count, 0);
|
|
}
|
|
|
|
export async function prestageNativeHarnessBackups(db: Db, input: { storage: StorageService; signal: AbortSignal }) {
|
|
const deferSource = (scopeDigest: string, reason: string, outcome: string) => {
|
|
console.warn(JSON.stringify({ schema: "paperclip.native-harness-prestage-source.v1", scopeDigest, reason }));
|
|
return outcome;
|
|
};
|
|
const outcomes = await mapSources({ ...input, concurrency: 1, unavailableSource: (error, scopeDigest) => {
|
|
if (error instanceof NativeHarnessBackupSourceError || error instanceof Error && error.message === "native_harness_migration_source_unsettled") {
|
|
return deferSource(scopeDigest, error.message, "source_unavailable");
|
|
}
|
|
if (["ENOENT", "ENOTDIR", "EISDIR", "ELOOP", "EACCES", "EPERM"].includes((error as NodeJS.ErrnoException)?.code ?? "")) {
|
|
return deferSource(scopeDigest, "native_harness_migration_source_unavailable", "source_unavailable");
|
|
}
|
|
return 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) {
|
|
if (!run.finishedAt || !["failed", "cancelled", "interrupted", "timed_out"].includes(run.status)) return "source_not_successful";
|
|
const providerLeaseId = await verifiedSourceLease(db, source, run, signal);
|
|
if (!providerLeaseId) return "source_unverified";
|
|
try {
|
|
const owner = await settledNativeHarnessBackupOwner(db, { companyId: run.companyId, issueId: run.issueId,
|
|
runId: source.runId, sessionId: run.sessionId, runnerInstanceId: run.runnerId, scopeDigest: source.scopeDigest,
|
|
root: source.root, manifestSha256: source.manifestSha256, providerLeaseId, signal });
|
|
if (!owner) return "source_not_successful";
|
|
await persistNativeHarnessBackup({ db, storage: input.storage, owner, root: source.root,
|
|
scopeDigest: source.scopeDigest, signal, maxArchiveBytes: source.archiveLimit });
|
|
return "published";
|
|
} catch (error) {
|
|
signal.throwIfAborted();
|
|
if (error instanceof NativeHarnessBackupSourceError || error instanceof Error && ["native_harness_preservation_source_unsettled",
|
|
"native_harness_archive_invalid"].includes(error.message)
|
|
|| ((error as { code?: string; cause?: { code?: string } })?.code
|
|
?? (error as { cause?: { code?: string } })?.cause?.code) === "55P03") {
|
|
return deferSource(source.scopeDigest, error instanceof Error ? error.message : "native_harness_source_busy", "source_changed");
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
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 instanceof NativeHarnessBackupSourceError || 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 deferSource(source.scopeDigest, error.message, "source_changed");
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
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;
|
|
}
|
|
return counts;
|
|
}
|