Skip to content
Closed
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
110 changes: 110 additions & 0 deletions devlog/_plan/260814_usage_memory_roadmap/000_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
title: Usage & Memory Roadmap — P0 through P3
date: 2026-08-14
class: C4
prior-art: "#1008 (closed/unmerged), #1412 (open draft), #1367 (open), #1635 (open), #820 (open), #1217 (open)"
---

# 000 — Objective

OpenCodex has two independent structural problems:

1. **Usage data lifecycle** — a single unbounded `usage.jsonl` that grows forever,
dashboard reads only the newest 64 MiB tail, and 30d/all-time aggregates silently
lose data beyond that window (#1497, #1580).

2. **Memory defense before Bun 1.4** — oversized input, continuation compounding,
provider transport failures, and no process-wide memory coordination mean a single
bad request or slow client can push RSS to OOM.

This unit implements P0 (memory defense, 5 PRs) and P1 (usage storage, 3 PRs) as a
stacked PR chain. P2 (retention + UI) and P3 (stream coordinator) are documented but
deferred.

## Constraints

- CWD: worktree on `dev` at `/Users/jun/.codex/worktrees/8d76/opencodex`
- Bun 1.3.14 bundled, `MIN_FIXED_BUN_VERSION = null`
- No gui/ changes (P2 scope)
- No docs-site/ changes
- No release automation changes
- Preserve existing dirty state (antigravity-models.ts, 260814_gemini-tiered-wire-ids)
- Push with --no-verify, CI deferred to stack review time
- Local focused tests as verification bar

## Current codebase state (2026-08-14)

| Component | File | State |
|-----------|------|-------|
| Usage writer | `src/usage/log.ts` | Single `appendUsageEntry` → `usage.jsonl`, no rotation |
| Usage reader | `src/usage/log.ts:983` | 64 MiB tail, incremental after first read |
| Usage summary | `src/usage/summary.ts` | In-memory aggregation from parsed entries |
| Usage cache | `src/server/management/usage-summary-cache.ts` | TTL cache over summary |
| Memory watchdog | `src/server/memory-watchdog.ts` | Warn-only, no restart |
| Lifecycle | `src/server/lifecycle.ts` | `drainAndShutdown` exists, `markRecyclingForExit` exists |
| Admission | `src/lib/admission.ts` | Per-host circuit gate, no input-size gate |
| Config types | `src/types.ts:616` (OcxConfig), `src/types.ts:1233` (OcxProviderConfig) |
| Depth cap | `src/integrations/serialize.ts:231` | JSON only (`MAX_JSON_NESTING=1000`) |
| YAML/TOML | `src/integrations/serialize.ts` | No depth cap on YAML write, no YAML/TOML parse cap |
| Continuation | `src/server/responses/core.ts` | previous_response_id expansion, no size admission |
| Stream mode | `src/types.ts:709` | `streamMode: auto|legacy-tee|eager-relay` |
| Provider config | `src/types.ts:1233` | No `upstreamHttpVersion` or `responseDelivery` fields |

## Phase map (dependency-ordered)

```
P0 — Memory defense (independent of each other, stacked for review):
010 M0-1 Input admission gate → branch: codex/m0-1-input-admission
020 M0-2 Continuation overlap removal → branch: codex/m0-2-continuation-dedup
030 M0-3 Provider delivery policy → branch: codex/m0-3-provider-delivery
040 M0-4 Memory recovery policy → branch: codex/m0-4-memory-recovery
050 M0-5 Non-JSON depth cap → branch: codex/m0-5-nonjson-depth-cap

P1 — Usage storage (U1 → U2 → U3 dependency chain):
060 U1 Segmented usage writer → branch: codex/u1-segmented-writer
070 U2 SQLite projector → branch: codex/u2-sqlite-projector
080 U3 Projection-backed API → branch: codex/u3-projection-api
```

P0 items are functionally independent but stacked for review ordering. P1 items
have real dependencies: U2 consumes U1's segments, U3 consumes U2's SQLite.

## Stack plan (DEV-STACK-01)

The 8 branches form a single review stack:
- Layer 0: `codex/usage-memory-roadmap-docs` (base: `dev`) — this unit, docs only
- Layer 1: `codex/m0-1-input-admission` (base: layer 0)
- Each subsequent branch bases on the one below
- Merge bottom-up after review

Layer 0 exists so every implementation layer can cite its own decade doc from a
base that already contains it. It carries no source change, so it is mergeable on
its own and cannot block the layers above it.

## Verifiers

| Phase | Command | Reads target |
|-------|---------|-------------|
| M0-1 | `bun test tests/input-admission.test.ts` | New test file |
| M0-2 | `bun test tests/continuation-dedup.test.ts` | New test file |
| M0-3 | `bun test tests/provider-delivery.test.ts` | New test file |
| M0-4 | `bun test tests/memory-recovery.test.ts` | New test file |
| M0-5 | `bun test tests/nonjson-depth.test.ts` | New test file |
| U1 | `bun test tests/usage-segmented-writer.test.ts` | New test file |
| U2 | `bun test tests/usage-sqlite-projector.test.ts` | New test file |
| U3 | `bun test tests/usage-projection-api.test.ts` | New test file |
| All | `bun run typecheck` | Whole project |

## SoT sync target

`structure/` — update if architectural boundaries change. No current architecture
doc covers the usage subsystem or memory coordination.

## Out of scope

- gui/ dashboard changes (P2)
- docs-site/ updates
- Release automation
- Bun upgrade (P4)
- Stream timeline / TurnScope / one-reader relay (P3, documented only)
- WebSocket upstream (#1608, Bun 1.4 gated)
113 changes: 113 additions & 0 deletions devlog/_plan/260814_usage_memory_roadmap/010_m0_1_input_admission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
---
title: "M0-1: Responses input admission gate"
phase: "010"
depends: []
consumes: []
branch: codex/m0-1-input-admission
closes: "(split from #1412)"
---

# 010 — M0-1: Model-aware input admission gate

## Thesis

Reject oversized requests BEFORE upstream dispatch. A 1.3M-token request that exceeds
the model's context window should never reach the provider — it wastes bandwidth, holds
a turn slot, and the provider will reject it anyway with a less useful error.

## Current state

- `src/server/responses/core.ts:1843` has `acquireUpstreamHostAdmission` — per-host
circuit breaker, not size-based
- `src/server/responses/core.ts:680` returns 413 for translator buffer overflow
(post-translation, not pre-dispatch)
- `src/types.ts:1340` has `contextWindow` and `modelContextWindows` on OcxProviderConfig
- `src/types.ts:1347` has `modelMaxInputTokens` on OcxProviderConfig
- No code path compares request input size against model context window before dispatch

## File change map

### NEW: src/server/responses/input-admission.ts

Purpose: Pre-dispatch input size estimation and admission gate.

```ts
export interface InputAdmissionResult {
admitted: boolean;
estimatedTokens: number;
contextWindow: number | null;
reason?: string;
}

/**
* Estimate token count from a parsed Responses request and compare
* against the model's advertised context window.
*
* Token estimation: count characters / 4 as a rough upper bound,
* with base64 image data counted at its decoded byte size / 750.
* This is intentionally conservative (overestimates) — better to
* reject a request that's close than to let a too-large one through.
*/
export function estimateInputTokens(parsed: OcxParsedRequest): number;

/**
* Resolve the effective context window for a provider+model pair.
* Priority: modelContextWindows[model] > contextWindow > null.
*/
export function resolveContextWindow(
provider: OcxProviderConfig,
model: string,
): number | null;

/**
* Check whether the estimated input fits within the model's context window.
* Returns admitted:true if no context window is known (fail-open for unconfigured models).
*/
export function checkInputAdmission(
parsed: OcxParsedRequest,
provider: OcxProviderConfig,
model: string,
): InputAdmissionResult;
```

### MODIFY: src/server/responses/core.ts

Location: Inside `handleResponsesInner`, after route resolution and before
`acquireUpstreamHostAdmission` (around line 1840).

```diff
+ // Pre-dispatch input admission: reject requests whose estimated token count
+ // exceeds the resolved model context window.
+ const admission = checkInputAdmission(parsed, providerConfig, resolvedModel);
+ if (!admission.admitted) {
+ return formatErrorResponse(413, "request_too_large",
+ `Estimated input (~${admission.estimatedTokens} tokens) exceeds the model context window (${admission.contextWindow} tokens). `
+ + `Reduce the conversation size or choose a model with a larger context window.`,
+ { estimated_tokens: admission.estimatedTokens, context_window: admission.contextWindow });
+ }
```

### NEW: tests/input-admission.test.ts

Test cases:
1. Request within context window → admitted
2. Request exceeding context window → 413 with token estimates
3. No context window configured → admitted (fail-open)
4. Base64 image data counted at reduced rate
5. Tool results included in estimate
6. `modelMaxInputTokens` caps below context window
7. Edge: exactly at boundary → admitted
8. Edge: 1 token over → rejected

## Activation scenario

A Codex turn with 500k tokens of conversation history targeting a model with a
128k context window hits `checkInputAdmission` → returns `admitted: false` →
413 response returned to client before any upstream fetch.

## Scope boundary

IN: New admission module + core.ts insertion point + test file
OUT: Changing existing translator buffer limits, modifying provider configs,
adding UI for admission settings

Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
---
title: "M0-2: Continuation overlap removal"
phase: "020"
depends: []
consumes: []
branch: codex/m0-2-continuation-dedup
closes: "(split from #1412)"
---

# 020 — M0-2: Stop compounding replayed history

## Thesis

When a request already contains the full conversation history (from the client
replaying its own context), the proxy must not prepend stored continuation state
on top. This duplication turns a 127k request into 254k→381k→508k across
continuations, eventually causing OOM or provider rejection.

## Current state

- `src/server/responses/core.ts:1544` sets `parsed._providerContinuation`
from `previousResponseProviderState`
- The continuation expansion code (around line 1621) checks
`hasUnexpandedPreviousResponse` but does not detect when the client has
already included the full history in the `input` array
- #1412 documents cases where 1x input becomes 2x→3x→4x through repeated
continuation prepending

## File change map

### NEW: src/server/responses/continuation-dedup.ts

```ts
/**
* Detect whether a request's input array already contains messages that
* overlap with the stored continuation state. If substantial overlap is
* detected, skip the continuation expansion.
*
* Detection strategy: compare the first N message hashes from stored
* continuation against the request's input array. If ≥80% of stored
* messages appear in the input, the client already replayed history.
*/
export function detectHistoryOverlap(
requestInput: unknown[],
storedMessages: unknown[],
): { overlapping: boolean; overlapRatio: number };

/**
* Fingerprint a message for overlap detection. Uses a fast hash of
* role + first 200 chars of content. Images/tool-results use their
* type + id as fingerprint.
*/
export function messageFingerprint(message: unknown): string;
```

### MODIFY: src/server/responses/core.ts

Location: Around the continuation expansion block (near line 1621).

```diff
const hasUnexpandedPreviousResponse = !!parsed.previousResponseId
&& typeof (body as { previous_response_id?: unknown }).previous_response_id === "string";

+ // Guard: if the client already included full history in input[],
+ // skip continuation expansion to prevent 1x → 2x → 3x compounding.
+ if (hasUnexpandedPreviousResponse && parsed._providerContinuation) {
+ const { overlapping } = detectHistoryOverlap(
+ parsed.input ?? [],
+ parsed._providerContinuation.messages ?? [],
+ );
+ if (overlapping) {
+ parsed._previousResponseInputExpanded = true; // mark as already expanded
+ // Clear the continuation to prevent double-prepending
+ parsed._providerContinuation = undefined;
+ }
+ }
```

### NEW: tests/continuation-dedup.test.ts

Test cases:
1. Full history already in input → continuation skipped, chain stays 1x
2. Delta continuation (new messages only) → continuation applied normally
3. Partial overlap (50%) → continuation applied (conservative)
4. Empty input + continuation → continuation applied normally
5. Preserved: call ID, reasoning, image, tool result integrity
6. Preserved: stateless provider behavior unchanged

## Activation scenario

A Codex session with 50 turns: each turn the client sends the full 50-turn
history as `input[]`. Without this fix, turn 3's request would contain
50 + 50 + 50 = 150 messages. With this fix, the overlap detector recognizes
the duplication and the request stays at 50 messages.

## Scope boundary

IN: Dedup detection module + core.ts guard + test file
OUT: Changing Codex client behavior, modifying continuation cache storage,
restructuring the continuation protocol

Loading
Loading