Files
PaperClipAI/server/src/services/native-runtime/native-speed-budgets.ts
T
DottaandClaude 84007892e9 refactor: move AGENTS.md section detail to docs and rules, add a bug-fixing section (#20585)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - `AGENTS.md` is the instruction file that each human and AI
contributor reads before a change
> - Pull request #20557 shortened the file, but several sections still
held detail that applies to only some changes
> - The scoped-rules section told agents to read each matching rule in
full, and most changes match five rules
> - The core engineering rules mixed three product invariants with
process conventions
> - This pull request moves that detail to the docs and to scoped rules,
and adds a section on how to fix a bug
> - The benefit is a 211-line file that states what applies to every
change and points to the rest

## Linked Issues or Issue Description

Refs #20557

**Issue type**

Outdated or unclear contributor documentation.

**Where is the issue?**

`AGENTS.md` at the repository root, and `rules/`.

**What's wrong?**

- The scoped-rules section made agents read the full body of each
matching rule before a change. The rule bodies total about 47 KB.
- Sections 4, 6, 8, 9 and 10 repeated detail that belongs in
`doc/DEVELOPING.md`, `doc/DATABASE.md`, a route rule, `DESIGN.md` and
`CONTRIBUTING.md`.
- Section 4 had a database reset command for `data/pglite`. That
directory does not exist.
- Section 5 had seven rules. Four of them were process conventions or
path-specific detail.
- The file did not say how to fix a bug.

**Suggested fix**

Make the rule headers the index. Move section detail to the document or
rule that owns it. Keep only the three invariants in section 5. Add a
bug-fixing section.

## What Changed

- The scoped-rules section now tells agents to list the rule headers
with one `grep` command. Agents read a rule body only for a design
decision in its area, when the description is not sufficient, or when a
reviewer cites it.
- `rules/README.md` says that each `description` must state the required
behavior in one sentence.
- `rules/pull-request-review.mdc` has a description that states its
requirements.
- Section 4 (dev setup) and section 11 (pull requests) now point to
`doc/DEVELOPING.md` and `CONTRIBUTING.md`.
- The schema change workflow moved from section 6 to `doc/DATABASE.md`.
- The API and auth expectations moved from section 8 to the new
`rules/api-route-expectations.mdc`.
- Section 9 links to `DESIGN.md` for design rules. `AGENTS.md` no longer
tells contributors to run `pnpm check:token-gates`.
- Added section 10, "Fixing a Bug": reproduce the error, write a failing
test, write the fix, see the test pass, and verify the fix.
- Section 5 now has three rules: company scope, contract
synchronization, and the control-plane invariants.
- The company-scope exceptions moved to the new
`rules/company-scope-exceptions.mdc`.
- Removed the strategic-docs rule. The plan-doc convention moved to the
repo map. The artifact rule moved to section 7.
- Removed rule 7. Five code comments that cited `AGENTS.md §5.7` now
cite `rules/data-paths.mdc`.
- Regenerated the SDK public source pin, because one of those comments
is in `packages/shared/src/crash-points.ts`.
- Added both new rules to `.greptile/files.json`.
- The API route rule applies only to routes that read or write company
data. Its status code list is a list of common codes.
- The company-scope rule lists the sidebar company order as an
instance-wide exception.
- The `honeycomb-tracing` skill now points to `rules/data-paths.mdc` for
the Telemetry workflow.

## Verification

- Open `AGENTS.md` and confirm that each link resolves. A local script
checked this.
- Run `grep -E '^(description|globs|alwaysApply):' rules/*.mdc` and
confirm that each rule prints a description.
- Run `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD>
--check`. It passes locally.
- `packages/shared/src/crash-points.test.ts` passed locally before the
rebase onto `master`.
- Five source files changed in comments only. No other test suite ran
locally.

## Risks

- An agent that reads only rule descriptions can miss a detail in a rule
body. Reviewers still apply the full rules.
- Pull requests and the definition of done are now sections 11 and 12.
Sections 1 to 9 keep their numbers.
- Dated plan documents under `doc/plans/` still cite `AGENTS.md §5.7`.
They are historical records.
- Pull request #20565 also changes `AGENTS.md`. The second one to merge
needs a conflict fix in section 1 and section 2.

## Model Used

- Claude (Anthropic), in Claude Code with tool use.
- `claude-fable-5-2`.

## 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
- [ ] 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 <noreply@anthropic.com>
2026-11-10 11:00:31 -06:00

168 lines
8.3 KiB
TypeScript
Raw Blame History

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