mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Budgets hold new work while the cost of completed runs is unknown > - In a restart chaos wave, a native ACPX Claude run crossed a server kill, resumed, and succeeded. Its usage receipt never became complete. > - For the 10-minute closure bound, admission refused all work in the company. This included tasks that never crossed a fault. > - This pull request fixes the receipt for a turn that was lost on restore. It also stops a succeeded run from holding all new work. > - The benefit: one late usage report no longer freezes a company, and hard stops still apply. ## Linked Issues or Issue Description Refs #19700 (the accounting closure bound that this follows). **What happened?** A native ACPX Claude run crossed a server SIGKILL in the middle of a turn. The Runner restored the session, and the run succeeded 54 seconds after it started. All 17 usage receipts stayed incomplete. Accounting settled the run only at the 10-minute closure bound. During those 10 minutes, admission refused all company work with "Company cannot start work while completed runs await accounting". **Root cause** ACPX reports provisional usage (`runDeltaComplete: false`) until a turn ends. The kill lost the turn that was active, so that turn never got a complete report. The restored Runner failed that turn with `provider_turn_lost_on_restore`. Native turn accounting (`createNativeTurnAccounting`) required every terminal turn to have usage, so this turn kept every later receipt incomplete. This was true even after the continuation turn completed with full usage. The admission gate then counted the succeeded run as a pending terminal run for the whole company. **Expected behavior** The receipt closes when the run's turns end. A turn that was lost on restore makes the usage a lower bound. A succeeded run with only usage outstanding does not hold other work. ## What Changed - `native-usage-accounting.ts`: a turn that failed with `provider_turn_lost_on_restore` and has no complete usage no longer holds the receipt open. The snapshot marks `accountingUsageLost`. - `native-session-executor.ts` and `native-usage-delivery.ts`: such a receipt carries the provenance note "a provider turn was lost on restore and never reported its usage; observed usage is a lower bound". - `anthropic-pricing.ts` and `codex-pricing.ts`: the rate-card pricers keep a producer's provenance note. - `native-usage-delivery.ts` and `usage-receipts.ts`: remote producer delivery keeps a `lostOnRestore` marker on the receipt's producer event, so replay also excuses the lost turn. Receipts saved without the marker still replay. - `budget-exposure.ts` and `budgets.ts`: admission waits only for pending runs that ended without succeeding (`waitingRunCount`). A succeeded run whose provider reported observed usage is bounded like a running run: its observed cost counts toward spend and reservation capacity, and its settled charge can still reach the hard stop. - `budget-exposure.ts`: a closure that timed out because of a control-plane restart keeps the unpriced-usage exemptions, whatever its token count. Restart evidence is a replaced receipt source, a native recovery transition that is not an ordinary same-run retry, or a turn lost on restore. A second chaos run showed the need. It recovered and succeeded, but its final usage report was lost with the restart, so it settled as unpriced 0 cents. The blocking policy then opened a hard incident and paused the company. A timed-out closure with no restart evidence still counts. - `run-cost-accounting.ts`: a lost receipt that carries a positive provider-reported cost settles at that cost as an estimate marked as a lower bound, not as unpriced. - `paperclip-runner-tool-authority.ts` and `budgets.ts`: a running run's own tool mutations are evaluated as admitted work. Hard stops and pauses still refuse them. Accounting holds, recovering runs and budget inputs that await evaluation do not. In a third chaos run, a live run's write to its task document was refused during a temporary hold, so the run proposed a blocked result. - `doc/SPEC-implementation.md`: documents these rules. Policy choice: a succeeded run's provider work is over, so it has less unknown spend than a running run. Running runs never held admission. Failed, cancelled, timed-out and interrupted runs still hold new work, because their provider may not have stopped. A per-agent gate would still freeze unrelated work of the same agent. A per-reservation gate does not help when `reservationCents` is 0, which was the case in this wave. ## Verification - Live: a restart chaos wave ran master plus this PR (c2a48fb1e0) with no masking. It passed 6/6, and every task crossed a restart. There were 0 accounting holds, 0 refused agent writes, 0 company pauses, and every cost settled as estimated. - Red then green: `native-usage-accounting.test.ts`, "closes a run whose earlier turn was lost on restore, as a lower bound". It uses the event sequence of the chaos run. - Red then green: `closed-native-receipt-accounting.test.ts`, "does not hold other work for a succeeded run with only usage outstanding". It also checks that the hard stop still applies. - I changed "neither pauses nor holds admission while a succeeded run's charge commits" to the new policy. A failed run in the same state still holds new work. - Red then green: `closed-native-receipt-accounting.test.ts`, "never hard-stops a blocking policy for a restart-derived timed-out receipt". It uses the zero-token row shape of the chaos run and a case with unpriced tokens. Without the fix, the company was paused. - Red then green: `runner-api.integration.test.ts`, "lets a running run write during a temporary accounting hold, but not after a hard stop". - Red then green: `closed-native-receipt-accounting.test.ts`, "does not hold other work for the s7504 succeeded row shape". - Red then green: `closed-native-receipt-accounting.test.ts`, "settles a timed-out restart receipt at its provider-reported cost, without holding or pausing". It uses the OpenCode/OpenRouter row shape from a fourth chaos run. - `anthropic-pricing.test.ts`: the rate-card provenance keeps the lower-bound note. - Local: `closed-native-receipt-accounting`, the budget suites, `native-usage-accounting`, `native-usage-delivery.integration`, `native-runner-restart-recovery.integration` and `anthropic-pricing` all pass (187 tests). Server `tsc --noEmit` is clean. `pnpm verify` asks for CI coverage because the spec changed. ## Risks - A succeeded run whose final charge is higher than its observed cost can let one more task start before the charge commits. The hard stop then pauses the scope as it does for a running run that overruns. - A run that includes a lost turn settles with a lower-bound usage figure. The provenance says so, and a later receipt still reconciles through the late-receipt path. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), Claude Code agent with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
763 lines
55 KiB
TypeScript
763 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";
|
|
|
|
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);
|
|
try { await persistUsageReceipt(db, envelope); 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`;
|
|
}
|