mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +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>
128 lines
8.7 KiB
TypeScript
128 lines
8.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { agents, companies, createDb, environmentLeases, environments, executionWorkspaces, heartbeatRunEvents, heartbeatRuns, issues } from "@paperclipai/db";
|
|
import { recoveryPostgresSupport, startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js";
|
|
import { createReusableLeaseHandoff } from "../environment-lease-handoff.js";
|
|
import { retiredSourceAuthorizesNativeBackupRestore } from "./native-retired-backup-source.js";
|
|
|
|
const describeDb = recoveryPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
describeDb("retired native backup source authority", () => {
|
|
let database: Awaited<ReturnType<typeof startRecoveryPostgres>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => {
|
|
database = await startRecoveryPostgres("retired-backup-source");
|
|
db = createDb(database.connectionString);
|
|
}, 120_000);
|
|
afterAll(async () => { await database?.cleanup(); });
|
|
|
|
const manifestSha256 = `sha256:${"b".repeat(64)}`;
|
|
const completedAt = new Date("2026-11-05T05:02:50.000Z");
|
|
|
|
async function fixture(options: { termination?: "destroyed" | "stopped" | null; status?: string; stampSha?: string;
|
|
providerEventAt?: Date; secondLease?: boolean; handoffs?: number;
|
|
handoffFault?: "missing_receipt" | "changed_receipt" | "active_predecessor" | "missing_predecessor" } = {}) {
|
|
const companyId = randomUUID(), agentId = randomUUID(), sessionId = randomUUID(), runnerInstanceId = randomUUID();
|
|
const sourceRunId = randomUUID(), currentRunId = randomUUID(), leaseId = randomUUID(), providerLeaseId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Retired source", issuePrefix: `R${companyId.slice(0, 5)}` });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Codie", adapterType: "paperclip_runner" });
|
|
for (const id of [sourceRunId, currentRunId]) await db.insert(heartbeatRuns).values({ id, companyId, agentId,
|
|
status: id === sourceRunId ? "succeeded" : "running", runtimeMode: "native", nativeSessionId: sessionId,
|
|
invocationSource: "assignment", triggerDetail: "system" });
|
|
const lease = (id: string) => ({ id, companyId, heartbeatRunId: sourceRunId, provider: "daytona", providerLeaseId,
|
|
status: options.status ?? "expired", cleanupStatus: "success", releasedAt: new Date(),
|
|
metadata: {
|
|
nativeHarnessBackup: { schema: "paperclip.native-harness-backup-stamp.v2", normalizedSessionId: sessionId, runnerInstanceId,
|
|
sessionScopeSha256: "a".repeat(64), sourceProviderLeaseId: providerLeaseId, authorizedProviderLeaseId: providerLeaseId,
|
|
manifestSha256: options.stampSha ?? manifestSha256, completedAt: completedAt.toISOString() },
|
|
...(options.termination === null ? {} : { remoteExecutionTermination: { schema: "paperclip.remote-termination.v1", companyId,
|
|
runId: sourceRunId, leaseId: id, provider: "daytona", providerLeaseId, state: options.termination ?? "destroyed",
|
|
confirmedAt: new Date().toISOString() } }),
|
|
} });
|
|
const sourceLease = lease(leaseId);
|
|
if (options.handoffs) {
|
|
const environmentId = randomUUID(), issueId = randomUUID(), workspaceId = randomUUID();
|
|
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Retained session" });
|
|
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, sourceIssueId: issueId,
|
|
mode: "shared_workspace", strategyType: "project_primary", name: "Retained workspace" });
|
|
const history: Array<typeof environmentLeases.$inferInsert> = [];
|
|
for (let index = 0; index <= options.handoffs; index++) {
|
|
const ownerRunId = index === options.handoffs ? sourceRunId : randomUUID();
|
|
if (ownerRunId !== sourceRunId) await db.insert(heartbeatRuns).values({ id: ownerRunId, companyId, agentId,
|
|
status: "succeeded", runtimeMode: "native", nativeSessionId: sessionId,
|
|
invocationSource: "assignment", triggerDetail: "system" });
|
|
const acquiredAt = new Date(completedAt.getTime() - (options.handoffs - index + 1) * 60_000);
|
|
const row = { ...sourceLease, id: index === options.handoffs ? leaseId : randomUUID(),
|
|
heartbeatRunId: ownerRunId, environmentId, issueId, executionWorkspaceId: workspaceId, acquiredAt,
|
|
leasePolicy: "reuse_by_environment", metadata: { ...sourceLease.metadata, agentId } };
|
|
if (index < options.handoffs) {
|
|
const { nativeHarnessBackup: _backup, remoteExecutionTermination: _termination, ...metadata } = row.metadata;
|
|
history.push({ ...row, metadata });
|
|
} else history.push(row);
|
|
}
|
|
for (let index = 1; index < history.length; index++) {
|
|
const previous = history[index - 1]!, current = history[index]!;
|
|
const handoff = createReusableLeaseHandoff(previous as typeof environmentLeases.$inferSelect,
|
|
current as typeof environmentLeases.$inferSelect);
|
|
previous.releasedAt = current.acquiredAt;
|
|
previous.metadata = { ...previous.metadata, reusableLeaseSuccessor: handoff };
|
|
current.metadata = { ...current.metadata, reusableLeasePredecessor: handoff };
|
|
}
|
|
if (options.handoffFault === "missing_receipt") delete history[0]!.metadata!.reusableLeaseSuccessor;
|
|
if (options.handoffFault === "changed_receipt") history[0]!.metadata!.reusableLeaseSuccessor = {
|
|
...(history[0]!.metadata!.reusableLeaseSuccessor as Record<string, unknown>), successorRunId: randomUUID() };
|
|
if (options.handoffFault === "active_predecessor") history[0]!.status = "active";
|
|
if (options.handoffFault === "missing_predecessor") history.shift();
|
|
await db.insert(environmentLeases).values(history);
|
|
} else await db.insert(environmentLeases).values(sourceLease);
|
|
if (options.secondLease) await db.insert(environmentLeases).values(lease(randomUUID()));
|
|
// The backup turn's own provider events precede the checkpoint.
|
|
await db.insert(heartbeatRunEvents).values({ companyId, runId: sourceRunId, agentId, seq: 1, eventType: "turn.started",
|
|
createdAt: new Date(completedAt.getTime() - 60_000) });
|
|
if (options.providerEventAt) await db.insert(heartbeatRunEvents).values({ companyId, runId: currentRunId === sourceRunId
|
|
? sourceRunId : (await db.insert(heartbeatRuns).values({ companyId, agentId, status: "failed", runtimeMode: "native",
|
|
nativeSessionId: sessionId, invocationSource: "automation", triggerDetail: "system" }).returning())[0]!.id,
|
|
agentId, seq: 1, eventType: "session.resumed", createdAt: options.providerEventAt });
|
|
return retiredSourceAuthorizesNativeBackupRestore(db, { companyId, currentRunId, normalizedSessionId: sessionId,
|
|
manifest: { sourceProviderLeaseId: providerLeaseId, runnerInstanceId, normalizedSessionId: sessionId }, manifestSha256 });
|
|
}
|
|
|
|
it("authorizes a destroyed source lease whose stamp names the backup", async () => {
|
|
await expect(fixture()).resolves.toBe(true);
|
|
});
|
|
|
|
it.each([1, 3])("authorizes a destroyed allocation after %i verified warm lease handoffs", async (handoffs) => {
|
|
await expect(fixture({ handoffs })).resolves.toBe(true);
|
|
});
|
|
|
|
it.each(["missing_receipt", "changed_receipt", "active_predecessor", "missing_predecessor"] as const)(
|
|
"fails closed for a warm lease chain with %s", async (handoffFault) => {
|
|
await expect(fixture({ handoffs: 2, handoffFault })).resolves.toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["an unrelated owner", { secondLease: true }],
|
|
["a stopped source", { termination: "stopped" as const }],
|
|
["another checkpoint", { stampSha: `sha256:${"c".repeat(64)}` }],
|
|
["later provider work", { providerEventAt: new Date(completedAt.getTime() + 60_000) }],
|
|
])("keeps the backup restore held after warm handoffs with %s", async (_label, options) => {
|
|
await expect(fixture({ handoffs: 2, ...options })).resolves.toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["the provider only stopped the source", { termination: "stopped" as const }],
|
|
["no termination receipt exists", { termination: null }],
|
|
["the source lease is still reusable", { status: "released" }],
|
|
["the stamp names another checkpoint", { stampSha: `sha256:${"c".repeat(64)}` }],
|
|
["provider work followed the checkpoint", { providerEventAt: new Date(completedAt.getTime() + 60_000) }],
|
|
["one allocation maps to two host leases", { secondLease: true }],
|
|
])("fails closed when %s", async (_label, options) => {
|
|
await expect(fixture(options)).resolves.toBe(false);
|
|
});
|
|
|
|
it("ignores provider work that finished before the checkpoint", async () => {
|
|
await expect(fixture({ providerEventAt: new Date(completedAt.getTime() - 1_000) })).resolves.toBe(true);
|
|
});
|
|
});
|