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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-09 01:22:28 -06:00
1 parent 42bc238aa5
commit 6fd46c0025
11 files changed
+320 -36

No files matched your search

@@ -99,6 +99,12 @@ Restart recovery latency, from `ready` to the recovered run's next provider even
| codex | 6.5 / 8.0 |
| opencode | 25.5 / 38.5 |
Since 2026-11-09 this full latency is reported, not judged: the provider's saved-session load (`provider.session.load`) grows with session size and with how many sessions resume at once (Codex at concurrency 6: 3.0–6.3 s of a 5.5–8.5 s recovery). The judged row is Paperclip's own share, `restart_recovery_control_plane`: from `ready` to the start of the provider's session load, or to the next provider event when nothing reloads. It covers claim, prepare, runtime stage and runner launch, which measured 1.2–2.0 s per run with six Codex runs resuming at once (wave `w-20261109T045320-s9807`).
| Adapter | Paperclip share budget (s, p50 / p95) |
|---|---|
| claude, codex, opencode | 2.5 / 4.0 |
These budgets measure the product default `lifecycleMode: per_turn`. Phase 1's TTFT work (runner and provider-session reuse, baseline finding 1) should lower them. Lower budgets go in the same table.
### Phase 1 report-only data: local chaos waves (2026-11-07)
+15 -3
View File
@@ -64,6 +64,11 @@ instance run log and add no first-party Telemetry or OpenTelemetry export.
## Provider-pack preparation timing
Every `run.performance.span` payload also carries `wallStartedAtMs`, the span's own
start time in epoch milliseconds (since 2026-11-09). Span rows can be written late
through deferred diagnostics, so readers take a span's start from this field (or
from the trace start plus `startOffsetMs`), never from the row's write time.
`run.performance.span` also records `task.time_to_first_token` once per native
dispatch at the first newly committed deliverable text: assistant or reasoning
`item.delta`/`item.completed`, a redaction tail released at completion, or an
@@ -97,8 +102,15 @@ budgets come from the Phase 0 local baseline and are listed in
the first text delta, tool-call start or reasoning item; named `ttft` and
judged on `task.time_to_first_token` before 2026-11-08), `agent_boot` (end of
`heartbeat.queue` to the end of `runner.turn.submit` or `runner.session.resume`)
or `restart_recovery` (startup recovery `ready` to the recovered run's first
provider event). The payload names `adapter` (`codex`, `claude` or `opencode`),
or `restart_recovery_control_plane` (startup recovery `ready` to the start of the
provider's saved-session load, `provider.session.load`, or to the recovered run's
first provider event when nothing reloads). That is Paperclip's own share of a
recovery: claim, prepare, runtime stage and runner launch. The full recovery
(`ready` to the first provider event) and the provider's session load grow with
session size and with how many sessions resume at once, so they are reported in
the payload as `restartTotalMs` and `providerLoadMs` and are not judged; events
written before 2026-11-09 judged the full latency as `restart_recovery`.
The payload names `adapter` (`codex`, `claude` or `opencode`),
`environment` (`local`), `startKind` (`cold`, `warm` or `unknown`; an unknown
start uses the looser budget), `measuredMs`, `budgetP50Ms`, `budgetP95Ms`,
`percentile` (`p95`), `baseline`, and, for restart recovery, `disposition`.
@@ -106,7 +118,7 @@ For `time_to_first_activity`, `measuredMs` counts from run creation: when the in
run to exist (for example a comment posted while the previous turn was still
running), that wait is excluded from the judgment and reported as `inputWaitMs`.
Each metric is checked at most once per dispatch. A restart-recovery dispatch is
judged only on `restart_recovery`, because its first activity and boot span the outage. Time to first text (`task.time_to_first_token`) is reported without a budget. Remote runs and other adapters have
judged only on `restart_recovery_control_plane`, because its first activity and boot span the outage. Time to first text (`task.time_to_first_token`) is reported without a budget. Remote runs and other adapters have
no budget yet. The event is written in detached diagnostic work after the
measurement, so it adds no wait before `turn.start`. It is a run-log row only
and never goes to Telemetry.
@@ -262,11 +262,16 @@ function safeAttributes(
* seams inherit that context. The persisted run-event copy retains the same
* semantic parent name for developer installs without an OTLP collector.
*/
/** The runner span for the provider loading its saved session on resume. */
const PROVIDER_SESSION_LOAD_SPAN = "provider.session.load";
export function createNativeRunTrace(input: {
runId: string;
startedAtMs?: number;
onEvent?: NativeRunTraceSink;
traceContext?: StartupTraceContextHandle;
/** Called once when the trace finishes. */
onFinish?: () => void;
/** Speed-budget subject (invariant 8). Absent or remote means no check. */
budget?: {
adapter: SpeedBudgetAdapter | null;
@@ -279,6 +284,8 @@ export function createNativeRunTrace(input: {
const tracing =
input.traceContext ?? getStartupTraceContext("paperclip.native-runner");
const traceStartedAtMs = finiteMs(input.startedAtMs ?? Date.now());
/** Start times of the provider's saved-session loads (restart recovery split). */
const providerLoadStartsMs: number[] = [];
const runIdHash = hashedId(input.runId);
let rootSpan: SpanHandle;
try {
@@ -429,6 +436,9 @@ export function createNativeRunTrace(input: {
durationMs,
outcome,
...attributes,
// The span's own start time. The row can be written late (deferred
// diagnostics), so readers must not infer the start from its write time.
wallStartedAtMs: startedAtMs,
},
});
} catch {
@@ -447,6 +457,11 @@ export function createNativeRunTrace(input: {
} = {},
): NativeRunSpanScope => {
const startedAtMs = finiteMs(options.startedAtMs ?? Date.now());
if (name === PROVIDER_SESSION_LOAD_SPAN) {
providerLoadStartsMs.push(startedAtMs);
// The measurement can arrive before its provider span is recorded; judge now.
if (pendingRecovery) judgeRestartRecovery();
}
const attributes = safeAttributes(options.attributes);
const parent = parentFor(options.parentName);
let span: SpanHandle;
@@ -635,8 +650,13 @@ export function createNativeRunTrace(input: {
const finish = async (outcome: NativeRunSpanOutcome): Promise<void> => {
if (ended) return;
try { input.onFinish?.(); } catch { /* Diagnostics cannot change runner control flow. */ }
// Settled diagnostics can defer more (a budget breach after a deferred
// measurement), so drain until none remain.
// measurement), so drain until none remain. Judge restart recovery after
// the first drain, so a deferred provider span is counted, then drain its
// breach report.
while (pendingDiagnostics.size > 0) await Promise.all([...pendingDiagnostics]);
judgeRestartRecovery();
while (pendingDiagnostics.size > 0) await Promise.all([...pendingDiagnostics]);
if (ended) return;
ended = true;
@@ -666,8 +686,28 @@ export function createNativeRunTrace(input: {
};
/** Restart recovery latency: control plane ready to the recovered run's
* next provider event, checked once per run. */
* next provider event, checked once per run. Only Paperclip's share is
* judged: ready to the start of the provider's session load (or to the
* event when nothing reloads). It is judged as soon as both are known: at
* the check when the load span is already recorded or nothing reloads (a
* reattached runner, whose dispositions launch nothing), when the load span is recorded, and otherwise when
* the run finishes. A run that detaches for another restart therefore keeps
* its warning unless the detach lands between the event and a deferred span. */
let recoveryChecked = false;
let pendingRecovery: RestartRecoveryMeasurement | null = null;
const judgeRestartRecovery = (): void => {
const measurement = pendingRecovery;
pendingRecovery = null;
if (!input.budget || !measurement || measurement.readyAtMs === null) return;
const readyAtMs = measurement.readyAtMs;
const loads = providerLoadStartsMs.filter(at => at >= readyAtMs && at <= measurement.committedAtMs);
const loadStartedAtMs = loads.length ? Math.min(...loads) : null;
const totalMs = measurement.committedAtMs - readyAtMs;
reportBudgetBreach(evaluateSpeedBudget({ adapter: input.budget.adapter, environment: input.budget.environment,
metric: "restart_recovery_control_plane", start: null, measuredMs: (loadStartedAtMs ?? measurement.committedAtMs) - readyAtMs,
disposition: measurement.disposition, restartTotalMs: totalMs,
providerLoadMs: loadStartedAtMs === null ? 0 : measurement.committedAtMs - loadStartedAtMs }));
};
const checkRestartRecovery = (measurement: RestartRecoveryMeasurement): void => {
if (!input.budget || recoveryChecked || measurement.readyAtMs === null) return;
// An event emitted before ready (buffered by a surviving runner and
@@ -676,15 +716,20 @@ export function createNativeRunTrace(input: {
if (measurement.emittedAtMs !== null && measurement.emittedAtMs < measurement.readyAtMs) return;
if (measurement.committedAtMs < measurement.readyAtMs) return;
recoveryChecked = true;
reportBudgetBreach(evaluateSpeedBudget({ adapter: input.budget.adapter, environment: input.budget.environment,
metric: "restart_recovery", start: null, measuredMs: measurement.committedAtMs - measurement.readyAtMs,
disposition: measurement.disposition }));
pendingRecovery = measurement;
const readyAtMs = measurement.readyAtMs;
// Only a launched runner reloads a provider session (native-restart-recovery).
if (!["resume_dead_runner", "bootstrap_incomplete"].includes(measurement.disposition)
|| providerLoadStartsMs.some(at => at >= readyAtMs && at <= measurement.committedAtMs)) judgeRestartRecovery();
};
return {
traceStartedAtMs,
setBudgetStart,
checkRestartRecovery,
/** Judge a pending restart-recovery check now (the controller is detaching
* this run for another restart and will not finish the trace). */
flushRestartRecovery: () => judgeRestartRecovery(),
start,
annotate,
activate,
@@ -380,6 +380,9 @@ export class NativeControllerDetachedForRestartError extends Error {
const activeNativeSessions = new Map<string, ActiveNativeSession>();
// Shutdown can race provider startup before onSession publishes its handle.
const nativeRunsDetachingForRestart = new Map<string, symbol>();
/** Flushes a run's pending restart-recovery budget check when the controller
* detaches it for another restart (its trace never finishes). */
const nativeRunRecoveryFlushers = new Map<string, () => void>();
// A restart must not cut authority rotation between archiving the previous
// runner state and authenticating its replacement. Keep the startup owner
@@ -465,6 +468,7 @@ export async function detachNativeSessionsForRestart(
continue;
}
nativeRunsDetachingForRestart.set(runId, Symbol("native controller detach"));
try { nativeRunRecoveryFlushers.get(runId)?.(); } catch { /* Diagnostics cannot change detach. */ }
let active = activeNativeSessions.get(runId);
if (!active) {
// Only a startup registered before this fence may complete read-only
@@ -9592,6 +9596,24 @@ async function executePaperclipNativeSessionWithinScope(
prepareAfterClaim?: () => Promise<Parameters<typeof executePaperclipNativeSession>[0]>,
cleanupOnly = false,
ownDiagnosticLease?: (lease: NativeDiagnosticLease) => void,
): Promise<AdapterExecutionResult> {
// Drop this attempt's restart-recovery flush however it ends (a failure that
// never finishes its trace included), without touching a newer attempt's.
const runId = input.execution.binding.runId;
const priorFlush = nativeRunRecoveryFlushers.get(runId);
try {
return await executePaperclipNativeSessionAttempt(input, prepareAfterClaim, cleanupOnly, ownDiagnosticLease);
} finally {
const current = nativeRunRecoveryFlushers.get(runId);
if (current !== undefined && current !== priorFlush) nativeRunRecoveryFlushers.delete(runId);
}
}
async function executePaperclipNativeSessionAttempt(
input: Parameters<typeof executePaperclipNativeSession>[0],
prepareAfterClaim?: () => Promise<Parameters<typeof executePaperclipNativeSession>[0]>,
cleanupOnly = false,
ownDiagnosticLease?: (lease: NativeDiagnosticLease) => void,
): Promise<AdapterExecutionResult> {
if (input.execution.provider.kind === "acpx" && input.execution.provider.agent === "antigravity") {
const candidate = resolveAntigravityCandidateAdmission({
@@ -9649,16 +9671,25 @@ async function executePaperclipNativeSessionWithinScope(
preparationSpans,
Date.now(),
);
const traceRunId = input.execution.binding.runId;
const flushRecovery = () => {
if (nativeRunRecoveryFlushers.get(traceRunId) === flushRecovery) nativeRunRecoveryFlushers.delete(traceRunId);
trace.flushRestartRecovery();
};
const trace = createNativeRunTrace({
runId: input.execution.binding.runId,
runId: traceRunId,
startedAtMs: preparationStarts.runStartedAtMs,
onEvent: input.onEvent,
onFinish: () => {
if (nativeRunRecoveryFlushers.get(traceRunId) === flushRecovery) nativeRunRecoveryFlushers.delete(traceRunId);
},
budget: {
adapter: speedBudgetAdapter(input.execution.provider),
environment: !input.runnerExecutionTarget || input.runnerExecutionTarget.kind === "local" ? "local" : "remote",
restartRecovery: Boolean(input.restartRecovery),
},
});
nativeRunRecoveryFlushers.set(traceRunId, flushRecovery);
await emitStartupReuse(input.onEvent, "environment", environmentStartupReuse(input.runnerExecutionTarget));
if (!input.runnerExecutionTarget || input.runnerExecutionTarget.kind === "local") {
await emitStartupReuse(input.onEvent, "workspace", { state: "not_applicable", reason: "not_applicable" });
@@ -134,12 +134,12 @@ describe("speed budget checks in the native run trace", () => {
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.p95Ms + 1_000;
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", measuredMs: late,
expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: late,
disposition: "resume_dead_runner", startKind: "unknown" })]);
});
@@ -148,22 +148,79 @@ describe("speed budget checks in the native run trace", () => {
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.p95Ms + 1;
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"]);
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.p95Ms + 1_000;
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", measuredMs: late })]);
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 () => {
@@ -189,11 +246,11 @@ describe("speed budget checks in the native run trace", () => {
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.p95Ms;
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", measuredMs: budget + 12_000 })]);
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 () => {
@@ -22,7 +22,16 @@ export type SpeedBudgetAdapter = "claude" | "codex" | "opencode";
* 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.
*/
export type SpeedBudgetMetric = "time_to_first_activity" | "agent_boot" | "restart_recovery";
/**
* `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 };
@@ -43,22 +52,29 @@ const s = (seconds: number) => Math.round(seconds * 1000);
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) },
},
};
@@ -77,6 +93,7 @@ export function speedBudgetFor(adapter: SpeedBudgetAdapter, metric: SpeedBudgetM
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) };
@@ -98,6 +115,10 @@ export type SpeedBudgetBreach = {
/** 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. */
@@ -109,6 +130,8 @@ export function evaluateSpeedBudget(input: {
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);
@@ -118,6 +141,8 @@ export function evaluateSpeedBudget(input: {
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) } : {}),
};
}
@@ -119,12 +119,44 @@ describe("ttft baseline statistics", () => {
]);
const silent = run("r2", "restart", []);
expect(recoverySamples([recovered, silent], [{ kind: "K1", readyAt, runIds: ["r1", "r2", "missing"] }])).toEqual([
{ runId: "r1", adapter: "codex", kind: "K1", disposition: "resume_dead_runner", readyAt, latencyMs: 3_250 },
{ runId: "r2", adapter: "codex", kind: "K1", disposition: "none", readyAt, latencyMs: null },
{ runId: "r1", adapter: "codex", kind: "K1", disposition: "resume_dead_runner", readyAt, latencyMs: 3_250, controlPlaneMs: 3_250, providerLoadMs: 0 },
{ runId: "r2", adapter: "codex", kind: "K1", disposition: "none", readyAt, latencyMs: null, controlPlaneMs: null, providerLoadMs: null },
]);
});
});
describe("restart recovery split", () => {
// The trace starts at the epoch; the queue span is written promptly and anchors it.
const queue = { eventType: "run.performance.span", createdAt: at(100), payload: { span: "heartbeat.queue", startOffsetMs: 0, durationMs: 100 } };
const load = (writtenAt: number) => ({ eventType: "run.performance.span", createdAt: at(writtenAt),
payload: { span: "provider.session.load", startOffsetMs: 11_500, durationMs: 4_500 } });
it("splits a recovery at the start of the provider's session load", () => {
const readyAt = Date.parse(at(10_000));
const recovered = run("r1", "restart", [queue, load(16_000), { eventType: "session.resumed", createdAt: at(16_400) }]);
expect(recoverySamples([recovered], [{ kind: "K2", readyAt, runIds: ["r1"] }])).toEqual([
{ runId: "r1", adapter: "codex", kind: "K2", disposition: "none", readyAt, latencyMs: 6_400, controlPlaneMs: 1_500, providerLoadMs: 4_900 },
]);
});
it("uses the start the server logged even when every span row was written late", () => {
const readyAt = Date.parse(at(10_000));
// All rows deferred by 8 s, so no write time can anchor the trace.
const late = (span: string, offset: number, duration: number) => ({ eventType: "run.performance.span", createdAt: at(offset + duration + 8_000),
payload: { span, startOffsetMs: offset, durationMs: duration, wallStartedAtMs: Date.parse(at(offset)) } });
const recovered = run("r1", "restart", [late("heartbeat.queue", 0, 100), { eventType: "session.resumed", createdAt: at(16_400) },
late("provider.session.load", 11_500, 4_500)]);
expect(recoverySamples([recovered], [{ kind: "K2", readyAt, runIds: ["r1"] }])[0]).toMatchObject({ controlPlaneMs: 1_500, providerLoadMs: 4_900 });
});
it("takes the load start from the span's offset, not its (possibly deferred) write time", () => {
const readyAt = Date.parse(at(10_000));
// The load span's run-log row is written 5 s late, after the provider event.
const recovered = run("r1", "restart", [queue, { eventType: "session.resumed", createdAt: at(16_400) }, load(21_000)]);
expect(recoverySamples([recovered], [{ kind: "K2", readyAt, runIds: ["r1"] }])[0]).toMatchObject({ controlPlaneMs: 1_500, providerLoadMs: 4_900 });
});
});
describe("restart sample guard", () => {
const tool = (eventType: string, executionId: string, extra: Record<string, unknown> = {}): BaselineEvent => ({
eventType, createdAt: at(0), payload: { prpEvent: { payload: { executionId, ...extra } } },
+45 -2
View File
@@ -132,9 +132,42 @@ export function runMetrics(run: BaselineRun): RunMetrics {
const PROVIDER_EVENT_TYPES = 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"]);
export interface RecoverySample { runId: string; adapter: string; kind: string; disposition: string; readyAt: number; latencyMs: number | null }
export interface RecoverySample { runId: string; adapter: string; kind: string; disposition: string; readyAt: number; latencyMs: number | null;
/** Paperclip's share: ready to the start of the provider's session load (`provider.session.load`), or the whole
* latency when nothing reloads. The judged row. */
controlPlaneMs: number | null;
/** The provider's share: session load start to the next provider event. Reported. */
providerLoadMs: number | null }
/** Control plane ready → the recovered run's next provider event, keyed by runner disposition. */
/** The run's trace start: every span records its offset from it. A span event is
* written when the span ends, possibly through deferred work, so each one gives
* an upper bound (write time minus offset and duration); the earliest is used. */
export function spanTraceStartMs(run: BaselineRun): number | null {
let earliest: number | null = null;
for (const event of run.events) {
if (event.eventType !== "run.performance.span" || !event.payload) continue;
const candidate = Date.parse(event.createdAt) - (Number(event.payload.startOffsetMs) + Number(event.payload.durationMs));
if (Number.isFinite(candidate) && (earliest === null || candidate < earliest)) earliest = candidate;
}
return earliest;
}
/** Stamps each provider session load with its wall-clock start (`wallStartMs`).
* The server writes the span's own start as `wallStartedAtMs`; runs logged before
* that field existed fall back to the trace start plus the span's offset, never to
* its write time. Call on the raw run, before outage removal rewrites offsets. */
export function stampProviderLoadStarts(run: BaselineRun): BaselineRun {
const anchor = spanTraceStartMs(run);
return { ...run, events: run.events.map(event => {
if (event.eventType !== "run.performance.span" || event.payload?.span !== "provider.session.load" || event.payload.wallStartMs !== undefined) return event;
const logged = Number(event.payload.wallStartedAtMs);
const offset = Number(event.payload.startOffsetMs);
const start = Number.isFinite(logged) ? logged : anchor !== null && Number.isFinite(offset) ? anchor + offset : null;
return start === null ? event : { ...event, payload: { ...event.payload, wallStartMs: start } };
}) };
}
export function recoverySamples(runs: readonly BaselineRun[], faults: readonly BaselineFault[]): RecoverySample[] {
const byId = new Map(runs.map(run => [run.runId, run]));
const samples: RecoverySample[] = [];
@@ -146,9 +179,19 @@ export function recoverySamples(runs: readonly BaselineRun[], faults: readonly B
const transition = after.find(event => event.eventType === "native.recovery.transition"
&& Date.parse(event.createdAt) >= fault.readyAt - 60_000);
const next = run.events.find(event => PROVIDER_EVENT_TYPES.has(event.eventType) && Date.parse(event.createdAt) >= fault.readyAt);
const nextAt = next ? Date.parse(next.createdAt) : null;
const stamped = stampProviderLoadStarts(run);
const loadStarts = nextAt === null ? [] : stamped.events.flatMap(event => {
if (event.eventType !== "run.performance.span" || event.payload?.span !== "provider.session.load") return [];
const start = Number(event.payload.wallStartMs);
return Number.isFinite(start) && start >= fault.readyAt && start <= nextAt ? [start] : [];
});
const loadStart = loadStarts.length ? Math.min(...loadStarts) : null;
samples.push({ runId, adapter: run.adapter, kind: fault.kind, readyAt: fault.readyAt,
disposition: String(transition?.payload?.runnerDisposition ?? "none"),
latencyMs: next ? Date.parse(next.createdAt) - fault.readyAt : null });
latencyMs: nextAt === null ? null : nextAt - fault.readyAt,
controlPlaneMs: nextAt === null ? null : (loadStart ?? nextAt) - fault.readyAt,
providerLoadMs: nextAt === null ? null : loadStart === null ? 0 : nextAt - loadStart });
}
}
return samples;
+3 -2
View File
@@ -204,7 +204,7 @@ function report(runs: BaselineRun[], faults: BaselineFault[]): string {
lines.push("", "| Pre-turn step | n | p50 | p95 | Adapters |", "|---|---|---|---|---|");
for (const step of rankPreTurnSteps(runs).slice(0, 25)) lines.push(`| \`${step.step}\` | ${step.n} | ${ms(step.p50)} | ${ms(step.p95)} | ${step.adapters.join(", ")} |`);
const samples = recoverySamples(runs, faults);
lines.push("", "| Adapter | Fault | Disposition | n | Recovery p50 | Recovery p95 | No provider event |", "|---|---|---|---|---|---|---|");
lines.push("", "| Adapter | Fault | Disposition | n | Recovery p50 | Recovery p95 | Paperclip share p50 / p95 | No provider event |", "|---|---|---|---|---|---|---|---|");
const groups = new Map<string, typeof samples>();
for (const sample of samples) {
const key = `${sample.adapter}|${sample.kind}|${sample.disposition}`;
@@ -213,7 +213,8 @@ function report(runs: BaselineRun[], faults: BaselineFault[]): string {
for (const [key, group] of [...groups.entries()].sort()) {
const [adapter, kind, disposition] = key.split("|");
const d = distribution(group.flatMap(sample => sample.latencyMs === null ? [] : [sample.latencyMs]));
lines.push(`| ${adapter} | ${kind} | ${disposition} | ${d.n} | ${ms(d.p50)} | ${ms(d.p95)} | ${group.length - d.n} |`);
const control = distribution(group.flatMap(sample => sample.controlPlaneMs === null ? [] : [sample.controlPlaneMs]));
lines.push(`| ${adapter} | ${kind} | ${disposition} | ${d.n} | ${ms(d.p50)} | ${ms(d.p95)} | ${ms(control.p50)} / ${ms(control.p95)} | ${group.length - d.n} |`);
}
return `${lines.join("\n")}\n`;
}
+18 -2
View File
@@ -45,7 +45,23 @@ describe("wave speed grade", () => {
const recovered: BaselineRun = { runId: "r", adapter: "claude", environment: "local", phase: "cold",
events: [{ eventType: "turn.started", createdAt: new Date(10_000 + 30_000).toISOString() }] };
const speed = gradeWaveSpeed("local", [recovered], [{ kind: "K1", readyAt: 10_000, runIds: ["r"] }], 0);
expect(speed.rows.find(row => row.metric === "restart_recovery")).toMatchObject({ adapter: "claude", breaches: ["p50", "p95"] });
// Nothing reloaded, so the whole latency is Paperclip's share and is judged.
expect(speed.rows.find(row => row.metric === "restart_recovery_control_plane")).toMatchObject({ adapter: "claude", breaches: ["p50", "p95"] });
expect(speed.rows.find(row => row.metric === "restart_recovery")).toMatchObject({ budget: null, breaches: [], measured: { p50: 30_000 } });
});
it("judges Paperclip's share of a recovery and only reports the provider's session load", () => {
const loadEnd = 10_000 + 1_500 + 9_000;
const recovered: BaselineRun = { runId: "r", adapter: "codex", environment: "local", phase: "cold", events: [
{ eventType: "run.performance.span", createdAt: new Date(100).toISOString(), payload: { span: "heartbeat.queue", startOffsetMs: 0, durationMs: 100 } },
{ eventType: "run.performance.span", createdAt: new Date(loadEnd).toISOString(),
payload: { span: "provider.session.load", startOffsetMs: 11_500, durationMs: 9_000 } },
{ eventType: "session.resumed", createdAt: new Date(loadEnd + 300).toISOString() }] };
const speed = gradeWaveSpeed("local", [recovered], [{ kind: "K2", readyAt: 10_000, runIds: ["r"] }], 0);
expect(speed.rows.find(row => row.metric === "restart_recovery_control_plane")).toMatchObject({ measured: { p50: 1_500 }, breaches: [] });
expect(speed.rows.find(row => row.metric === "restart_provider_load")).toMatchObject({ measured: { p50: 9_300 }, budget: null, breaches: [] });
expect(speed.rows.find(row => row.metric === "restart_recovery")).toMatchObject({ measured: { p50: 10_800 }, budget: null });
expect(speed.breaches).toBe(0);
});
it("removes a later outage from restart recovery", () => {
@@ -54,7 +70,7 @@ describe("wave speed grade", () => {
const windows = outageWindows([{ startedAt: 12_000, readyAt: 312_000 } as FaultReceipt]);
const speed = gradeWaveSpeed("local", [recovered], [{ kind: "K1", readyAt: 10_000, runIds: ["r"] }], 0, [],
{ adjusted: 0, removedMs: 0, windows });
expect(speed.rows.find(row => row.metric === "restart_recovery")).toMatchObject({ measured: { p50: 2_000 }, breaches: [] });
expect(speed.rows.find(row => row.metric === "restart_recovery_control_plane")).toMatchObject({ measured: { p50: 2_000 }, breaches: [] });
});
it("has no budget for remote environments or unbudgeted adapters", () => {
+26 -10
View File
@@ -1,7 +1,7 @@
import { LOCAL_SPEED_BUDGETS, type SpeedBudget, type SpeedBudgetAdapter } from "../../server/src/services/native-runtime/native-speed-budgets.js";
import type { ChaosApi } from "./api.js";
import {
baselineRows, distribution, recoverySamples, runMetrics, runSpans, runWarmth,
baselineRows, distribution, recoverySamples, runMetrics, runSpans, runWarmth, stampProviderLoadStarts,
type BaselineEvent, type BaselineFault, type BaselineRun, type Distribution,
} from "./ttft-baseline-stats.js";
import type { AdapterId, EnvironmentKind, FaultReceipt, RunSample } from "./types.js";
@@ -11,7 +11,8 @@ type Json = Record<string, unknown>;
export interface WaveSpeedTask { issueId: string | null; adapter: AdapterId; samples: Array<{ runs?: RunSample[] }> }
export interface SpeedRow {
adapter: string; metric: "time_to_first_activity" | "ttft" | "agent_boot" | "workspace_provision" | "restart_recovery"; start: "cold" | "warm" | "restart";
adapter: string; metric: "time_to_first_activity" | "ttft" | "agent_boot" | "workspace_provision" | "restart_recovery"
| "restart_recovery_control_plane" | "restart_provider_load"; start: "cold" | "warm" | "restart";
measured: Distribution; budget: SpeedBudget | null; breaches: Array<"p50" | "p95">;
}
@@ -33,9 +34,11 @@ export function budgetFor(environment: EnvironmentKind, adapter: string, metric:
start: SpeedRow["start"]): SpeedBudget | null {
const key = BUDGET_ADAPTER[adapter as AdapterId];
if (environment !== "local" || !key) return null;
if (metric === "ttft" || metric === "workspace_provision") return null;
// Reported without a budget: the full recovery and the provider's session load
// grow with session size and simultaneous resumes; Paperclip's share is judged.
if (metric === "ttft" || metric === "workspace_provision" || metric === "restart_recovery" || metric === "restart_provider_load") return null;
const budgets = LOCAL_SPEED_BUDGETS[key] as unknown as Record<string, Record<"cold" | "warm", SpeedBudget> | SpeedBudget>;
if (metric === "restart_recovery") return budgets.restart_recovery as SpeedBudget;
if (metric === "restart_recovery_control_plane") return budgets.restart_recovery_control_plane as SpeedBudget;
const byStart = (metric === "time_to_first_activity" ? budgets.time_to_first_activity ?? budgets.ttft : budgets[metric]) as
Record<"cold" | "warm", SpeedBudget>;
return byStart[start === "warm" ? "warm" : "cold"];
@@ -180,12 +183,24 @@ export function gradeWaveSpeed(environment: EnvironmentKind, runs: readonly Base
}
}
const clock = outageFreeClock(outage.windows ?? []);
const samples = recoverySamples(runs, faults).map(sample => sample.latencyMs === null ? sample
: { ...sample, latencyMs: Math.max(0, clock(sample.readyAt + sample.latencyMs) - clock(sample.readyAt)) });
const outageFree = (readyAt: number, ms: number | null) => ms === null ? null : Math.max(0, clock(readyAt + ms) - clock(readyAt));
const samples = recoverySamples(runs, faults).map(sample => sample.latencyMs === null ? sample : {
...sample, latencyMs: outageFree(sample.readyAt, sample.latencyMs),
controlPlaneMs: outageFree(sample.readyAt, sample.controlPlaneMs),
providerLoadMs: sample.providerLoadMs === null || sample.controlPlaneMs === null ? sample.providerLoadMs
: Math.max(0, clock(sample.readyAt + sample.controlPlaneMs + sample.providerLoadMs) - clock(sample.readyAt + sample.controlPlaneMs)),
});
for (const adapter of [...new Set(samples.map(sample => sample.adapter))].sort()) {
const measured = distribution(samples.flatMap(sample => sample.adapter === adapter && sample.latencyMs !== null ? [sample.latencyMs] : []));
const budget = budgetFor(environment, adapter, "restart_recovery", "restart");
rows.push({ adapter, metric: "restart_recovery", start: "restart", measured, budget, breaches: breachesOf(measured, budget) });
for (const [metric, pick] of [["restart_recovery", (sample: typeof samples[number]) => sample.latencyMs],
["restart_recovery_control_plane", (sample: typeof samples[number]) => sample.controlPlaneMs],
["restart_provider_load", (sample: typeof samples[number]) => sample.providerLoadMs]] as const) {
const measured = distribution(samples.flatMap(sample => {
const value = sample.adapter === adapter ? pick(sample) : null;
return value === null ? [] : [value];
}));
const budget = budgetFor(environment, adapter, metric, "restart");
rows.push({ adapter, metric, start: "restart", measured, budget, breaches: breachesOf(measured, budget) });
}
}
const singleRunBreaches = runs.filter(run => run.phase !== "restart" && run.phase !== "verify").filter(run => {
const start = runWarmth(run);
@@ -248,7 +263,8 @@ export async function collectWaveSpeed(api: ChaosApi, environment: EnvironmentKi
}
if (run.retryOfRunId && faultHit.has(run.retryOfRunId)) raw.phase = "restart";
if (faultBeforeFirstActivity(raw, windows, Date.parse(run.createdAt!))) raw.phase = "restart";
const trimmed = withoutOutage(raw, windows);
// Stamp provider load starts from raw offsets before outage removal rewrites them.
const trimmed = withoutOutage(stampProviderLoadStarts(raw), windows);
outage.adjusted += trimmed.adjusted; outage.removedMs += trimmed.removedMs;
runs.push(trimmed.run);
timing.push({ id: run.id, createdAt: Date.parse(run.createdAt!), finishedAt: run.finishedAt ? Date.parse(run.finishedAt) : null });