Files
PaperClipAI/tests/runner-e2e/server.ts
T
f4337bb5fb test(evals): land the chat catalog eval harness on master (#19614)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat-feature evals measure whether agents complete real chat
requests through the product
> - The chat catalog eval harness and its repairs live on the
`chat-eval/master-candidate` branch, not on master
> - Today's work made 139 of 242 chat evals pass in verified single-case
live runs. Each product fix landed on master as its own pull request
> - The eval-side changes (setups, graders, fault seams, evidence
capture) still exist only on the candidate
> - This pull request lands the harness on master. Master stays the
source of truth for product behavior
> - The benefit is one tree: evals run against master commits, and
product fixes reach the evals as soon as they merge

## Linked Issues or Issue Description

Refs #19441, Refs #19599, Refs #20270, Refs #20334, Refs #20342, Refs
#20344, Refs #20354, Refs #20360, Refs #20373, Refs #20382, Refs #20409

**What existing behavior does this improve?**
The chat catalog eval harness and the product it evaluates live on a
long-running branch outside master. Eval fixes do not reach master, so
master-based eval runs use old setups and graders.

**Subsystem affected**
`tests/runner-e2e` chat catalog harness, plus a small set of product
changes that exist only on the candidate.

**Proposed change**
Land the harness on master. Every product fix from this eval work
already merged to master as its own pull request. Conflicts keep
master's product behavior.

## What Changed

- Most of the diff (808 of 851 files) is under `tests/runner-e2e/`:
chat-catalog domains, scenarios, descriptors, the TestOps manifest,
private evidence tooling, and their tests.
- Graders now check saved state, setup literals and rendered or clicked
links. Many keyword and regex checks on free-form replies are removed.
Each changed grader has a bumped `oracleVersion` and calibration tests
(a valid reply, a reworded valid reply, and broken variants that must
fail).
- The shared link helper `hasLink` no longer treats a list link (for
example `/COM/issues?view=mine`) as a link to every task without a URL
key.
- Setup fixes build real state through supported product routes: routine
history, agent grants through the board grant route, budget holds with a
real cap, and a storage-layer archive fault for
`inbox-search.archive-partial-failure`.
- Browser boundary journeys (sign-in, chat-disabled, onboarding) now
write the standard pass evidence files.
- Retargeted cases, approved by the maintainer:
`agent-chat.chat-disabled` (Conference Room gate),
`agents.restore-instructions` (no saved instruction history),
`access.manage-membership` (member permission edits stay board-only).
- The private evidence trusted files (`manual-private-handoff.ts`,
`manual-private-retention.ts`, `manual-private-report-assets.ts`) are
byte-identical to master.
- Product files outside `tests/` (43 files, +1,328 −49) carry
candidate-only changes that master does not yet have. Most are tests.
The main code changes are in `request-human-input.ts`,
`heartbeat-run-summary.ts`, `native-completion-feedback.ts`,
`native-run-finalizer.ts`, `openapi.ts`, `secrets.ts` and
`IssueChatLongThreadPerf.tsx`.

## Verification

- `pnpm vitest run chat-catalog-domains chat-catalog-contract.test.ts
chat-catalog-consistency.test.ts unit-launcher.test.mjs
--testTimeout=180000` in `tests/runner-e2e`: 269 files pass, 3,550 tests
pass.
- `node --test chat-full-catalog-testops-manifest.test.mjs`: 10 pass.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`:
no change.
- `tsc` for `server` and `tests/runner-e2e`: clean.
- Live single-case verification: 139 of 242 cases passed on paid runs,
and a person read the saved evidence for each. These are single-case
runs, not one full-cohort run.

## Risks

- Large diff. Most of it is test-only, but the 43 non-test files change
product code paths. Review those files first.
- Some graders are now less strict on reply wording. A person must read
replies for meaning on those cases.
- 42 cases need disposable outside accounts (Notion, Slack, GitHub,
Daytona and others) and stay blocked. `access.invite-teammate` stays
failing until local agent isolation lands, because the runner's durable
state keeps a live invite token.
- The paid workflow compares the trusted private-evidence files with
master. They are unchanged, so this risk is low.

## Model Used

Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code
execution, in Claude Code. Subagents used the same model.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 08:02:11 -06:00

558 lines
27 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 { teamRuntimeCatalogServerEnvironment } from "./chat-catalog-domains/external-team-runtime-fixture.js";
import { stopSetupHealthIfRequested } from "./chat-setup-health.js";
import { publishInvitationSourceProof, invitationSourceProofPath } from "./chat-catalog-domains/identity-invite-source-proof.js";
import { readSelectedVaultServerEnvironment } from "./chat-vault-private-server.js";
import { readValidatedPublicOriginReceipt, publicOriginServerOverrides, PUBLIC_ORIGIN_RECEIPT_ENV, PUBLIC_ORIGIN_CONTROL_ENV } from "./chat-public-origin.js";
import { dockerDatabaseSupervisorEnvironment } from "./docker-postgres.js";
import { CHAT_INSTALL_PLAN, refreshInstalledChatPins, publishInstalledChatReceipt } from "./chat-installed-lane.js";
import { resolveRunnerE2ESource } from "./source.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, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { prepareSupervisorServerConfig } 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 dockerEnvironment = await dockerDatabaseSupervisorEnvironment({ temporaryRoot, env: process.env });
const sourceEnvironment = { ...dockerEnvironment.source };
const publicOriginReceipt = process.env[PUBLIC_ORIGIN_RECEIPT_ENV] ? await readValidatedPublicOriginReceipt({ receiptPath: required(PUBLIC_ORIGIN_RECEIPT_ENV), executionId: executionIds.length === 1 ? executionIds[0]! : "", attemptId: required("PAPERCLIP_E2E_ACCOUNTING_ATTEMPT_ID"), backendOrigin: `http://127.0.0.1:${port}` }) : undefined;
if (publicOriginReceipt && process.env.PAPERCLIP_RUNNER_E2E_PUBLIC_MCP !== "1") throw new Error("Owned public origin requires the exact authenticated selected case");
for (const key of [PUBLIC_ORIGIN_RECEIPT_ENV, PUBLIC_ORIGIN_CONTROL_ENV, "PAPERCLIP_PUBLIC_URL", "PAPERCLIP_AUTH_PUBLIC_BASE_URL", "PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", "PAPERCLIP_ALLOWED_HOSTNAMES"]) delete sourceEnvironment[key];
const vaultEnvironment = await readSelectedVaultServerEnvironment({ executionIds, temporaryRoot, backendOrigin: `http://127.0.0.1:${port}`, env: process.env });
const teamCatalogEnvironment = await teamRuntimeCatalogServerEnvironment({ executionIds, temporaryRoot, repositoryRoot });
const serverEnvironment = buildPaperclipServerEnvironment(installedRelease
? installedReleaseEnvironment(sourceEnvironment, repositoryRoot, path.join(temporaryRoot, "provider-bin"))
: sourceEnvironment, {
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",
...publicOriginServerOverrides(publicOriginReceipt),
// 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",
...vaultEnvironment,
...teamCatalogEnvironment,
...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, usesInstalledCli(process.env), {
temporaryRoot, executionIds, sourceRevision: resolveRunnerE2ESource().sha,
});
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)}`;
}
let invitationSourceGeneration: string | undefined;
async function startServer() {
if (shutdownRequested()) {
throw new Error("Refusing to start Paperclip after wrapper shutdown");
}
if (process.env[CHAT_INSTALL_PLAN]) refreshInstalledChatPins(process.env, selectedExecutions[0]!.id);
// 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;
}
invitationSourceGeneration = undefined;
await rm(invitationSourceProofPath(temporaryRoot), { force: true });
const sourceArgs = runnerE2ETypeScriptProcessArgs(repositoryRoot, paperclipCli, ["onboard", "--yes", "--run"]);
const sourceSpawnedAt = new Date().toISOString();
const candidate = spawn(
process.execPath,
installed ? [installed.entry, "onboard", "--yes", "--run"] : sourceArgs,
{
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);
if (process.env[CHAT_INSTALL_PLAN] && candidate.pid) publishInstalledChatReceipt(process.env, selectedExecutions[0]!.id, candidate.pid);
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);
const invitationAttemptId = process.env.PAPERCLIP_E2E_ACCOUNTING_ATTEMPT_ID;
if (!installed && candidate.pid && invitationAttemptId) {
invitationSourceGeneration = await publishInvitationSourceProof({ temporaryRoot, sourceRoot: repositoryRoot, apiOrigin: `http://127.0.0.1:${port}`,
attemptId: invitationAttemptId, executionIds, pid: candidate.pid, argv: sourceArgs, spawnedAt: sourceSpawnedAt });
}
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(),
...(invitationSourceGeneration ? { invitationSourceGeneration } : {}),
...(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 prepareSupervisorServerConfig({
temporaryRoot,
configPath,
serverPort: Number(port),
database: dockerEnvironment,
});
// 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;
if (await stopSetupHealthIfRequested({ executionIds, temporaryRoot, stop: () => stopServer.stopAll("SIGTERM") })) { shutdownSignal ??= "SIGTERM"; continue; }
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;