Skip to content

feat(lib): reserve tokens and output before dispatch, and keep the ledger across restart (#4546) - #4625

Merged
lidge-jun merged 2 commits into
devfrom
codex/4546-wpe-durable-reservation
Sep 14, 2026
Merged

lidge-jun merged 2 commits into
devfrom
codex/4546-wpe-durable-reservation

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Summary

The workflow guard added in #4614 counts sends, so a 1k-token turn and a 150k-token turn cost the same unit, and its ledger lives in a process-memory map, so a restart silently resets an exhausted root. Cleanup made that worse: it protected only roots with an active request, so an exhausted-but-idle root could be evicted and then recreated fresh under the same id.

src/lib/spend-reservation-ledger.ts replaces counting with reservation. Before dispatch a send reserves its whole input plus its enforceable output ceiling — max_output_tokens, or the model's documented cap when the caller sent none. Never an optimistic estimate, and never shrunk by a cache-hit expectation: a prefix that was supposed to hit and did not is exactly the case the reservation exists to survive. Cache-hit projections remain useful for efficiency reporting and have no effect on the safety number.

Admission requires

settled spend + in-flight reservations + unresolved spend + this reservation <= approved limit

checked at three scopes at once: root workflow, authenticated identity, and account pool. The last two are what stop a caller from minting fresh root ids to mint fresh budget.

Settlement books real usage and is idempotent per send id, because double settlement is as wrong as none. A send whose usage frame is lost does not release its reservation — it moves to unresolved spend, since the work may well have been billed. Reservations, settlements and losses append to a JSONL journal under the opencodex home directory and replay on startup, so an exhausted scope is still exhausted after a restart. Root eviction now requires a root to be both inactive and not exhausted inside the retention window.

Default policy is unchanged behaviour: count caps only, with token accounting observed but not enforcing. Real token limits are opt-in, so no existing install is newly refused by this upgrade. The guarantee is one proxy process against its own journal; a second process sharing the same pool is stated as out of scope in the module header rather than implied away.

Stacked on #4624. Roadmap and stack order: devlog/_plan/260914_cost_guard_stabilization/090_remaining_stack.md.

Verification

Not run, by explicit instruction: the local suite, bun run typecheck, bun install, and any build. The verification posture for this unit is hosted CI at the exact final head SHA and nothing else; this push used --no-verify.

New coverage: tests/lib/spend-reservation-ledger.test.ts and tests/lib/workflow-budget.test.ts pin refusal at the boundary, all three scopes, idempotent settlement, lost usage becoming unresolved spend rather than a release, and an exhausted root that stays exhausted across a simulated restart. Both are registered in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json.

Known open, stated rather than hidden: the Responses call sites do not yet pass a reservation. admitWorkflowTurn takes it as optional trailing parameters, so callers keep today's count-only behaviour until the wiring layer higher in this stack computes the enforceable output ceiling and calls settleWorkflowSpend with real usage. A ledger nobody calls protects nothing; that obligation belongs to the later layers of this stack.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Summary by CodeRabbit

  • New Features

    • Added durable token-spending reservations across workflows, identities, and account pools.
    • Token usage reservations persist across restarts and account for input and potential output usage.
    • Added workflow spend tracking for dispatched, completed, abandoned, and unresolved requests.
    • Added safeguards against duplicate requests and admissions that cannot be durably recorded.
  • Bug Fixes

    • Workflow tracking now refuses new admissions when capacity is exhausted instead of exceeding configured limits.

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 14, 2026 13:00
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 10ddb5c2-4c47-465d-bff8-a8c79dd406e9

📥 Commits

Reviewing files that changed from the base of the PR and between 68951a1 and 1d5d299.

📒 Files selected for processing (7)
  • scripts/test-layout/layout.json
  • src/lib/spend-reservation-ledger.ts
  • src/lib/workflow-budget.ts
  • tests/fixtures/test-layout-expected.json
  • tests/lib/spend-ledger-file-journal.test.ts
  • tests/lib/spend-reservation-ledger.test.ts
  • tests/lib/workflow-budget.test.ts

📝 Walkthrough

Walkthrough

The pull request adds a durable, salted spend-reservation ledger with journal replay, retention, corruption handling, and token limits. Workflow admission integrates reservations with existing count caps and dispatch outcomes. New tests cover ledger, workflow, filesystem, privacy, and layout behavior.

Changes

Spend and workflow controls

Layer / File(s) Summary
Ledger contracts and durable journal
src/lib/spend-reservation-ledger.ts, tests/lib/spend-ledger-file-journal.test.ts
Lines 67-480 define spend policies, reservation types, journal records, validation, and journal interfaces. Lines 347-428 implement hardened journal and salt persistence. Tests verify 0600 permissions, atomic rewrite, and stable salts.
Replay, reservation, and retention
src/lib/spend-reservation-ledger.ts, tests/lib/spend-reservation-ledger.test.ts
Lines 564-917 rebuild state, enforce scope limits, handle dispatch, settlement, abandonment, lost usage, corruption, retention, capacity, and compaction. Tests cover these transitions and privacy behavior.
Workflow admission integration
src/lib/workflow-budget.ts, tests/lib/workflow-budget.test.ts
Lines 61-308 add spend requests, dispatch tracking, spend-related denials, durable reservation integration, and hard root-tracking bounds. Tests cover count caps, scope intersections, replay refusal, settlement, and eviction.
Test-layout mappings
scripts/test-layout/layout.json, tests/fixtures/test-layout-expected.json
The three new test files are mapped to the lib domain in the layout configuration and expected fixture.

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Suggested reviewers: ingwannu

Sequence Diagram(s)

sequenceDiagram
  participant WorkflowClient
  participant WorkflowBudget
  participant SpendReservationLedger
  participant SpendJournal
  WorkflowClient->>WorkflowBudget: submit workflow turn and spend request
  WorkflowBudget->>SpendReservationLedger: reserve input plus output ceiling
  SpendReservationLedger->>SpendJournal: persist reservation
  WorkflowBudget-->>WorkflowClient: return workflow admission
  WorkflowClient->>WorkflowBudget: mark dispatched or release
  WorkflowBudget->>SpendReservationLedger: settle, abandon, or mark lost
  SpendReservationLedger->>SpendJournal: persist final state
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/4546-wpe-durable-reservation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-14T13:05:55.260311Z ef3eb73 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef3eb736cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// fresh under the same id resets the very ceiling that already fired.
if (state.active > 0) continue;
if (state.sends >= policy.maxPhysicalSends) continue;
if (spendLedger?.exhausted("root", key) === true) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the hard cap when exhausted roots cannot be evicted

When every entry at maxTrackedRoots is idle but exhausted, this skip leaves oldestKey unset, yet admitWorkflowTurn still inserts the new root unconditionally. A caller that repeatedly exhausts fresh root IDs can therefore grow roots beyond the advertised bound indefinitely; retain bounded tombstones or deny/evict safely when no full state is removable.

Useful? React with 👍 / 👎.

Comment on lines +1 to +2
/**
* Durable token spend reservation, above the send-count workflow guard (#4546).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new durable ledger in the owning structure docs

This adds a persistent src/lib subsystem and changes workflow admission architecture, but the commit updates none of the structure documents assigned to src/lib/ in structure/INDEX.md. Add the durable reservation, replay, retention, and failure invariants to the applicable owning documents so the architecture source of truth does not contradict the runtime.

AGENTS.md reference: src/AGENTS.md:L10-L11

Useful? React with 👍 / 👎.

Comment thread src/lib/spend-reservation-ledger.ts Outdated
Comment on lines +337 to +339
if (state.reserved > 0 || state.lastSeenAt >= cutoff) continue;
if (isExhausted(scope, state)) continue;
scopes.delete(key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist pruning so restart does not resurrect expired spend

When prune() removes a dormant under-limit scope, the deletion is only in memory; the append-only journal retains every reserve and settlement. After a restart, replay recreates the supposedly expired scope and its spend, so admission depends on whether the process restarted, while the journal and replay maps also grow without respecting retentionMs. Record pruning/expiry or compact the journal consistently with the in-memory deletion.

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 70 / 80

설명

이 PR(#4625, codex/4546-wpe-durable-reservation)은 #4546 남은 스택의 2층(wpe) 이다. base는 1층 wpc(#4624)이고, dev tip 4f788f916 위에 쌓인다. 지금 dev에는 이미 물리 send 개수 예산(#4605~#4614)이 있지만, 그 예산은 (1) 1k 토큰 전송과 150k 토큰 전송을 같은 1회로 세고, (2) 프로세스 메모리 Map이라 재시작하면 소진된 root가 새 한도로 다시 태어난다. 이 PR이 막는 구멍이 바로 그것이다.

새 모듈 src/lib/spend-reservation-ledger.ts 는 전송 전에 입력 토큰 + 강제 가능한 출력 천장(caller의 max_output_tokens 또는 모델 문서 상한)을 root / identity / pool 세 스코프에 한꺼번에 예약한다. 캐시 히트 기대로 숫자를 줄이지 않는다. 한도가 없으면 OBSERVE ONLY라서 거절하지 않는다 — 090_remaining_stack.md가 경고한 ‘기본 한도를 낮춰 업그레이드 장애’를 피한 설계다. 저널은 OPENCODEX_HOME 아래 spend-ledger.jsonl append-only이며, 찢긴 마지막 줄은 스킵한다. 단일 프로세스 보장만 명시하고 두 번째 프록시 공유는 범위 밖이라고 솔직히 적었다.

src/lib/workflow-budget.ts 는 count cap과 token cap이 교차하는 지점이다. admitWorkflowTurn에 optional spend/spendLedger가 붙고, 거절 이유에 workflow-spend-exhaustedspendScope가 생긴다. release 때 settle이 없으면 markLost로 unresolved에 옮긴다(과소계상 방지). pruneOldestRoot는 active뿐 아니라 count-exhausted·spend-exhausted idle root도 지우지 않는다. 테스트는 ledger 155줄 + workflow-budget 164줄이 추가됐고 layout에 등록됐다. hosted CI는 test 1~4·gates·docker smoke pass, macos 일부 pending. 호출부가 실한도를 config에 아직 안 붙였고, 기본은 observe-only라 기존 설치 동작은 그대로다.

점수를 70으로 둔 이유: 재시작 세탁을 막는 계약이 명확하고 기본값이 안전하며 테스트가 핵심을 고정한다. 다만 배선·설정 표면·다중 프로세스·settle이 예약보다 커질 때의 정책은 다음 층/운영 판단이 남는다.

라인 applySettle / src/lib/spend-reservation-ledger.ts - settle 시 실제 usage가 예약 ceiling보다 커도 settled에 그대로 더한다. 의도는 맞지만, 한 번 초과 settle이 나면 다음 reserve가 바로 거절될 수 있다. 호출부가 outputCeiling을 모델 cap으로 채우는지 배선 PR에서 고정해야 한다.
경로 reserve() 검사 순서 - 모든 스코프를 먼저 검사한 뒤 mutate하므로 부분 예약은 없다. 좋다. 다만 journal append 실패 시 persistFailures만 올리고 메모리 상태는 유지한다. 재시작 시 그 예약이 사라질 수 있어 ‘내구성 저하’ 신호가 실제 관측(메트릭/로그)으로 올라가는지 배선 때 확인이 필요하다.
경로 prune / retentionMs 기본 7일 - 소진되지 않고 유휴인 스코프만 지운다. 소진+유휴는 유지. 장기 운영에서 저널 파일 성장/compact 전략은 이 PR 범위 밖이지만, 무한 append만으로는 디스크가 자란다.
경로 admitWorkflowTurn spend 예약 시점 - concurrency 검사 다음에 reserve한다. reserve 성공 후 active++ 이므로 순서상 안전하다. spend 없이 ledger만 넘기면 eviction이 spend-exhausted를 볼 수 있게 한 점도도 맞다.
심볼 DEFAULT_SPEND_RESERVATION_POLICY - 모든 maxTokens undefined. config/types에 한도 노브가 아직 없다. 강제 켜는 순간 기본값이 낮으면 장애라는 스택 문서 경고와 맞물린다.
경로 tests/lib/spend-reservation-ledger.test.ts - 재시작 replay·lost·idempotent settle을 기대한다. 다중 프로세스/동시 append는 범위 밖이라 테스트도 없다(정직).
경로 base codex/4546-wpc-quota-cache-domains - wpc 머지 전에 단독으로 dev에 올릴 수 없다. 스택 순서 준수가 필수다.

메인테이너의 판단이 필요한 지점

  • observe-only 기본으로 wpe를 wpc 직후 머지할지, 설정 노브+배선(wpa)까지 보고 묶을지.
  • settle > reserved 초과분을 다음 한도에 즉시 반영하는 현재 정책을 유지할지.
  • persistFailures를 운영 신호로 노출할지, 조용히 둘지.
  • identityId를 wpc의 authIdentity와 같은 문자열로 맞출지(스택 계약).

너의 추천
#4624(wpc) CI/머지 판단이 난 뒤 스택 순서대로 머지 후보. 머지 전 조건은 (1) wpc 착륙, (2) 남은 macos CI green, (3) 후속 배선 PR 본문에 ‘실한도는 명시 설정 전에는 refuse 없음’과 identity/pool id 출처를 한 줄로 적기. 지금 상태에서 기본 한도를 넣지 말 것. types/config 분할·중복 close 대상 아님.

이 댓글은 grok-bot이 작성했습니다

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on exact head ef3eb736cf94f73a1833fead62fff4a34e474500. The reservation model is the right direction, but three persistence boundaries are not safe enough for a ledger that later layers will use to authorize costly sends.

  1. createSpendReservationLedger casts every parsed JSON line to JournalRecord and immediately reads/applies it. Valid JSON such as null, or { "v":1, "kind":"reserve", "sendId":"s", "scopes":null, ... }, can throw during hydration; malformed timestamps/scopes/ids can also poison unbounded state. Add a strict bounded runtime decoder for every record and tests for valid-but-wrong JSON, not only an unparsable torn line.

  2. reserve() mutates memory before journal append, and append() swallows persistence failure. It still returns reserved:true; after a restart that admitted send disappears. A persistFailures counter observed later cannot prove that this specific authorization was durable. When any applicable token limit is enforcing, persistence failure must refuse/roll back before dispatch (with a typed outcome). Observe-only accounting may degrade if that choice is documented.

  3. The journal appends two records per settled send forever, and startup uses unbounded readFileSync(..., "utf8"). prune() only trims memory; it never compacts disk. Add an explicit file/record ceiling and a crash-safe checkpoint/compaction contract that retains open reservations and exhausted scopes, with restart tests at the boundary.

The exact-head hosted matrix is green, but its tests do not cover these counterexamples. The PR also remains stacked on #4624, which currently has requested changes. Please keep the single-process topology statement, observe-only default, and three-scope atomic admission while fixing the persistence contract.

@lidge-jun
lidge-jun force-pushed the codex/4546-wpe-durable-reservation branch from ef3eb73 to 56cd54a Compare September 14, 2026 14:15
Base automatically changed from codex/4546-wpc-quota-cache-domains to dev September 14, 2026 14:55
@lidge-jun
lidge-jun changed the base branch from codex/4546-wpc-quota-cache-domains to dev September 14, 2026 14:55
…dger across restart (#4546)

Part of the stacked delivery closing the remaining OCX-4546 cost-guard scope.

Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof. Pushed with --no-verify.
…rite, and bound retention (#4546)

Review findings on the reservation ledger: a reused send id authorised a free dispatch, a failed journal append still admitted the request, replay parsed unvalidated JSON, retention was unbounded, an undispatched reservation booked phantom debt, and raw account identifiers reached disk.

Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof. Pushed with --no-verify.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants