Files
PaperClipAI/server/src/services/native-runtime/native-speed-budgets.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

168 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Speed budgets for native runs (plan invariant 8, Phase 1).
*
* Source: the Phase 0 local baseline (doc/plans/2026-11-07-ttft-baseline.md,
* merged in #19846: TTFT and boot from runs 1 and 3, restart recovery from
* run 4; Apple M5 Max, shared host). Each
* budget is the baseline value times 1.2, rounded up to 0.5 s. The table is
* recorded in doc/plans/2026-11-07-durable-ownership-and-reconciliation.md.
*
* A single run is checked against the p95 budget; p50 budgets apply to
* aggregates (chaos waves, the baseline driver). Remote environments have no
* budget until a Daytona baseline exists. Breaches go to the run log only,
* never to Telemetry (AGENTS.md §5.7).
*/
export type SpeedBudgetAdapter = "claude" | "codex" | "opencode";
/**
* `time_to_first_activity`: run creation to the first agent activity the UI
* shows (a text delta, a tool-call start or a reasoning indicator, whichever
* comes first; span `task.time_to_first_agent_activity`). That is when the user
* sees the agent respond. Time to first text (`task.time_to_first_token`) stays
* a reported metric without a budget: tool-first adapters such as Claude often
* call tools before they write text. The budget numbers are the TTFT baseline's.
*/
/**
* `restart_recovery_control_plane`: control plane ready to the moment the
* recovered run's provider starts loading its saved session
* (`provider.session.load`), or to the run's next provider event when nothing
* reloads (a reattached runner). That is Paperclip's own share of a recovery:
* claim, prepare, runtime stage and runner launch. The provider's session load
* grows with session size and with how many sessions resume at once, so the
* full `restart_recovery` latency is reported without a budget.
*/
export type SpeedBudgetMetric = "time_to_first_activity" | "agent_boot" | "restart_recovery" | "restart_recovery_control_plane";
export type SpeedBudgetStart = "cold" | "warm";
export type SpeedBudget = { p50Ms: number; p95Ms: number };
export const SPEED_BUDGET_BASELINE = "2026-11-07-ttft-baseline (local)";
export const SPEED_BUDGET_EXCEEDED_EVENT_TYPE = "run.performance.budget_exceeded";
/** The provider events the baseline counts as a recovered run's next event
* (tests/restart-chaos/ttft-baseline-stats.ts). Diagnostics do not count. */
export const RESTART_RECOVERY_PROVIDER_EVENT_TYPES: ReadonlySet<string> = new Set([
"turn.submitted", "turn.started", "turn.accepted", "item.started", "item.delta", "item.completed",
"tool.execution.started", "tool.execution.progressed", "tool.execution.completed", "session.resumed",
]);
const s = (seconds: number) => Math.round(seconds * 1000);
/** First activity and agent boot by adapter and start kind; restart recovery by adapter. */
export const LOCAL_SPEED_BUDGETS: Readonly<Record<SpeedBudgetAdapter, {
time_to_first_activity: Record<SpeedBudgetStart, SpeedBudget>;
agent_boot: Record<SpeedBudgetStart, SpeedBudget>;
/** Full recovery (ready → next provider event). Reported, not judged. */
restart_recovery: SpeedBudget;
/** Paperclip's share of a recovery; judged. Measured 1.2–2.0 s per run with six
* Codex runs resuming at once (wave w-20261109T045320-s9807, local). */
restart_recovery_control_plane: SpeedBudget;
}>> = {
claude: {
time_to_first_activity: { cold: { p50Ms: s(16.5), p95Ms: s(30) }, warm: { p50Ms: s(9.5), p95Ms: s(28.5) } },
agent_boot: { cold: { p50Ms: s(9), p95Ms: s(20.5) }, warm: { p50Ms: s(3.5), p95Ms: s(16.5) } },
restart_recovery: { p50Ms: s(15), p95Ms: s(22.5) },
restart_recovery_control_plane: { p50Ms: s(2.5), p95Ms: s(4) },
},
codex: {
time_to_first_activity: { cold: { p50Ms: s(6), p95Ms: s(17) }, warm: { p50Ms: s(10), p95Ms: s(28.5) } },
agent_boot: { cold: { p50Ms: s(2.5), p95Ms: s(7) }, warm: { p50Ms: s(5), p95Ms: s(16.5) } },
restart_recovery: { p50Ms: s(6.5), p95Ms: s(8) },
restart_recovery_control_plane: { p50Ms: s(2.5), p95Ms: s(4) },
},
opencode: {
time_to_first_activity: { cold: { p50Ms: s(17.5), p95Ms: s(49) }, warm: { p50Ms: s(26.5), p95Ms: s(88) } },
agent_boot: { cold: { p50Ms: s(12), p95Ms: s(37) }, warm: { p50Ms: s(22), p95Ms: s(65.5) } },
restart_recovery: { p50Ms: s(25.5), p95Ms: s(38.5) },
restart_recovery_control_plane: { p50Ms: s(2.5), p95Ms: s(4) },
},
};
/** Budgeted adapter of a native execution provider, or null (no budget). */
export function speedBudgetAdapter(provider: { kind: string; agent?: unknown } | null | undefined): SpeedBudgetAdapter | null {
if (!provider) return null;
if (provider.kind === "codex") return "codex";
if (provider.kind === "opencode") return "opencode";
if (provider.kind === "acpx" && provider.agent === "claude") return "claude";
return null;
}
/** The budget for one measurement. An unknown start kind uses the looser of
* the cold and warm budgets, so an unlabelled run is never over-reported. */
export function speedBudgetFor(adapter: SpeedBudgetAdapter, metric: SpeedBudgetMetric,
start: SpeedBudgetStart | null): SpeedBudget {
const budgets = LOCAL_SPEED_BUDGETS[adapter];
if (metric === "restart_recovery") return budgets.restart_recovery;
if (metric === "restart_recovery_control_plane") return budgets.restart_recovery_control_plane;
const byStart = budgets[metric];
if (start) return byStart[start];
return { p50Ms: Math.max(byStart.cold.p50Ms, byStart.warm.p50Ms), p95Ms: Math.max(byStart.cold.p95Ms, byStart.warm.p95Ms) };
}
export type SpeedBudgetBreach = {
schema: "paperclip.run-performance-budget.v1";
metric: SpeedBudgetMetric;
adapter: SpeedBudgetAdapter;
environment: "local";
startKind: SpeedBudgetStart | "unknown";
measuredMs: number;
budgetP50Ms: number;
budgetP95Ms: number;
/** Single runs are judged against p95. */
percentile: "p95";
baseline: string;
disposition?: string;
/** First activity only: time the input waited for its run to exist (for example a
* comment posted while the previous turn was running). Not judged. */
inputWaitMs?: number;
/** Control-plane recovery only: the full ready → next provider event latency
* and the provider's share of it (session load onwards). Not judged. */
restartTotalMs?: number;
providerLoadMs?: number;
};
/** A breach when one measured run exceeds its p95 budget, else null. */
export function evaluateSpeedBudget(input: {
adapter: SpeedBudgetAdapter | null;
environment: "local" | "remote";
metric: SpeedBudgetMetric;
start: SpeedBudgetStart | null;
measuredMs: number;
disposition?: string;
inputWaitMs?: number;
restartTotalMs?: number;
providerLoadMs?: number;
}): SpeedBudgetBreach | null {
if (!input.adapter || input.environment !== "local" || !Number.isFinite(input.measuredMs) || input.measuredMs < 0) return null;
const budget = speedBudgetFor(input.adapter, input.metric, input.start);
if (input.measuredMs <= budget.p95Ms) return null;
return {
schema: "paperclip.run-performance-budget.v1", metric: input.metric, adapter: input.adapter, environment: "local",
startKind: input.start ?? "unknown", measuredMs: Math.round(input.measuredMs), budgetP50Ms: budget.p50Ms,
budgetP95Ms: budget.p95Ms, percentile: "p95", baseline: SPEED_BUDGET_BASELINE,
...(input.disposition ? { disposition: input.disposition } : {}),
...(input.restartTotalMs !== undefined ? { restartTotalMs: Math.round(input.restartTotalMs) } : {}),
...(input.providerLoadMs !== undefined ? { providerLoadMs: Math.round(input.providerLoadMs) } : {}),
...(input.inputWaitMs ? { inputWaitMs: Math.round(input.inputWaitMs) } : {}),
};
}
/** The restart-recovery measurement for one committed runner event, or null
* when the event is not a provider event (the baseline's set). */
export function restartRecoveryMeasurementFor(input: {
event: { eventType: string; emittedAt?: string | null };
startup: { phase: string; updatedAt: string };
committedAtMs: number;
disposition: string;
}): { readyAtMs: number | null; emittedAtMs: number | null; committedAtMs: number; disposition: string } | null {
if (!RESTART_RECOVERY_PROVIDER_EVENT_TYPES.has(input.event.eventType)) return null;
const emitted = Date.parse(input.event.emittedAt ?? "");
const ready = Date.parse(input.startup.updatedAt);
return {
readyAtMs: input.startup.phase === "ready" && Number.isFinite(ready) ? ready : null,
emittedAtMs: Number.isFinite(emitted) ? emitted : null,
committedAtMs: input.committedAtMs,
disposition: input.disposition,
};
}