Skip to content

feat(responses): a finite send ceiling per root workflow (#4546) - #4612

Merged
lidge-jun merged 1 commit into
devfrom
codex/260914-workflow-budget
Sep 14, 2026
Merged

feat(responses): a finite send ceiling per root workflow (#4546)#4612
lidge-jun merged 1 commit into
devfrom
codex/260914-workflow-budget

Conversation

@lidge-jun

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

Copy link
Copy Markdown
Owner

Summary

Refs #4546. PRD R05, first slice, following #4609 and #4611.

The per-request budget bounds how many times one request reaches upstream. It cannot bound a fan-out: a worker that spawns seven hundred children, each sending exactly once, never violates a per-request cap and still spends the account. That is the second half of the incident, and it needs a ceiling of its own.

src/lib/workflow-budget.ts tracks the root workflow — the user-visible task, identified by the parent thread header — and gives it a finite physical-send ceiling. Every send charged to the request budget is charged to the root as well, including the cross-account move, and a root that has spent its ceiling is refused before dispatch with workflow_budget_exhausted rather than a synthetic upstream error.

An exhausted root is never evicted to make room. Dropping a live entry would hand the fan-out a fresh allowance, which is precisely the laundering the ceiling exists to stop, so eviction skips any root with work in flight.

Scope and honest limits

The ledger is process-local and in-memory. It bounds a single proxy process and says nothing about a second process sharing the same account pool; that needs a shared durable store and is stated as out of scope rather than implied. The concurrency ceiling and the interactive reserve are implemented in the module but not yet wired, because they need a release path tied to the turn lease.

Verification

No local suite, typecheck, install or build was run — this environment forbids them, so hosted CI at the exact final head is the only runtime proof. Local checks: NOT RUN.

Checklist

  • Targets dev
  • No request bodies, API keys or account identifiers are logged
  • Local CI — NOT RUN by policy

Summary by CodeRabbit

  • New Features

    • Added workflow-level safeguards to limit concurrent activity, physical model sends, and distinct child workflows.
    • Interactive requests retain reserved capacity when worker activity reaches its limits.
    • Workflow activity is tracked separately across retries and related requests.
  • Bug Fixes

    • Requests that exceed the workflow send limit now receive a clear 429 workflow_budget_exhausted response.
    • Retry attempts are included in workflow budget accounting to prevent uncontrolled repeated sends.

Refs #4546. PRD R05/wp5, first slice.

The per-request budget bounds how many times one request reaches upstream. It cannot bound a
fan-out: a worker that spawns seven hundred children, each sending exactly once, never violates a
per-request cap and still spends the account. That is the second half of the incident.

src/lib/workflow-budget.ts tracks the root workflow -- the user-visible task, identified by the
parent thread header -- and gives it a finite physical-send ceiling. Every send charged to the
request budget is charged to the root as well, including the cross-account move, and a root that
has spent its ceiling is refused before dispatch with workflow_budget_exhausted rather than a
synthetic upstream error.

An exhausted root is never evicted to make room. Dropping a live entry would hand the fan-out a
fresh allowance, which is precisely the laundering the ceiling exists to stop, so eviction skips
any root with work in flight.

The ledger is process-local and in-memory. It bounds a single proxy process honestly and says
nothing about a second process sharing the same account pool; that needs a shared durable store
and is declared out of scope rather than implied. The concurrency ceiling and the interactive
reserve are implemented in the module but not yet wired, because they need a release path tied to
the turn lease.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 14, 2026 09:52
@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-14T09:56:25.896113Z 552b410 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 github-actions Bot added the intake: hygiene-blocked Deterministic PR hygiene checks failed label Sep 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Deterministic hygiene checks failed.

  • missing_regression_test — Behavior changed under src/ or gui/src/ without a test change. Add focused coverage or obtain test-exception-approved.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • hygiene: missing_regression_test.

What to do

  • Fix missing_regression_test — Behavior changed under src/ or gui/src/ without a test change. Add focused coverage or obtain test-exception-approved.

This pull request was already a draft. Its draft status will be preserved after every issue above is resolved.

@github-actions
github-actions Bot marked this pull request as draft September 14, 2026 09:52
@github-actions github-actions Bot added the enhancement New feature or request label Sep 14, 2026
@lidge-jun
lidge-jun merged commit d23fdd1 into dev Sep 14, 2026
22 of 26 checks passed
@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: 97ca79ba-5f0d-4967-a0c1-f11991c4f4be

📥 Commits

Reviewing files that changed from the base of the PR and between 1abc5cc and 552b410.

📒 Files selected for processing (2)
  • src/lib/workflow-budget.ts
  • src/server/responses/core.ts

📝 Walkthrough

Walkthrough

Adds an in-memory workflow budget ledger with admission, send, child, and root-tracking limits. The Responses handler charges transient and account-move sends to the root workflow and returns 429 workflow_budget_exhausted before dispatch when the send ceiling is reached.

Changes

Workflow budget enforcement

Layer / File(s) Summary
Budget ledger and admission API
src/lib/workflow-budget.ts
Lines 1-57 define budget policies, defaults, lane types, admission results, and denial reasons. Lines 65-132 track roots, prune idle roots, enforce send, child, and concurrency limits, and return idempotent leases. Lines 138-172 add send accounting, ceiling checks, snapshots, and test reset behavior.
Responses send enforcement
src/server/responses/core.ts
Lines 236-239 import the ledger APIs. Lines 1304-1305 and 6132 thread workflowRootId through alternate-account retries. Lines 1668-1669 charge account-move sends. Lines 5040-5059 charge transient sends and reject requests with 429 workflow_budget_exhausted before upstream dispatch.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant ResponsesHandler
  participant WorkflowBudgetLedger
  participant UpstreamDispatch
  ResponsesHandler->>WorkflowBudgetLedger: Check workflow send ceiling
  alt ceiling reached
    WorkflowBudgetLedger-->>ResponsesHandler: Ceiling reached
    ResponsesHandler-->>UpstreamDispatch: Return 429 workflow_budget_exhausted
  else ceiling available
    WorkflowBudgetLedger-->>ResponsesHandler: Dispatch allowed
    ResponsesHandler->>UpstreamDispatch: Send request
    UpstreamDispatch-->>ResponsesHandler: Report physical sends
    ResponsesHandler->>WorkflowBudgetLedger: Charge workflow sends
  end
Loading
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/260914-workflow-budget

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.

@lidge-jun
lidge-jun deleted the codex/260914-workflow-budget branch September 14, 2026 09:52

@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: 552b41060d

ℹ️ 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".

Comment on lines +140 to +141
const state = roots.get(rootId);
if (!state) return;

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 Create workflow state before recording sends

For every request carrying x-codex-parent-thread-id, chargeWorkflowSends immediately returns because roots.get(rootId) is undefined: the only function that creates a state is admitWorkflowTurn, and a repository-wide search shows it has no call sites. Consequently the snapshot remains absent after any number of charges and workflowSendCeilingReached always returns false, so the new 256-send ceiling never rejects a request. Initialize/admit the root before checking or charging it, and add a focused test that reaches the ceiling through the Responses path.

AGENTS.md reference: src/AGENTS.md:L22-L25

Useful? React with 👍 / 👎.

Comment on lines +5044 to +5047
const noteTransientSends = (used: number): void => {
const charged = Math.max(0, used);
sendBudget.used += charged;
chargeWorkflowSends(workflowRootId, charged);

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 Charge sends from every provider dispatch path

Even after workflow state creation is wired, this callback is not a common dispatch hook: in the generic routed path, providers without transientRetryPolicy use fetchWithResetRetry without onSendsConsumed (core.ts around lines 7764-7794), while adapters implementing fetchResponse receive only the request budget. Thus ordinary successful requests for those providers never invoke chargeWorkflowSends, and a root can make unlimited physical sends without approaching the workflow ceiling. Charge immediately in the shared pre-dispatch path, or propagate root accounting through every send implementation, with focused coverage for a reset-only provider.

AGENTS.md reference: src/AGENTS.md:L22-L25

Useful? React with 👍 / 👎.

lidge-jun added a commit that referenced this pull request Sep 14, 2026
…) (#4614)

Refs #4546. PRD R06/wp5, second slice, completing the module landed in #4612.

A fan-out shares the conversation it serves. Without a reserve, a worker burst takes every
concurrency slot under its own root and the interactive turn that started it waits behind its own
children. runAdmittedHttpTurn now admits each turn against the root workflow as well as the
process-wide turn gate: a request that names a parent thread distinct from its own is treated as
that fan-out and may not take the reserved slots, while a top-level request is the conversation
and may.

The refusal is a local queue-capacity answer, not a synthetic upstream error, and the lease is
released on both the normal and the throwing path so a failed turn cannot leak a slot.
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 58 / 80

이 PR은 #4546 비용 가드 열차의 R05 첫 슬라이스다. 지금 dev HEAD(237a12869)에는 이미 이 커밋(d23fdd1a1, #4612)과 바로 이은 #4613까지 들어와 있다. 그래서 이 리뷰는 머지 전 게이트가 아니라, 이미 dev에 올라간 첫 칸이 실제로 무엇을 켜 두었는지를 현재 트리 기준으로 다시 확인하는 글이다.

왜 필요한가. #4609·#4611까지는 한 논리 요청이 upstream에 몇 번 닿는지를 막는다. 그런데 워커가 자식을 일곱백 개 만들고, 자식마다 전송을 딱 한 번만 하면 요청당 상한은 절대 안 깨진다. 계정은 그래도 다 쓴다. 그게 #4546 사고의 다른 절반이다. 이 PR은 그 절반을 막으려고 루트 워크플로(클라이언트가 주는 부모 스레드 = 사용자에게 보이는 한 작업)에 물리 전송 천장(maxPhysicalSends, 기본 256)을 붙인다.

모양이 어떻게 생겼나. 새 파일 src/lib/workflow-budget.ts가 프로세스 안 메모리 장부다. WorkflowBudgetPolicy로 동시 자식·물리 전송·서로 다른 자식 수·인터랙티브 예비 슬롯·추적 루트 상한을 정하고, admitWorkflowTurn / chargeWorkflowSends / workflowSendCeilingReached / workflowBudgetSnapshot / resetWorkflowBudgetsForTest를 내보낸다. 활성 루트는 절대 쫓아내지 않는다. 쫓아내면 팬아웃이 새 허용량을 받아 세탁이 되기 때문이다. 배선은 src/server/responses/core.ts에만 있다. x-codex-parent-thread-idworkflowRootId로 읽고, noteTransientSends가 요청 예산과 함께 루트에도 같은 발수를 올리고, 천장에 닿으면 디스패치 전에 429 / workflow_budget_exhausted로 거절한다. 계정 이동(retryCodexPoolOnAlternateAccount)도 물리 전송이라 chargeWorkflowSends(..., 1)을 한 번 더 친다. 본문이 밝힌 한계도 그대로다. 장부는 프로세스 로컬이고, 동시성·인터랙티브 예비는 모듈에만 있고 아직 안 묶였다. 공유 내구 저장소는 범위 밖이다.

다만 현재 dev에서 호출 그래프를 다시 세면, 보내기 천장이 아직 살아 있지 않다. admitWorkflowTurn을 부르는 곳이 workflow-budget.ts 정의 말고는 없다. chargeWorkflowSendsroots에 이미 있는 루트만 올리고, 없으면 조용히 return 한다. workflowSendCeilingReached도 상태가 없으면 false다. 즉 core가 헤더를 읽고 charge·ceiling을 호출해도, admit으로 장부 칸이 생기기 전에는 카운터가 안 움직이고 거절도 안 난다. 본문이 동시성은 아직 안 묶었다고 한 것과 달리, 물리 전송 천장 배선 자체도 admit 없이는 사실상 비활성이다. 그게 이 슬라이스의 가장 큰 구멍이다. hygiene도 missing_regression_test / intake: hygiene-blocked였고 (머지 시점 draft 상태였음) 회귀 테스트는 없다.

라인 workflow-budget.ts 138-141 - chargeWorkflowSends가 상태 없으면 no-op이다. core만 보면 첫 전송부터 올려야 하는데, admit이 없으면 영원히 0이다. charge 쪽에서 루트를 만들거나, core에서 admit을 먼저 불러야 한다.
경로 admitWorkflowTurn 전 저장소 - 정의만 있고 호출자가 없다. 동시성·자식·인터랙티브 예비뿐 아니라, 위 charge no-op까지 연쇄로 막힌다. #4614(워커 팬아웃·인터랙티브 용량)에서 묶을 계획이면, 그 PR이 천장 활성화 PR이기도 하다.
라인 core.ts 5043 / 5053-5058 - 헤더가 없으면 workflowRootId가 undefined라 천장 검사가 꺼진다. 의도적 옵트인이면 문서·클라이언트 계약에 부모 스레드 헤더 없으면 루트 천장 없음을 한 줄로 고정해야 한다. 헤더를 안 보내는 팬아웃은 그대로 뚫린다.
라인 workflow-budget.ts 67-76 / 94-97 - maxTrackedRoots에 닿아도 전부 active > 0이면 pruneOldestRoot가 아무 키도 못 지운다. 그다음에도 roots.set을 해서 상한을 넘길 수 있다. 거절할지, 대기할지, 상한을 하드로 막을지 정책이 없다.
라인 core.ts 1669 vs 5044-5047 - 계정 이동은 chargeWorkflowSends(1)을 직접 치고, 일반 전송은 noteTransientSends로 친다. 지금 구조상 이중 집계로 보이진 않지만, 이동 경로가 나중에 onSendsConsumed/noteTransientSends까지 타면 루트 발수가 두 배가 된다. 이동은 루트 +1 한 번만인지 테스트로 고정할 것.
라인 workflow-budget.ts 161-163 - workflowBudgetSnapshot 반환 타입 선언 안에 빈 줄이 끼어 있다. 동작과 무관한 잡음이다.
경로 hygiene missing_regression_test - 천장 도달 시 429/workflow_budget_exhausted, 헤더 없을 때 통과, admit 후 charge가 누적되는지, 활성 루트는 prune되지 않는지 — 최소 하나라도 소스 오라클/동작 테스트가 있어야 첫 슬라이스가 회귀로 안 무너진다. resetWorkflowBudgetsForTest는 이미 준비돼 있다.

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

  • 이미 dev에 들어온 상태이므로: #4614에서 admitWorkflowTurn을 반드시 묶을지, 아니면 초소형 follow-up으로 chargeWorkflowSends가 없는 루트를 생성하게 고칠지
  • x-codex-parent-thread-id 없는 요청을 영원히 천장 밖으로 둘지, 다른 루트 키(세션/스레드 id)로 폴백할지
  • 추적 루트가 꽉 차고 전부 활성이면 새 루트를 거절할지, 상한을 soft로 둘지
  • 회귀 테스트를 #4614에 실을지, 이미 머지된 feat(responses): a finite send ceiling per root workflow (#4546) #4612 위에 바로 핫픽스 PR을 overlays을지

너의 추천

추가 머지 논의는 필요 없다. 이미 d23fdd1a1dev에 있다. 대신 바로 이어서 (1) admitWorkflowTurn을 Responses 경로에 묶거나 chargeWorkflowSends가 루트 엔트리를 만들게 해서 천장을 실제로 켜고, (2) 천장 도달 429·헤더 없음 통과·활성 루트 비퇴출을 최소 테스트로 고정하라. 방향은 #4546 R05에 맞고 types/config 분할에 무효화되지도 않는다. 동시성·인터랙티브 예비·프로세스 간 공유는 본문대로 다음 칸(#4614 등)으로 남겨도 된다. 다만 전송 천장을 켰다고 말하려면 admit/charge 중 하나는 루트를 반드시 만들어야 한다. #4546 에픽은 계속 OPEN이다.

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

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

Labels

enhancement New feature or request intake: hygiene-blocked Deterministic PR hygiene checks failed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant