Files
PaperClipAI/server/src/services/native-runtime/native-speed-budgets.test.ts
T
DottaandClaude Opus 5.5 6fd46c0025 perf(budgets): judge Paperclip's share of restart recovery; report the provider's session load (#20183)
## Thinking Path

- Speed budgets protect the time to first visible agent activity, and
restart recovery is one of the judged metrics
(`native-speed-budgets.ts`, wave grader).
- In the B1 concurrency-6 chaos wave (`w-20261109T045320-s9807`), Codex
`restart_recovery` was p50 8.6 s and p95 11.2 s, against a budget of 6.5
s and 8.0 s. At concurrency 4 (K13) it was 3.8 s and 5.0 s.
- The preparation spans on a copy of the kept database break it down:
- Paperclip's share (ready → claim → prepare → runtime stage → runner
launch) is a steady 1.2–2.0 s.
- `provider.session.load`, the Codex app-server boot plus
`thread/resume` of long saved sessions, takes 3.0–6.3 s, 65–75% of each
recovery.
- Resumes run concurrently, and the worktree lock and the startup
reconciler are not on the path.
- Judging the full latency therefore mixes our code with provider and
session-size effects. Budget the part we control, so a regression in our
code shows up regardless of session size or concurrency, and report the
rest.

## Linked Issues or Issue Description

**What happened?**
`restart_recovery` breached its budget whenever many long provider
sessions resumed at once, although Paperclip's own recovery work stayed
within about 2 s. The breach did not point at anything Paperclip could
fix, and a real regression in our code could hide inside the
provider-load variance.

**Steps to reproduce**
1. Run a local chaos wave at concurrency 6 with long-running Codex tasks
and K1/K2/K4 faults (for example B1, seed 9807).
2. Compare `restart_recovery` in `speed.json` with
`provider.session.load` in the recovered runs' run logs.

**Expected behavior**
The budget judges Paperclip's share of a recovery. The provider's
session load and the full latency are reported but not judged.

## What Changed

- `server/src/services/native-runtime/native-speed-budgets.ts`:
- New budgeted metric `restart_recovery_control_plane`: from `ready` to
the start of `provider.session.load`, or to the next provider event when
nothing reloads (for example a reattached runner).
- Its budget is 2.5 s / 4.0 s for claude, codex and opencode. Measured:
1.2–2.0 s per run at concurrency 6.
  - The full `restart_recovery` stays in the table for reporting.
  - Breach payloads carry `restartTotalMs` and `providerLoadMs`.
- `server/src/services/native-runtime/native-run-trace.ts`:
- The per-run check records when `provider.session.load` starts, keeps
the recovery measurement, and judges it when the run finishes.
- It judges after the deferred diagnostics drain, so a provider span
committed after the provider event still counts.
  - It emits `restart_recovery_control_plane` breaches only.
- `tests/restart-chaos/ttft-baseline-stats.ts`: each recovery sample
gains `controlPlaneMs` and `providerLoadMs`. The provider-load start is
the span event's write time minus its duration.
- `tests/restart-chaos/wave-speed.ts`:
- Three restart rows per adapter: `restart_recovery` (report-only),
`restart_recovery_control_plane` (budgeted) and `restart_provider_load`
(report-only).
  - All three have outage time removed.
- `tests/restart-chaos/ttft-baseline.ts`: the recovery table adds a
"Paperclip share" column.
- Docs: `doc/run-log-events.md` (metric and payload) and the
speed-budget table in
`doc/plans/2026-11-07-durable-ownership-and-reconciliation.md`.

## Verification

- `server`: `npx vitest run
src/services/native-runtime/native-speed-budgets.test.ts`, 18 passed.
Two new tests:
  - a recovery dominated by provider load does not breach;
- a slow Paperclip share breaches with `restartTotalMs` and
`providerLoadMs`, even when the provider span is committed after the
provider event.

The existing recovery tests moved to the new metric. All six were red
before the trace change.
- `tests/restart-chaos`: `npx vitest run --config
tests/restart-chaos/vitest.config.ts wave-speed.test.ts
ttft-baseline-stats.test.ts`, 29 passed. New tests cover the sample
split and the grader rows (a judged share, a report-only provider load,
zero breaches).
- Server typecheck: no errors in the touched files.

## Risks

- **Lower sensitivity to provider-side slowdowns.** A provider
regression in session load no longer breaches. It stays visible in the
report-only rows and in breach payloads.
- **Runs with no `provider.session.load` span** (reattach, or an adapter
that doesn't emit it) are judged on the full latency against the
control-plane budget. That's conservative.
- **Run-log consumers that filter on `metric: "restart_recovery"`** see
`restart_recovery_control_plane` from now on. The doc notes the change
date.

## Model Used

Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), 1M context window, tool
use and code execution, via 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>
2026-11-09 01:22:28 -06:00

267 lines
17 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { AdapterRuntimeEvent } from "../../adapters/index.js";
import { createNativeRunTrace } from "./native-run-trace.js";
import {
evaluateSpeedBudget,
LOCAL_SPEED_BUDGETS,
RESTART_RECOVERY_PROVIDER_EVENT_TYPES,
restartRecoveryMeasurementFor,
SPEED_BUDGET_EXCEEDED_EVENT_TYPE,
speedBudgetAdapter,
speedBudgetFor,
} from "./native-speed-budgets.js";
describe("speed budgets", () => {
it("defines p50 below p95 for every adapter, metric and start", () => {
for (const budgets of Object.values(LOCAL_SPEED_BUDGETS)) {
for (const budget of [budgets.time_to_first_activity.cold, budgets.time_to_first_activity.warm, budgets.agent_boot.cold, budgets.agent_boot.warm,
budgets.restart_recovery]) {
expect(budget.p50Ms).toBeGreaterThan(0);
expect(budget.p50Ms).toBeLessThanOrEqual(budget.p95Ms);
}
}
});
it("maps native providers to budgeted adapters", () => {
expect(speedBudgetAdapter({ kind: "codex" })).toBe("codex");
expect(speedBudgetAdapter({ kind: "opencode" })).toBe("opencode");
expect(speedBudgetAdapter({ kind: "acpx", agent: "claude" })).toBe("claude");
expect(speedBudgetAdapter({ kind: "acpx", agent: "cursor" })).toBeNull();
expect(speedBudgetAdapter({ kind: "claude_managed" })).toBeNull();
});
it("judges one run against p95 and uses the looser budget when the start kind is unknown", () => {
const codexCold = LOCAL_SPEED_BUDGETS.codex.time_to_first_activity.cold.p95Ms;
expect(evaluateSpeedBudget({ adapter: "codex", environment: "local", metric: "time_to_first_activity", start: "cold", measuredMs: codexCold }))
.toBeNull();
expect(evaluateSpeedBudget({ adapter: "codex", environment: "local", metric: "time_to_first_activity", start: "cold", measuredMs: codexCold + 1 }))
.toMatchObject({ metric: "time_to_first_activity", adapter: "codex", startKind: "cold", budgetP95Ms: codexCold, percentile: "p95" });
const loose = speedBudgetFor("codex", "time_to_first_activity", null);
expect(loose.p95Ms).toBe(Math.max(codexCold, LOCAL_SPEED_BUDGETS.codex.time_to_first_activity.warm.p95Ms));
expect(evaluateSpeedBudget({ adapter: "codex", environment: "local", metric: "time_to_first_activity", start: null, measuredMs: codexCold + 1 }))
.toBeNull();
});
it("does not judge remote runs or adapters without a baseline", () => {
expect(evaluateSpeedBudget({ adapter: "codex", environment: "remote", metric: "time_to_first_activity", start: "cold", measuredMs: 1e9 })).toBeNull();
expect(evaluateSpeedBudget({ adapter: null, environment: "local", metric: "time_to_first_activity", start: "cold", measuredMs: 1e9 })).toBeNull();
});
});
describe("speed budget checks in the native run trace", () => {
function traced(budget?: Parameters<typeof createNativeRunTrace>[0]["budget"]) {
const events: AdapterRuntimeEvent[] = [];
const trace = createNativeRunTrace({ runId: "run-1", startedAtMs: 0, budget,
onEvent: async event => { events.push(event); } });
const breaches = () => events.filter(event => event.eventType === SPEED_BUDGET_EXCEEDED_EVENT_TYPE)
.map(event => event.payload as Record<string, unknown>);
return { trace, breaches };
}
it("writes one run-log breach for a slow agent boot and none for a fast TTFT", async () => {
const { trace, breaches } = traced({ adapter: "codex", environment: "local" });
trace.setBudgetStart("cold");
await trace.record({ name: "heartbeat.queue", startedAtMs: 0, endedAtMs: 100 });
const boot = LOCAL_SPEED_BUDGETS.codex.agent_boot.cold.p95Ms + 500;
await trace.record({ name: "runner.turn.submit", startedAtMs: 100, endedAtMs: 100 + boot });
// A second submit in the same run is not a second boot.
await trace.record({ name: "runner.turn.submit", startedAtMs: 100, endedAtMs: 100 + boot * 2 });
await trace.record({ name: "task.time_to_first_agent_activity", startedAtMs: 0, endedAtMs: 1_000 });
await trace.finish("ok");
expect(breaches()).toEqual([expect.objectContaining({
schema: "paperclip.run-performance-budget.v1", metric: "agent_boot", adapter: "codex", startKind: "cold",
measuredMs: boot, budgetP95Ms: LOCAL_SPEED_BUDGETS.codex.agent_boot.cold.p95Ms,
})]);
});
it("writes a breach for a slow TTFT recorded by deferred diagnostics before the trace closes", async () => {
const { trace, breaches } = traced({ adapter: "claude", environment: "local" });
trace.setBudgetStart("warm");
const ttft = LOCAL_SPEED_BUDGETS.claude.time_to_first_activity.warm.p95Ms + 1;
trace.defer(async () => {
await trace.record({ name: "task.time_to_first_agent_activity", startedAtMs: 0, endedAtMs: ttft });
});
await trace.finish("ok");
expect(breaches()).toEqual([expect.objectContaining({ metric: "time_to_first_activity", adapter: "claude", startKind: "warm", measuredMs: ttft })]);
});
it("judges a follow-up's TTFT from run creation and reports the wait behind the live turn", async () => {
// A comment posted while the previous turn runs waits for a run to exist;
// the budget (idle follow-up baseline) judges the time after that.
const budget = LOCAL_SPEED_BUDGETS.claude.time_to_first_activity.warm.p95Ms;
const wait = 60_000;
const quiet = traced({ adapter: "claude", environment: "local" });
quiet.trace.setBudgetStart("warm");
await quiet.trace.record({ name: "comment.to_run_created", startedAtMs: 0, endedAtMs: wait });
await quiet.trace.record({ name: "heartbeat.queue", startedAtMs: wait, endedAtMs: wait + 100 });
await quiet.trace.record({ name: "task.time_to_first_agent_activity", startedAtMs: 0, endedAtMs: wait + budget - 1 });
await quiet.trace.finish("ok");
expect(quiet.breaches()).toEqual([]);
const slow = traced({ adapter: "claude", environment: "local" });
slow.trace.setBudgetStart("warm");
await slow.trace.record({ name: "comment.to_run_created", startedAtMs: 0, endedAtMs: wait });
await slow.trace.record({ name: "heartbeat.queue", startedAtMs: wait, endedAtMs: wait + 100 });
await slow.trace.record({ name: "task.time_to_first_agent_activity", startedAtMs: 0, endedAtMs: wait + budget + 1 });
await slow.trace.finish("ok");
expect(slow.breaches()).toEqual([expect.objectContaining({ metric: "time_to_first_activity", measuredMs: budget + 1, inputWaitMs: wait })]);
});
it("still judges queueing after run creation (admission, dispatch) inside the TTFT budget", async () => {
const { trace, breaches } = traced({ adapter: "codex", environment: "local" });
trace.setBudgetStart("cold");
const budget = LOCAL_SPEED_BUDGETS.codex.time_to_first_activity.cold.p95Ms;
await trace.record({ name: "heartbeat.queue", startedAtMs: 0, endedAtMs: budget });
await trace.record({ name: "task.time_to_first_agent_activity", startedAtMs: 0, endedAtMs: budget + 1 });
await trace.finish("ok");
expect(breaches()).toEqual([expect.objectContaining({ metric: "time_to_first_activity", measuredMs: budget + 1 })]);
expect(breaches()[0]).not.toHaveProperty("inputWaitMs");
});
it("judges first visible activity, not first text, so a tool-first turn is within budget", async () => {
const { trace, breaches } = traced({ adapter: "claude", environment: "local" });
trace.setBudgetStart("cold");
const budget = LOCAL_SPEED_BUDGETS.claude.time_to_first_activity.cold.p95Ms;
await trace.record({ name: "heartbeat.queue", startedAtMs: 0, endedAtMs: 50 });
// The agent calls a tool at once and writes its first text much later.
await trace.record({ name: "task.time_to_first_agent_activity", startedAtMs: 0, endedAtMs: 5_000 });
await trace.record({ name: "task.time_to_first_token", startedAtMs: 0, endedAtMs: budget * 2 });
await trace.finish("ok");
expect(breaches()).toEqual([]);
});
it("checks restart recovery latency once, from ready to the first provider event", async () => {
const { trace, breaches } = traced({ adapter: "opencode", environment: "local" });
const late = LOCAL_SPEED_BUDGETS.opencode.restart_recovery_control_plane.p95Ms + 1_000;
trace.checkRestartRecovery({ readyAtMs: 10_000, emittedAtMs: 10_000 + late, committedAtMs: 10_000 + late, disposition: "resume_dead_runner" });
trace.checkRestartRecovery({ readyAtMs: 10_000, emittedAtMs: 10_000 + late * 2, committedAtMs: 10_000 + late * 2, disposition: "resume_dead_runner" });
await trace.finish("ok");
expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: late,
disposition: "resume_dead_runner", startKind: "unknown" })]);
});
it("judges a restart-recovery dispatch only on recovery latency, not on TTFT or boot spanning the outage", async () => {
const { trace, breaches } = traced({ adapter: "codex", environment: "local", restartRecovery: true });
await trace.record({ name: "heartbeat.queue", startedAtMs: 0, endedAtMs: 1 });
await trace.record({ name: "runner.session.resume", startedAtMs: 1, endedAtMs: 120_000 });
await trace.record({ name: "task.time_to_first_agent_activity", startedAtMs: 0, endedAtMs: 120_000 });
const at = LOCAL_SPEED_BUDGETS.codex.restart_recovery_control_plane.p95Ms + 1;
trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: at, committedAtMs: at, disposition: "resume_dead_runner" });
await trace.finish("ok");
expect(breaches().map(breach => breach.metric)).toEqual(["restart_recovery_control_plane"]);
});
it("judges only Paperclip's share of a recovery, not the provider's session load", async () => {
const { trace, breaches } = traced({ adapter: "codex", environment: "local", restartRecovery: true });
const total = LOCAL_SPEED_BUDGETS.codex.restart_recovery.p95Ms + 3_000;
// Ready → claim, prepare and launch in 1.5 s; the provider reloads its long session.
await trace.record({ name: "provider.session.load", startedAtMs: 1_500, endedAtMs: total - 500 });
trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: total, committedAtMs: total, disposition: "resume_dead_runner" });
await trace.finish("ok");
expect(breaches()).toEqual([]);
});
it("reports a slow control-plane share with the full latency and the provider's share", async () => {
const { trace, breaches } = traced({ adapter: "codex", environment: "local", restartRecovery: true });
const control = LOCAL_SPEED_BUDGETS.codex.restart_recovery_control_plane.p95Ms + 2_000;
trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: control + 3_000, committedAtMs: control + 3_000, disposition: "resume_dead_runner" });
// The provider span is committed after the provider event that ended the recovery.
await trace.record({ name: "provider.session.load", startedAtMs: control, endedAtMs: control + 2_800 });
await trace.finish("ok");
expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: control,
restartTotalMs: control + 3_000, providerLoadMs: 3_000 })]);
});
it("flushes a pending recovery check when the controller detaches the run", async () => {
const { trace, breaches } = traced({ adapter: "codex", environment: "local", restartRecovery: true });
const late = LOCAL_SPEED_BUDGETS.codex.restart_recovery_control_plane.p95Ms + 1_000;
// A launched runner whose load span has not been written yet.
trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: late, committedAtMs: late, disposition: "resume_dead_runner" });
await new Promise(resolve => setTimeout(resolve, 0));
expect(breaches()).toEqual([]);
trace.flushRestartRecovery();
await new Promise(resolve => setTimeout(resolve, 0));
expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: late })]);
});
it("logs each span's own start time in its run-log row", async () => {
const events: AdapterRuntimeEvent[] = [];
const trace = createNativeRunTrace({ runId: "run-1", startedAtMs: 1_000, onEvent: async event => { events.push(event); } });
await trace.record({ name: "provider.session.load", startedAtMs: 4_000, endedAtMs: 9_000 });
await trace.finish("ok");
expect(events.find(event => (event.payload as Record<string, unknown>)?.span === "provider.session.load")?.payload)
.toMatchObject({ startOffsetMs: 3_000, durationMs: 5_000, wallStartedAtMs: 4_000 });
});
it("judges a recovery before the run finishes, so a later detach for restart keeps the warning", async () => {
const control = LOCAL_SPEED_BUDGETS.codex.restart_recovery_control_plane.p95Ms + 1_000;
// The load span arrives after the provider event: judged when it is recorded.
const resumed = traced({ adapter: "codex", environment: "local", restartRecovery: true });
resumed.trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: control + 2_000, committedAtMs: control + 2_000, disposition: "resume_dead_runner" });
await resumed.trace.record({ name: "provider.session.load", startedAtMs: control, endedAtMs: control + 1_900 });
// A reattached runner reloads nothing: judged at the check.
const reattached = traced({ adapter: "codex", environment: "local", restartRecovery: true });
reattached.trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: control, committedAtMs: control, disposition: "reattach_existing_runner" });
// Neither trace finishes (the controller detached for another restart).
await new Promise(resolve => setTimeout(resolve, 0));
expect(resumed.breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: control })]);
expect(reattached.breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: control, providerLoadMs: 0 })]);
});
it("does not spend the restart-recovery check on an event emitted before ready", async () => {
const { trace, breaches } = traced({ adapter: "codex", environment: "local", restartRecovery: true });
const late = LOCAL_SPEED_BUDGETS.codex.restart_recovery_control_plane.p95Ms + 1_000;
// Buffered during the outage, committed after ready.
trace.checkRestartRecovery(restartRecoveryMeasurementFor({ event: { eventType: "item.delta", emittedAt: new Date(9_000).toISOString() },
startup: { phase: "ready", updatedAt: new Date(10_000).toISOString() }, committedAtMs: 10_500, disposition: "reattach_existing_runner" })!);
trace.checkRestartRecovery(restartRecoveryMeasurementFor({ event: { eventType: "item.delta", emittedAt: new Date(10_000 + late - 200).toISOString() },
startup: { phase: "ready", updatedAt: new Date(10_000).toISOString() }, committedAtMs: 10_000 + late, disposition: "reattach_existing_runner" })!);
await trace.finish("ok");
expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: late })]);
});
it("measures a resumed attempt's boot from its own claim, not the run's first queue span", async () => {
const { trace, breaches } = traced({ adapter: "codex", environment: "local" });
trace.setBudgetStart("warm");
await trace.record({ name: "heartbeat.queue", startedAtMs: 0, endedAtMs: 100 });
// The attempt resumed ten minutes later and booted in one second.
await trace.record({ name: "heartbeat.prepare_before_environment", startedAtMs: 600_000, endedAtMs: 600_200 });
await trace.record({ name: "runner.session.resume", startedAtMs: 600_200, endedAtMs: 601_000 });
await trace.finish("ok");
expect(breaches()).toEqual([]);
});
it("counts only the baseline's provider events as a recovered run's next event", () => {
const startup = { phase: "ready", updatedAt: new Date(10_000).toISOString() };
const measure = (eventType: string) => restartRecoveryMeasurementFor({ event: { eventType, emittedAt: new Date(11_000).toISOString() },
startup, committedAtMs: 12_000, disposition: "resume_dead_runner" });
expect(measure("item.delta")).toEqual({ readyAtMs: 10_000, emittedAtMs: 11_000, committedAtMs: 12_000, disposition: "resume_dead_runner" });
expect(measure("harness.diagnostic")).toBeNull();
expect(measure("run.performance.span")).toBeNull();
expect(RESTART_RECOVERY_PROVIDER_EVENT_TYPES.has("session.resumed")).toBe(true);
});
it("measures to the commit time, so delivery delays after emission count", async () => {
const { trace, breaches } = traced({ adapter: "codex", environment: "local", restartRecovery: true });
const budget = LOCAL_SPEED_BUDGETS.codex.restart_recovery_control_plane.p95Ms;
// Emitted 1 s after ready, committed 20 s after ready.
trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: 1_000, committedAtMs: budget + 12_000, disposition: "resume_dead_runner" });
await trace.finish("ok");
expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: budget + 12_000 })]);
});
it("writes nothing for remote runs or without a budget subject", async () => {
for (const budget of [{ adapter: "codex" as const, environment: "remote" as const }, undefined]) {
const { trace, breaches } = traced(budget);
await trace.record({ name: "heartbeat.queue", startedAtMs: 0, endedAtMs: 1 });
await trace.record({ name: "runner.turn.submit", startedAtMs: 1, endedAtMs: 10_000_000 });
await trace.record({ name: "task.time_to_first_agent_activity", startedAtMs: 0, endedAtMs: 10_000_000 });
await trace.finish("ok");
expect(breaches()).toEqual([]);
}
});
});