diff --git a/doc/COST-ACCOUNTING.md b/doc/COST-ACCOUNTING.md
index c63d6281a11..f8b287d3715 100644
--- a/doc/COST-ACCOUNTING.md
+++ b/doc/COST-ACCOUNTING.md
@@ -174,3 +174,10 @@ edits deliver hard-stop cancellation after their transaction commits.
Status-card update costs retain the ledger's fractional-cent storage precision.
The mutation gate retains partial results and per-run reports/logs under
`coverage/accounting/` when its baseline fails or a mutation survives.
+
+Failed native turns persist observed run-delta usage before semantic-result
+finalization. A matching terminal failure, cancellation, or interruption closes
+that receipt; missing terminal evidence and session-only totals remain pending.
+For multiple provider attempts, known prices remain in the exact spend total
+when another attempt is unpriced. The aggregate stays marked unpriced, so the
+configured unknown-price policy still controls whether new work may start.
diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md
index d31ce5fea97..c0ca25fa2c6 100644
--- a/doc/connections/AI-CONNECTIONS.md
+++ b/doc/connections/AI-CONNECTIONS.md
@@ -186,6 +186,17 @@ and contain neither credentials nor raw provider errors. Expired credentials
report `authentication_required`; the probe does not exchange refresh tokens
or change connection health.
+The Costs dashboard uses the company quota endpoint. It reads each authorized
+managed subscription separately, retaining the last successful observation on
+transient errors. For managed Codex accounts, a 401 can trigger one persisted
+OAuth refresh; it defers while potentially competing OpenAI work is active.
+Refresh takes the company secret-mutation lock before database row locks and
+requires those row locks immediately, before exchanging a single-use token.
+Reconnect and runtime credential write-back follow the same lock order.
+Credential resolution checks its version after any database wait and reloads a
+rotated value before giving it to a new run. Unknown utilization has no progress
+bar, but any provider-reported reset time remains visible.
+
Verification:
```sh
diff --git a/packages/adapter-utils/src/usage-checkpoint.test.ts b/packages/adapter-utils/src/usage-checkpoint.test.ts
index 2b622ef60f0..a158f587c15 100644
--- a/packages/adapter-utils/src/usage-checkpoint.test.ts
+++ b/packages/adapter-utils/src/usage-checkpoint.test.ts
@@ -29,6 +29,29 @@ describe("usage checkpoint stream", () => {
expect(parser.mock.calls[0][0]).not.toContain("private content");
});
+ it("bounds large content strings before buffering and preserves usage on either side", async () => {
+ const saved = vi.fn(); const parser = vi.fn(createParser()); const output = vi.fn();
+ const log = createUsageCheckpointLog(output, saved, parser);
+ const line = JSON.stringify({ type: "result", usage: { inputTokens: 2, costUsd: 0.25 }, messages: [{ content: '"\\\n'.repeat(2 * 1024 * 1024) }], model: "after-large-content" });
+ for (let offset = 0; offset < line.length; offset += 4093) await log("stdout", line.slice(offset, offset + 4093));
+ await log.flush();
+ expect(saved).toHaveBeenLastCalledWith(expect.objectContaining({ costUsd: 0.25, complete: true }));
+ expect(parser.mock.calls[0][0]).toContain("after-large-content");
+ expect(parser.mock.calls[0][0].length).toBeLessThan(1000);
+ expect(output.mock.calls.map(call => call[1]).join("")).toBe(line);
+ });
+
+ it("preserves Unicode and escaped quotes across the string limit and chunk boundaries", async () => {
+ for (const suffix of ['\\u1234', '\\"', '\\\\']) {
+ const saved = vi.fn(); const parser = vi.fn(createParser());
+ const log = createUsageCheckpointLog(vi.fn(), saved, parser);
+ const line = '{"type":"result","text":"' + 'a'.repeat(249) + suffix + 'discarded","usage":{"costUsd":0.25}}\n';
+ for (const character of line) await log("stdout", character);
+ await log.flush();
+ expect(saved).toHaveBeenCalledWith(expect.objectContaining({ costUsd: 0.25 }));
+ }
+ });
+
it("surfaces a persistence failure outside the local process's best-effort log path", async () => {
const failure = new Error("Receipt storage unavailable");
const output = vi.fn(); const saved = vi.fn().mockRejectedValue(failure);
diff --git a/packages/adapter-utils/src/usage-checkpoint.ts b/packages/adapter-utils/src/usage-checkpoint.ts
index 1e0c44bd86e..123ae66615e 100644
--- a/packages/adapter-utils/src/usage-checkpoint.ts
+++ b/packages/adapter-utils/src/usage-checkpoint.ts
@@ -24,6 +24,41 @@ function project(value: unknown, depth = 0, field = ""): unknown {
: project(entry, depth + 1, key)]));
}
+/** Bound strings before buffering whole JSON lines. Provider records can carry
+ * many MiB of conversation text alongside a small usage object. Preserve JSON
+ * escapes across chunks so discarded text cannot corrupt the accounting fields. */
+function createStringCompactor() {
+ let inString = false, length = 0, escapeRemaining = 0;
+ let escapeCode = false, keepEscape = false;
+ return (chunk: string) => {
+ let output = "";
+ for (const character of chunk) {
+ if (character === "\n") {
+ inString = false; escapeRemaining = 0; escapeCode = false;
+ output += character;
+ } else if (!inString) {
+ output += character;
+ if (character === '"') { inString = true; length = 0; }
+ } else if (escapeRemaining > 0) {
+ if (keepEscape) output += character;
+ escapeRemaining = escapeCode && character === "u" ? 4 : escapeRemaining - 1;
+ escapeCode = false;
+ length++;
+ } else if (character === '"') {
+ output += character; inString = false;
+ } else if (character === "\\") {
+ keepEscape = length < 250;
+ if (keepEscape) output += character;
+ escapeRemaining = 1; escapeCode = true; length++;
+ } else {
+ if (length < 250) output += character;
+ length++;
+ }
+ }
+ return output;
+ };
+}
+
/** The consumer incrementally parses only new records (never the full history).
* A fresh instance belongs to one CLI attempt. Call flush() after its process
* exits, before retrying or final result handling. Checkpoint failures are
@@ -34,6 +69,7 @@ export function createUsageCheckpointLog(
consume: (records: string) => AdapterUsageCheckpoint | null,
) {
const attemptId = randomUUID();
+ const compactStrings = createStringCompactor();
let remainder = "", accounting = "", previous = "";
let snapshot: AdapterUsageCheckpoint | null = null;
let failure: unknown;
@@ -63,7 +99,7 @@ export function createUsageCheckpointLog(
const log = async (stream: "stdout" | "stderr", chunk: string) => {
if (onUsage && !failed && stream === "stdout") {
try {
- remainder += chunk;
+ remainder += compactStrings(chunk);
const lines = remainder.split("\n"); remainder = lines.pop() ?? "";
if (remainder.length > 8 * 1024 * 1024) throw new Error("Accounting protocol line exceeds 8 MiB");
for (const line of lines) retain(line);
diff --git a/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts b/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts
index 0ca1bee28ca..2cd6ba7c173 100644
--- a/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts
+++ b/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts
@@ -463,13 +463,15 @@ describe("codex auth cache store", () => {
releaseFirstCaller = resolve;
});
+ const entered = deferredSignal();
const firstCall = withAccountHomeSecretMutationLock(env, "company-shared", async () => {
events.push("first-enter");
+ entered.resolve();
await firstCallerGate;
events.push("first-exit");
return "first";
});
- await new Promise((resolve) => setTimeout(resolve, 20));
+ await entered.promise;
const secondCall = withAccountHomeSecretMutationLock(env, "company-shared", async () => {
events.push("second-enter");
events.push("second-exit");
@@ -497,12 +499,14 @@ describe("codex auth cache store", () => {
releaseCompanyA = resolve;
});
+ const entered = deferredSignal();
const companyACall = withAccountHomeSecretMutationLock(env, "company-a", async () => {
events.push("a-enter");
+ entered.resolve();
await companyAGate;
events.push("a-exit");
});
- await new Promise((resolve) => setTimeout(resolve, 20));
+ await entered.promise;
const companyBCall = withAccountHomeSecretMutationLock(env, "company-b", async () => {
events.push("b-enter");
events.push("b-exit");
@@ -536,6 +540,28 @@ describe("codex auth cache store", () => {
expect(events).toEqual(["promotion-enter", "mutation-enter", "mutation-exit", "promotion-exit"]);
});
+ it("allows awaited nested writes but expires inherited ownership after release", async () => {
+ const env = envFor(await makeInstanceRoot());
+ const detachedGate = deferredSignal();
+ let detached!: Promise;
+ let enteredDetached = false;
+ await withAccountHomeSecretMutationLock(env, "nested", async () => {
+ await expect(withAccountHomeSecretMutationLock(env, "nested", async () => "nested write")).resolves.toBe("nested write");
+ detached = detachedGate.promise.then(() => withAccountHomeSecretMutationLock(env, "nested", async () => { enteredDetached = true; }));
+ });
+ const holding = deferredSignal();
+ const release = deferredSignal();
+ const other = withAccountHomeSecretMutationLock(env, "nested", async () => { holding.resolve(); await release.promise; });
+ await holding.promise;
+ detachedGate.resolve();
+ try {
+ await new Promise(resolve => setTimeout(resolve, 30));
+ expect(enteredDetached).toBe(false);
+ } finally { release.resolve(); }
+ await Promise.all([other, detached]);
+ expect(enteredDetached).toBe(true);
+ });
+
});
describe("Phase 8: account-home directory validity before a secret write commits", () => {
@@ -581,3 +607,9 @@ describe("codex auth cache store", () => {
});
});
});
+
+function deferredSignal() {
+ let resolve!: () => void;
+ const promise = new Promise(done => { resolve = done; });
+ return { promise, resolve };
+}
diff --git a/packages/adapters/codex-local/src/server/codex-auth-cache.ts b/packages/adapters/codex-local/src/server/codex-auth-cache.ts
index c51921fff6b..d6dd757313a 100644
--- a/packages/adapters/codex-local/src/server/codex-auth-cache.ts
+++ b/packages/adapters/codex-local/src/server/codex-auth-cache.ts
@@ -1,4 +1,5 @@
-import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
+import { AsyncLocalStorage } from "node:async_hooks";
+import { lstat, mkdir, open, readFile, realpath, rename, rm } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
@@ -334,6 +335,11 @@ export async function withCodexAccountHomePromotionLock(
* create) can still call into a secrets-service write that takes this lock
* without deadlocking on itself.
*/
+// An outer credential transaction acquires this lock before database row locks.
+// Nested secret-service writes must share that ownership, not acquire it again.
+// Expire ownership on return so detached work cannot inherit a released lock.
+const secretMutationOwnership = new AsyncLocalStorage
)}
{account.windows.map((window, index) => window.usedPercent == null ? (
-
-
{window.label}
-
{window.valueLabel ?? "Usage not reported"}
+
+
+ {window.label}
+ {window.valueLabel ?? "Usage not reported"}
+
+ {window.resetsAt &&
Resets {new Date(window.resetsAt).toLocaleString()}
}
) : (