Files
PaperClipAI/server/scripts/benchmark-accounting.ts
T
Devin FoleyandPaperclip e9b579ba6b fix: enforce accounting coverage and recovery regression gates (#15001)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Durable accounting needs repeatable regression gates and inspectable
operational guidance.
> - Broad suite success alone does not prove that crash recovery or
critical financial invariants are tested.
> - This pull request adds per-module coverage floors, mutation
sentinels and an accounting operations guide.
> - CI detects weakened accounting tests and operators can inspect
failures without guessing.

## Linked Issues or Issue Description

Refs #14548, #5598. Completes the verification for #14997 and #14999.

Stack order: #14989 → #14991 → #14994 → #14997 → #14999 → #15001. Part 6
of 6. Review against `fix/costs-dashboard-accounting`. Merge authorized;
landing in dependency order.

## What Changed

- Add and typecheck focused PostgreSQL tests for crash recovery,
concurrency, receipt replay, reporting and operator recovery, plus
exact-money and provider-import tests.
- Require saved passing results for all four SIGKILL cases and both
historical migration cases; skipped or missing cases fail. The
historical runtime fixture restores bigint and verifies an exact
lifetime total beyond JavaScript’s safe integer range.
- Require each of thirteen accounting service modules to meet its own
coverage thresholds.
- Kill deliberate mutations of receipt deduplication, company isolation,
projection conservation and budget boundaries at their specifically
marked assertions; reject setup errors, timeouts and unrelated failures.
- Retain mutation and coverage evidence on failures and upload it from
the trusted CI workflow.
- Preserve frozen-only installs for canary onboarding, optional E2E,
Storybook visual and live provider evals. Execute regressions that
require stale locks to fail closed without registry resolution; the
existing repository bot owns lockfile refreshes.
- Add a scale benchmark and the final implementation/operations guide,
with spec links introduced alongside the guide.
- Exercise empty-turn recovery through the real usage translator with
legacy/current receipts, complete/incomplete flags, malformed metadata
and counter checks. Keep Cloud-auth fixture teardown hardening in this
test gate.
- Cover ACPX completeness through the real notification translator and
driver, plus additive reservation/dispatch and exact daily-spend
assertions.
- Drain Cloud-auth browser route handlers before teardown.
- Require frozen-only installation in the accounting CI lane and gate
coverage execution on successful installation, so the provider cannot
come from inline registry resolution. Other CI lanes keep their existing
install policy.
- Test fractional status-card and skill-test response costs, invalid
monetary values and integer token counters. Include the additive
accounting OpenAPI assertions.
- Cover missing/malformed/mismatched billing secret designations,
privileged caller roles and inactive or cross-company memberships.
Document dedicated billing-secret setup. Include fractional and large
status-card cost regressions.

## Verification

- `pnpm test:accounting` checks 98% lines, 90% branches, 96% statements
and 96% functions per service module, plus required migration/recovery
evidence and mutation checks.
- `node --test scripts/test-accounting-mutations.test.mjs
scripts/verify-accounting-test-results.test.mjs` passed all 22
gate-harness scenarios.
- Inspect `coverage/accounting/` for coverage and test/mutation
evidence. The benchmark is an explicit operator command, not a timing
threshold in CI.
- All 30 release/workflow shard tests and actionlint pass. The new
regression executes the actual accounting-lane install: a stale lock
exits without any registry resolution; a current lock proceeds. It fails
against the old fallback.

Integrated-stack validation on master `c365a16e346d`: repository-wide
typechecking and production build, token gates, 313 focused accounting
tests, two historical migration tests, four required SIGKILL cases and
four mutation sentinels pass. The thirteen accounting modules meet their
individual coverage floors (aggregate 99.7% lines, 94.82% branches). CI
fixture regressions pass 48 connection-card cases, both recovery release
orders and the personal-connection browser workflow. Final consumer
regressions pass all 28 Browser Use lifecycle, 42 email pipeline and 140
rendered issue-detail cases; local email verification used this
checkout’s bundled skills to avoid an unrelated ancestor skills
directory. Merged-source regressions pass AI connections (107), route
authorization (23), worktree cloning (63) and ACPX execution (221).
Additional checks during stack preparation passed native execution
(565), heartbeat admission (60), OpenCode/checkpoint/failure-path (102),
Codex runner drivers (316) and Costs UI (21). Runtime/connection (644),
adapter/billing (86), all Codex adapter cases (509 passed, one skipped),
dashboard (106), worktree-cloning (63), CLI cost (14) and gate-harness
(48) tests pass. Release workflow tests (18) and actionlint pass;
frozen-install shell regressions confirm current locks proceed and stale
locks fail without retries. The repository lockfile bot must land the
coverage dependency refresh before frozen-install workflows run. All
three accounting browser workflows pass on the current merged tree; the
Storybook build and populated provider/biller smoke check passed earlier
in stack preparation. Landing validation against the six intervening
master changes passes all 282 targeted UI tests, 18 workflow tests and
repository-wide typechecking; Storybook host validation adds 19 passing
cases and 18 live HTTP checks, with UI typechecking and token gates
passing. After master consolidated Cursor support, the one cancellation
overlap preserves both current-budget fencing and the new manual
cancellation identities. All 642 affected
heartbeat/cancellation/native/Cursor tests, repository-wide typechecking
and production build pass after reinstalling the updated ACPX patch. The
rebuilt final tree matches that combined source. Each PR’s standalone,
latest-head CI status is recorded below. Latest-head CI has not yet
passed. Latest-head Greptile sign-off is pending. A fresh Apex review
will be requested for this authorized merge.

## Risks

The focused gate adds CI time and requires PostgreSQL support. Trusted
workflow changes take effect when this PR reaches master. Coverage does
not claim to cover every heartbeat, adapter or browser path.
Frozen-install jobs stop until the repository bot refreshes
pnpm-lock.yaml for the new coverage dependency; no fallback runs
unreviewed dependency code in those jobs.

## Model Used

OpenAI Codex, based on GPT-6, with repository inspection, code editing
and test execution. The exact serving model ID and context window were
not exposed to this session.

## 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

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-07 07:26:25 -07:00

170 lines
14 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.
import { randomUUID } from "node:crypto";
import { reserveRunBudget } from "../src/services/budget-reservations.js";
import { createRunUsageRecorder, indexPendingUsageReceipts, usageReceiptSpoolPath } from "../src/services/usage-receipts.js";
import { budgetService } from "../src/services/budgets.js";
import { sql } from "drizzle-orm";
/** Reproducible synthetic PostgreSQL benchmark. Never reads DATABASE_URL or
* provider credentials; startEmbeddedPostgresTestDatabase owns all data. */
import { performance, monitorEventLoopDelay } from "node:perf_hooks";
import { mkdir, writeFile, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { agents, companies, projects, heartbeatRuns, budgetPolicies, createDb, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
import { costService } from "../src/services/costs.js";
import { withAccountingTransaction } from "../src/services/accounting-transaction.js";
import { accountingIntegrityService } from "../src/services/accounting-integrity.js";
import { reconcileRunCosts } from "../src/services/run-cost-accounting.js";
const count = Number(process.env.PAPERCLIP_ACCOUNTING_BENCH_ROWS ?? 1_000_000);
if (!Number.isInteger(count) || count < 1000 || count > 10_000_000) throw new Error("Benchmark row count must be 1,000–10,000,000");
// Recovery reads the instance receipt spool as well as the database. Always
// own both; a caller's real PAPERCLIP_HOME must never be scanned or replayed.
const previousHome = process.env.PAPERCLIP_HOME;
const benchmarkHome = await mkdtemp(path.join(os.tmpdir(), "accounting-benchmark-"));
process.env.PAPERCLIP_HOME = benchmarkHome;
const database = await startEmbeddedPostgresTestDatabase("accounting-scale-").catch(async error => {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
await rm(benchmarkHome, { recursive: true, force: true });
throw error;
});
const db = createDb(database.connectionString);
const started = performance.now();
const eventLoop = monitorEventLoopDelay({ resolution: 10 }); eventLoop.enable();
function distribution(values: number[]) {
const sorted = [...values].sort((a,b) => a-b);
const pick = (p: number) => Number(sorted[Math.min(sorted.length - 1, Math.ceil(p * sorted.length) - 1)].toFixed(2));
return { samples: sorted.length, p50Ms: pick(0.5), p95Ms: pick(0.95), p99Ms: pick(0.99), maxMs: pick(1) };
}
async function time(work: () => Promise<unknown>) { const start = performance.now(); await work(); return performance.now() - start; }
try {
const [company] = await db.insert(companies).values({ name: "Scale fixture", issuePrefix: "SCALE" }).returning();
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", role: "engineer", adapterType: "process", status: "idle" }).returning();
const [project] = await db.insert(projects).values({ companyId: company.id, name: "Hot project" }).returning();
const seedMs = await time(async () => {
await db.$client`insert into cost_events (company_id,agent_id,project_id,provider,biller,model,cost_cents,input_tokens,cached_input_tokens,output_tokens,occurred_at)
select ${company.id},${agent.id}, case when i % 100 = 0 then ${project.id}::uuid else null end, 'fixture','fixture','fixture', 0.1234567,7,11,3,
date_trunc('month',now() at time zone 'UTC') at time zone 'UTC' - (i % 12) * interval '1 month'
from generate_series(1,${count}) i`;
await db.$client`analyze cost_events`;
// Seed projections through an explicit reviewed repair, not benchmark-only SQL.
const integrity = accountingIntegrityService(db); const review = await integrity.inspect(company.id);
await integrity.repair(company.id, review.fingerprint, "Initialize synthetic benchmark projections", "benchmark");
});
const reports: number[] = [], projectReports: number[] = [];
for (let i = 0; i < 20; i++) {
reports.push(await time(() => costService(db).summary(company.id, { allTime: true })));
projectReports.push(await time(() => costService(db).byProject(company.id, { allTime: true })));
}
const writes: number[] = []; const concurrency = 8; const writeStarted = performance.now();
for (let batch = 0; batch < 8; batch++) await Promise.all(Array.from({ length: concurrency }, (_, index) => time(() => costService(db).createEvent(company.id, {
agentId: agent.id, provider: "fixture", model: "fixture", costCents: "0.0000001", idempotencyKey: `bench:${batch}:${index}`, occurredAt: new Date(),
})).then(ms => writes.push(ms))));
const writeMs = performance.now() - writeStarted;
await budgetService(db).upsertPolicy(company.id, { scopeType: "company", scopeId: company.id, amount: 2_000_000_000 }, "benchmark");
await budgetService(db).upsertPolicy(company.id, { scopeType: "agent", scopeId: agent.id, amount: 2_000_000_000 }, "benchmark");
const budgetedWrites: number[] = [];
const budgetedStart = performance.now();
for (let batch = 0; batch < 4; batch++) await Promise.all(Array.from({ length: concurrency }, (_, index) => time(() => costService(db).createEvent(company.id, {
agentId: agent.id, provider: "fixture", model: "fixture", costCents: "0.0000001", idempotencyKey: `budgeted:${batch}:${index}`, occurredAt: new Date(),
})).then(ms => budgetedWrites.push(ms))));
const budgetedMs = performance.now() - budgetedStart;
const admissions: number[] = [];
for (let i = 0; i < 20; i++) {
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running" }).returning();
admissions.push(await time(() => reserveRunBudget(db, company.id, run.id, null)));
}
const dashboard: number[] = [], writesWhilePolling: number[] = [];
// Eight viewers each issue the ledger queries used by the Costs overview,
// together with budget and accounting health reads, while eight writes run.
for (let batch = 0; batch < 4; batch++) await Promise.all([
...Array.from({ length: 8 }, () => time(() => Promise.all([
costService(db).summary(company.id), costService(db).byAgent(company.id),
costService(db).byProject(company.id), costService(db).byAgentModel(company.id),
budgetService(db).overview(company.id), accountingIntegrityService(db).health(company.id),
])).then(ms => dashboard.push(ms))),
...Array.from({ length: concurrency }, (_, index) => time(() => costService(db).createEvent(company.id, {
agentId: agent.id, provider: "fixture", model: "fixture", costCents: "0.0000001", idempotencyKey: `polling:${batch}:${index}`, occurredAt: new Date(),
})).then(ms => writesWhilePolling.push(ms))),
]);
const [checkpointRun] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running" }).returning();
const recorder = await createRunUsageRecorder(db, { companyId: company.id, runId: checkpointRun.id, adapterType: "process" });
const checkpoints: number[] = [];
for (let i = 1; i <= 50; i++) checkpoints.push(await time(() => recorder.capture({ complete: false, usageBasis: "per_run", usage: { inputTokens: i, outputTokens: i }, costUsdExact: "0.000000001" })));
// Include historical run-table size, independently of the pending index.
await db.$client`insert into heartbeat_runs (company_id,agent_id,status,cost_accounting_pending,finished_at)
select ${company.id},${agent.id},'succeeded',false, now() from generate_series(1,100000)`;
await db.$client`analyze heartbeat_runs`;
const health: number[] = [];
for (let i = 0; i < 20; i++) health.push(await time(() => accountingIntegrityService(db).health(company.id)));
const waits: number[] = [];
await Promise.all(Array.from({ length: concurrency }, () => {
const start = performance.now();
return withAccountingTransaction(db, company.id, async tx => { waits.push(performance.now() - start); await tx.execute(sql`select pg_sleep(0.005)`); });
}));
await db.$client`insert into heartbeat_runs (company_id,agent_id,status,cost_accounting_pending,usage_json,finished_at)
select ${company.id},${agent.id},'failed',true,'{"accountingReceiptReady":true,"costUsdExact":"0.000000001","inputTokens":1}'::jsonb, now() from generate_series(1,250)`;
const recovery: Array<{ milliseconds: number; scanned: number; accounted: number }> = [];
for (let i = 0; i < 3; i++) {
const before = performance.now(); const result = await reconcileRunCosts(db); recovery.push({ milliseconds: performance.now()-before, ...result });
}
if (recovery.reduce((sum, r) => sum+r.accounted,0) !== 250) throw new Error("Benchmark recovery did not conserve receipt count");
const spool = usageReceiptSpoolPath(); await mkdir(spool, { recursive: true });
const spoolBacklogs: Array<{ files: number; indexMs: number; recorderStartMs: number }> = [];
const foreignRunId = randomUUID();
let published = 0;
for (const files of [1000, 10000]) {
// Publish synthetic immutable files directly; timed checkpoints above use
// the real fsync path. These belong to another run and must be retained.
for (; published < files; published += 50) await Promise.all(Array.from({ length: 50 }, (_, offset) => writeFile(path.join(spool, `${published + offset}.json`), JSON.stringify({
schema: "paperclip/accounting-receipt/v1", id: randomUUID(), companyId: company.id, runId: foreignRunId,
sourceId: randomUUID(), sequence: 1, receivedAt: new Date().toISOString(), adapterType: "process", receipt: { complete: false },
}))));
const indexMs = await time(() => indexPendingUsageReceipts());
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running" }).returning();
const recorderStartMs = await time(() => createRunUsageRecorder(db, { companyId: company.id, runId: run.id, adapterType: "process" }));
spoolBacklogs.push({ files, indexMs, recorderStartMs });
}
await rm(spool, { recursive: true, force: true });
// A large policy list must not make each write fetch every agent's policies.
const extraAgents = await db.insert(agents).values(Array.from({ length: 100 }, (_, i) => ({ companyId: company.id, name: `Idle ${i}`, role: "engineer", adapterType: "process" }))).returning();
await db.insert(budgetPolicies).values(extraAgents.map(extra => ({ companyId: company.id, scopeType: "agent", scopeId: extra.id, windowKind: "calendar_month_utc", amount: 2_000_000_000 })));
const largeOverview: number[] = [];
for (let i = 0; i < 10; i++) largeOverview.push(await time(async () => {
const overview = await budgetService(db).overview(company.id);
if (overview.policies.length !== 102) throw new Error("Benchmark policy fixture is incomplete");
}));
const largePolicyWrites: number[] = [], largePolicyAdmissions: number[] = [];
const largePolicyStart = performance.now();
for (let batch = 0; batch < 4; batch++) await Promise.all(Array.from({ length: concurrency }, (_, index) => time(() => costService(db).createEvent(company.id, {
agentId: agent.id, provider: "fixture", model: "fixture", costCents: "0.0000001", idempotencyKey: `large-policy:${batch}:${index}`, occurredAt: new Date(),
})).then(ms => largePolicyWrites.push(ms))));
const largePolicyMs = performance.now() - largePolicyStart;
for (let i = 0; i < 20; i++) {
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running" }).returning();
largePolicyAdmissions.push(await time(() => reserveRunBudget(db, company.id, run.id, null)));
}
const integrity = await accountingIntegrityService(db).inspect(company.id);
if (integrity.findings.length) throw new Error(`Benchmark violated accounting integrity: ${JSON.stringify(integrity.findings)}`);
const plan = await db.$client`explain (analyze,buffers,format json) select sum(cost_cents) from cost_events where company_id = ${company.id} and project_id = ${project.id}
and occurred_at >= date_trunc('month',now() at time zone 'UTC') at time zone 'UTC'`;
const report = { generatedAt: new Date().toISOString(), platform: `${os.platform()} ${os.arch()}`, cpus: os.cpus().length, node: process.version,
seedRows: count, seedMs, concurrency, allTimeSummary: distribution(reports), allTimeProjects: distribution(projectReports),
hotCompanyWrites: { activePolicies: 0, ...distribution(writes), writesPerSecond: writes.length / (writeMs / 1000) }, companyLockAcquisition: distribution(waits),
budgetedCompanyWrites: { activePolicies: 2, ...distribution(budgetedWrites), writesPerSecond: budgetedWrites.length / (budgetedMs / 1000) },
admission: distribution(admissions), concurrentDashboard: distribution(dashboard), writesWhilePolling: distribution(writesWhilePolling),
durableCheckpoint: distribution(checkpoints), healthWith100kHistoricalRuns: distribution(health),
overviewWith102Policies: distribution(largeOverview),
writesWith102Policies: { activePolicies: 102, relevantPolicies: 2, ...distribution(largePolicyWrites), writesPerSecond: largePolicyWrites.length / (largePolicyMs / 1000) },
admissionWith102Policies: distribution(largePolicyAdmissions), spoolBacklogs,
eventLoopDelay: { p95Ms: eventLoop.percentile(95) / 1e6, maxMs: eventLoop.max / 1e6 },
recovery, projectBudgetQueryPlan: plan, integrityFindings: integrity.findings.length, totalMs: performance.now()-started };
const output = path.resolve("coverage/accounting/scale.json"); await mkdir(path.dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(report,null,2));
process.stdout.write(JSON.stringify({ ...report, projectBudgetQueryPlan: "see coverage/accounting/scale.json" },null,2)+"\n");
} finally {
eventLoop.disable();
try { await db.$client.end(); await database.cleanup(); }
finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
await rm(benchmarkHome, { recursive: true, force: true });
}
}