test(chaos): grade a fault-hit run as a restart, not a cold start (#20106)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The restart chaos harness grades speed against the native speed
budgets, and a regression fails the wave
> - It grades a run's time to first activity as a cold or warm start,
and restart recovery separately
> - Fault receipts carry no run ids, so a run that a fault hit before
its first activity was graded as a cold start
> - In a K4/K6 wave, the two slowest codex cold samples were runs that a
K6 SIGKILL hit before their first activity, so the cold p95 breach was
not a cold start
> - This pull request grades such a run as a restart
> - The benefit is that cold and warm rows measure only starts with no
fault, and restart cost stays in restart recovery

## Linked Issues or Issue Description

Refs #20071, #20094

**What happened?**
Wave `w-20261108T200620-s9701` reported a codex cold
time-to-first-activity p95 of 29.9 s against a 17 s budget. The two
slowest samples were runs that a K6 restart hit before their first
activity.

**Expected behavior**
A run whose first-activity window contains a fault start is graded as a
restart, not as a cold or warm start.

**Actual behavior**
The run was graded as a cold start, and the restart time counted as
cold-start latency.

## What Changed

- `faultBeforeFirstActivity(run, faultStarts)`: true when a fault
started between the run's trace start and the end of its raw
first-activity span (or first-token span when no activity span exists).
- `collectWaveSpeed` sets such a run's phase to `restart` before it
removes outage time. Cold, warm and single-run grading already skip
restart runs. The run still counts in restart recovery through the
fault's run timing.
- A test covers the helper and the exclusion from the cold row.

## Verification

- `npx vitest run wave-speed` in `tests/restart-chaos`: 10 passed.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos`: clean.

## Risks

- A run with no first-activity or first-token span counts as hit when
any fault starts after its trace start. Such a run has no cold sample to
grade anyway.
- A fault that starts just before a run's trace start does not count,
and outage removal handles it as before.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.

## 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 15:30:45 -06:00
1 parent 776f4cb602
commit 83a229ae8c
2 files changed
+33 -1

No files matched your search

+21 -1
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import type { BaselineRun } from "./ttft-baseline-stats.js";
import type { ChaosApi } from "./api.js";
import type { FaultReceipt } from "./types.js";
import { breachesOf, budgetFor, faultRuns, firstActivityMs, gradeWaveSpeed, outageWindows, speedMarkdown, traceStartMs, withoutOutage } from "./wave-speed.js";
import { breachesOf, budgetFor, collectWaveSpeed, faultBeforeFirstActivity, faultRuns, firstActivityMs, gradeWaveSpeed, outageWindows, speedMarkdown, traceStartMs, withoutOutage } from "./wave-speed.js";
import { runMetrics } from "./ttft-baseline-stats.js";
const span = (name: string, startOffsetMs: number, durationMs: number, extra: Record<string, unknown> = {}) =>
@@ -68,6 +69,25 @@ describe("wave speed grade", () => {
createdAt: new Date(trace + offset + duration).toISOString(), payload: { span: name, startOffsetMs: offset, durationMs: duration } })),
});
it("treats a run that a fault hit before its first activity as a restart, not a cold start", async () => {
const hit = traced(1_000_000, [["heartbeat.queue", 0, 100], ["task.time_to_first_agent_activity", 0, 30_000]]);
expect(faultBeforeFirstActivity(hit, [1_010_000], 1_000_000)).toBe(true);
expect(faultBeforeFirstActivity(hit, [1_040_000, 990_000], 1_000_000)).toBe(false);
expect(faultBeforeFirstActivity(hit, [1_005_000], 1_008_000)).toBe(false);
const clean = traced(2_000_000, [["heartbeat.queue", 0, 100], ["task.time_to_first_agent_activity", 0, 4_000]]);
const events: Record<string, object[]> = { hit: [...hit.events, { eventType: "turn.started", createdAt: new Date(1_013_000).toISOString() }],
clean: clean.events };
const api = { get: async (route: string) => (route.includes("/hit/") ? events.hit : events.clean)
.map((event: object, seq: number) => ({ ...event, seq })) } as unknown as ChaosApi;
const sample = (id: string, createdAt: number) => ({ runs: [{ id, status: "succeeded", createdAt: new Date(createdAt).toISOString(),
finishedAt: new Date(createdAt + 60_000).toISOString() }] }) as never;
const fault = { kind: "K1", startedAt: 1_010_000, readyAt: 1_012_000 } as FaultReceipt;
const speed = await collectWaveSpeed(api, "local", [{ issueId: "i1", adapter: "claude", samples: [sample("hit", 1_000_000)] },
{ issueId: "i2", adapter: "claude", samples: [sample("clean", 2_000_000)] }], [fault]);
expect(speed.rows.find(row => row.metric === "time_to_first_activity" && row.start === "cold")).toMatchObject({ measured: { n: 1, p50: 4_000 } });
expect(speed.rows.find(row => row.metric === "restart_recovery")).toMatchObject({ adapter: "claude", measured: { n: 1 } });
});
it("finds the trace start from when its spans were written, never later than it", () => {
expect(traceStartMs(traced(1_000_000, [["a", 0, 5_000], ["b", 2_000, 1_000]]))).toBe(1_000_000);
const late = traced(1_000_000, [["a", 0, 5_000]]);
+12
View File
@@ -100,6 +100,16 @@ export function withoutOutage(run: BaselineRun, windows: readonly OutageWindow[]
return { run: { ...run, events }, adjusted, removedMs, anchored: true };
}
export function faultBeforeFirstActivity(run: BaselineRun, faultStarts: readonly number[], runCreatedAt: number): boolean {
const traceStart = traceStartMs(run);
if (traceStart === null) return false;
const spans = runSpans(run);
const first = spans.find(span => span.name === "task.time_to_first_agent_activity")
?? spans.find(span => span.name === "task.time_to_first_token");
const end = first ? traceStart + first.startOffsetMs + first.durationMs : Number.POSITIVE_INFINITY;
return faultStarts.some(at => at >= runCreatedAt && at <= end);
}
export function firstActivityMs(run: BaselineRun): number | null {
const metrics = runMetrics(run);
return metrics.firstActivityAfterRunCreatedMs ?? metrics.ttftAfterRunCreatedMs;
@@ -188,6 +198,8 @@ export async function collectWaveSpeed(api: ChaosApi, environment: EnvironmentKi
try {
const raw: BaselineRun = { runId: run.id, adapter: task.adapter, environment, phase: index === 0 ? "cold" : "warm",
events: await runEventsWithRetry(api, run.id) };
if (faultBeforeFirstActivity(raw, faults.filter(fault => fault.readyAt !== null).map(fault => fault.startedAt),
Date.parse(run.createdAt!))) raw.phase = "restart";
const trimmed = withoutOutage(raw, windows);
outage.adjusted += trimmed.adjusted; outage.removedMs += trimmed.removedMs;
runs.push(trimmed.run);