mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## 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>
210 lines
13 KiB
TypeScript
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([]);
|
|
}
|
|
});
|
|
});
|