mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 10:40:33 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A native session retains its workspace and provider session across turns. > - An ephemeral web server must restore those files without its predecessor's disk. > - Legacy enrollment changes the workspace authority and can change both workspace addresses. > - The retained provider backup checks those addresses as part of its identity. > - This pull request preserves the exact addresses in a verified migration receipt. > - The benefit is that the next turn can use the original session after enrollment. ## Linked Issues or Issue Description Refs #18932, merged as `612d9931a6`. This pull request builds on that all-Runner durability change. Related: #18918 fixes concurrent workspace preservation locks. This change covers identity preservation during offline enrollment. ## What Changed - The native workspace migration owner records the selected session bindings in the successful workspace migration operation. - Runtime selection retains the original logical address only for the exact agent, task and session. - Remote workspace preparation restores the original provider address. It refuses conflicting targets, forged receipts and occupied unowned roots. - The environment owner includes the migration operation in the reusable lease fingerprint. Warm policy stays warm and receives a fresh allocation through normal lease cleanup. - Exact migration evidence permits the existing verified provider backup to restore into that fresh warm allocation. - Preserve verified provider allocation files through a normalized, lease-bound digest receipt. Unknown files and altered markers remain refused; interrupted publication is retryable. - Retain legacy managed-instruction session fingerprints using the exact imported content hash and recorded paths, preserving all other configuration and credential checks. - Migrate existing local skill archive/manifest objects through upload claims, readback verification and receipt compare-and-swap; retain original objects and IDs. - Add explicit two-turn browser qualification for every released native Daytona profile, with warm and per-turn variants. The production maintenance entry runs offline. The old home is deleted only after a verified export and successor readiness. The oracle checks provider identity, native session, addresses, reference bytes, question and final document. ## Verification - Full repository typecheck passed at `b12ecd823e`. The production session fix passed 198 session/credential tests. Full repository build passed at `94bb917947`. The final Rust-only provider fix passed 57 ACPX provider tests and the exact Linux release build. - Workspace and transport checks passed: 117 transport/workspace tests, six final real PostgreSQL migration boundary cases, 178 environment tests, 539 Daytona tests (eight skips), and the native warm-backup admission control. - Local skill migration passed eight PostgreSQL/S3 tests (one existing opt-in skip). Failed readback retains the source. Successful readback still works after deletion of the old home. - Latest Product E2E typecheck and 38 focused harness tests pass. Earlier catalog/admission/report checks passed. Twelve unrelated support-test failures reproduce on unchanged base `1a39dea9aa`; these are not reported as passing. - Live Codex per-turn at `2d5bb9a6d1` retained the native and provider sessions, both workspace paths and exact reference bytes after deletion of the old home. It completed the task and delivered its saved document and original reference. Its original failure was an output-count grading defect. The corrected grader passes all 21 checks against retained, hashed evidence; the original result remains unchanged. - Live OpenCode per-turn and Codex warm at harness `94bb917947` pass all 21 product checks. Their launcher cleanup failed because a read-only diagnostic observer kept its disposable database open. The observer now closes before ordinary database cleanup; a no-provider reproduction confirms the fix. All owned sandboxes were independently verified absent, child keys revoked and buckets removed. Original failed campaign dispositions remain unchanged. - Claude at `94bb917947` kept its sessions and reference bytes but omitted the final clickable document link. That user-delivery failure remains recorded. The fixture now explicitly asks for the link and records migration evidence before checking navigation. The fresh Claude attempt at `b12ecd823e` passed both strict migration checks: exact provider/native session, both paths, original file bytes and independently downloaded attachment. Its full browser workflow failed again: it requested unnecessary confirmation, used the answer sentence as the reference, and omitted the clickable document link. This is retained as a failed user-workflow result, not a full pass. - Earlier live failures exposed and reproduced the skill-object, fresh-allocation marker and managed-instruction fingerprint bugs. The fixes retain original identity checks. No provider session reset or accounting bypass was used. - The real Grok continuation at `b12ecd823e` failed before provider execution because its immutable-instruction attach policy rejected relocated registered file copies. A no-provider regression reproduced the exact rejection. `b05ba3728a` selects the existing strict authenticated asset-path policy for Grok; content digests, unknown context fields, session/workspace identity and same-run mutations remain enforced. The regression and all 57 ACPX provider controls pass. The Grok browser migration now passes all 21 checks with the b05 Linux Runner, including provider identity, exact reference bytes and final user output; the launcher also reports successful cleanup. - `24fabf1d81` applies the same policy to released ACPX Codex after a second no-provider reproduction. All 58 ACPX provider controls and the final Linux release build pass. Its live browser migration passes all 21 checks on the final binary, including exact original session/file preservation and completed user output. - The full native-session executor suite passes all 1,121 tests after updating its existing lease database fixture for the migration-receipt join. The prior CI failure was this unsupported test-double method; the production guard still rejects ordinary unproven recovery. CI and Greptile 5/5 passed at `ca1e0d5fe6`. The first CI attempt lost several workers and had one rotated-recovery transport failure; its seven descriptor cases passed locally, and the single unchanged retry passed. The original failure remains recorded. Staging enrollment has not started. Initial offline enrollment and subsequent rolling availability are separate checks. - The final review follow-up adds real runtime-selection PostgreSQL cases for exact, different-session and absent receipts, plus executor-level warm restoration that copies actual backup files and verifies provider identity and the new lease stamp. Both complete affected suites pass (1,172 tests), and full repository typecheck passes. Removing either production migration connection independently makes its new positive case fail; both source files were restored byte-for-byte after these negative controls. - Final commit `0971d373a3` passes all 63 checks (57 successes, five expected skips, one neutral); Greptile is 5/5 and every review thread is resolved. ## Risks - An identity conflict blocks enrollment and keeps the original files. It does not silently reset the session. - A new migration generation retires a reusable lease through its existing owner. The original sandbox must finish normal cleanup before reuse proceeds. - Initial enrollment has an offline step. The separate rolling suites prove uninterrupted deploy behavior after enrollment. - This adds no schema migration and changes no provider credentials or accounting gate. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model build and context window are not exposed in this session. The agent used reasoning, terminal tools, code edits, PostgreSQL/filesystem tests and isolated live browser qualification. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
523 lines
24 KiB
TypeScript
523 lines
24 KiB
TypeScript
import { isRollingWebDeployment } from "./controller-rolling-deployment.js";
|
|
import { superviseRollingDeployment } from "./rolling-deployment-supervisor.js";
|
|
import { isLegacyWorkspaceMigration } from "./legacy-workspace-migration.js";
|
|
import { superviseLegacyWorkspaceMigration } from "./legacy-workspace-migration-supervisor.js";
|
|
import { readRestartRequest, type RestartRequest } from "./server-restart-request.js";
|
|
import { startCursorCloudCallbackProxy } from "./cursor-cloud-callback-proxy.js";
|
|
import { prepareControllerDatabase } from "./controller-database.js";
|
|
import { readUpgradeArtifacts, verifyUpgradeArtifact, assertServingArtifact, upgradeArtifactKey, type ServingArtifact } from "./upgrade-artifacts.js";
|
|
import { createControllerRecoveryObserver } from "./controller-recovery-observer.js";
|
|
import { readFinalizationCrashBoundary } from "./finalization-crash-boundary.js";
|
|
import { replaceController, retireControllerCache, type ControllerOutageReceipt } from "./controller-outage.js";
|
|
import { recoverInterruptedFeedback } from "./feedback-recovery.js";
|
|
import { normalizedSecrets } from "./redaction.js";
|
|
import { CREDENTIAL_NAMES } from "./types.js";
|
|
import { EVAL_OBSERVABILITY_SECRET_NAMES } from "./braintrust.js";
|
|
import { usesInstalledCli, verifyInstalledCli } from "./installed-cli.js";
|
|
import { runnerMatrix } from "./catalog.js";
|
|
import { remoteWorkspaceStorageServerEnvironment } from "./remote-workspace-storage.js";
|
|
import { createRunnerE2EServerStopper, runnerE2EServerDetached } from "./server-stop.js";
|
|
import { runnerE2ETypeScriptProcessArgs } from "./web-server-command.js";
|
|
import { qualifyLegacyClaudeCli } from "./legacy-claude-cli.js";
|
|
import { prepareSourceServerDependencies } from "./source-startup.js";
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { createWriteStream } from "node:fs";
|
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { prepareRunnerE2EServerConfig } from "./server-config.js";
|
|
import { installedReleaseEnvironment, installedReleaseLaunch } from "./installed-release.js";
|
|
import {
|
|
assertIsolatedServerEnvironment,
|
|
buildPaperclipServerEnvironment,
|
|
runnerE2EServerControlPaths,
|
|
} from "./harness-env.js";
|
|
|
|
function required(name: string) {
|
|
const value = process.env[name]?.trim();
|
|
if (!value) throw new Error(`${name} is required`);
|
|
return value;
|
|
}
|
|
|
|
const logPath = required("PAPERCLIP_RUNNER_E2E_SERVER_LOG");
|
|
const temporaryRoot = required("PAPERCLIP_RUNNER_E2E_TEMP_ROOT");
|
|
const paperclipHome = required("PAPERCLIP_HOME");
|
|
const configPath = required("PAPERCLIP_CONFIG");
|
|
const port = required("PAPERCLIP_RUNNER_E2E_PORT");
|
|
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
|
|
const paperclipCli = path.join(repositoryRoot, "tests/runner-e2e/server-entry.ts");
|
|
const executionIds: string[] = JSON.parse(process.env.PAPERCLIP_RUNNER_E2E_EXECUTION_IDS ?? "[]");
|
|
const upgradeArtifacts = await readUpgradeArtifacts(process.env[upgradeArtifactKey]);
|
|
let artifactIndex = 0;
|
|
let kimiCandidateAdmission: RestartRequest["kimiCandidateAdmission"];
|
|
const selectedExecutions = usesInstalledCli(process.env) || upgradeArtifacts ? executionIds.map(id => {
|
|
const execution = runnerMatrix.find(row => row.id === id);
|
|
if (!execution) throw new Error("Unknown installed CLI proof execution");
|
|
return execution;
|
|
}) : [];
|
|
const {
|
|
controlDirectory,
|
|
restartRequestPath,
|
|
restartAcknowledgementPath: restartAckPath,
|
|
} = runnerE2EServerControlPaths(temporaryRoot);
|
|
const restartTimeoutMs = 180_000;
|
|
const gracefulStopTimeoutMs = 30_000;
|
|
const initialCli = upgradeArtifacts?.artifacts.A.pins.PAPERCLIP_RUNNER_E2E_INSTALLED_CLI ?? process.env.PAPERCLIP_RUNNER_E2E_INSTALLED_CLI;
|
|
const installedRelease = initialCli ? installedReleaseLaunch(initialCli) : null;
|
|
const workspaceStorage = remoteWorkspaceStorageServerEnvironment({
|
|
executions: executionIds.map(id => runnerMatrix.find(execution => execution.id === id)).filter((execution): execution is typeof runnerMatrix[number] => Boolean(execution)),
|
|
env: process.env, instanceId: required("PAPERCLIP_INSTANCE_ID"),
|
|
});
|
|
const servingArtifacts = new WeakMap<ChildProcess, ServingArtifact>();
|
|
const serverEnvironment = buildPaperclipServerEnvironment(installedRelease
|
|
? installedReleaseEnvironment(process.env, repositoryRoot, path.join(temporaryRoot, "provider-bin"))
|
|
: process.env, {
|
|
NODE_ENV: "test",
|
|
PORT: port,
|
|
// Keep provider caches attempt-private without changing Playwright's browser
|
|
// cache lookup in the parent process.
|
|
XDG_CACHE_HOME: path.join(temporaryRoot, "xdg-cache"),
|
|
PAPERCLIP_HOME: paperclipHome,
|
|
PAPERCLIP_CONFIG: configPath,
|
|
PAPERCLIP_INSTANCE_ID: required("PAPERCLIP_INSTANCE_ID"),
|
|
PAPERCLIP_AGENT_JWT_SECRET: required("PAPERCLIP_AGENT_JWT_SECRET"),
|
|
PAPERCLIP_DECISION_SIGNING_SECRET: required(
|
|
"PAPERCLIP_DECISION_SIGNING_SECRET",
|
|
),
|
|
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: required(
|
|
"PAPERCLIP_TOOL_ACTION_SIGNING_SECRET",
|
|
),
|
|
BETTER_AUTH_SECRET: required("BETTER_AUTH_SECRET"),
|
|
PAPERCLIP_BIND: "loopback",
|
|
PAPERCLIP_BIND_HOST: "127.0.0.1",
|
|
PAPERCLIP_DEPLOYMENT_MODE: process.env.PAPERCLIP_RUNNER_E2E_PUBLIC_MCP === "1" ? "authenticated" : "local_trusted",
|
|
...(process.env.PAPERCLIP_RUNNER_E2E_PUBLIC_MCP === "1" ? {
|
|
PAPERCLIP_PUBLIC_URL: `http://127.0.0.1:${port}`,
|
|
PAPERCLIP_AUTH_PUBLIC_BASE_URL: `http://127.0.0.1:${port}`,
|
|
PAPERCLIP_AUTH_BASE_URL_MODE: "explicit",
|
|
// Use empty provider configuration as the managed homes' seed. A local
|
|
// operator's installed plugins, MCP connections and auth are not fixtures.
|
|
CODEX_HOME: path.join(temporaryRoot, "provider-config", "codex"),
|
|
CLAUDE_CONFIG_DIR: path.join(temporaryRoot, "provider-config", "claude"),
|
|
} : {}),
|
|
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private",
|
|
SERVE_UI: "true",
|
|
PAPERCLIP_STORAGE_PROVIDER: "local_disk",
|
|
PAPERCLIP_STORAGE_LOCAL_DIR: path.join(temporaryRoot, "storage"),
|
|
PAPERCLIP_SECRETS_PROVIDER: "local_encrypted",
|
|
PAPERCLIP_SECRETS_STRICT_MODE: "true",
|
|
PAPERCLIP_DB_BACKUP_ENABLED: "false",
|
|
PAPERCLIP_DB_BACKUP_DIR: path.join(temporaryRoot, "backups"),
|
|
// Onboarding normally opens the app after listen. Browser ownership belongs
|
|
// to Playwright in this harness, so never create a developer desktop tab.
|
|
PAPERCLIP_OPEN_ON_LISTEN: "false",
|
|
...workspaceStorage,
|
|
});
|
|
assertIsolatedServerEnvironment(serverEnvironment, {
|
|
temporaryRoot,
|
|
paperclipHome,
|
|
configPath,
|
|
workspaceStorage,
|
|
});
|
|
const definedServerEnvironment = Object.fromEntries(
|
|
Object.entries(serverEnvironment).filter(
|
|
(entry): entry is [string, string] => entry[1] !== undefined,
|
|
),
|
|
);
|
|
|
|
await prepareSourceServerDependencies(repositoryRoot, definedServerEnvironment, Boolean(installedRelease));
|
|
|
|
await Promise.all([
|
|
mkdir(path.dirname(logPath), { recursive: true }),
|
|
mkdir(controlDirectory, { recursive: true, mode: 0o700 }),
|
|
...(process.env.PAPERCLIP_RUNNER_E2E_PUBLIC_MCP === "1" ? [
|
|
mkdir(path.join(temporaryRoot, "provider-config", "codex"), { recursive: true, mode: 0o700 }),
|
|
mkdir(path.join(temporaryRoot, "provider-config", "claude"), { recursive: true, mode: 0o700 }),
|
|
] : []),
|
|
]);
|
|
const log = createWriteStream(logPath, { flags: "a", mode: 0o600 });
|
|
const expectedStops = new WeakSet<ChildProcess>();
|
|
const childErrors = new WeakMap<ChildProcess, Error>();
|
|
let child: ChildProcess | null = null;
|
|
let unexpectedChildFailure: Error | null = null;
|
|
let shutdownSignal: NodeJS.Signals | null = null;
|
|
let activeRestartRequestId: string | null = null;
|
|
let feedbackRecovery: Promise<void> | undefined;
|
|
let controllerDatabase: Awaited<ReturnType<typeof prepareControllerDatabase>> | undefined;
|
|
|
|
function recoverFeedbackBeforeShutdown() {
|
|
return feedbackRecovery ??= (async () => {
|
|
const privateDir = process.env.PAPERCLIP_RUNNER_E2E_PRIVATE_DIR;
|
|
const campaignId = process.env.PAPERCLIP_E2E_ACCOUNTING_CAMPAIGN_ID;
|
|
const attemptId = process.env.PAPERCLIP_E2E_ACCOUNTING_ATTEMPT_ID;
|
|
if (!privateDir || !campaignId || !attemptId || process.env.PAPERCLIP_RUNNER_E2E_PUBLIC_MCP === "1") return;
|
|
const deadlineAt = Date.now() + 5_000;
|
|
const secrets = normalizedSecrets([...CREDENTIAL_NAMES, ...EVAL_OBSERVABILITY_SECRET_NAMES,
|
|
"PAPERCLIP_E2E_GATEWAY_TOKEN", "PAPERCLIP_AGENT_JWT_SECRET", "PAPERCLIP_DECISION_SIGNING_SECRET",
|
|
"PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", "BETTER_AUTH_SECRET"].map(name => process.env[name]));
|
|
for (const executionId of executionIds) {
|
|
if (Date.now() >= deadlineAt) break;
|
|
const execution = runnerMatrix.find(row => row.id === executionId);
|
|
if (!execution) continue;
|
|
try {
|
|
await recoverInterruptedFeedback({ identity: { campaignId, attemptId, executionId },
|
|
caseDirectory: path.join(privateDir, "cases", execution.task.id), secrets, deadlineAt,
|
|
api: { async get<T>(endpoint: string, options?: { timeout: number }): Promise<T> {
|
|
const base = `http://127.0.0.1:${port}`;
|
|
const url = new URL(endpoint, base);
|
|
if (url.origin !== base || !url.pathname.startsWith("/api/")) throw new Error("Invalid feedback recovery endpoint");
|
|
const response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(Math.max(1, Math.min(options?.timeout ?? 1_000, deadlineAt - Date.now()))) });
|
|
if (!response.ok) { await response.body?.cancel(); throw new Error("Feedback recovery read failed"); }
|
|
return response.json() as Promise<T>;
|
|
} },
|
|
});
|
|
} catch { appendLog("Feedback recovery checkpoint unavailable; retain private recovery state.\n"); }
|
|
}
|
|
})();
|
|
}
|
|
|
|
async function stopWithFeedback(candidate: ChildProcess, signal: NodeJS.Signals) {
|
|
await recoverFeedbackBeforeShutdown();
|
|
await stopServer(candidate, signal);
|
|
}
|
|
|
|
function appendLog(message: string) {
|
|
process.stderr.write(message);
|
|
log.write(message);
|
|
}
|
|
|
|
function shutdownRequested() {
|
|
return shutdownSignal !== null;
|
|
}
|
|
|
|
function childExited(candidate: ChildProcess) {
|
|
return candidate.exitCode !== null || candidate.signalCode !== null;
|
|
}
|
|
|
|
function describeChildExit(candidate: ChildProcess) {
|
|
const spawnError = childErrors.get(candidate);
|
|
if (spawnError) return `server spawn failed: ${spawnError.message}`;
|
|
return `server exited code=${String(candidate.exitCode)} signal=${String(candidate.signalCode)}`;
|
|
}
|
|
|
|
async function startServer() {
|
|
if (shutdownRequested()) {
|
|
throw new Error("Refusing to start Paperclip after wrapper shutdown");
|
|
}
|
|
// Recheck bytes and dependency resolution on every controller restart.
|
|
const selected = upgradeArtifacts?.sequence[artifactIndex];
|
|
if (upgradeArtifacts && !selected) throw new Error("Upgrade artifact sequence exhausted");
|
|
const verified = selected ? await verifyUpgradeArtifact(upgradeArtifacts!.artifacts[selected], process.env, selectedExecutions) : undefined;
|
|
const installed = verified?.installed ?? await verifyInstalledCli(process.env, selectedExecutions);
|
|
const launch = installed ? installedReleaseLaunch(installed.entry) : null;
|
|
// The serving process must report its baked-in revision, never a test-supplied SHA.
|
|
const environment = { ...definedServerEnvironment };
|
|
if (kimiCandidateAdmission) environment.PAPERCLIP_ACPX_CANDIDATE_ADMISSION = JSON.stringify(kimiCandidateAdmission);
|
|
if (upgradeArtifacts) {
|
|
delete environment.PAPERCLIP_BUILD_COMMIT;
|
|
delete environment[upgradeArtifactKey];
|
|
environment.GIT_CEILING_DIRECTORIES = upgradeArtifacts.artifacts[selected!].root;
|
|
}
|
|
const candidate = spawn(
|
|
process.execPath,
|
|
installed ? [installed.entry, "onboard", "--yes", "--run"] : runnerE2ETypeScriptProcessArgs(repositoryRoot, paperclipCli, ["onboard", "--yes", "--run"]),
|
|
{
|
|
cwd: launch?.cwd ?? repositoryRoot,
|
|
env: environment,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
// Playwright signals the wrapper's group. Keep that signal from bypassing
|
|
// our single graceful stop; the launcher still tracks descendant groups.
|
|
detached: runnerE2EServerDetached,
|
|
},
|
|
);
|
|
child = candidate;
|
|
if (verified) servingArtifacts.set(candidate, verified.receipt);
|
|
|
|
candidate.stdout?.on("data", (chunk) => {
|
|
process.stdout.write(chunk);
|
|
log.write(chunk);
|
|
});
|
|
candidate.stderr?.on("data", (chunk) => {
|
|
process.stderr.write(chunk);
|
|
log.write(chunk);
|
|
});
|
|
candidate.once("error", (error) => {
|
|
childErrors.set(candidate, error);
|
|
if (!expectedStops.has(candidate) && !shutdownRequested()) {
|
|
unexpectedChildFailure = new Error(
|
|
`Paperclip server spawn failed: ${error.message}`,
|
|
);
|
|
}
|
|
});
|
|
candidate.once("exit", () => {
|
|
appendLog(`\n${describeChildExit(candidate)}\n`);
|
|
if (!expectedStops.has(candidate) && !shutdownRequested()) {
|
|
unexpectedChildFailure = new Error(
|
|
`Paperclip server stopped unexpectedly: ${describeChildExit(candidate)}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// A shutdown may arrive in the synchronous interval around spawn. Never let
|
|
// that race create an unowned replacement server.
|
|
if (shutdownSignal) {
|
|
// The main/error path awaits this same promise and reports any failure.
|
|
void stopWithFeedback(candidate, shutdownSignal).catch(() => {});
|
|
}
|
|
await stopServer.watch(candidate);
|
|
return candidate;
|
|
}
|
|
|
|
function delay(milliseconds: number) {
|
|
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
|
}
|
|
|
|
const stopServer = createRunnerE2EServerStopper({
|
|
gracefulTimeoutMs: gracefulStopTimeoutMs,
|
|
forcedTimeoutMs: 5_000,
|
|
hasSpawnError: (candidate) => childErrors.has(candidate),
|
|
markExpectedStop: (candidate) => { expectedStops.add(candidate); },
|
|
log: appendLog,
|
|
});
|
|
|
|
async function waitForHealth(candidate: ChildProcess) {
|
|
const deadline = Date.now() + restartTimeoutMs;
|
|
const healthUrl = `http://127.0.0.1:${port}/api/health`;
|
|
while (Date.now() < deadline) {
|
|
if (shutdownRequested()) {
|
|
throw new Error("Wrapper shutdown interrupted the Paperclip restart");
|
|
}
|
|
if (childErrors.has(candidate) || childExited(candidate)) {
|
|
throw new Error(
|
|
`Replacement Paperclip server could not start: ${describeChildExit(candidate)}`,
|
|
);
|
|
}
|
|
try {
|
|
const response = await fetch(healthUrl, {
|
|
signal: AbortSignal.timeout(1_000),
|
|
});
|
|
if (response.ok) {
|
|
const artifact = servingArtifacts.get(candidate);
|
|
if (artifact) assertServingArtifact(await response.json(), artifact);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.includes("serving server revision")) throw error;
|
|
// The replacement process may still be booting.
|
|
}
|
|
await delay(250);
|
|
}
|
|
throw new Error(
|
|
`Replacement Paperclip server did not become healthy within ${restartTimeoutMs}ms`,
|
|
);
|
|
}
|
|
|
|
async function waitForHealthToStop() {
|
|
const healthUrl = `http://127.0.0.1:${port}/api/health`;
|
|
const deadline = Date.now() + gracefulStopTimeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (shutdownRequested()) {
|
|
throw new Error("Wrapper shutdown interrupted the Paperclip restart");
|
|
}
|
|
try {
|
|
await fetch(healthUrl, { signal: AbortSignal.timeout(500) });
|
|
} catch {
|
|
return;
|
|
}
|
|
await delay(100);
|
|
}
|
|
throw new Error(
|
|
"The old Paperclip server remained healthy after its launcher exited",
|
|
);
|
|
}
|
|
|
|
|
|
async function writeRestartAck(
|
|
requestId: string,
|
|
status: "ready" | "failed",
|
|
message?: string,
|
|
controllerCacheDiscarded = false,
|
|
crashBoundary?: Awaited<ReturnType<typeof readFinalizationCrashBoundary>>,
|
|
controllerOutage?: ControllerOutageReceipt,
|
|
) {
|
|
const temporaryAckPath = `${restartAckPath}.${process.pid}.tmp`;
|
|
await writeFile(
|
|
temporaryAckPath,
|
|
`${JSON.stringify({
|
|
requestId,
|
|
status,
|
|
controllerCacheDiscarded,
|
|
completedAt: new Date().toISOString(),
|
|
...(message ? { message } : {}),
|
|
...(crashBoundary ? { crashBoundary } : {}),
|
|
...(controllerOutage ? { controllerOutage } : {}),
|
|
})}\n`,
|
|
{ encoding: "utf8", mode: 0o600 },
|
|
);
|
|
await rename(temporaryAckPath, restartAckPath);
|
|
}
|
|
|
|
async function restartServer({ requestId, kimiCandidateAdmission: stagedKimiAdmission, preserveRunner, discardControllerCache, crashAfterResult, controllerOutage, recoveryBinding, controllerDowntimeMs = 0 }: RestartRequest) {
|
|
activeRestartRequestId = requestId;
|
|
appendLog(`\nRestart request ${requestId}: stopping Paperclip\n`);
|
|
const previous = child;
|
|
if (!previous) throw new Error("No Paperclip server is available to restart");
|
|
const discardCache = async () => {
|
|
const cache = path.join(paperclipHome, "instances", required("PAPERCLIP_INSTANCE_ID"), "runtime", "paperclip-runner");
|
|
await retireControllerCache(cache, path.join(controlDirectory, `retired-controller-cache-${requestId}`));
|
|
appendLog(`Restart request ${requestId}: old controller cache made unavailable\n`);
|
|
};
|
|
if (controllerOutage) {
|
|
if (upgradeArtifacts) artifactIndex += 1;
|
|
const previousArtifact = servingArtifacts.get(previous);
|
|
const observer = recoveryBinding ? await createControllerRecoveryObserver(configPath, recoveryBinding) : undefined;
|
|
try {
|
|
const receipt = await replaceController({ requestId, previousPid: previous.pid!, outage: controllerOutage,
|
|
stop: async signal => {
|
|
if (signal === "SIGKILL") await stopServer.crashForRestart(previous);
|
|
else await stopServer(previous);
|
|
if (child === previous) child = null;
|
|
},
|
|
waitForUnhealthy: waitForHealthToStop, discardCache, start: startServer, waitForReady: waitForHealth,
|
|
...(previousArtifact ? { previousArtifact, replacementArtifact: () => child && servingArtifacts.get(child) || undefined } : {}),
|
|
cancelled: shutdownRequested,
|
|
afterStopped: observer?.stopped,
|
|
afterReady: observer ? (receipt, record) => observer.recovered(receipt, record, shutdownRequested) : undefined,
|
|
record: async receipt => {
|
|
const target = path.join(controlDirectory, `controller-outage-${requestId}.json`);
|
|
await writeFile(`${target}.tmp`, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
|
|
await rename(`${target}.tmp`, target);
|
|
},
|
|
});
|
|
await writeRestartAck(requestId, "ready", undefined, true, undefined, receipt);
|
|
appendLog(`Restart request ${requestId}: Paperclip is healthy after ${Math.round(receipt.readyAfterMs!)}ms\n`);
|
|
activeRestartRequestId = null;
|
|
return;
|
|
} finally { await observer?.close(); }
|
|
}
|
|
const crashBoundary = crashAfterResult ? await readFinalizationCrashBoundary(async route => {
|
|
const response = await fetch(`http://127.0.0.1:${port}${route}`, { signal: AbortSignal.timeout(5_000), cache: "no-store" });
|
|
if (!response.ok) throw new Error("Finalization crash boundary could not be read");
|
|
return response.json();
|
|
}, crashAfterResult) : undefined;
|
|
if (crashBoundary) await stopServer.crashForRestart(previous);
|
|
else if (preserveRunner) await stopServer.forRestart(previous, preserveRunner);
|
|
else await stopServer(previous);
|
|
if (child === previous) child = null;
|
|
// Do not mistake an orphaned old server for a healthy replacement. The port
|
|
// must stop answering before the next launcher is allowed to start.
|
|
await waitForHealthToStop();
|
|
const downtimeDeadline = Date.now() + controllerDowntimeMs;
|
|
while (Date.now() < downtimeDeadline) {
|
|
if (shutdownRequested()) throw new Error("Wrapper shutdown interrupted controller downtime");
|
|
await delay(Math.min(250, downtimeDeadline - Date.now()));
|
|
}
|
|
if (discardControllerCache) {
|
|
await discardCache();
|
|
}
|
|
if (shutdownRequested()) {
|
|
throw new Error("Wrapper shutdown interrupted the Paperclip restart");
|
|
}
|
|
|
|
if (stagedKimiAdmission) kimiCandidateAdmission = stagedKimiAdmission;
|
|
appendLog(`Restart request ${requestId}: starting Paperclip\n`);
|
|
const replacement = await startServer();
|
|
await waitForHealth(replacement);
|
|
if (shutdownRequested()) {
|
|
throw new Error("Wrapper shutdown interrupted the Paperclip restart");
|
|
}
|
|
await writeRestartAck(requestId, "ready", undefined, discardControllerCache, crashBoundary);
|
|
appendLog(`Restart request ${requestId}: Paperclip is healthy\n`);
|
|
activeRestartRequestId = null;
|
|
}
|
|
|
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
|
process.on(signal, () => {
|
|
if (shutdownSignal) return;
|
|
shutdownSignal = signal;
|
|
if (!child) return;
|
|
void stopWithFeedback(child, signal).catch(() => {});
|
|
});
|
|
}
|
|
|
|
async function supervise() {
|
|
const executionIds: string[] = JSON.parse(process.env.PAPERCLIP_RUNNER_E2E_EXECUTION_IDS ?? "[]");
|
|
if (executionIds.some(id => id.includes(".legacy-claude.local.") || /\.assistant-claude-(?:haiku|sonnet)\.local\./.test(id))) {
|
|
definedServerEnvironment.PATH = await qualifyLegacyClaudeCli(temporaryRoot, definedServerEnvironment);
|
|
}
|
|
const databaseReservation = await prepareRunnerE2EServerConfig({
|
|
temporaryRoot,
|
|
configPath,
|
|
serverPort: Number(port),
|
|
});
|
|
// Postgres needs the socket itself; release immediately before child spawn.
|
|
await databaseReservation?.close();
|
|
const legacyMigration = executionIds.some(id => { const execution = runnerMatrix.find(row => row.id === id); return execution && isLegacyWorkspaceMigration(execution); });
|
|
if (upgradeArtifacts || legacyMigration || executionIds.some(id => id.endsWith(".recover-controller"))) {
|
|
// The wrapper owns this sibling process. Killing the serving child must
|
|
// leave Postgres available to the stopped-controller observer and successor.
|
|
controllerDatabase = await prepareControllerDatabase(configPath, temporaryRoot);
|
|
}
|
|
if (legacyMigration) {
|
|
if (upgradeArtifacts || installedRelease) throw new Error("Migration source qualification cannot claim installed-artifact evidence");
|
|
await superviseLegacyWorkspaceMigration({ temporaryRoot, repositoryRoot, configPath, port: Number(port),
|
|
environment: definedServerEnvironment, executionIds, cancelled: shutdownRequested });
|
|
return;
|
|
}
|
|
if (executionIds.some(id => { const execution = runnerMatrix.find(row => row.id === id); return execution && isRollingWebDeployment(execution); })) {
|
|
if (upgradeArtifacts || installedRelease) throw new Error("Rolling source qualification cannot claim installed-artifact evidence");
|
|
await superviseRollingDeployment({ temporaryRoot, repositoryRoot, configPath, port: Number(port),
|
|
environment: definedServerEnvironment, executionIds, cancelled: shutdownRequested });
|
|
return;
|
|
}
|
|
const initial = await startServer();
|
|
if (upgradeArtifacts) await waitForHealth(initial);
|
|
let lastRestartRequestId: string | null = null;
|
|
while (!shutdownRequested()) {
|
|
if (unexpectedChildFailure) throw unexpectedChildFailure;
|
|
const request = await readRestartRequest(restartRequestPath, executionIds);
|
|
if (request && request.requestId !== lastRestartRequestId) {
|
|
lastRestartRequestId = request.requestId;
|
|
await restartServer(request);
|
|
}
|
|
await delay(200);
|
|
}
|
|
|
|
await recoverFeedbackBeforeShutdown();
|
|
await stopServer.stopAll(shutdownSignal ?? "SIGTERM");
|
|
}
|
|
|
|
let exitCode = 0;
|
|
let closeCursorCloudCallbackProxy: (() => Promise<void>) | undefined;
|
|
try {
|
|
if (executionIds.some(id => id.startsWith("cursor-cloud."))) closeCursorCloudCallbackProxy = await startCursorCloudCallbackProxy({ port: Number(required("PAPERCLIP_CURSOR_CLOUD_CALLBACK_PORT")), appPort: Number(port) });
|
|
await supervise();
|
|
} catch (error) {
|
|
exitCode = 1;
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
appendLog(`\nPaperclip E2E server supervisor failed: ${message}\n`);
|
|
if (activeRestartRequestId) {
|
|
try {
|
|
await writeRestartAck(activeRestartRequestId, "failed", message);
|
|
} catch (ackError) {
|
|
appendLog(
|
|
`Failed to write restart acknowledgement: ${ackError instanceof Error ? ackError.message : String(ackError)}\n`,
|
|
);
|
|
}
|
|
}
|
|
try {
|
|
await recoverFeedbackBeforeShutdown();
|
|
await stopServer.stopAll();
|
|
} catch (stopError) {
|
|
appendLog(`Failed to stop Paperclip after supervisor failure: ${stopError instanceof Error ? stopError.message : String(stopError)}\n`);
|
|
}
|
|
}
|
|
|
|
try { await controllerDatabase?.cleanup(); }
|
|
catch (error) {
|
|
exitCode = 1;
|
|
appendLog(`Qualification database cleanup failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
}
|
|
await closeCursorCloudCallbackProxy?.();
|
|
await new Promise<void>((resolve) => log.end(resolve));
|
|
process.exitCode = exitCode;
|