feat(chaos): check I6 and I8 per crash point; ready the Daytona points (#19984)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The on-demand crash-point suite (#19946) kills the control plane at
named durable write boundaries and checks the plan's invariants
> - Its first full run measured I1–I4 and I7, but I5, I6 and I8 were
reported as not measured
> - #19960 (generation stamping) and #19863 (speed budgets) now record
what I6 and I8 need in the run log
> - The six Daytona-only points have not run yet, and three of them
cannot be reached by a plain task
> - This pull request checks I6 and I8 per point, names the I5 gap, and
readies the Daytona points for the coming Daytona wave
> - The benefit is that every invariant is either asserted or a named
gap, and a Daytona run explains any point it cannot reach

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 5). Follows #19946 (the suite). Uses #19960
(event generations) and #19863 (speed budgets).

**Problem**
- The suite reported I5, I6 and I8 as "not measured".
- A Daytona run would report three points as `not_reached` with no
reason.
- A Daytona run without its key or image failed only after a 3-minute
boot.

**Expected behavior**
- I6 and I8 are checked at each point.
- I5 is a named gap with its reason.
- Daytona points say what they need, and a Daytona run checks its inputs
before it boots.

## What Changed

- `tests/restart-chaos/invariants.ts`: `runLogViolations` checks two
invariants from the run log, scoped to the wave's company:
- I6: a run that failed with an error that names a stale or
earlier-generation event;
- I8: each `run.performance.budget_exceeded` event, with the budget
evidence (metric, measured, p95, adapter, start kind).
Both are keyed by run or event id, so the per-point baseline diff
charges each one to the point that produced it. I5 is reported as
`notMeasured` with a reason (`I5_GAP`).
- `packages/shared/src/crash-point-registry.ts`: an optional
`prerequisite` on the three Daytona points a plain task cannot reach.
These are the teardown claim (needs a failed release), the reusable
destroy (needs a `reuse_by_environment` lease), and stop-and-retain.
- `tests/restart-chaos/crash-points.ts`:
- `--list` prints each point's `needs:`, and a `not_reached` result
names it too;
- `--env daytona` refuses to start without `DAYTONA_API_KEY` and an
image.
- Docs: the README and the plan describe the I5/I6/I8 coverage and the
Daytona points.

## Verification

- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts
tests/restart-chaos/crash-points.test.ts`: 12 passed.
- `cd packages/shared && npx vitest run src/crash-points.test.ts`: 15
passed.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos` and
`npx tsc --noEmit -p packages/shared` both pass.
- I ran `runLogViolations` against a throwaway Postgres 18 with seeded
rows, then stopped and deleted it. It returned:
  - I8 for the budget breach, with its evidence;
- I6 for the run of this company that failed on
`stale_generation_event`.
It excluded the other company's stale run, the `process_lost` run and a
non-budget event.
- `node scripts/crash-points.mjs --list --env daytona` lists 19 points,
with `needs:` on the three. `--env daytona` with no key or image fails
at once with the missing names.
- No harness run: the harness slot is held for other work. The next
suite run exercises the new checks.

## Risks

- Test harness only; no product code.
- I6 is a proxy: it matches the run's error text and code. A failure
that names a generation for another reason would be reported, with its
evidence, for a person to judge.
- I8 can flag breaches on a loaded host. Each breach carries its
measured and budget values, so a load-caused breach is visible as one.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits).

## 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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-08 00:06:01 -06:00
1 parent 46bf913fb9
commit 68d5fb5a6e
6 files changed
+114 -19

No files matched your search

@@ -226,7 +226,7 @@ The runner stamps `generation` and `turnId` on every event. Driver and session c
**Built (Phase 5).**
- `crashPoint(name)` in `packages/shared/src/crash-points.ts`, with the named points in `crash-point-registry.ts`. `PAPERCLIP_CRASH_POINT=<name>[:<nth>]` arms one point; `NODE_ENV=production` ignores it. Disarmed, a call costs about 4 ns. In-process tests use `armCrashPointForTest`.
- 20 points at the boundaries in the inventory's write-ahead table, from `sandbox.create.after_provider_before_lease` to `accounting.closure.after_commit_before_spool_unlink`. `server/src/__tests__/crash-point-call-sites.test.ts` requires a call site for each one.
- `node scripts/crash-points.mjs [--points <glob>] [--kinds <id>] [--adapters …]` boots one instance and arms each point in turn at runtime (`PAPERCLIP_CRASH_POINT_CONTROL`). It asserts R1–R6 plus I1–I4 and I7 per point, from the Phase 1 reconciler. I5, I6 and I8 are reported as not measured until sections F and G land. The manual CI run is `.github/workflows/crash-points.yml`.
- `node scripts/crash-points.mjs [--points <glob>] [--kinds <id>] [--adapters …]` boots one instance and arms each point in turn at runtime (`PAPERCLIP_CRASH_POINT_CONTROL`). It asserts R1–R6 plus I1–I4 and I7 per point, from the Phase 1 reconciler. I6 and I8 come from the run log: a run failed by a stale-generation event, and speed-budget breaches. I5 is a known gap until restart claims record their charge decision; R3 bounds it. The manual CI run is `.github/workflows/crash-points.yml`.
- First full local run (2026-11-08, 14 local points, codex A1, 11.8 min): 11 pass. Work list:
- `workspace.finalize.after_start_before_copyback` and `…after_copyback_before_done` leave a `workspace_finalize` operation running after the run succeeded (I2 and R6, kind `native_workspace_finalize_attempt`).
- `wake.claim.after_run_running_before_wakeup_claimed` reports I4 on a wake whose retry delivered the work: the known refusal-regex false positive, which section E removes.
@@ -21,6 +21,8 @@ export type CrashPointDefinition = {
environments: readonly CrashPointEnvironment[];
kinds: readonly string[];
invariants: readonly CrashPointInvariant[];
/** What a run must go through first when one plain task does not reach the point. */
prerequisite?: string;
};
export const CRASH_POINT_NAME_PATTERN = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\.(before|after)_[a-z0-9_]+$/;
@@ -97,18 +99,21 @@ export const CRASH_POINTS = {
environments: ["daytona"],
kinds: ["environment_teardown_claim", "environment_pending_cleanup"],
invariants: ["I2", "I7"],
prerequisite: "A pending_cleanup lease: a provider release that failed or could not be verified. A plain task's release succeeds and never claims a teardown.",
},
"environment.teardown.after_destroy_before_lease": {
description: "A reusable sandbox was destroyed at the provider; its lease row is not released.",
environments: ["daytona"],
kinds: ["environment_run_lease_active", "environment_retained_hold"],
invariants: ["I2"],
prerequisite: "A reuse_by_environment lease that expires or is replaced (destroyReusableSandboxLease). The chaos Daytona environment's leases are ephemeral by default.",
},
"environment.stop_retain.after_claim_before_stop": {
description: "A stop-and-retain intent and its claim are committed; the provider stop has not been retried.",
environments: ["daytona"],
kinds: ["environment_stop_and_retain_intent"],
invariants: ["I2", "I7"],
prerequisite: "A stop-and-retain intent: a run whose sandbox must be stopped but kept, for a native workspace export resume.",
},
"environment.lease.after_release_before_runtime_services": {
description: "The run's environment leases are released; its workspace runtime services are not.",
+20 -3
View File
@@ -245,9 +245,16 @@ The suite runs one wave, `main.ts --crash-point <a>:<nth>,<b>:<nth>,...`:
- two passes at the real clock for I1 (owned) and I2 (settled);
- one pass with the clock moved 15 minutes ahead for I3 (admission), I4
(exactly-once intents) and I7 (visible next action).
Each violation carries its wake and run state as evidence. I5, I6 and I8
are reported as not measured, with the reconciler's reasons; R3 and R1
cover part of I5 and I8.
Each violation carries its wake and run state as evidence. Two run-log
checks complete the set:
- I6: a run that failed on a stale-generation event. Since #19960 an
earlier generation's event is history and must never fail the current
turn.
- I8: every `run.performance.budget_exceeded` event the speed budgets
wrote (TTFT, agent boot, restart recovery).
I5 (work-based budgets) is a known gap: charge decisions are not recorded
per restart claim, so the suite cannot tell a charged restart from an
uncharged one. R3 bounds it (no point may need a Retry).
5. Every task is graded R1–R6 and T at the end, as in any wave.
The first full local run (14 points, codex A1) took 11.8 minutes: one boot,
@@ -267,6 +274,16 @@ violated, or the wave exited 1 for a wave-level reason such as a Daytona
sandbox left after cleanup), `not_reached` (the chosen workloads never crossed the point: pick a
workload or `--nth` that does) and `harness_error`. Exit code 0, 1, 3 and 2.
**Daytona points.** Six points are reachable only with `--env daytona`
(`node scripts/crash-points.mjs --list --env daytona`). The run needs
`DAYTONA_API_KEY` and an image matching this checkout (`--daytona-image` or
`PAPERCLIP_E2E_DAYTONA_IMAGE`; see "Daytona" above), and refuses to start
without them. A plain task reaches both `sandbox.create.*` points and
`environment.lease_release.after_provider_before_lease`. The other three print
what they need (`needs:` in `--list` and in a `not_reached` result): a failed
release (teardown claim), a reusable lease (reusable destroy), or a
stop-and-retain export.
**CI.** `.github/workflows/crash-points.yml` runs the same command on demand
(`workflow_dispatch`, inputs `points`, `kinds`, `adapters`, `workloads`, `nth`).
It is not a required PR check. It runs only from the default branch, for
+12
View File
@@ -3,6 +3,7 @@ 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";
@@ -73,6 +74,17 @@ describe("classifyPoint", () => {
.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 |");
+13 -2
View File
@@ -124,7 +124,10 @@ export function classifyPoint(point: CrashPointName, exitCode: number | null, en
if (base.failedTasks.length > 0 || base.violations.length > 0) return { ...base, outcome: "fail" };
if (entry.invariants?.errors.length) return { ...base, outcome: "harness_error", error: entry.invariants.errors.join("; ") };
if (!entry.invariants) return { ...base, outcome: "harness_error", error: "no invariant check" };
if (!entry.reached) return { ...base, outcome: "not_reached" };
if (!entry.reached) {
const prerequisite = (CRASH_POINTS as Record<string, CrashPointDefinition>)[point]?.prerequisite;
return { ...base, outcome: "not_reached", ...(prerequisite ? { error: `needs: ${prerequisite}` } : {}) };
}
return { ...base, outcome: "pass" };
}
@@ -180,10 +183,18 @@ async function main(): Promise<number> {
if (options.list) {
for (const name of selected) {
const definition = CRASH_POINTS[name] as CrashPointDefinition;
console.info(`${name}\n ${definition.description}\n kinds ${definition.kinds.join(", ") || "—"}; invariants ${definition.invariants.join(", ")}; env ${definition.environments.join(", ")}`);
console.info(`${name}\n ${definition.description}\n kinds ${definition.kinds.join(", ") || "—"}; invariants ${definition.invariants.join(", ")}; env ${definition.environments.join(", ")}`
+ (definition.prerequisite ? `\n needs: ${definition.prerequisite}` : ""));
}
return 0;
}
if (options.env === "daytona") {
// Fail before a 3-minute boot, not after it.
const missing = [!process.env.DAYTONA_API_KEY && "DAYTONA_API_KEY",
!options.passthrough.includes("--daytona-image") && !process.env.PAPERCLIP_E2E_DAYTONA_IMAGE && "--daytona-image (or PAPERCLIP_E2E_DAYTONA_IMAGE)"]
.filter(Boolean);
if (missing.length) throw new Error(`--env daytona needs ${missing.join(" and ")}`);
}
if (!selected.length) throw new Error(`No crash point matches --points ${options.points.join(",") || "*"} --kinds ${options.kinds.join(",") || "*"} --env ${options.env}`);
const runId = `cp-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "")}`;
const runDir = path.join(resultsRoot, "crash-points", runId);
+63 -13
View File
@@ -10,9 +10,15 @@
* I3, I4, I7 one more pass with the clock moved past every grace window
* (accounting closure 10 min, next action 5 min). The tasks are at
* rest, so anything still waiting then would wait forever.
* I5, I6, I8 not measurable by the reconciler yet; reported as coverage
* gaps with its own reasons. The chaos graders cover part of them:
* R3 (one Retry at most) bounds I5 and R1 (finished in budget) I8.
* I6 run-log proxy: a run that failed on a stale-generation event.
* Since #19960 an earlier generation's event is history and never
* fails the current turn (runner code `stale_generation_event`).
* I8 run-log check: every `run.performance.budget_exceeded` event the
* speed budgets wrote (TTFT, agent boot, restart recovery; #19863).
* I5 not measured: see I5_GAP. R3 (one Retry at most) bounds it.
*
* I6 and I8 are company-wide scans keyed by run or event id, so the per-point
* baseline diff in main.ts charges each one to the point that produced it.
*/
import { createDb, type Db } from "../../packages/db/src/index.js";
import {
@@ -23,8 +29,23 @@ import {
export type PlanInvariant = "I1" | "I2" | "I3" | "I4" | "I5" | "I6" | "I7" | "I8";
export type SuiteRule = InvariantViolation["rule"] | "I6_generation_fencing" | "I8_fast_turns";
/** A violation with the state of its record and run, so a failure is diagnosable after the instance is gone. */
export type EvidencedViolation = InvariantViolation & { evidence?: Record<string, unknown> };
export type EvidencedViolation = Omit<InvariantViolation, "rule"> & { rule: SuiteRule; evidence?: Record<string, unknown> };
/**
* Why I5 (work-based budgets) is not asserted. A charge is decided per restart
* claim from the attempt's event window (provider-attempt-outcome.ts, #19948)
* and recorded only indirectly in the finalization's bounded recovery
* history (progressAttempt, attemptKeptForProgress). Asserting it needs either
* an explicit per-claim charge record or reading that history per run. The
* cheap check that exists is R3, and the pre-spawn and pre-accept points
* (native.process_start.*, runner.process.*, runner.turn_start.*) recover
* without a Retry today.
*/
export const I5_GAP = "Charge decisions are not recorded per restart claim; needs an explicit charge record (section F follow-up). "
+ "R3 bounds it: no point needed a Retry.";
export interface InvariantCheck {
violations: EvidencedViolation[];
@@ -37,9 +58,9 @@ export interface InvariantCheck {
/** Grace windows the shifted pass must exceed (ownership-invariant-report.ts). */
export const GRACE_SHIFT_MS = 15 * 60_000;
export const invariantOf = (violation: Pick<InvariantViolation, "rule">): PlanInvariant => violation.rule.slice(0, 2) as PlanInvariant;
export const invariantOf = (violation: Pick<EvidencedViolation, "rule">): PlanInvariant => violation.rule.slice(0, 2) as PlanInvariant;
export function summarizeViolations(violations: readonly InvariantViolation[]): Partial<Record<PlanInvariant, number>> {
export function summarizeViolations(violations: readonly Pick<EvidencedViolation, "rule">[]): Partial<Record<PlanInvariant, number>> {
const counts: Partial<Record<PlanInvariant, number>> = {};
for (const violation of violations) counts[invariantOf(violation)] = (counts[invariantOf(violation)] ?? 0) + 1;
return counts;
@@ -65,12 +86,40 @@ async function withWaveInstance<T>(instance: { paperclipHome: string; instanceId
const clip = (value: unknown) => typeof value === "string" ? value.slice(0, 300) : value ?? null;
async function withEvidence(db: Db, violations: InvariantViolation[]): Promise<EvidencedViolation[]> {
type Row = Record<string, unknown>;
const text = (value: unknown) => (typeof value === "string" ? value : "");
/** I6 and I8 from the run log of `companyId` (all companies when null). */
export async function runLogViolations(db: Db, companyId: string | null): Promise<EvidencedViolation[]> {
const breaches = await db.$client.unsafe(
`select e.id::text as id, e.company_id::text as "companyId", e.run_id::text as "runId", r.issue_id::text as "issueId", e.payload
from heartbeat_run_events e join heartbeat_runs r on r.id = e.run_id
where e.event_type = 'run.performance.budget_exceeded' and ($1::text is null or e.company_id::text = $1)`, [companyId]) as Row[];
const stale = await db.$client.unsafe(
`select id::text as id, company_id::text as "companyId", issue_id::text as "issueId", status, error_code as "errorCode", error
from heartbeat_runs
where status in ('failed', 'timed_out', 'interrupted')
and (coalesce(error_code, '') ~* 'generation' or coalesce(error, '') ~* 'stale_generation|earlier generation')
and ($1::text is null or company_id::text = $1)`, [companyId]) as Row[];
return [
...breaches.map(row => {
const payload = (row.payload ?? {}) as Record<string, unknown>;
return { rule: "I8_fast_turns" as const, kind: "speed_budget", recordId: text(row.id), companyId: text(row.companyId) || null,
runId: text(row.runId) || null, issueId: text(row.issueId) || null, reason: `${text(payload.metric) || "unknown"}_over_p95`,
evidence: { budget: { metric: payload.metric, measuredMs: payload.measuredMs, budgetP95Ms: payload.budgetP95Ms,
adapter: payload.adapter, startKind: payload.startKind } } };
}),
...stale.map(row => ({ rule: "I6_generation_fencing" as const, kind: "run", recordId: text(row.id),
companyId: text(row.companyId) || null, runId: text(row.id), issueId: text(row.issueId) || null,
reason: "run_failed_on_earlier_generation_event" })),
];
}
async function withEvidence(db: Db, violations: EvidencedViolation[]): Promise<EvidencedViolation[]> {
if (!violations.length) return violations;
const wakeIds = violations.filter(violation => violation.kind === "wake_request" || violation.kind === "wake_request_claimed")
.map(violation => violation.recordId);
const runIds = [...new Set(violations.flatMap(violation => violation.runId ? [violation.runId] : []))];
type Row = Record<string, unknown>;
const wakes = (wakeIds.length ? await db.$client.unsafe(
"select id::text, status, reason, error, run_id::text as \"runId\" from agent_wakeup_requests where id::text = any($1)", [wakeIds]) : []) as Row[];
const runs = (runIds.length ? await db.$client.unsafe(
@@ -115,16 +164,17 @@ export async function checkPlanInvariants(input: {
|| violation.companyId === input.companyId;
const fromClock = second.violations.filter(violation => violation.rule === "I1_owned" || violation.rule === "I2_settled");
const fromShift = shifted.violations.filter(violation => violation.rule !== "I1_owned" && violation.rule !== "I2_settled");
let runLog: EvidencedViolation[] = [];
try { runLog = await runLogViolations(db, input.companyId); } catch (error) {
errors.push(`I6/I8 run-log check: ${error instanceof Error ? error.message : String(error)}`);
}
const violations = await withEvidence(db, [...fromClock, ...fromShift].filter(ownCompany));
violations.push(...runLog);
const inProgress = Object.fromEntries(second.kinds.filter(kind => kind.inProgress > 0)
.map(kind => [kind.kind, { inProgress: kind.inProgress, ...kind.verdicts }]));
return {
violations, byInvariant: summarizeViolations(violations), inProgress, errors,
notMeasured: {
I5: second.coverageGaps.I5_work_based_budgets ?? "not measured",
I6: second.coverageGaps.I6_generation_fencing ?? "not measured",
I8: second.coverageGaps.I8_fast_turns ?? "not measured",
},
notMeasured: { I5: I5_GAP },
};
} finally {
await db.$client.end({ timeout: 5 }).catch(() => undefined);