Skip to content

fix(devin): wait out a stated rate-limit reset and replay the turn - #5041

Merged
lidge-jun merged 3 commits into
lidge-jun:devfrom
luvs01:agent/stated-reset-retry-20260918
Sep 18, 2026
Merged

lidge-jun merged 3 commits into
lidge-jun:devfrom
luvs01:agent/stated-reset-retry-20260918

Conversation

@luvs01

@luvs01 luvs01 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Devin/Cognition free-tier caps answer resource_exhausted trailers that name their own recovery delay: Your limit will reset in 35 seconds. That stated delay was never parsed — clients received the synthetic 2s Retry-After and combo cooldowns fell back to the 60s default, so a retry fired straight back into the live cap and the turn died inside a window it could have waited out. The Codex client does not retry 429s, so the failure surfaced as stream disconnected before completion.

  • parseRetryAfterFromMessage reads reset in N <unit> and the existing retry hints. Follow-up 9144d91 extracts the parser into a leaf module, handles compound durations and milliseconds, rejects misleading unit prefixes, and preserves the local parser binding in errors.ts.
  • streamChatEventsWithResetRetry wraps the Devin cloud stream: a pre-output 429 with a parseable stated delay can wait and replay the same logical request. Any yielded cloud event prevents replay. Ambiguous transport failures remain terminal.
  • Waits are limited to 2 replays and a cumulative scheduled-wait allowance per wrapper invocation: 30 minutes by default, adjustable with OPENCODEX_DEVIN_STATED_RESET_WAIT_MS up to 1 hour. 0 disables local waiting. The allowance is not reset for the second replay, and a server delay is never shortened to fit it. This is not a wall-clock deadline on model generation or a request-wide allowance shared across combo/OAuth re-entry.
  • Explicit numeric or HTTP-date Retry-After cooldowns now retain their full duration in combo state rather than becoming selectable after 10 minutes. The local fallback cap and the combo live-wait setting remain unchanged. The separate resetAt fallback is not changed here.

Validation of follow-up 9144d91

Performed in the editing environment:

  • 90 offline core/boundary checks passed.
  • 43 cases from the three new regression files passed against the proposed full modules through a Node.js/TypeScript harness. Bun assertions were adapted to Node; external routing/state/RPC dependencies were stubbed. This is not a Bun or live integration run.
  • Strict TypeScript check passed for src/lib/errors.ts and src/lib/retry-delay.ts only.
  • Original source blob hashes were verified before editing; uploaded source blobs match the locally checked versions.

Not rerun here: complete repository Bun suites, repository-wide tsc --noEmit, or a live Devin/Codex long-wait request. The original author's 10/10 stated-reset tests, 21/21 retry-after tests, 65/65 regression cases, and clean full typecheck were reported for the earlier head; they are not claimed as results for this follow-up.

Run on the latest head:

bun test tests/providers/devin-stated-reset-retry.test.ts tests/providers/devin-stated-reset-hardening.test.ts tests/server/retry-after-429.test.ts tests/server/retry-delay-hardening.test.ts tests/codex-integration/combo-authoritative-reset.test.ts
bun test tests/providers/devin-hardening.test.ts tests/server/errors-adapter-failure.test.ts tests/providers/opencode-zen-rate-limit.test.ts tests/codex-integration/combos.test.ts
bun x tsc --noEmit

Remaining long-wait integration work

This PR must not be described as complete one-hour automatic recovery yet:

  • OAuth/combo preflight can hold the response before SSE keep-alives begin; the default 300-second stall guard still needs to distinguish intentional cooldown waits from upstream silence.
  • Devin's physical inner replays still need integration with the shared send budget, provider pacing/fetch wrapper, and physical-attempt accounting.
  • Cumulative waiting is bounded within one reset wrapper, not across combo transitions or OAuth re-entry.
  • Combo waitForCooldownMs remains capped at 10 minutes. Persisting a cooldown does not imply that an HTTP connection can wait for that whole duration.

No global timeout was disabled, replay-safety boundary weakened, or automatic paid-provider fallback introduced by the follow-up.

Declaration

  • This PR is ready for review
  • I have tested the core changes locally within the scope described above
  • I have linked related issues or context

Validation

  • Focused Bun tests pass on the latest head
  • Regression suites pass on the latest head
  • Full repository typecheck passes on the latest head

Risk

  • Replay is gated on zero yielded cloud events
  • Scheduled waits are cumulatively bounded per wrapper invocation and abort-aware
  • Malformed/unsupported duration components are not partially interpreted
  • Long-wait transport and shared send/wait budget integration is complete

Notes

  • Latest dev synchronization rechecked
  • Draft until maintainer review and remaining validation

Summary by CodeRabbit

  • New Features

    • Devin cloud-direct requests now retry eligible pre-response rate-limit refusals when a recovery delay is provided.
    • Retry delays support reset messages, compound durations, and HTTP-date values.
    • Server-provided cooldown periods are honored more accurately.
  • Bug Fixes

    • Prevents retries after response output, during cancellation, or beyond configured wait limits.
    • Improves handling of malformed, ambiguous, and excessive retry-delay messages.
  • Tests

    • Added coverage for retry behavior, cooldown handling, cancellation, and delay parsing.

@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: e859fd14-1658-49d9-892b-d0ba268aab22

📥 Commits

Reviewing files that changed from the base of the PR and between f3cb520 and 6d435fa.

📒 Files selected for processing (13)
  • scripts/test-layout/layout.json
  • src/adapters/devin.ts
  • src/adapters/devin/cloud-direct/index.ts
  • src/adapters/devin/cloud-direct/stated-reset-retry.ts
  • src/combos/failover.ts
  • src/lib/errors.ts
  • src/lib/retry-delay.ts
  • tests/codex-integration/combo-authoritative-reset.test.ts
  • tests/fixtures/test-layout-expected.json
  • tests/providers/devin-stated-reset-hardening.test.ts
  • tests/providers/devin-stated-reset-retry.test.ts
  • tests/server/retry-after-429.test.ts
  • tests/server/retry-delay-hardening.test.ts
 ___________________________________________________
< This is not a microservice. This is a macro-mess. >
 ---------------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

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.

@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 18, 2026
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • review readiness checklist open (6/12 boxes ticked).

What to do

  • Tick all four boxes in the PR description once you're done (currently 6/12).

Review readiness checklist

  • ⬜ All CI tests are green on my local testing.
  • ✅ I pushed my PR to the latest dev commit.
  • ✅ I resolved all correct Codex and CodeRabbit findings.
  • ⬜ My PR is ready for review.

6/12 boxes ticked.

Automatic draft conversion failed. Please convert this pull request to a draft manually until every box above is ticked.

@luvs01
luvs01 force-pushed the agent/stated-reset-retry-20260918 branch from 9fde561 to 9a72d2b Compare September 18, 2026 08:17
@lidge-jun

Copy link
Copy Markdown
Owner

리뷰 · 우선순위 71 / 80

이 PR은 Devin/Cognition free-tier가 보내는 resource_exhausted 메시지 안의 명시적 재시도 지연(예: Your limit will reset in 35 seconds)을 파싱·대기·재전송하지 못하던 구멍을 막습니다. 지금 dev에서는 그 문구가 parseRetryAfterFromMessage에 없어서 클라이언트가 합성 2s Retry-After를 받고, combo cooldown은 60s 기본으로 떨어지며, Codex 클라이언트는 429를 재시도하지 않아 stream disconnected before completion으로 보입니다. tip facd2b6ca(#5040)와도 겹치지 않는 adapter-local + 공통 파서 확장입니다.

두 층입니다. (1) src/lib/errors.tsparseRetryAfterFromMessagereset in N <unit>과 분/시를 읽고, 기존 try-again/retry-after도 단위를 받습니다. 단위 없는 reset in 2026은 연도로 오인하지 않게 unit을 요구합니다. (2) 새 streamChatEventsWithResetRetry가 Devin cloud 스트림을 감싸, 이벤트를 하나도 yield하기 전 429 + 파싱 가능한 delay일 때만 sleep 후 동일 요청을 replay합니다. 출력이 나간 뒤의 실패는 재전송하지 않아 double side-effect를 피합니다. replay cap 2, wait ceiling 기본 30분(관측 ~21분, env OPENCODEX_DEVIN_STATED_RESET_WAIT_MS로 최대 1시간), abort 시 대기 중단.

테스트가 두껍습니다. devin-stated-reset-retry.test.ts 10케이스(대기·replay, post-output 금지, 파싱 실패/비429/비CloudChatError, cap, ceiling, 21분, env, abort), retry-after-429.test.ts에 Cognition trailer·분/시·cooldown metadata·단위 없는 2026 거부. +374/-9. 리스크 설명이 솔직하고 behaviour-oracle 방향과도 맞습니다.

경로 src/adapters/devin/cloud-direct/stated-reset-retry.ts - zero-event 게이트 + bound wait. 핵심 안전장치.
경로 src/lib/errors.ts parseRetryAfterFromMessage - 공통 파서 확장이라 Devin 밖 combo/Retry-After에도 영향. 회귀 스위트가 그걸 잠근다.
라인 (PR) adapters/devin.ts - streamChatEventsstreamChatEventsWithResetRetry 교체. 표면이 좁다.
테스트 “21 minutes under default ceiling” - 관측 윈도우를 기본 30분에 맞춰 고정. 좋다.
경로 tip #5040 - combo RF 거절 봉투 테스트와 축이 다름. 충돌·무효화 없음.

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

  • 공통 파서에 minute/hour를 여는 것이 다른 provider 메시지에 과한 cooldown을 만들 여지. includeDefault:false 케이스로 충분히 잠갔는지.
  • 기본 30분 hold가 UX상 너무 긴지(사용자는 “멈춘 것”처럼 볼 수 있음). env 문서화가 PR/changelog에 필요한지.
  • draft 상태면 ready만 올리면 되는지.

너의 추천
조건부로 merge. zero-event 게이트와 wait bound가 건전하다. 공통 파서 확장이 다른 429 문구 fixture에서 과한 delay를 만들지 않는지만 한 번 더 훑고, draft 해제 후 랜딩. types/config 분할과 무관.

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

luvs01 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up pushed as 9144d919ece06fec1896f0ff5486953d583869fd on the existing PR branch (non-force update, preserving 9a72d2b).

The commit changes 7 files: fixes compound/millisecond duration parsing and its errors.ts local import, bounds waits cumulatively within one reset-wrapper invocation, preserves explicit long Retry-After cooldowns, and adds three regression files covering 43 cases.

Verification: 90 offline core checks passed; the 43 new cases passed through a Node/TypeScript full-module harness with external routing/state/RPC dependencies stubbed; strict typecheck passed for the parser and errors modules. This is not a claim that full Bun/repository checks or live Devin/Codex recovery passed.

The newly triggered Cross-platform CI run 35328460765 and React Doctor run 35328460578 currently report action_required, not successful validation. The PR description now separates performed validation from outstanding checks, and the PR remains draft. Long-wait SSE/preflight handling and shared cross-layer send/wait budgeting remain outstanding; this commit must not be presented as complete one-hour automatic recovery.

luvs01 and others added 3 commits September 18, 2026 18:44
Cognition free-tier caps answer resource_exhausted trailers that name their own recovery delay ("Your limit will reset in 35 seconds"). The delay was never parsed: clients got the synthetic 2s Retry-After and combo cooldowns fell back to the 60s default, so a retry fired straight back into the live cap and the turn died inside a window it could have waited out.

parseRetryAfterFromMessage now reads "reset in N <unit>" phrasing plus minute/hour units on the existing hints, so the stated delay drives the client Retry-After and the cooldown metadata. streamChatEventsWithResetRetry waits the stated delay and replays the identical request, but only while the stream has yielded zero events — after any output a replay could double billable side effects, so those failures keep their terminal path. Waits are bounded by a replay cap and a per-wait ceiling; longer stated windows surface with the parsed delay intact.
Parse complete and compound retry durations without treating milliseconds as minutes. Keep the errors.ts local parser binding as well as its public export.

Bound scheduled waits cumulatively within a stated-reset wrapper invocation; preserve explicit refusal errors, cancellation, zero-wait opt-out and the no-output replay boundary. Store explicit Retry-After cooldowns without truncating them to the local ten-minute fallback.

Add regression coverage for parser integration, cumulative waits, abort races, post-output refusals and authoritative numeric/HTTP-date cooldowns.

Validation in this environment: 90 offline core checks; 43 proposed regression cases executed via a Node/TypeScript module harness with external routing/state/RPC dependencies stubbed; strict typecheck of errors.ts and retry-delay.ts. Full Bun/repository typecheck and live Devin validation remain pending. Long-lived SSE/preflight and cross-layer send/wait budgeting are not implemented by this commit.
`tests/test-layout-tooling.test.ts` failed its membership oracle: three of the
new test files resolve through neither the explicit map nor a regex seed, so
nothing placed them. `combo-authoritative-reset.test.ts` collides with the
`combo-` seeds, which point at `routing`, and the `devin-stated-reset-*` pair
has no seed at all.

Registered in both `scripts/test-layout/layout.json` and
`tests/fixtures/test-layout-expected.json`, which the oracle compares against
each other as well as against the tree.

Also rebased onto current `dev`.

Co-authored-by: luvs01 <luvs01@users.noreply.github.com>
@lidge-jun
lidge-jun force-pushed the agent/stated-reset-retry-20260918 branch from 9144d91 to 6d435fa Compare September 18, 2026 09:45
@lidge-jun

Copy link
Copy Markdown
Owner

Pushed a follow-up commit (6d435fa2c2) and rebased onto current dev.

test 2/4 was tests/test-layout-tooling.test.ts's membership oracle, not the Devin change. Three of the new files resolve through neither the explicit map nor a regex seed, so nothing placed them: combo-authoritative-reset.test.ts collides with the combo- seeds, which point at routing rather than codex-integration, and the devin-stated-reset-* pair has no seed at all. All three are now registered in both scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json, which the oracle compares against each other as well as against the tree.

Verified after the rebase: unresolvedNew, missingFromTree and wrongTarget are all empty, layout.explicit equals the fixture, and no file exceeds its size cap.

@lidge-jun

Copy link
Copy Markdown
Owner

macos 2/2 failed on sideband GET /v1/live/{callId} relays the exact frame ceiling bidirectionally at 15910.85ms. That case is one of the four already recorded in #4997 from the first completed macos control run, where it overran a 20000ms budget at 30837ms. It has nothing to do with the Devin rate-limit reset this branch changes.

Re-running the failed job. The Linux shards and every gate are green at this head.

@lidge-jun
lidge-jun marked this pull request as ready for review September 18, 2026 11:02
@lidge-jun
lidge-jun merged commit ff5d440 into lidge-jun:dev Sep 18, 2026
48 of 50 checks passed
lidge-jun added a commit that referenced this pull request Sep 19, 2026
)

* feat(devin): admit every inference send through the shared budget

PR #5041 shipped a pre-output replay of a stated rate-limit reset and recorded
what it left undone: those inner sends never reached the request-wide send
budget, the provider fetch wrapper, or physical-attempt accounting. A turn could
therefore make three real inference requests while the shared cap, the pacing
slot and the request log each saw one.

All three now go through the existing shared physical-send primitive, so a permit
is reserved once per actual send, confirmed at the wire boundary, and refunded
when admission succeeded but no request followed. Nothing increments a counter
directly and no second counter exists. The initial send is not double-charged:
the adapter reserves it as ordinal 1, and the request observer already ignores
ordinal 1 because the caller records the entry send itself.

A replay the budget refuses makes no inference request, records why recovery was
withheld, and surfaces the provider's original 429 rather than a local budget
error, so the server-stated reset and any outer cooldown behavior survive.
Reservation still happens after the stated wait, not before it, so a one-hour
wait does not hold a spend booking open for an hour. Catalog and JWT calls are
not inference sends and keep the global fetch.

* test(responses): admit the Devin row so the counted send actually happens

The case asserted one GetChatMessage call and got none, in thirteen milliseconds
with no adapter output: the turn was refused before the adapter ran. The devin
registry entry declares authKind "oauth", and an omitted authMode inherits it, so
the fixture's row demanded an OAuth credential while supplying an apiKey. It now
states authMode "key", which is what the working sibling fixture in this file
does and what the supplied credential actually is.

Both numbers are now asserted together and carry the response status and body in
the failure message, so a turn that never reaches the adapter says so instead of
presenting as an empty URL list.

* test(responses): route the Devin count case through a stored credential

The authMode guess was wrong: the case still recorded no send. Devin is an
OAuth-kind provider, and the key its adapter uses is injected onto the row from
the stored credential, so a config carrying only apiKey never routes and the turn
ends before the adapter. The case now seeds the credential the same way the
working Devin fixture does, which is the path production takes, and the catalog
is keyed to that same value.

The assertion is unchanged: one GetChatMessage call and one recorded send,
reported together with the response status and body so the next failure is
self-describing.

* fix(devin): count the first inference send where it is admitted

Review found the accounting still describing an intention rather than a send.
runTurnAttempt logs the attempt's first send before handing control to the
adapter, which is right for a transport whose sends the caller performs. Devin
now admits its own sends through the shared budget, so that first send can be
refused - and once earlier combo or empty-recovery sends have spent the
allowance, the log claimed a request the wire never made.

An adapter that reports every physical send now says so, and for those the
caller stops pre-logging and the observer counts ordinal 1 at the executor
boundary that actually dispatched it. Every other adapter and call site is
unchanged, including the ordinal-1 skip they rely on. An attempt-level recovery
kind still labels that first send when the adapter supplies none of its own.

* test(devin): pin that a refused first send is neither made nor counted

The accounting fix needs the case that exposed it: an allowance already spent
before this turn starts, which is what an earlier combo fan-out or empty-response
recovery leaves behind. Nothing reaches GetChatMessage, nothing is observed as a
physical send, and the budget records nothing - where the previous ordering would
have logged a send the wire never made.

The admitted case beside it still asserts exactly one call, one observation at
ordinal 1, and one charged send, so the fix cannot be satisfied by counting less.

* test(responses): pin the refused Devin send at the request boundary

The direct-adapter case cannot see the defect it was written for. The phantom
send was logged by executeResponsesRunTurn before the adapter ran, so only a case
that goes through handleResponses with a RequestLogContext can prove the attempt
records nothing.

This one puts Devin last in a failover combo behind a chat target that spends the
allowance first, which is the shape that leaves nothing for Devin's initial send.
It asserts no GetChatMessage request and a Devin attempt sendCount of zero, and
that the members which did send still account for themselves - so the fix removes
a phantom rather than suppressing real counts. The status and body travel in the
failure message.

The direct-adapter case stays for what it does cover, the executor side, and no
longer carries the claim that it pins the outer behavior.

* test(responses): force the refusal instead of arranging it through a combo

Review traced the arithmetic: a two-target combo has five total sends against a
base of four, the first target settles a counted booking leaving three, the
transition books the fourth, and Devin's initial send was still admitted at five.
The case therefore never reached the denied path, and an optional attempt lookup
let an absent attempt satisfy a zero count.

The budget is now handed to handleResponses already spent, built by the real
factory rather than inferred from combo behaviour. The assertions are the ones
that prove a refusal: exactly one attempt, that attempt is Devin's and its
sendCount is zero, no GetChatMessage request, zero total sends, and the response
carries request_send_budget_exhausted. Status and body travel in the failure
message.

This row fails against the eager pre-log it was written for: that path recorded
the attempt's send before the adapter ran, so sendCount would read one.
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.

2 participants