Repository navigation
Start each new request to a Codex worker in a fresh thread - #46
Merged
Merged
Conversation
added 4 commits
October 10, 2026 19:06
When a Codex or Grok agent started a new thread while the app was running, the circuit breaker did not see what that thread spent before the usage beat first sampled it, and once the new thread's totals were below the old session's it saw nothing of it at all. RunBaseline took a new session's first sample as its baseline, the right choice for the first session seen in a run, which can carry history from before the app started. A session that replaces it later began while the run was watching, so none of it is history. And because a new thread usually holds far less than the long one it replaced, its first sample also read as a shrink, re-basing it on itself again. A session change now gets a zero baseline, checked before the shrink test. The first session seen for an agent, and the first after a respawn, are unchanged.
A Codex worker ran one interactive thread for as long as its process lived, and a respawn resumed it. A reviewer handling a request a day therefore carried every earlier review in its context, and paid for it on every model call, so most of what a long-lived reviewer was billed for input was context from work it had already finished. A worker now starts a new thread for each new request it is sent, and keeps its thread for everything that continues the work in it. The rule (codexThread.ts): - A thread is the mail delivered into it plus the mail the worker sent from it, recorded as the router sees each message and kept on disk beside the worker's Codex home, so it survives an app restart. - A `request` from another agent opens a new thread unless its `in_reply_to` names a message of the current thread. A fix round answers the worker's own review, so it continues; a new review that happens to reply to someone else's message is new work. - Replies, notices, and mail the app sends itself (circuit-breaker steers, scheduled jobs) never open one. - A thread is never replaced while mail delivered into it is still in the inbox, or while a follow-up to it is waiting; a new process takes its first request in the thread it started with. The move happens where a nudge would announce the mail: the renderer's queued nudge (intercepted at pty:write) and the main-process wake watchdog both go through openNewCodexThread. Instead of the nudge it types `/new`, which starts a new thread in the running process, then the worker's brief (the protocol text its process was started with, now kept by HiveManager) and the nudge, pasted as one message. Codex refuses `/new` while a turn runs, so it cannot cut a turn short. This was chosen over respawning the process per request. A respawn replaces the terminal, re-runs the whole spawn and boot grace, and would race the renderer's own tab handling. `/new` stays inside the running process, and its cost accounting needs no change. Checked on Codex 0.162.0: - the new thread gets its own session id (SessionStart fires on its first prompt), so hooks move the registry and ledger to it; - it writes its own rollout, whose token totals start from zero, so the existing per-file reader counts it whole. The old thread's usage is written to the ledger before the move, since the ledger samples only the current session and the old thread's last turn would otherwise never be read.
The fleet snapshot could show a Codex agent as last active a negative number of seconds ago. A Codex sample is dated by its rollout's modification time, and a rollout restored from a backup or written under a clock that ran ahead can carry one past now. The Codex activity reader already clamps that reading; the usage sample, which the snapshot now prefers, passed it through unchanged. The sample's time is clamped to now where it is made, so the ledger row and the snapshot agree.
…erminal Every fleet snapshot read the session files of every unarchived Codex and Grok agent, including ones whose process had died and whose record was only waiting to be archived: a directory walk each, and a first read of a long rollout that can take a few hundred milliseconds on the main thread. A dead process's files say nothing live, and the breaker beat already skips agents without a terminal. The snapshot now pulls a file sample only for an agent that has one.
🚫 This PR is missing its before/after evidenceEvery pull request here has to show its work. Screenshots or a short screen recording, before the change and after it.
How to fix it: edit the description, keep the A bug fix with no visible surface still needs it: show the failing behaviour, then the same steps passing. A terminal recording is fine. Genuinely nothing to show — a CI tweak, a typo, a dependency bump? A maintainer can apply the |
A correction to a request could start a new Codex thread, throwing away the context of the very work it corrects. And after a respawn, the next new request could be taken as a follow-up of the old one. An opening request was recorded only when it actually got a new thread. Every request handled in the current thread instead was never recorded: - one that arrived while the worker was still on the previous request; - one that arrived alongside other mail of the thread; - one already waiting when a new process started. A correction replying to such a request then looked like new work. And a new process whose first request came from that waiting mail stayed marked as not having had a request, so it absorbed the next one too. A request joins the current thread wherever it shows it is being handled there: - it is routed while that thread still has mail pending, so no new thread can start; - a nudge hands it to the current thread, including for a process that has no brief to start a new thread with; - the worker replies to it. A reply also marks a new process as having had its request.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
A Codex worker ran one interactive thread for as long as its process lived, and a respawn resumed it. So a reviewer that handled a request a day carried every earlier review in its context and paid for it again on every model call: most of what a long-lived reviewer was billed for input was context from work it had already finished.
A Codex worker now starts a new thread for each new request it is sent, and keeps its thread for everything that continues the work already in it. The boundary is explicit and tested (
src/main/codexThread.ts):A thread consists of the mail delivered into it and the mail the worker sent from it. The router records both as it sees each message. The record is kept on disk beside the worker's Codex home (
.codex/hive-thread.json), so it survives an app restart.A
requestfrom another agent opens a new thread, unless itsin_reply_tonames a message of the current thread:Nothing else opens one. Replies, notices, and mail the app sends itself (circuit-breaker steers, scheduled jobs) never open a thread.
A thread is never replaced mid-task:
inbox/.done/once it has handled it);A new process takes its first request in the thread it started with.
A request that does not get a new thread belongs to the one that handled it, so a correction to it continues that thread. It is recorded wherever that shows: it was routed while the thread still had mail pending, a nudge handed it to the current thread, or the worker replied to it.
The boundary relies on the sender: a fix round must reply to the worker's verdict, or to the request it corrects. A follow-up sent with no
in_reply_toopens a new thread; nothing else (theconversationfield, for instance, can span several reviews) tells it apart reliably.How the move happens. Both nudge paths go through one function,
openNewCodexThread: the renderer's queued nudge (intercepted atpty:write) and the main-process wake watchdog. When the pending mail opens a thread, it types something else instead of the nudge:/new, which starts a new thread inside the running Codex process.HiveManager), with the nudge after it, pasted as one message.Codex refuses
/newwhile a turn is running, so this can never cut a turn short. On 0.162.0 I replayed the whole sequence into a running turn: the/newwas dropped without leaving text in the composer, no new session started, and the brief and nudge reached the running turn of the same session, which is where a plain nudge would have gone. Both nudge paths only fire when the worker is idle, so in practice the sequence always lands between turns.Why
/newand not a respawn per request./newstays inside the running process and keeps cost accounting correct with no change to the usage reader. Checked on Codex 0.162.0, the new thread:CodexUsageReadercounts it whole: its first event takes the "no previous totals" path, the same one a restart takes.Two accounting details came with this:
RunBaselinecounts a session that replaces another within the run from zero (the first commit). Previously the breaker missed everything the new thread spent before the beat first sampled it. And because a new thread holds far less than the long one it replaced, its first sample also read as a shrink, so the breaker saw none of it.The last two commits are small, independent fixes to the Codex fleet-snapshot path, bundled here because they touch the same code:
Behaviour for Claude-provider agents is unchanged: every new path checks for
provider === 'codex'.Type of change
Evidence
Before
On the base branch, an idle Codex worker's inbox nudge is typed into the thread it already has, however unrelated the new request is. Here is the new wiring test run with the
pty:writeinterception removed, which is the base behaviour. The renderer's nudge goes straight into the existing thread:And
RunBaseline, before its fix, for a long session replaced by a small new thread:After
Below are the exact keystrokes
newThreadWrites()produces, replayed into a real Codex 0.162.0 TUI in a pseudo-terminal: one turn, then the new-thread sequence with a three-line brief. These are the hook payloads Codex sent:input_tokens15,383) is what a fresh thread costs, not the first thread's context plus more.In an earlier run of the same probe, two threads started with identical prompts reported the same
input_tokens(15,281 each), so a new thread's counters start from zero.The same wiring test, with the change:
Full suite: 1756 of 1758 pass, 2 skipped, 0 failing. The base, f666fa3, was 1728 of 1730 with the same 2 skipped.
How I tested it
RunBaselinecase above;test/codex-thread.test.cjs(25 tests) covers:openNewCodexThread,nudgeWorker,pty:writehandler and routed-message observer fromindex.ts, run as written with stubbed collaborators;HiveManager.ensureAgentkeeping a Codex worker's brief and none for a Claude agent.pty:writeinterception, the watchdog hook, the ledger flush, the router observer, the brief storage, the provider check, the post-move record, the pending-submit count and the in-flight guard; and each of the ways a request handled in the current thread is recorded (reply, routing with mail pending, nudge, no-brief nudge, a new process's first reply). The suite went red every time.ptyToAgent. It maps no terminal to a worker there, so those four tests run unchanged.npm run typecheck: 0 errors.npm run build: succeeds.codex-new-threadline inlog.jsonland a new session id for the worker in the registry.Credit (optional)
Discord:
X:
Checklist
npm run typecheckpasses.npm run test:focusedpasses.npm run buildsucceeds.RunBaselinefix it depends on; the last two commits are small fleet-snapshot fixes bundled deliberately, and each is its own commit.)commented-out code, or unrelated formatting churn in it.
DESIGN.md/tokens.ts— no ad-hoc colors,spacing, or fonts. (No UI.)
ATTRIBUTION.md. (No art.)