mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
<!-- Write all pull request text in Simplified Technical English (ASD-STE100): short sentences, one instruction per sentence, simple approved vocabulary, and the active voice. --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Native warm runs on a remote sandbox continue a saved session in the next run. The next run first reads the retained Runner lifecycle from the sandbox. > - #20346 abandons the workspace epoch of a run that died during preparation. The next run then restores the last save into a new directory of the same sandbox. > - The retained-sandbox binding check required the old and the new directory to be the same. The warm follow-up failed with `native_controller_recovery_missing_evidence:retained_sandbox_binding` before provider work. > - The failed run's no-replay hold then refused its files with `allocation_ownership_ambiguous`. The task stayed blocked. The text said that Retry "succeeds only after Paperclip is updated", so no click could help. > - This pull request binds the Runner read to the directory that the retained lease recorded, and lets the existing retained-source fallback settle these holds from the last save. > - The benefit is that a moved workspace continues its session, and a hold that cannot be verified continues automatically instead of dead-ending. ## Linked Issues or Issue Description Refs #20346 **What happened?** On staging, a warm native continuation ran about 10 minutes after a deploy. The run before it died during preparation (database deadlock), so its workspace epoch was abandoned. The continuation restored the last save into a new allocation directory of the same Daytona sandbox. It then failed with `native_controller_recovery_missing_evidence:retained_sandbox_binding`, because the retained lease of the committed source run recorded the old directory. The failure recorded a no-replay hold (`native_continuation_requires_reconciliation`, resolved with outcome `blocked`). Workspace recovery refused the source with `allocation_ownership_ambiguous`, because the sandbox's reusable-lease chain had predecessors in another directory and a non-native preparation-only run. The task stayed blocked with "Retry runs the check again and succeeds only after Paperclip is updated." **Expected behavior** The continuation reads the retained Runner state from the directory where that Runner wrote it, and continues. When retained files cannot be verified, the task continues from the last committed save without a person. **Steps to reproduce** 1. Run a warm native session on a reusable remote sandbox and let it commit. 2. Fail the next run during preparation, so that its epoch is abandoned (#20346). 3. Start the next warm continuation. It restores the last save into a new allocation directory and fails with `retained_sandbox_binding`. 4. See the hold refuse its source with `allocation_ownership_ambiguous` and stay blocked. **Paperclip version or commit** Staging `c874cfa4dacc` (includes #20346). ## What Changed - `assertNativeRetainedSandboxBinding` now returns the directory that the retained lease recorded. It accepts a different target directory only when both are canonical sibling allocations of the same execution workspace under the sandbox's `.paperclip-workspaces` (`<workspaceId>` or `<workspaceId>-<epoch>-<leaseId>`). Same sandbox (`providerLeaseId`), environment, run, issue and committed owner are still required. Other directories stay refused. - `restoreRemoteSuspendedRunnerCheckpoint` reads the Runner state from that recorded directory. A moved source must be suspended and settled. A Runner left in the old allocation is never adopted live. - The retained-source fallback (`continueFromLastSavedWorkspace`) and its discovery now also cover the native no-replay hold (`native_continuation_requires_reconciliation`, active or resolved with outcome `blocked`, replay blocked for the same run). Rows saved by earlier releases are discovered without migration. - The fallback owns the Blocked status of such a hold only while the recorded `nativeFailureBlock` status version still matches the task. Otherwise it keeps the newer block and starts no continuation. - The refusal text no longer says "succeeds only after Paperclip is updated", and it promises no fallback. - `doc/execution-semantics.md` describes both rules. ## Verification - Red (unfixed code): `native-controller-recovery.integration.test.ts` "binds a retained Runner read to the source's directory after the workspace moved within the same sandbox" failed with `native_controller_recovery_missing_evidence:retained_sandbox_binding` at `native-controller-recovery.ts:145`, the staging error. - Red (unfixed code): `workspace-recovery-fallback.test.ts` "continues a resolved-but-blocked native no-replay hold from the last save, discovered by the worker" failed: the worker found no due work for the exact staging row. The newer-block safety case and the message case in `system-recovery-state.test.ts` also failed. - Provider boundary: a new test drives `restoreRemoteSuspendedRunnerCheckpoint` through a sandbox runner that refuses paths outside the sandbox workspace base. The suspended case reads `runner-state.json` from the source directory, never the moved root. The ready case is refused with `runner_not_settled` and caches nothing. - Green: `native-controller-recovery.integration.test.ts`, `workspace-recovery-fallback.test.ts` and `system-recovery-state.test.ts` pass (45 tests). The neighbouring suites `workspace-recovery-retry-copyback`, `workspace-recovery-preparation-gates` and `workspace-recovery-stop-proof` pass (135 tests). - Safety cases: another workspace, another parent, a non-canonical path or name (`-0-`, trailing text), `/unrelated` and another sandbox stay refused. A hold whose task moved to a newer Blocked decision keeps the block and starts no continuation. Exactly one continuation is delivered. Native replacement is denied for the abandoned source, so no second executor starts. - `npx tsc --noEmit -p server/tsconfig.json` passes. `pnpm verify` returns exit 2 (a global doc input changed), so required CI supplies the broad coverage. ## Risks - Previous-release state: rows saved by earlier releases carry forward without migration. The binding reads existing lease metadata. The fallback discovers existing resolved-but-blocked holds by their existing evidence fields. - The binding now accepts a sibling allocation directory. The read still binds to the exact sandbox, run, environment and committed owner, and it reads only the source's recorded directory. A non-suspended Runner in a moved allocation is refused, not adopted. - The fallback abandons the unverified epoch and continues from the last committed save with a fresh session. In the staging shape the run did no provider work after it restored that save, so no work is lost. Unverified files are attached to the task when they can be read, as before. - Not verified live: a real warm Codex resume after a moved allocation on Daytona. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits, read-only browser access to staging). Extended reasoning enabled. ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
343 lines
23 KiB
TypeScript
343 lines
23 KiB
TypeScript
import { posix } from "node:path";
|
|
import { and, eq, inArray, ne, or, sql } from "drizzle-orm";
|
|
import { environmentLeases, heartbeatRuns, issues, nativeRunFinalizations, nativeRunnerCheckpoints, type Db } from "@paperclipai/db";
|
|
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
|
import { captureLegacyDurablePrpCheckpoint, parseNativeExecutionInput, type DurablePrpCheckpoint, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
|
|
import { notifyNativeFinalizationWork } from "../native-finalization-notifications.js";
|
|
import { readRunCancellation } from "../run-cancellation.js";
|
|
import { nativeSha256 } from "./canonical.js";
|
|
import { findNativeSessionResumeRun, isNativeSessionId, isUnusedNativeSessionBootstrap, nativeSessionProviderEvidence } from "./native-session-resume.js";
|
|
import { insertMigratedNativeRunnerCheckpoint, loadNativeRunnerCheckpoint } from "./native-runner-checkpoint.js";
|
|
import { offlineMigrationExecutionWhere } from "../offline-migration-execution.js";
|
|
import { redactRunnerApiPayloads } from "./runner-api-log-redaction.js";
|
|
|
|
export const NATIVE_CONTROLLER_RECOVERY_MAX_BYTES = 4 * 1024;
|
|
|
|
/** Private run-profile metadata. No credentials or controller-local paths. */
|
|
export interface NativeControllerRecovery {
|
|
schema: "paperclip.native-controller-recovery.v1";
|
|
snapshotSha256: string;
|
|
configDigest: string;
|
|
processConfigDigest: string | null;
|
|
checkpoint: {
|
|
runId: string;
|
|
runnerInstanceId: string;
|
|
normalizedSessionId: string;
|
|
} | null;
|
|
}
|
|
|
|
export type NativeControllerCompatibility = Pick<NativeControllerRecovery, "configDigest" | "processConfigDigest">;
|
|
|
|
export function createNativeControllerRecovery(
|
|
snapshot: PersistedNativeSession,
|
|
compatibility: NativeControllerCompatibility,
|
|
checkpoint: NativeControllerRecovery["checkpoint"],
|
|
): NativeControllerRecovery {
|
|
const value: NativeControllerRecovery = {
|
|
schema: "paperclip.native-controller-recovery.v1",
|
|
snapshotSha256: nativeSha256(snapshot),
|
|
configDigest: compatibility.configDigest,
|
|
processConfigDigest: compatibility.processConfigDigest,
|
|
checkpoint,
|
|
};
|
|
if (Buffer.byteLength(JSON.stringify(value)) > NATIVE_CONTROLLER_RECOVERY_MAX_BYTES) {
|
|
throw new Error("native_controller_recovery_metadata_too_large");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
export function readNativeControllerRecovery(value: unknown, snapshot: unknown): NativeControllerRecovery | null {
|
|
const envelope = record(value), checkpoint = record(envelope.checkpoint);
|
|
if (Buffer.byteLength(JSON.stringify(value) ?? "null") > NATIVE_CONTROLLER_RECOVERY_MAX_BYTES
|
|
|| envelope.schema !== "paperclip.native-controller-recovery.v1"
|
|
|| typeof envelope.snapshotSha256 !== "string" || envelope.snapshotSha256 !== nativeSha256(snapshot)
|
|
|| typeof envelope.configDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(envelope.configDigest)
|
|
|| !(envelope.processConfigDigest === null || (typeof envelope.processConfigDigest === "string" && /^[a-f0-9]{64}$/.test(envelope.processConfigDigest)))
|
|
|| ![checkpoint.runId, checkpoint.runnerInstanceId, checkpoint.normalizedSessionId].every(v => typeof v === "string" && v.length > 0)) return null;
|
|
return envelope as unknown as NativeControllerRecovery;
|
|
}
|
|
|
|
export type NativeControllerRecoveryOutcome =
|
|
| { kind: "recoverable"; snapshot: PersistedNativeSession; metadata: NativeControllerRecovery; checkpoint: DurablePrpCheckpoint }
|
|
| { kind: "waiting_for_owner"; deadline: Date }
|
|
| { kind: "missing_evidence" | "unsupported" | "dependency_unavailable"; reason: string; cause?: unknown }
|
|
| { kind: "no_prior_execution" };
|
|
|
|
export const NATIVE_RETAINED_OWNER_WAIT = "native_controller_recovery_waiting_for_owner";
|
|
|
|
export class NativeControllerRecoveryWaitingError extends Error {
|
|
constructor(readonly retryAt: Date) {
|
|
super(NATIVE_RETAINED_OWNER_WAIT);
|
|
this.name = "NativeControllerRecoveryWaitingError";
|
|
}
|
|
}
|
|
|
|
/** Admission has not crossed the provider boundary. Retain this same business
|
|
* run on the existing retry owner; never turn an ownership wait into a failure
|
|
* or invent a new provider attempt. The usual resumption gates recheck Stop,
|
|
* permissions and task ownership when this deadline arrives. */
|
|
export async function deferNativeControllerRecovery(db: Db, input: {
|
|
companyId: string; agentId: string; issueId: string; runId: string;
|
|
leaseOwner?: string; retryAt: Date;
|
|
}): Promise<void> {
|
|
await db.transaction(async tx => {
|
|
const [task] = await tx.select({ executionRunId: issues.executionRunId }).from(issues)
|
|
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId))).for("update");
|
|
const [owner] = await tx.select().from(nativeRunFinalizations).where(and(
|
|
eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.issueId, input.issueId),
|
|
eq(nativeRunFinalizations.runId, input.runId))).for("update");
|
|
const [run] = await tx.select().from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId),
|
|
eq(heartbeatRuns.agentId, input.agentId), eq(heartbeatRuns.nativeIssueId, input.issueId))).for("update");
|
|
const [authority] = await tx.select({ runId: nativeRunnerCheckpoints.runId }).from(nativeRunnerCheckpoints)
|
|
.where(and(eq(nativeRunnerCheckpoints.companyId, input.companyId), eq(nativeRunnerCheckpoints.runId, input.runId))).limit(1);
|
|
const progress = await nativeSessionProviderEvidence(tx as unknown as Db, [input.runId]);
|
|
const cancellation = record(run?.resultJson?.nativeCancellation);
|
|
const stop = readRunCancellation(run?.resultJson);
|
|
const [clock] = await tx.execute<{ now: Date }>(sql`select clock_timestamp() as now`);
|
|
if (!run || run.runtimeMode !== "native" || run.status !== "running" || task?.executionRunId !== input.runId
|
|
|| !owner || owner.phase !== "observed" || owner.resultId || owner.attempt !== 0
|
|
|| owner.controllerGeneration !== 0 || owner.leaseOwner !== (input.leaseOwner ?? null)
|
|
|| (input.leaseOwner && (!owner.leaseExpiresAt || owner.leaseExpiresAt <= new Date(clock.now)))
|
|
|| run.processPid || run.processGroupId || run.processStartedAt || authority || progress.size
|
|
|| !record(run.runnerProfileJson).nativeExecutionInput || run.resultJson?.startupCancellation
|
|
|| (stop?.source === "operator" && stop.expected)
|
|
|| (["run", "issue"].includes(String(cancellation.scope)) && ["pending", "acknowledged"].includes(String(cancellation.dispatchState)))) {
|
|
throw new Error("native_controller_recovery_wait_authority_changed");
|
|
}
|
|
await notifyNativeFinalizationWork(tx, input);
|
|
await tx.update(nativeRunFinalizations).set({
|
|
phase: "retryable_failure", leaseOwner: null, leaseExpiresAt: null, controlDeadlineAt: null,
|
|
nextAttemptAt: input.retryAt, recoveryState: "resuming_session", failureCode: NATIVE_RETAINED_OWNER_WAIT,
|
|
failureDetail: { originalFailureCode: NATIVE_RETAINED_OWNER_WAIT,
|
|
message: "Waiting for the retained native controller owner to release its lease.",
|
|
recoveryMode: record(run.runnerProfileJson).sessionCheckpoint ? "same_session_resume" : "bootstrap_retry",
|
|
providerSessionEstablished: false, providerEventsExist: false },
|
|
updatedAt: new Date(),
|
|
}).where(eq(nativeRunFinalizations.runId, input.runId));
|
|
});
|
|
}
|
|
|
|
/** The retained Runner keeps its state under the directory its own lease
|
|
* recorded. A workspace can move to a new allocation of the SAME sandbox: its
|
|
* prior epoch was abandoned, so the last save was restored into a fresh root
|
|
* named for the new lease. Both roots are canonical siblings under the
|
|
* sandbox's `.paperclip-workspaces` and carry the same execution-workspace ID.
|
|
* Anything else (another workspace, another parent, a non-canonical path) is
|
|
* not the retained allocation and stays refused. */
|
|
function sameSandboxWorkspaceAllocation(recorded: string, current: string): boolean {
|
|
if (recorded === current) return true;
|
|
const canonical = (path: string) => posix.isAbsolute(path) && posix.normalize(path) === path && !path.endsWith("/");
|
|
if (!canonical(recorded) || !canonical(current)) return false;
|
|
const parent = posix.dirname(recorded);
|
|
if (posix.basename(parent) !== ".paperclip-workspaces" || posix.dirname(current) !== parent) return false;
|
|
const workspace = WORKSPACE_ALLOCATION.exec(posix.basename(recorded))?.[1];
|
|
return Boolean(workspace && WORKSPACE_ALLOCATION.exec(posix.basename(current))?.[1] === workspace);
|
|
}
|
|
|
|
/** The published allocation names (remote-workspaces publishedWorkspaceDirectory):
|
|
* exactly `<workspaceId>`, or `<workspaceId>-<epoch>-<leaseId>`. */
|
|
const UUID = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}";
|
|
const WORKSPACE_ALLOCATION = new RegExp(`^(${UUID})(?:-[1-9][0-9]*-${UUID})?$`);
|
|
|
|
/** Bind a read of retained Runner lifecycle to the surviving sandbox and
|
|
* return the directory that Runner wrote under. The ordinary admission owner
|
|
* still checks current permissions before execution. */
|
|
export async function assertNativeRetainedSandboxBinding(db: Db, input: {
|
|
companyId: string; issueId: string; runId: string;
|
|
target: Extract<AdapterExecutionTarget, { kind: "remote" }>;
|
|
}): Promise<{ remoteCwd: string }> {
|
|
const { target } = input;
|
|
if (target.transport !== "sandbox" || target.sandboxLeaseAcquisition?.outcome !== "resumed"
|
|
|| !target.providerKey || !target.environmentId || !target.leaseId) {
|
|
throw new Error("native_controller_recovery_dependency_unavailable:retained_sandbox");
|
|
}
|
|
const [owner] = await db.select().from(nativeRunFinalizations).where(and(
|
|
eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.issueId, input.issueId),
|
|
eq(nativeRunFinalizations.runId, input.runId)));
|
|
const leases = await db.select({ metadata: environmentLeases.metadata, status: environmentLeases.status }).from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, input.companyId), eq(environmentLeases.issueId, input.issueId),
|
|
eq(environmentLeases.heartbeatRunId, input.runId), eq(environmentLeases.environmentId, target.environmentId),
|
|
eq(environmentLeases.provider, target.providerKey), eq(environmentLeases.providerLeaseId, target.sandboxLeaseAcquisition.providerLeaseId)));
|
|
const recordedCwd = leases.length === 1 ? leases[0].metadata?.remoteCwd : undefined;
|
|
if (!owner || !owner.resultId || owner.recoveryState || typeof recordedCwd !== "string"
|
|
|| !sameSandboxWorkspaceAllocation(recordedCwd, target.remoteCwd)
|
|
|| !["active", "released", "expired"].includes(leases[0].status)) {
|
|
throw new Error("native_controller_recovery_missing_evidence:retained_sandbox_binding");
|
|
}
|
|
if (owner.leaseOwner && owner.leaseExpiresAt && owner.leaseExpiresAt > new Date()) {
|
|
throw new NativeControllerRecoveryWaitingError(owner.leaseExpiresAt);
|
|
}
|
|
if (owner.leaseOwner || owner.phase !== "committed") {
|
|
throw new Error("native_controller_recovery_missing_evidence:retained_sandbox_binding");
|
|
}
|
|
return { remoteCwd: recordedCwd };
|
|
}
|
|
|
|
/** Only the existing bounded selector chooses history. Never search backwards
|
|
* again when its selected record is incomplete or incompatible. This operation
|
|
* reads evidence; it grants neither command nor acknowledgement authority. */
|
|
export async function resolveNativeControllerRecovery(db: Db, input: {
|
|
companyId: string; agentId: string; issueId: string; normalizedSessionId: string; currentRunId: string;
|
|
}): Promise<NativeControllerRecoveryOutcome> {
|
|
if (!isNativeSessionId(input.normalizedSessionId)) return { kind: "missing_evidence", reason: "session_binding" };
|
|
const [current] = await db.select({ createdAt: heartbeatRuns.createdAt, processPid: heartbeatRuns.processPid,
|
|
processGroupId: heartbeatRuns.processGroupId, processStartedAt: heartbeatRuns.processStartedAt,
|
|
runnerProfileJson: heartbeatRuns.runnerProfileJson }).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.id, input.currentRunId), eq(heartbeatRuns.companyId, input.companyId),
|
|
eq(heartbeatRuns.agentId, input.agentId), eq(heartbeatRuns.nativeIssueId, input.issueId),
|
|
eq(heartbeatRuns.nativeSessionId, input.normalizedSessionId)));
|
|
if (!current) return { kind: "missing_evidence", reason: "current_binding" };
|
|
const source = await findNativeSessionResumeRun(db, { ...input, beforeCreatedAt: current.createdAt, includeProgressBarrier: true });
|
|
if (!source) {
|
|
// An exhausted candidate window is unknown history, not fresh-bootstrap
|
|
// proof. Read only IDs, and retain the selector's existing 32-row boundary.
|
|
const prior = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
|
ne(heartbeatRuns.id, input.currentRunId), eq(heartbeatRuns.companyId, input.companyId),
|
|
eq(heartbeatRuns.agentId, input.agentId), eq(heartbeatRuns.nativeIssueId, input.issueId),
|
|
eq(heartbeatRuns.nativeSessionId, input.normalizedSessionId))).limit(33);
|
|
if (prior.length > 32) return { kind: "missing_evidence", reason: "candidate_window_exhausted" };
|
|
const progress = await nativeSessionProviderEvidence(db, [input.currentRunId]);
|
|
if (!isUnusedNativeSessionBootstrap(current, progress.has(input.currentRunId))) {
|
|
return { kind: "missing_evidence", reason: "current_execution_progress" };
|
|
}
|
|
// The normal admission and remote untouched-session claim still execute.
|
|
return { kind: "no_prior_execution" };
|
|
}
|
|
const [owner] = await db.select({ leaseOwner: nativeRunFinalizations.leaseOwner, leaseExpiresAt: nativeRunFinalizations.leaseExpiresAt }).from(nativeRunFinalizations)
|
|
.where(and(eq(nativeRunFinalizations.runId, source.id), eq(nativeRunFinalizations.companyId, input.companyId),
|
|
eq(nativeRunFinalizations.issueId, input.issueId)));
|
|
if (owner?.leaseOwner && owner.leaseExpiresAt && owner.leaseExpiresAt.getTime() > Date.now()) {
|
|
return { kind: "waiting_for_owner", deadline: owner.leaseExpiresAt };
|
|
}
|
|
if (source.runtimeMode !== "native" || !["succeeded", "failed", "cancelled", "interrupted", "timed_out"].includes(source.status)
|
|
|| source.createdAt > current.createdAt) return { kind: "missing_evidence", reason: "newer_progress_barrier" };
|
|
const profile = record(source.runnerProfileJson), snapshot = record(profile.sessionCheckpoint);
|
|
const identity = record(snapshot.identity);
|
|
if (identity.companyId !== input.companyId || identity.agentId !== input.agentId
|
|
|| identity.issueId !== input.issueId || identity.runId !== source.id || identity.sessionId !== input.normalizedSessionId) {
|
|
return { kind: "missing_evidence", reason: "snapshot_binding" };
|
|
}
|
|
if (record(profile.nativeControllerRecovery).schema !== "paperclip.native-controller-recovery.v1") {
|
|
return { kind: "unsupported", reason: "metadata_format" };
|
|
}
|
|
const metadata = readNativeControllerRecovery(profile.nativeControllerRecovery, snapshot);
|
|
if (!metadata?.checkpoint || metadata.checkpoint.runId !== source.id
|
|
|| metadata.checkpoint.normalizedSessionId !== input.normalizedSessionId
|
|
|| metadata.checkpoint.runnerInstanceId !== source.runnerInstanceId) {
|
|
return { kind: "missing_evidence", reason: "snapshot_digest_or_checkpoint_binding" };
|
|
}
|
|
let checkpoint: DurablePrpCheckpoint | null;
|
|
try {
|
|
checkpoint = await loadNativeRunnerCheckpoint(db, { companyId: input.companyId, issueId: input.issueId, runId: source.id });
|
|
} catch (cause) {
|
|
return { kind: "dependency_unavailable", reason: "checkpoint_authority_unreadable", cause };
|
|
}
|
|
if (!checkpoint || checkpoint.recoveryEvidence.runnerInstanceId !== metadata.checkpoint.runnerInstanceId
|
|
|| checkpoint.recoveryEvidence.normalizedSessionId !== input.normalizedSessionId) {
|
|
return { kind: "missing_evidence", reason: "checkpoint_binding" };
|
|
}
|
|
return { kind: "recoverable", snapshot: snapshot as unknown as PersistedNativeSession, metadata, checkpoint };
|
|
}
|
|
|
|
/** Bounded internal migration of ONE retained source while its disk is available.
|
|
* The migration owner supplies compatibility computed from the admitted input.
|
|
* This never repairs/replaces the saved snapshot or claims a live execution. */
|
|
export async function importNativeControllerRecovery(db: Db, input: {
|
|
companyId: string; agentId: string; issueId: string; runId: string; normalizedSessionId: string;
|
|
expectedConfigDigest: string;
|
|
supervisor?: unknown;
|
|
offline?: { writersStopped: true; legacyControllerState?: string; legacyRunnerArtifact?: { version: string; digest: string } };
|
|
legacyCheckpoint?: { legacyControllerState?: string; legacyRunnerArtifact?: { version: string; digest: string } };
|
|
}): Promise<{ kind: "imported" | "unchanged" } | Exclude<NativeControllerRecoveryOutcome, { kind: "recoverable" } | { kind: "no_prior_execution" }>> {
|
|
if (input.offline && input.legacyCheckpoint) throw new Error("Conflicting native controller migration authority");
|
|
const legacy = input.offline ?? input.legacyCheckpoint;
|
|
const supervisor = record(input.supervisor);
|
|
if (input.supervisor !== undefined && (supervisor.schema !== "paperclip.native-session-supervisor.v1" || supervisor.configDigest !== input.expectedConfigDigest)) {
|
|
return { kind: "unsupported", reason: "supervisor_compatibility" };
|
|
}
|
|
return db.transaction(async tx => {
|
|
const [task] = await tx.select({ executionRunId: issues.executionRunId, checkoutRunId: issues.checkoutRunId }).from(issues)
|
|
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId))).for("share");
|
|
const [owner] = await tx.select().from(nativeRunFinalizations).where(and(
|
|
eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.issueId, input.issueId),
|
|
eq(nativeRunFinalizations.runId, input.runId))).for("update");
|
|
if (!task || !owner) return { kind: "missing_evidence" as const, reason: "owner_binding" };
|
|
if (input.offline) {
|
|
const [active] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(offlineMigrationExecutionWhere()).limit(1);
|
|
if (!input.offline.writersStopped || active) return { kind: "missing_evidence" as const, reason: "offline_writers_required" };
|
|
}
|
|
const [clock] = await tx.execute<{ now: Date }>(sql`select clock_timestamp() as now`);
|
|
const liveOwner = owner.leaseOwner && (!owner.leaseExpiresAt || owner.leaseExpiresAt > new Date(clock.now));
|
|
if (input.offline ? liveOwner : owner.leaseOwner || task.executionRunId) {
|
|
return owner.leaseExpiresAt && owner.leaseExpiresAt > new Date(clock.now)
|
|
? { kind: "waiting_for_owner" as const, deadline: owner.leaseExpiresAt }
|
|
: { kind: "missing_evidence" as const, reason: "normal_ownership_recovery_required" };
|
|
}
|
|
if (!input.offline && (!(owner.phase === "committed" && owner.resultId) || owner.recoveryState)) {
|
|
return { kind: "missing_evidence" as const, reason: "pending_finalization" };
|
|
}
|
|
if (input.legacyCheckpoint) {
|
|
const [active] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, input.companyId), inArray(heartbeatRuns.status, ["queued", "running"]),
|
|
or(eq(heartbeatRuns.nativeIssueId, input.issueId), eq(heartbeatRuns.issueId, input.issueId),
|
|
eq(heartbeatRuns.nativeSessionId, input.normalizedSessionId)))).limit(1);
|
|
if (task.checkoutRunId || active) return { kind: "missing_evidence" as const, reason: "legacy_source_active" };
|
|
}
|
|
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId),
|
|
eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.agentId, input.agentId),
|
|
eq(heartbeatRuns.nativeIssueId, input.issueId), eq(heartbeatRuns.nativeSessionId, input.normalizedSessionId))).for("update");
|
|
const profile = record(run?.runnerProfileJson), snapshot = record(profile.sessionCheckpoint), identity = record(snapshot.identity);
|
|
if (run?.runtimeMode !== "native" || !(run.status === "succeeded" || input.legacyCheckpoint && run.status === "failed"
|
|
|| input.offline && ["failed", "cancelled", "interrupted", "timed_out"].includes(run.status)) || !run.finishedAt
|
|
|| identity.runId !== input.runId || identity.companyId !== input.companyId || identity.issueId !== input.issueId
|
|
|| identity.agentId !== input.agentId || identity.sessionId !== input.normalizedSessionId
|
|
|| input.supervisor !== undefined && nativeSha256(snapshot) !== nativeSha256(redactRunnerApiPayloads(supervisor.snapshot))) {
|
|
return { kind: "missing_evidence" as const, reason: "stale_supervisor_snapshot" };
|
|
}
|
|
let checkpoint = await loadNativeRunnerCheckpoint(tx, input);
|
|
if (input.supervisor === undefined) {
|
|
try {
|
|
const admitted = parseNativeExecutionInput(profile.nativeExecutionInput);
|
|
if (!legacy || (!checkpoint && admitted.session.lifecyclePolicy.mode !== "per_turn")
|
|
|| admitted.session.normalizedSessionId !== input.normalizedSessionId
|
|
|| admitted.binding.runId !== input.runId || admitted.binding.companyId !== input.companyId
|
|
|| admitted.binding.agentId !== input.agentId || admitted.binding.issueId !== input.issueId) {
|
|
return { kind: "missing_evidence" as const, reason: "admitted_per_turn_binding" };
|
|
}
|
|
} catch { return { kind: "unsupported" as const, reason: "admitted_input_format" }; }
|
|
}
|
|
const importing = !checkpoint && legacy?.legacyControllerState !== undefined;
|
|
if (importing) {
|
|
if (!run.runnerInstanceId) return { kind: "missing_evidence" as const, reason: "checkpoint_binding" };
|
|
checkpoint = captureLegacyDurablePrpCheckpoint(legacy!.legacyControllerState!, {
|
|
runId: input.runId, runnerInstanceId: run.runnerInstanceId, normalizedSessionId: input.normalizedSessionId,
|
|
}, legacy!.legacyRunnerArtifact);
|
|
}
|
|
if (!checkpoint || checkpoint.recoveryEvidence.normalizedSessionId !== input.normalizedSessionId
|
|
|| checkpoint.recoveryEvidence.runnerInstanceId !== run.runnerInstanceId) {
|
|
return { kind: "missing_evidence" as const, reason: "checkpoint_binding" };
|
|
}
|
|
const metadata = createNativeControllerRecovery(snapshot as unknown as PersistedNativeSession, {
|
|
configDigest: input.expectedConfigDigest,
|
|
processConfigDigest: typeof supervisor.processConfigDigest === "string" ? supervisor.processConfigDigest : null,
|
|
}, { runId: input.runId, runnerInstanceId: checkpoint.recoveryEvidence.runnerInstanceId, normalizedSessionId: input.normalizedSessionId });
|
|
if (!readNativeControllerRecovery(metadata, snapshot)) return { kind: "unsupported" as const, reason: "metadata_format" };
|
|
if (profile.nativeControllerRecovery) {
|
|
if (nativeSha256(profile.nativeControllerRecovery) !== nativeSha256(metadata)) {
|
|
return { kind: "missing_evidence" as const, reason: "existing_metadata_conflict" };
|
|
}
|
|
if (importing) await insertMigratedNativeRunnerCheckpoint(tx, input, checkpoint);
|
|
return { kind: importing ? "imported" as const : "unchanged" as const };
|
|
}
|
|
if (importing) await insertMigratedNativeRunnerCheckpoint(tx, input, checkpoint);
|
|
await tx.update(heartbeatRuns).set({ runnerProfileJson: { ...profile, nativeControllerRecovery: metadata }, updatedAt: new Date() })
|
|
.where(eq(heartbeatRuns.id, input.runId));
|
|
return { kind: "imported" as const };
|
|
});
|
|
}
|