Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions devlog/_plan/260813_usage_parse_retention/000_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
title: Usage-parse retention β€” live RSS blow-up
date: 2026-08-13
class: C4
---

# 000 β€” Objective

Local `ocx start --port 10100` climbed to RSS 9.5 GiB in about three minutes
(Bun 1.3.14 bundled, JSC objectCount ~6.7M, JS/JSC heap 1.0–1.8 GiB, CPU ~90%).
App-owned retained stores reported only ~32–47 MB. The user contract is: once
`/api/usage` has loaded, it must stop; another dashboard tab must not force a
full re-parse or re-retain of `usage.jsonl`.

This unit stops that blow-up, independently reviews the changed paths, then
lands the fix on `origin/dev`, preview, main, and the matching npm release.
The user authorized push, preview/main promotion, and deploy in the requesting
turn.

## Live snapshot (2026-08-13 KST)

| Probe | Value |
|---|---|
| First process | PID 83382, uptime ~202s, RSS 9.57 GiB, heapUsed 1.07 GiB, jscHeap 1.07 GiB / 6.77M objects |
| App-owned | retainedBytes 32.9 MB (responses_continuation 29.9 MB, request_log 2.8 MB) |
| usage.jsonl | 243,587,988 bytes / 453,345 lines |
| routing-history.sqlite | 453,324,800 bytes (derived index, not the live retainer) |
| Watchdog | warnThreshold 4 GiB, observed rss 8.3–9.5 GiB |
| Later process | PID 99534 replaced 83382 without this session killing it; uptime 407s, RSS 6.71 GiB, heapUsed 169 MB, objectCount 441k |

The later process still sits well above the 4 GiB warn line with only 169 MB of
JS heap. That is allocator high-water / native residual, not a registered store.

## Loop spec

- Archetype: HOTL incident fix + release train.
- Trigger: dashboard memory card at 6.4 / 4.0 GiB and local RSS 9.5 GiB.
- Goal: growing `usage.jsonl` no longer causes a 64 MiB reparse after first load.
- Non-goals: deleting `usage.jsonl`; raising the 4 GiB watchdog; killing the live proxy as the fix.
- Verifier: focused bun tests, typecheck, append-and-assert, Opus + Sol verdicts, git/gh SHAs.
- Wall-clock: 6 hours.
- Memory artifact: this directory.

## HOTL resource bounds

Write scope: `src/usage/log.ts`, `logs-usage-routes.ts`, `usage-summary-cache.ts`, `api-key-usage.ts`, GUI `/api/usage` callers, `ws-upstream.ts` only if WP3 confirms, matching tests, docs-site only if user-facing behavior changes, this unit.
Out of scope: untracked `.dirfd-probe-*.ok`, credential files, deleting home-dir ledgers.

## Hypotheses

H1 confirmed: append invalidates exact revision cache and reparses 64 MiB (`src/usage/log.ts:502-598`, `logs-usage-routes.ts:216-226`). `tests/api-usage.test.ts:300-322` currently requires `fullReads` 1 to 2 after one append. `tailReads` exists and is never incremented.

H2 confirmed as allocation volume, rejected as durable JS retainer. Nothing stores `snapshot.entries` after `jsonResponse`.

H3 rejected for history/catalog/request-log/responses-state. Secondary open item: unbounded Codex WS queue in `src/server/responses/ws-upstream.ts:138-162`. PR #1608 already bounds it and is not on `dev`.

## Work-phase map

1. WP0 docs-only lock (000 + 001 research + 010-050).
2. WP1 `010` append-tolerant snapshot + caches.
3. WP2 `020` GUI stampede.
4. WP3 `030` WS bound if still needed.
5. WP4 `040` Opus + Sol review.
6. WP5 `050` push / preview / main / release.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# 001 WS queue decision

WP3 is unconditional NOOP in this unit. Dashboard reproduction is the usage parse (H1). Unbounded WS enqueue remains tracked by open PR 1608. Do not read admin-api-token. Do not invent a live probe this cycle.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 010 One owner flight, single-pass compact artifacts

No module-level PersistedUsageEntry[]. No mergeUsageSummary. Waiters never receive entries[].

Owner flight key = identityKey + maxReadBytes + overlayVersion.
Owner reads the 64MiB window once, then ONE loop over entries that fills:
- 12 compact UsageSummary buckets (7d/30d/all x all/codex/claude/grok)
- one generic API-key attribution map (apiKeyId -> {totalRequests, requests7d, lastUsedAt, attributionSince, historyTruncated})
Do not call summarizeUsage 12 times. Add accumulateUsageSummaries(entries, now) in src/usage/summary.ts that does a single pass. Existing summarizeUsage can wrap it for one pair.
Then drop entries and resolve waiters with compact artifacts only.

Install rule: after compute, if userCostOverlayVersion() !== owner.overlayVersion OR a newer overlay flight exists, discard (do not write caches). Only the current overlay generation installs the 12 summaries and the API-key map. generationFreshUntil = now + 60000.

Hit: identity + overlay + now < generationFreshUntil + maxReadBytes matches installed generation. Return compact summary. No log read.
Sequential API-key after /api/usage: project configured IDs from the compact map, including duplicate-id ambiguous behavior already in rollupApiKeyUsage. No second parse.

Abort in-flight only on identity change, size decrease (replacement), or 30s stale. Append does not abort.

Tests:
1. append + GET within 60s: requests unchanged, fullReads 1
2. two concurrent different range/surface GETs: fullReads 1; one accumulateUsageSummaries invocation (spy); both summaries present
3. /api/usage then API-key sequentially: fullReads 1
4. Date.now +60s: one more fullReads
5. overlay bump DURING parse: old owner installs nothing; new owner fullReads; no mixed-price cache
6. size decrease: next GET fullReads

MODIFY src/usage/summary.ts (accumulateUsageSummaries), src/usage/log.ts (flight key identity+maxReadBytes, no abort on size growth), logs-usage-routes.ts (owner + install check), usage-summary-cache.ts (identityKey, generationFreshUntil, maxReadBytes), api-key-usage.ts (project compact map), tests/api-usage.test.ts, tests/usage-summary.test.ts, tests/usage-log.test.ts, tests/api-key-attribution.test.ts as needed.

identityKey = path + dev + ino + birthtimeMs only. Normalize maxReadBytes with Number() before keying. Shared owner API: loadUsageGeneration(maxReadBytes) in src/server/management/usage-generation.ts. API-key projects from generation.apiKeyAttribution { map, attributionSince, historyTruncated }.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# 020 GUI usage stampede

Canonical in-document resource:
- key: usage-summary-30d:${apiBase}:all
- type: full /api/usage JSON (UsageSummary30d / UsageResponse)
- fetcher: shared GET /api/usage?range=30d, no rank transform inside the resource

Callers that MUST use that key and derive locally:
- gui/src/pages/use-dashboard-data.ts (remove pollMs 60000; change key off dashboard-usage)
- gui/src/pages/Providers.tsx (replace add-provider-usage)
- gui/src/components/AddProviderModal.tsx (replace add-provider-usage)
- gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx (delete raw fetch; subscribe to the keyed resource)

Codex-only: usage-summary-30d:${apiBase}:codex in useCodexAccountPool.ts; remove pollMs only; do not share the all-surface store.

Usage.tsx stays on its own range/surface key with no poll.

MODIFY gui/tests/dashboard-contracts.test.ts: expect usage-summary-30d key and the absence of pollMs: 60_000 on that resource.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# 030 WS queue bound

NOOP. Record: PR 1608 stays the owner. This cycle does not patch ws-upstream.ts.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# 040 Opus + Sol review

After 010-030 commits:

1. SHA=$(git rev-parse HEAD)
2. spawn_agent model=anthropic/claude-opus-5 reasoning_effort=medium. If tool rejects, spawn anthropic/claude-opus-4-6 and write fallback in 041_review_verdicts.md.
3. spawn_agent model=gpt-5.6-sol reasoning_effort=medium.
4. Packet both: Review git diff origin/dev...$SHA. Files: src/usage/log.ts, logs-usage-routes.ts, usage-summary-cache.ts, api-key-usage.ts, ws-upstream.ts if touched, GUI usage callers. H1/H2/H3 + falsifiers. Look for retained entries[], unbounded maps, aborted flights, WS backlog. End VERDICT: PASS | GO-WITH-FIXES (blockers=N) | FAIL. MUST NOT push/kill/read secrets.
5. Paste both finals into 041_review_verdicts.md under ## round-1 SHA.
6. If FAIL or High GO-WITH-FIXES: patch, commit, repeat steps 1-5 as ## round-2 SHA then ## round-3 SHA. Stop after round-3 and return to P if still FAIL.
7. PASS or residuals-only: continue to 050.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 050 Push preview main release

Do not fast-forward. ORIGIN=$(git remote get-url origin)

Before PR ready / merge to dev:
- bun test tests/usage-log.test.ts tests/api-usage.test.ts gui/tests/dashboard-contracts.test.ts
- bun run test
- bun run typecheck
- bun run privacy:scan
- if GUI changed: cd gui && bun test tests && bun run lint && bun run build
- PR body from .github/PULL_REQUEST_TEMPLATE.md; screenshot if title/body mentions gui

Then:
1. push branch, gh pr create --base dev, merge to origin/dev
2. PREVIEW_DIR=$(mktemp -d)/preview; git clone --branch preview "$ORIGIN" "$PREVIEW_DIR"; git -C "$PREVIEW_DIR" merge --no-ff origin/dev; git -C "$PREVIEW_DIR" push origin preview
3. live unused versions via npm view and gh release list (candidates 2.14.2-preview.20260813 and 2.14.2)
4. (cd "$PREVIEW_DIR" && bun scripts/release.ts <preview> --tag preview --publish); preview bump stays on preview
5. MAIN_DIR=$(mktemp -d)/main; git clone --branch main "$ORIGIN" "$MAIN_DIR"; git -C "$MAIN_DIR" merge --no-ff <origin/dev SHA before preview bump>; git -C "$MAIN_DIR" push origin main; (cd "$MAIN_DIR" && bun scripts/release.ts <stable> --tag latest --publish)
6. evidence: origin SHAs, npm dist-tag, gh release view
12 changes: 5 additions & 7 deletions gui/src/components/AddProviderModal.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usageSummary30dResourceKey } from "../usage-summary-resource";
import { useEffect, useMemo, useReducer, useRef } from "react";
import { IconX } from "../icons";
import { useT } from "../i18n/shared";
Expand Down Expand Up @@ -79,22 +80,19 @@ export default function AddProviderModal({
},
);
const usagePoll = useKeyedClientResource(
`add-provider-usage:${apiBase}`,
usageSummary30dResourceKey(apiBase),
[apiBase],
async (signal) => {
const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal });
if (!res.ok) return {} as Record<string, number>;
const data = await res.json() as { providers?: Array<{ provider: string; requests: number }> };
const rank: Record<string, number> = {};
for (const row of data.providers ?? []) rank[row.provider] = row.requests;
return rank;
if (!res.ok) throw new Error(String(res.status));
return await res.json() as { providers?: Array<{ provider: string; requests: number }> };
},
);

const oauthSupported = oauthPoll.data ?? [];
const presets = presetsPoll.data ?? fallbackPresets;
const presetsLoading = presetsPoll.loading;
const usageRank = usagePoll.data ?? {};
const usageRank = Object.fromEntries((usagePoll.data?.providers ?? []).map(row => [row.provider, row.requests]));
const {
preset, form, saving, error, oauthBusy, oauthMsg, oauthMsgTone, oauthUrl, oauthUrlProvider,
manualCode, manualCodeBusy, manualCodeMsg, manualCodeOk, endpointChoice, oauthTosPending,
Expand Down
63 changes: 23 additions & 40 deletions gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* arrive in WP090/091; until then the slot renders a real placeholder message.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useKeyedClientResource } from "../../client-resource";
import { usageSummary30dResourceKey } from "../../usage-summary-resource";
import { useT } from "../../i18n/shared";
import { IconFilter, IconSearch, IconBoxes, IconGlobe, IconLock, IconKey, IconTrash } from "../../icons";
import {
Expand Down Expand Up @@ -173,6 +175,7 @@ export default function ProviderWorkspaceShell({
});
const [modelsLoadEpoch, setModelsLoadEpoch] = useState(0);
const filterWrapRef = useRef<HTMLDivElement>(null);
const usageResource = useKeyedClientResource(usageSummary30dResourceKey(apiBase), [apiBase], async (signal) => { const res = await fetch(apiBase + "/api/usage?range=30d", { signal }); if (!res.ok) throw new Error(String(res.status)); return await res.json(); });

const sections = useMemo(() => {
const base = buildProviderWorkspace(hideRedundantChatGptForwardProviders(providers));
Expand Down Expand Up @@ -216,47 +219,27 @@ export default function ProviderWorkspaceShell({
useEffect(() => {
let cancelled = false;
const timeout = window.setTimeout(() => {
// Keep last-good paint when sessionStorage already seeded β€” don't flash loading skeletons.
// Read inside the effect (keyed by usageCacheKey) so the seed check stays correct without
// closing over an unstable cachedUsage render value.
if (!readSessionListCache(usageCacheKey)) setUsageLoading(true);
void fetch(`${apiBase}/api/usage?range=30d`)
.then(r => readJsonIfOk<{
providers?: Array<{ provider: string; requests: number; totalTokens?: number }>;
models?: Array<{ provider: string; model: string; resolvedModel?: string; requests: number; totalTokens: number; inputTokens: number; outputTokens: number; shareRatio: number; estimatedCostUsd?: number }>;
}>(r))
.then((data) => {
if (cancelled || !data) return;
const byProvider: Record<string, ProviderUsageTotals> = {};
for (const p of data.providers ?? []) byProvider[p.provider] = { requests: p.requests, totalTokens: p.totalTokens };
setUsageTotals(byProvider);
// Group model rows by provider
const byProviderModels: Record<string, ProviderModelUsageRow[]> = {};
for (const m of data.models ?? []) {
const key = m.provider;
if (!byProviderModels[key]) byProviderModels[key] = [];
byProviderModels[key].push({
model: m.model,
...(m.resolvedModel ? { resolvedModel: m.resolvedModel } : {}),
requests: m.requests,
totalTokens: m.totalTokens,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
shareRatio: m.shareRatio,
...(m.estimatedCostUsd !== undefined ? { estimatedCostUsd: m.estimatedCostUsd } : {}),
});
}
setUsageModels(byProviderModels);
writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels });
})
.catch(() => {})
.finally(() => { if (!cancelled) setUsageLoading(false); });
const data = usageResource.data as { providers?: Array<{ provider: string; requests: number; totalTokens?: number }>; models?: Array<{ provider: string; model: string; resolvedModel?: string; requests: number; totalTokens: number; inputTokens: number; outputTokens: number; shareRatio: number; estimatedCostUsd?: number }> } | undefined;
if (cancelled) return;
if (!data) {
if (usageResource.loading) setUsageLoading(!readSessionListCache(usageCacheKey));
return;
}
const byProvider: Record<string, ProviderUsageTotals> = {};
for (const row of data.providers ?? []) byProvider[row.provider] = { requests: row.requests, totalTokens: row.totalTokens };
setUsageTotals(byProvider);
const byProviderModels: Record<string, ProviderModelUsageRow[]> = {};
for (const m of data.models ?? []) {
const key = m.provider;
if (!byProviderModels[key]) byProviderModels[key] = [];
byProviderModels[key].push({ model: m.model, ...(m.resolvedModel ? { resolvedModel: m.resolvedModel } : {}), requests: m.requests, totalTokens: m.totalTokens, inputTokens: m.inputTokens, outputTokens: m.outputTokens, shareRatio: m.shareRatio, ...(m.estimatedCostUsd !== undefined ? { estimatedCostUsd: m.estimatedCostUsd } : {}) });
}
setUsageModels(byProviderModels);
writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels });
setUsageLoading(false);
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [apiBase, usageCacheKey]);
return () => { cancelled = true; window.clearTimeout(timeout); };
}, [apiBase, usageCacheKey, usageResource.data, usageResource.loading]);

useEffect(() => {
let cancelled = false;
Expand Down
7 changes: 3 additions & 4 deletions gui/src/hooks/useCodexAccountPool.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usageSummary30dResourceKey } from "../usage-summary-resource";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { normalizeAccountPriority } from "../account-priority";
import { useKeyedClientResource } from "../client-resource";
Expand Down Expand Up @@ -112,8 +113,6 @@ export interface CodexAccountPoolController {
}

const REFRESH_INTERVAL_MS = 30_000;
const USAGE_REFRESH_INTERVAL_MS = 60_000;

interface CodexAccountUsageRow {
accountLogLabel: string;
totalTokens: number;
Expand All @@ -132,14 +131,14 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
const seed = lastGoodByBase.get(apiBase);
const [accounts, setAccounts] = useState<CodexAccountEntry[]>(() => seed?.accounts ?? []);
const usage30d = useKeyedClientResource<CodexAccountUsageSummary>(
`codex-account-usage-30d:${apiBase}`,
usageSummary30dResourceKey(apiBase, "codex"),
[apiBase],
async (signal) => {
const response = await fetch(`${apiBase}/api/usage?range=30d&surface=codex`, { signal });
if (!response.ok) throw new Error("account usage load failed");
return response.json() as Promise<CodexAccountUsageSummary>;
},
{ enabled, pollMs: USAGE_REFRESH_INTERVAL_MS },
{ enabled },
);
const [activeId, setActiveId] = useState<string | null>(() => seed?.activeId ?? null);
const [loadState, setLoadState] = useState<CodexAccountLoadState>(() => (seed != null ? "ready" : "loading"));
Expand Down
10 changes: 4 additions & 6 deletions gui/src/pages/Providers.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usageSummary30dResourceKey } from "../usage-summary-resource";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell";
import ProviderDetails from "../components/provider-workspace/ProviderDetails";
Expand Down Expand Up @@ -110,15 +111,12 @@ export default function Providers({ apiBase }: { apiBase: string }) {
},
);
useKeyedClientResource(
`add-provider-usage:${apiBase}`,
usageSummary30dResourceKey(apiBase),
[apiBase],
async (signal) => {
const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal });
if (!res.ok) return {} as Record<string, number>;
const data = await res.json() as { providers?: Array<{ provider: string; requests: number }> };
const rank: Record<string, number> = {};
for (const row of data.providers ?? []) rank[row.provider] = row.requests;
return rank;
if (!res.ok) throw new Error(String(res.status));
return await res.json() as { providers?: Array<{ provider: string; requests: number }> };
},
);
/*
Expand Down
5 changes: 3 additions & 2 deletions gui/src/pages/use-dashboard-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
normalizeInjectionSelection,
type DashboardEpochRefs,
} from "./dashboard-core-poll";
import { usageSummary30dResourceKey } from "../usage-summary-resource";
import {
type DashboardSection,
type HealthData,
Expand Down Expand Up @@ -263,10 +264,10 @@ export function useDashboardData(apiBase: string) {
);

const usagePoll = useKeyedClientResource(
`dashboard-usage:${apiBase}`,
usageSummary30dResourceKey(apiBase),
[apiBase],
(signal) => fetchDashboardUsage(apiBase, signal),
{ pollMs: 60_000, enabled: overviewReady },
{ enabled: overviewReady },
);

const diagnosticsPoll = useKeyedClientResource(
Expand Down
5 changes: 5 additions & 0 deletions gui/src/usage-summary-resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function usageSummary30dResourceKey(apiBase: string, surface: "all" | "codex" = "all"): string {
return surface === "codex"
? ["usage-summary-30d", apiBase, "codex"].join(":")
: ["usage-summary-30d", apiBase, "all"].join(":");
}
Loading
Loading