Files
PaperClipAI/server/src/services/native-runtime/native-usage-delivery.ts
T
DottaandClaude Opus 5.5 c0593b69f1 fix(accounting): close a remote-Runner turn completed across a session restore as a lower bound (#20524)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs settle their charge once the final usage receipt is
complete.
> - #20517 fixed one case for the local Runner. If a restart stops the
provider before a turn's final usage report, and the restored Runner
then reports that turn completed, the turn now closes as a lower bound
instead of waiting 10 minutes.
> - Remote Runners deliver usage through a separate producer-owned
receiver. That receiver dropped `session.resumed`, so the same turn
there still waited for the 10-minute closure timeout.
> - This pull request applies the #20517 rule to that receiver.
> - The benefit is that remote ACPX and OpenCode runs settle promptly
with an honest lower-bound label after a mid-turn restore.

## Linked Issues or Issue Description

Refs #20517.

**What happened?**

After #20517, `session.resumed` reached turn accounting only through the
local Runner's fallback recorder. The remote producer receiver
(`native-usage-delivery.ts`) and the session-authority filter
(`isNativeUsageAccountingEvent`) both dropped it. A remote ACPX or
OpenCode turn that was open across a session restore, and then reported
terminal without a complete report, left the receipt incomplete until
the closure timeout.

**Expected behavior**

Same rule as the local Runner. Such a turn closes as a lower bound right
away. A complete report that arrives after the restore is still used,
and a turn that starts after the restore stays pending until its own
report is complete.

**Steps to reproduce**

`native-usage-delivery.integration.test.ts > a turn restored mid-turn on
a remote Runner > closes the turn as a lower bound, also after the
controller restarts between restore and terminal` fails on master: the
receipt stays `accountingReceiptReady: false`.

## What Changed

- **Receiver (`createNativeUsageReceiver`).** It now accepts
`session.resumed` for turn-reporting providers (ACPX, OpenCode) while a
turn is open. It journals the restore as a receipt like any other
producer event, so turn accounting marks the open turn restored. A
replacement controller rebuilds the same state from that journal.
- **Restores never fail delivery.** A restore from another run or
source, or one with no open turn, is ignored, not rejected. An
unacknowledged lifecycle event would otherwise replay without end.
- **Session-authority filter.** `acceptAccountingEvent` now uses a new
`isNativeUsageDeliveryEvent`: the usage events plus `session.resumed`. A
restore for another run is ignored there too.
- **Recovery routing is unchanged.** `acceptRecoveryAccountingEvent` and
`onRecoveryAccountingEvent` keep the usage-only
`isNativeUsageAccountingEvent`, so a source run's restore never requires
a proven recovery source.
- **Turn accounting** gets a `hasOpenTurn()` helper.
- **Docs:** `doc/SPEC-implementation.md` now covers both Runners.

## Verification

- `native-usage-delivery.integration.test.ts`, describe `a turn restored
mid-turn on a remote Runner` (real PostgreSQL):

  | Test | On master | Here |
  |---|---|---|
| `closes the turn as a lower bound, also after the controller restarts
between restore and terminal`: the restore is journaled by one receiver
and the terminal arrives at a rebuilt one; replaying everything again is
idempotent | fails | passes |
| `keeps the turn's own final report when it arrives after the restore`
(guard) | passes | passes |
| `keeps a turn started after the restore pending, and journals nothing
for a restore with no open turn` (guard) | passes | passes |

- `native-session-executor.test.ts > binds retained warm transport usage
to the successor run authority` covers routing through the
session-authority filter:
  - a current-run restore reaches the successor's capture;
  - a draining-run restore reaches the drained capture;
  - an unrelated-run restore resolves with no capture;
  - recovery routing ignores a source restore.

It fails when the filter is reverted to `isNativeUsageAccountingEvent`.
- Related suites pass: `native-usage-delivery.integration` and
`native-usage-accounting` (46 tests), and the usage, accounting, receipt
and authority cases in `native-session-executor.test.ts` (224 tests).
Server typecheck passes.
- **The live remote path is unproven.** No Daytona wave ran because the
Daytona Runner image has not been rebuilt yet. The local Runner
counterpart (#20517) passed restart-chaos L1, seed 20386, 4/4
(`w-20261110T104332-s20386`).

## Risks

- **Upgrade safety.** Usage state saved by current master has no
journaled restore and rebuilds as before. A receiver that predates this
change replays a journaled restore as an ignored event, which is the
behaviour on master. A restore that an older controller already
acknowledged is not replayed, so that run waits for the closure timeout
as it does today.
- **One extra receipt per mid-turn restore**, only for ACPX and OpenCode
and only while a turn is open. Nothing is added before `turn.start`: a
restore with no open turn returns immediately.
- **Codex and other providers are unaffected.** They do not use turn
accounting.
- No schema or persisted-format change.

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through
Claude Code with shell, file-edit and code-execution tools.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:42:52 -06:00

216 lines
16 KiB
TypeScript

import { createHash } from "node:crypto";
import { and, asc, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
import { heartbeatRuns, runUsageReceipts, type Db } from "@paperclipai/db";
import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
import type { NativeExecutionInput, PrpEvent } from "../../vendor/paperclip-runner/index.js";
import { priceAnthropicReceipt } from "../anthropic-pricing.js";
import { priceCodexReceipt } from "../codex-pricing.js";
import { liveRunUsageCostCents } from "../budget-live-usage.js";
import { receiptFingerprint } from "../receipt-fingerprint.js";
import { persistUsageReceipt, type UsageReceiptEnvelope } from "../usage-receipts.js";
import { createNativeTurnAccounting, NATIVE_LOST_TURN_USAGE_EVIDENCE, NATIVE_TURN_LOST_ON_RESTORE, nativeUsageCostUsd, normalizeNativeUsage } from "./native-usage-accounting.js";
const terminal = new Set(["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"]);
const uuid = (identity: string) => {
const hex = createHash("sha256").update(identity).digest("hex");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
};
type NativeUsageDeliveryInput = {
companyId: string; runId: string; runnerInstanceId: string;
provider: NativeExecutionInput["provider"];
billing: Pick<AdapterUsageCheckpoint, "provider" | "biller" | "billingType">;
};
export async function createNativeUsageDelivery(db: Db, input: NativeUsageDeliveryInput) {
const sourceId = uuid(`${input.runId}:${input.runnerInstanceId}`);
const [bound] = await db.update(heartbeatRuns).set({ usageJson: sql`
coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || '{"accountingReceiptOwner":"native_producer"}'::jsonb ||
case when ${heartbeatRuns.usageJson}->>'accountingReceiptSourceId' = ${sourceId}
then '{}'::jsonb else
case when jsonb_typeof(${heartbeatRuns.usageJson}->'accountingReceiptSourceId') = 'string'
then jsonb_build_object('accountingReceiptSourceFences',
coalesce(${heartbeatRuns.usageJson}->'accountingReceiptSourceFences', '{}'::jsonb)
|| jsonb_build_object(${heartbeatRuns.usageJson}->>'accountingReceiptSourceId',
coalesce(${heartbeatRuns.usageJson}->'accountingReceiptSequence', '0'::jsonb)))
else '{}'::jsonb end
|| ${JSON.stringify({ accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0, accountingReceiptReady: false })}::jsonb end`
}).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId),
isNull(heartbeatRuns.costAccountedAt))).returning({ id: heartbeatRuns.id });
if (!bound) throw new Error("native_usage_run_not_available");
return createNativeUsageReceiver(db, input, sourceId, await loadNativeUsageReceipts(db, input, sourceId));
}
/** Drain the authenticated previous producer's accounting without acquiring or
* resetting its ownership. Finalized sources retain normal receipt equality
* and reconciliation rules; this grants no business/tool execution authority. */
export async function createNativeUsageContinuationDelivery(db: Db, input: Omit<NativeUsageDeliveryInput, "billing">) {
const sourceId = uuid(`${input.runId}:${input.runnerInstanceId}`);
const [source] = await db.select({ runnerInstanceId: heartbeatRuns.runnerInstanceId, usage: heartbeatRuns.usageJson, finishedAt: heartbeatRuns.finishedAt })
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId)));
if (source?.runnerInstanceId !== input.runnerInstanceId || source.usage?.accountingReceiptOwner !== "native_producer"
|| source.usage.accountingReceiptSourceId !== sourceId) throw new Error("native_usage_continuation_source_mismatch");
const saved = await loadNativeUsageReceipts(db, input, sourceId);
const classification = (receipt: Pick<AdapterUsageCheckpoint, "provider" | "biller" | "billingType">) => ({
provider: receipt.provider, biller: receipt.biller, billingType: receipt.billingType,
});
const receipts = saved.map(row => (row.receipt as UsageReceiptEnvelope).receipt as AdapterUsageCheckpoint);
const recorded = source.usage as Partial<AdapterUsageCheckpoint>;
if (!receipts.length && !recorded.biller) throw new Error("native_usage_continuation_receipts_missing");
const billing = classification(receipts[0] ?? recorded as AdapterUsageCheckpoint);
if (receipts.some(receipt => receiptFingerprint(classification(receipt)) !== receiptFingerprint(billing)
|| receipt.model !== (input.provider.model ?? "unknown"))) throw new Error("native_usage_source_conflict");
const terminalAcknowledged = source.finishedAt !== null && input.provider.kind === "codex"
&& !saved.some(row => terminal.has((row.receipt as UsageReceiptEnvelope).producerEvent?.eventType ?? ""));
return createNativeUsageReceiver(db, { ...input, billing }, sourceId, saved, true, terminalAcknowledged);
}
function loadNativeUsageReceipts(db: Db, input: Pick<NativeUsageDeliveryInput, "companyId" | "runId">, sourceId: string) {
return db.select({ receipt: runUsageReceipts.receiptJson, hash: runUsageReceipts.receiptHash,
sequence: runUsageReceipts.sequence }).from(runUsageReceipts).where(and(
eq(runUsageReceipts.companyId, input.companyId), eq(runUsageReceipts.runId, input.runId),
eq(runUsageReceipts.sourceId, sourceId),
// A journaled late tail is durable evidence, not an accepted delivery. Keep
// replaying it through the explicit reconciliation response after restart.
sql`not exists (select 1 from ${heartbeatRuns} r where r.id = ${runUsageReceipts.runId}
and r.company_id = ${runUsageReceipts.companyId} and r.usage_json->>'accountingLateReceiptPending' = 'true'
and (r.usage_json->>'accountingLateReceiptId' = ${runUsageReceipts.id}::text
or coalesce(r.usage_json->'accountingLateReceiptDebts', '{}'::jsonb) ? ${runUsageReceipts.id}::text))`)).orderBy(asc(runUsageReceipts.sequence));
}
const isUuid = (value: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
const finishedRunStatuses = ["succeeded", "failed", "timed_out", "cancelled", "interrupted"];
/** The Runner names a late report's owner when the turn proves it. A session's
* usage belongs to that session, so an unproven owner is the session's last
* finished run, recorded as inferred. */
async function captureLateUsageForOwner(db: Db, input: NativeUsageDeliveryInput, event: PrpEvent, payload: Record<string, unknown>) {
const reconciliation = payload.reconciliation;
if (!reconciliation || typeof reconciliation !== "object" || !isUuid(event.normalizedSessionId)) return false;
const measured = { runDelta: payload.runDelta };
const usage = normalizeNativeUsage(measured, { inputIncludesCacheReads: input.provider.kind === "codex" });
// An older Runner reports only an invalidation, with zero-filled counters.
// It has no increment to move and still belongs to the delivering run.
if (!usage || (usage.inputTokens + usage.outputTokens + (usage.cachedInputTokens ?? 0)) <= 0) return false;
const prior = (reconciliation as Record<string, unknown>).priorOrigin as Record<string, unknown> | null | undefined;
const runs = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.nativeSessionId, event.normalizedSessionId),
inArray(heartbeatRuns.status, finishedRunStatuses), ne(heartbeatRuns.id, input.runId),
)).orderBy(desc(heartbeatRuns.finishedAt));
const exact = runs.find(run => run.id === prior?.runId && prior?.normalizedSessionId === event.normalizedSessionId);
// With no finished run in this session, the delivering run is the only owner
// left. Its own delta already excludes this usage, so it is never counted twice.
const owner = exact ?? runs[0] ?? { id: input.runId };
const receipt = priceAnthropicReceipt(priceCodexReceipt({ ...input.billing, model: input.provider.model ?? "unknown", usage, usageBasis: "per_run",
complete: true, costUsd: nativeUsageCostUsd(measured, input.provider) ?? null }));
await persistUsageReceipt(db, { schema: "paperclip/accounting-receipt/v1", id: uuid(`${owner.id}:${input.runnerInstanceId}:${event.sourceEventId}`),
companyId: input.companyId, runId: owner.id, adapterType: "paperclip_runner", sourceId: uuid(`late:${owner.id}:${input.runId}:${input.runnerInstanceId}`),
sequence: event.sourceSeq, receivedAt: event.emittedAt, receipt,
producerEvent: { sourceEventId: event.sourceEventId, turnId: event.turnId, eventType: event.eventType, usage: { runDelta: payload.runDelta, reconciliation } },
lateDelta: { attribution: exact ? "exact" : "inferred", reportedByRunId: input.runId } });
return true;
}
function createNativeUsageReceiver(db: Db, input: NativeUsageDeliveryInput, sourceId: string,
saved: Awaited<ReturnType<typeof loadNativeUsageReceipts>>, verifySaved = false, terminalAcknowledged = false) {
const accepted = new Map<string, UsageReceiptEnvelope>();
const turnAccounting = ["opencode", "acpx"].includes(input.provider.kind) ? createNativeTurnAccounting(input.provider) : null;
let observedUsage: Record<string, unknown> | null = null;
let observedTurn: string | undefined;
let activeProviderTurn: string | undefined;
const observe = (envelope: UsageReceiptEnvelope) => {
const fact = envelope.producerEvent!;
if (fact.eventType === "turn.started") activeProviderTurn = fact.providerTurnId ?? fact.sourceEventId;
const accountingTurn = fact.providerTurnId ?? activeProviderTurn ?? fact.turnId;
const interpretedUsage = fact.usage ? { ...fact.usage, runDeltaComplete: fact.usage.runDeltaComplete === true
&& (input.provider.kind !== "codex" || fact.usage.runDeltaBasis === "run") } : undefined;
const event = { sourceInstanceId: input.runnerInstanceId, sourceSeq: envelope.sequence,
turnId: accountingTurn, eventType: fact.eventType,
payload: interpretedUsage ? { kind: "usage", usage: interpretedUsage }
: fact.lostOnRestore ? { error: { code: NATIVE_TURN_LOST_ON_RESTORE } } : {} } as PrpEvent;
if (turnAccounting) {
turnAccounting.observe(event);
return turnAccounting.snapshot();
}
if (fact.eventType === "turn.started") observedTurn = undefined;
if (interpretedUsage) {
observedUsage = interpretedUsage;
observedTurn = normalizeNativeUsage(observedUsage, { inputIncludesCacheReads: input.provider.kind === "codex" }) ? accountingTurn : undefined;
}
const closesRun = terminal.has(fact.eventType) || (terminalAcknowledged && interpretedUsage !== undefined);
return { usage: observedUsage, complete: closesRun && observedTurn !== undefined && observedTurn === accountingTurn };
};
for (const row of saved) {
const envelope = row.receipt as UsageReceiptEnvelope;
if (!envelope.producerEvent) throw new Error("native_usage_receipt_missing_producer");
if (verifySaved && (envelope.companyId !== input.companyId || envelope.runId !== input.runId
|| envelope.sourceId !== sourceId || envelope.sequence !== row.sequence || envelope.adapterType !== "paperclip_runner"
|| envelope.id !== uuid(`${input.runId}:${input.runnerInstanceId}:${envelope.producerEvent.sourceEventId}`)
|| receiptFingerprint(envelope) !== row.hash || accepted.has(envelope.producerEvent.sourceEventId))) {
throw new Error("native_usage_source_conflict");
}
accepted.set(envelope.producerEvent.sourceEventId, envelope);
observe(envelope);
}
let pending: UsageReceiptEnvelope | undefined;
let chain = Promise.resolve();
const capture = async (event: PrpEvent) => {
// A restore is journaled only for turn-reporting providers with an open
// turn, so a rebuilt receiver can close that turn as a lower bound.
// Receivers that predate it replay the receipt as an ignored event.
if (event.eventType === "session.resumed") {
// Never fail delivery for a lifecycle event: an unacknowledged event
// would replay without end. Another source's restore is not ours.
if (!turnAccounting || event.runId !== input.runId || event.sourceInstanceId !== input.runnerInstanceId
|| (!turnAccounting.hasOpenTurn() && !accepted.has(event.sourceEventId))) return;
} else if (event.eventType !== "usage.reported" && event.eventType !== "turn.started" && !terminal.has(event.eventType)) return;
if (event.runId !== input.runId || event.sourceInstanceId !== input.runnerInstanceId) throw new Error("native_usage_source_mismatch");
const payload = event.payload as Record<string, unknown>;
if (event.eventType === "usage.reported" && await captureLateUsageForOwner(db, input, event, payload)) return;
const fact: NonNullable<UsageReceiptEnvelope["producerEvent"]> = {
sourceEventId: event.sourceEventId, turnId: event.turnId, eventType: event.eventType,
...(typeof payload.providerTurnId === "string" ? { providerTurnId: payload.providerTurnId } : {}),
...(event.eventType === "usage.reported" ? { usage: { runDelta: payload.runDelta, runDeltaComplete: payload.runDeltaAvailable === true, runDeltaBasis: payload.runDeltaBasis,
...(payload.reconciliation && typeof payload.reconciliation === "object" ? { reconciliation: payload.reconciliation } : {}) } } : {}),
...(event.eventType === "turn.failed" && payload.error && typeof payload.error === "object"
&& (payload.error as Record<string, unknown>).code === NATIVE_TURN_LOST_ON_RESTORE ? { lostOnRestore: true as const } : {}),
};
const previous = accepted.get(event.sourceEventId) ?? (pending?.producerEvent?.sourceEventId === event.sourceEventId ? pending : undefined);
// A receipt saved before the marker existed replays without it.
const { lostOnRestore: _marker, ...unmarked } = fact;
const comparable = previous?.producerEvent?.lostOnRestore ? fact : unmarked;
if (previous && (previous.sequence !== event.sourceSeq || previous.receivedAt !== event.emittedAt
|| receiptFingerprint(previous.producerEvent) !== receiptFingerprint(comparable))) throw new Error("native_usage_source_conflict");
if (accepted.has(event.sourceEventId)) return;
if (pending && pending.producerEvent?.sourceEventId !== event.sourceEventId) throw new Error("native_usage_report_pending");
if (!pending) {
const envelope: UsageReceiptEnvelope = { schema: "paperclip/accounting-receipt/v1",
id: uuid(`${input.runId}:${input.runnerInstanceId}:${event.sourceEventId}`), companyId: input.companyId,
runId: input.runId, adapterType: "paperclip_runner", sourceId, sequence: event.sourceSeq,
receivedAt: event.emittedAt, producerEvent: fact, receipt: { complete: false } };
const snapshot = observe(envelope);
const usage = normalizeNativeUsage(snapshot.usage, { inputIncludesCacheReads: input.provider.kind === "codex" });
envelope.receipt = priceAnthropicReceipt(priceCodexReceipt({ ...input.billing,
model: input.provider.model ?? "unknown", usage, usageBasis: "per_run", complete: snapshot.complete && usage !== undefined,
costUsd: usage ? nativeUsageCostUsd(snapshot.usage, input.provider) ?? null : null,
costUsdExact: typeof snapshot.usage?.accountingCostUsdExact === "string" ? snapshot.usage.accountingCostUsdExact : undefined,
costStatus: usage === undefined || snapshot.usage?.accountingCostIncomplete === true ? "unpriced"
: input.provider.kind === "aws_agentcore" ? "estimated" : undefined,
...(snapshot.usage?.accountingUsageLost === true ? { pricingProvenance: {
source: typeof snapshot.usage.accountingCostUsdExact === "string" ? "provider_reported" as const : "unknown" as const,
version: "accounting-receipt/v1", evidence: NATIVE_LOST_TURN_USAGE_EVIDENCE } } : {}),
}));
envelope.liveCostCents = liveRunUsageCostCents(envelope.receipt as AdapterUsageCheckpoint);
pending = envelope;
}
await persistUsageReceipt(db, pending);
accepted.set(event.sourceEventId, pending);
pending = undefined;
};
return { capture(event: PrpEvent) {
const result = chain.then(() => capture(event));
chain = result.catch(() => undefined);
return result;
} };
}