Repository navigation
Background process polling wastes tokens: each write_stdin poll triggers full API turn with complete history #13733
Description
Problem
When a background process is running (e.g. cargo build, cargo test), Codex enters a polling loop where each status check triggers a full API round-trip with the entire conversation history. This burns tokens/credits proportional to history size × poll count, even though no meaningful work is being done.
Root Cause (from source analysis)
The turn loop in codex.rs:4869 works as follows:
- Model issues
exec_command→ local process spawns → partial output returned needs_follow_up = true(stream_events_utils.rs:133)- Loop back →
clone_history().for_prompt()sends full history to API (codex.rs:4901-4905) - Model sees process still running → issues
write_stdinwith empty input to poll process_manager.rswaitsMIN_EMPTY_YIELD_TIME_MS(5s) then returns "(no new output)"needs_follow_up = trueagain → goto step 3
Each poll = 1 full API request with complete conversation history. A 60-second cargo build generates ~12 polling turns. With 250+ items in history, this is extremely wasteful.
The model also "reasons" about whether to wait or do something else during each poll, consuming additional output tokens for no useful purpose.
Observed Behavior
From proxy debug logs, a single Codex session shows items growing: 237 → 239 → 242 → ... → 300+ over routine tool calls. Each turn re-transmits the entire history. During background waits, the cadence is ~10s per poll with no substantive new content.
Confirmed by Community
Issue #10957 reporter noted:
"jobs are running in background but not sure implementation is very 'token' friendly as it keep polling and reasoning about it instead of just waiting"
Proposed Solutions
Option A: Local wait before API turn (preferred)
When write_stdin returns no new output and the process hasn't exited, do not set needs_follow_up = true. Instead, wait locally (30-60s configurable) and only re-poll the API when:
- The process produces new output, OR
- The process exits, OR
- A timeout expires
This could be implemented in the tool handler layer — if write_stdin response has no output and no exit code, sleep locally before returning, rather than bouncing back to the model.
Option B: Increase MIN_EMPTY_YIELD_TIME_MS
Change MIN_EMPTY_YIELD_TIME_MS from 5s to 30-60s for truly empty polls. This is a minimal change but still wastes one API turn per interval.
Option C: Batch tool results
When the model issues write_stdin for a process that has no new output, return the tool result without setting needs_follow_up. The model only gets called again when the process finishes or produces output (via a notification/callback mechanism).
Environment
- Codex CLI via Responses API proxy
- Observed on both macOS and Windows (Windows worse due to slower builds)
- History sizes: 200-300+ items typical for medium sessions
- GAC (proxy) bills per payload KB, making this especially costly
Related Issues
- Unified exec: long-running background commands stops unexpectedly #10957 — unified exec background commands + token inefficiency
- Background tasks keep spinning independently w/o way to kill them #8656 — background tasks spinning independently
- Token usage spikes too quickly in Codex CLI sessions #6113 — token usage spikes
- Background Terminal Sessions #3968 — background terminal sessions
Activity
github-actions commentedon Mar 6, 2026
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
vjain419 commentedon Mar 6, 2026
Please multiple team member sees this issue best workaround I found is use a subagent to not mess up with main agent context.
happyme531 commentedon Mar 6, 2026
I completely agree with this issue, but the token waste is only half the problem. The biggest dealbreaker here is that the AI literally "loses patience" and abandons long-running tasks.
While the token burn is awful, the current polling behavior completely breaks down on heavy, time-consuming commands (like a large compilation or a massive test suite that takes 20-30 minutes).
Here is what happens:
write_stdin10 to 15 times over the span of a few minutes.It is incredibly frustrating to wait for a 30-minute build, only to realize the AI gave up on monitoring it 5 minutes in.
For single, synchronous, or known long-running tasks, the agent should stop abusing the background terminal feature, and simply execute them in the foreground.
miraclebakelaser commentedon Mar 6, 2026
@jitlabs-sg can't you already do option B with
background_terminal_max_timeoutin config.toml?kcosr commentedon Mar 7, 2026
You're lucky. It's polling every second (yield_time_ms=1000) by default for my tasks. I can't use it for background tasks that don't produce frequent output. I've even added heartbeat output and the model ignores it and aborts because "it's taking too long to get the final output."
robertmsale commentedon Mar 7, 2026
All of the reasons everyone put in this issue are extremely valid and relevant, but I would like to add something to this.
I found a workaround by using MCP servers for long-running commands, but the problem is you have to develop your own sandboxing and agent thread bookkeeping. Basically, the only way to ensure long-running commands run until completion is by routing them through an MCP server that can be abused or avoided, or just create a bunch of friction.
They should at least allow a config to raise the minimum yield time so that we can raise it to whatever we want, such as 2 billion years. That would be better than the way it currently works.
robertmsale commentedon Mar 8, 2026
You guys are gonna get a kick out of this.
New skill: https://github.com/robertmsale/.codex/tree/main/skills/command-executionNew MCP: https://github.com/robertmsale/.codex/tree/main/mcp/command-execution^^^ Outdated
Codex searches your PATH for the shell you're using, and assigns that path to the PTY that the agent will use for the rest of the program. There is no configurable way to change the polling frequency, and in
unified_exec=falsemode there is no way to raise the command execution cap beyond 10 minutes (also the agent can set arbitrarily low timeouts, which it does fairly often, and runs the command multiple times up to the 10 min cap before giving up or deciding the timeout is a codebase issue and tries to fix non-problems). However, you can trick Codex (the application) into loading a shell that produces a job ID with every command execution that the agent sees, and with the MCP server they can pass that job ID and wait until that command completes. On mac the process looks like this:/opt/homebrew/shimon my system)zshenv PATH="/path/to/shim:$PATH" codexThe skill instructions tell them that all commands produce a job ID whether they like it or not, and if the command is not finished they use the MCP to wait until the command finishes. Set the tool call timeout to some absurdly high value and that's all she wrote!
Past couple days have been using this with extremely good results. Wasting tokens is not the biggest issue, as others have pointed out, it's the decisions the agent makes when a process runs for what they perceive as "too long", and no amount of prompt engineering seems to prevent them from eventually terminating a good process (besides tediously steering the agent). Also this prevents the spinlock/reawakening so it really does save tokens.
Anyways, figured I'd share this little workaround incase OpenAI decides to close this as not planned (lack of votes or otherwise), or it just takes months to land on an agreed solution + implementation.Not practical, preserved for historical reference onlyLooks like this in practice:

SproutSeeds commentedon Mar 16, 2026
I pushed on the narrowest tool-side fix locally, and my read is that this issue is real.
One important detail from the current code:
background_terminal_max_timeoutdoes not currently do what people in this thread seem to expect for empty polls. In thewrite_stdinpath, empty polls still effectively use the short fixed window because the requested yield is clamped into the5s .. background_terminal_max_timeoutrange. So when the model asks for something like1000ms, the actual empty-poll wait is still5000ms, not the configured background timeout.Locally, I patched the unified-exec path so that:
write_stdinpolls wait up tobackground_terminal_max_timeoutThat means a single empty poll can bridge a long silent period without bouncing back to the model every 5 seconds, but it still yields promptly once there is something useful to send back.
I also added a regression that crosses the old 5-second ceiling (
sleep 5.5 && echo ...) and verifies that one empty poll captures the delayed output. Targetedcodex-coretests passed locally.I ran a full workspace
cargo testtoo. It was not fully green on this machine, but the new unified-exec regression passed in the fullcodex_corerun, and several non-unified failures from that workspace run passed when rerun in isolation. The only still-reproducible blocker I hit separately was the existing seatbelt test pair.So my current read is:
background_terminal_max_timeoutis currently only an upper bound, not the actual empty-poll wait window people expectchernistry commentedon Apr 6, 2026
This is fundamentally an architecture problem, not a tuning problem. The core issue is that scheduling decisions (should I wait for this process? how long?) are being routed through the LLM, which means every "should I keep waiting?" check costs a full API round-trip with the entire conversation history re-serialized. Adjusting
MIN_EMPTY_YIELD_TIME_MSfrom 5s to 30s (Option B) just makes the bleeding slower — you still pay for reasoning tokens the model burns "deciding" to keep waiting, which is a foregone conclusion.We hit this exact class of problem when building our multi-agent orchestrator. The solution was separating the control plane from the inference plane completely. The orchestrator is deterministic Python code — it never calls an LLM. It spawns agents as subprocesses, then checks liveness with
proc.poll()(literally a non-blocking syscall that returns the exit code orNone). The tick loop runs every ~30 seconds, callscheck_alive()on each agent session, and reaps dead ones. Zero tokens consumed for scheduling. Zero API calls for "is my build done yet?"The key architectural insight: process lifecycle management is not an LLM problem. An LLM cannot wait more efficiently than a
select()call or a PID check. When you route "is process X still running?" through the model, you're paying for the model to parse the question, "reason" about it (consuming output tokens for a trivially deterministic answer), and emit a tool call — all while re-transmitting the full conversation context. With 250+ history items at ~10s cadence during a 60-second build, that's ~6 full-context API calls doing nothing.Option C in the issue description is closest to the right fix. But I'd go further: the tool handler layer should own the entire wait lifecycle. When
exec_commandspawns a background process, the local runtime should:This is effectively an event-driven architecture instead of a polling one. The model gets called when there's something to think about, not to ask "anything new?"
Related patterns that help in practice:
Per-task model routing. We assign a complexity tier to each task and route to an appropriate model. A trivial formatting fix doesn't need the same model or token budget as a multi-file refactor. This prevents the secondary waste where an expensive model is burning tokens on wait-poll cycles for work that shouldn't have used that model in the first place.
Token growth monitoring. We sample cumulative token consumption per agent every 30 seconds and detect quadratic growth patterns — when the per-interval token delta doubles across consecutive windows, it signals unbounded context accumulation (exactly what the polling loop produces). Agents that exceed a token threshold with zero file changes get auto-killed. A circuit breaker guards against infinite compaction loops when context windows fill up.
Cost anomaly detection. Per-task cost ceilings relative to the complexity tier median, burn-rate tracking against budget, output/input token ratio alerting, and retry-spiral detection (cumulative retry cost exceeding a multiplier of the first attempt). These catch exactly the scenario described here — a routine build-and-test cycle silently burning 10x the expected tokens.
The proxy billing model (per payload KB) makes this even worse since you're paying quadratically: each poll re-sends the growing history, so poll cost increases over the session lifetime. With N polls and history growing by ~3 items per poll, total payload is roughly proportional to N^2.
For anyone dealing with this now: if you can intercept at the proxy layer, you could short-circuit
write_stdintool calls that return "(no new output)" by caching and replaying the response without forwarding to the API. Ugly, but it stops the bleeding while the proper fix ships.We've been iterating on these patterns in Bernstein (https://github.com/chernistry/bernstein) — the token monitor and cost anomaly detector specifically exist because LLM-mediated polling is a recurring footgun in agent orchestration.
30 remaining items
PFCLEEAI commentedon Jul 20, 2026
I have a quantified multi-session reproduction that extends this issue from single-process polling to coordinator/worker orchestration.
Environment:
I parsed the local JSONL logs using:
response_itemas a response packetpayload.type == reasoningas a model reasoning stepshasum, SHA-256, artifact hashes, candidate manifests, and hash manifestsAdjacent-window comparison:
Functional code/test window:
Five minutes after the worker explicitly froze source changes and Unity runs:
Important correction: reasoning steps per packet did not increase (33.3% -> 30.0%). Instead:
This suggests the primary amplification is repeated full-history/cache replay caused by tool polling and cross-session supervision, not additional useful reasoning.
Expected behavior:
Affected local thread IDs can be uploaded with
/feedback:robertmsale commentedon Jul 21, 2026
I found a pretty elegant combination of solutions that doesn't involve janky MCPs or custom builds.
Then each project has a
$build-expectationsskill. You go through all the different build/test commands, time them, and record approximately how long they take, and tell the agent the initial poll should be the recorded minimum, and remaining polls can be every 30s or whatever. This setup is codex version agnostic and in my testing I've observed ~27% fewer input tokens on average (not a huge win but better than nothing) from tool calls. Since the agent can choose the polling time, the solution is making them aware of expectations in advance.I'm satisfied with this solution. It's made
unified_execvery cost effective and usable for me. But one thing that might make it more powerful is if the PreToolUse hook allowed for"yield_time_ms"to be rewritable and can matchwrite_stdintool calls. That way the agent doesn't need to be aware of the initial poll period. You can set it up so certain commands in the project have a minimum first poll time, and remaining polls can usemax(yield_time_ms, project_min_ms)defined by a hook or some other durable source. I think hooks are the key affordance for keeping the underlying implementation general purpose while users can tune behavior themselves.rbjarnason commentedon Jul 28, 2026
While this is manageable with a human using Codex, then for our AI scientist project that uses Codex App Server in a larger agent harness, then this was not sustainable. Here is a opinionated patch that we use for our project that prevents Codex busy-polling. I'm sharing this here in case it would be useful for someone, this is not a general solution and I hope OpenAI fixes this soon. https://gist.github.com/rbjarnason/5014b19e9326bdea782be4c994b2dc60
rogersau commentedon Jul 28, 2026
Confirmed on Codex CLI
0.145.0, running through T3 Code via Codex app-server on Linux.I inspected one rollout lasting approximately 103 minutes:
write_stdincalls, all sending empty inputyield_time_ms: 1000yield_time_ms: 30000write_stdinprocessed approximately 8.05M input tokens—about 34% of the session's inputThe one-second calls were not interacting with the process; they were repeatedly checking whether it had produced output or completed. Each check returned control to the model and caused another large-context inference.
This confirms the issue remains present in
0.145.0and also affects third-party clients using Codex app-server, rather than only the native CLI/Desktop UI.Expected behaviour: an empty wait should remain inside the runtime until output, process completion, user interruption, or a reasonably long timeout. An unchanged process state should not require another model turn every second.
naowalrahman commentedon Aug 3, 2026
I built an MCP server to address part of this issue. In particular, my workflow involves monitoring output on an HPC cluster for long periods, and Claude Code's monitor tool is really useful for that, so I tried to replicate it. The code is actually somewhat irrelevant -- it's really just a fancy wait. But in
~/.codex/config.tomlyou can define a custom timeout length for an MCP server's tool calls withtool_timeout_secand that is separate fromyield_time_msfor theexectool.I suppose another workaround for long-running shell commands could be just having a simple bash MCP server that bypasses the regular
exectool.majiayu000 commentedon Aug 9, 2026
Additional quantified evidence from a macOS workstation, collected from all local Codex session JSONL records over a continuous 17-hour window (2026-08-08 19:53 to 2026-08-09 12:53, Asia/Shanghai).
Environment:
Results:
The narrower five-session sample that contained the long-running repository jobs showed 3,078 empty polls among 6,358 tool calls (48%). The two percentages use different denominators: 28% is all API requests across all 18 sessions; 48% is tool calls in the five long-running sessions.
Worst observed cases:
yield_time_ms=1000.An important configuration observation:
background_terminal_max_timeoutwas already set to 1,800,000 ms (30 minutes) during this run. The model still explicitly requested 1-second, 10-second, and 30-second waits. This supports the point raised above that a maximum timeout alone does not solve the problem; it neither supplies nor enforces a useful empty-poll default/minimum.The practical local mitigation is now:
background_terminal_max_timeoutto the documented 300,000 ms default;These are only mitigations. The desired fix remains runtime-owned, event-driven completion: keep process liveness and waiting in the local control plane, and re-enter the model only when stdout/stderr changes, the process exits, or a real deadline is reached. This would remove the API/model turn from the deterministic question "is the process done yet?" and should also reduce compaction pressure and premature abandonment of healthy long-running commands.
naipi11 commentedon Aug 18, 2026
Empty
write_stdinpolls are billed as full model turns with the whole history. A longcargo build/ test run can spend a large share of the session on "(no new output)" checks.Method:
write_stdinpolls.Evidence: current-thread measurements include a 103-minute rollout with 51 empty
write_stdinpolls and millions of input tokens, plus a 17-hour local sample where empty polls were ~28% of requests / ~30% of input tokens.Independent community workaround; not an official OpenAI fix.
weathon commentedon Aug 24, 2026
Yes, I really want something like Claude Code where it can start a process and keep it running in background and get notified when it finishes. I am running LLM training and it takes weeks or months, I do not need it keep checking it every 30s. I already put it in AGENTS.md that it should not use it but it doesn't work. For tasks with long logs, this also make it very expensive, every time it read 10k token log, I think this is the reason my usage is draining so fast.
I now ask it to run it in tmux but it just started two tmux one for running another one for monitoring
Edit: Somehow i get it to run in background without blocking the conversation and no polling
tiago-peixoto commentedon Sep 6, 2026
Thanks for tracing the model-turn cost here and for the maintainer discussion about a runtime-owned watcher. I have a current, version-pinned reproduction that adds two details: the documented long empty-poll window is not taking effect in this build, and increasing the caller's requested timeout did not reduce the production polling rate.
Environment:
codex-cli 0.153.4on macOS.Measured tool calls:
exec_command({cmd: "sleep 200", yield_time_ms: 300000})write_stdin({chars: "", yield_time_ms: 290000})wait({cell_id, yield_time_ms: 60000})The first probe cost 2 model requests. The second cost 6 model requests for one 200-second wait.
Minimal reproduction:
sleep 200withexec_command, requestingyield_time_ms: 300000.charsandyield_time_ms: 290000.execand resume its returned cell withwait({cell_id, yield_time_ms: 60000}); that path yields after about 61 seconds.This appears inconsistent with the current configuration reference, which documents
background_terminal_max_timeoutas the maximum empty-write_stdinpoll window, with a 300,000 ms default and no stated upper bound. That default was in force. I also could not find documented[code_mode]yield settings.The impact is visible beyond the synthetic probe. Production runs already used 30-minute checkpoints and still spent 60 to 63 model requests per quiet hour. Across 26 isolated idle stretches (10.8 idle hours, 1,227 requests), the rate was 113.4 monitoring-driven model requests per idle hour. Over two days, 6,753 requests were spent, about 26.7 percent of estimated cost, on model turns that waited and received no new information. Moving the same workload to a runtime where waiting is not model-driven changed the monitoring share from 30.3 percent of all requests to 0.1 percent.
This seems to reinforce the runtime-owned, event-driven watcher direction discussed above rather than another timeout-only mitigation. Is that still the approach maintainers want contributors to pursue? If so, I am happy to help with an implementation after the intended API and ownership boundary are confirmed.
chernistry commentedon Sep 9, 2026
The three rows in @tiago-peixoto's table do not have the same cause, and separating them narrows where to look. Reading the code at tag
rust-v0.153.4, the version measured:Row 1 is what the code does by design.
background_terminal_max_timeoutis not consulted on theexec_commandpath at all.exec_commandputs its requested yield throughclamp_yield_time():called from
process_manager.rs::exec_command. So the first yield of any background command is capped at 30s regardless of what is requested or configured, which matches the measured 30.002s exactly. That reads as a documentation mismatch rather than a bug: the config reference describesbackground_terminal_max_timeoutwithout noting that it does not governexec_command.Row 2 is a different thing. On the empty-
write_stdinpath the same tag does honour the setting:max_write_stdin_yield_time_msis set once per session insession.rsfromUnifiedExecProcessManager::new(config.background_terminal_max_timeout), and thewrite_stdinhandler passescharsstraight through asinput. With the documented 300,000ms default and a 290,000ms request, that expression evaluates to 290,000. The measured 31.0s is instead within a second ofMAX_YIELD_TIME_MS, which is the ceiling on the other branch. So on that build either the empty branch was not taken or the manager was constructed with a smaller bound than the documented default.That distinction matters for the question being asked here, because row 2 decides whether the empty-poll case needs a new mechanism or is a wiring problem in an existing one. A cheap way to tell them apart without patching anything: run the same empty poll on a command that produces no output, and check whether the ~30s ceiling holds when
background_terminal_max_timeoutis set explicitly inconfig.tomlrather than left to the default. If it still returns at ~30s with an explicit value, the configured bound is not reaching the process manager.Row 1 is separable from the larger design discussion and cheap either way:
exec_commandcould take the same bound as the empty-poll branch instead of the fixed 30s cap, or the config reference could state the current limit. Today the first yield of every background start costs a model turn that no configuration can avoid.LawrentChen commentedon Sep 21, 2026
Hi guys. I am not a professional developer, but I made my tiny solution (a skill) for this polling issue in long-run tasks.
https://github.com/LawrentChen/codex_callback
For me it works pretty well. It allows the thread to be smoothly awakened and take over each time(can be more than once) the long-run task encounters a problem, and after completing phased tasks, it smoothly transitions to downstream docking tasks, requiring no polling or human intervention.
I use codex app and ssh to my developing server. It just works fine.
Take a look if you are interested.
LawrentChen commentedon Sep 22, 2026
#14110 (comment)
/goal can be a better way
docs: explain temporary cli-run workaround (openai/codex#42908, opena…
PHPCraftdream commentedon Oct 1, 2026
The separation @chernistry made between the initial exec yield and the empty-poll window is important: increasing a timeout is not the same intervention as removing model turns from waiting. @naowalrahman's suggestion of using an MCP-owned wait is the direction we used for an interim command runner.
I maintain
cc-arch-hands; itscli-runintegration takes the polling question out of the model's path for commands launched through that tool. A detached Node worker owns the actual child processes and writes combined stdout/stderr plus structured exit results to disk. In the default queue delivery, the launch call returns a task name and UID, the parent turn can end, and onecodex queuemessage is attempted after the whole run reaches completion. The worker does not periodically ask the model whether the commands are still running. The model can inspect saved results after notification rather than repeatedly consuming emptywrite_stdinresponses.The packaged option is available now:
It installs a Codex-only skill bundle, MCP configuration, and a marked usage-guidance section; restart Codex afterward. The source and coverage table are here.
For this issue, I would distinguish the architectural effect from a cost claim. We have not run a controlled before/after token-cost benchmark, so I am not claiming a particular percentage reduction or applying the measurements above to our package. The narrower claim is that this worker's wait does not require a stream of model-mediated status checks. Users should not reintroduce that stream by polling the provided
statustool; it is for requested inspection or recovering a failed notification.Limitations remain: native
exec_commandwaits, other tools, and autonomous goal continuations are unchanged. Queue access to the target thread is required, and an unavailable thread can leave delivery unsuccessful/pending. The alternative inline mode holds the MCP call open until results are ready, so it has different timeout/lifecycle constraints and is not an idle wakeup.This is also not a transparent sandboxed substitute for
exec_command: MCP jobs run outside Codex's per-turn filesystem sandbox, and arbitrary command execution needs user approval or explicit trust. A runtime-owned wait in upstream Codex would remove both this integration burden and the native polling overhead; that remains the fix I would want.renesugar commentedon Oct 2, 2026
I ran a deterministic 61m30s local sleeper to measure this terminal-polling behavior against ChatGPT subscription telemetry. The harness and analysis with individual reports are now public.
The following Codex trials verified both host and CLI as 0.160.0 and used the default service tier:
Each target completed 3690 seconds without restart, duplicate, or fail-safe. Trials were sequential and no subscription reset crossed an individual measurement. The mitigation kept Astra as the parent: JavaScript re-armed 13 terminal waits inside one Code Mode execution, rather than returning to the model after every terminal timeout. No monitoring subagent was used in these Codex trials.
The baseline sampling records occurred strictly before the sleeper's completion marker. This supports unnecessary model-mediated waiting as an identified client efficiency problem, rather than inference being inferred solely from quota movement. Sol reduced observed quota drain while the same estimated number of model rounds remained.
These are subscriber-window snapshots, not API-dollar estimates or a per-request server ledger. Round counts are best effort. Model/effort and starting headroom differed; a reset occurred between the repeated Astra trials. Each Codex trial included one host-required hourly status message and short snapshot boundary intervals. The analysis documents these limits, pinned source references, additional trials, and the distinction between local tool execution and remote inference.
Related: #35259, #42981, #32188.