mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Preserve plugin session continuity through warm execution and checkpoint recovery (#17820)
This commit is contained in:
1 parent
dcfc2acfa8
commit
ea727dc3c2
23 files changed
+433
-82
No files matched your search
@@ -2062,29 +2062,34 @@ function savePluginRunnerCheckpoint(root: string, state: Record<string, unknown>
|
||||
renameSync(temporary, path);
|
||||
}
|
||||
|
||||
function pluginRunnerdAuthorityLifecycle(
|
||||
root: string,
|
||||
identity: RunnerdDurableIdentity,
|
||||
): "absent" | "suspended" | "not_suspended" | "indeterminate" {
|
||||
try {
|
||||
const state = record(JSON.parse(readBoundedNativeFile(resolve(root, "plugin-runner-state.json"),
|
||||
NATIVE_RUNNER_STATE_MAX_BYTES, "runner_state_too_large").toString("utf8")));
|
||||
if (state.schema !== RUNNERD_STATE_SCHEMA || !RUNNERD_STATE_LIFECYCLES.has(String(state.lifecycle))
|
||||
|| !Object.entries(identity).every(([key, value]) => state[key] === value)) return "indeterminate";
|
||||
return state.lifecycle === "suspended" ? "suspended" : "not_suspended";
|
||||
} catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "indeterminate"; }
|
||||
}
|
||||
|
||||
function runnerdAuthorityLifecycle(
|
||||
root: string,
|
||||
identity: RunnerdDurableIdentity,
|
||||
): "absent" | "suspended" | "not_suspended" | "indeterminate" {
|
||||
const runnerRoot = resolve(root, "runner");
|
||||
if (!existsSync(runnerRoot)) {
|
||||
try {
|
||||
const state = record(JSON.parse(readBoundedNativeFile(resolve(root, "plugin-runner-state.json"),
|
||||
NATIVE_RUNNER_STATE_MAX_BYTES, "runner_state_too_large").toString("utf8")));
|
||||
if (state.schema !== RUNNERD_STATE_SCHEMA || !RUNNERD_STATE_LIFECYCLES.has(String(state.lifecycle))
|
||||
|| !Object.entries(identity).every(([key, value]) => state[key] === value)) return "indeterminate";
|
||||
return state.lifecycle === "suspended" ? "suspended" : "not_suspended";
|
||||
} catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "indeterminate"; }
|
||||
}
|
||||
if (!existsSync(runnerRoot)) return pluginRunnerdAuthorityLifecycle(root, identity);
|
||||
if (!isSafeNativeStateDirectory(runnerRoot)) return "indeterminate";
|
||||
const statePath = resolve(runnerRoot, "runner-state.json");
|
||||
if (!existsSync(statePath)) {
|
||||
try {
|
||||
// Older remote transports created this controller-side placeholder even
|
||||
// though runner state was owned by the sandbox. It carries no authority,
|
||||
// so a verified failover backup may be consulted. Any non-empty direct
|
||||
// so a verified plugin receipt or failover backup may be consulted. Any non-empty direct
|
||||
// directory remains indeterminate and therefore blocks fallback.
|
||||
return readdirSync(runnerRoot).length === 0 ? "absent" : "indeterminate";
|
||||
return readdirSync(runnerRoot).length === 0 ? pluginRunnerdAuthorityLifecycle(root, identity) : "indeterminate";
|
||||
} catch {
|
||||
return "indeterminate";
|
||||
}
|
||||
@@ -4894,6 +4899,72 @@ async function verifyWarmTransitionRestart(input: {
|
||||
return verified;
|
||||
}
|
||||
|
||||
async function recoverPluginRunnerCheckpoint(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInput;
|
||||
runnerExecutionTarget?: AdapterExecutionTarget | null;
|
||||
}, scoped: string): Promise<void> {
|
||||
const target = input.runnerExecutionTarget;
|
||||
if (target?.kind !== "remote" || target.transport !== "sandbox" || !target.runnerTask || !target.leaseId ||
|
||||
target.sandboxLeaseAcquisition?.outcome === "resumed") return;
|
||||
let root = scoped;
|
||||
const quarantined = !existsSync(scoped);
|
||||
const deny = (): never => { throw new Error("runner_state_identity_mismatch: plugin_checkpoint_recovery_unproven"); };
|
||||
if (quarantined) {
|
||||
const quarantine = resolve(runnerdStateBase(), "quarantine");
|
||||
if (!existsSync(quarantine)) return;
|
||||
if (!isSafeNativeStateDirectory(quarantine)) return deny();
|
||||
const candidates = readdirSync(quarantine).filter(name => name.startsWith(`${basename(scoped)}.identity_indeterminate.`));
|
||||
if (candidates.length === 0) return;
|
||||
if (candidates.length !== 1) return deny();
|
||||
root = resolve(quarantine, candidates[0]!);
|
||||
}
|
||||
if (!existsSync(root)) return;
|
||||
if (!isSafeNativeStateDirectory(root)) return deny();
|
||||
if (hasRetainedWarmTransitionEvidence(root)) {
|
||||
if (quarantined) return deny();
|
||||
return;
|
||||
}
|
||||
const identity = readRunnerdDurableIdentity(root);
|
||||
if (!durableIdentityMatchesSession(identity, input.execution) || identity.runId === input.execution.binding.runId) {
|
||||
if (quarantined) return deny();
|
||||
return;
|
||||
}
|
||||
const direct = runnerdAuthorityLifecycle(root, identity);
|
||||
const verifiedPluginReceipt = direct === "suspended" &&
|
||||
!existsSync(resolve(root, "runner", "runner-state.json")) && pluginRunnerdAuthorityLifecycle(root, identity) === "suspended";
|
||||
// Never hide contradictory direct authority behind a remote checkpoint.
|
||||
if (direct !== "absent" && !(quarantined && direct === "suspended") && !verifiedPluginReceipt) {
|
||||
if (quarantined) return deny();
|
||||
return;
|
||||
}
|
||||
await assertCleanupActivationCommitted(input.db, root, input.execution);
|
||||
const prior = await verifyPriorRunnerdStateForSessionScope({ ...input, root, identity, allowVerifiedBackup: true });
|
||||
if (prior !== "terminal_state_indeterminate" && !((quarantined || verifiedPluginReceipt) && prior === "verified")) {
|
||||
if (quarantined) return deny();
|
||||
return;
|
||||
}
|
||||
const control = record(JSON.parse(readBoundedNativeFile(resolve(root, "control-plane", "control-plane-state.json"),
|
||||
NATIVE_CONTROL_PLANE_STATE_MAX_BYTES, "runner_durable_identity_too_large").toString("utf8")));
|
||||
const terminal = record(Array.isArray(control.committedEvents) ? control.committedEvents.at(-1) : null);
|
||||
if (!Array.isArray(control.commands) || control.commands.some(command => record(command).status !== "completed") ||
|
||||
terminal.eventType !== "run.terminal" || record(terminal.envelope).runId !== identity.runId ||
|
||||
record(terminal.envelope).normalizedSessionId !== identity.normalizedSessionId) return deny();
|
||||
const resumeFrom = await resolvePluginTaskResume(input.db, { companyId: input.execution.binding.companyId,
|
||||
leaseId: target.leaseId, priorIdentity: identity, inspectCheckpoint: true });
|
||||
const checkpoint = environmentTaskResultSchema.parse(await target.runnerTask({ kind: "read_state", identity, resumeFrom }));
|
||||
if (checkpoint.kind !== "state" || checkpoint.state.lifecycle !== "suspended" ||
|
||||
!Array.isArray(checkpoint.state.outbox) || checkpoint.state.outbox.length !== 0 ||
|
||||
checkpoint.state.pendingTerminalDelivery != null || checkpoint.state.pendingProviderCleanup != null ||
|
||||
checkpoint.state.warmTransition != null) return deny();
|
||||
savePluginRunnerCheckpoint(root, checkpoint.state, identity);
|
||||
if (await verifyPriorRunnerdStateForSessionScope({ ...input, root, identity, allowVerifiedBackup: true }) !== "verified") return deny();
|
||||
if (quarantined) {
|
||||
if (existsSync(scoped)) return deny();
|
||||
renameSync(root, scoped);
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateRunnerdStateRootForExecution(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInput;
|
||||
@@ -4913,6 +4984,9 @@ async function migrateRunnerdStateRootForExecution(input: {
|
||||
issueId: input.execution.binding.issueId,
|
||||
stateKey: basename(scoped),
|
||||
});
|
||||
if (!input.restartRecovery && !input.retainedWarmOwnerRunId) {
|
||||
await recoverPluginRunnerCheckpoint(input, scoped);
|
||||
}
|
||||
if (
|
||||
input.allowLocalRecovery &&
|
||||
!input.retainedWarmOwnerRunId &&
|
||||
@@ -8000,10 +8074,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
// Inspection can replace a stopped retained task before deciding between adoption and checkpoint recovery.
|
||||
await taskTarget.runnerTask({kind:"describe"});
|
||||
if (taskTarget.sandboxLeaseAcquisition?.outcome !== "resumed") {
|
||||
const resumeFrom=await resolvePluginTaskResume(input.db,{companyId:input.execution.binding.companyId,leaseId:taskTarget.leaseId,priorIdentity:identity});
|
||||
const checkpoint=environmentTaskResultSchema.parse(await taskTarget.runnerTask({kind:"read_state",identity,resumeFrom}));
|
||||
if(checkpoint.kind!=="state") throw new Error("Plugin Runner checkpoint unavailable");
|
||||
savePluginRunnerCheckpoint(root,checkpoint.state,identity);
|
||||
await recoverPluginRunnerCheckpoint(input, root);
|
||||
}
|
||||
}
|
||||
if (durableIdentityMatchesSession(identity, input.execution) && identity.runId !== input.execution.binding.runId &&
|
||||
@@ -11802,8 +11873,8 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
const remoteTarget = target.kind === "remote" ? target : null;
|
||||
const pluginTask = remoteTarget?.transport === "sandbox" ? remoteTarget.runnerTask : undefined;
|
||||
if (pluginTask && input.runnerIngressAuthorized !== true) throw new Error("runner_ingress_unavailable");
|
||||
if (pluginTask && (input.execution.schema === "paperclip.native-execution-input.v6" || input.execution.schema === "paperclip.native-execution-input.v8" || (input.execution.session.lifecyclePolicy.mode === "warm" && input.execution.provider.kind !== "codex"))) {
|
||||
throw new Error("Plugin warm Runner tasks require a Codex workspace session");
|
||||
if (pluginTask && (input.execution.schema === "paperclip.native-execution-input.v6" || input.execution.schema === "paperclip.native-execution-input.v8")) {
|
||||
throw new Error("Plugin Runner tasks require a workspace session");
|
||||
}
|
||||
const remoteCommandRunner = remoteTarget
|
||||
? remoteTarget.transport === "ssh"
|
||||
@@ -11992,7 +12063,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
.where(and(eq(environmentLeases.id, remoteTarget.leaseId!), eq(environmentLeases.companyId, input.execution.binding.companyId)));
|
||||
}, onDispatched: input.onTaskDispatched,
|
||||
};
|
||||
if (input.execution.session.lifecyclePolicy.mode === "warm" && (!credentials.accountIdentity || description.warmTaskVersion !== 1)) throw new Error("Plugin Codex warm task capability or account unavailable");
|
||||
if (input.execution.session.lifecyclePolicy.mode === "warm" && (!credentials.accountIdentity || description.warmTaskVersion !== 1)) throw new Error("Plugin warm task capability or account unavailable");
|
||||
const owner = retainedOwner ?? createPluginRunnerTaskOwner(ownerInput);
|
||||
if (retainedOwner) retainedOwner.rebind(ownerInput);
|
||||
if (description.workspaceFileReadVersion === 1) {
|
||||
|
||||
Reference in new issue
Block a user