mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The restart-chaos harness (`tests/restart-chaos/`) reproduces staging failures before users meet them > - Staging fails with `runner_remote_process_identity_unavailable` when the remote Runner or its provider is gone after a deploy, a stall or a re-wake, and nobody has seen the sandbox side > - The harness had no cell for a provider-only death or a plugin RPC stall, and its sandbox capture ran only on K15 waves > - This pull request adds the staging class R cells CR1–CR3, and captures the sandbox side on every Daytona wave > - The benefit is that a wave that reproduces the refusal also keeps the evidence of why the Runner or provider died ## Linked Issues or Issue Description **What is the problem?** The harness cannot answer why a remote runnerd or its provider dies. Its capture ran only around K15 restarts. It recorded no process start times, sockets or provider log files. It had no fault that kills only the provider or stalls a plugin RPC. **What should change?** Add cells for each staging shape, and capture the sandbox before, during and after each one, on every Daytona wave. **Context** Refs #20907 (keeps runnerd's stderr in the sandbox, and reports it on failed recovery). Refs #20893 (W2 re-wake cells). ## What Changed - `class-r.ts` (new) adds three faults, selected with `--faults`: - **CR1:** a K15 redeploy with a held outage. `--cr1-hold-seconds`, 0–600, default 360. - **CR2:** SIGKILL of only the provider process under the run's `paperclip-runnerd`, inside its sandbox. Runnerd stays alive. By default the controller stays up; `--cr2-hold-seconds` adds a K15 outage. - **CR3:** SIGSTOP of the server's Daytona plugin worker. `--cr3-stall-seconds`, 41–600, default 45. Each CR task must pass the `class_r_fault_applied` check. - `daytona-runner-logs.ts` adds `CLASS_R_PROBE`. It reads process start times and `wchan`, sockets, the runtime file listing, and the tails of the Runner's and the provider's state and log files. It also adds `executeInWaveSandboxes`, the fixed-script runner that CR2 uses. - `main.ts` and `supervisor.ts`: - every Daytona wave captures with the class R probe when a task rests and at wave end; - CR cells also capture before the fault, after the stop, before the restart, and every 15 s for 180 s after ready; - `--sandbox-watch-seconds` captures on a clock for the whole wave. - The README has a new "Staging class R cells" section. ## Verification - `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts`: 49 files, 600 tests pass, including the new `class-r.test.ts`. It covers option parsing, planning, the kill command's quoting and its refusal of anything but a run id, plugin worker selection, the watch schedule and the applied check. - `node scripts/typecheck-test-projects.mjs tests/restart-chaos` passes. - Daytona waves run from this branch plus #20907. The results are in the follow-up comment. ## Risks - This change is limited to the harness. CR2 is its only in-sandbox mutation: a SIGKILL of the provider, run by a fixed script only in sandboxes labelled with the wave's company and environment. The script never signals runnerd. - More captures add Daytona toolbox calls on every Daytona wave. Each capture has a 45 s deadline, and a failed capture is recorded and never fails the wave. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, test runs, Daytona waves). ## 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 <noreply@anthropic.com>
170 lines
9.7 KiB
TypeScript
170 lines
9.7 KiB
TypeScript
/**
|
||
* K15 (redeploy): what a hosted deploy does to the control plane, beyond K2's graceful stop.
|
||
*
|
||
* On the hosted staging stack every deploy replaces the server's container. The replacement runs
|
||
* in another process space (host name, kernel boot and PID namespace), so the server's PID-based
|
||
* "the previous owner has exited" proofs (`server/src/services/controller-process-identity.ts`,
|
||
* `owner-proof.ts`) never apply. It also carries a new Railway deployment id on the same exclusive
|
||
* volume, which is the proof hosted recovery uses instead. A K1/K2 restart on one machine keeps the
|
||
* process space, so every PID proof succeeds there and this path is never exercised.
|
||
*
|
||
* K15 emulates both changes without a container: each server of a K15 wave sees an emulated host
|
||
* name (a `--import` preload replaces `os.hostname`, which changes the process-space hash) and
|
||
* Railway-shaped deployment variables naming one stable project, environment, service and volume
|
||
* and a deployment id per generation. A K15 stops the server with SIGTERM like K2 and starts the
|
||
* replacement as the next generation. Other faults in the same wave restart within a generation,
|
||
* like a platform restart of the same deployment.
|
||
*/
|
||
import { createHash } from "node:crypto";
|
||
import { fileURLToPath } from "node:url";
|
||
import type { WavePlan } from "./types.js";
|
||
|
||
/** Whether any server of this wave can be redeployed: a planned, storm or replayed K15. */
|
||
export function redeployWave(plan: Pick<WavePlan, "options" | "tasks" | "storm">): boolean {
|
||
// CR1 always redeploys; CR2 only when it holds the controller down (class-r.ts). Planned holds decide CR2.
|
||
const redeploys = (kind: string, holdMs: number) => kind === "K15" || kind === "CR1" || (kind === "CR2" && holdMs > 0);
|
||
return plan.options.faults.some(kind => kind === "K15" || kind === "CR1")
|
||
|| (plan.storm?.faults.some(fault => redeploys(fault.kind, fault.holdMs)) ?? false)
|
||
|| plan.tasks.some(task => redeploys(task.fault, task.holdMs)
|
||
|| (task.replayFaults?.some(fault => redeploys(fault.kind, fault.holdMs)) ?? false));
|
||
}
|
||
|
||
/** `--k15-hold-seconds` upper bound: the controller-outage cap (tests/runner-e2e/controller-outage.ts). */
|
||
export const K15_MAX_HOLD_SECONDS = 600;
|
||
|
||
/**
|
||
* `--k15-hold-seconds <n>` or `<min>-<max>` (0–600 s): how long a K15 keeps the server down between its SIGTERM
|
||
* and the next deploy's start. Staging deploys were down 4–7 minutes, so the remote Runner has to survive the
|
||
* gap mid-turn. Without it a K15 restarts at once.
|
||
*/
|
||
export function parseK15HoldSeconds(options: { faults: readonly string[] }, raw: string | undefined): { k15HoldMs?: [number, number] } {
|
||
if (raw === undefined) return {};
|
||
if (!options.faults.includes("K15")) throw new Error("--k15-hold-seconds sets the K15 outage; add K15 to --faults");
|
||
const match = /^([0-9]+)(?:-([0-9]+))?$/.exec(raw.trim());
|
||
if (!match) throw new Error("--k15-hold-seconds takes <seconds> or <min>-<max> seconds");
|
||
const min = Number(match[1]);
|
||
const max = match[2] === undefined ? min : Number(match[2]);
|
||
if (max > K15_MAX_HOLD_SECONDS || max < min) {
|
||
throw new Error(`--k15-hold-seconds must be between 0 and ${K15_MAX_HOLD_SECONDS} seconds, min not above max`);
|
||
}
|
||
return { k15HoldMs: [min * 1000, max * 1000] };
|
||
}
|
||
|
||
/** `--k15-overlap-seconds` bounds: staging's old server kept running 30–150 s after the new one was up. */
|
||
export const K15_OVERLAP_SECONDS_RANGE: readonly [number, number] = [5, 600];
|
||
|
||
/**
|
||
* The staging deploy shape (README "Redeploy without a volume, with overlap"):
|
||
*
|
||
* - `--k15-overlap-seconds <n>` or `<min>-<max>`: a K15 boots the next deploy generation beside the old server,
|
||
* switches traffic (the wave's stable port) to it once it is ready, keeps the old server running for the seeded
|
||
* overlap, then stops the old one with SIGTERM. The seeded K15 hold becomes the overlap; there is no outage.
|
||
* - `--k15-fresh-home`: every deploy generation gets a new, empty instance home and no Railway volume, as staging's
|
||
* deploys after 07:25Z reported `volumeId: null`. The database, secrets key and storage stay (they live outside
|
||
* the instance home on staging); controller-local files such as `runtime-context-assets` do not. Daytona only:
|
||
* a local run's workspace and runner live under the instance home.
|
||
*/
|
||
export function parseK15DeployShape(options: { env: string; faults: readonly string[]; k15HoldMs?: [number, number] },
|
||
overlapRaw: string | undefined, freshHome: boolean): { k15HoldMs?: [number, number]; k15Overlap?: true; k15FreshHome?: true } {
|
||
const out: { k15HoldMs?: [number, number]; k15Overlap?: true; k15FreshHome?: true } = {};
|
||
if ((overlapRaw !== undefined || freshHome) && !options.faults.includes("K15")) {
|
||
throw new Error("--k15-overlap-seconds and --k15-fresh-home shape the K15 redeploy; add K15 to --faults");
|
||
}
|
||
if (overlapRaw !== undefined) {
|
||
if (options.k15HoldMs) throw new Error("--k15-overlap-seconds replaces the K15 outage; do not combine it with --k15-hold-seconds");
|
||
const match = /^([0-9]+)(?:-([0-9]+))?$/.exec(overlapRaw.trim());
|
||
if (!match) throw new Error("--k15-overlap-seconds takes <seconds> or <min>-<max> seconds");
|
||
const min = Number(match[1]);
|
||
const max = match[2] === undefined ? min : Number(match[2]);
|
||
const [low, high] = K15_OVERLAP_SECONDS_RANGE;
|
||
if (min < low || max > high || max < min) throw new Error(`--k15-overlap-seconds must be between ${low} and ${high} seconds, min not above max`);
|
||
out.k15HoldMs = [min * 1000, max * 1000];
|
||
out.k15Overlap = true;
|
||
}
|
||
if (freshHome) {
|
||
if (options.env !== "daytona") throw new Error("--k15-fresh-home runs only with --env daytona: a local run's workspace and runner live in the instance home");
|
||
out.k15FreshHome = true;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** The seeded hold of one K15. Draws only when a range is set, so existing seeds keep their plans. */
|
||
export function k15HoldMs(range: readonly [number, number] | undefined, int: (min: number, max: number) => number): number {
|
||
return range ? int(range[0], range[1]) : 0;
|
||
}
|
||
|
||
/** The preload that applies the emulated host name before the server loads. */
|
||
export const REDEPLOY_PRELOAD_PATH = fileURLToPath(new URL("./redeploy-preload.mjs", import.meta.url));
|
||
/** Read by the preload: the host name `os.hostname()` returns in the server process. */
|
||
export const REDEPLOY_HOSTNAME_ENV = "RESTART_CHAOS_REDEPLOY_HOSTNAME";
|
||
|
||
export interface RedeployIdentity {
|
||
/** 0 for the wave's first server; each K15 adds one. */
|
||
generation: number;
|
||
hostname: string;
|
||
projectId: string;
|
||
environmentId: string;
|
||
serviceId: string;
|
||
deploymentId: string;
|
||
volumeId: string;
|
||
}
|
||
|
||
/** A deterministic UUID-shaped id for one wave, role and generation (no randomness: replays match). */
|
||
function uuidFor(waveId: string, role: string, generation = 0): string {
|
||
const hex = createHash("sha256").update(`restart-chaos-redeploy:${waveId}:${role}:${generation}`).digest("hex");
|
||
// Version 5-style layout so validators that require a UUID shape accept it.
|
||
const variant = ((parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16);
|
||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
||
}
|
||
|
||
/** The identity of the server that serves generation `generation` of this wave. */
|
||
export function redeployIdentity(waveId: string, generation: number): RedeployIdentity {
|
||
if (!Number.isInteger(generation) || generation < 0) throw new Error(`Redeploy generation must be a non-negative integer, got ${generation}`);
|
||
return {
|
||
generation,
|
||
hostname: `chaos-deploy-${createHash("sha256").update(`${waveId}:${generation}`).digest("hex").slice(0, 10)}`,
|
||
projectId: uuidFor(waveId, "project"),
|
||
environmentId: uuidFor(waveId, "environment"),
|
||
serviceId: uuidFor(waveId, "service"),
|
||
deploymentId: uuidFor(waveId, "deployment", generation),
|
||
volumeId: uuidFor(waveId, "volume"),
|
||
};
|
||
}
|
||
|
||
/** The next deploy of the same service: same project, environment, service and volume. */
|
||
export function nextRedeployIdentity(waveId: string, current: RedeployIdentity): RedeployIdentity {
|
||
return redeployIdentity(waveId, current.generation + 1);
|
||
}
|
||
|
||
/**
|
||
* Server environment for one generation. The volume mount is the instance home: on staging the
|
||
* instance directory lives on the exclusive volume that survives each deploy.
|
||
*/
|
||
export function redeployEnvironment(identity: RedeployIdentity, volumeMountPath: string | null): Record<string, string> {
|
||
return {
|
||
[REDEPLOY_HOSTNAME_ENV]: identity.hostname,
|
||
RAILWAY_PROJECT_ID: identity.projectId,
|
||
RAILWAY_ENVIRONMENT_ID: identity.environmentId,
|
||
RAILWAY_SERVICE_ID: identity.serviceId,
|
||
RAILWAY_DEPLOYMENT_ID: identity.deploymentId,
|
||
// `--k15-fresh-home`: no volume, as staging's later deploys reported `volumeId: null`.
|
||
...(volumeMountPath === null ? {} : { RAILWAY_VOLUME_ID: identity.volumeId, RAILWAY_VOLUME_MOUNT_PATH: volumeMountPath }),
|
||
};
|
||
}
|
||
|
||
/** `--k15-fresh-home`: generation N's own instance home (generation 0 keeps the wave's first home). */
|
||
export function generationHome(temporaryRoot: string, firstHome: string, generation: number): string {
|
||
return generation === 0 ? firstHome : `${temporaryRoot}/home-g${generation}`;
|
||
}
|
||
|
||
/** Node arguments that load the preload ahead of the server entry's own `--import` loader. */
|
||
export function redeployNodeArgs(args: readonly string[]): string[] {
|
||
return ["--import", REDEPLOY_PRELOAD_PATH, ...args];
|
||
}
|
||
|
||
/** What a K15 receipt records: both generations, so a replay and the evidence name them. */
|
||
export function redeployReceipt(from: RedeployIdentity, to: RedeployIdentity) {
|
||
return { fromGeneration: from.generation, toGeneration: to.generation, fromHostname: from.hostname, toHostname: to.hostname,
|
||
fromDeploymentId: from.deploymentId, toDeploymentId: to.deploymentId, volumeId: to.volumeId };
|
||
}
|