Files
PaperClipAI/tests/restart-chaos/redeploy.ts
T
DottaandClaude 0bf3236bc8 test(restart-chaos): staging class R cells CR1-CR3 and sandbox capture on every Daytona wave (#20909)
## 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>
2026-11-11 15:50:59 -06:00

170 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}