Skip to content

Commit 77243d9

Browse files
committed
feat(memory): enforce an app-owned retained-state byte budget
One process-wide budget now sits over every evictable retained store — diagnostic rings, caches, Cursor blobs, and continuation state — demoting oldest entries in a fixed category order and reporting the whole picture as a privacy-safe appOwnedBytes block on the system memory endpoint. The budget is a user-visible setting on /api/settings (256 MiB default), documented across all five locales as a cap on app-owned state, not RSS. Three defects found by external review land as pre-work: the snapshot writer's unbounded retry is capped with stable-only spill unlinking, the RAM-cap loop demotes residents before touching older spill stubs, and the GCP ADC expiry sweep finally joins the periodic registration table. The usage-log entry-cap test also gains the store budget windows-latest measured it needing.
1 parent 7b1f059 commit 77243d9

44 files changed

Lines changed: 2051 additions & 98 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

devlog/_plan/260801_zero_leak_state_stores/040_app_bytes_observability.md

Lines changed: 135 additions & 21 deletions
Large diffs are not rendered by default.

docs-site/src/content/docs/ja/reference/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ namespaced selected id を bare id に変えます。
5959
| `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する成功的新セッション bind 数。範囲 1–100。`accountPoolStrategy``round-robin` のときのみ。 |
6060
| `upstreamFailoverThreshold?` | `number` | `3` | 一時的な上流失敗が連続して起きたのち、以降の新しいセッションを別の適合 pool アカウントに failover する回数。`0` なら失敗ベースの failover をオフにします。 |
6161
| `modelCacheTtlMs?` | `number` | `300000` | プロバイダー別 `/models` キャッシュの有効期間(5 分)。 |
62+
| `appOwnedMemoryBudgetMb?` | `number` | `256` | 退避可能なアプリ所有の保持状態(ログ、キャッシュ、Blob、継続応答ペイロード)に対するプロセス全体の上限(MiB)です。有効範囲は 64〜4096 で、RSS やネイティブランタイムメモリの上限ではありません。 |
6263
| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt cache ポリシー。オフ、5 分 ephemeral、1 時間 extended のいずれか。 |
6364
| `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on | ウェブ検索サイドカーオプション(下記参照)。 |
6465
| `visionSidecar?` | `OcxVisionSidecarConfig` | on | ビジョンサイドカーオプション(下記参照)。 |

docs-site/src/content/docs/ko/reference/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ namespaced selected id를 bare id로 바꿉니다.
6060
| `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 성공적 새 세션 bind 수. 범위 1–100. `accountPoolStrategy``round-robin`일 때만 적용. |
6161
| `upstreamFailoverThreshold?` | `number` | `3` | 일시적인 업스트림 실패가 연속으로 발생한 뒤, 이후 새 세션을 다른 적합한 pool 계정으로 failover할 횟수. `0`이면 실패 기반 failover를 끕니다. |
6262
| `modelCacheTtlMs?` | `number` | `300000` | 프로바이더별 `/models` 캐시의 유효 기간(5분). |
63+
| `appOwnedMemoryBudgetMb?` | `number` | `256` | 제거 가능한 앱 소유 유지 상태(로그, 캐시, Blob, 연속 응답 페이로드)의 프로세스 전체 상한(MiB)입니다. 유효 범위는 64~4096이며 RSS나 네이티브 런타임 메모리 상한이 아닙니다. |
6364
| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt cache 정책. 끔, 5분 ephemeral, 1시간 extended 중 하나입니다. |
6465
| `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on | 웹 검색 사이드카 옵션(아래 참조). |
6566
| `visionSidecar?` | `OcxVisionSidecarConfig` | on | 비전 사이드카 옵션(아래 참조). |

docs-site/src/content/docs/reference/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids.
6565
| `accountPoolStickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection before advancing. Range 1–100; only applies when `accountPoolStrategy` is `round-robin`. |
6666
| `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient upstream failures before future new sessions fail over to another eligible pool account. Set `0` to disable failure failover. |
6767
| `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache (5 min). |
68+
| `appOwnedMemoryBudgetMb?` | `number` | `256` | Process-wide cap in MiB for evictable app-owned retained state (logs, caches, blobs, and continuation payloads), valid from 64 to 4096. This does not cap RSS or native runtime memory. |
6869
| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. |
6970
| `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on | Web-search sidecar options (see below). |
7071
| `visionSidecar?` | `OcxVisionSidecarConfig` | on | Vision sidecar options (see below). |

docs-site/src/content/docs/ru/reference/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е
6464
| `accountPoolStickyLimit?` | `number` | `1` | Число успешных привязок новых сессий, удерживаемых на одном выборе round-robin перед переходом дальше. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. |
6565
| `upstreamFailoverThreshold?` | `number` | `3` | Число подряд идущих временных сбоев вышестоящей стороны, после которого будущие новые сессии переключаются (failover) на другой подходящий аккаунт пула. Установите `0`, чтобы отключить переключение по сбоям. |
6666
| `modelCacheTtlMs?` | `number` | `300000` | Окно свежести кэша `/models` каждого провайдера (5 минут). |
67+
| `appOwnedMemoryBudgetMb?` | `number` | `256` | Общий для процесса лимит в МиБ для вытесняемого удерживаемого состояния приложения (журналы, кэши, BLOB-данные и данные продолжения), допустимый диапазон — 64–4096. Это не лимит RSS или нативной памяти среды выполнения. |
6768
| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Политика кэша промптов Anthropic: отключён, эфемерный на 5 минут или расширенный на 1 час. |
6869
| `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | вкл. | Параметры сайдкара веб-поиска (см. ниже). |
6970
| `visionSidecar?` | `OcxVisionSidecarConfig` | вкл. | Параметры vision-сайдкара (см. ниже). |

docs-site/src/content/docs/zh-cn/reference/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧
5858
| `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的成功新 session 绑定数。范围 1–100;仅当 `accountPoolStrategy``round-robin` 时生效。 |
5959
| `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次临时上游失败后,让后续新 session failover 到其他合格 pool account。设为 `0` 可禁用失败切换。 |
6060
| `modelCacheTtlMs?` | `number` | `300000` | 每个 provider 的 `/models` 缓存新鲜度窗口(5 分钟)。 |
61+
| `appOwnedMemoryBudgetMb?` | `number` | `256` | 进程级可驱逐应用保留状态(日志、缓存、Blob 和续接响应负载)上限,单位为 MiB,有效范围为 64–4096。它不是 RSS 或原生运行时内存上限。 |
6162
| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache 策略:禁用、5 分钟 ephemeral 或 1 小时 extended。 |
6263
| `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | 开启 | 网络搜索 sidecar 选项(见下文)。 |
6364
| `visionSidecar?` | `OcxVisionSidecarConfig` | 开启 | 视觉 sidecar 选项(见下文)。 |

src/adapters/anthropic-image-normalize.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
TOTAL_IMAGE_BASE64_BUDGET,
2121
type ImageBlockRef,
2222
} from "./anthropic-image-guard";
23+
import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
2324

2425
/** One ladder position: dimension cap, JPEG quality attempts, per-image base64 cap. */
2526
export interface TierSpec {
@@ -168,6 +169,7 @@ function cachePut(key: string, value: CacheValue): boolean {
168169
cacheBytes += next.sizeBytes;
169170
cacheMetadataBytes += next.metadataBytes;
170171
if (typeof value === "string") cacheSentinelEntries++;
172+
enforceAppOwnedMemoryBudget();
171173
return true;
172174
}
173175

src/adapters/cursor/native-exec.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createHash } from "node:crypto";
2+
import { enforceAppOwnedMemoryBudget } from "../../lib/app-owned-memory";
23
import { create } from "@bufbuild/protobuf";
34
import {
45
DiagnosticsErrorSchema,
@@ -166,6 +167,11 @@ function recomputeBlobClassAccounting(): void {
166167
scheduleBlobExpiryAccounting(now);
167168
}
168169

170+
function reconcileBlobClassAccountingAndEnforce(): void {
171+
recomputeBlobClassAccounting();
172+
enforceAppOwnedMemoryBudget();
173+
}
174+
169175
function scheduleBlobExpiryAccounting(now: number): void {
170176
if (blobExpiryAccountingTimer) clearTimeout(blobExpiryAccountingTimer);
171177
blobExpiryAccountingTimer = undefined;
@@ -178,7 +184,7 @@ function scheduleBlobExpiryAccounting(now: number): void {
178184
if (!Number.isFinite(nextExpiry)) return;
179185
blobExpiryAccountingTimer = setTimeout(() => {
180186
blobExpiryAccountingTimer = undefined;
181-
recomputeBlobClassAccounting();
187+
reconcileBlobClassAccountingAndEnforce();
182188
}, Math.max(0, nextExpiry - now));
183189
blobExpiryAccountingTimer.unref?.();
184190
}
@@ -205,7 +211,7 @@ function releaseHydratedBlob(k: string, requestScope?: CursorBlobRequestScopeTok
205211
state?.keys.delete(k);
206212
if (state?.sealed && state.keys.size === 0) blobRequestScopes.delete(scope);
207213
}
208-
if (changed) recomputeBlobClassAccounting();
214+
if (changed) reconcileBlobClassAccountingAndEnforce();
209215
}
210216

211217
function setBlob(
@@ -306,7 +312,7 @@ function setBlob(
306312
blobs.set(k, entry);
307313
blobBytes += entry.sizeBytes;
308314
for (const scope of entry.requestPins) blobRequestScopes.get(scope)?.keys.add(k);
309-
recomputeBlobClassAccounting();
315+
reconcileBlobClassAccountingAndEnforce();
310316
return { admitted: true, replaced: existing !== undefined };
311317
}
312318

@@ -361,7 +367,7 @@ export function releaseCursorBlobRequestScope(scope: CursorBlobRequestScopeToken
361367
if (!state) return;
362368
for (const k of state.keys) blobs.get(k)?.requestPins.delete(scope);
363369
blobRequestScopes.delete(scope);
364-
recomputeBlobClassAccounting();
370+
reconcileBlobClassAccountingAndEnforce();
365371
}
366372

367373
export function storeCursorBlob(data: Uint8Array, requestScope?: CursorBlobRequestScopeToken): Uint8Array {

src/adapters/google-antigravity-replay.ts

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
2+
13
/**
24
* Antigravity (Cloud Code Assist) thoughtSignature reasoning-replay cache.
35
*
@@ -21,6 +23,7 @@ interface ReplayEntry {
2123
byCall: Map<string, ReplayCall>;
2224
bytes: number;
2325
expiresAtMs: number;
26+
oldestAtMs: number | null;
2427
}
2528

2629
const MIN_SIGNATURE_LEN = 16;
@@ -47,6 +50,8 @@ const replayCache = new Map<string, ReplayEntry>();
4750
const utf8 = new TextEncoder();
4851
let replayLimits = { ...DEFAULT_REPLAY_LIMITS };
4952
let replayBytes = 0;
53+
let replayOldestSessionKey: string | undefined;
54+
let replayOldestAt: number | null = null;
5055

5156
function replayKey(model: string, sessionId: string): string {
5257
return `${model}::session:${sessionId}`;
@@ -87,9 +92,32 @@ function deleteReplaySession(key: string): number {
8792
if (!entry) return 0;
8893
replayCache.delete(key);
8994
replayBytes -= entry.bytes;
95+
if (replayOldestSessionKey === key) recomputeReplayOldestCandidate();
9096
return entry.bytes;
9197
}
9298

99+
function recomputeReplayOldestCandidate(): void {
100+
replayOldestSessionKey = undefined;
101+
replayOldestAt = null;
102+
for (const [key, entry] of replayCache) {
103+
if (entry.oldestAtMs === null || (replayOldestAt !== null && entry.oldestAtMs >= replayOldestAt)) continue;
104+
replayOldestSessionKey = key;
105+
replayOldestAt = entry.oldestAtMs;
106+
}
107+
}
108+
109+
function refreshReplaySessionCandidate(key: string, entry: ReplayEntry): void {
110+
entry.oldestAtMs = entry.byCall.values().next().value?.touchedAtMs ?? null;
111+
if (replayOldestSessionKey === key) {
112+
recomputeReplayOldestCandidate();
113+
return;
114+
}
115+
if (entry.oldestAtMs !== null && (replayOldestAt === null || entry.oldestAtMs < replayOldestAt)) {
116+
replayOldestSessionKey = key;
117+
replayOldestAt = entry.oldestAtMs;
118+
}
119+
}
120+
93121
function deleteExpiredReplaySessions(now: number): void {
94122
for (const [key, entry] of replayCache) if (entry.expiresAtMs <= now) deleteReplaySession(key);
95123
}
@@ -138,7 +166,12 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
138166
const now = Date.now();
139167
deleteExpiredReplaySessions(now);
140168
const key = replayKey(model, sessionId);
141-
const entry = replayCache.get(key) ?? { byCall: new Map<string, ReplayCall>(), bytes: 0, expiresAtMs: 0 };
169+
const entry = replayCache.get(key) ?? {
170+
byCall: new Map<string, ReplayCall>(),
171+
bytes: 0,
172+
expiresAtMs: 0,
173+
oldestAtMs: null,
174+
};
142175
let inserted = false;
143176
for (const raw of parts) {
144177
if (!raw || typeof raw !== "object") continue;
@@ -161,7 +194,9 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
161194
evictInnerCalls(entry);
162195
entry.expiresAtMs = now + REPLAY_TTL_MS;
163196
replayCache.set(key, entry);
197+
refreshReplaySessionCandidate(key, entry);
164198
evictIfNeeded();
199+
enforceAppOwnedMemoryBudget();
165200
}
166201

167202
/**
@@ -177,6 +212,7 @@ export function applyAntigravityReplay(model: string, sessionId: string, content
177212
if (!entry) {
178213
return contents;
179214
}
215+
let touched = false;
180216
for (const c of contents as { role?: string; parts?: unknown[] }[]) {
181217
if (!c || typeof c !== "object" || c.role !== "model" || !Array.isArray(c.parts)) continue;
182218
for (const raw of c.parts) {
@@ -191,9 +227,11 @@ export function applyAntigravityReplay(model: string, sessionId: string, content
191227
part.thoughtSignature = call.signature;
192228
entry.byCall.delete(ck);
193229
entry.byCall.set(ck, { ...call, touchedAtMs: now });
230+
touched = true;
194231
}
195232
}
196233
}
234+
if (touched) refreshReplaySessionCandidate(replayKey(model, sessionId), entry);
197235
return contents;
198236
}
199237

@@ -224,27 +262,17 @@ export function antigravityReplayRetainedStoreSnapshot(): {
224262
pinnedBytes: number;
225263
oldestAt: number | null;
226264
} {
227-
let oldestAt: number | null = null;
228-
for (const entry of replayCache.values()) {
229-
for (const call of entry.byCall.values()) {
230-
oldestAt = oldestAt === null ? call.touchedAtMs : Math.min(oldestAt, call.touchedAtMs);
231-
}
232-
}
233-
return { count: replayCache.size, bytes: replayBytes, evictableBytes: replayBytes, pinnedBytes: 0, oldestAt };
265+
return {
266+
count: replayCache.size,
267+
bytes: replayBytes,
268+
evictableBytes: replayBytes,
269+
pinnedBytes: 0,
270+
oldestAt: replayOldestAt,
271+
};
234272
}
235273

236274
export function evictOldestAntigravityReplayForBudget(): number {
237-
let oldestKey: string | undefined;
238-
let oldestAt = Number.POSITIVE_INFINITY;
239-
for (const [key, entry] of replayCache) {
240-
let sessionOldest = entry.expiresAtMs;
241-
for (const call of entry.byCall.values()) sessionOldest = Math.min(sessionOldest, call.touchedAtMs);
242-
if (sessionOldest < oldestAt) {
243-
oldestAt = sessionOldest;
244-
oldestKey = key;
245-
}
246-
}
247-
return oldestKey === undefined ? 0 : deleteReplaySession(oldestKey);
275+
return replayOldestSessionKey === undefined ? 0 : deleteReplaySession(replayOldestSessionKey);
248276
}
249277

250278
export function setAntigravityReplayLimitsForTests(limits?: Partial<ReplayLimits>): void {
@@ -256,4 +284,6 @@ export function setAntigravityReplayLimitsForTests(limits?: Partial<ReplayLimits
256284
export function __resetAntigravityReplayCache(): void {
257285
replayCache.clear();
258286
replayBytes = 0;
287+
replayOldestSessionKey = undefined;
288+
replayOldestAt = null;
259289
}

src/claude/inbound-debug.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import { createHmac, randomBytes } from "node:crypto";
1616
import { isClaudeDebugEnabled } from "../lib/debug-settings";
1717
import { retainedUtf8Bytes, truncateRetainedUtf8 } from "../lib/admission";
18+
import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
1819

1920
export interface ClaudeInboundDebugEntry {
2021
/** Monotonic capture id — unique even when several entries share Date.now(). */
@@ -138,6 +139,7 @@ export function captureClaudeInbound(
138139
ring.push(entry);
139140
ringBytes += entryBytes(entry);
140141
if (ring.length > RING_LIMIT) removeOldest();
142+
enforceAppOwnedMemoryBudget();
141143
}
142144

143145
/** Newest-first snapshot for /api/claude/inbound-debug. */

0 commit comments

Comments
 (0)