Skip to content

[WRONG BRANCH] fix(routing): give a withheld recovery a retry time that is actually later (#4546) - #4771

Merged
lidge-jun merged 8 commits into
codex/bl4-key-usage-attributionfrom
codex/bl5-recovery-limiter-dispatch
Sep 16, 2026
Merged

lidge-jun merged 8 commits into
codex/bl4-key-usage-attributionfrom
codex/bl5-recovery-limiter-dispatch

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

The transient-hold resolver and the pool-wide recovery limiter added in #4626 still have no production caller, so this does not close #4701. Wiring them turned up a defect in the thing being wired, and that had to come first.

A withheld dispatch promises the caller a retry time, and that time was computed from the probe pacing alone. When the ratio limiter is what refused, the account usually has no probe state at all — nothing was ever granted for it — so nextProbeAt returned now, and the refusal told the caller to try again immediately. A withheld dispatch that busy-loops puts the same load on an already-failing pool as the dispatch it declined, which is the opposite of what the limiter exists for, and it violates the Retry-After half of #4701's completion criteria directly.

The limiter is the only thing that knows when its own window moves, so it now says. nextRecoveryAt returns now while the allowance is unspent, and otherwise the moment the oldest bucket still inside the window falls out. Every such bucket started after now - windowMs, so the answer is always strictly in the future, and it is a real change point rather than a guessed delay. The withheld result takes the later of that and the probe pacing.

The existing zero-allowance test asserted only that the result was withheld, which is why this survived the unit suite written to cover the module. It now asserts the time too.

Refs #4701

Verification

No local suite, focused test, typecheck, install, or build step was run. The repository owner prohibits local suite execution in this lane after a past local run deleted real user home data. Verification is static reading plus hosted CI.

  • Traced both refusal paths: a lease-pacing refusal already produced a future time (the existing test covers it), and only the ratio-refusal path could return now. That is the case the new assertions cover.
  • Checked the bucket arithmetic: bucketFor starts a bucket at floor(now / bucketMs) * bucketMs and totals skips buckets at or before now - windowMs, so every bucket the loop can return started strictly after now - windowMs and start + windowMs is strictly greater than now.
  • Confirmed the new method is additive on the interface and that the only production construction sites are the shared singleton and the test factory.

Regression coverage in tests/routing/probe-lease.test.ts: the zero-allowance withheld result now asserts a strictly future retryAt that equals the limiter's own answer; a new case walks the limiter from unspent to spent and back, asserting the reported time is a real change point that admits a dispatch when it arrives.

Hosted CI: non-tip layer of a stacked lane, carrying the skip marker under the maintainer-approved DEV-STACK-08 tip-only CI policy. The lane's CI gate runs on the tip branch.

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.

…later (#4546) [skip ci]

The transient-hold resolver and the pool-wide recovery limiter added in #4626
still have no production caller, so #4701 is not closed here. Wiring them turned
up a defect in the thing being wired, and that has to be fixed first.

A withheld dispatch promises the caller a retry time. It was computed from the
probe pacing alone. When the RATIO limiter is what refused, the account usually
has no probe state at all -- nothing was ever granted for it -- so nextProbeAt
returned now, and the refusal told the caller to try again immediately. A
withheld dispatch that busy-loops puts the same load on an already-failing pool
as the dispatch it refused, which is the opposite of what the limiter is for.
It also violates the Retry-After half of #4701's completion criteria directly.

The limiter is the only thing that knows when its own window moves, so it now
says: nextRecoveryAt returns now while the allowance is unspent, and otherwise
the moment the oldest bucket still inside the window falls out. Every such
bucket started after now - windowMs, so the answer is always strictly in the
future, and it is a real change point rather than a guessed delay. The withheld
result takes the later of that and the probe pacing.

The existing zero-allowance test asserted only that the result was withheld,
which is why the defect survived the unit suite that was written to cover this
module. It now asserts the time as well.

Refs #4701
…#4546)

Each layer of this lane closes one seam of the #4546 amplification: the
credential hop that was charged twice, the spend ledger with no caller, the
refusal reported as a provider fault, the usage attributed to the wrong key, the
withheld recovery that said "retry now". What none of them checks is whether the
seams agree with each other.

This composes the real primitives -- the request execution budget, the durable
spend ledger with its request-scoped caller, the pool recovery limiter -- and
asserts that the numbers describe the same events: physical sends, budget
consumption, ledger reservation and settlement, and the refusal the caller is
given.

The scenarios are the incident's own: a request whose every layer tries to
recover, concurrent requests contending for one process-wide recovery
allowance, a caller that keeps its detour instead of adding a second trial to a
failing account, a fan-out child spending the parent's allowance rather than a
fresh one, and a restart that must neither reset a ceiling nor settle the same
send twice.

A fixture that only counted sends would have passed throughout the incident,
which is why every case ties a send count to the spend the ledger recorded for
it.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 16, 2026 02:32
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 8a72223d-73a3-4af4-aa0b-629e4f343e18

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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 16, 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-16T02:34:36.801074Z e90d0ae 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.

The account-change scenario the incident needs, written against current
behaviour because the #4710 refusal is owned by another lane and is not in this
stack yet.

What it pins now: continuation state is dropped and the turn continues, an
uploaded file reference is classified non-portable and is NOT removed by the
scrub, and the carriers must be read directly because the portability verdict
reports only the first reason it finds -- a body carrying both a response id and
a file reports the response id.

What it documents: once the refusal lands, that body must be declined before
dispatch and the refusal must win over the response id. The two properties above
are what the change has to preserve, so they are asserted now.

Also pins the accounting invariant that refusal owes: a decision made before
dispatch spends no send and books no ledger entry. A refusal counted as a send
would appear as provider load that never happened and would push a healthy
account toward a cooldown.
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 77 / 80

이 PR은 일시 보류(withheld)된 복구 디스패치가 호출자에게 돌려주는 재시도 시각을 고칩니다. 지금 dev(HEAD 3070d64d8, 패키지 2.57.0)에는 #4626에서 들어온 resolveHeldAccountDispatch와 풀 단위 복구 비율 제한기(PoolBackpressureLimiter)가 코드와 단위 테스트로만 있고, 실제 요청 경로에는 아직 연결되어 있지 않습니다(#4701이 그 구멍입니다). 이 레이어는 그 연결을 준비하다가 연결 대상 자체에 있던 결함을 먼저 고칩니다. 비율 제한기가 거절했을 때, 해당 계정에 프로브 상태가 없으면 nextProbeAtnow를 돌려줬고, 거절 응답이 “지금 당장 다시 시도하라”고 말했습니다. 그러면 거절한 쪽과 같은 부하가 이미 망가진 풀에 다시 걸립니다. 제한기가 있는 이유의 정반대이고, #4701 완료 조건의 Retry-After/미래 재시도 시각 절반을 직접 깨뜨립니다.

고치는 방법은 단순합니다. 제한기에 nextRecoveryAt을 추가해서, 허용량이 남아 있으면 now, 다 썼으면 슬라이딩 창 안의 가장 오래된 버킷이 창 밖으로 나가는 시각을 말하게 합니다. 그 시각은 항상 now보다 뒤이고, 추측 지연이 아니라 창이 실제로 움직이는 지점입니다. withheld 결과의 retryAtMath.max(nextProbeAt(...), limiter.nextRecoveryAt(now))로 프로브 간격과 제한기 창 중 더 늦은 쪽을 택합니다. 기존 제로 허용량 테스트는 kind === withheld만 봤기 때문에 이 버그가 살아남았고, 이제는 시각까지 단언하며, 허용량 소진→창 이동→재허가를 한 케이스로 더 밟습니다.

스택 위치는 계정/예산 레인 위입니다. 대략 #4745(bl1) → #4756(bl2) → #4758(bl3) → #4760(bl4 key-usage) → 이 PR #4771(bl5 recovery-limiter-dispatch) 이고, 바로 위에 이미 #4772(bl7 #4546 통합 회귀)가 codex/bl5-recovery-limiter-dispatch를 base로 얹혀 있습니다. base는 dev가 아니라 codex/bl4-key-usage-attribution입니다. 본문도 명시하듯 #4701은 여기서 닫지 않습니다 — 프로덕션 배선은 아직이고, 배선 전에 “거절 = 미래 시각”이 깨져 있으면 배선해도 바쁜 루프가 나기 때문에 이 순서가 맞습니다. 커밋에 [skip ci]가 있고 MERGEABLE UNSTABLE이며, 스택 중간 레이어라 tip-only CI 정책(DEV-STACK-08)과 맞습니다. 로컬 스위트/타입체크/빌드는 금지 레인 그대로, 검증은 정적 추적 + 호스티드 CI입니다.

현재 devsrc/routing/probe-lease.ts를 보면 withheld 분기가 아직 retryAt: nextProbeAt(...)만 쓰고, PoolBackpressureLimiter 인터페이스에도 nextRecoveryAt이 없습니다. 이 PR이 그 두 곳을 채웁니다. 파일은 probe-lease.tstests/routing/probe-lease.test.ts 두 개뿐이고(+64/−1), 모노리스 경로를 건드리지 않아 types/config 분할 캠페인과도 충돌하지 않습니다. #4546 에픽은 여전히 OPEN이고, 이 레이어는 그 안의 복구 제한기 품질을 올리는 한 칸입니다.

resolveHeldAccountDispatch withheld retryAt - 비율 거절 + 프로브 상태 없음일 때 now가 나가던 경로. 지금은 제한기 창과 프로브 간격의 최댓값을 쓰므로, 호출자가 기다릴 실시간이 생깁니다.
PoolBackpressureLimiter.nextRecoveryAt - 인터페이스에만 추가된 조회 API. 허용량 미소진이면 now, 소진이면 가장 오래된 유효 버킷의 start + windowMs. 빈 버킷 폴백은 now + windowMs라서 미래 보장이 유지됩니다.
tests/routing/probe-lease.test.ts - 제로 허용량 withheld에 retryAt > now와 제한기 답과의 일치 단언을 붙였고, 소진→창 이동→재허가 시나리오를 새로 넣었습니다. 종류만 보던 구멍이 메워졌습니다.
프로덕션 호출 부재 - resolveHeldAccountDispatch / sharedPoolBackpressure 등은 여전히 테스트 밖 호출자가 없습니다. 이 PR 범위 밖이지만 #4701은 OPEN으로 남고, 배선 PR이 따로 와야 이슈가 닫힙니다.
스택 base codex/bl4-key-usage-attribution (#4760) - dev 직머지가 아니라 부모 랜딩 순서를 기다려야 합니다. tip [skip ci]도 동일 정책입니다.

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

너의 추천
유지하세요. #4701을 이 PR로 닫지 말고, 부모 스택(#4745#4756#4758#4760)이 올라온 뒤 이 레이어를 랜딩한 다음, 실제 요청 경로에 resolveHeldAccountDispatch / 제한기를 연결하는 후속 PR로 #4701을 닫으세요. #4772는 bl5 tip 위의 통합 핀으로 함께 보는 게 맞습니다.

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

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 76 / 80

이 PR(#4771)은 #4546 레인 안의 라우팅 결함 하나다. resolveHeldAccountDispatch가 비율(RATIO) 리미터 때문에 요청을 거절(withheld)할 때, retryAt을 프로브 페이싱(nextProbeAt)만으로 계산했다. 그 계정에 아직 프로브 상태가 없으면 nextProbeAtnow를 돌려주고, 거절 응답이 “지금 다시 시도하세요”가 되어 이미 아픈 풀에 거절한 것과 같은 부하를 바로 다시 건다. 지금 비교 기준 dev HEAD(3070d64d8822c6d8c62989665f82ab665e4d164c, package 2.57.0)의 src/routing/probe-lease.ts도 같은 모양이다. #4626에서 들어온 transient-hold/리미터 축이고, 본문이 밝히듯 이 패치만으로는 #4701을 닫지 않는다(생산 호출자 배선은 아직). 다만 #4701 완료 조건의 Retry-After 절반을 직접 깨는 버그라, 배선 전에 고쳐야 하는 자리가 맞다.

고치는 방법은 좁다. PoolBackpressureLimiternextRecoveryAt(now)을 추가한다. 허용치가 남아 있으면 now, 이미 썼으면 윈도우 안에 남은 가장 오래된 버킷이 빠져나가는 시각(bucket.start + windowMs)을 돌려서, 추측 지연이 아니라 실제 변화 시점을 준다. withheld 결과의 retryAtMath.max(nextProbeAt(...), limiter.nextRecoveryAt(now))로 둘 중 더 늦은 쪽을 고른다. 기존 zero-allowance 테스트가 kind만 검사해서 이 버그를 놓쳤던 점을 고치고, 리미터가 unspent→spent→다시 admit 되는 변화 시점을 새 케이스로 잠근다. 파일은 src/routing/probe-lease.tstests/routing/probe-lease.test.ts 두 개뿐(+64/-1). types.ts/config.ts 분할에 무효화되지 않는다.

스택은 bl 레인의 bl5다. base는 codex/bl4-key-usage-attribution(#4760)이고, 아래는 #4745(bl1) → #4756(bl2) → #4758(bl3) → #4760(bl4) → 이 PR(bl5) → tip #4772(bl7 통합 회귀)다. MERGEABLE/UNSTABLE, 커밋 헤드 e90d0aeb28e5. 본문은 non-tip + skip ci라고 하며, tip CI는 #4772에서 돈다고 한다. Refs #4701, Closes는 없다. close-don't-rebase 해당 없음.

라인 resolveHeldAccountDispatch withheld 분기 (src/routing/probe-lease.ts) - retryAt을 프로브만 보지 않고 리미터 nextRecoveryAt과 max로 합쳐, ratio 거절이 now로 busy-loop 하던 경로를 막는다
라인 nextRecoveryAt (limiter 내부) - 허용 남으면 now, 아니면 가장 오래된 in-window 버킷의 start+windowMs. 주석대로 그 시각은 항상 미래다
인터페이스 PoolBackpressureLimiter.nextRecoveryAt - 가산 메서드라 기존 구현 자리(싱글톤·테스트 팩토리)만 맞춰 주면 된다. 파괴적 변경 아님
테스트 zero-allowance withheld - kind뿐 아니라 retryAt > now이고 limiter 답과 같음을 어서트해서 예전 구멍이 막혔다
테스트 "limiter reports when its window could next admit" - spend 후 변화 시점과, 그 시점에 다시 admit 됨을 증명한다
범위 주의 - 본문 명시대로 #4701 생산 배선/완료는 이 PR 밖이다. 배선 PR이 따로 와야 이슈를 닫을 수 있다

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

너의 추천
KEEP. ratio withheld가 retryAt=now로 busy-loop 하던 실제 결함이고, 수정·테스트가 한 파일 쌍에 잘 모여 있다. 부모 bl 스택이 먼저 안정적으로 올라가고 tip #4772 CI가 이 레이어를 포함한 뒤 머지하면 된다. #4701은 닫지 말고 OPEN 유지. 중복/무효화 PR 아님.

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

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 16, 2026
…fusing sends (#4546)

Four fixes, batched into one push so the queue only pays once.

1. src/server/responses/core.ts was 214 lines against a 210-line cap in
   tests/fixtures/file-size-baseline.json. The spend-observer wiring added four
   lines of comment and continuation. The comment is now one line and the
   expression one line, and the file is back at its cap. The ratchet only ever
   lowers caps, so growing past one is a hard failure rather than a nudge.

2. The spend tracker refused a dispatch on ANY ledger denial. Only an operator's
   configured ceiling should: capacity, durability and a journal this process
   could not prove complete all mean the ledger cannot ACCOUNT for the send,
   which is not a reason to refuse one. An unconfigured install keeps the count
   caps it already had and is not newly refused, and a degraded ledger must not
   become an outage.

3. The shared ledger is now resolved on the first charge rather than when the
   request is built. It opens a journal under the OpenCodex home, and a request
   that never dispatches has no business creating one; this also means the home
   in effect at dispatch is the one written to, instead of whichever home was
   current when the first request of the process happened to be constructed.

4. Three assertions in the new tests claimed states the code never reaches.
   The concurrent-probe case asserted a limiter refusal, but the second caller
   short-circuits on the lease before it reaches the limiter and costs no
   allowance; the shared bound is now proved by asking the limiter directly.
   The exhausted-ceiling case asserted final-recovery-spent where the total
   ceiling refuses first, so it asserts total-exhausted and checks reserveSpent
   separately for the point it was making. The unstructured-error control
   asserted an exact 502 where the property that matters is that the identity is
   gone, so it asserts that instead.

Tests are not typechecked -- tsconfig includes only src -- so a test that
asserts the opposite of what it claims passes silently. These were found by
reading, not by running.
Two source-of-truth failures from the previous tip run, both mine.

tests/lib/transient-budget-scope-source.test.ts pinned the exact core.ts line
that mints the request's send budget, and bl2 changed it to install the spend
observer. The oracle now matches the new shape and additionally asserts the
observer is attached at the same place, which is the property that actually
matters: a combo child inherits the parent's holder and must not open a second
set of ledger entries for the same physical sends.

tests/lib/spend-reservation-ledger.test.ts caught a real defect in the replay
reconciliation, not a stale expectation. An exhausted scope must still be
exhausted after a restart -- that is the whole reason the ledger is on disk --
and abandoning a replayed undispatched reservation handed its tokens back and
reset the ceiling.

The distinction I drew was wrong. "Open" does not prove nothing was sent: the
torn-tail rule immediately above says the journal may be missing its last
record, so a send can dispatch and die before its dispatch record lands.
Both live states now resolve to unresolved spend, which is the conservative
answer and the one that preserves the ceiling.

The bl2 wiring test asserted the old split and is updated to the new figures,
along with the structure contract and the tracker's own comment.
…gression

test(responses): pin the #4546 incident as one system, not five fixes (#4546)
@lidge-jun

Copy link
Copy Markdown
Owner Author

Cascading downward. This layer repairs the probe-lease precondition: withheld.retryAt returned now on a ratio refusal instead of the moment the window actually moves. The production wiring for #4701 is carried separately.

Evidence at the verified tip d9e5b28 (tree b69974e3f2c5da4e1cda8e302943a5ea6b107474), from run 35055864527:

  • test 1/4, 2/4, 3/4 and 4/4 all completed with conclusion success, confirmed through the check-runs API rather than the check rollup, so the heavy jobs actually executed and were not path-filtered.
  • macos 1/2 and 2/2, gates and the aggregate ci check all completed with conclusion success.
  • The same commit also carries a ci failure and a gates cancellation from run 35055864360. Its annotation reads needed job(s) did not pass: changes=cancelled: that run was superseded by workflow concurrency when the six branches were pushed together. It is a cancellation, not a test failure.
  • The lane absorbed dev at 5e3029e from the bottom layer upward, so each pull request keeps its own layer diff (8 / 11 / 8 / 51 / 2 / 10 files) and the tip stays reviewable. Four merge conflicts were resolved by keeping both sides: the documentation sections that dev and this lane each added, and all four test imports. No test was deleted and no dev-only content was lost.
  • git merge-tree --write-tree origin/dev <tip> reports a clean merge.
  • Ancestry verified so each layer closes as MERGED: bl1, bl2, bl3, bl4 and bl5 are all ancestors of this tip.

Chained-child stacks merge top-down, so this lands in the parent branch and cascades to dev. CI evidence transfers by tree identity at each step.

Maintainer integration decision under MAINTAINERS.md / AGENTS.md: a maintainer with maintain or admin access may integrate into dev without a second maintainer approval, recording the decision and exact-head CI evidence.

@lidge-jun
lidge-jun merged commit bf68be4 into codex/bl4-key-usage-attribution Sep 16, 2026
7 of 8 checks passed
@lidge-jun
lidge-jun deleted the codex/bl5-recovery-limiter-dispatch branch September 16, 2026 04:48
@github-actions github-actions Bot changed the title fix(routing): give a withheld recovery a retry time that is actually later (#4546) [WRONG BRANCH] fix(routing): give a withheld recovery a retry time that is actually later (#4546) Sep 16, 2026
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • wrong target branch (codex/bl4-key-usage-attribution); retarget to dev.

What to do

  • Retarget this PR to dev — all contributions go to dev.

Its title has been prefixed with [WRONG BRANCH].
Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually. The required enforce-target check will keep failing until every issue above is resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant