Files
PaperClipAI/tests/runner-e2e/controller-replacement-smoke.ts
T
DottaandPaperclip 16787e4167 Qualify rolling web deployments and preserve Stop through handoff (#18526)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The web server owns request handling and remote execution
coordination.
> - Deployment must start a ready replacement while the old server still
serves.
> - An outage test does not prove that two live servers can overlap
safely.
> - This pull request adds a separate Product E2E cell for that
boundary.
> - The existing run, file, recovery, and retained-session checks still
apply.

## Linked Issues or Issue Description

Refs #18518. The runtime prerequisite is merged; this PR qualifies its
ephemeral server and deployment contracts.

## What Changed

- Add the explicit `rolling-web-deployment` Codex Daytona workflow.
- Start independent A/B/C homes and compiled builds behind a test
ingress.
- Wait for readiness before routing, then drain and delete the old home.
- Observe active authority before handoff and use the existing read-only
recovery oracle.
- Require ordinary reads and saves to succeed throughout replacement.
Read back every accepted save exactly once.
- Preserve the five-minute outage suites and their existing validators.
- Add a model-free supervisor smoke and an unready replacement control.
- Require graceful exit before deleting an old home; forced cleanup
cannot satisfy the oracle.
- Preserve a durable Stop through lease release/expiry until the
recovered execution owner acknowledges it. Permit only fenced
reattachment of the original execution for Stop cleanup; submit no new
turn.
- Exclude events saved by A during shutdown from evidence of B making
progress.

## Verification

- E2E TypeScript check passed.
- Affected harness checks: 143 passed. Catalog, fixture and report
checks: 120 passed.
- Actual supervisor smoke passed two replacements: 436 reads, 45 saves,
zero request failures, and confirmed owned resource cleanup.
- B became ready in 18.7 seconds; C became ready in 109.2 seconds. A
prior C timeout remains failed evidence.
- Fresh browser proof: 11 checks passed, 246 reads, 50 acknowledged
saves, zero failures, 1,058ms reconnect, both old homes deleted and
cleanup confirmed.
- Stop lease-gap regression: three PostgreSQL delivery tests passed with
a simulated provider edge. Current server TypeScript build/vendoring
passed.
- Full harness: 5,814 passed, 17 failed, four skipped. Two shutdown
races passed the focused file; two report timeouts passed an isolated
42-test rerun. Onboarding/workflow-security/Pi/date-expired fixtures are
unchanged on inspected master; the host embedded PostgreSQL fixture
remains a setup limitation. This is not a green full local suite.
- CI at `172509b8be` passed all gates. Greptile is 5/5 with every review
thread resolved. The earlier access-service race failure passed its
isolated regression and the subsequent full CI.
- Final review corrections: six focused executor cases, three PostgreSQL
command-delivery cases, 19 harness cases and server/E2E typechecks
passed.
- Three live attempts failed before agent creation: two harness setup
gaps were fixed, then provider authentication failed. The saved OpenAI
key returns 401; a refresh is pending. The Daytona probe reported
cleanup uncertain, and no sandbox with that attempt label was found
through the authenticated staging manager. No live model execution is
claimed.
- Live Runner overlap, released-container qualification and hosted
deployment proof remain pending. Merging this opt-in qualification lane
does not claim those results.

## Risks

- The source lane shares workspace dependencies and does not establish
container packaging correctness.
- The live workflow needs an explicitly owned S3 bucket, PostgreSQL,
pinned remote image and provider credentials.
- Qualification cleanup stops all owned processes after failure. A
failed replacement must not be reported as a pass.

## Model Used

OpenAI Codex, GPT-6. Tool use, code execution and repository review. 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
- [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>
2026-11-01 02:53:27 -06:00

203 lines
14 KiB
TypeScript

import { validControllerRollingDeploymentReceipt } from "./controller-rolling-deployment.js";
import { createRequire } from "node:module";
import { readUpgradeArtifacts, upgradeArtifactKey } from "./upgrade-artifacts.js";
/** Credential-free main-server replacement. This proves the wrapper/startup/DB
* boundary; only the hosted workflow can qualify an active Daytona agent.
* Run: node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/controller-replacement-smoke.ts
*/
import assert from "node:assert/strict";
import { execFileSync, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { createDb, startEmbeddedPostgresTestDatabase } from "../../packages/db/src/index.js";
import { paperclipConfigSchema } from "../../packages/shared/src/config-schema.js";
import { configuredControllerOutage, validControllerOutageReceipt, type ControllerOutage } from "./controller-outage.js";
import { runnerE2EServerControlPaths } from "./harness-env.js";
import { reserveRunnerE2EServerPort } from "./ports.js";
import { createRunnerE2ETemporaryRoot, prepareRunnerE2EServerConfig } from "./server-config.js";
import { createRunnerE2EServerStopper } from "./server-stop.js";
import { cleanupControllerSmokeDatabase } from "./controller-smoke-cleanup.js";
import { runnerE2ETypeScriptProcessArgs } from "./web-server-command.js";
const rolling = Boolean(process.env.PAPERCLIP_ROLLING_TEST_STORAGE_CONFIG);
const { S3Client, CreateBucketCommand, ListObjectsV2Command, DeleteObjectsCommand, DeleteBucketCommand } = createRequire(new URL("../../server/package.json", import.meta.url))("@aws-sdk/client-s3");
let storageClient: InstanceType<typeof S3Client> | undefined;
const storageBucket = `rolling-supervisor-${randomUUID()}`;
let storageCreated = false;
let storageEnvironment: NodeJS.ProcessEnv = {};
let outage: ControllerOutage;
let upgrades: Awaited<ReturnType<typeof readUpgradeArtifacts>> = null;
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
const temporaryRoot = await createRunnerE2ETemporaryRoot();
const home = path.join(temporaryRoot, "home");
const configPath = path.join(home, "instances", "default", "config.json");
const control = runnerE2EServerControlPaths(temporaryRoot);
const reportPath = path.join(temporaryRoot, "replacement-evidence.json");
console.info(JSON.stringify({ started: true, evidence: reportPath }));
const report: Record<string, unknown> = { schema: "paperclip.e2e.controller-replacement-smoke/v1",
providerTurns: 0, activeAgentContinuityQualified: false, cleanupConfirmed: false,
databaseCreated: false, wrapperStarted: false };
// Register resources only after creation so setup failures still have evidence
// and cleanup never assumes a database or client exists.
let database: { connectionString: string; cleanup(): Promise<void> } | undefined;
let db: ReturnType<typeof createDb> | undefined;
const log = createWriteStream(path.join(temporaryRoot, "wrapper.log"), { mode: 0o600 });
const stopper = createRunnerE2EServerStopper({ gracefulTimeoutMs: rolling ? 100_000 : 35_000, forcedTimeoutMs: 5_000,
hasSpawnError: () => false, markExpectedStop: () => {}, log: message => log.write(message) });
let wrapper: ReturnType<typeof spawn> | undefined;
let failure: unknown;
let ownedPostgresProcessId: number | null | undefined = process.env.PAPERCLIP_TEST_DATABASE_URL ? null : undefined;
report.databaseCleanupScope = ownedPostgresProcessId === null ? "owned_database" : "owned_cluster_process";
try {
report.sourceRevision = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repositoryRoot, encoding: "utf8" }).trim();
report.sourceDirty = execFileSync("git", ["status", "--porcelain"], { cwd: repositoryRoot, encoding: "utf8" }).trim().length > 0;
outage = configuredControllerOutage(process.env);
report.outage = outage;
upgrades = await readUpgradeArtifacts(process.env[upgradeArtifactKey]);
const port = await reserveRunnerE2EServerPort();
// The database belongs to this driver, never to either server process tree.
const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
if (!external) {
if (upgrades) throw new Error("Artifact migration proof requires PAPERCLIP_TEST_DATABASE_URL; the embedded test helper pre-migrates its database");
database = await startEmbeddedPostgresTestDatabase("controller-replacement-smoke-");
} else {
// Create an empty database. Artifact A applies its own migrations before B.
// Record ownership before closing the admin client, which can itself fail.
const name = `upgrade_${randomUUID().replaceAll("-", "")}`;
const url = new URL(external); url.pathname = `/${name}`;
const admin = createDb(external);
try {
await admin.$client.unsafe(`create database "${name}"`);
database = { connectionString: url.toString(), cleanup: async () => {
const cleanup = createDb(external);
try { await cleanup.$client.unsafe(`drop database "${name}"`); }
finally { await cleanup.$client.end(); }
} };
report.databaseCreated = true;
} finally { await admin.$client.end(); }
}
report.databaseCreated = true;
db = createDb(database.connectionString);
if (ownedPostgresProcessId !== null) {
const [owner] = await db.$client`select split_part(pg_read_file('postmaster.pid', 0, 64), chr(10), 1)::int as pid`;
const pid = Number(owner?.pid);
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("owned_postgres_identity_unavailable");
ownedPostgresProcessId = pid;
}
const reservation = await prepareRunnerE2EServerConfig({ temporaryRoot, configPath, serverPort: port });
await reservation?.close();
const config = paperclipConfigSchema.parse(JSON.parse(await readFile(configPath, "utf8")));
config.database = { ...config.database, mode: "postgres", connectionString: database.connectionString };
await writeFile(configPath, JSON.stringify(config), { mode: 0o600 });
if (rolling) {
if (upgrades) throw new Error("Rolling supervisor smoke does not qualify upgrade artifacts");
const storage = JSON.parse(await readFile(process.env.PAPERCLIP_ROLLING_TEST_STORAGE_CONFIG!, "utf8"));
const endpoint = new URL(storage.endpoint);
if (endpoint.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(endpoint.hostname)
|| endpoint.username || endpoint.password) throw new Error("Supervisor smoke requires disposable loopback S3");
storageClient = new S3Client({ region: "us-east-1", endpoint: storage.endpoint, forcePathStyle: true,
credentials: { accessKeyId: storage.accessKeyId, secretAccessKey: storage.secretAccessKey } });
await storageClient.send(new CreateBucketCommand({ Bucket: storageBucket })); storageCreated = true;
storageEnvironment = { PAPERCLIP_RUNNER_E2E_WORKSPACE_STORAGE_BUCKET: storageBucket,
PAPERCLIP_RUNNER_E2E_WORKSPACE_STORAGE_ENDPOINT: storage.endpoint, PAPERCLIP_RUNNER_E2E_WORKSPACE_STORAGE_FORCE_PATH_STYLE: "true",
AWS_ACCESS_KEY_ID: storage.accessKeyId, AWS_SECRET_ACCESS_KEY: storage.secretAccessKey, AWS_EC2_METADATA_DISABLED: "true" };
}
const source = { ...storageEnvironment, PATH: process.env.PATH, HOME: home, TMPDIR: process.env.TMPDIR,
...(upgrades ? { ...upgrades.artifacts.A.pins, [upgradeArtifactKey]: process.env[upgradeArtifactKey] } : {}),
NODE_ENV: "test", PAPERCLIP_HOME: home, PAPERCLIP_CONFIG: configPath, PAPERCLIP_INSTANCE_ID: "default",
PAPERCLIP_AGENT_JWT_SECRET: randomUUID(), PAPERCLIP_DECISION_SIGNING_SECRET: randomUUID(),
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: randomUUID(), BETTER_AUTH_SECRET: randomUUID(),
PAPERCLIP_RUNNER_E2E_SERVER_LOG: path.join(temporaryRoot, "server.log"),
PAPERCLIP_RUNNER_E2E_TEMP_ROOT: temporaryRoot, PAPERCLIP_RUNNER_E2E_PORT: String(port),
PAPERCLIP_RUNNER_E2E_EXECUTION_IDS: JSON.stringify([`${rolling ? "rolling-web-deployment" : "everyday-workflows"}.runner-codex.daytona.recover-controller`]) };
wrapper = spawn(process.execPath, runnerE2ETypeScriptProcessArgs(repositoryRoot, path.join(import.meta.dirname, "server.ts")),
{ cwd: repositoryRoot, env: source, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
wrapper.once("spawn", () => { report.wrapperStarted = true; });
wrapper.stdout!.pipe(log, { end: false }); wrapper.stderr!.pipe(log, { end: false });
const stopped = new Promise<void>((resolve, reject) => { wrapper!.once("error", reject); wrapper!.once("close", () => resolve()); });
void stopped.catch(() => {});
await stopper.watch(wrapper);
async function poll<T>(load: () => Promise<T>, accept: (value: T) => boolean, timeoutMs: number): Promise<T> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (wrapper!.exitCode !== null || wrapper!.signalCode !== null) throw new Error("Server supervisor exited; inspect private logs");
const value = await load();
if (accept(value)) return value;
await delay(200);
}
throw new Error("Main-server replacement smoke timed out; inspect private logs");
}
const base = `http://127.0.0.1:${port}`;
async function api(route: string, body?: object) {
const response = await fetch(`${base}/api${route}`, { method: body ? "POST" : "GET",
headers: { "content-type": "application/json", origin: base }, body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(5_000) });
if (!response.ok) throw new Error(`Smoke API ${route} returned ${response.status}`);
return response.json() as Promise<Record<string, unknown>>;
}
await poll(() => api("/health").catch(() => null), health => health?.status === "ok", 180_000);
const company = await api("/companies", { name: `Restart smoke ${randomUUID()}` });
const [before] = await db.$client<{ started: string }[]>`select pg_postmaster_start_time()::text as started`;
const receipts: unknown[] = [];
for (let index = 1; index < (upgrades?.sequence.length ?? (rolling ? 3 : 2)); index += 1) {
// Empty fixture cache exercises retirement without pretending a runner exists.
const cache = path.join(home, "instances", "default", "runtime", "paperclip-runner");
await mkdir(cache, { recursive: true });
const requestId = `smoke-${index}-${randomUUID()}`;
await writeFile(`${control.restartRequestPath}.tmp`, JSON.stringify({ requestId, discardControllerCache: true, ...(rolling ? { rollingDeployment: true } : { controllerOutage: outage }) }), { mode: 0o600 });
await rename(`${control.restartRequestPath}.tmp`, control.restartRequestPath);
const ack = await poll(async () => {
const value = await readFile(control.restartAcknowledgementPath, "utf8").then(text => JSON.parse(text) as Record<string, unknown>)
.catch(error => { if (error.code === "ENOENT") return null; throw error; });
if (value?.requestId === requestId && value.status === "failed") throw new Error(String(value.message));
return value;
}, value => value?.requestId === requestId && value.status === "ready", outage.holdMs + 180_000);
assert(rolling ? validControllerRollingDeploymentReceipt(ack!.rollingDeployment, requestId) : validControllerOutageReceipt(ack!.controllerOutage, outage, requestId, upgrades ? {
previous: upgrades.artifacts[upgrades.sequence[index - 1]!],
replacement: upgrades.artifacts[upgrades.sequence[index]!],
} : undefined), "Invalid replacement fault or requested artifact receipt");
receipts.push(rolling ? ack!.rollingDeployment : ack!.controllerOutage);
report.replacements = receipts;
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
assert.equal((await api(`/companies/${company.id}`)).id, company.id);
const after: { started: string } = (await db.$client<{ started: string }[]>`select pg_postmaster_start_time()::text as started`)[0]!;
assert.equal(after.started, before.started, "Postgres must remain alive across the web-server replacement");
await api("/companies", { name: `New admission after restart ${randomUUID()}` });
}
Object.assign(report, { postgresStayedAlive: true, existingDataReadable: true, newMutationAccepted: true, passed: true });
await stopper.stopAll();
await stopped;
} catch (error) {
failure = error;
Object.assign(report, { passed: false, error: error instanceof Error ? error.message : String(error) });
} finally {
const cleanupErrors: string[] = [];
const cleanups: Array<() => Promise<unknown>> = [];
if (wrapper) cleanups.push(() => stopper.stopAll());
if (db) { const client = db.$client; cleanups.push(() => client.end()); }
if (database) { const owned = database; cleanups.push(() => cleanupControllerSmokeDatabase(owned, ownedPostgresProcessId)); }
if (storageClient && storageCreated) cleanups.push(async () => {
let continuationToken: string | undefined;
do {
const listed = await storageClient!.send(new ListObjectsV2Command({ Bucket: storageBucket, ContinuationToken: continuationToken }));
if (listed.Contents?.length) await storageClient!.send(new DeleteObjectsCommand({ Bucket: storageBucket,
Delete: { Objects: listed.Contents.map((object: { Key: string }) => ({ Key: object.Key })) } }));
continuationToken = listed.NextContinuationToken;
} while (continuationToken);
await storageClient!.send(new DeleteBucketCommand({ Bucket: storageBucket })); storageClient!.destroy();
});
for (const cleanup of cleanups) {
try { await cleanup(); }
catch (error) { failure ??= error; cleanupErrors.push(error instanceof Error ? error.message : String(error)); }
}
report.cleanupConfirmed = cleanupErrors.length === 0;
if (cleanupErrors.length) { report.cleanupErrors = cleanupErrors; report.passed = false; }
await new Promise<void>(resolve => log.end(resolve));
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
console.info(JSON.stringify({ passed: report.passed, evidence: reportPath, cleanupConfirmed: report.cleanupConfirmed }));
}
if (failure) throw failure;