Files
PaperClipAI/server/src/services/native-runtime/native-speed-budgets.test.ts
T
DottaandClaude Opus 5.5 b32ef9f111 perf(budgets): judge time to first visible agent activity; keep time to first text as a reported metric (#20077)
## Thinking Path

- The native speed budgets (plan invariant 8) judge how fast an agent
responds. They judged `task.time_to_first_token`: time to the first
deliverable **text**.
- In the clean local restart storm (w-20261108T153612-s9401), Claude
breached its TTFT budget (cold p50/p95 17.4/34.7 s against 16.5/30). The
per-run spans showed this was not a control-plane wait: in all 49 cold
runs (and 76% of warm), Claude started a tool call before writing any
text. Its first visible activity came at 7.4 s p50 from run creation;
first text came at 19.6 s. Codex never calls a tool first.
- So first text charges model tool work as latency for tool-first
adapters. What the user experiences as "the agent responded" is the
first visible activity: a text delta, a tool-call start, or a thinking
indicator.
- The coordinator decided: budgets judge time to first visible agent
activity from run creation, with the budget numbers unchanged; first
text stays a reported metric with no budget for tool-first adapters.

## Linked Issues or Issue Description

**What happened?**
Local storm s9401: the Claude TTFT budget breached on every aggregate,
while the control-plane steps (queue 0.05 s, prepare 0.3 s, runner
startup ~4 s, first provider event 1.3–1.8 s) were well within budget.
The rest of the first-text wait was Claude streaming tool input (about
5.5 s per call) and running tools before it wrote text.

**Steps to reproduce**
1. Run a Claude task whose first action is a tool call (most real
tasks).
2. Read the run log: `provider.time_to_first_agent_event` ends at the
tool call; `task.time_to_first_token` ends many seconds later at the
first text.
3. The speed budget judges the later one and reports a breach.

**Expected behavior**
The budget judges the first visible agent activity from run creation
(`task.time_to_first_agent_activity`). Time to first text is still
recorded and reported.

## What Changed

- `server/src/services/native-runtime/native-session-executor.ts`: when
the first meaningful agent event commits (the existing
`firstMeaningfulAgentEventKind`: text delta, tool-call start, or
reasoning), record `task.time_to_first_agent_activity`. It uses the same
detached anchor resolution as TTFT (accepted comment, answered question,
durable wake request, or run creation), so it adds nothing to the turn
path.
- `server/src/services/native-runtime/native-speed-budgets.ts`: the
metric is now named `time_to_first_activity` (was `ttft`), with
identical budget numbers. The type comment documents the definition and
why first text is unbudgeted.
- `server/src/services/native-runtime/native-run-trace.ts`: the budget
check judges `task.time_to_first_agent_activity` from run creation,
keeping the existing input-wait handling. `task.time_to_first_token` is
no longer budgeted.
- `tests/restart-chaos/ttft-baseline-stats.ts` and `ttft-baseline.ts`:
report first activity from run creation (the budgeted value) next to
TTFT.
- Docs: `doc/observability.md` (new span) and
`doc/plans/2026-11-07-ttft-baseline.md` (metric table).
- Tests:
- A tool-first turn records first activity at the tool call (5 s) while
TTFT records first text (16 s).
- A tool-first trace with slow first text but fast first activity is
within budget.
  - The existing budget tests now judge the activity span.

Observability path (AGENTS.md section 5.7): the new run-log span uses
only the existing `eventKind` and `anchor` attributes. Its name survives
run-log redaction intact; checked with `redactEventPayload`. The
operator OTLP gate is unchanged. It is not Telemetry.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-speed-budgets.test.ts
src/services/native-runtime/native-run-trace.test.ts`: 44/44 pass.
- `cd server && npx vitest run
src/services/native-runtime/native-session-executor.test.ts -t "first
visible activity|first assistant text once|first-token timing"`: 7/7
pass.
- `npx vitest run --root tests/restart-chaos`: 159/159 pass; `tsc` on
`tests/restart-chaos` is clean.

## Risks

- **A metric rename.** Consumers of `run.performance.budget_exceeded`
see `metric: "time_to_first_activity"` instead of `"ttft"`. In-repo
there are none besides the tests; Phase 3's chaos speed grade switches
to the new span.
- **A looser budget for slow first text.** Intentional: first text is
reported, not judged. A regression in control-plane latency still shows
up in first activity.

## 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-08 12:32:42 -06:00

210 lines
13 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.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,
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.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"]);
});
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;
// 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 })]);
});
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.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 })]);
});
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([]);
}
});
});