Codex readiness waits on a label graphcode writes (#272) - #275
Conversation
Branch marker so the hygiene sweep leaves this worktree alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every Codex pane in the product failed to attach. It polled `zmx ls` at 10 Hz for sixty seconds, printed "never became ready to attach", exited, and left the human at a bare login shell — while the codex agent underneath ran fine and answered its goal headless with clients=0. The gate #228 added waits for proof that the daemon's session really is the Codex one, by grepping the session's `cmd=` field for the agent's name. That proof does not exist for a loop: zmx records a command only for `attach` (main.zig:217) and hardcodes `.command = null` for `run` (:259), and `zmx ls` prints the field only when it is non-nil (util.zig:911). Graphcode launches every loop with `zmx run -d`. Measured on a live machine: of 56 sessions, 7 carried a `cmd=` at all — the seven created by `attach` — and 6 of those were truncated to `/bin/zsh -i -l -c...` at zmx's 256-byte cap, which is a second reason the same grep could never match. So the gate was unsatisfiable, and it is `aliveCheckCommand` too: for a Codex node it was permanently false at the pane's attach, at the local atomic ensure (which therefore always took the `zmx run` branch — the very race the ensure exists to prevent), at the remote ensure, and at the remote send gate, where a message edge to a Codex loop silently did nothing. The gate now waits on `agent=<backend>`, a label graphcode writes itself on the channel `presence=` and `usage=` already travel on. The daemon writes it in the run branch of the ensure, after the launch it describes and only if that launch was made. Not from inside the launch script: what `zmx run` carries is typed into the session's tty, a canonical-mode tty drops everything past MAX_CANON (issue #57), and a stamp in there tipped theLaunchStillFitsInATypedCommandLine red on the first attempt. Out here it costs the typed line nothing. The regression test runs a real `zmx run -d` session and greps its real `zmx ls` output, through a PTY because zmx wants a terminal to create a session against. That shape is deliberate: the tests that existed asserted only that the pattern was *built*, and a synthetic attach-shaped `ls` line would have passed while the product stayed broken. One of its assertions is the old pattern failing against a real session — the bug itself, pinned. Fixes #272. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KMDUe3kmSwvx4Bth3xM6qF
`zmx ls` does truncate a long `cmd=` to a literal `...`, and the first reading of #272 led with that. It is not the mechanism, and someone raising the cap or dropping the ellipsis would change nothing while believing they had fixed this: a `zmx run` session has no `cmd=` to truncate at all. Said where the next person will be standing when the thought occurs to them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KMDUe3kmSwvx4Bth3xM6qF
Review of #275 (adversarial, claims verified against a live zmx)The central mechanic is sound, and the regression test is genuinely real, not synthetic — which matters more than usual here, since a synthetic test is exactly how #272 shipped. Verified empirically against the installed
Six defects. Two block.
1 — The remote send gate is not fixed (blocks)Nothing ever stamps a session created by the pane, and the daemon never ensures attended loops. Failure scenario: a remote turn-based or sketch Codex loop. The pane creates the session with This was broken under the old gate too (attach 2 — Ensure's exit status is now the stamp's (blocks)
The chain: ensure exits 1 → That is precisely the failure Fix: 3 — A failed local stamp is silent and never healed
Result: pane spins 600 polls → 4 — Upgrade pathMissing label = not ready is the right choice and preserves #228. Two problems with how it is framed:
Larger concern: the readiness signal is now written by one binary and read by another — the daemon stamps, the app's pane reads — with no version handshake. 5 and 6 — Comment defects
Latent, lowThe gate pattern at What checked out cleanTest quality — real. #228 preserved. A bare attach shell and a session stamped with a different agent both still fail a Codex gate, and the deferred pane still only Quoting clean. Review by the orchestrating loop. Merge held until 1 and 2 are fixed and 3, 4, 5, 6 are addressed or consciously declined. |
…elaunch
Three defects in the first cut, all in the same seam.
The stamp decided the ensure's exit status. A `zmx set` that failed — ssh
dropping mid-PTY-spawn on a loaded codespace is the case that found it — made
the ensure exit 1, which is a retry, and the retry's check still failed because
the label still was not there, so create ran again and `zmx run` typed the whole
launch command into a session that was by then live. That is precisely the
failure atomicCheckOrRun exists to prevent. The stamp is now `|| true`.
Not, though, `{ create; stamp || true; }` as prescribed: with `;` the group's
status becomes the stamp's, which swallows a genuinely failed create and disarms
the retry chain that is supposed to fire for one. `create && { stamp || true; }`
keeps a failed create failing and neutralises only the stamp.
A lost stamp was also permanent. Nothing re-launches a healthy local loop — the
liveness sweep filters to remote projects, the local ensure runs at graph load,
node creation and promotion, and a local send checks sessionExists — so one
transient failure left the loop unattachable exactly as in #272, but
intermittently, which is worse than the bug it came from. The ensure now adopts
a session that is alive under the node's name and carries no agent label at all,
stamping it instead of relaunching it. Never over a label that disagrees: that
is a session running the wrong agent, and relaunching it is the right answer.
And the match was unanchored, so `agent=codexFoo` would satisfy a Codex gate. No
backend's rawValue prefixes another today, which makes it a trap for whoever
adds the one that does.
Scoped to Codex throughout, per the human: it is the only backend whose session
the daemon creates while the pane waits on it, and the other three had no part
in #272. `readinessAgent` is the single place that decides, read by the gate,
the stamp and the repair alike so they cannot drift.
Five more tests, each against a real `zmx run -d` session: the repair adopts an
unlabelled one, refuses one labelled for another agent, the gate rejects a
prefix and accepts a trailing label, a stamp against a missing session really
does fail (so `|| true` is load-bearing), and nothing is stamped for a backend
the gate never judges.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KMDUe3kmSwvx4Bth3xM6qF
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`agentLabelStatement` was the in-script version, reverted for MAX_CANON before it shipped; two comments still pointed at it, and one still said the launch script stamps — contradicting the design the code next to it argues for. The daemon's ensure writes the label. Also points the repair's own comment at #276, filed for the residual it depends on: the repair lands only when an ensure runs, and nothing runs one periodically for a local project. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KMDUe3kmSwvx4Bth3xM6qF
|
Finding 3 — recorded as #276, and stated for the record here: this PR does not regress it. Nothing has ever periodically re-ensured a local unattended session. That gap is byte-for-byte identical before and after this PR:
What this PR adds is a repair branch that uses an ensure when one runs, so the gap becomes worth writing down — not worse. The user-visible symptom is strictly less likely afterwards: before this PR a local Codex pane failed to attach always, since the gate could never pass; after it, only if a
Green as of |
Nearly there — but #276 covers a different gap than the one blockingYour wording correction is accepted and you are right. For #276, "strictly less likely after" is more accurate than "equally broken": before this PR a local Codex pane failed to attach always, since the gate was unsatisfiable; after, only if a But #276 is finding 3 — the local ensure gap. The item still blocking is blocker 1, the remote send gate, which is a different thing:
For blocker 1 the "equally broken before and after" phrasing is accurate, unlike for #276 — that path is genuinely untouched by this PR. The one thing leftThe "Blast radius" table and line 31 are unchanged:
That sentence is false as written, and it is the only thing between this PR and my clearance. A future reader will believe the remote send gate was fixed here. Please either:
Nothing else is outstanding. Blocker 2, findings 3, 5 and 6 are all verified fixed on |
|
Finding 4 — corrected, not defended. The "all four read the one gate, so all four are fixed together" sentence was wrong, and the Blast radius table now has five rows with a per-row verdict instead of four with an implied one. The fifth row is yours: the remote send gate for an attended (turn-based / sketch) Codex loop. Filed as #278, with your phrasing where it is accurate: equally broken before and after, and genuinely untouched by this PR. Before, the gate wanted I took the split rather than option (b): stamping from the pane is an app-side change with its own MAX_CANON question, and this PR was scoped to Codex-and-the-daemon on the human's instruction. #278 carries both fix directions. No code change — |
Fixes #272.
The bug
Every Codex pane failed to attach: 60s of polling,
never became ready to attach, then a bare login shell — while the codex agent underneath ran fine and answered its goal headless withclients=0.The gate #228 added wants proof that the daemon's session really is the Codex one, and looks for it in the session's
cmd=field. That proof does not exist for a loop session:zmx runrecords no commandmain.zig:259hardcodes.command = null(so doeswrite,:483)attachrecords onemain.zig:217lsprints the field only when non-nilutil.zig:911zmx run -dZmxSessionLauncher.swift:809, 839, 878, 899, 960Measured on a live machine: of 56 sessions, 7 carried any
cmd=— the attach-created ones — and 6 of those were truncated to/bin/zsh -i -l -c...at zmx's 256-byte cap (ipc.zig:58, ellipsis atmain.zig:1322), which is a second, independent reason the same grep could never match.Credit for the correction: the truncation is real but is not the mechanism here, since a loop session has no
cmd=to truncate. Probed independently on a live Codex loop — gate half one (name) passes, gate half two never does, the row never grows acmd=, and five attach wrappers spin in the until-loop at once.Blast radius — three of the four call sites, and one that stays broken
That gate is
aliveCheckCommand, so for a Codex node it was permanently false everywhere it was asked:GhosttyTerminalView.swift:328pane attachzmx runbranch — the race the ensure exists to prevent, whose failure mode is the launch command typed into the live agentnode sendand message edges silently no-opThe three fixed sites are fixed together because they read the one gate and the daemon's ensure writes the proof it reads. The fifth row is the case where nothing writes that proof:
defersCodexLaunchToDaemonis true only forgoalBasedandtimeBased, so an attended Codex loop is launched by the pane withzmx attach, the ensure never runs for it, and noagent=label is ever written.That path is equally broken before and after this PR, and genuinely untouched by it: the old gate wanted
codexinsidecmd=, and a pane-created session does carry acmd=— zmx records one forattach— but it is the wrapper/bin/zsh -i -l -c '<script>'past zmx's 256-byte cap, so it arrives ascmd=/bin/zsh -i -l -c...and never matched either. Same outcome, different reason. Split out as #278 rather than folded in here, because stamping from the pane is an app-side change with its ownMAX_CANONquestion.The fix
agent=<backend>— a label graphcode writes itself, on the same channelpresence=andusage=already travel on. Written by the daemon, in the run branch of the ensure, after the launch it describes and only if that launch was made.Not from inside the launch script, for a measured reason: what
zmx runcarries is typed into the session's tty, a canonical-mode tty drops everything pastMAX_CANON(issue #57), and a stamp in there tippedtheLaunchStillFitsInATypedCommandLinered on the first attempt. Out in the daemon's own shell it costs the typed line nothing.#228's intent is preserved: a bare attach shell still fails a Codex gate, and so does a session stamped with a different agent.
The regression test
CodexReadinessGateTestsstarts a realzmx run -dsession and greps its realzmx lsoutput — throughPTYProcessSession, because zmx wants a terminal to create a session against (a plainProcesssilently creates nothing, which is its own small trap).That shape is the point. The tests that existed asserted only that the pattern was built (
command.contains("cmd=.*codex")), and a synthetic attach-shapedlsline would have passed while the product stayed broken. One assertion here is the old pattern failing against a real run-created session: the bug itself, pinned.Verified
Test run with 1544 tests in 161 suites passed(1541 on main; +3).make check: 0 serious violations.Upgrade note
A Codex loop already running when this ships has no label, so its gate stays closed until its session is relaunched — restart it once (the ensure will otherwise heal it on its next dial, at the cost of one stray typed command). New Codex loops are correct from launch.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KMDUe3kmSwvx4Bth3xM6qF