mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(native): resume a remote Runner after a settled suspension
fix(native): resume a remote Runner after a settled suspension A scheduled checkpoint retry can retain a remote reattach claim after closing its failed predecessor cleanly suspends and exits the Runner. Resume only that exact settled source after confirmed exit and a fresh identity/state check. Preserve pending work and uncertain-exit fences. Admit the last already-charged provider attempt and share one bounded recovery deadline with the final state read, retaining closed diagnostic labels on read failures. Cover fresh ACPX Runner relaunch on attempts two and three through actual processes and the durable close barrier. Six affected suites pass 1,579 tests, with 14 environment-gated skips; all server typecheck layers pass on Node 25.6.1. The complete provider turn continuation failure remains preserved as follow-up #19751. Refs: #19366 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Paperclip <noreply@paperclip.ing> Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com>
This commit is contained in:
11 files changed
+417
-35
No files matched your search
@@ -255,7 +255,8 @@ import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { reportRunFailure } from "../run-failure-report.js";
|
||||
import {
|
||||
REMOTE_RECOVERY_MARKER_READ_SCRIPT, REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT,
|
||||
RemoteRunnerRecoveryError, attachRemoteRunnerRecoveryDiagnostic, readRemoteRunnerRecoveryError, remoteRecoveryProbeReason,
|
||||
RemoteRunnerRecoveryError, attachRemoteRunnerRecoveryDiagnostic, describeExitedRemoteRunner, readRemoteRunnerRecoveryError,
|
||||
remoteRecoveryProbeReason,
|
||||
} from "./remote-runner-recovery-diagnostics.js";
|
||||
import { readRemoteRunnerRecoveryProbe, REMOTE_RUNNER_RECOVERY_PROBE_BUDGET_MS } from "./remote-runner-recovery-probe.js";
|
||||
import { describeOwnFailedAttachment, inspectRemoteWarmSessionRecovery, isSettledRemoteWarmControlState, summarizePostTerminalEvents, type RemoteWarmSessionRecovery } from "./remote-warm-session-recovery.js";
|
||||
@@ -308,6 +309,7 @@ import {
|
||||
type NativeLocalRunnerStopProof,
|
||||
isCurrentNativeControllerProcess,
|
||||
nextNativeProviderAttempt,
|
||||
NATIVE_PROVIDER_RECOVERY_ATTEMPT_LIMIT,
|
||||
type NativeControllerIdentity,
|
||||
type NativeRestartRecoveryClaim,
|
||||
} from "./native-restart-recovery.js";
|
||||
@@ -5721,9 +5723,10 @@ async function migrateRunnerdStateRootForExecution(input: {
|
||||
await verifyPluginRunnerTaskReattachment(target.runnerTask, identity);
|
||||
return;
|
||||
}
|
||||
await verifyRemoteRunnerReattachment({
|
||||
await verifyRemoteRunnerOrSettledExit({
|
||||
claim: input.restartRecovery, target: input.runnerExecutionTarget, identity,
|
||||
runId: input.execution.binding.runId, normalizedSessionId: nativeSessionKey(input.execution),
|
||||
providerAttempt: input.restartRecovery.providerAttempt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -13291,6 +13294,20 @@ export function parseRemoteRunnerProcessIdentity(
|
||||
return { pid, startedAt, ...(processStartFingerprint ? { processStartFingerprint } : {}) };
|
||||
}
|
||||
|
||||
const REMOTE_RUNNER_IDENTITY_FIELDS = [
|
||||
"runId",
|
||||
"normalizedSessionId",
|
||||
"runnerInstanceId",
|
||||
"environmentLeaseId",
|
||||
"turnId",
|
||||
"itemId",
|
||||
] as const;
|
||||
|
||||
function remoteRunnerStateDirectory(remoteCwd: string, normalizedSessionId: string) {
|
||||
return posix.join(remoteCwd, ".paperclip-runtime", "paperclip-runner", "sessions",
|
||||
createHash("sha256").update(normalizedSessionId).digest("hex"), "runner");
|
||||
}
|
||||
|
||||
/** Verify remote ownership before exposing the transport's authenticated adoption path. */
|
||||
export async function verifyRemoteRunnerReattachment(input: {
|
||||
claim: Pick<Extract<
|
||||
@@ -13302,6 +13319,7 @@ export async function verifyRemoteRunnerReattachment(input: {
|
||||
runId: string;
|
||||
normalizedSessionId: string;
|
||||
requireIdle?: boolean;
|
||||
recoveryDeadline?: number;
|
||||
}) {
|
||||
const { claim, target, identity } = input;
|
||||
if (
|
||||
@@ -13320,28 +13338,15 @@ export async function verifyRemoteRunnerReattachment(input: {
|
||||
throw new Error("native_remote_recovery_lease_mismatch");
|
||||
}
|
||||
const runner = target.runner;
|
||||
const probeDeadline = Date.now() + REMOTE_RUNNER_RECOVERY_PROBE_BUDGET_MS;
|
||||
const stateDirectory = posix.join(
|
||||
target.remoteCwd,
|
||||
".paperclip-runtime",
|
||||
"paperclip-runner",
|
||||
"sessions",
|
||||
createHash("sha256").update(input.normalizedSessionId).digest("hex"),
|
||||
"runner",
|
||||
);
|
||||
const probeDeadline = Math.min(input.recoveryDeadline ?? Number.POSITIVE_INFINITY,
|
||||
Date.now() + REMOTE_RUNNER_RECOVERY_PROBE_BUDGET_MS);
|
||||
const stateDirectory = remoteRunnerStateDirectory(target.remoteCwd, input.normalizedSessionId);
|
||||
const runnerState = await readRemoteRunnerState({ runner, stateDirectory, recoveryDeadline: probeDeadline });
|
||||
if (input.requireIdle && (runnerState.lifecycle !== "ready" ||
|
||||
!Array.isArray(runnerState.outbox) || runnerState.outbox.length !== 0 || runnerState.pendingTerminalDelivery !== null)) {
|
||||
throw new Error("runner_state_identity_mismatch: remote_warm_session_not_idle");
|
||||
}
|
||||
for (const field of [
|
||||
"runId",
|
||||
"normalizedSessionId",
|
||||
"runnerInstanceId",
|
||||
"environmentLeaseId",
|
||||
"turnId",
|
||||
"itemId",
|
||||
]) {
|
||||
for (const field of REMOTE_RUNNER_IDENTITY_FIELDS) {
|
||||
if (
|
||||
typeof identity[field] !== "string" ||
|
||||
!identity[field] ||
|
||||
@@ -13408,7 +13413,10 @@ export async function verifyRemoteRunnerReattachment(input: {
|
||||
return { alive, reason };
|
||||
};
|
||||
const verified = await check(undefined, probeDeadline);
|
||||
if (!verified.alive) throw new RemoteRunnerRecoveryError({ stage: "process_verification", reason: verified.reason });
|
||||
if (!verified.alive) {
|
||||
throw new RemoteRunnerRecoveryError({ stage: "process_verification", reason: verified.reason,
|
||||
...(verified.reason === "process_not_running" ? describeExitedRemoteRunner(runnerState) : {}) });
|
||||
}
|
||||
// These checks authorize an attempt to reconnect, not the task itself. The
|
||||
// transport still requires authentication against the exact durable PRP key.
|
||||
return {
|
||||
@@ -13419,6 +13427,28 @@ export async function verifyRemoteRunnerReattachment(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyRemoteRunnerOrSettledExit(
|
||||
input: Parameters<typeof verifyRemoteRunnerReattachment>[0] & { providerAttempt: number },
|
||||
) {
|
||||
const recoveryDeadline = Math.min(input.recoveryDeadline ?? Number.POSITIVE_INFINITY,
|
||||
Date.now() + REMOTE_RUNNER_RECOVERY_PROBE_BUDGET_MS);
|
||||
try {
|
||||
return await verifyRemoteRunnerReattachment({ ...input, recoveryDeadline });
|
||||
} catch (error) {
|
||||
const diagnostic = readRemoteRunnerRecoveryError(error);
|
||||
const target = input.target;
|
||||
if (input.providerAttempt > NATIVE_PROVIDER_RECOVERY_ATTEMPT_LIMIT || diagnostic?.stage !== "process_verification" ||
|
||||
diagnostic.reason !== "process_not_running" ||
|
||||
diagnostic.runnerLifecycle !== "suspended" || diagnostic.runnerSettled !== true ||
|
||||
target?.kind !== "remote" || target.transport !== "sandbox" || !target.runner) throw error;
|
||||
const state = await readRemoteRunnerState({ runner: target.runner,
|
||||
stateDirectory: remoteRunnerStateDirectory(target.remoteCwd, input.normalizedSessionId), recoveryDeadline });
|
||||
if (REMOTE_RUNNER_IDENTITY_FIELDS.some(field => state[field] !== input.identity[field]) ||
|
||||
state.lifecycle !== "suspended" || !describeExitedRemoteRunner(state).runnerSettled) throw error;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** A crash before source bootstrap leaves a suspended Runner; a crash during
|
||||
* its drain leaves the same live source. Neither permits a replacement until
|
||||
* the physical process and all six persisted identity fields are proven. */
|
||||
@@ -13433,8 +13463,7 @@ async function verifyStagedRemoteRunner(input: {
|
||||
throw new Error("native_runner_staged_attachment_profile_unsupported");
|
||||
}
|
||||
const sourceIdentity = input.recovery.sourceIdentity;
|
||||
const stateDirectory = posix.join(target.remoteCwd, ".paperclip-runtime", "paperclip-runner", "sessions",
|
||||
createHash("sha256").update(sourceIdentity.normalizedSessionId).digest("hex"), "runner");
|
||||
const stateDirectory = remoteRunnerStateDirectory(target.remoteCwd, sourceIdentity.normalizedSessionId);
|
||||
const read = () => readRemoteRunnerState({ runner: target.runner!, stateDirectory });
|
||||
const state = await read();
|
||||
const control = record(JSON.parse(readBoundedNativeFile(resolve(scopedRunnerdStateRoot(input.execution),
|
||||
@@ -14057,8 +14086,9 @@ async function prepareRemoteExternalRunner({ input, target, runner, root, identi
|
||||
const processIdentityReady = new Promise<void>((resolve, reject) => { identityReady = resolve; identityFailed = reject; });
|
||||
void processIdentityReady.catch(() => {});
|
||||
const adoptExistingRunner = input.restartRecovery?.kind === "reattach_remote_runner"
|
||||
? await verifyRemoteRunnerReattachment({ claim: input.restartRecovery, target, identity: { ...identity },
|
||||
runId: input.execution.binding.runId, normalizedSessionId: nativeSessionKey(input.execution) }) : undefined;
|
||||
? await verifyRemoteRunnerOrSettledExit({ claim: input.restartRecovery, target, identity: { ...identity },
|
||||
runId: input.execution.binding.runId, normalizedSessionId: nativeSessionKey(input.execution),
|
||||
providerAttempt: input.restartRecovery.providerAttempt }) : undefined;
|
||||
if (adoptExistingRunner) { spawned(); identityReady(); }
|
||||
const launch = createRemoteRunnerProcessLauncher({ target, runner, remoteBinary,
|
||||
processIdentityPath: posix.join(runnerStateDirectory, "runner-process.identity"), stateDirectory: runnerStateDirectory,
|
||||
@@ -15828,12 +15858,13 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
});
|
||||
})()
|
||||
: input.restartRecovery?.kind === "reattach_remote_runner"
|
||||
? await verifyRemoteRunnerReattachment({
|
||||
? await verifyRemoteRunnerOrSettledExit({
|
||||
claim: input.restartRecovery,
|
||||
target,
|
||||
identity: durableIdentity ?? {},
|
||||
runId: input.execution.binding.runId,
|
||||
normalizedSessionId: nativeSessionKey(input.execution),
|
||||
providerAttempt: input.restartRecovery.providerAttempt,
|
||||
})
|
||||
: target.kind === "local" && localRecoveryProcess
|
||||
? {
|
||||
|
||||
Reference in new issue
Block a user