Files
PaperClipAI/server/src/services/budget-exposure.ts
T
DottaandClaude Opus 5.5 4c15eea770 fix(accounting): a run's unproven accounting never holds the company (#20254)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a restart, each lost provider receipt leaves an accounting
debt that settles later
> - In restart-chaos G1 (seed 9802, task t02), six debts each waited the
full 10-minute closure bound, and the board's accepted Retry waited with
them
> - The last debt settled as an unpriced charge. Under the blocking
unpriced-usage policy, that charge hard-stopped the whole company, which
cancelled the Retry, and the task showed "budget"
> - Dotta's plan decision 1 says accounting is never a company-wide
gate: admission reads per-run exposure and real hard stops only
> - This pull request makes those debts settle at once when the stop is
proven, records accounting-settled charges without a company hold, and
labels the remaining accounting wait correctly
> - The benefit is that a restart no longer stalls a company's work for
10 minutes or stops it outright

## Linked Issues or Issue Description

Refs #20250 (Phase 2: a run that only inherited its predecessor's runner
identity settles at once; this PR builds on it), #19944, #19826, #19857

**What happened?**
Restart-chaos wave w-20261109T111954-s9802 (G1), task t02, master
c5da0b88ad. After a K-fault restart at 11:27, six `run_accounting_debt`
records were "unprovable". Each settled exactly 10 minutes after its run
finished: bf9ea6b4 and c2e222a2 at 11:30, 097d0036 at 11:35, f64f9f46
and aa2c9f03 at 11:36, and 0b4b404f at 11:37:23.
- Four runs were `cancelled` (issue_terminalized), with
`native.local_process_stopped` as their last process event and no
recorded pid. The receipt-closed rule accepted that stop proof only for
`failed` runs.
- 0b4b404f failed with `native_session_cleanup_quarantined` before it
started any process. It journaled no receipt, but it carried pid 11356
of the previous run's warm runner, so the never-started rule did not
apply.
- At 11:37:23, 0b4b404f settled as an unpriced unknown-billing charge
with zero tokens. With `unpricedUsagePolicy: block`, the company hit a
hard stop at 11:37:24 (24 of 3000 cents). The pause cancelled the
board's Retry (751751ea, accepted at 11:28:37), and the task showed
`userAction: "budget"`.

**Expected behavior**
Accounting debts whose provider stop is proven settle at once.
Accounting never holds or pauses the whole company. A wait caused by
unpriced usage is labeled as accounting, not budget.

**Steps to reproduce**
Restart the control plane while native runs are being cancelled, with
the company's unpriced-usage policy set to block. Before this change,
the debts wait 10 minutes and then hard-stop the company.

## What Changed

- `run-cost-accounting.ts`: a verified local process stop closes the
receipt source for `cancelled`, `timed_out` and `interrupted` runs as
well as `failed` ones. The inherited-pid run (0b4b404f) is covered by
#20250, so this PR does not change the never-started rule.
- `budget-exposure.ts`: a final charge that accounting settled without
its receipt (receipt unavailable, closure timed out, capture failed, or
lost on restore) never trips the blocking unpriced-usage policy.
Unpriced usage from a complete receipt still does. A capture failure
stays exempt even if a later report adds usage, because its receipt is
never certified complete. This removes the restart-derived special case,
because the rule now covers every accounting-settled charge.
- `task-interruption-presentation.ts`: a `budget.blocked` wait caused by
the unpriced-usage refusal is shown as `accounting.unpriced_usage`, with
accounting wording, for both a held Retry and a never-started task. Only
the fixed refusal phrase is matched; the stored message is never shown.
- `doc/SPEC-implementation.md`: the admission and accounting rules are
updated to match.

## Verification

- `closed-native-receipt-accounting.test.ts`: new tests for cancelled,
timed-out and interrupted runs with a verified stop; they were red
before the fix. When a timed-out closure settles under the blocking
policy, the board's saved Retry is promoted (due at once) and never
cancelled. That assertion was red on master, where the hard stop
cancelled it. Seven tests that asserted the old rule (an
accounting-settled charge keeps the blocking policy) now assert that the
charge is recorded and the company stays active and admits work. All 61
pass.
- `task-interruption-query.test.ts` (Postgres): a Retry held by the
unpriced refusal, and a skipped wake, are labeled
`accounting.unpriced_usage`. A budget hard stop keeps `budget.blocked`.
The test was red before the fix.
- Also passing: `run-cost-accounting`, `cost-data-quality`,
`idle-accounting-work`, `native-usage-delivery.integration`,
`task-interruption-presentation`, and the accounting and budget tests in
`heartbeat-process-recovery`. Server typecheck has no errors in the
changed files. The only local errors are in
`native-session-executor.ts`, from a stale local runner build.
- Not run: the G1 chaos rerun. Phase 3 runs it after merge.

## Risks

- Behavior change: a charge that accounting settled without a receipt no
longer hard-stops a company under the blocking policy. That includes a
remote run whose closure evidence never arrived, which may still have
spent. The charge stays in Costs with provenance and an activity entry,
and the spend still counts toward the budget limit. This follows plan
decision 1.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, test execution). Extended reasoning
enabled.

## 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-09 07:02:47 -06:00

216 lines
16 KiB
TypeScript

import { addCents, normalizeCents, usdToUnits, unitsToCents } from "@paperclipai/shared";
import { and, eq, gte, inArray, lt, or, sql, type SQL } from "drizzle-orm";
import { budgetPolicies, budgetReservations, browserUseRuns, browserUseSessions, costEvents, heartbeatRuns, issues, type Db } from "@paperclipai/db";
import { withBudgetSpan } from "./budget-tracing.js";
export interface BudgetExposure {
ledgerCents: string;
liveObservedCents: string;
effectiveHeldCents: string;
pendingTerminalRunCount: number;
unpricedEventCount: number;
/** Unconfirmed POSTs or unfinished uncapped browser work, separate from ledger data quality. */
unknownBrowserRunCount: number;
}
export type SpendPolicy = Pick<typeof budgetPolicies.$inferSelect, "companyId" | "scopeType" | "scopeId" | "windowKind" | "metric">;
export type ObservedSpend = BudgetExposure & {
windowStart: Date; windowEnd: Date; total: number; totalExact: string;
/** Terminal runs whose accounting is unfinished. Display only: each one
* contributes its bounded estimate to exposure and never gates admission. */
pendingRunCount: number;
};
export function budgetWindow(kind: string, now = new Date()) {
return kind === "lifetime" ? { start: new Date(0), end: new Date(Date.UTC(9999, 0, 1)) }
: { start: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)), end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)) };
}
/** Only normalized per-run prices are exposure. An incomplete receipt can
* contain a known partial price while still being unpriced for settlement. */
export function knownRunCostCents(usage: Record<string, unknown>): string | null {
if (usage.billingType === "subscription_included" || usage.billingType === "subscription") return "0";
if (usage.usageSource === "session_cumulative" || usage.usageBasis === "session_cumulative") return null;
for (const value of [usage.cacheAdjustedCostUsd, usage.costUsdExact, usage.costUsd]) {
if ((typeof value !== "number" || !Number.isFinite(value) || value < 0) && typeof value !== "string") continue;
try {
const amount = usdToUnits(value);
if (amount >= 0n) return unitsToCents(amount);
} catch { /* An older malformed field cannot erase a reliable later field. */ }
}
return null;
}
/** SQL compatibility for active runs written before the exposure projection.
* Numeric text is bounded and checked before casting legacy JSON fields. */
function normalizedNumericCents(value: SQL, usd = false) {
const scale = usd ? 1_000_000_000 : 10_000_000;
return sql`case when length(${value}) <= 160 and ${value} ~ '^[+]?[0-9]+([.][0-9]+)?([eE][+-]?[0-9]{1,3})?$'
then case when abs(coalesce(substring(${value} from '[eE]([+-]?[0-9]+)$'), '0')::int) <= 100
then case when round((${value})::numeric * ${scale}) <= 999999999999999999999999::numeric
then round((${value})::numeric * ${scale}) / 10000000::numeric else null end
else null end else null end`;
}
const usage = heartbeatRuns.usageJson;
const projected = normalizedNumericCents(sql`${usage}->'budgetExposure'->>'costCents'`);
const hasProjection = sql`jsonb_typeof(${usage}->'budgetExposure') = 'object'
and jsonb_typeof(${usage}->'budgetExposure'->'sourceId') = 'string'
and jsonb_typeof(${usage}->'budgetExposure'->'sequence') = 'number'`;
const legacy = sql`case when coalesce(${usage}->>'usageSource', 'per_run') <> 'session_cumulative'
and coalesce(${usage}->>'usageBasis', 'per_run') <> 'session_cumulative' then
coalesce(${normalizedNumericCents(sql`${usage}->>'cacheAdjustedCostUsd'`, true)},
${normalizedNumericCents(sql`${usage}->>'costUsdExact'`, true)}, ${normalizedNumericCents(sql`${usage}->>'costUsd'`, true)}) else null end`;
// The projection already separates attempts and cumulative/session counters.
// Raw compatibility fields are a fallback, never stronger monetary evidence.
// A canonical unknown projection must remain unknown, not inherit an unsafe
// cumulative aggregate retained only for old accounting compatibility.
const liveCost = sql`case when ${heartbeatRuns.costAccountedAt} is null and ${heartbeatRuns.id} is not null
and coalesce(${usage}->>'billingType', '') not in ('subscription_included', 'subscription')
then case when ${hasProjection} then coalesce(${projected}, 0)
else coalesce(${projected}, ${legacy}, 0) end else 0 end`;
/** Batched ledger, held-operation and unfinished-run aggregates. Never scans
* agents or receipt history; each policy retains its own scope and window. */
async function readObservedSpends(db: Db, policies: SpendPolicy[], now: Date): Promise<ObservedSpend[]> {
const windows = policies.map(policy => budgetWindow(policy.windowKind, now));
const billable = policies.map((policy, index) => ({ policy, index })).filter(({ policy }) => policy.metric === "billed_cents");
const ledgerColumns: Record<string, SQL> = {}, heldColumns: Record<string, SQL> = {}, runColumns: Record<string, SQL> = {}, browserColumns: Record<string, SQL> = {};
const ledgerPredicates: SQL[] = [], heldPredicates: SQL[] = [], runPredicates: SQL[] = [], browserPredicates: SQL[] = [];
for (const { policy, index } of billable) {
const { start, end } = windows[index];
const ledger = and(eq(costEvents.companyId, policy.companyId),
policy.scopeType === "agent" ? eq(costEvents.agentId, policy.scopeId) : undefined,
policy.scopeType === "project" ? eq(costEvents.projectId, policy.scopeId) : undefined,
policy.windowKind === "calendar_month_utc" ? and(gte(costEvents.occurredAt, start), lt(costEvents.occurredAt, end)) : undefined)!;
const held = and(eq(budgetReservations.companyId, policy.companyId), eq(budgetReservations.state, "held"),
policy.scopeType === "agent" ? eq(budgetReservations.agentId, policy.scopeId) : undefined,
policy.scopeType === "project" ? eq(budgetReservations.projectId, policy.scopeId) : undefined)!;
// Hosted browser work is a separate paid operation from its parent run.
// Its durable hold survives unknown POST outcomes and monthly rollover.
const browserHold = sql`${browserUseRuns.result}->'paperclipBudgetHold'`;
const hasBrowserHold = sql`coalesce(jsonb_typeof(${browserHold}) = 'object'
and ${browserHold}->>'invocationId' = ${browserUseRuns.invocationId}::text, false)`;
// Earlier browser rows did not persist a cap/dispatch projection. Their
// unfinished work remains unknown until a terminal receipt is accounted.
const legacyBrowserPending = sql`not ${hasBrowserHold} and ${browserUseRuns.eventsDrained} = 0
and (${browserUseRuns.providerRunId} is not null or ${browserUseRuns.status} in ('creating', 'unknown', 'queued', 'dispatching', 'running'))`;
const browser = and(eq(browserUseRuns.companyId, policy.companyId), eq(browserUseSessions.companyId, policy.companyId),
sql`((${hasBrowserHold} and ${browserHold}->>'state' = 'held') or (${legacyBrowserPending}))`,
policy.scopeType === "agent" ? eq(browserUseSessions.agentId, policy.scopeId) : undefined,
policy.scopeType === "project" ? sql`case when ${hasBrowserHold} then ${browserHold}->>'projectId'
else ${issues.projectId}::text end = ${policy.scopeId}` : undefined)!;
browserPredicates.push(browser);
browserColumns[`held${index}`] = sql`coalesce(sum(${normalizedNumericCents(sql`${browserHold}->>'amountCents'`)}) filter (where ${browser}), 0)::text`;
// A successful uncapped POST still has no bounded monetary exposure until
// terminal accounting. Introducing a budget cannot treat that work as free.
// Older holds had no explicit flag: zero meant an uncapped dispatch. New
// capped zero holds are distinct, and unstarted reservations are not spend.
const uncapped = sql`case when ${browserHold}->>'uncapped' = 'false' then false
when ${browserHold}->>'uncapped' = 'true' then true
else coalesce(${normalizedNumericCents(sql`${browserHold}->>'amountCents'`)}, 0) = 0 end`;
const dispatched = sql`(${browserUseRuns.providerRunId} is not null or ${browserHold}->>'dispatchCommittedAt' is not null)`;
browserColumns[`unknown${index}`] = sql`count(*) filter (where ${browser} and
((${legacyBrowserPending}) or ${browserHold}->>'billingUnknown' = 'true' or (${uncapped} and ${dispatched})))::int`;
const inWindow = policy.windowKind === "calendar_month_utc"
? sql`coalesce(${heartbeatRuns.finishedAt}, ${now.toISOString()}::timestamptz) >= ${start.toISOString()}::timestamptz
and coalesce(${heartbeatRuns.finishedAt}, ${now.toISOString()}::timestamptz) < ${end.toISOString()}::timestamptz` : sql`true`;
const lateReceiptDebt = sql`coalesce(${usage}->>'accountingLateReceiptPending', 'false') = 'true'`;
const run = and(eq(heartbeatRuns.companyId, policy.companyId), or(eq(heartbeatRuns.costAccountingPending, true), lateReceiptDebt),
policy.scopeType === "agent" ? eq(heartbeatRuns.agentId, policy.scopeId) : undefined,
policy.scopeType === "project" ? sql`${usage}->'ledgerScope'->>'projectId' = ${policy.scopeId}` : undefined)!;
const terminal = and(run, inArray(heartbeatRuns.status, ["succeeded", "failed", "timed_out", "cancelled", "interrupted"]), or(inWindow, lateReceiptDebt))!;
// Keep correlated subqueries in nested SQL chunks. Drizzle strips column
// qualifiers from a single-table selection's direct column chunks.
const hasReservation = sql`exists (select 1 from ${budgetReservations}
where ${budgetReservations.companyId} = ${heartbeatRuns.companyId}
and ${budgetReservations.runId} = ${heartbeatRuns.id} and ${budgetReservations.state} = 'held')`;
ledgerPredicates.push(ledger); heldPredicates.push(held); runPredicates.push(run);
ledgerColumns[`total${index}`] = sql`coalesce(sum(${costEvents.costCents}) filter (where ${ledger}), 0)::text`;
// "Usage with no reliable price" means usage from a complete receipt that
// could not be priced. These unpriced charges are not that, so they never
// trip the blocking policy (they stay visible in Costs):
// - a final charge accounting settled without its receipt: lost with a
// stopped provider, closure evidence that never arrived, or a failed
// capture (its receipt is never certified complete, even if a later
// report adds usage). Accounting is never a company gate (plan decision 1); its
// exposure is recorded, and the activity entry is the board's notice;
// - a charge that carries a known partial cost;
// - metered API or subscription billing with no observed tokens.
// Unknown billing with no tokens (for example Cursor Cloud) still counts.
// The receipt-unavailable exemption covers only that run's settled final
// charge; later growth from a complete receipt is ordinary usage.
const finalCharge = sql`${costEvents.idempotencyKey} = 'heartbeat:' || ${costEvents.heartbeatRunId}::text || ':final'`;
const exemptFromUnpricedBlock = sql`((${finalCharge}
and exists (select 1 from ${heartbeatRuns} lost_receipt_run where lost_receipt_run.id = ${costEvents.heartbeatRunId}
and lost_receipt_run.company_id = ${costEvents.companyId}
and (lost_receipt_run.usage_json->>'accountingReceiptUnavailable' = 'true'
or lost_receipt_run.usage_json->>'accountingClosureTimedOut' = 'true'
or lost_receipt_run.usage_json->>'accountingCaptureFailed' = 'true'
or coalesce(lost_receipt_run.usage_json->'pricingProvenance'->>'evidence', '') like '%lost on restore%')))
or ${costEvents.costCents} > 0
or (${costEvents.billingType} in ('metered_api', 'api', 'subscription') and ${costEvents.inputTokens} = 0 and ${costEvents.cachedInputTokens} = 0
and ${costEvents.outputTokens} = 0))`;
ledgerColumns[`unpriced${index}`] = sql`count(*) filter (where ${ledger} and ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included'
and not ${exemptFromUnpricedBlock})::int`;
runColumns[`live${index}`] = sql`coalesce(sum(${liveCost}) filter (where ${run} and ${inWindow}), 0)::text`;
runColumns[`unreserved${index}`] = sql`coalesce(sum(${liveCost}) filter (where ${run} and not ${hasReservation}), 0)::text`;
// Reservations carry over UTC windows. A reservation and its running
// observation describe one operation, so count their maximum, not both.
heldColumns[`held${index}`] = sql`coalesce(sum(greatest(${budgetReservations.amountCents}, ${liveCost})) filter (where ${held}), 0)::text`;
runColumns[`pending${index}`] = sql`count(*) filter (where ${terminal})::int`;
// Unfinished accounting is exposure, not a gate (SPEC 11.6, invariant 3).
// Its bounded estimate is the larger of the run's held reservation and its
// observed cost, counted above; a hard stop reads the ledger plus observed
// cost. No terminal run's cleanup or accounting state refuses new work.
}
// A single statement preserves hold-to-ledger atomicity even when a caller
// owns an otherwise READ COMMITTED transaction. Alias dynamic columns before
// JSON projection so the driver does not collapse repeated aggregate names.
const columns = (values: Record<string, SQL>) => Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value.as(key)]));
let ledger: Record<string, string> = {}, held: Record<string, string> = {}, runs: Record<string, string> = {}, browser: Record<string, string> = {};
if (billable.length) {
const ledgerQuery = db.select(columns(ledgerColumns)).from(costEvents).where(or(...ledgerPredicates));
const heldQuery = db.select(columns(heldColumns)).from(budgetReservations)
.leftJoin(heartbeatRuns, and(eq(heartbeatRuns.id, budgetReservations.runId), eq(heartbeatRuns.companyId, budgetReservations.companyId)))
.where(or(...heldPredicates));
const runsQuery = db.select(columns(runColumns)).from(heartbeatRuns).where(or(...runPredicates));
const browserQuery = db.select(columns(browserColumns)).from(browserUseRuns)
.innerJoin(browserUseSessions, and(eq(browserUseSessions.id, browserUseRuns.sessionId), eq(browserUseSessions.companyId, browserUseSessions.companyId)))
.leftJoin(issues, and(eq(issues.id, browserUseSessions.issueId), eq(issues.companyId, browserUseSessions.companyId)))
.where(or(...browserPredicates));
const [row] = await db.execute<{ ledger: Record<string, string>; held: Record<string, string>; runs: Record<string, string>; browser: Record<string, string> }>(sql`
select row_to_json(ledger) as ledger, row_to_json(held) as held,
row_to_json(runs) as runs, row_to_json(browser) as browser
from (${ledgerQuery}) ledger cross join (${heldQuery}) held
cross join (${runsQuery}) runs cross join (${browserQuery}) browser`);
({ ledger, held, runs, browser } = row);
}
return policies.map((_, index) => {
const ledgerCents = normalizeCents(String(ledger[`total${index}`] ?? 0));
const liveObservedCents = normalizeCents(String(runs[`live${index}`] ?? 0));
const totalExact = addCents(ledgerCents, liveObservedCents);
const pendingRunCount = Number(runs[`pending${index}`] ?? 0);
return { windowStart: windows[index].start, windowEnd: windows[index].end,
ledgerCents, liveObservedCents, effectiveHeldCents: addCents(String(held[`held${index}`] ?? 0), String(runs[`unreserved${index}`] ?? 0), String(browser[`held${index}`] ?? 0)),
total: Number(totalExact), totalExact, pendingRunCount, pendingTerminalRunCount: pendingRunCount,
unpricedEventCount: Number(ledger[`unpriced${index}`] ?? 0), unknownBrowserRunCount: Number(browser[`unknown${index}`] ?? 0) };
});
}
export async function computeObservedSpend(db: Db, policy: SpendPolicy, now = new Date()) {
return (await computeObservedSpends(db, [policy], now))[0];
}
export function computeObservedSpends(db: Db, policies: SpendPolicy[], now = new Date()): Promise<ObservedSpend[]> {
return withBudgetSpan("budget.exposure.evaluate", async () => {
// Each policy contributes several aggregate columns. Bound statement width
// below PostgreSQL's target-list limit. Multi-scope evaluators/reports own
// a repeatable-read transaction; individual admission batches fit one SQL.
const result: ObservedSpend[] = [];
for (let offset = 0; offset < policies.length; offset += 128)
result.push(...await readObservedSpends(db, policies.slice(offset, offset + 128), now));
return result;
}, { "budget.policy_count": policies.length });
}