Files
PaperClipAI/tests/restart-chaos/crash-points.test.ts
T
DottaandClaude Opus 5.5 bfa9002a3c test(restart-chaos): reach the stop-and-retain crash point with --daytona-reuse-lease (#20049)
## 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>
2026-11-08 08:07:19 -06:00

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/);
});
});