Files
PaperClipAI/tests/runner-e2e/server.ts
T
DottaandPaperclip 6987c2f20b test(upgrades): qualify controller replacement with immutable artifacts and phase evidence (#17455)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/105/changes/paperclip/105
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip needs evidence that an admitted execution survives a
main-server replacement.
> - Runner mechanics and server adoption belong to #17097 and #17439.
> - A healthy HTTP endpoint does not prove recovery of the original
execution.
> - Qualification must identify the serving artifact, natural ownership,
authenticated attachment and resumed processing separately.
> - It must also retain failed runs and measure normal-operation costs.
> - This PR extends the existing qualification machinery and acceptance
record.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Operators can assess upgrade continuity from phase evidence tied to
exact artifacts and profiles.

**Current behavior**

A same-artifact restart or synthetic executor pass cannot establish
different-artifact upgrade and rollback support. Idle SQL gates also do
not measure active-run capacity.

**Proposed behavior**

Use immutable A/B artifacts and strict phase receipts through the
existing supervisor. Preserve failed evidence and add measured
before/after workload costs.

Builds on merged #17097 and #17439. The base is now `master`. #17456
follows for review convenience. This PR lands qualification machinery
and the evidence record. The remaining hosted gates stay open for
deployment qualification; merging this machinery does not claim those
experiments passed.

## What Changed

- Extend the main-server supervisor and installed-artifact verification
for actual serving revisions, forward migrations and compatible
rollback.
- Distinguish HTTP readiness, natural ownership, authenticated
attachment, resumed processing and backlog settlement.
- Retain the original reconnect grace and execution identity checks.
Never substitute a new task or edited lease for continuity.
- Reuse real-harness and Daytona qualification paths, with exact profile
and artifact provenance.
- Bind current Copilot attestation inputs in the Daytona image
fingerprint.
- Keep one acceptance record, including both failed file-only stress
phases and the final passing bounded-pool comparison.

## Verification

After retargeting to master, all 158 focused qualification cases across
13 files pass, with E2E typecheck. The first 12-file batch has four
report-lock test failures and 133 passes. Master #17811 had committed
the exact already-approved output lock, while that fixture assumed HEAD
must be the uncorrected input. The fixture now checks both pinned forms,
executes the committed-lock path, and reconstructs the pinned input to
retain all rejection tests. The production helper, policy hashes, patch
and dependency resolution are unchanged. The corrected batch explicitly
includes both process-preservation suites; the prior 146-case receipt
has its own source and selection.

Current candidate `8f1d6ec02f` integrates master `09ad9adce2`, including
the merged server slice. Its diff contains only qualification code and
documentation. The [qualification
record](https://github.com/paperclipai/paperclip/blob/8f1d6ec02f5bf6c90e7b27da8156953cabd9a180/doc/plans/2026-10-22-hot-upgrade-qualification.md)
is the acceptance ledger and retains source-attributed failures and
hosted limits.

On the eighth integration, all 146 cases across 12 focused qualification
files pass, including report-lock preparation, workflow security and
workflow targeting. E2E typecheck passes. The earlier 118-case result
remains attributed to its prior source. The first sandbox invocation has
117 passes and one failure because process inspection was denied during
inert fixture cleanup. The permitted repeat passes all 118 and a
targeted process check finds no remaining fixture daemon. The earlier
96-case receipt remains source-attributed in the ledger. The latest two
review regressions first fail: an intermediate replacement can cite
another runner/session despite a valid final step, and malformed receipt
JSON aborts evidence packaging. Each replacement now binds
run/company/issue/runner/session, and corrupt receipts retain explicit
invalid metadata plus sanitized diagnostics while other logs and
receipts still package. These checks prove qualification machinery with
injected supervisor operations, configuration subprocesses and real
filesystem packaging. They do not qualify a hosted harness.

Three request-reader cases, 14 artifact/configuration/cache/history
cases, and three lost-partial-receipt cases fail before their respective
corrections. The final collector preserves sanitized startup, readiness
and recovery phase receipts through packaging and temporary-root
deletion. Earlier embedded-Postgres initialization, report-catalog
drift, CI scheduling/cleanup failures and fixture collisions remain
recorded with their corrections or limits. The previous Greptile service
error is retained as historical review evidence, not a current review
pass.

The record includes prior source `9bb9d3417e`'s actual Linux
contention/fairness successes and the ordinary-warm regression proof
from the server slice. The historical 300-session component campaign
passes four after phases and retains earlier burst failures. Those
component costs do not establish hosted capacity. The hosted API-only
pair has zero agents/viewers, fails both absolute latency gates and the
candidate memory gate, and does not complete the active-agent/viewer
matrix.

The strict ten-agent same-artifact restart remains attributed to its
original artifact. It does not qualify this updated stack. Actual
A→B/B→A, the normal drain-free Cloud rollout, and other hosted harnesses
remain open. No new provider turn, deployment or hosted qualification
was run during merge preparation.

Prior source `57d3767eac` jobs 122590643944, 122590643983 and
122590644161 ended after runner shutdown/cancellation without final
Vitest results. The shutdown cause is unproven. No retry was issued
before this source was superseded by the required server recovery fix.
These attempts remain failures/incomplete evidence. Fresh exact-head CI,
legacy status, completed Greptile 5/5, resolved feedback and normal
merge eligibility remain required.

## Risks

Different-artifact A→B/B→A, the normal drain-free Cloud rollout path,
other hosted harnesses and the complete concurrency/viewer matrix remain
open. Frozen branch builds are development-build evidence. This PR owns
qualification and evidence; production defects must be fixed in their
earlier owning slice.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools and code
execution. The runtime does not expose a more specific model ID or
context-window size.

## 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
- [ ] 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>
2026-10-27 21:58:09 -05:00

492 lines
21 KiB
TypeScript

import { readRestartRequest, type RestartRequest } from "./server-restart-request.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 { 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;
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 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",
});
assertIsolatedServerEnvironment(serverEnvironment, {
temporaryRoot,
paperclipHome,
configPath,
});
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 (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, 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");
}
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();
if (upgradeArtifacts || 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);
}
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;
try {
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 new Promise<void>((resolve) => log.end(resolve));
process.exitCode = exitCode;