mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The restart-chaos crash-point suite kills the server at named points, then checks that recovery restores every invariant > - On Daytona, `environment.stop_retain.after_claim_before_stop` was never reached, because no workload produced a stop-and-retain release > - A per-turn run on a reusable sandbox lease ends every turn with `stop_and_reuse`, which releases through exactly that stop-only claim > - This pull request adds `--daytona-reuse-lease`, so the chaos Daytona environment keeps reusable sandboxes and the point is reachable > - The benefit is live coverage of the stop-and-retain crash window ## Linked Issues or Issue Description This is a follow-up from the Phase 2 durable-ownership data (`doc/plans/2026-11-07-phase2-reconciler-data.md`, "Not covered"). Related: #19937 (crash-point suite) and #20042 (stop-only claim fence renewal). No duplicate PR was found. **What happened?** The Daytona crash-point run `cp-20261108T104102` reported `environment.stop_retain.after_claim_before_stop` as `not_reached`. The point needs a stop-and-retain intent, and the chaos Daytona environment is created with ephemeral leases. **Expected behavior** A flag lets a Daytona wave or crash-point run use reusable leases. A per-turn run then stops and keeps its sandbox, and the point is reached and graded. **Steps to reproduce** 1. Run `node scripts/crash-points.mjs --env daytona --daytona-image <image> --points environment.stop_retain.after_claim_before_stop --adapters codex --workloads A1`. 2. Before this PR the point is `not_reached`. 3. With `--daytona-reuse-lease --lifecycle per_turn` it is reached. **Paperclip version or commit** `master` at `8475826d2d` (the signed image `c24059f1…` revision). ## What Changed - `--daytona-reuse-lease` (wave and crash-point suite, Daytona only) creates the chaos environment with `reuseLease: true`. The crash-point suite passes the flag through. - The R6 sandbox sweep counts a **stopped** sandbox behind a **released `reuse_by_environment`** lease as kept for reuse, not a leak. A later run on the same workspace resumes it, because reuse accepts released and retained leases. A running sandbox, or a stopped one behind an ephemeral lease, is still a leak. - The crash-point registry prerequisite and the README name the flag. ## Verification - Live, Daytona, codex A1, from the image revision `8475826d2d`: - `cp-20261108T132149`: the point was **reached** and settled with no invariant violations. R6 flagged the stopped reusable sandbox as a leak, which led to the sweep fix. - `cp-20261108T133351`, with the sweep fix: **pass**. 0 orphaned sandboxes, 0 held leases, the environment deleted, and nothing left running. - `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts` passes `daytona-sweep`, `scheduler` and `crash-points` (52/52). `tsc -p tests/restart-chaos/tsconfig.json` is clean. ## Risks - Test harness only; the registry change is prerequisite text. Without the flag, waves behave as before. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use. ## 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 Opus 5.5 <noreply@anthropic.com>
121 lines
6.8 KiB
TypeScript
121 lines
6.8 KiB
TypeScript
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import type { CrashPointDefinition, CrashPointName } from "../../packages/shared/src/crash-point-registry.js";
|
|
import { summarizeViolations } from "./invariants.js";
|
|
import { classifyPoint, globToRegExp, parseSuiteArgs, renderSummary, selectPoints, type CrashPointEntry } from "./crash-points.js";
|
|
import { parseCommand } from "./config.js";
|
|
import { readCrashPointReceipt } from "./supervisor.js";
|
|
|
|
const definition = (kinds: string[], environments: Array<"local" | "daytona"> = ["local", "daytona"]): CrashPointDefinition => ({
|
|
description: "test point description long enough", kinds, invariants: ["I2"], environments,
|
|
});
|
|
const registry: Record<string, CrashPointDefinition> = {
|
|
"runner.startup.after_spawn": definition(["native_startup_context"]),
|
|
"sandbox.create.after_provider_before_lease": definition(["sandbox_creation_intent"], ["daytona"]),
|
|
"wake.request.after_claim_before_run": definition(["wake_request_claimed"]),
|
|
};
|
|
const point = "runner.startup.after_spawn" as CrashPointName;
|
|
|
|
describe("selectPoints", () => {
|
|
it("filters by glob, kind and environment", () => {
|
|
expect(selectPoints(registry, { points: [], kinds: [], env: "local" }))
|
|
.toEqual(["runner.startup.after_spawn", "wake.request.after_claim_before_run"]);
|
|
expect(selectPoints(registry, { points: ["runner.*"], kinds: [], env: "local" })).toEqual(["runner.startup.after_spawn"]);
|
|
expect(selectPoints(registry, { points: [], kinds: ["sandbox_creation_intent"], env: "daytona" }))
|
|
.toEqual(["sandbox.create.after_provider_before_lease"]);
|
|
expect(selectPoints(registry, { points: [], kinds: ["sandbox_creation_intent"], env: "local" })).toEqual([]);
|
|
});
|
|
|
|
it("matches globs literally apart from * and ?", () => {
|
|
expect(globToRegExp("runner.*").test("runnerXstartup")).toBe(false);
|
|
expect(globToRegExp("*.after_?pawn").test("runner.startup.after_spawn")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("parseSuiteArgs", () => {
|
|
it("defaults to one local codex A1 task per point", () => {
|
|
expect(parseSuiteArgs([])).toMatchObject({ env: "local", adapters: ["codex"], workloads: ["A1"], nth: 1, list: false, passthrough: [] });
|
|
});
|
|
|
|
it("passes harness options through and refuses unknown values", () => {
|
|
expect(parseSuiteArgs(["--", "--keep", "--budget-cents", "500"]).passthrough).toEqual(["--budget-cents", "500", "--keep"]);
|
|
expect(parseSuiteArgs(["--env", "daytona", "--daytona-reuse-lease"]).passthrough).toEqual(["--daytona-reuse-lease"]);
|
|
expect(() => parseSuiteArgs(["--adapters", "gpt"])).toThrow(/Unknown adapter/);
|
|
expect(() => parseSuiteArgs(["--nth", "0"])).toThrow(/positive integer/);
|
|
});
|
|
});
|
|
|
|
describe("classifyPoint", () => {
|
|
const entry = (overrides: Partial<CrashPointEntry> = {}): CrashPointEntry => ({
|
|
point: `${point}:1`, reached: { name: point, hit: 1, pid: 42 }, armedAt: 0, restedAt: 120_000,
|
|
tasks: [{ taskId: "t01", adapter: "codex", workload: "A1", pass: true, signature: null, rules: { R1: "pass" } }],
|
|
invariants: { violations: [], byInvariant: {}, errors: [] },
|
|
...overrides,
|
|
});
|
|
|
|
it("passes a reached point with no failed task or new violation, timed from arming to rest", () => {
|
|
expect(classifyPoint(point, 0, entry())).toMatchObject({ outcome: "pass", durationMs: 120_000 });
|
|
});
|
|
|
|
it("fails on a new invariant violation or a failed task, even when not reached", () => {
|
|
const violation = { rule: "I2_settled", kind: "native_executor_claim", reason: "owner_run_terminal", recordId: "r1" };
|
|
expect(classifyPoint(point, 1, entry({ invariants: { violations: [violation], byInvariant: { I2: 1 }, errors: [] } })))
|
|
.toMatchObject({ outcome: "fail", failedInvariants: { I2: 1 } });
|
|
expect(classifyPoint(point, 1, entry({ reached: null,
|
|
tasks: [{ taskId: "t01", adapter: "codex", workload: "A1", pass: false, signature: "x", rules: { R1: "fail" } }] })).outcome).toBe("fail");
|
|
});
|
|
|
|
it("separates unreached, unrun and unchecked points", () => {
|
|
expect(classifyPoint(point, 0, entry({ reached: null })).outcome).toBe("not_reached");
|
|
expect(classifyPoint(point, 2, null)).toMatchObject({ outcome: "harness_error", error: "not run (wave exited 2)" });
|
|
expect(classifyPoint(point, 0, entry({ invariants: null })).outcome).toBe("harness_error");
|
|
expect(classifyPoint(point, 0, entry({ invariants: { violations: [], byInvariant: {}, errors: ["I3 pass did not finish"] } })).outcome)
|
|
.toBe("harness_error");
|
|
});
|
|
|
|
it("names what an unreached point needs when its registry entry says so", () => {
|
|
const teardown = "environment.teardown.after_claim_before_destroy" as CrashPointName;
|
|
expect(classifyPoint(teardown, 0, entry({ point: `${teardown}:1`, reached: null })))
|
|
.toMatchObject({ outcome: "not_reached", error: expect.stringContaining("needs: A pending_cleanup lease") });
|
|
});
|
|
|
|
it("counts run-log violations under their plan invariant", () => {
|
|
expect(summarizeViolations([{ rule: "I8_fast_turns" }, { rule: "I6_generation_fencing" }, { rule: "I8_fast_turns" }]))
|
|
.toEqual({ I8: 2, I6: 1 });
|
|
});
|
|
|
|
it("renders a summary row per point", () => {
|
|
const result = { ...classifyPoint(point, 0, entry()), waveDir: null };
|
|
expect(renderSummary("cp-x", parseSuiteArgs([]), [result])).toContain("| `runner.startup.after_spawn` | pass | — | — | 2.0 |");
|
|
});
|
|
});
|
|
|
|
describe("readCrashPointReceipt", () => {
|
|
it("returns the receipt line of the given pid only", () => {
|
|
const dir = mkdtempSync(path.join(os.tmpdir(), "crash-receipt-"));
|
|
try {
|
|
const file = path.join(dir, "receipts.jsonl");
|
|
writeFileSync(file, `${JSON.stringify({ point, hit: 1, pid: 7 })}\n{"partial`);
|
|
expect(readCrashPointReceipt(file, 7)).toEqual({ name: point, hit: 1, pid: 7 });
|
|
expect(readCrashPointReceipt(file, 8)).toBeNull();
|
|
expect(readCrashPointReceipt(path.join(dir, "missing"), 7)).toBeNull();
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("main.ts --crash-point", () => {
|
|
it("plans a K1-only wave with the point and refuses scheduled-fault options", () => {
|
|
const parsed = parseCommand(["--crash-point", `${point}:2`, "--tasks", "1", "--seed", "1"]);
|
|
expect(parsed).toMatchObject({ mode: "wave", options: { crashPoint: `${point}:2`, faults: ["K1"] } });
|
|
expect(() => parseCommand(["--crash-point", point, "--faults", "K2"])).toThrow(/replaces scheduled faults/);
|
|
expect(() => parseCommand(["--crash-point", "Bad Name"])).toThrow(/<name>\[:<nth>\]/);
|
|
expect(parseCommand(["--crash-point", `${point}:1,${point}:2`, "--tasks", "2"]))
|
|
.toMatchObject({ options: { crashPoint: `${point}:1,${point}:2` } });
|
|
expect(() => parseCommand(["--replay", "w-20261107T000000-s1/t01", "--crash-point", point])).toThrow(/--replay/);
|
|
});
|
|
});
|