mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## 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>
231 lines
12 KiB
TypeScript
231 lines
12 KiB
TypeScript
import { usdToUnits } from "@paperclipai/shared";
|
|
import type { NativeExecutionInput, PrpEvent } from "../../vendor/paperclip-runner/index.js";
|
|
const record = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
|
|
function numericUsageField(
|
|
usage: Record<string, unknown> | null,
|
|
keys: string[],
|
|
): number | undefined {
|
|
if (!usage) return undefined;
|
|
for (const key of keys) {
|
|
const value = usage[key];
|
|
if (typeof value === "number" && Number.isFinite(value) && value >= 0)
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function nativeUsageMeasurement(usage: Record<string, unknown>) {
|
|
const nestedUsage = record(usage.usage);
|
|
// An explicit partial delta must not fall through to a full session total.
|
|
if (Object.hasOwn(usage, "runDelta")) return record(usage.runDelta);
|
|
if (Object.hasOwn(nestedUsage, "runDelta")) return record(nestedUsage.runDelta);
|
|
const candidates = [
|
|
record(usage.runDelta),
|
|
record(nestedUsage.runDelta),
|
|
record(usage.total),
|
|
record(nestedUsage.total),
|
|
record(usage.cumulative),
|
|
record(nestedUsage.cumulative),
|
|
nestedUsage,
|
|
usage,
|
|
];
|
|
return (
|
|
candidates.find(
|
|
(candidate) =>
|
|
numericUsageField(candidate, [
|
|
"inputTokens",
|
|
"input",
|
|
"promptTokens",
|
|
"outputTokens",
|
|
"output",
|
|
"completionTokens",
|
|
]) !== undefined,
|
|
) ?? usage
|
|
);
|
|
}
|
|
|
|
export function nativeUsageBiller(provider: NativeExecutionInput["provider"]): string {
|
|
if (provider.kind === "cursor_cloud") return "cursor";
|
|
if (provider.kind === "acpx") {
|
|
if (provider.agent === "cursor") return "cursor";
|
|
if (provider.agent === "copilot") return "github";
|
|
if (provider.agent === "pi") return "openrouter";
|
|
if (provider.agent === "grok") return "xai";
|
|
}
|
|
return "openai";
|
|
}
|
|
|
|
/** Provenance note for a receipt whose run includes a provider turn lost on
|
|
* restore. That turn's usage can never be reported, so the receipt is final
|
|
* but its usage and price are a lower bound. */
|
|
export const NATIVE_LOST_TURN_USAGE_EVIDENCE = "a provider turn was lost on restore and never reported its usage; observed usage is a lower bound";
|
|
export const NATIVE_TURN_LOST_ON_RESTORE = "provider_turn_lost_on_restore";
|
|
const LOST_ON_RESTORE = NATIVE_TURN_LOST_ON_RESTORE;
|
|
|
|
export function createNativeTurnAccounting(provider: NativeExecutionInput["provider"]) {
|
|
const turns = new Map<string, { usage: Record<string, unknown> | null; terminal: boolean; lost?: boolean;
|
|
/** Open when the provider session was restored after process loss. */
|
|
restored?: boolean }>();
|
|
const sequences = new Map<string, number>();
|
|
let currentTurn: string | undefined;
|
|
const terminalTypes = ["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"];
|
|
const snapshot = () => {
|
|
if (turns.size === 0) return { usage: null, turnId: undefined, complete: false };
|
|
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
|
|
let cost = 0n, costKnown = false, costIncomplete = false, complete = true, usageLost = false;
|
|
for (const [turnId, turn] of turns) {
|
|
const normalized = normalizeNativeUsage(turn.usage);
|
|
// A turn the restored Runner reports as lost on restore can never report
|
|
// its usage. It does not hold the receipt open; it makes it a lower bound.
|
|
const lost = turn.lost === true && normalized === undefined;
|
|
usageLost ||= lost;
|
|
complete &&= turnId !== "unattributed" && turn.terminal && (normalized !== undefined || lost);
|
|
inputTokens += (normalized?.inputTokens ?? 0) - (normalized?.cacheWriteTokens ?? 0);
|
|
outputTokens += normalized?.outputTokens ?? 0;
|
|
cacheReadTokens += normalized?.cachedInputTokens ?? 0;
|
|
cacheWriteTokens += normalized?.cacheWriteTokens ?? 0;
|
|
const price = nativeUsageCostUsd(turn.usage, provider);
|
|
if (price === undefined) costIncomplete = true;
|
|
else { cost += usdToUnits(price); costKnown = true; }
|
|
}
|
|
// A free empty attempt contributes no price evidence for later paid work.
|
|
// Keep an incomplete zero subtotal absent so complete aggregate tokens can
|
|
// still receive an estimate. Preserve positive known spend and true zero totals.
|
|
const retainCost = costKnown && (!costIncomplete || cost > 0n);
|
|
const runDelta = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
|
|
...(retainCost ? { providerCostUsd: Number(cost) / 1_000_000_000 } : {}) };
|
|
const current = currentTurn ? turns.get(currentTurn) : undefined;
|
|
return { usage: { runDelta, accountingCostIncomplete: costIncomplete, ...(usageLost ? { accountingUsageLost: true } : {}),
|
|
...(retainCost ? { accountingCostUsdExact: `${cost / 1_000_000_000n}.${String(cost % 1_000_000_000n).padStart(9, "0")}` } : {}) },
|
|
// A turn closed as lost still names the terminal turn the executor must match.
|
|
turnId: current && (normalizeNativeUsage(current.usage) || (current.lost && current.terminal)) ? currentTurn : undefined, complete };
|
|
};
|
|
return {
|
|
observe(event: PrpEvent) {
|
|
if (!event) return false;
|
|
const payload = record(event.payload);
|
|
const terminal = terminalTypes.includes(event.eventType);
|
|
const resumed = event.eventType === "session.resumed";
|
|
if (payload.kind !== "usage" && event.eventType !== "turn.started" && !terminal && !resumed) return false;
|
|
// Historical replay can revisit an earlier report after restoration.
|
|
// Re-persist its aggregate, but never rewind or count that report twice.
|
|
if (event.sourceInstanceId && Number.isSafeInteger(event.sourceSeq)) {
|
|
if (event.sourceSeq <= (sequences.get(event.sourceInstanceId) ?? -1)) return true;
|
|
sequences.set(event.sourceInstanceId, event.sourceSeq);
|
|
}
|
|
// A restore after process loss ends the provider process that would
|
|
// have sent an open turn's final report. Mark those turns; a terminal
|
|
// event for one without a complete report closes it as a lower bound,
|
|
// like a turn the Runner fails as lost on restore.
|
|
if (resumed) {
|
|
for (const turn of turns.values()) if (!turn.terminal) turn.restored = true;
|
|
return false;
|
|
}
|
|
const key = event.turnId ?? "unattributed";
|
|
const turn = turns.get(key) ?? { usage: null, terminal: false };
|
|
if (event.eventType === "turn.started") {
|
|
currentTurn = event.turnId;
|
|
turn.usage = null;
|
|
turn.terminal = false;
|
|
turn.lost = false;
|
|
turn.restored = false;
|
|
} else if (payload.kind === "usage" && (!currentTurn || !turns.has(key))) currentTurn = event.turnId;
|
|
if (payload.kind === "usage") {
|
|
const usage = record(payload.usage);
|
|
turn.usage = Object.hasOwn(usage, "runDelta")
|
|
? { runDelta: record(usage.runDelta), runDeltaComplete: usage.runDeltaComplete } : null;
|
|
}
|
|
if (terminal) turn.terminal = true;
|
|
if (event.eventType === "turn.failed" && record(payload.error).code === LOST_ON_RESTORE) turn.lost = true;
|
|
if (terminal && turn.restored) turn.lost = true;
|
|
turns.set(key, turn);
|
|
return true;
|
|
},
|
|
snapshot,
|
|
/** A restore matters only while a turn is open. */
|
|
hasOpenTurn() {
|
|
for (const turn of turns.values()) if (!turn.terminal) return true;
|
|
return false;
|
|
},
|
|
finish(turnId: string | null) {
|
|
const turn = turnId ? turns.get(turnId) : undefined;
|
|
if (turn) turn.terminal = true;
|
|
return snapshot();
|
|
},
|
|
};
|
|
}
|
|
|
|
export function nativeUsageCostUsd(
|
|
usage: Record<string, unknown> | null,
|
|
provider?: NativeExecutionInput["provider"],
|
|
) {
|
|
if (provider?.kind === "cursor_cloud") return undefined;
|
|
// The ACP normalization contract fills absent per-turn cost with zero and
|
|
// reports actual cost cumulatively. Until it carries an authoritative run
|
|
// delta with provenance, neither value is a candidate's billed USD receipt.
|
|
if (provider?.kind === "acpx" && ["cursor", "copilot", "pi"].includes(provider.agent)) return undefined;
|
|
if (!usage) return undefined;
|
|
const measurement = nativeUsageMeasurement(usage);
|
|
const hasRunDelta = Object.hasOwn(usage, "runDelta") || Object.hasOwn(record(usage.usage), "runDelta");
|
|
const direct = numericUsageField(measurement, ["cacheAdjustedCostUsd", "providerCostUsd", "costUsd"])
|
|
?? (hasRunDelta ? undefined : numericUsageField(usage, ["cacheAdjustedCostUsd", "providerCostUsd", "costUsd"]));
|
|
// PRP currently defaults an absent providerCostUsd to zero. That zero is
|
|
// not proof of a free request when the runtime observed token consumption.
|
|
if (direct !== undefined && direct > 0) return direct;
|
|
const normalized = normalizeNativeUsage(usage);
|
|
if (direct === 0 && normalized && Object.values(normalized).every(value => value === 0)) return 0;
|
|
const cost = record(hasRunDelta ? measurement.cost : usage.cost);
|
|
const currency =
|
|
typeof cost.currency === "string" ? cost.currency.toUpperCase() : "USD";
|
|
if (currency !== "USD") return undefined;
|
|
return numericUsageField(cost, ["amount", "total"]);
|
|
}
|
|
|
|
export function normalizeNativeUsage(usage: Record<string, unknown> | null, options: { inputIncludesCacheReads?: boolean } = {}) {
|
|
if (!usage || usage.runDeltaComplete === false || record(usage.usage).runDeltaComplete === false) return undefined;
|
|
const measurement = nativeUsageMeasurement(usage);
|
|
const cache = record(measurement.cache);
|
|
const count = (source: Record<string, unknown>, keys: string[]) => {
|
|
for (const key of keys) {
|
|
if (source[key] === undefined) continue;
|
|
const value = source[key];
|
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : NaN;
|
|
}
|
|
return undefined;
|
|
};
|
|
const input = count(measurement, ["inputTokens", "input", "promptTokens"]);
|
|
const outputTokens = count(measurement, ["outputTokens", "output", "completionTokens"]);
|
|
const cachedInputTokens = count(measurement, ["cachedInputTokens", "cacheReadInputTokens", "cacheReadTokens", "cachedReadTokens"]) ?? count(cache, ["read"]);
|
|
const cacheWriteTokens = count(measurement, ["cacheWriteTokens", "cacheCreationInputTokens", "cachedWriteTokens"]) ?? count(cache, ["write"]);
|
|
if (input === undefined || outputTokens === undefined
|
|
|| ![input, outputTokens, cachedInputTokens ?? 0, cacheWriteTokens ?? 0].every(Number.isSafeInteger)) return undefined;
|
|
const inputTokens = input - (options.inputIncludesCacheReads ? cachedInputTokens ?? 0 : 0)
|
|
+ (options.inputIncludesCacheReads ? 0 : cacheWriteTokens ?? 0);
|
|
if (!Number.isSafeInteger(inputTokens) || inputTokens < (cacheWriteTokens ?? 0)) return undefined;
|
|
return {
|
|
inputTokens, outputTokens,
|
|
...(cachedInputTokens === undefined ? {} : { cachedInputTokens }),
|
|
...(cacheWriteTokens === undefined ? {} : { cacheWriteTokens }),
|
|
};
|
|
}
|
|
|
|
export function usesNativeUsageDelivery(input: {
|
|
runnerd: boolean; remote: boolean; provider?: NativeExecutionInput["provider"] | null;
|
|
}) {
|
|
return input.runnerd && input.remote && input.provider !== undefined && input.provider !== null
|
|
&& ["codex", "opencode", "acpx", "claude_managed", "aws_agentcore"].includes(input.provider.kind);
|
|
}
|
|
|
|
export function isNativeUsageAccountingEvent(event: Pick<PrpEvent, "eventType">) {
|
|
return ["usage.reported", "turn.started", "turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"].includes(event.eventType);
|
|
}
|
|
|
|
/** Producer delivery for the run itself also needs the session restore: a
|
|
* turn open across it can close as a lower bound (see createNativeTurnAccounting).
|
|
* Recovery routing keeps isNativeUsageAccountingEvent: a source run's restore
|
|
* carries no usage and must not require a proven recovery source. */
|
|
export function isNativeUsageDeliveryEvent(event: Pick<PrpEvent, "eventType">) {
|
|
return isNativeUsageAccountingEvent(event) || event.eventType === "session.resumed";
|
|
}
|