mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
Reject failover backups behind the accepted event cursor
Check supported controller journals before copying a verified backup to a replacement sandbox. Preserve native_event_replay_conflict when a runner exit wraps the error, so stale state does not enter a retry loop. Also supply the normal image-only input in the workflow target fixture used by current master. Verified 1,171 native session tests and nine workflow target tests. The new stale-cursor and classification cases fail on unchanged master; the workflow fixture reproduces and fixes the unset input error. Broader CI and exact-head Apex review remain required. Co-Authored-By: Paperclip <noreply@paperclip.ing> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d728be8fbf
commit
0048595610
4 files changed
+129
-4
No files matched your search
@@ -63,6 +63,15 @@ directories, and data Codex downloads again on startup: remote plugin bundles un
|
||||
install staging. After a restore, remote plugins can be unavailable to a turn until
|
||||
Codex finishes downloading them.
|
||||
|
||||
A turn that ends without a proven Runner suspension writes no new copy, but the
|
||||
controller can still acknowledge the events Runner produced after the last copy.
|
||||
Before it restores a copy into a replacement sandbox, the server compares the
|
||||
copy's produced event cursor with the controller's acknowledged cursor. If the
|
||||
copy is behind, the run fails with `native_event_replay_conflict` and automatic
|
||||
recovery stops. No files are copied to the sandbox. Resuming that copy would make
|
||||
Runner reject the controller's acknowledgement, and it could repeat work the
|
||||
controller already accepted.
|
||||
|
||||
An hourly background sweep expires the copy once the session can only resume by
|
||||
reopening closed work. It checks at most 200 sessions and expires at most 50 per
|
||||
batch, starting two minutes after readiness. A copy expires when all of these hold:
|
||||
|
||||
@@ -18089,6 +18089,92 @@ describe("runnerd provider runtime wiring", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ backup: "stale", nextSourceSeq: 120, controlSchema: "paperclip.runner.durable.control-plane-state.v1" },
|
||||
{ backup: "stale", nextSourceSeq: 120, controlSchema: "paperclip.runner.durable.control-plane-state.v2" },
|
||||
{ backup: "current", nextSourceSeq: 1518, controlSchema: "paperclip.runner.durable.control-plane-state.v2" },
|
||||
])("checks the controller cursor before restoring a $backup failover backup ($controlSchema)", async ({ backup, nextSourceSeq, controlSchema }) => {
|
||||
const remoteCwd = join(isolatedStateDirectory, "remote");
|
||||
await mkdir(join(remoteCwd, ".paperclip-runtime", "paperclip-runner"), { recursive: true });
|
||||
const syncIn = vi.fn(async () => undefined);
|
||||
const remoteExecute = vi.fn(async (command: { command: string; args?: string[] }) => {
|
||||
if (command.args?.[0] === "--build-metadata") return {
|
||||
exitCode: 0, timedOut: false, stdout: JSON.stringify({
|
||||
schema: "paperclip-runner/runnerd-build-metadata/v1", binaryName: "paperclip-runnerd",
|
||||
packageName: "@paperclipai/paperclip-runner", binaryContractVersion: 2,
|
||||
durableSessionCapabilities: ["unlimited_runtime", "connection_lease_renewal"],
|
||||
prpTransportModes: ["listen_ws"],
|
||||
}), stderr: "",
|
||||
};
|
||||
if (command.args?.[0] === "--version") return {
|
||||
exitCode: 0, timedOut: false, stdout: "codex-cli 0.160.0", stderr: "",
|
||||
};
|
||||
if (command.args?.[1]?.includes("base64")) return {
|
||||
exitCode: 1, timedOut: false, stdout: "", stderr: "",
|
||||
};
|
||||
return { exitCode: 0, timedOut: false, stdout: "", stderr: "" };
|
||||
});
|
||||
const runnerInstanceId = "runner-failover-cursor";
|
||||
await createRunnerdBackend({
|
||||
db: leaseDb(execution), execution, runnerInstanceId,
|
||||
runnerIngressAuthorized: true,
|
||||
runnerExecutionTarget: {
|
||||
kind: "remote", transport: "sandbox", remoteCwd, environmentId: "environment",
|
||||
leaseId: "lease-replacement", providerKey: "daytona",
|
||||
effectiveCapabilities: { runnerWebSocketIngress: true },
|
||||
sandboxLeaseAcquisition: { outcome: "replacement", providerLeaseId: "sandbox-replacement", reason: "not_found" },
|
||||
runner: { execute: remoteExecute, syncIn },
|
||||
} as never,
|
||||
});
|
||||
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
|
||||
const options = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & {
|
||||
prepareExternalRunnerState: () => Promise<void>;
|
||||
};
|
||||
const root = options.stateDirectory!;
|
||||
await mkdir(join(root, "control-plane"), { recursive: true });
|
||||
await writeFile(join(root, "control-plane", "control-plane-state.json"), JSON.stringify({
|
||||
schema: controlSchema,
|
||||
identity: options.prpIdentity, ackedSourceSeq: 1517, connectionCount: 1, committedEvents: [], commands: [],
|
||||
}));
|
||||
const current = join(root, "failover-backups", "current");
|
||||
await mkdir(join(current, "runner"), { recursive: true });
|
||||
await mkdir(join(current, "codex-home", "sessions"), { recursive: true });
|
||||
await writeFile(join(current, "runner", "runner-state.json"), JSON.stringify({
|
||||
schema: "paperclip.runner.durable.state.v1", ...options.prpIdentity, runnerInstanceId,
|
||||
lifecycle: "suspended", nextSourceSeq, ackedSourceSeq: nextSourceSeq - 1, outbox: [],
|
||||
}));
|
||||
await writeFile(join(current, "codex-home", "sessions", "thread.jsonl"), "thread-state");
|
||||
await writeFile(join(current, "manifest.json"), JSON.stringify(buildNativeHarnessBackupManifest({
|
||||
backupRoot: current, execution, runnerInstanceId,
|
||||
providerSessionIdentity: { providerSessionId: "thread-1", providerBackendSessionId: "session-1", providerSessionIdentity: null },
|
||||
sourceProviderLeaseId: "sandbox-previous",
|
||||
})));
|
||||
|
||||
const prepared = options.prepareExternalRunnerState();
|
||||
if (backup === "stale") {
|
||||
await expect(prepared).rejects.toThrow(
|
||||
"native_event_replay_conflict: failover backup is behind the controller cursor (backup produced 119, controller acknowledged 1517)",
|
||||
);
|
||||
expect(syncIn).not.toHaveBeenCalled();
|
||||
const error = await prepared.catch((caught: unknown) => caught);
|
||||
expect(nativeSessionFailureSourceCode(new Error(`native_session_recovery_failed: ${String(error)}`)))
|
||||
.toBe("native_event_replay_conflict");
|
||||
expect(nativeSessionFailureDisposition(1, new Date(), "native_event_replay_conflict").phase).toBe("terminal_failure");
|
||||
} else {
|
||||
await expect(prepared).rejects.toThrow("runner_harness_state_mismatch: restored_provider_identity_changed");
|
||||
expect(syncIn).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an integrity conflict permanent when a runner exit wraps it", () => {
|
||||
expect(nativeSessionFailureSourceCode(new Error(
|
||||
"native_session_recovery_failed: runnerd exited unexpectedly: native_event_replay_conflict: failover backup is behind the controller cursor",
|
||||
))).toBe("native_event_replay_conflict");
|
||||
expect(nativeSessionFailureSourceCode(new Error(
|
||||
"native_session_recovery_failed: Error: runnerd exited while waiting for runner.drain",
|
||||
))).toBe("native_runner_process_exited");
|
||||
});
|
||||
|
||||
it.each([
|
||||
...["current", "preinstalled-exact", "preinstalled-mismatch", "preinstalled-error", "preinstalled-timeout", "stale", "missing", "retained", "retained-mismatch", "retained-error", "retained-timeout", "retained-explicit"]
|
||||
.map((image) => ({ image, version: "0.160.0" })),
|
||||
|
||||
@@ -7005,6 +7005,34 @@ export function verifyNativeHarnessBackup(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function nativeHarnessBackupCursorConflict(input: {
|
||||
backupRoot: string;
|
||||
stateRoot: string;
|
||||
}): string | null {
|
||||
const read = (path: string, maxBytes: number) => {
|
||||
try {
|
||||
return existsSync(path)
|
||||
? record(JSON.parse(readBoundedNativeFile(path, maxBytes, "runner_durable_identity_too_large").toString("utf8")))
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const control = read(resolve(input.stateRoot, "control-plane", "control-plane-state.json"),
|
||||
NATIVE_CONTROL_PLANE_STATE_MAX_BYTES);
|
||||
if (!control || !RUNNERD_CONTROL_PLANE_STATE_SCHEMAS.has(String(control.schema))) return null;
|
||||
const runner = read(resolve(input.backupRoot, "runner", "runner-state.json"), NATIVE_RUNNER_STATE_MAX_BYTES);
|
||||
if (!runner) return null;
|
||||
const acked = control.ackedSourceSeq;
|
||||
const next = runner.nextSourceSeq;
|
||||
if (!Number.isSafeInteger(acked) || !Number.isSafeInteger(next) || (next as number) < 1 ||
|
||||
record(control.identity).runnerInstanceId !== runner.runnerInstanceId) return null;
|
||||
const produced = (next as number) - 1;
|
||||
return (acked as number) > produced
|
||||
? `native_event_replay_conflict: failover backup is behind the controller cursor (backup produced ${produced}, controller acknowledged ${acked as number})`
|
||||
: null;
|
||||
}
|
||||
|
||||
function nativeSessionCheckpointDirectory(): string {
|
||||
const directory = resolve(
|
||||
resolvePaperclipInstanceRoot(),
|
||||
@@ -7412,6 +7440,9 @@ export function nativeSessionFailureSourceCode(
|
||||
) {
|
||||
return "provider_transport_failed";
|
||||
}
|
||||
if (/native_event_replay_conflict/i.test(message)) {
|
||||
return "native_event_replay_conflict";
|
||||
}
|
||||
if (
|
||||
/native_runner_process_exited|runnerd exited|runner process failed/i.test(
|
||||
message,
|
||||
@@ -7422,9 +7453,6 @@ export function nativeSessionFailureSourceCode(
|
||||
if (/planning_mode_unsupported/i.test(message)) {
|
||||
return "planning_mode_unsupported";
|
||||
}
|
||||
if (/native_event_replay_conflict/i.test(message)) {
|
||||
return "native_event_replay_conflict";
|
||||
}
|
||||
if (message.toLowerCase().includes(NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ.toLowerCase())) {
|
||||
return NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ;
|
||||
}
|
||||
@@ -14346,6 +14374,8 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
});
|
||||
if (!backup) throw new Error("runner_harness_backup_unavailable");
|
||||
const cursorConflict = nativeHarnessBackupCursorConflict({ backupRoot: backup.root, stateRoot: root });
|
||||
if (cursorConflict) throw new Error(cursorConflict);
|
||||
await measureNativeRunnerSpan(
|
||||
input.trace,
|
||||
"harness_state.failover_restore",
|
||||
|
||||
@@ -17,7 +17,7 @@ async function resolveTarget(event: string, cadence: string, branch = "master")
|
||||
const result = spawnSync("bash", ["-c", `gh() { printf '%s\\n' "$MOCK_BRANCH_SHA"; }\n${script}`], {
|
||||
encoding: "utf8", env: { ...process.env, EVENT_NAME: event, EVAL_CADENCE: cadence,
|
||||
TARGET_BRANCH: branch, DEFAULT_BRANCH: "master", GITHUB_SHA: queuedSha,
|
||||
MOCK_BRANCH_SHA: advancedSha, GITHUB_OUTPUT: output, REPOSITORY: "paperclipai/paperclip" },
|
||||
MOCK_BRANCH_SHA: advancedSha, GITHUB_OUTPUT: output, REPOSITORY: "paperclipai/paperclip", IMAGE_ONLY: "false" },
|
||||
});
|
||||
return { status: result.status, stderr: result.stderr,
|
||||
output: await readFile(output, "utf8").catch(() => "") };
|
||||
|
||||
Reference in new issue
Block a user