mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - When someone messages an agent, the reply must start as fast as
possible, and restart-safety work must not slow it
> - The durable ownership plan makes this invariant 8 ("fast turns") and
asks Phase 1 to turn the Phase 0 baseline into p50/p95 budgets per
adapter
> - Without budgets, a regression in time to first token, agent boot or
restart recovery shows up only when someone reruns the baseline by hand
> - This pull request records the budgets and adds a per-run check that
writes a run-log row when a run exceeds its p95 budget
> - The benefit is that every later phase can show "no regression
against the budgets" from the run log, without new waits on the turn
path
## Linked Issues or Issue Description
Refs #19831 (plan, "Performance constraint", rule 4, and invariant 8).
Refs #19846 (the Phase 0 baseline these budgets come from). Stacked on
#19861.
**Problem**
The plan requires p50/p95 budgets per adapter for TTFT, agent boot and
restart recovery latency, and a check against them. Neither exists.
**Expected behavior**
Budgets are recorded in the plan and in code. A run that exceeds a p95
budget gets one run-log row that names the metric, the measurement and
the budget.
**Impact**
Phase 2–5 exit criteria can cite budget breaches from chaos waves and
staging. The check adds no wait before `turn.start`.
## What Changed
- New `server/src/services/native-runtime/native-speed-budgets.ts`: the
local budget table (codex, claude, opencode) for TTFT and agent boot
(cold and warm) and restart recovery latency. Each budget is 1.2× the
baseline, rounded up to 0.5 s. `evaluateSpeedBudget` judges one run
against p95. An unknown start kind uses the looser budget. Remote runs
and adapters without a baseline are not judged.
- `native-run-trace.ts`: the trace takes an optional `budget` subject.
It checks `task.time_to_first_token`, agent boot (end of
`heartbeat.queue` to the end of the first `runner.turn.submit` or
`runner.session.resume`) and restart recovery once each. It writes
`run.performance.budget_exceeded` through `defer`, so the write is
detached from the event path. `finish` now drains diagnostics that defer
more work.
- `native-session-executor.ts`: passes the adapter and environment to
the trace, labels cold or warm at the existing `provider_session` reuse
receipts, and checks restart recovery at a recovered run's first
provider event, measured from the startup recovery `ready` time.
- Docs: budget tables in
`doc/plans/2026-11-07-durable-ownership-and-reconciliation.md` ("Speed
budgets (Phase 1)"), and the new event in `doc/run-log-events.md`.
## Verification
- `npx vitest run
src/services/native-runtime/native-speed-budgets.test.ts
src/services/native-runtime/native-run-trace.test.ts` (in `server/`): 34
tests pass. They cover:
- every budget has p50 ≤ p95;
- the provider-to-adapter mapping;
- p95 judgement, and the looser budget when the start kind is unknown;
- one breach for a slow boot, and none for a second submit or a fast
TTFT;
- a deferred TTFT breach is written before the trace closes;
- restart recovery is checked once;
- remote runs and runs with no budget subject write nothing.
- `npx tsc --noEmit -p server`: no errors.
## Risks
- Low risk. This is a run-log change only (AGENTS.md §5.7, no extra
review). The OTLP and Telemetry paths are unchanged.
- The baseline samples are small (2–12 per cell) on a shared host.
Budgets carry 20% headroom, and the codex restart budget (8.0 s p95) is
tight. Expect some breach rows on a loaded machine. They are diagnostics
and change no behavior.
- Restart-recovery budgets use run 4 of the baseline, as merged in
#19846.
- Restart recovery is measured from the startup recovery `ready` time to
the first provider event, which matches the baseline definition.
- Previous-release story: no persisted run state changes. The new event
is additive.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use and code execution.
## 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>
130 lines
5.9 KiB
TypeScript
130 lines
5.9 KiB
TypeScript
/**
|
|
* 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";
|
|
export type SpeedBudgetMetric = "ttft" | "agent_boot" | "restart_recovery";
|
|
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);
|
|
|
|
/** TTFT and agent boot by adapter and start kind; restart recovery by adapter. */
|
|
export const LOCAL_SPEED_BUDGETS: Readonly<Record<SpeedBudgetAdapter, {
|
|
ttft: Record<SpeedBudgetStart, SpeedBudget>;
|
|
agent_boot: Record<SpeedBudgetStart, SpeedBudget>;
|
|
restart_recovery: SpeedBudget;
|
|
}>> = {
|
|
claude: {
|
|
ttft: { 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) },
|
|
},
|
|
codex: {
|
|
ttft: { 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) },
|
|
},
|
|
opencode: {
|
|
ttft: { 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) },
|
|
},
|
|
};
|
|
|
|
/** 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;
|
|
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;
|
|
};
|
|
|
|
/** 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;
|
|
}): 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 } : {}),
|
|
};
|
|
}
|
|
|
|
/** 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,
|
|
};
|
|
}
|