mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - 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>
204 lines
10 KiB
TypeScript
204 lines
10 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 }>();
|
|
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")}` } : {}) },
|
|
turnId: current && normalizeNativeUsage(current.usage) ? currentTurn : undefined, complete };
|
|
};
|
|
return {
|
|
observe(event: PrpEvent) {
|
|
if (!event) return false;
|
|
const payload = record(event.payload);
|
|
const terminal = terminalTypes.includes(event.eventType);
|
|
if (payload.kind !== "usage" && event.eventType !== "turn.started" && !terminal) 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);
|
|
}
|
|
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;
|
|
} 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;
|
|
turns.set(key, turn);
|
|
return true;
|
|
},
|
|
snapshot,
|
|
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);
|
|
}
|