mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 17:22:00 +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`) tests how runs survive control-plane faults > - Every wave runs one server, so no wave can reach a race between two servers on one database > - Hosted Cloud deploys by rolling overlap: a new server's startup recovery and sweeps run beside the old server's live runs > - #20508 fixed such a race (another server's orphan sweep failing a run whose owner took its lease back) with a database test only > - This pull request adds three two-server faults (MS1–MS3) and a cross-server invariant > - The benefit is that cross-server ownership races can be reproduced and graded in a live wave ## Linked Issues or Issue Description Refs #20508 (the race this harness can now reach live). **What is missing?** The harness starts one control plane per wave. A fault replaces or freezes that one server. Nothing starts a second server on the same database while the first still owns runs, so cross-server ownership bugs are found only by database tests. **How production runs (checked before choosing the scenarios)** - Hosted Cloud runs on Railway with Neon Postgres. A deploy is a rolling A/B overlap: B starts on the same database, A serves until B is ready, then A drains. Defaults are a 5 s overlap and up to 90 s of drain (`doc/plans/2026-10-31-zero-downtime-web-deploys.md`, lines 374–375). The chaos plan names the same shape as K12 (`doc/plans/2026-11-06-control-plane-restart-chaos.md`, line 103). It also records B's startup recovery hitting a lock on a run A still owned (lines 229–230). - This repository has no replica count or deploy strategy of its own. `docker/ecs-task-definition.json` describes one task, and the real Cloud deploy lives in another repository. The self-hosting docs do not describe several servers on one database. - Server-side ownership: per-boot controller lease of 90 s (`server/src/services/controller-instance-lease.ts`), legacy run lease of 60 s, native executor lease of 20 min, and the orphan sweep in `server/src/services/heartbeat/recovery.ts`. So MS1 models B's overlap beside a live A, MS2 models A paused (for example a host sleep) past its controller lease while B is up, and MS3 models the rolling upgrade itself. ## What Changed - New `tests/restart-chaos/multi-server.ts`: - MS1: B boots on the same database, instance directory, storage and secrets, on its own port. It stays up 60–150 s beside a live A, then gets SIGTERM. - MS2: B boots. A and its tree are frozen (TM2's freeze without aging) for 100–150 s, past the 90 s controller lease, then thawed. B stays up 30 s more, then stops. - MS3: B boots, then A gets K2 and a replacement on A's port. B stops 20–45 s after the replacement is ready. - B is always stopped once started, also when a step fails. - `checkCrossServer`, graded once per wave with an MS fault, over the wave database and the server process windows (pid, start, exit, freezes): - `MS_foreign_failure`: a run failed with `process_lost` or `native_execution_ownership_unverified` while its owner was alive and not frozen (#20508's shape included). The owner's own lease loss counts only without a prior freeze past the lease. - `MS_double_executor`: a run recorded a second runner identity before the first was shown stopped. - Unattributable ownership failures are listed, not charged. - `supervisor.ts`: records every server's process window and freezes, spawns and stops B (`<wave>/server-b.log`), and runs the MS steps. TM freezes are recorded too. - `types.ts`, `scheduler.ts`, `journey-matrix.ts`, `config.ts`: MS1–MS3 fault kinds, seeded holds, and validation (local only; no storm, crash point or K14). - `main.ts`: writes `<wave>/cross-server.json` and fails the wave on a violation. - README section "Two servers, one database (MS1–MS3)", with the production evidence and the limits. Test-only. No `package.json` or `.github` changes. ## Verification - `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts`: 38 files, 473 tests pass. - New `multi-server.test.ts` (14 tests): option validation and seeded holds; MS1/MS2/MS3 step order and timing with fake hooks; B stopped when a step fails; the invariant for a live owner, a frozen or exited owner, #20508's thaw-then-write shape, lease loss with and without a freeze past the lease, unattributed owners, and double executors. - New `multi-server-db.test.ts` (embedded Postgres): a run still `running` with `native_execution_ownership_unverified` (#20508's marker) is read at its write time and charged as `MS_foreign_failure`. - `node scripts/typecheck-test-projects.mjs tests/restart-chaos` passes. - Live wave `w-20261110T105448-s301` on head `a9ec5a4502` (master product code + this harness), `--adapters codex --workloads B5,Q1 --faults MS1,MS2 --concurrency 1 --tasks 3`: 3 of 3 pass, all crossed their fault. B booted on the shared database in about 10 s each time. MS2 froze A for 104 s; A stayed up after the thaw. Cross-server invariant: 0 violations, 0 unattributed. ## Risks - Test-harness code only. - B runs on the same host and PID namespace as A, so it can check A's PIDs directly, which a Railway replica cannot. These faults are a lower bound on cross-server exposure. The README says so. - MS2 outlasts the 90 s controller lease, not the 20 min native executor lease. - MS faults refuse TM2/TM3 in the same wave, and fail when B exits before its planned stop. - `MS_foreign_failure` uses the run's `finished_at` (or `updated_at` for a still-running row) and the owner recorded on the run. A run whose owner changed after the failure could be misattributed; the evidence file keeps the owner and windows for review. - `MS_double_executor` reads runner identity events; it cannot see a second executor that never recorded one. ## Model Used - Anthropic Claude, model ID `claude-opus-5-5`, used through Claude Code with tool use (shell, file edit, code execution). ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
149 lines
8.9 KiB
TypeScript
149 lines
8.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { parseCommand } from "./config.js";
|
|
import { planTasks } from "./scheduler.js";
|
|
import {
|
|
CONTROLLER_LEASE_TTL_MS, MS2_THAW_TAIL_MS, MULTI_SERVER_HOLD_RANGE_MS, checkCrossServer, runMultiServerFault,
|
|
type CrossServerRun, type MultiServerHooks, type MultiServerKind, type MultiServerRecord, type ServerProcessWindow,
|
|
} from "./multi-server.js";
|
|
import { FAULT_SIGNAL } from "./supervisor.js";
|
|
|
|
describe("two-server fault options", () => {
|
|
it("accepts MS faults locally and gives each a seeded hold in its range", () => {
|
|
const parsed = parseCommand(["--faults", "MS1,MS2,MS3", "--adapters", "codex", "--workloads", "B5,Q1", "--seed", "3"]);
|
|
if (parsed.mode !== "wave") throw new Error("expected a wave");
|
|
expect(parsed.options.faults).toEqual(["MS1", "MS2", "MS3"]);
|
|
const plan = planTasks({ seed: 3, tasks: 12, workloads: ["B5"], adapters: ["codex"], faults: ["MS1", "MS2", "MS3"] });
|
|
expect(new Set(plan.map(task => task.fault))).toEqual(new Set(["MS1", "MS2", "MS3"]));
|
|
for (const task of plan) {
|
|
const [min, max] = MULTI_SERVER_HOLD_RANGE_MS[task.fault as MultiServerKind];
|
|
expect(task.holdMs).toBeGreaterThanOrEqual(min);
|
|
expect(task.holdMs).toBeLessThanOrEqual(max);
|
|
}
|
|
// MS2 freezes the primary past the controller lease, so a second server may legitimately fence it.
|
|
expect(MULTI_SERVER_HOLD_RANGE_MS.MS2[0]).toBeGreaterThan(CONTROLLER_LEASE_TTL_MS);
|
|
expect(FAULT_SIGNAL).toMatchObject({ MS1: "none", MS2: "SIGSTOP", MS3: "SIGTERM" });
|
|
});
|
|
|
|
it("refuses combinations a second local server cannot serve", () => {
|
|
expect(() => parseCommand(["--faults", "MS1", "--env", "daytona", "--daytona-image", `r/i@sha256:${"a".repeat(64)}`])).toThrow(/--env local/);
|
|
expect(() => parseCommand(["--faults", "MS2", "--storm-minutes", "10"])).toThrow(/storm/);
|
|
expect(() => parseCommand(["--faults", "MS3,K14", "--upgrade-from", "/tmp/x"])).toThrow(/K14/);
|
|
expect(() => parseCommand(["--faults", "MS1,TM2", "--workloads", "B5"])).toThrow(/TM2 or TM3/);
|
|
});
|
|
});
|
|
|
|
function fakeHooks(options: { failOn?: "start" | "freeze" | "restart"; exitAt?: number } = {}) {
|
|
let clock = 0;
|
|
const calls: string[] = [];
|
|
const hooks: MultiServerHooks = {
|
|
now: () => clock,
|
|
sleep: async ms => { clock += ms; },
|
|
cancelled: () => false,
|
|
secondaryFailure: () => (options.exitAt !== undefined && clock >= options.exitAt ? "code=1 signal=null" : null),
|
|
startSecondary: async () => { calls.push("start"); if (options.failOn === "start") throw new Error("B did not start"); },
|
|
stopSecondary: async () => { calls.push("stop"); },
|
|
freezePrimary: async holdMs => { calls.push(`freeze:${holdMs}`); clock += holdMs; if (options.failOn === "freeze") throw new Error("A could not be shown stopped"); },
|
|
restartPrimary: async () => { calls.push("restart"); clock += 5_000; if (options.failOn === "restart") throw new Error("A' not ready"); },
|
|
};
|
|
return { hooks, calls, clock: () => clock };
|
|
}
|
|
|
|
const record = (kind: MultiServerKind): MultiServerRecord => ({ kind, secondary: null, steps: [] });
|
|
|
|
describe("runMultiServerFault", () => {
|
|
it("MS1 keeps B beside a live A for the hold, then stops B", async () => {
|
|
const fake = fakeHooks();
|
|
const result = record("MS1");
|
|
await runMultiServerFault("MS1", 90_000, result, fake.hooks);
|
|
expect(fake.calls).toEqual(["start", "stop"]);
|
|
expect(fake.clock()).toBe(90_000);
|
|
expect(result.steps.map(step => step.step)).toEqual(["secondary_starting", "secondary_ready", "secondary_stopping", "secondary_stopped"]);
|
|
});
|
|
|
|
it("MS2 freezes A past the lease while B is up, keeps B for the thaw tail, then stops B", async () => {
|
|
const fake = fakeHooks();
|
|
const result = record("MS2");
|
|
await runMultiServerFault("MS2", 120_000, result, fake.hooks);
|
|
expect(fake.calls).toEqual(["start", "freeze:120000", "stop"]);
|
|
expect(fake.clock()).toBe(120_000 + MS2_THAW_TAIL_MS);
|
|
expect(result.steps.map(step => step.step)).toContain("primary_thawed");
|
|
});
|
|
|
|
it("MS3 restarts A (K2) while B serves, holds B's tail, then stops B", async () => {
|
|
const fake = fakeHooks();
|
|
await runMultiServerFault("MS3", 30_000, record("MS3"), fake.hooks);
|
|
expect(fake.calls).toEqual(["start", "restart", "stop"]);
|
|
expect(fake.clock()).toBe(35_000);
|
|
});
|
|
|
|
it("fails the fault when B exits on its own during the overlap or a freeze, and still stops it", async () => {
|
|
const overlap = fakeHooks({ exitAt: 30_000 });
|
|
await expect(runMultiServerFault("MS1", 90_000, record("MS1"), overlap.hooks)).rejects.toThrow(/exited before its planned stop/);
|
|
expect(overlap.calls).toEqual(["start", "stop"]);
|
|
expect(overlap.clock()).toBeLessThan(90_000);
|
|
const frozen = fakeHooks({ exitAt: 50_000 });
|
|
await expect(runMultiServerFault("MS2", 120_000, record("MS2"), frozen.hooks)).rejects.toThrow(/exited before its planned stop/);
|
|
expect(frozen.calls.at(-1)).toBe("stop");
|
|
const restarted = fakeHooks({ exitAt: 1_000 });
|
|
await expect(runMultiServerFault("MS3", 0, record("MS3"), restarted.hooks)).rejects.toThrow(/exited before its planned stop/);
|
|
});
|
|
|
|
it.each(["start", "freeze", "restart"] as const)("stops B and fails the fault when %s fails", async failOn => {
|
|
const fake = fakeHooks({ failOn });
|
|
const kind: MultiServerKind = failOn === "freeze" ? "MS2" : failOn === "restart" ? "MS3" : "MS1";
|
|
await expect(runMultiServerFault(kind, 60_000, record(kind), fake.hooks)).rejects.toThrow();
|
|
expect(fake.calls.at(-1)).toBe("stop");
|
|
});
|
|
});
|
|
|
|
const A: ServerProcessWindow = { pid: 100, role: "primary", spawnedAt: 0, exitedAt: null, frozen: [] };
|
|
const B: ServerProcessWindow = { pid: 200, role: "secondary", spawnedAt: 50_000, exitedAt: 300_000, frozen: [] };
|
|
const run = (overrides: Partial<CrossServerRun>): CrossServerRun => ({ runId: "r1", issueId: "i1", status: "failed",
|
|
errorCode: "native_execution_ownership_unverified", endedAt: 60_000, ownerBootId: "boot-a", ownerPid: 100, ...overrides });
|
|
|
|
describe("checkCrossServer", () => {
|
|
it("charges an ownership failure written while the owner was alive and running", () => {
|
|
const check = checkCrossServer({ runs: [run({})], events: [], windows: [A, B] });
|
|
expect(check.violations).toMatchObject([{ rule: "MS_foreign_failure", runId: "r1", issueId: "i1" }]);
|
|
});
|
|
|
|
it("allows an ownership failure while the owner was frozen or after it exited", () => {
|
|
const frozen = { ...A, frozen: [{ from: 55_000, to: 200_000 }] };
|
|
expect(checkCrossServer({ runs: [run({})], events: [], windows: [frozen, B] }).violations).toEqual([]);
|
|
const exited = { ...A, exitedAt: 59_000 };
|
|
const check = checkCrossServer({ runs: [run({})], events: [], windows: [exited, B] });
|
|
expect(check.violations).toEqual([]);
|
|
expect(check.unattributed).toHaveLength(1);
|
|
});
|
|
|
|
it("charges #20508's shape: a sweep's write after the frozen owner thawed and took its lease back", () => {
|
|
const thawed = { ...A, frozen: [{ from: 10_000, to: 110_000 }] };
|
|
const check = checkCrossServer({ runs: [run({ endedAt: 115_000 })], events: [], windows: [thawed, B] });
|
|
expect(check.violations.map(violation => violation.rule)).toEqual(["MS_foreign_failure"]);
|
|
});
|
|
|
|
it("allows the owner's own lease loss after a freeze past the lease, not without one", () => {
|
|
const leaseLost = run({ errorCode: "controller_lease_lost", endedAt: 115_000 });
|
|
const pastLease = { ...A, frozen: [{ from: 10_000, to: 10_000 + CONTROLLER_LEASE_TTL_MS + 1 }] };
|
|
expect(checkCrossServer({ runs: [leaseLost], events: [], windows: [pastLease, B] }).violations).toEqual([]);
|
|
const shortFreeze = { ...A, frozen: [{ from: 10_000, to: 40_000 }] };
|
|
expect(checkCrossServer({ runs: [leaseLost], events: [], windows: [shortFreeze, B] }).violations).toHaveLength(1);
|
|
});
|
|
|
|
it("ignores ordinary failures and reports owners it cannot attribute", () => {
|
|
expect(checkCrossServer({ runs: [run({ errorCode: "adapter_failed" })], events: [], windows: [A] })).toMatchObject({ violations: [], unattributed: [] });
|
|
expect(checkCrossServer({ runs: [run({ ownerPid: null })], events: [], windows: [A] }).unattributed).toHaveLength(1);
|
|
});
|
|
|
|
it("charges a second runner identity recorded before the first was shown stopped", () => {
|
|
const recorded = (pid: number, at: number) => ({ runId: "r1", kind: "recorded" as const, pid, at });
|
|
const stopped = (pid: number, at: number) => ({ runId: "r1", kind: "stopped" as const, pid, at });
|
|
const runs = [run({ errorCode: null, status: "succeeded" })];
|
|
expect(checkCrossServer({ runs, events: [recorded(1, 1), recorded(2, 5)], windows: [A] }).violations)
|
|
.toMatchObject([{ rule: "MS_double_executor", evidence: { firstPid: 1, secondPid: 2 } }]);
|
|
expect(checkCrossServer({ runs, events: [recorded(1, 1), stopped(1, 4), recorded(2, 5)], windows: [A] }).violations).toEqual([]);
|
|
// The same runner recorded again (a re-attach) is one executor.
|
|
expect(checkCrossServer({ runs, events: [recorded(1, 1), recorded(1, 5)], windows: [A] }).violations).toEqual([]);
|
|
});
|
|
});
|