mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A control-plane restart between two durable writes leaves an in-progress record owned by a dead process > - #19937 added `crashPoint(name)` and a registry of 20 named boundaries from the Phase 0 inventory > - A name without a call site cannot fire, so the crash-point suite cannot test it > - This pull request puts one call at each registered boundary and adds a test that keeps the registry and the call sites in step > - The benefit is that the on-demand suite (next pull request) can kill the control plane at every known write-ahead gap ## Linked Issues or Issue Description Refs #19831 (plan, Phase 5). Refs #19846 (inventory, "Write-ahead coverage"). Stacked on #19937 (helper and registry). Refs #19861 (ownable kind ids). **Problem** The registered crash points have no call sites. **Expected behavior** Each registered point is called at its gap: after the first durable write or outside effect returns, before the next one starts. Disarmed (always in production) each call is one comparison. **Scope** Call sites and a coverage test only. The suite runner and CI workflow follow. ## What Changed - Server call sites (`@paperclipai/shared/crash-points`): - `sandbox-creation-operations.ts`: around the provider `create` call (intent → provider → lease). - `native-session-executor.ts`: after `native.process_start_requested`, before spawn. - `paperclip-control-plane-port.ts`: after the result-accept transaction, before finalization starts (only when a result was inserted). - `native-run-finalizer.ts`: after `commitNativeStatusDecision`, before the run projection. - `workspace-operations.ts`: `workspace_finalize` only — after the running row, and after `settle()` before the done row. - `environment-runtime.ts`: provider release → lease row; teardown claim → destroy; reusable destroy → lease release; stop-and-retain claim → stop. - `heartbeat.ts`: lease release → runtime services; budget reservation hold → dispatch. - `heartbeat/queue.ts`: run claimed `running` → wake request `claimed`. - `run-cost-accounting.ts`, `usage-receipts.ts`: accounting commit → spool unlink; receipt spool → DB row → unlink. - `packages/paperclip-runner/src/crash-point.ts`: a small copy of the helper for controller code in this package, which does not depend on `@paperclipai/shared` (adding that dependency would change the runner content ID). It arms only `runner.*` names. runnerd never sees the variable: its environment is an allowlist. - Runner call sites: after the runnerd spawn, before its identity is published (both spawn paths); `turn.start` result received → controller journal saved → next send. - `server/src/__tests__/crash-point-call-sites.test.ts`: every registered point has a call site, every call names a registered point, and every `kinds` entry is an ownable kind id of the Phase 1 registry. ## Verification - `cd server && npx vitest run src/__tests__/crash-point-call-sites.test.ts` → 3 passed. - `pnpm --filter @paperclipai/server typecheck` → clean. `pnpm --filter @paperclipai/paperclip-runner exec tsc --noEmit -p .` → clean. - Behavior with nothing armed is unchanged: each call returns at its first comparison (measured in #19937: about 3.7 ns). ## Risks - Low risk. No call changes control flow unless `PAPERCLIP_CRASH_POINT` names it and `NODE_ENV` is not `production`. - Points on the path before `turn.start` (process start, runner spawn) cost nanoseconds, within plan invariant 8. - `usage-receipts.ts`: the persist-then-unlink one-liner became a block with the same statements in the same order. - The runner package change gives the runner a new content ID, as any runner source change does. No persisted run state changes (rules/upgrade-safe-run-state.mdc). ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits) and a read-only exploration subagent that mapped the boundaries. ## 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>
769 lines
55 KiB
TypeScript
769 lines
55 KiB
TypeScript
import { beginIdleTrackedWork } from "./task-admission.js";
|
|
import { priceAnthropicReceipt } from "./anthropic-pricing.js";
|
|
import { priceCodexReceipt } from "./codex-pricing.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { z } from "zod";
|
|
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
import { heartbeatRuns, runUsageReceipts, costEvents, costAdjustments, type Db } from "@paperclipai/db";
|
|
import type { AdapterExecutionResult, AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
|
|
import { centsToUnits, compareCents, normalizeCents, pricingProvenanceSchema, usdToUnits, unitsToCents } from "@paperclipai/shared";
|
|
import { idleAccountingSpoolPath, registerIdleSpoolDirectory } from "./idle-local-work.js";
|
|
import { conflict, notFound } from "../errors.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { receiptFingerprint } from "./receipt-fingerprint.js";
|
|
import { withOperationAccountingTransaction } from "./accounting-transaction.js";
|
|
import { ensureDurableDirectory, syncDirectory } from "../lib/durable-directory.js";
|
|
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
|
import { knownRunCostCents } from "./budget-exposure.js";
|
|
import { createLiveRunUsageProjection, liveRunUsageCostCents } from "./budget-live-usage.js";
|
|
import { applyCostCorrectionInTransaction } from "./accounting-corrections.js";
|
|
import { logActivity, type ActivityPublication } from "./activity-log.js";
|
|
import { createCostEventInTransaction } from "./costs.js";
|
|
import { queueBudgetEvaluation } from "./budget-evaluation-work.js";
|
|
import { crashPoint } from "@paperclipai/shared/crash-points";
|
|
|
|
const token = z.number().int().nonnegative().max(2_147_483_647);
|
|
const price = z.number().finite().nonnegative().nullable().optional();
|
|
const usage = z.object({ inputTokens: token, outputTokens: token, cachedInputTokens: token.optional(), cacheWriteTokens: token.optional() });
|
|
const checkpointSchema = z.object({
|
|
attemptId: z.string().uuid().optional(), usage: usage.optional(),
|
|
usageByModel: z.array(z.object({ model: z.string().min(1).max(250), usage, costUsd: z.number().finite().nonnegative() })).max(500).optional(),
|
|
usageBasis: z.enum(["per_run", "session_cumulative"]).nullable().optional(),
|
|
provider: z.string().max(250).nullable().optional(), biller: z.string().max(250).nullable().optional(),
|
|
model: z.string().max(250).nullable().optional(), billingType: z.string().max(100).nullable().optional(),
|
|
costUsd: price, cacheAdjustedCostUsd: price,
|
|
costStatus: z.enum(["reported", "estimated", "unpriced"]).optional(),
|
|
pricingContext: z.object({ serviceTier: z.string().max(50).optional(), contextTier: z.enum(["short", "long"]).optional() }).optional(),
|
|
pricingProvenance: pricingProvenanceSchema.optional(),
|
|
costUsdExact: z.string().max(160).refine(value => { try { return !value.startsWith("-") && usdToUnits(value) >= 0n; } catch { return false; } }, "Invalid exact USD amount").nullable().optional(),
|
|
providerRequestId: z.string().max(250).nullable().optional(), complete: z.boolean(),
|
|
});
|
|
const envelopeSchema = z.object({
|
|
schema: z.literal("paperclip/accounting-receipt/v1"), id: z.string().uuid(), companyId: z.string().uuid(),
|
|
runId: z.string().uuid(), sourceId: z.string().uuid(), sequence: z.number().int().positive(), receivedAt: z.iso.datetime(),
|
|
adapterType: z.string().max(100), receipt: checkpointSchema,
|
|
producerEvent: z.object({ sourceEventId: z.string(), turnId: z.string().optional(), providerTurnId: z.string().optional(),
|
|
eventType: z.string(), usage: z.record(z.string(), z.unknown()).optional(),
|
|
// The restored Runner failed this turn as lost on restore: its usage can
|
|
// never be reported, so it does not hold the receipt open.
|
|
lostOnRestore: z.literal(true).optional() }).optional(),
|
|
// Derived by the recorder, not a provider's final charge. Keeping it in the
|
|
// fsynced envelope preserves known partial attempts through database replay.
|
|
liveCostCents: z.string().max(160).refine(value => {
|
|
try { return compareCents(value, 0) >= 0; } catch { return false; }
|
|
}, "Invalid live cost").transform(normalizeCents).nullable().optional(),
|
|
// Set when another run's Runner stream reported this run's late usage. The
|
|
// receipt's usage and cost are then an increment, not a run total.
|
|
lateDelta: z.object({ attribution: z.enum(["exact", "inferred"]), reportedByRunId: z.string().uuid() }).optional(),
|
|
});
|
|
export type UsageReceiptEnvelope = z.infer<typeof envelopeSchema>;
|
|
export const usageReceiptSpoolPath = idleAccountingSpoolPath;
|
|
|
|
export async function spoolUsageReceipt(envelope: UsageReceiptEnvelope, directory = usageReceiptSpoolPath()) {
|
|
registerIdleSpoolDirectory(directory);
|
|
const parsed = envelopeSchema.parse(envelope);
|
|
await ensureDurableDirectory(directory);
|
|
const target = path.join(directory, `${parsed.id}.json`);
|
|
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
const handle = await fs.open(temporary, "wx", 0o600);
|
|
try {
|
|
try { await handle.writeFile(JSON.stringify(parsed)); await handle.sync(); }
|
|
finally { await handle.close(); }
|
|
await fs.rename(temporary, target); await syncDirectory(directory);
|
|
} catch (error) { await fs.rm(temporary, { force: true }); throw error; }
|
|
return target;
|
|
}
|
|
|
|
function lateReceiptDebts(usage: Record<string, unknown>): Record<string, boolean> {
|
|
const raw = usage.accountingLateReceiptDebts;
|
|
const debts = Object.fromEntries(Object.entries(raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {})
|
|
.filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean"));
|
|
// Backward compatibility with the original single pending-receipt marker.
|
|
if (usage.accountingLateReceiptPending === true && typeof usage.accountingLateReceiptId === "string"
|
|
&& !(usage.accountingLateReceiptId in debts))
|
|
debts[usage.accountingLateReceiptId] = usage.accountingLateReceiptAttemptedId === usage.accountingLateReceiptId;
|
|
return debts;
|
|
}
|
|
|
|
function acceptedReceiptSequence(usage: Record<string, unknown>, reconciled?: UsageReceiptEnvelope) {
|
|
let sequence = Number(usage.accountingReceiptSequence ?? 0);
|
|
if (usage.accountingReconciledReceiptSourceId === usage.accountingReceiptSourceId)
|
|
sequence = Math.max(sequence, Number(usage.accountingReconciledReceiptSequence ?? 0));
|
|
// Rows corrected before the explicit sequence field was introduced still have
|
|
// their immutable reconciled receipt, which supplies the same source fence.
|
|
if (reconciled && reconciled.sourceId === usage.accountingReceiptSourceId) sequence = Math.max(sequence, reconciled.sequence);
|
|
return sequence;
|
|
}
|
|
|
|
/** Caller transaction may already own the run row. No company mutex or
|
|
* filesystem work belongs in journal acceptance. */
|
|
export async function persistUsageReceiptInTransaction(tx: Db, raw: UsageReceiptEnvelope) {
|
|
const envelope = envelopeSchema.parse(raw);
|
|
const hash = receiptFingerprint(envelope);
|
|
const [run] = await tx.select({
|
|
id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, status: heartbeatRuns.status,
|
|
usageJson: heartbeatRuns.usageJson, costAccountedAt: heartbeatRuns.costAccountedAt,
|
|
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, envelope.runId), eq(heartbeatRuns.companyId, envelope.companyId))).for("update");
|
|
if (!run) throw notFound("Accounting run not found");
|
|
const [existing] = await tx.select().from(runUsageReceipts).where(and(eq(runUsageReceipts.companyId, envelope.companyId), eq(runUsageReceipts.runId, envelope.runId), eq(runUsageReceipts.sourceId, envelope.sourceId), eq(runUsageReceipts.sequence, envelope.sequence)));
|
|
if (existing && existing.receiptHash !== hash) throw conflict("Conflicting usage checkpoint replay");
|
|
if (!existing) await tx.insert(runUsageReceipts).values({
|
|
id: envelope.id, companyId: envelope.companyId, runId: envelope.runId, sourceId: envelope.sourceId,
|
|
sequence: envelope.sequence, receiptHash: hash, receiptJson: envelope, complete: envelope.receipt.complete, receivedAt: new Date(envelope.receivedAt),
|
|
});
|
|
const previous = run.usageJson ?? {};
|
|
if (run.costAccountedAt || envelope.lateDelta) {
|
|
const activeSource = previous.accountingReceiptSourceId === envelope.sourceId;
|
|
const closedSources = previous.accountingReceiptSourceFences as Record<string, number> | undefined;
|
|
if (!activeSource && typeof closedSources?.[envelope.sourceId] === "number" && envelope.sequence <= closedSources[envelope.sourceId])
|
|
return { reconciliationRequired: false };
|
|
const [reconciled] = typeof previous.accountingReconciledReceiptId === "string"
|
|
? await tx.select({ receiptJson: runUsageReceipts.receiptJson }).from(runUsageReceipts).where(and(
|
|
eq(runUsageReceipts.runId, run.id), eq(runUsageReceipts.companyId, envelope.companyId),
|
|
sql`${runUsageReceipts.id}::text = ${previous.accountingReconciledReceiptId}`)) : [];
|
|
const reconciledEnvelope = reconciled ? envelopeSchema.parse(reconciled.receiptJson) : undefined;
|
|
const newer = envelope.sequence > acceptedReceiptSequence(previous, reconciledEnvelope);
|
|
const comparison = reconciledEnvelope ? { ...previous, ...reconciledEnvelope.receipt } : previous;
|
|
const acceptedMoney = knownRunCostCents(comparison);
|
|
const incomingMoney = liveRunUsageCostCents(envelope.receipt as AdapterUsageCheckpoint);
|
|
const authoritative = envelope.receipt.pricingProvenance
|
|
&& !["rate_card", "unknown"].includes(envelope.receipt.pricingProvenance.source);
|
|
const rateCardOnly = envelope.producerEvent && envelope.receipt.pricingProvenance?.source === "rate_card";
|
|
// A charge settled without its final receipt accepts a complete receipt's
|
|
// own price, including a rate-card price, as a correction. Reconciliation
|
|
// can only post a receipt that carries its price; token changes are
|
|
// detected below as usual.
|
|
const ownPrice = envelope.receipt.cacheAdjustedCostUsd ?? envelope.receipt.costUsdExact ?? envelope.receipt.costUsd;
|
|
const unavailableClosed = previous.accountingReceiptUnavailable === true && envelope.receipt.complete === true
|
|
&& ownPrice != null && envelope.receipt.costStatus !== "unpriced" && envelope.receipt.usageBasis !== "session_cumulative";
|
|
const changed = unavailableClosed || envelope.lateDelta !== undefined
|
|
|| (envelope.producerEvent?.eventType === "usage.reported" && !envelope.receipt.usage)
|
|
|| envelope.receipt.usageBasis === "session_cumulative"
|
|
|| ["provider", "biller", "model", "billingType"].some(key => {
|
|
const value = envelope.receipt[key as keyof typeof envelope.receipt];
|
|
return value !== undefined && value !== null && value !== previous[key];
|
|
}) || Object.entries(envelope.receipt.usage ?? {}).some(([key, value]) => previous[key] !== value)
|
|
// A projection alone cannot be posted against a charge settled without
|
|
// its receipt; only the receipt's own price (above) can correct it.
|
|
|| (!rateCardOnly && incomingMoney !== null && (previous.accountingReceiptUnavailable !== true || ownPrice != null)
|
|
&& (acceptedMoney === null || compareCents(incomingMoney, acceptedMoney) !== 0))
|
|
|| (!rateCardOnly && envelope.receipt.costStatus !== undefined && envelope.receipt.costStatus !== comparison.costStatus)
|
|
|| (authoritative
|
|
&& receiptFingerprint(envelope.receipt.pricingProvenance) !== receiptFingerprint(comparison.pricingProvenance ?? null));
|
|
const debts = lateReceiptDebts(previous);
|
|
if ((!existing && changed && (!activeSource || newer)) || envelope.id in debts) {
|
|
if (!existing) {
|
|
debts[envelope.id] = false;
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, envelope.companyId);
|
|
await queueBudgetEvaluation(tx, { companyId: envelope.companyId, agentId: run.agentId,
|
|
projectId: typeof (previous.ledgerScope as Record<string, unknown> | undefined)?.projectId === "string"
|
|
? (previous.ledgerScope as Record<string, string>).projectId : null });
|
|
await tx.update(heartbeatRuns).set({ costAccountingPending: true,
|
|
accountingLastError: "late_usage_requires_reconciliation",
|
|
usageJson: { ...previous, accountingLateReceiptPending: true, accountingLateReceiptId: envelope.id,
|
|
accountingLateReceiptDebts: debts },
|
|
}).where(eq(heartbeatRuns.id, run.id));
|
|
}
|
|
return { reconciliationRequired: true };
|
|
}
|
|
return { reconciliationRequired: false };
|
|
}
|
|
// A replaced source cannot overwrite the accepted snapshot. Its immutable
|
|
// journal entry remains available to source-handoff/recovery diagnostics.
|
|
if (previous.accountingReceiptSourceId !== envelope.sourceId || Number(previous.accountingReceiptSequence ?? 0) >= envelope.sequence) {
|
|
const fences = previous.accountingReceiptSourceFences as Record<string, number> | undefined;
|
|
const [fenced] = !existing && typeof fences?.[envelope.sourceId] === "number" && envelope.sequence > fences[envelope.sourceId]
|
|
? await tx.select({ receiptJson: runUsageReceipts.receiptJson }).from(runUsageReceipts).where(and(
|
|
eq(runUsageReceipts.companyId, envelope.companyId), eq(runUsageReceipts.runId, run.id),
|
|
eq(runUsageReceipts.sourceId, envelope.sourceId), eq(runUsageReceipts.sequence, fences[envelope.sourceId]))) : [];
|
|
const fencedReceipt = fenced ? envelopeSchema.parse(fenced.receiptJson).receipt : undefined;
|
|
const sameFencedEvidence = fencedReceipt && receiptFingerprint({ usage: fencedReceipt.usage, money: liveRunUsageCostCents(fencedReceipt as AdapterUsageCheckpoint) })
|
|
=== receiptFingerprint({ usage: envelope.receipt.usage, money: liveRunUsageCostCents(envelope.receipt as AdapterUsageCheckpoint) });
|
|
if (!existing && typeof fences?.[envelope.sourceId] === "number" && envelope.sequence > fences[envelope.sourceId] && !sameFencedEvidence) {
|
|
await queueBudgetEvaluation(tx, { companyId: envelope.companyId, agentId: run.agentId,
|
|
projectId: typeof (previous.ledgerScope as Record<string, unknown> | undefined)?.projectId === "string"
|
|
? (previous.ledgerScope as Record<string, string>).projectId : null });
|
|
await tx.update(heartbeatRuns).set({ costAccountingPending: true, accountingLastError: "usage_after_source_handoff_requires_reconciliation",
|
|
usageJson: { ...previous, accountingCaptureFailed: true, accountingReceiptReady: false,
|
|
accountingSourceReceiptPending: envelope.id },
|
|
}).where(eq(heartbeatRuns.id, run.id));
|
|
}
|
|
return { reconciliationRequired: false };
|
|
}
|
|
const receipt = envelope.receipt;
|
|
const billingType = receipt.billingType === "api" ? "metered_api" : receipt.billingType === "subscription" ? "subscription_included" : receipt.billingType ?? "unknown";
|
|
const oldProjection = previous.budgetExposure && typeof previous.budgetExposure === "object"
|
|
? previous.budgetExposure as Record<string, unknown> : {};
|
|
const canonicalProjection = typeof oldProjection.sourceId === "string" && typeof oldProjection.sequence === "number";
|
|
let previousCost = canonicalProjection ? null : knownRunCostCents(previous);
|
|
if (typeof oldProjection.costCents === "string") {
|
|
try {
|
|
const projected = normalizeCents(oldProjection.costCents);
|
|
if (compareCents(projected, 0) >= 0 && (canonicalProjection || compareCents(projected, previousCost ?? "0") > 0)) previousCost = projected;
|
|
} catch { /* Older malformed projections are not billing evidence. */ }
|
|
}
|
|
const reportedCost = envelope.liveCostCents !== undefined ? envelope.liveCostCents
|
|
: liveRunUsageCostCents({ ...receipt, billingType } as AdapterUsageCheckpoint);
|
|
const observedCost = reportedCost === null ? previousCost
|
|
: !receipt.complete && previousCost !== null && compareCents(previousCost, reportedCost) > 0 ? previousCost : reportedCost;
|
|
// Running checkpoints cannot settle yet. Their terminal transition owns
|
|
// the accounting wake; only a late receipt needs to reactivate that worker.
|
|
if (!["queued", "running"].includes(run.status)) await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, envelope.companyId);
|
|
await tx.update(heartbeatRuns).set({
|
|
costAccountingPending: true,
|
|
accountingLastError: previous.accountingCaptureFailed === true ? "usage_capture_failed" : null,
|
|
usageJson: { ...previous, ...receipt.usage,
|
|
provider: receipt.provider ?? "unknown", biller: receipt.biller ?? receipt.provider ?? "unknown", model: receipt.model ?? "unknown", billingType,
|
|
costUsd: receipt.costUsd ?? null, costUsdExact: receipt.costUsdExact ?? null, cacheAdjustedCostUsd: receipt.cacheAdjustedCostUsd ?? null,
|
|
usageByModel: receipt.usageByModel ?? null, usageSource: receipt.usageBasis ?? "per_run", providerRequestId: receipt.providerRequestId ?? null,
|
|
accountingReceiptReady: previous.accountingCaptureFailed !== true && receipt.complete && receipt.usageBasis !== "session_cumulative", accountingReceiptId: envelope.id,
|
|
accountingReceiptSequence: envelope.sequence, accountingReceiptReceivedAt: envelope.receivedAt,
|
|
costStatus: receipt.costStatus ?? null,
|
|
budgetExposure: { sourceId: envelope.sourceId, sequence: envelope.sequence, costCents: observedCost },
|
|
pricingProvenance: receipt.pricingProvenance ?? { source: receipt.costUsd != null || receipt.costUsdExact != null ? "provider_reported" : "unknown", version: "accounting-receipt/v1" },
|
|
},
|
|
}).where(and(eq(heartbeatRuns.id, run.id), isNull(heartbeatRuns.costAccountedAt)));
|
|
const scope = previous.ledgerScope && typeof previous.ledgerScope === "object" ? previous.ledgerScope as Record<string, unknown> : {};
|
|
await queueBudgetEvaluation(tx, { companyId: envelope.companyId, agentId: run.agentId,
|
|
projectId: typeof scope.projectId === "string" ? scope.projectId : null });
|
|
return { reconciliationRequired: false };
|
|
}
|
|
|
|
const receiptTokenKeys = ["inputTokens", "cachedInputTokens", "outputTokens"] as const;
|
|
|
|
function lateDeltaTotals(usage: Record<string, unknown>) {
|
|
const saved = (usage.accountingLateDeltas ?? {}) as Record<string, unknown>;
|
|
const count = (key: string) => typeof saved[key] === "number" ? saved[key] as number : 0;
|
|
return { tokens: { inputTokens: count("inputTokens"), cachedInputTokens: count("cachedInputTokens"), outputTokens: count("outputTokens") },
|
|
units: typeof saved.units === "string" ? BigInt(saved.units) : 0n };
|
|
}
|
|
|
|
function formatUsdUnits(units: bigint) {
|
|
return `${units / 1_000_000_000n}.${String(units % 1_000_000_000n).padStart(9, "0")}`;
|
|
}
|
|
|
|
/** A provider can report more usage after its run's charge was posted. Cumulative
|
|
* reports make the increment exact, so post it instead of leaving the run wrong.
|
|
* Lower or conflicting evidence stays visible debt for an operator to review. */
|
|
function lateUsageGrowth(previous: Record<string, unknown>, late: UsageReceiptEnvelope,
|
|
event: typeof costEvents.$inferSelect, newer: boolean, reconciled?: UsageReceiptEnvelope["receipt"]) {
|
|
// A non-terminal report is never rate-card priced when it arrives, but its run
|
|
// totals are all this growth posts. Price them now, or the tokens stay free.
|
|
const receipt = priceAnthropicReceipt(priceCodexReceipt({ ...late.receipt, complete: true } as AdapterUsageCheckpoint)) as UsageReceiptEnvelope["receipt"];
|
|
const accepted = (key: string) => typeof previous[key] === "number" ? previous[key] as number : 0;
|
|
const billingType = receipt.billingType === "api" ? "metered_api"
|
|
: receipt.billingType === "subscription" ? "subscription_included" : receipt.billingType ?? "unknown";
|
|
if (!receipt.usage || receipt.usageBasis === "session_cumulative" || receipt.usageByModel?.length
|
|
|| (Array.isArray(previous.usageByModel) && previous.usageByModel.length) || receipt.model !== event.model
|
|
|| receipt.provider !== event.provider || (receipt.biller ?? receipt.provider) !== event.biller || billingType !== event.billingType) return null;
|
|
if (!late.lateDelta && (!newer || late.sourceId !== previous.accountingReceiptSourceId)) return null;
|
|
const reported = (key: typeof receiptTokenKeys[number]) => receipt.usage?.[key] ?? 0;
|
|
const deltas = lateDeltaTotals(previous);
|
|
// A source's cumulative receipts never include increments from later runs.
|
|
const tokens = Object.fromEntries(receiptTokenKeys.map(key =>
|
|
[key, late.lateDelta ? reported(key) : reported(key) - (accepted(key) - deltas.tokens[key])])) as Record<typeof receiptTokenKeys[number], number>;
|
|
if (receiptTokenKeys.some(key => tokens[key] < 0) || receiptTokenKeys.every(key => tokens[key] === 0)) return null;
|
|
const moneyValue = (value: unknown) => typeof value === "number" || typeof value === "string" ? usdToUnits(value) : null;
|
|
const acceptedUnits = moneyValue(previous.cacheAdjustedCostUsd) ?? moneyValue(previous.costUsdExact) ?? moneyValue(previous.costUsd);
|
|
const reportedUnits = moneyValue(receipt.cacheAdjustedCostUsd) ?? moneyValue(receipt.costUsdExact) ?? moneyValue(receipt.costUsd);
|
|
const subscription = billingType === "subscription_included";
|
|
// A price correction already moved the accepted total, so measure from it.
|
|
const correctedTotal = reconciled ? moneyValue(reconciled.cacheAdjustedCostUsd) ?? moneyValue(reconciled.costUsdExact) ?? moneyValue(reconciled.costUsd) : null;
|
|
const units = subscription ? 0n : reportedUnits === null ? null
|
|
: late.lateDelta ? reportedUnits : reportedUnits - (correctedTotal ?? (acceptedUnits ?? 0n) - deltas.units);
|
|
if (units !== null && units < 0n) return null;
|
|
const totalUnits = units === null ? acceptedUnits : (acceptedUnits ?? 0n) + units;
|
|
return { tokens, units, pricingProvenance: receipt.pricingProvenance, costStatus: receipt.costStatus, usage: {
|
|
inputTokens: accepted("inputTokens") + tokens.inputTokens,
|
|
cachedInputTokens: accepted("cachedInputTokens") + tokens.cachedInputTokens,
|
|
outputTokens: accepted("outputTokens") + tokens.outputTokens,
|
|
...(late.lateDelta ? { accountingLateDeltas: {
|
|
inputTokens: deltas.tokens.inputTokens + tokens.inputTokens,
|
|
cachedInputTokens: deltas.tokens.cachedInputTokens + tokens.cachedInputTokens,
|
|
outputTokens: deltas.tokens.outputTokens + tokens.outputTokens,
|
|
units: String(deltas.units + (units ?? 0n)),
|
|
} } : {}),
|
|
// Keep the integrity check's receipt total equal to the run's charges.
|
|
...(subscription || units === null || totalUnits === null ? {} : { cacheAdjustedCostUsd: null, costUsd: null, costUsdExact: formatUsdUnits(totalUnits) }),
|
|
...(units === null && !subscription ? { costStatus: "unpriced" } : {}),
|
|
} };
|
|
}
|
|
|
|
/** Caller owns the run row. A money-only authoritative revision can correct one
|
|
* existing final charge. Ambiguous usage/source/model evidence remains debt. */
|
|
export async function reconcileLateRunUsageReceiptInTransaction(tx: Db, run: typeof heartbeatRuns.$inferSelect, publications: ActivityPublication[]) {
|
|
const previous = run.usageJson ?? {};
|
|
if (previous.accountingLateReceiptPending !== true || typeof previous.accountingLateReceiptId !== "string") return false;
|
|
// Unchanged attempted debt is excluded from background scans. An explicit
|
|
// operator retry still rechecks it after a reviewed ledger correction.
|
|
const [saved] = await tx.select({ receiptJson: runUsageReceipts.receiptJson }).from(runUsageReceipts).where(and(
|
|
eq(runUsageReceipts.companyId, run.companyId), eq(runUsageReceipts.runId, run.id),
|
|
sql`${runUsageReceipts.id}::text = ${previous.accountingLateReceiptId}`));
|
|
const late = saved ? envelopeSchema.parse(saved.receiptJson) : undefined;
|
|
const receipt = late?.receipt;
|
|
const [reconciled] = typeof previous.accountingReconciledReceiptId === "string"
|
|
? await tx.select({ receiptJson: runUsageReceipts.receiptJson }).from(runUsageReceipts).where(and(
|
|
eq(runUsageReceipts.runId, run.id), eq(runUsageReceipts.companyId, run.companyId),
|
|
sql`${runUsageReceipts.id}::text = ${previous.accountingReconciledReceiptId}`)) : [];
|
|
const acceptedSequence = acceptedReceiptSequence(previous, reconciled ? envelopeSchema.parse(reconciled.receiptJson) : undefined);
|
|
const events = await tx.select({ event: costEvents, exact: sql<string>`${costEvents.costCents}::text` }).from(costEvents)
|
|
.where(and(eq(costEvents.companyId, run.companyId), eq(costEvents.heartbeatRunId, run.id),
|
|
eq(costEvents.idempotencyKey, `heartbeat:${run.id}:final`))).for("update");
|
|
const rawAmount = receipt?.cacheAdjustedCostUsd ?? receipt?.costUsdExact ?? receipt?.costUsd;
|
|
const automaticCorrectionPrefix = `heartbeat:${run.id}:late:`;
|
|
const [reviewedValuation] = events.length === 1 ? await tx.select({ id: costAdjustments.id }).from(costAdjustments)
|
|
.where(and(eq(costAdjustments.companyId, run.companyId), eq(costAdjustments.costEventId, events[0].event.id),
|
|
sql`not (${costAdjustments.actorId} = 'cost_accounting'
|
|
and left(${costAdjustments.idempotencyKey}, length(${automaticCorrectionPrefix})) = ${automaticCorrectionPrefix})`)).limit(1) : [];
|
|
const lateDeltas = lateDeltaTotals(previous);
|
|
const sameTokens = receipt?.usage && receiptTokenKeys.every(key =>
|
|
Number(previous[key] ?? 0) - lateDeltas.tokens[key] === Number(receipt.usage?.[key] ?? 0));
|
|
const authoritative = receipt?.pricingProvenance && !["rate_card", "unknown"].includes(receipt.pricingProvenance.source);
|
|
const billingType = receipt?.billingType === "api" ? "metered_api"
|
|
: receipt?.billingType === "subscription" ? "subscription_included" : receipt?.billingType ?? "unknown";
|
|
const biller = receipt?.biller ?? receipt?.provider ?? "unknown";
|
|
const qualified = late && receipt?.complete && receipt.usageBasis !== "session_cumulative"
|
|
&& late.sourceId === previous.accountingReceiptSourceId && sameTokens
|
|
&& (authoritative || (previous.accountingReceiptUnavailable === true && receipt.pricingProvenance?.source === "rate_card"))
|
|
&& rawAmount != null && receipt.costStatus !== "unpriced" && events.length === 1
|
|
&& receipt.model === events[0].event.model && receipt.provider === events[0].event.provider
|
|
&& biller === events[0].event.biller && billingType === events[0].event.billingType
|
|
&& (!receipt.providerRequestId || !events[0].event.providerRequestId || receipt.providerRequestId === events[0].event.providerRequestId)
|
|
&& (!receipt.usageByModel || receipt.usageByModel.length === 0);
|
|
const debts = lateReceiptDebts(previous);
|
|
const currentId = previous.accountingLateReceiptId;
|
|
async function updateDebtState(reconciled: boolean, advanceReceiptIdentity = reconciled) {
|
|
if (reconciled) delete debts[currentId];
|
|
else debts[currentId] = true;
|
|
const nextId = Object.keys(debts).find(id => !debts[id]) ?? Object.keys(debts)[0] ?? null;
|
|
await tx.update(heartbeatRuns).set({ costAccountingPending: nextId !== null,
|
|
accountingLastError: nextId ? "late_usage_requires_reconciliation" : null, accountingLastAttemptAt: new Date(),
|
|
usageJson: { ...previous, accountingLateReceiptPending: nextId !== null,
|
|
accountingLateReceiptDebts: debts, accountingLateReceiptId: nextId,
|
|
accountingLateReceiptAttemptedId: nextId && debts[nextId] ? nextId : null,
|
|
...(reconciled && advanceReceiptIdentity ? { accountingReconciledReceiptId: currentId,
|
|
accountingReconciledReceiptSourceId: late!.sourceId,
|
|
accountingReconciledReceiptSequence: late!.sequence } : {}),
|
|
// A complete receipt now accounts for the usage a closure timeout
|
|
// could not see, so its unknown-spend charge stops blocking.
|
|
// Only a complete whole-run receipt from the run's own source does;
|
|
// an increment reported by a later run does not.
|
|
...(reconciled && late?.receipt.complete && !late.lateDelta && late.sourceId === previous.accountingReceiptSourceId
|
|
&& previous.accountingClosureTimedOut === true ? { accountingClosureTimedOut: false } : {}),
|
|
},
|
|
}).where(eq(heartbeatRuns.id, run.id));
|
|
}
|
|
const growth = late && events.length === 1 && !reviewedValuation
|
|
? lateUsageGrowth(previous, late, events[0].event, late.sequence > acceptedSequence,
|
|
reconciled ? envelopeSchema.parse(reconciled.receiptJson).receipt : undefined) : null;
|
|
if (growth && late) {
|
|
const { event } = events[0];
|
|
const costStatus = growth.units === null && event.billingType !== "subscription_included" ? "unpriced" : growth.costStatus ?? event.costStatus;
|
|
await createCostEventInTransaction(tx, run.companyId, {
|
|
idempotencyKey: `${automaticCorrectionPrefix}${late.id}`, agentId: run.agentId, heartbeatRunId: run.id,
|
|
issueId: event.issueId, projectId: event.projectId, goalId: event.goalId, billingCode: event.billingCode,
|
|
provider: event.provider, biller: event.biller, providerRequestId: receipt?.providerRequestId ?? null,
|
|
pricingProvenance: growth.pricingProvenance ?? event.pricingProvenance, model: event.model, billingType: event.billingType,
|
|
costStatus, inputTokens: growth.tokens.inputTokens, cachedInputTokens: growth.tokens.cachedInputTokens,
|
|
outputTokens: growth.tokens.outputTokens, costCents: growth.units === null ? "0" : unitsToCents(growth.units),
|
|
occurredAt: new Date(late.receivedAt),
|
|
}, publications, undefined, { deferBudgetEvaluation: true });
|
|
Object.assign(previous, growth.usage);
|
|
await logActivity(tx, { companyId: run.companyId, actorType: "system", actorId: "cost_accounting", action: "cost.late_usage_posted",
|
|
entityType: "heartbeat_run", entityId: run.id, runId: run.id, agentId: run.agentId,
|
|
details: { receiptId: late.id, attribution: late.lateDelta?.attribution ?? "exact", reportedByRunId: late.lateDelta?.reportedByRunId ?? run.id,
|
|
inputTokens: growth.tokens.inputTokens, cachedInputTokens: growth.tokens.cachedInputTokens, outputTokens: growth.tokens.outputTokens,
|
|
costCents: growth.units === null ? null : unitsToCents(growth.units) } }, publications);
|
|
await queueBudgetEvaluation(tx, { companyId: run.companyId, agentId: run.agentId, projectId: event.projectId });
|
|
await updateDebtState(true, late.lateDelta === undefined);
|
|
if (Object.keys(debts).length === 0) await notifyDeliveryWork(tx, DELIVERY_QUEUES.strandedAgentStatus,
|
|
JSON.stringify({ companyId: run.companyId, agentId: run.agentId }));
|
|
return true;
|
|
}
|
|
// Totals are cumulative, so an older receipt at or below the accepted totals
|
|
// from this source is already covered. Conflicting billing stays visible debt.
|
|
if (late && !late.lateDelta && late.sourceId === previous.accountingReceiptSourceId && late.sequence <= acceptedSequence && receipt?.usage
|
|
&& receipt.usageBasis !== "session_cumulative" && events.length === 1 && receipt.model === events[0].event.model
|
|
&& receipt.provider === events[0].event.provider && biller === events[0].event.biller && billingType === events[0].event.billingType
|
|
&& receiptTokenKeys.every(key => (receipt.usage?.[key] ?? 0) <= Number(previous[key] ?? 0) - lateDeltas.tokens[key])) {
|
|
await queueBudgetEvaluation(tx, { companyId: run.companyId, agentId: run.agentId, projectId: events[0].event.projectId });
|
|
await updateDebtState(true, false);
|
|
if (Object.keys(debts).length === 0) await notifyDeliveryWork(tx, DELIVERY_QUEUES.strandedAgentStatus,
|
|
JSON.stringify({ companyId: run.companyId, agentId: run.agentId }));
|
|
return true;
|
|
}
|
|
if (!qualified) { await updateDebtState(false); return false; }
|
|
// A receipt's money covers the whole run. Earlier late charges already hold
|
|
// their share, so the original charge may only be corrected to the remainder.
|
|
const lateCharges = await tx.select({ exact: sql<string>`${costEvents.costCents}::text` }).from(costEvents).where(and(
|
|
eq(costEvents.companyId, run.companyId), eq(costEvents.heartbeatRunId, run.id),
|
|
sql`left(${costEvents.idempotencyKey}, length(${automaticCorrectionPrefix})) = ${automaticCorrectionPrefix}`));
|
|
const correctedUnits = usdToUnits(rawAmount) + lateDeltas.units - lateCharges.reduce((sum, charge) => sum + centsToUnits(charge.exact), 0n);
|
|
if (correctedUnits < 0n) { await updateDebtState(false); return false; }
|
|
const corrected = receipt.billingType === "subscription_included" || receipt.billingType === "subscription"
|
|
? "0.0000000" : unitsToCents(correctedUnits);
|
|
if (reviewedValuation) {
|
|
if (compareCents(events[0].exact, corrected) !== 0) { await updateDebtState(false); return false; }
|
|
// The operator has already reconciled this exact billing evidence. Resolve
|
|
// the hold without creating another adjustment or replacing their pricing
|
|
// authority, and never rewind a newer accepted source sequence.
|
|
await queueBudgetEvaluation(tx, { companyId: run.companyId, agentId: run.agentId,
|
|
projectId: typeof (previous.ledgerScope as Record<string, unknown> | undefined)?.projectId === "string"
|
|
? (previous.ledgerScope as Record<string, string>).projectId : null });
|
|
await updateDebtState(true, late.sequence > acceptedSequence);
|
|
if (Object.keys(debts).length === 0) await notifyDeliveryWork(tx, DELIVERY_QUEUES.strandedAgentStatus,
|
|
JSON.stringify({ companyId: run.companyId, agentId: run.agentId }));
|
|
return true;
|
|
}
|
|
if (late.sequence <= acceptedSequence) {
|
|
// A higher complete receipt from this same cumulative source already
|
|
// supersedes this money-only debt. Resolve it without repricing or rewinding
|
|
// the reconciled receipt identity; unrelated source/usage debt remains.
|
|
await queueBudgetEvaluation(tx, { companyId: run.companyId, agentId: run.agentId,
|
|
projectId: typeof (previous.ledgerScope as Record<string, unknown> | undefined)?.projectId === "string"
|
|
? (previous.ledgerScope as Record<string, string>).projectId : null });
|
|
await updateDebtState(true, false);
|
|
if (Object.keys(debts).length === 0) await notifyDeliveryWork(tx, DELIVERY_QUEUES.strandedAgentStatus,
|
|
JSON.stringify({ companyId: run.companyId, agentId: run.agentId }));
|
|
return true;
|
|
}
|
|
if (compareCents(events[0].exact, corrected) !== 0 || events[0].event.costStatus !== "reported"
|
|
|| receiptFingerprint(events[0].event.pricingProvenance) !== receiptFingerprint(receipt.pricingProvenance)) {
|
|
await applyCostCorrectionInTransaction(tx, publications, { companyId: run.companyId, eventId: events[0].event.id,
|
|
idempotencyKey: `heartbeat:${run.id}:late:${late.id}`, expectedCents: events[0].exact, correctedCents: corrected,
|
|
reason: "Authoritative late run usage receipt", pricing: receipt.pricingProvenance!, actorId: "cost_accounting" });
|
|
}
|
|
await queueBudgetEvaluation(tx, { companyId: run.companyId, agentId: run.agentId,
|
|
projectId: typeof (previous.ledgerScope as Record<string, unknown> | undefined)?.projectId === "string"
|
|
? (previous.ledgerScope as Record<string, string>).projectId : null });
|
|
await updateDebtState(true);
|
|
if (Object.keys(debts).length === 0) await notifyDeliveryWork(tx, DELIVERY_QUEUES.strandedAgentStatus,
|
|
JSON.stringify({ companyId: run.companyId, agentId: run.agentId }));
|
|
return true;
|
|
}
|
|
|
|
export async function persistUsageReceipt(db: Db, raw: UsageReceiptEnvelope) {
|
|
const envelope = envelopeSchema.parse(raw);
|
|
// Late evidence is committed as run-level debt that accounting resolves or
|
|
// surfaces. It never fails the delivering session or an unrelated later run.
|
|
await withOperationAccountingTransaction(db, envelope.companyId,
|
|
tx => persistUsageReceiptInTransaction(tx, envelope));
|
|
}
|
|
|
|
export async function replayUsageReceipts(db: Db, directory = usageReceiptSpoolPath(), limit = 100) {
|
|
let files: string[];
|
|
try { files = (await fs.readdir(directory)).filter(name => name.endsWith(".json")).sort(); }
|
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { replayed: 0, failed: 0 }; throw error; }
|
|
let replayed = 0, failed = 0;
|
|
for (const name of files.slice(0, limit)) {
|
|
const file = path.join(directory, name);
|
|
try {
|
|
const info = await fs.lstat(file);
|
|
if (!info.isFile() || info.size > 1024 * 1024) throw new Error("Invalid accounting spool entry");
|
|
await persistUsageReceipt(db, envelopeSchema.parse(JSON.parse(await fs.readFile(file, "utf8"))));
|
|
await fs.rm(file, { force: true }); replayed++;
|
|
} catch (error) {
|
|
failed++;
|
|
// Rotate failures behind fresh entries without discarding the evidence.
|
|
await fs.rename(file, path.join(directory, `retry-${Date.now()}-${randomUUID()}.json`)).catch(() => undefined);
|
|
logger.error({ err: error, receiptFile: name }, "Accounting spool receipt remains pending");
|
|
}
|
|
}
|
|
return { replayed, failed };
|
|
}
|
|
|
|
type ReceiptIdentity = { companyId: unknown; runId: unknown } | null;
|
|
export type UsageReceiptIndex = Map<string, ReceiptIdentity>;
|
|
function receiptIdentity(raw: unknown): ReceiptIdentity {
|
|
return raw && typeof raw === "object" && "companyId" in raw && "runId" in raw
|
|
? { companyId: raw.companyId, runId: raw.runId } : null;
|
|
}
|
|
|
|
/** Build once per recovery batch, before acquiring any accounting lock.
|
|
* Spool publications are immutable, uniquely named files. Cache identities,
|
|
* never receipt contents: matching evidence is revalidated during settlement. */
|
|
export async function indexPendingUsageReceipts(directory = usageReceiptSpoolPath()): Promise<UsageReceiptIndex> {
|
|
const index: UsageReceiptIndex = new Map();
|
|
let names: string[];
|
|
try { names = await fs.readdir(directory); }
|
|
// Indexing is only an optimization. The locked drain reports missing or
|
|
// unreadable storage through the existing per-run recovery error path.
|
|
catch { return index; }
|
|
for (const name of names.filter(name => name.endsWith(".json"))) {
|
|
const file = path.join(directory, name);
|
|
try {
|
|
const info = await fs.lstat(file);
|
|
if (!info.isFile() || info.size > 1024 * 1024) { index.set(file, null); continue; }
|
|
index.set(file, receiptIdentity(JSON.parse(await fs.readFile(file, "utf8"))));
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) index.set(file, null);
|
|
// Unreadable or concurrently moved entries remain unknown and are
|
|
// checked again under the lock, preserving per-run failure reporting.
|
|
}
|
|
}
|
|
return index;
|
|
}
|
|
|
|
export type PendingRunUsageReceipt = { file: string; envelope: UsageReceiptEnvelope };
|
|
|
|
/** Capture immutable spool evidence before a database transaction. Finalizers
|
|
* revalidate their closed producer identity after taking the run-row fence. */
|
|
export async function readPendingRunUsageReceipts(input: { companyId: string; runId: string }, directory = usageReceiptSpoolPath(), index?: UsageReceiptIndex): Promise<PendingRunUsageReceipt[]> {
|
|
const pending = new Map<string, PendingRunUsageReceipt>();
|
|
for (let scan = 0; scan < 3; scan++) {
|
|
const names = await fs.readdir(directory).catch(error => {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
throw error;
|
|
});
|
|
let moved = false;
|
|
for (const name of names.filter(name => name.endsWith(".json"))) {
|
|
const file = path.join(directory, name);
|
|
if (pending.has(file)) continue;
|
|
const indexed = index?.get(file);
|
|
if (indexed !== undefined && (indexed?.companyId !== input.companyId || indexed?.runId !== input.runId)) continue;
|
|
try {
|
|
const info = await fs.lstat(file);
|
|
if (!info.isFile() || info.size > 1024 * 1024) continue;
|
|
const raw: unknown = JSON.parse(await fs.readFile(file, "utf8"));
|
|
const identity = receiptIdentity(raw);
|
|
if (identity?.companyId !== input.companyId || identity?.runId !== input.runId) continue;
|
|
pending.set(file, { file, envelope: envelopeSchema.parse(raw) });
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") { moved = true; continue; }
|
|
if (error instanceof SyntaxError) continue;
|
|
throw error;
|
|
}
|
|
}
|
|
if (!moved) return [...pending.values()];
|
|
}
|
|
throw new Error("Accounting spool changed during recovery; retry required");
|
|
}
|
|
|
|
export async function recoverPendingRunUsageReceipts(db: Db, input: { companyId: string; runId: string }, directory = usageReceiptSpoolPath(), options: { retainFiles?: boolean; index?: UsageReceiptIndex } = {}) {
|
|
const pending = await readPendingRunUsageReceipts(input, directory, options.index);
|
|
for (const item of pending) {
|
|
await persistUsageReceipt(db, item.envelope);
|
|
if (!options.retainFiles) await fs.rm(item.file, { force: true });
|
|
}
|
|
return pending.map(item => item.file);
|
|
}
|
|
|
|
export async function createRunUsageRecorder(db: Db, input: { companyId: string; runId: string; adapterType: string }, directory = usageReceiptSpoolPath()) {
|
|
registerIdleSpoolDirectory(directory);
|
|
const sourceId = randomUUID();
|
|
// Verify durable storage before starting paid work, including a directory
|
|
// sync. The database binding fences checkpoints from replaced controllers.
|
|
await ensureDurableDirectory(directory);
|
|
const probe = await fs.open(path.join(directory, `${sourceId}.probe`), "wx", 0o600);
|
|
try { await probe.sync(); } finally { await probe.close(); await fs.rm(path.join(directory, `${sourceId}.probe`)); }
|
|
await syncDirectory(directory);
|
|
const [beforeHandoff] = await db.select({ usageJson: heartbeatRuns.usageJson }).from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
|
const receiptIdentity = (state: Record<string, unknown> | null | undefined) => JSON.stringify([
|
|
state?.accountingReceiptSourceId, state?.accountingReceiptSequence, state?.accountingReceiptId,
|
|
]);
|
|
const pending = await readPendingRunUsageReceipts(input, directory);
|
|
const bound = await withOperationAccountingTransaction(db, input.companyId, async tx => {
|
|
const [run] = await tx.select({ id: heartbeatRuns.id, agentId: heartbeatRuns.agentId,
|
|
usageJson: heartbeatRuns.usageJson, costAccountedAt: heartbeatRuns.costAccountedAt }).from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId))).for("update");
|
|
if (!run) throw notFound("Accounting run not found");
|
|
if (run.costAccountedAt) throw conflict("Run cannot accept a new usage recorder");
|
|
if (receiptIdentity(run.usageJson) !== receiptIdentity(beforeHandoff?.usageJson))
|
|
throw conflict("Usage source changed during receipt recovery; retry recorder handoff");
|
|
for (const item of pending) await persistUsageReceiptInTransaction(tx, item.envelope);
|
|
await queueBudgetEvaluation(tx, { companyId: input.companyId, agentId: run.agentId,
|
|
projectId: typeof (run.usageJson?.ledgerScope as Record<string, unknown> | undefined)?.projectId === "string"
|
|
? (run.usageJson!.ledgerScope as Record<string, string>).projectId : null });
|
|
const [current] = await tx.select({ usageJson: heartbeatRuns.usageJson }).from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
|
|
const old = current.usageJson ?? {};
|
|
const fences = { ...(old.accountingReceiptSourceFences as Record<string, number> | undefined) };
|
|
if (typeof old.accountingReceiptSourceId === "string") fences[old.accountingReceiptSourceId] = Number(old.accountingReceiptSequence ?? 0);
|
|
return tx.update(heartbeatRuns).set({ usageJson: { ...old, accountingReceiptSourceFences: fences,
|
|
accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0, accountingReceiptReady: false } })
|
|
.where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId), isNull(heartbeatRuns.costAccountedAt))).returning({ id: heartbeatRuns.id, runtimeMode: heartbeatRuns.runtimeMode, usageJson: heartbeatRuns.usageJson });
|
|
});
|
|
for (const item of pending) await fs.rm(item.file, { force: true });
|
|
if (bound.length !== 1) throw conflict("Run cannot accept a new usage recorder");
|
|
let sequence = 0;
|
|
let currentAttempt = "default";
|
|
const attempts = new Map<string, AdapterUsageCheckpoint>();
|
|
let initialLiveUsage: Parameters<typeof createLiveRunUsageProjection>[0];
|
|
// Native run deltas retain their baseline across same-run recovery. Restore
|
|
// the previous snapshot as the same attempt, never add it a second time.
|
|
// An empty final result must not erase the last durable observation.
|
|
const priorReceiptId = bound[0].usageJson?.accountingReceiptId;
|
|
if (bound[0].runtimeMode === "native" && typeof priorReceiptId === "string") {
|
|
const [previous] = await db.select().from(runUsageReceipts).where(and(
|
|
eq(runUsageReceipts.id, priorReceiptId), eq(runUsageReceipts.runId, input.runId), eq(runUsageReceipts.companyId, input.companyId),
|
|
));
|
|
if (previous) {
|
|
const restored = envelopeSchema.parse(previous.receiptJson);
|
|
const receipt = restored.receipt as AdapterUsageCheckpoint;
|
|
attempts.set("default", receipt);
|
|
initialLiveUsage = { receipt, costCents: restored.liveCostCents };
|
|
}
|
|
}
|
|
const projectLiveUsage = createLiveRunUsageProjection(initialLiveUsage);
|
|
let chain = Promise.resolve();
|
|
let captureFailed = bound[0].usageJson?.accountingCaptureFailed === true;
|
|
let finishFailedCapture: (() => void) | undefined;
|
|
async function persistFailure() {
|
|
if (!captureFailed) return;
|
|
const persisted = await withOperationAccountingTransaction(db, input.companyId, async tx => {
|
|
const [run] = await tx.select({ agentId: heartbeatRuns.agentId, costAccountedAt: heartbeatRuns.costAccountedAt,
|
|
usageJson: heartbeatRuns.usageJson }).from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId))).for("update");
|
|
if (!run || run.costAccountedAt || run.usageJson?.accountingReceiptSourceId !== sourceId) return false;
|
|
if (run.usageJson?.accountingCaptureFailed === true) return true;
|
|
await queueBudgetEvaluation(tx, { companyId: input.companyId, agentId: run.agentId,
|
|
projectId: typeof (run.usageJson?.ledgerScope as Record<string, unknown> | undefined)?.projectId === "string"
|
|
? (run.usageJson!.ledgerScope as Record<string, string>).projectId : null });
|
|
const rows = await tx.update(heartbeatRuns).set({
|
|
costAccountingPending: true, accountingLastError: "usage_capture_failed",
|
|
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || '{"accountingCaptureFailed":true,"accountingReceiptReady":false}'::jsonb`,
|
|
}).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId),
|
|
isNull(heartbeatRuns.costAccountedAt), sql`${heartbeatRuns.usageJson}->>'accountingReceiptSourceId' = ${sourceId}`)).returning({ id: heartbeatRuns.id });
|
|
return rows.length === 1;
|
|
});
|
|
if (persisted) { finishFailedCapture?.(); finishFailedCapture = undefined; }
|
|
}
|
|
function enqueue(action: () => Promise<AdapterUsageCheckpoint>) {
|
|
const finishQueuedCapture = beginIdleTrackedWork();
|
|
const result = chain.then(action).catch(async error => {
|
|
captureFailed = true;
|
|
finishFailedCapture ??= beginIdleTrackedWork();
|
|
// Persist before exposing rejection: the adapter's flush may throw before
|
|
// complete(), and failure finalization must not settle an older receipt.
|
|
try { await persistFailure(); }
|
|
catch (failure) { logger.error({ err: failure, runId: input.runId }, "Usage capture failure fence requires retry before finalization"); }
|
|
throw error;
|
|
});
|
|
chain = result.then(() => undefined, () => undefined).finally(finishQueuedCapture);
|
|
return result;
|
|
}
|
|
async function capture(raw: AdapterUsageCheckpoint) {
|
|
const parsed = checkpointSchema.parse(raw) as AdapterUsageCheckpoint;
|
|
const attempt = parsed.attemptId ?? currentAttempt;
|
|
if (!attempts.has(attempt)) currentAttempt = attempt;
|
|
const previous = attempts.get(attempt);
|
|
const priced = priceAnthropicReceipt(priceCodexReceipt({ ...parsed, usage: parsed.usage ?? previous?.usage,
|
|
complete: parsed.complete && !(previous?.usage && !parsed.usage) }));
|
|
priced.pricingProvenance ??= {
|
|
source: priced.costUsd != null || priced.costUsdExact != null || priced.cacheAdjustedCostUsd != null ? "provider_reported" : "unknown",
|
|
version: "accounting-receipt/v1",
|
|
};
|
|
attempts.set(attempt, priced);
|
|
const liveCostCents = projectLiveUsage(attempt, priced);
|
|
const parts = [...attempts.values()];
|
|
const latest = attempts.get(currentAttempt)!;
|
|
const receipt: AdapterUsageCheckpoint = { ...latest, complete: latest.complete && !captureFailed, usage: {
|
|
inputTokens: parts.reduce((sum, item) => sum + (item.usage?.inputTokens ?? 0), 0),
|
|
cachedInputTokens: parts.reduce((sum, item) => sum + (item.usage?.cachedInputTokens ?? 0), 0),
|
|
...(parts.some(item => item.usage?.cacheWriteTokens !== undefined) ? { cacheWriteTokens: parts.reduce((sum, item) => sum + (item.usage?.cacheWriteTokens ?? 0), 0) } : {}),
|
|
outputTokens: parts.reduce((sum, item) => sum + (item.usage?.outputTokens ?? 0), 0),
|
|
} };
|
|
if (parts.length > 1) {
|
|
let total = 0n, priced = true;
|
|
for (const part of parts) {
|
|
const subscription = ["subscription", "subscription_included"].includes(part.billingType ?? "");
|
|
const cost = subscription ? 0 : part.cacheAdjustedCostUsd ?? part.costUsdExact ?? part.costUsd;
|
|
if (cost == null || (!subscription && part.costStatus === "unpriced")) priced = false;
|
|
if (cost != null) total += usdToUnits(cost);
|
|
}
|
|
if (new Set(parts.map(part => part.billingType ?? "unknown")).size > 1) receipt.billingType = "unknown";
|
|
const decimal = `${total / 1_000_000_000n}.${String(total % 1_000_000_000n).padStart(9, "0")}`;
|
|
receipt.costUsdExact = decimal;
|
|
receipt.costUsd = Number(decimal);
|
|
receipt.cacheAdjustedCostUsd = null;
|
|
// A rejected resume can report a complete, explicit zero before the
|
|
// successful retry. It contributes neither spend nor tokens and must
|
|
// not discard the successful attempt's known model attribution.
|
|
const attributed = parts.filter(part => {
|
|
const cost = part.cacheAdjustedCostUsd ?? part.costUsdExact ?? part.costUsd;
|
|
return !part.complete || part.costStatus === "unpriced" || cost == null || usdToUnits(cost) !== 0n
|
|
|| Object.values(part.usage ?? {}).some(value => (value ?? 0) !== 0);
|
|
});
|
|
receipt.usageByModel = attributed.length === 1 ? attributed[0].usageByModel : undefined;
|
|
receipt.costStatus = priced && parts.some(part => part.costStatus === "estimated") ? "estimated" : priced ? "reported" : "unpriced";
|
|
receipt.pricingProvenance = { source: receipt.costStatus === "estimated" ? "rate_card" : "unknown", version: "per-attempt/v1", evidence: "Sum of attempted-run receipts; component prices are retained in the immutable receipt journal." };
|
|
// Every attempted provider must supply a final receipt before the total
|
|
// can be considered complete, even if a later attempt succeeded.
|
|
receipt.complete = !captureFailed && parts.every(part => part.complete);
|
|
}
|
|
const envelope: UsageReceiptEnvelope = { schema: "paperclip/accounting-receipt/v1", id: randomUUID(), ...input, sourceId, sequence: ++sequence, receivedAt: new Date().toISOString(), receipt, liveCostCents };
|
|
const file = await spoolUsageReceipt(envelope, directory);
|
|
crashPoint("accounting.usage_receipt.after_spool_before_db");
|
|
try {
|
|
await persistUsageReceipt(db, envelope);
|
|
crashPoint("accounting.usage_receipt.after_db_before_unlink");
|
|
await fs.rm(file, { force: true });
|
|
}
|
|
catch (error) {
|
|
// The fsynced receipt is durable even when the DB transaction failed.
|
|
// Wake the owned spool recovery path; never certify that failed write as free.
|
|
const { wakeCostAccountingWork } = await import("./budget-enforcement-worker.js");
|
|
wakeCostAccountingWork(db);
|
|
logger.warn({ err: error, runId: input.runId, receiptId: envelope.id }, "Usage receipt is durable on disk; database replay pending");
|
|
}
|
|
return receipt;
|
|
}
|
|
return {
|
|
capture: (receipt: AdapterUsageCheckpoint) => enqueue(() => capture(receipt)),
|
|
// Retry a transient database failure before heartbeat can mark the run
|
|
// terminal. A failed retry must propagate and leave finalization pending.
|
|
persistFailure: async () => { await chain; await persistFailure(); },
|
|
complete: (result: AdapterExecutionResult) => enqueue(async () => {
|
|
const final = checkpointSchema.parse({ ...result, complete: result.usageComplete !== false }) as AdapterUsageCheckpoint;
|
|
const previous = attempts.get(currentAttempt);
|
|
// A bounded stdout tail can lose a terminal record already saved from the
|
|
// full stream. Keep that stronger evidence, but never hide additional
|
|
// usage/charges or certify a snapshot inherited from a replaced recorder.
|
|
if (sequence > 0 && previous?.complete && result.usageComplete === false) {
|
|
const moreTokens = (["inputTokens", "cachedInputTokens", "cacheWriteTokens", "outputTokens"] as const)
|
|
.some(key => (final.usage?.[key] ?? 0) > (previous.usage?.[key] ?? 0));
|
|
const finalPrice = final.cacheAdjustedCostUsd ?? final.costUsdExact ?? final.costUsd;
|
|
const previousPrice = previous.cacheAdjustedCostUsd ?? previous.costUsdExact ?? previous.costUsd;
|
|
const moreCost = finalPrice != null && (previousPrice == null || usdToUnits(finalPrice) > usdToUnits(previousPrice));
|
|
if (!moreTokens && !moreCost) return capture(previous);
|
|
}
|
|
return capture(final);
|
|
}),
|
|
};
|
|
}
|
|
|
|
export async function readAcceptedRunUsageCheckpoint(db: Db, input: { companyId: string; runId: string }): Promise<AdapterUsageCheckpoint | undefined> {
|
|
const [run] = await db.select({ usageJson: heartbeatRuns.usageJson }).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId)));
|
|
const state = run?.usageJson;
|
|
if (typeof state?.accountingReceiptId !== "string") return undefined;
|
|
const [saved] = await db.select({ receiptJson: runUsageReceipts.receiptJson }).from(runUsageReceipts).where(and(
|
|
eq(runUsageReceipts.companyId, input.companyId), eq(runUsageReceipts.runId, input.runId),
|
|
sql`${runUsageReceipts.id}::text = ${state.accountingReceiptId}`));
|
|
if (!saved) return undefined;
|
|
const receipt = envelopeSchema.parse(saved.receiptJson);
|
|
if (receipt.sourceId !== state.accountingReceiptSourceId || receipt.sequence !== state.accountingReceiptSequence) return undefined;
|
|
return { ...receipt.receipt, complete: receipt.receipt.complete && state.accountingReceiptReady === true } as AdapterUsageCheckpoint;
|
|
}
|
|
|
|
const producerAccountingProjectionFields = [
|
|
"accountingReceiptOwner", "accountingReceiptSourceId", "accountingReceiptSequence", "accountingReceiptReady",
|
|
"accountingReceiptId", "accountingReceiptReceivedAt", "accountingCaptureFailed", "accountingReceiptSourceFences",
|
|
"accountingSourceReceiptPending", "accountingLateReceiptPending", "accountingLateReceiptId",
|
|
"accountingLateReceiptAttemptedId", "accountingReconciledReceiptId", "accountingLateReceiptDebts", "accountingLateDeltas",
|
|
"accountingReconciledReceiptSourceId", "accountingReconciledReceiptSequence",
|
|
"inputTokens", "outputTokens", "cachedInputTokens", "cacheWriteTokens",
|
|
"provider", "biller", "model", "billingType", "usageSource", "providerRequestId", "usageByModel",
|
|
"costUsd", "costUsdExact", "cacheAdjustedCostUsd", "costStatus", "pricingProvenance", "budgetExposure",
|
|
];
|
|
|
|
export function preserveAcceptedRunUsageProjectionSql(patch: Record<string, unknown>) {
|
|
const fields = sql.join(producerAccountingProjectionFields.map(field => sql`${field}`), sql`, `);
|
|
return sql`case when ${heartbeatRuns.usageJson}->>'accountingReceiptOwner' = 'native_producer'
|
|
then ${JSON.stringify(patch)}::jsonb || (select coalesce(jsonb_object_agg(key, value), '{}'::jsonb)
|
|
from jsonb_each(coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb)) where key in (${fields}))
|
|
else ${JSON.stringify(patch)}::jsonb end`;
|
|
}
|