mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
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:
1 parent
776f4cb602
commit
83a229ae8c
2 files changed
+33
-1
No files matched your search
@@ -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]]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user