mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip manages AI agents and keeps their work across turns. > - A native session can keep the same sandbox through several warm lease handoffs. > - Each handoff keeps the old lease row and records matching receipts on both owners. > - The retired-backup guard required exactly one row for a provider allocation. > - That rule rejected valid history after Paperclip destroyed the sandbox. > - This change uses the existing handoff proof to select the final owner. > - A verified backup can then restore without discarding ownership checks. ## Linked Issues or Issue Description Refs #19208. **What happened?** A follow-up received a new sandbox after its old sandbox was destroyed. The old allocation had a normal warm lease handoff. Both lease rows remained in PostgreSQL. The guard rejected them with `runner_harness_state_mismatch: backup_without_reusable_lease`. Its later same-run retry failed with `native_remote_recovery_lease_mismatch`. **Expected behavior** Restore the verified backup when one final owner has a valid destroyed receipt and every earlier owner has a matching handoff receipt. Keep uncertain ownership blocked. **Steps to reproduce** 1. Complete a native warm turn. 2. Hand the reusable sandbox to another turn. 3. Complete that turn and save its backup. 4. Close the workspace and destroy the sandbox. 5. Send a follow-up. The new allocation must restore the saved session. **Paperclip version or commit** Reproduced in the PostgreSQL regression on the parent of this change. The affected deployed build also contains #19208. ## What Changed - Select the single final lease owner. Validate all predecessor rows with `retiredReusableLeasePredecessors`. - Keep the destroyed receipt, exact backup stamp, and newer-provider-work checks. - Add database regressions for one and three handoffs. Reject missing or changed receipts, an active predecessor, missing history, an unrelated owner, a stopped source, another backup, and later provider work. - Document the valid lease-history shape. ## Verification - Red before the source change: the two valid-handoff cases returned false. The other 16 database cases passed. - Green after the change: all 18 database cases passed. They also passed on current head `d16e098b8c2e6c0dda70984c1df21e3dc792ab34`. - Command: `pnpm --filter @paperclipai/server exec vitest run src/services/native-runtime/native-retired-backup-source.integration.test.ts`. - The database, executor, retry-admission, and lease suites passed: 4 files, 1,308 tests. - `pnpm -r typecheck` and `pnpm build` passed. The build stamp names head `4ee733c3a979436196522f36537ff182e9df5398`. The full local suite stopped in chat shard 3 after a 15-second timeout in `materializes direct external-chat finals once and accepts the next turn without agent bookkeeping writes`. Earlier shards passed 702 cases; that shard passed 303 other cases. The isolated test passes on this branch and unchanged master `10a7d039c8efeac0fd655c24970df5ae3658414b`. All current-head CI chat shards also passed. The earlier full-suite attempt remains failed; its timeout cause is not established. No test or timeout was changed. CI is waiting for its last Runner job. - Current head `d16e098b8c2e6c0dda70984c1df21e3dc792ab34` removes four comment lines from the previously tested head. Its 18 database cases passed. Greptile reviewed this head at 5/5 with no actionable findings: https://github.com/paperclipai/paperclip/pull/19299#issuecomment-6665446098. CI remains pending. - Live inspection found the matching predecessor/successor receipts and the final owner's destroyed receipt. This was read-only evidence from the affected build. - A live provider run at the fixed revision has not been exercised. Local Daytona credentials and an immutable test image are unavailable. No deployment or original-task retry is claimed. - Initial wider tests could not resolve the unbuilt plugin SDK. The SDK build is now available. A sandboxed database launch could not bind loopback; the same regression passed with the disposable database outside that restriction. ## Risks - A wrong owner could allow an old backup to rewind a session. The shared validator must account for every lease row and match both sides of each handoff. - Live, unrelated, incomplete, or conflicting history stays blocked. The existing backup integrity and provider-progress checks remain. - No migration, permission, model, trust, timeout, or retry-policy change is included. - The original task still needs the fixed build and normal recovery. Hosted acceptance remains unverified. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, code execution, and tool use. The session does not expose the exact model ID 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 (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 - [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>
72 lines
3.8 KiB
TypeScript
72 lines
3.8 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { and, eq, gt, inArray, ne } from "drizzle-orm";
|
|
import { environmentLeases, heartbeatRunEvents, heartbeatRuns, type Db } from "@paperclipai/db";
|
|
import { retiredReusableLeasePredecessors } from "../environment-lease-handoff.js";
|
|
import { hasRemoteTerminationReceipt } from "../remote-execution-termination.js";
|
|
import { readNativeHarnessBackupStamp } from "./native-harness-backup-stamp.js";
|
|
|
|
/** Durable run events that prove a provider session did work in a run. */
|
|
export const NATIVE_PROVIDER_DURABLE_EVENT_TYPES = new Set([
|
|
"harness.ready",
|
|
"session.started",
|
|
"session.resumed",
|
|
"session.updated",
|
|
"turn.started",
|
|
"provider.event",
|
|
"provider.rpc_result",
|
|
]);
|
|
|
|
export function nativeHarnessBackupManifestSha256(manifestBytes: Buffer | string): string {
|
|
return `sha256:${createHash("sha256").update(manifestBytes).digest("hex")}`;
|
|
}
|
|
|
|
/**
|
|
* A created sandbox normally refuses a per-turn backup that has no recorded
|
|
* reusable lease, because the backup's source sandbox may still hold newer
|
|
* provider state. That ambiguity is absent when Paperclip itself destroyed the
|
|
* source sandbox: the provider confirmed the termination, the source lease's
|
|
* stamp names exactly this backup, and no provider work for the session was
|
|
* recorded after the backup completed. This is the same proof a confirmed
|
|
* replacement relies on, reached through a lease that was retired first.
|
|
*/
|
|
export async function retiredSourceAuthorizesNativeBackupRestore(db: Db, input: {
|
|
companyId: string;
|
|
currentRunId: string;
|
|
normalizedSessionId: string;
|
|
manifest: { sourceProviderLeaseId: string; runnerInstanceId: string; normalizedSessionId: string };
|
|
manifestSha256: string;
|
|
}): Promise<boolean> {
|
|
if (!input.manifest.sourceProviderLeaseId || input.manifest.normalizedSessionId !== input.normalizedSessionId) return false;
|
|
const leases = await db.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, input.companyId),
|
|
eq(environmentLeases.providerLeaseId, input.manifest.sourceProviderLeaseId),
|
|
));
|
|
const source = leases.filter(lease => lease.companyId === input.companyId
|
|
&& lease.providerLeaseId === input.manifest.sourceProviderLeaseId);
|
|
const tips = source.filter(lease => !Object.hasOwn(lease.metadata ?? {}, "reusableLeaseSuccessor"));
|
|
if (tips.length !== 1) return false;
|
|
const lease = tips[0]!;
|
|
if (!retiredReusableLeasePredecessors(lease, source.filter(row => row.id !== lease.id))) return false;
|
|
const termination = lease.metadata?.remoteExecutionTermination as { state?: unknown } | undefined;
|
|
// A released lease can still be selected for resume; only a retired one is final.
|
|
if (!["expired", "failed"].includes(lease.status) || !hasRemoteTerminationReceipt(lease)
|
|
|| termination?.state !== "destroyed") return false;
|
|
const stamp = readNativeHarnessBackupStamp(lease.metadata?.nativeHarnessBackup, lease.providerLeaseId!);
|
|
if (!stamp || stamp.manifestSha256 !== input.manifestSha256
|
|
|| stamp.normalizedSessionId !== input.normalizedSessionId
|
|
|| stamp.runnerInstanceId !== input.manifest.runnerInstanceId) return false;
|
|
// Provider work recorded after the checkpoint means the destroyed sandbox
|
|
// held newer state than this backup. Restoring would silently rewind it.
|
|
const later = await db.select({ runId: heartbeatRunEvents.runId }).from(heartbeatRunEvents)
|
|
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, heartbeatRunEvents.runId))
|
|
.where(and(
|
|
eq(heartbeatRunEvents.companyId, input.companyId),
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
eq(heartbeatRuns.nativeSessionId, input.normalizedSessionId),
|
|
ne(heartbeatRuns.id, input.currentRunId),
|
|
inArray(heartbeatRunEvents.eventType, [...NATIVE_PROVIDER_DURABLE_EVENT_TYPES]),
|
|
gt(heartbeatRunEvents.createdAt, new Date(stamp.completedAt)),
|
|
)).limit(1);
|
|
return later.length === 0;
|
|
}
|