Files
PaperClipAI/server/src/services/run-cost-accounting.ts
T
2026-10-31 16:00:35 -05:00

242 lines
16 KiB
TypeScript

import { notifyStrandedAgentStatus } from "./live-run-recovery-notifications.js";
import { usdToUnits, unitsToCents } from "@paperclipai/shared";
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
import { accountingRuntimeBaselines, budgetReservations, agentRuntimeState, agents, costEvents, heartbeatRuns, nativeRunFinalizations, issues, projects, decisionInvocations, promptInjectionInvocations, type Db } from "@paperclipai/db";
import { withAccountingTransaction } from "./accounting-transaction.js";
import { withBudgetSpan } from "./budget-tracing.js";
import { conflict } from "../errors.js";
import { createCostEventInTransaction } from "./costs.js";
import { budgetServiceInTransaction, type BudgetServiceHooks } from "./budgets.js";
import { logger } from "../middleware/logger.js";
import { promises as fs } from "node:fs";
import { replayUsageReceipts, indexPendingUsageReceipts, recoverPendingRunUsageReceipts, type UsageReceiptIndex } from "./usage-receipts.js";
const terminalStatuses = ["succeeded", "failed", "timed_out", "cancelled", "interrupted"];
const object = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const text = (value: unknown) => typeof value === "string" && value.length > 0 ? value : null;
const amount = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
/** The run's persisted usage is a durable receipt. Ledger, runtime totals, and
* acknowledgement commit together, including after a restart or failed run. */
export async function accountRunCost(db: Db, runId: string, hooks: BudgetServiceHooks = {}, receiptIndex?: UsageReceiptIndex) {
const [identity] = await db.select({ companyId: heartbeatRuns.companyId, status: heartbeatRuns.status,
pending: heartbeatRuns.costAccountingPending, accountedAt: heartbeatRuns.costAccountedAt,
issueId: sql<string | null>`${heartbeatRuns.usageJson}->'ledgerScope'->>'issueId'`,
}).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
if (!identity || !identity.pending || !terminalStatuses.includes(identity.status)) return false;
// Finalization can call twice. Settled or active runs owe no accounting work.
// Pending acknowledgements still receive the locked stale-marker cleanup.
const index = identity.pending && !identity.accountedAt && terminalStatuses.includes(identity.status)
? receiptIndex ?? await indexPendingUsageReceipts() : undefined;
let recoveredFiles: string[] = [];
const accounted = await withBudgetSpan("budget.accounting.settlement", () => withAccountingTransaction(db, identity.companyId, async (tx, publications) => {
// Native recovery owns the issue before updating its run. Acquire the
// charge's issue FK lock first so inserting it cannot reverse that order.
const issueId = text(identity.issueId);
if (issueId) await tx.select({ id: issues.id }).from(issues).where(and(
eq(issues.companyId, identity.companyId), eq(issues.id, issueId),
)).for("key share");
let [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).for("update");
if (!run?.costAccountingPending || !terminalStatuses.includes(run.status)) return false;
if (run.costAccountedAt) {
await notifyStrandedAgentStatus(tx, { companyId: run.companyId, agentId: run.agentId });
await tx.update(heartbeatRuns).set({ costAccountingPending: false }).where(eq(heartbeatRuns.id, run.id));
await budgetServiceInTransaction(tx, publications).evaluateAffectedScopes(run.companyId, run.agentId,
text(object(object(run.usageJson).ledgerScope).projectId));
return false;
}
// The bounded global replay can leave a newer receipt behind an older
// complete snapshot. Drain this run under the accounting lock before
// deciding completeness, and retain disk evidence until the outer commit.
recoveredFiles = await recoverPendingRunUsageReceipts(tx, { companyId: identity.companyId, runId }, undefined, { retainFiles: true, index });
[run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
if (text(object(object(run.usageJson).ledgerScope).issueId) !== issueId) {
throw conflict("Run cost attribution changed. Retry accounting.");
}
// A failed capture may follow an older complete attempt. Neither replay
// nor terminal status can certify that the missing work was free.
if (object(run.usageJson).accountingCaptureFailed === true) return false;
const preProviderFailure = object(object(run.resultJson).executionRecovery).providerWorkStarted === false
|| object(run.usageJson).accountingProviderWorkStarted === false;
// A failed native turn is not necessarily the end of this heartbeat: the
// coordinator can resume the same run. Only its closed provider boundary
// authorizes acknowledgement; finalization may still run after a result.
if (run.runtimeMode === "native" && !preProviderFailure) {
const [coordinator] = await tx.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id));
if (coordinator && !coordinator.resultId && coordinator.phase !== "terminal_failure") return false;
}
// A stop can mark the run terminal before the provider's shutdown receipt
// arrives. Keep the debt pending instead of acknowledging an invented zero
// and permanently discarding that late receipt.
if (!preProviderFailure && object(run.usageJson).accountingReceiptReady === false) return false;
// Acknowledgement is part of the same transaction. Budget checks inside
// this transaction must not mistake this receipt for missing accounting.
await notifyStrandedAgentStatus(tx, { companyId: run.companyId, agentId: run.agentId });
await tx.update(heartbeatRuns).set({ costAccountingPending: false, costAccountedAt: new Date() }).where(eq(heartbeatRuns.id, run.id));
await tx.update(budgetReservations).set({ state: preProviderFailure ? "released" : "settled", settledAt: new Date() })
.where(and(eq(budgetReservations.companyId, run.companyId), eq(budgetReservations.runId, run.id), eq(budgetReservations.state, "held")));
if (preProviderFailure) {
await budgetServiceInTransaction(tx, publications).evaluateAffectedScopes(run.companyId, run.agentId,
text(object(object(run.usageJson).ledgerScope).projectId));
return true;
}
const usage = object(run.usageJson);
const scope = object(usage.ledgerScope);
// Deleted attribution targets become unallocated; a deletion must never
// prevent recording provider spend. Foreign-company references still fail.
for (const [table, key] of [[issues, "issueId"], [projects, "projectId"]] as const) {
const id = text(scope[key]);
if (id) {
const [linked] = await tx.select({ id: table.id }).from(table).where(eq(table.id, id));
if (!linked) scope[key] = null;
}
}
const costUsd = amount(usage.cacheAdjustedCostUsd) ?? text(usage.costUsdExact) ?? amount(usage.costUsd);
const billingType = text(usage.billingType) ?? "unknown";
const costUnits = billingType === "subscription_included" ? 0n : usdToUnits(costUsd ?? 0);
const costCents = unitsToCents(costUnits);
const [agent] = await tx.select({ adapterType: agents.adapterType }).from(agents).where(eq(agents.id, run.agentId));
const hasBillableEvidence = agent?.adapterType !== "process" || costUsd !== null || usage.costStatus === "unpriced"
|| ["inputTokens", "cachedInputTokens", "outputTokens"].some(key => (amount(usage[key]) ?? 0) > 0)
|| (text(usage.provider) !== null && usage.provider !== "unknown");
const receipt = {
idempotencyKey: `heartbeat:${run.id}:final`,
agentId: run.agentId, heartbeatRunId: run.id,
issueId: text(scope.issueId), projectId: text(scope.projectId), billingCode: text(scope.billingCode),
provider: text(usage.provider) ?? "unknown", biller: text(usage.biller) ?? text(usage.provider) ?? "unknown",
providerRequestId: text(usage.providerRequestId),
pricingProvenance: object(usage.pricingProvenance),
model: text(usage.model) ?? "unknown", billingType,
costStatus: (costUsd === null || usage.costStatus === "unpriced") && billingType !== "subscription_included" && hasBillableEvidence ? "unpriced" : usage.costStatus === "estimated" ? "estimated" : "reported",
inputTokens: amount(usage.inputTokens) ?? 0, cachedInputTokens: amount(usage.cachedInputTokens) ?? 0, outputTokens: amount(usage.outputTokens) ?? 0,
costCents, occurredAt: run.finishedAt ?? run.createdAt,
};
const parts = Array.isArray(usage.usageByModel) ? usage.usageByModel.map(object) : [];
const tokensMatch = ["inputTokens", "cachedInputTokens", "outputTokens"].every((key) =>
parts.reduce((sum, part) => sum + (amount(object(part.usage)[key]) ?? 0), 0) === (amount(usage[key]) ?? 0));
const costMatches = costUsd !== null && parts.reduce((sum, part) => sum + usdToUnits(amount(part.costUsd) ?? 0), 0n) === usdToUnits(costUsd);
const validParts = parts.length > 0 && tokensMatch && costMatches
&& parts.every((part) => text(part.model) && amount(part.costUsd) !== null)
&& new Set(parts.map((part) => part.model)).size === parts.length;
if (validParts) {
let allocated = 0n;
for (const [index, part] of parts.entries()) {
const partUsage = object(part.usage);
const remainder = costUnits - allocated;
const reported = usdToUnits(amount(part.costUsd) ?? 0);
const partUnits = billingType === "subscription_included" ? 0n : index === parts.length - 1 ? remainder : reported < remainder ? reported : remainder;
allocated += partUnits;
const partCents = unitsToCents(partUnits);
await createCostEventInTransaction(tx, run.companyId, {
...receipt, idempotencyKey: `heartbeat:${run.id}:model:${index}`, model: text(part.model)!,
inputTokens: amount(partUsage.inputTokens) ?? 0, cachedInputTokens: amount(partUsage.cachedInputTokens) ?? 0,
outputTokens: amount(partUsage.outputTokens) ?? 0, costCents: partCents,
}, publications, undefined, { deferBudgetEvaluation: true });
}
} else {
await createCostEventInTransaction(tx, run.companyId, receipt, publications, undefined, { deferBudgetEvaluation: true });
}
await budgetServiceInTransaction(tx, publications).evaluateAffectedScopes(run.companyId, run.agentId, text(scope.projectId));
const [event] = await tx.select({
inputTokens: sql<string>`coalesce(sum(${costEvents.inputTokens}),0)::text`,
cachedInputTokens: sql<string>`coalesce(sum(${costEvents.cachedInputTokens}),0)::text`,
outputTokens: sql<string>`coalesce(sum(${costEvents.outputTokens}),0)::text`,
costCents: sql<string>`coalesce(sum(${costEvents.costCents}),0)::text`,
}).from(costEvents).where(and(eq(costEvents.companyId, run.companyId), eq(costEvents.heartbeatRunId, run.id)));
await tx.insert(agentRuntimeState).values({ agentId: run.agentId, companyId: run.companyId, adapterType: agent.adapterType }).onConflictDoNothing();
await tx.execute(sql`insert into accounting_runtime_baselines (agent_id,company_id,cost_cents,input_tokens,cached_input_tokens,output_tokens)
select agent_id,company_id,total_cost_cents,total_input_tokens,total_cached_input_tokens,total_output_tokens
from agent_runtime_state where agent_id = ${run.agentId} on conflict do nothing`);
await tx.update(agentRuntimeState).set({
totalInputTokens: sql`${agentRuntimeState.totalInputTokens} + ${event.inputTokens}`,
totalCachedInputTokens: sql`${agentRuntimeState.totalCachedInputTokens} + ${event.cachedInputTokens}`,
totalOutputTokens: sql`${agentRuntimeState.totalOutputTokens} + ${event.outputTokens}`,
totalCostCents: sql`${agentRuntimeState.totalCostCents} + ${event.costCents}::numeric`,
updatedAt: new Date(),
}).where(eq(agentRuntimeState.agentId, run.agentId));
await tx.update(heartbeatRuns).set({ costAccountingPending: false, costAccountedAt: new Date(), accountingProjectionVersion: "v2", accountingLastError: null, accountingLastAttemptAt: new Date() }).where(eq(heartbeatRuns.id, run.id));
return true;
}));
for (const file of recoveredFiles) {
try { await fs.rm(file, { force: true }); }
catch (error) { logger.warn({ err: error, runId }, "Accounting committed; recovered receipt cleanup will retry"); }
}
return accounted;
}
function actionableRunAccounting() {
return and(
eq(heartbeatRuns.costAccountingPending, true), inArray(heartbeatRuns.status, terminalStatuses),
or(sql`${heartbeatRuns.costAccountedAt} is not null`, and(
sql`${heartbeatRuns.usageJson}->>'accountingCaptureFailed' is distinct from 'true'`,
sql`(${heartbeatRuns.resultJson}->'executionRecovery'->>'providerWorkStarted' = 'false'
or ${heartbeatRuns.usageJson}->>'accountingProviderWorkStarted' = 'false'
or not exists (select 1 from ${nativeRunFinalizations} where ${nativeRunFinalizations.runId} = ${heartbeatRuns.id}
and ${heartbeatRuns.runtimeMode} = 'native' and ${nativeRunFinalizations.resultId} is null
and ${nativeRunFinalizations.phase} <> 'terminal_failure'))`,
sql`(${heartbeatRuns.usageJson}->>'accountingReceiptReady' is distinct from 'false' or ${heartbeatRuns.resultJson}->'executionRecovery'->>'providerWorkStarted' = 'false' or ${heartbeatRuns.usageJson}->>'accountingProviderWorkStarted' = 'false')`,
)),
);
}
/** Only unfinished, actionable debt warrants retries. Incomplete provider
* receipts retain their accounting/idle hold and wake when evidence arrives. */
export async function readCostAccountingWork(db: Db, now = Date.now(), receiptDirectory?: string) {
const [pending] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(actionableRunAccounting()).limit(1);
const [deadline] = await db.execute<{ at: Date | string | null }>(sql`
select min(started_at) + interval '120 seconds 1 millisecond' as at from (
select ${decisionInvocations.startedAt} as started_at from ${decisionInvocations}
where ${decisionInvocations.status} = 'running'
union all
select ${promptInjectionInvocations.startedAt} from ${promptInjectionInvocations}
where ${promptInjectionInvocations.status} = 'running'
) running_service_invocations`);
const nextRunAt = deadline?.at == null ? null : new Date(deadline.at).getTime();
const receipts = await indexPendingUsageReceipts(receiptDirectory);
return {
pending: !!pending || receipts.size > 0 || (nextRunAt !== null && nextRunAt <= now),
nextRunAt,
};
}
export async function reconcileRunCosts(db: Db, hooks: BudgetServiceHooks = {}) {
const pending = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(actionableRunAccounting())
.orderBy(asc(heartbeatRuns.updatedAt), asc(heartbeatRuns.id)).limit(100);
let accounted = 0;
const receiptIndex = pending.length > 0 ? await indexPendingUsageReceipts() : undefined;
for (const run of pending) {
try { if (await accountRunCost(db, run.id, hooks, receiptIndex)) accounted++; }
catch (error) {
// Move failed attempts behind other pending work so a poisoned receipt
// cannot monopolize the bounded batch on every recovery tick.
await db.update(heartbeatRuns).set({ updatedAt: new Date(), accountingLastAttemptAt: new Date(), accountingAttemptCount: sql`${heartbeatRuns.accountingAttemptCount} + 1`, accountingLastError: error instanceof Error ? error.message.slice(0, 1000) : "Accounting failed" }).where(eq(heartbeatRuns.id, run.id));
logger.error({ err: error, runId: run.id }, "Run accounting remains pending for retry");
}
}
return { scanned: pending.length, accounted };
}
/** Share recovery across overlapping signals. The owning delivery worker
* schedules retries only while a durable receipt or actionable debt remains. */
export function createCostAccountingReconciler(db: Db, hooks: BudgetServiceHooks = {}, recoverServices?: () => Promise<void>) {
let inFlight: Promise<Awaited<ReturnType<typeof reconcileRunCosts>>> | undefined;
return () => {
if (!inFlight) {
inFlight = (async () => {
try {
let serviceRecoveryFailure: { error: unknown } | undefined;
try { await recoverServices?.(); }
catch (error) { serviceRecoveryFailure = { error }; }
await replayUsageReceipts(db);
const result = await reconcileRunCosts(db, hooks);
if (serviceRecoveryFailure) throw serviceRecoveryFailure.error;
return result;
} finally {
inFlight = undefined;
}
})();
}
return inFlight;
};
}