mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A web server must prove that retained run state is durable before volume-free enrollment. > - The proof reads selected native sessions and validates their existing recovery receipts. > - Serial database checks made a valid inventory take 59.665 seconds. > - The signed preparation request has a 55-second limit. > - This pull request overlaps independent reads within fixed limits. > - The benefit is a faster eligibility check with the same preservation rules. ## Linked Issues or Issue Description Refs #20599 and #20601. A search found no duplicate of this remaining serial-read bottleneck. **What happened?** An actual full startup eligibility check returned eligible with no blockers in 59.665 seconds. Controller checks took 44.134 seconds and harness checks took 8.592 seconds. Signed preparation timed out at 55.002 seconds. **Expected behavior** A durable instance can complete signed preparation within its existing deadline. **Steps to reproduce** 1. Retain many native task sessions with database-backed controller checkpoints and valid filesystem-independence receipts. 2. Run the full startup eligibility check. 3. Observe serial checkpoint and receipt reads consume the signed request budget. ## What Changed - Check the existing terminal-controller metadata and checkpoint-existence predicate within the existing eight-reader source batch. - Validate harness independence receipts with at most four concurrent readers per existing page. - Stop admitting more harness reads after an invalid result or error. Join all started reads before returning or throwing. - Keep large controller, full-checkpoint and legacy archive reads serial. - Add real PostgreSQL regressions for overlap, concurrency limits and failure settlement. ## Verification - Genuine controller regression RED: all 16 checkpoint reads ran, but peak overlap was one. GREEN: 10 focused PostgreSQL controls passed, including bounded overlap, joined failures, active and warm handoffs, and failed-source preservation. The other 45 cases were filtered, not claimed as passes. - Genuine harness regression RED: overlap was one instead of four. GREEN: all three new PostgreSQL controls passed. Six existing authority, replacement and refusal controls also passed. - Final head `c059e031`: the same 10 controller and 9 harness PostgreSQL controls passed after rebasing onto current master and refreshing Runner artifacts. `node node_modules/typescript/bin/tsc --noEmit -p server/tsconfig.json` also passed. The first `pnpm exec` attempt could not start the compiler because package-manager bootstrap fetches failed; it is not counted as a typecheck pass. - `git diff --check` passed. - Actual private-process acceptance with deployed `8e4c5e81` dependencies and only the two changed owners: the complete check passed in 37.596 seconds with no blockers. Nine runs were running before and after; 1,750 sessions were selected. This is 17.4 seconds inside the unchanged request limit. The earlier complete baseline was 59.665 seconds on 1,745 selected sessions, so these are observed timings on a changing dataset, not a controlled exact speedup. No serving process was patched. - The first private candidate check stopped on a native-controller blocker in 18.216 seconds. A targeted follow-up found no blocker without any preservation or business write. Its cause was not identified. It is retained as a nonpass and is not a speedup result. - Current-head Greptile review is 5/5 with no actionable findings. Required CI is running: https://github.com/paperclipai/paperclip/actions/runs/42898013184. ## Risks - The scans now use more simultaneous database reads. Limits remain eight for controller source readers and four for harness proofs. - Invalid or failed proofs still refuse eligibility. All started readers settle before failure leaves the scan. - Previous-release state carries forward unchanged. This changes no stored shape, checkpoint identity, lease rule, schema, deadline or provider protocol. - Large legacy payload reads remain serial, so their existing memory bound is unchanged. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. This is a measured performance defect in the existing enrollment path. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, code execution and parallel agents. The runtime does not expose the exact model variant or context-window size. ## 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 and contains no internal 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 - [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>
271 lines
17 KiB
TypeScript
271 lines
17 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";
|
|
|
|
const MAX_PARALLEL_INDEPENDENCE_CHECKS = 4;
|
|
|
|
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`with selected_sessions as materialized (
|
|
select s.company_id, s.id, s.last_run_id, r.native_issue_id, r.native_session_id
|
|
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
|
|
where s.adapter_type = 'paperclip_runner'
|
|
) 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 selected_sessions s
|
|
join heartbeat_runs r on r.id = s.last_run_id and r.company_id = s.company_id
|
|
join environment_leases e on e.company_id = s.company_id and e.issue_id = s.native_issue_id
|
|
and e.metadata->'nativeHarnessBackup'->>'normalizedSessionId' = s.native_session_id::text
|
|
where e.metadata->'nativeHarnessBackup'->>'schema' = 'paperclip.native-harness-backup-stamp.v2'
|
|
and not exists (select 1 from native_harness_backups b where b.company_id = s.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`);
|
|
let next = 0, invalid = false;
|
|
const checked = await Promise.allSettled(Array.from({ length: Math.min(MAX_PARALLEL_INDEPENDENCE_CHECKS, rows.length) }, async () => {
|
|
while (!invalid && next < rows.length) {
|
|
const row = rows[next++]!;
|
|
try {
|
|
if (!row.has_receipt || !await hasValidNativeHarnessFilesystemIndependence(db, {
|
|
companyId: row.company_id, selectedSessionId: row.selected_session_id, scopeDigest: row.scope_digest,
|
|
})) invalid = true;
|
|
} catch (error) { invalid = true; throw error; }
|
|
}
|
|
}));
|
|
for (const result of checked) if (result.status === "rejected") throw result.reason;
|
|
if (invalid) 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;
|
|
}
|