Skip to content

Start each new request to a Codex worker in a fresh thread - #46

Merged
aaroncoville merged 5 commits into
theme/sixth-historyfrom
feat/codex-fresh-thread
Oct 10, 2026
Merged

aaroncoville merged 5 commits into
theme/sixth-historyfrom
feat/codex-fresh-thread

Conversation

@aaroncoville

@aaroncoville aaroncoville commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

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 request from another agent opens a new thread, unless its in_reply_to names a message of the current thread:

    • A fix round answering the worker's own review continues the thread, and so does a correction to the request in hand.
    • A new review that happens to reply to some other agent's message (for example, a forwarded "ready for review") is still new work.
  • 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:

    • not while any message delivered into it is still in the inbox (the worker moves mail to inbox/.done/ once it has handled it);
    • not while a follow-up to it is waiting, in which case everything pending goes into the current thread.

    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_to opens a new thread; nothing else (the conversation field, 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 at pty:write) and the main-process wake watchdog. When the pending mail opens a thread, it types something else instead of the nudge:

  1. /new, which starts a new thread inside the running Codex process.
  2. The worker's brief (the protocol text its process was started with, now kept by HiveManager), with the nudge after it, pasted as one message.

Codex refuses /new while 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 /new was 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 /new and not a respawn per request.

  • A respawn replaces the terminal, re-runs the whole spawn and its boot grace, and would race the renderer's own tab handling.
  • /new stays 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:
    • gets its own session id (SessionStart fires on its first prompt), so the hooks move the registry and the cost ledger onto it;
    • writes its own rollout, whose token totals start from zero, so CodexUsageReader counts it whole: its first event takes the "no previous totals" path, the same one a restart takes.

Two accounting details came with this:

  • Before the move, the old thread's usage is written to the ledger. The ledger samples only the current session, so the old thread's last turn would otherwise never be recorded.
  • RunBaseline counts 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:

  • A Codex sample's time is clamped to now. A restored or clock-skewed rollout could otherwise show a negative "last active".
  • The snapshot reads a Codex or Grok agent's usage files only while that agent has a terminal.

Behaviour for Claude-provider agents is unchanged: every new path checks for provider === 'codex'.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Docs
  • Build / CI

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:write interception removed, which is the base behaviour. The renderer's nudge goes straight into the existing thread:

not ok 1 - the renderer’s nudge for a new request becomes /new, the brief and the nudge
  error: |-
    Expected values to be strictly deep-equal:
    + actual - expected
      [
    +   'You have new hive inbox message(s) — at least: review-2. Read your inbox, act on what is pending there, …'
    -   '/new',
    -   '\r',
    -   '\x1B[200~You are Reviewer.…

And RunBaseline, before its fix, for a long session replaced by a small new thread:

not ok 18 - a session that replaces another in this run is counted from zero
# pass 20
# fail 1

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:

{"hook_event_name":"SessionStart","source":"startup","session_id":"01a12817-07f8-…a53a"}
{"hook_event_name":"UserPromptSubmit","session_id":"01a12817-07f8-…a53a","prompt":"Reply with the single word ONE and nothing else."}
{"hook_event_name":"Stop","session_id":"01a12817-07f8-…a53a"}
{"hook_event_name":"SessionStart","source":"startup","session_id":"01a12817-abfe-…2d4e"}
{"hook_event_name":"UserPromptSubmit","session_id":"01a12817-abfe-…2d4e","prompt":"You are \"Probe\", a test agent.\nLine two of the brief.\nReply to anything with the single word READY.\n\nYou have new hive inbox message(s) — at least: probe-1. …"}
{"hook_event_name":"Stop","session_id":"01a12817-abfe-…2d4e"}
  • The new thread has a new session id.
  • Its first prompt is the whole multi-line brief followed by the nudge, submitted once.
  • It wrote its own rollout, and its first token count (input_tokens 15,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:

ok 14 - the renderer’s nudge for a new request becomes /new, the brief and the nudge
ok 16 - the watchdog’s nudge takes the same path
ok 17 - a follow-up, or a Claude agent, is nudged exactly as before

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

  • OS: macOS (Darwin 25.6, arm64), Codex CLI 0.162.0, Node 22
  • Steps:
    • Measured the baseline on the base commit before changing anything: 1728 of 1730, 2 skipped.
    • Wrote each test first and saw it fail:
      • the RunBaseline case above;
      • the two snapshot nits ("a rollout dated in the future is dated now" and "a Codex agent with no terminal is not read for the snapshot");
      • the new wiring tests, which fail with the interception removed.
    • test/codex-thread.test.cjs (25 tests) covers:
      • the boundary rule, using the message shapes the router actually produces, including a request handled in the current thread (arriving mid-task, alongside other mail, or waiting before a respawn) and then corrected;
      • persistence across a restart;
      • the exact keystrokes;
      • the real openNewCodexThread, nudgeWorker, pty:write handler and routed-message observer from index.ts, run as written with stubbed collaborators;
      • HiveManager.ensureAgent keeping a Codex worker's brief and none for a Claude agent.
    • Broke the code on purpose 23 times: dropped the reply check, the agent-sender check, the fresh-thread check, the mid-task guard, the sent-mail recording, the respawn reset and the unknown-worker default; removed the pty:write interception, 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.
    • Updated the existing enrichment-order harness for the new name its slice now contains, ptyToAgent. It maps no terminal to a worker there, so those four tests run unchanged.
    • Replayed the keystroke plan against a real Codex TUI in a pseudo-terminal, shown above.
    • npm run typecheck: 0 errors. npm run build: succeeds.
  • Not verified here: a full app session with a live Codex worker receiving a real review request. That needs the running app. Look for a codex-new-thread line in log.jsonl and a new session id for the worker in the registry.
  • Applies only to Codex workers spawned after the change: running processes do not hold a brief until they respawn.

Credit (optional)

Discord:

X:

Checklist

  • Before and after evidence is attached above, under both headings.
  • npm run typecheck passes.
  • npm run test:focused passes.
  • npm run build succeeds.
  • This PR is one change. Unrelated fixes belong in their own PR. (The fresh-thread change plus the RunBaseline fix it depends on; the last two commits are small fleet-snapshot fixes bundled deliberately, and each is its own commit.)
  • I read the diff myself before opening this, and there is no debug output,
    commented-out code, or unrelated formatting churn in it.
  • Any new UI derives from DESIGN.md / tokens.ts — no ad-hoc colors,
    spacing, or fonts. (No UI.)
  • If I added art, it's my own or compatibly licensed, and listed in
    ATTRIBUTION.md. (No art.)

Aaron Coville 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.
@github-actions

Copy link
Copy Markdown

🚫 This PR is missing its before/after evidence

Every pull request here has to show its work. Screenshots or a short screen recording, before the change and after it.

  • Before — no image or video under that heading
  • After — no image or video under that heading

How to fix it: edit the description, keep the ### Before and ### After headings from the template, and drag an image or video under each. GitHub uploads it inline. This check re-runs the moment you save.

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 no-visual-change label. Please don't ask unless it truly has no observable effect.

📖 CONTRIBUTING.md → Evidence is mandatory

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.
@aaroncoville
aaroncoville merged commit df06d72 into theme/sixth-history Oct 10, 2026
0 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant