Files
PaperClipAI/tests/runner-e2e/rolling-deployment-supervisor.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

136 lines
9.1 KiB
TypeScript

import { randomBytes, randomUUID } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { paperclipConfigSchema } from "../../packages/shared/src/config-schema.js";
import { createControllerRecoveryObserver } from "./controller-recovery-observer.js";
import { rollController, validControllerRollingDeploymentReceipt } from "./controller-rolling-deployment.js";
import { readRestartRequest } from "./server-restart-request.js";
import { startRollingDeploymentProcess } from "./rolling-deployment-process.js";
import { startRollingDeploymentProxy } from "./rolling-deployment-proxy.js";
import { runnerE2EServerControlPaths } from "./harness-env.js";
type Server = Awaited<ReturnType<typeof startRollingDeploymentProcess>>;
/** Isolated Product E2E supervisor. Shares only Postgres, S3 and generated
* secrets. It never patches leases, checkpoints, execution or recovery state. */
export async function superviseRollingDeployment(input: {
temporaryRoot: string; repositoryRoot: string; configPath: string; port: number;
environment: NodeJS.ProcessEnv; executionIds: string[]; cancelled: () => boolean;
}) {
const config = paperclipConfigSchema.parse(JSON.parse(await readFile(input.configPath, "utf8")));
if (config.database.mode !== "postgres" || !config.database.connectionString) throw new Error("Rolling qualification requires independently owned Postgres");
if (input.environment.PAPERCLIP_STORAGE_PROVIDER !== "s3") throw new Error("Rolling qualification requires disposable S3 storage");
const databaseUrl = config.database.connectionString;
const controls = runnerE2EServerControlPaths(input.temporaryRoot);
const proxy = await startRollingDeploymentProxy(input.port);
const servers: Server[] = [];
const environment = { ...input.environment,
PAPERCLIP_EPHEMERAL_SERVER: "true", PAPERCLIP_DURABLE_AGENT_FILES: "true", PAPERCLIP_SHARED_UI_ASSETS: "true",
PAPERCLIP_DATABASE_LISTEN_URL: databaseUrl, PAPERCLIP_SECRETS_MASTER_KEY: randomBytes(32).toString("base64"),
HEARTBEAT_SCHEDULER_ENABLED: "true", RUN_LOG_S3_BUCKET: input.environment.PAPERCLIP_STORAGE_S3_BUCKET,
RUN_LOG_S3_ENDPOINT: input.environment.PAPERCLIP_STORAGE_S3_ENDPOINT,
RUN_LOG_S3_REGION: input.environment.PAPERCLIP_STORAGE_S3_REGION,
RUN_LOG_S3_FORCE_PATH_STYLE: input.environment.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE,
};
const start = async () => {
const server = await startRollingDeploymentProcess({ root: input.temporaryRoot, name: `controller-${servers.length + 1}`,
repositoryRoot: input.repositoryRoot, databaseUrl, ingressOrigin: proxy.origin, sharedEnvironment: environment, browser: {} });
servers.push(server);
return server;
};
const json = async (filename: string, value: unknown) => {
await writeFile(`${filename}.tmp`, JSON.stringify(value), { mode: 0o600 }); await rename(`${filename}.tmp`, filename);
};
const api = async <T>(route: string, method = "GET", body?: object): Promise<T> => {
const response = await fetch(`${proxy.origin}/api${route}`, { method,
headers: { "content-type": "application/json", origin: proxy.origin },
body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error(`${method} ${route} returned ${response.status}`);
return await response.json() as T;
};
let current: Server | undefined, lastRequest: string | undefined, probeIssueId: string | undefined;
// The read-only observer uses the production checkpoint loader in this
// isolated supervisor process. Give it the same disposable key as A/B/C.
const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
process.env.PAPERCLIP_SECRETS_MASTER_KEY = environment.PAPERCLIP_SECRETS_MASTER_KEY;
try {
// Keep the fixture's isolation evidence for its one generated stable secret.
// It is outside A/B/C homes, like the independent database and object store;
// each server receives this exact value through its environment.
const keyPath = path.resolve(config.secrets.localEncrypted.keyFilePath);
if (!keyPath.startsWith(`${path.resolve(input.temporaryRoot)}${path.sep}`)) throw new Error("Rolling fixture key escaped its owned root");
await mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
await writeFile(keyPath, environment.PAPERCLIP_SECRETS_MASTER_KEY, { mode: 0o600, flag: "wx" });
current = await start(); await current.waitForReady(); proxy.routeTo(current.origin);
while (!input.cancelled()) {
if (current.child.exitCode !== null || current.child.signalCode !== null) throw new Error("Serving rolling controller exited unexpectedly");
const request = await readRestartRequest(controls.restartRequestPath, input.executionIds);
if (!request || request.requestId === lastRequest) { await delay(100); continue; }
lastRequest = request.requestId;
if (!request.rollingDeployment) throw new Error("Rolling supervisor requires an explicit rolling deployment request");
const previous = current;
const observer = request.recoveryBinding ? await createControllerRecoveryObserver(previous.configPath, request.recoveryBinding) : undefined;
let probing = false, probes: Promise<void> | undefined;
const stats = { reads: 0, writes: 0, verifiedWrites: 0, failures: [] as string[] };
const writes: Array<{ id: string; body: string }> = [];
try {
if (!probeIssueId) {
const companyId = request.recoveryBinding?.companyId ?? (await api<{ id: string }>("/companies", "POST", { name: "Synthetic deployment probes" })).id;
const issue = await api<{ id: string }>(`/companies/${companyId}/issues`, "POST", {
title: "Synthetic deployment read/save probe", description: "Unassigned fixture for ordinary user saves during deployment.", status: "todo",
});
probeIssueId = issue.id;
}
const issueRoute = `/issues/${probeIssueId}`;
probing = true;
probes = (async () => {
while (probing) {
try { await api(issueRoute); stats.reads++; } catch (error) { stats.failures.push(String(error)); }
if (stats.reads % 10 === 0 || stats.writes === 0) {
const body = `Synthetic deployment save ${randomUUID()}`;
try {
const saved = await api<{ id: string }>(`${issueRoute}/comments`, "POST", { body, clientRequestId: randomUUID() });
writes.push({ id: saved.id, body }); stats.writes++;
} catch (error) { stats.failures.push(String(error)); }
}
await delay(100);
}
})();
const receiptPath = path.join(controls.controlDirectory, `controller-rolling-${request.requestId}.json`);
const receipt = await rollController({ requestId: request.requestId, previousPid: previous.child.pid!,
start: async () => { const server = await start(); return { pid: server.child.pid, server }; },
waitForReady: async replacement => { await replacement.server.waitForReady(); },
switchTraffic: replacement => { current = replacement.server; proxy.routeTo(current.origin); },
stopPrevious: () => previous.stop({ requireGraceful: true }),
deletePreviousHome: () => rm(previous.home, { recursive: true }),
beforeHandoff: observer?.beforeHandoff,
afterPreviousStopped: observer?.stoppedAfterHandoff,
afterHandoff: observer ? (receipt, record) => observer.recovered(receipt, record, input.cancelled) : undefined,
cancelled: input.cancelled, record: value => json(receiptPath, value),
});
probing = false; await probes;
const saved = await api<Array<{ id: string; body: string }>>(`${issueRoute}/comments`);
stats.verifiedWrites = writes.filter(write => saved.filter(comment => comment.id === write.id && comment.body === write.body).length === 1).length;
receipt.probes = stats; await json(receiptPath, receipt);
if (!validControllerRollingDeploymentReceipt(receipt, request.requestId)) throw new Error("Rolling boundary failed overlap, empty-home, or ordinary read/save qualification");
await json(controls.restartAcknowledgementPath, { requestId: request.requestId, status: "ready", controllerCacheDiscarded: true,
completedAt: new Date().toISOString(), rollingDeployment: receipt });
} catch (error) {
await json(controls.restartAcknowledgementPath, { requestId: request.requestId, status: "failed", message: String(error) });
throw error;
} finally { probing = false; await probes; await observer?.close(); }
}
} finally {
// Every started server remains owned, including an unready successor.
// End-of-attempt cleanup also runs after a retained failing receipt.
try {
try { await Promise.all(servers.map(server => server.stop())); }
finally { await proxy.close(); }
} finally {
if (previousMasterKey === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
else process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
}
}
}