Skip to content

fix(codex): seat a role from the app-server's loaded threads, not rollout files - #583

Merged
fujibee merged 4 commits into
mainfrom
fix/codex-seat-from-loaded
Aug 1, 2026
Merged

fix(codex): seat a role from the app-server's loaded threads, not rollout files#583
fujibee merged 4 commits into
mainfrom
fix/codex-seat-from-loaded

Conversation

@fujibee

@fujibee fujibee commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes #579.

A codex role's seat was recorded only when exactly one of the 40 newest rollouts
had a session_meta cwd matching the project. On a project worked in more than
once that count is never one, so the seat was never written — and every layer of
the monitor path requires it, so the bridge could not arm in that session or any
later one. The reporter measured 17 matching rollouts; a machine here has 44.

Why not the three directions the report offers

Measured against a machine with the same shape (44 rollouts sharing one project
cwd, 6 among the 40 newest).

Newest-matching-rollout fallback. The two functions that pick a rollout do
not sort by the same key: codex-record-session.sh sorts by filename (creation
time), agmsg_resolve_codex_thread by mtime (last write). A resume appends to
the existing rollout rather than creating one, so creation order systematically
misses the thread in use. At measurement time the two disagreed — filename-newest
was last written 2.7 days earlier, mtime-newest 2 minutes earlier.

Seat with what agmsg_resolve_codex_thread resolved. Reconstructed at all 77
historical session starts across two projects: correct up to 2s of hook delay,
first wrong at 3s, shortest observed gap to another session's write 2.6s. The
decisive part is that its retry does not cover this — it returns the first match
in mtime order and only sleeps when there are zero matches, and a project with
history always has matches. Today a wrong resolution fails the seat comparison
and delivery stops; writing it into the seat turns the same value into a resume
that wakes the wrong conversation. Harmless becomes harmful.

--thread loaded. Recommended internally, then measured and dropped.
thread/loaded/list returned 3 ids while 2 Codex windows were running — the
app-server keeps a thread loaded after its window closes — and its order is
ascending by thread id, which for UUIDv7 is creation order, so ids[0] is the
oldest loaded thread: on that machine, another running agent's live session.

What this does

Keep the rule — exactly one candidate or record nothing — and change what is
counted. Instead of rollouts matching the cwd, count the app-server's loaded
threads minus the threads a role already sits in. Stale loaded entries are
precisely the seated ones, so the remainder is the session not yet seated. A
unique remainder is recorded; anything else records nothing, leaving the bias
toward a fresh boot over a resume mis-fire unchanged.

The rollout scan stays as the fallback where there is no app-server to ask, and
the bridge rewrites the seat once armed, so the stored value is one the
app-server confirmed rather than one anything inferred.

delivery.sh status also stops reporting "not running" for a role that simply
has no seat, and in the ambiguous case says how many threads are loaded and what
recreates the app-server.

Known limit

The subtraction needs at least one seat to subtract. An app-server already
holding several threads with no seat ever written — the state an upgrade lands
in — stays ambiguous until it is recreated. After one seat exists it is
consistent from then on. Recorded in #579 rather than left implicit.

Verification

--print-loaded-threads was run against a live app-server, and the whole path
was exercised by driving a real Codex TUI through codex-monitor.sh under a pty
(the loaded set is 1 for a fresh session; adding a second role sequentially
leaves exactly one unclaimed id).

Mutation evidence, each breaking only what it should:

mutation fails
drop the subtraction seats-the-unclaimed-thread
remove the app-server path (rollout scan only) seats-the-unclaimed-thread, long-history
drop the type filter in agmsg_role_session_recorded_uuids other-type-not-subtracted
restore the old single status line both status tests

fujibee added 4 commits July 31, 2026 19:32
…lout files

A seat was recorded only when exactly one of the 40 newest rollouts had a
session_meta cwd matching the project. On a project worked in more than once
that count is never one, so the seat was never written -- and every layer of the
monitor path requires it, so the bridge could not arm in that session or in any
later one.

Rollout files cannot say which thread is the current session; they carry the
whole history of the cwd. The app-server can, but only as a bare id list, and it
keeps a thread loaded after its window is gone. Subtracting the threads a role
already sits in leaves the session not yet seated. A unique remainder is
recorded; zero or several record nothing, so the bias toward a fresh boot over
waking someone else's conversation is unchanged.

The rollout scan remains the fallback for a session with no app-server to ask,
where it resolves the same single-rollout case it always did.

The bridge also rewrites the seat once armed: whatever seeded it was an
inference, the thread the app-server resumed is not.
…er seated

`delivery.sh status` printed "not running" whenever the pidfile was missing,
which reads as a process that died. The far more common cause is that the role
has no seat, and no layer says so: the SessionStart hook exits 0, the launcher
re-execs, the dispatcher's output goes to /dev/null. The only visible symptom
was this one misleading line.

A recorded seat now yields "not running" with the seat printed, so the wording
means what it says. Without one, the loaded-thread count decides the message,
because it is what determines whether the next session can seed a seat at all:
none loaded, exactly one, or several with no way to tell them apart -- the last
carries the app-server recreation that unblocks it, and the warning that it
stops the project's other bridges too.
…seat a seated role

Three ways the previous commit could still seat a wrong thread.

An ambiguous probe fell through to the rollout scan. "The app-server answered and
this session cannot be told apart" is the strongest fail-closed statement
available, and a rollout that happens to be unique was overruling it -- which
reintroduces the original defect as a fallback. Probe availability is now tracked
apart from the candidate count, and only an app-server that could not be reached
at all may fall through.

The inference did not check whether the target role was already seated. The
subtraction removes every recorded thread including that role's own, so a seated
role running it again would find its own gone and adopt whatever was left,
replacing a correct seat with a stranger's thread. Inference now refuses a seated
role; CODEX_THREAD_ID, which is not an inference, still re-seats one -- that is
how the bridge writes back after arming.

The status diagnostic probed once per seatless identity with the bridge's own
timeouts, so a stale port held by something that accepts and never answers could
stall `status` for tens of seconds per identity. It now probes at most once per
run, with a timeout sized for a status line.

The seat write-back also moves after armWatch, where the comment already claimed
it was.
…every path

The guard sat inside the app-server branch, so it did not cover the rollout scan
-- which is precisely the path left in place for sessions with no app-server. A
seated role in such a session could still have its seat replaced by whatever
rollout happened to be unique in the cwd.

The rule is not a property of the subtraction. Any inference can pick the wrong
thread; the subtraction does it by removing this role's own thread and taking
what is left, the rollout scan by taking whatever the cwd happens to offer. What
makes CODEX_THREAD_ID different is not that it is more accurate, it is that it is
not an inference at all -- the session exports it, or the bridge passes the
thread the app-server confirmed.

So the guard moves to where the inference begins, ahead of both paths, and the
comment says which side of that line each source is on.
@fujibee
fujibee merged commit cfa1a03 into main Aug 1, 2026
14 checks passed
@fujibee
fujibee deleted the fix/codex-seat-from-loaded branch August 1, 2026 06:33
fujibee added a commit that referenced this pull request Aug 1, 2026
…ames

A numeric prefix of a real port is itself a valid port, so no reader-side check
can tell a half-written file from a good one. 5296 read while 52962 is being
written names a DIFFERENT app-server — on this machine, plausibly another
project's, which would answer and let its thread be seated here. That is the
exact outcome #583 exists to prevent, so the partial state has to be
unobservable rather than filtered: the writer publishes through the existing
atomic helper.

The reader additionally requires a port a TCP stack could have handed out.
Digits alone never established that, and the bound limits what anything other
than the writer could leave behind.

Also: the unseated count now returns nothing when it cannot be worked out
instead of 0, which had asserted "another role holds it" on a failed mktemp.

Reported by review of #591.
fujibee added a commit that referenced this pull request Aug 1, 2026
…le is absent (#591)

* fix(codex): resolve the app-server from the port file when the variable is absent

#583 seats a role from the app-server's loaded threads, but the probe was gated
on AGMSG_CODEX_BRIDGE_APP_SERVER. Under codex 0.146 --remote an agent's
shell_command runs inside the app-server process, and codex-monitor.sh cannot
export into that context: the URL does not exist until the banner has been
parsed, which is after the server is running. So the fix never ran on the path
it was written for.

The port file carries the same string. codex-monitor.sh writes the port, builds
ws://127.0.0.1:$PORT from it, and hands that to both the variable and
--remote — reading the file reconstructs the value byte for byte rather than
adding a second way to reach the server.

"No URL" still means "could not ask", never "asked and got nothing", so the
rollout fallback keeps its fail-closed shape.

Also: delivery.sh status called a missing seat unexpected whenever one thread
was loaded. A thread seats one role, so when that thread is already seated
elsewhere the missing seat is correct. The count is now compared against the
seated set, from the list the probe already fetched.

* fix(codex): publish the port file atomically, and bound the port it names

A numeric prefix of a real port is itself a valid port, so no reader-side check
can tell a half-written file from a good one. 5296 read while 52962 is being
written names a DIFFERENT app-server — on this machine, plausibly another
project's, which would answer and let its thread be seated here. That is the
exact outcome #583 exists to prevent, so the partial state has to be
unobservable rather than filtered: the writer publishes through the existing
atomic helper.

The reader additionally requires a port a TCP stack could have handed out.
Digits alone never established that, and the bound limits what anything other
than the writer could leave behind.

Also: the unseated count now returns nothing when it cannot be worked out
instead of 0, which had asserted "another role holds it" on a failed mktemp.

Reported by review of #591.
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.

codex monitor never arms when a project has more than one past session: the role-session seat it requires can no longer be written

1 participant