Skip to content

feat(health): a Codex observer feeds the core's runtime observation [HealthStatus] - #5140

Merged
john-the-dev merged 8 commits into
sonichi:mainfrom
marklysze:feat/healthstatus-codex-observer
Oct 8, 2026
Merged

john-the-dev merged 8 commits into
sonichi:mainfrom
marklysze:feat/healthstatus-codex-observer

Conversation

@marklysze

@marklysze marklysze commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #5086 (merged), which added the runtime observation record; this gives the Codex core an observer. Single commit, 4c570103, on current main.

What changed, and why

#5086 lets a seat's observer publish a runtime observation record, and HealthStatus reads it. Only Claude seats had an observer. docs/health-snapshot.md says a Codex core has no record, so its health comes only from the screen.

This adds src/agent/codex/cli/codex-observer.mjs, a small zero-dependency Node process that writes the Codex core's record. It changes nothing in Codex.

  • Finding the core's file. It asks tmux for the core pane's pid, walks that process tree (ps), and lists the rollout files those processes hold open (lsof -Fn). The core's file is the one whose session_meta names no parent thread. It needs exactly one such file, and every open rollout's metadata must be readable (an unreadable one could be a second top-level thread). Otherwise it keeps its target for one discovery, then writes no record, which /health reads as "no opinion". Only the open files' parent/agent classification is cached, and an unreadable session_meta is retried.
  • What it reads. It follows that rollout file:
    • task_started reads requesting · moving;
    • a response_item tool call reads tool, and its output reads requesting again;
    • a model output (an assistant message, reasoning or a tool call) stamps last_success_at and healthy. token_count is not success evidence, because Codex writes one from the session's running totals before a usage-limit failure;
    • task_complete reads idle · idle;
    • task_complete with an error reads failed · idle · abnormal, with codex_error_info mapped to needs-login (unauthorized), quota-limit (usage_limit_exceeded, session_budget_exceeded) or api-error (anything else). The failure stands until a later model output; a new turn alone does not clear it;
    • turn_aborted reads idle.
  • What it can't see. It doesn't see a stall: a turn that writes nothing still reads moving, so stalls stay with the screen-derived sources. Approval waits aren't in the rollout either.
  • Reading the file. Each read uses one fd and one size snapshot, and returns where the bytes read stop, so a write that lands mid-read is followed exactly once.
  • Writing the record. Through python3 src/runtime_observation.py write, using the interpreter scripts/python-binary.sh resolves, the same as claude-observer. No interpreter means no write. The record is seat core, session = the core's tmux session, observer codex-observer. Writes are debounced, there's a 15 s heartbeat inside the 45 s lease, and write errors are swallowed.
  • Lifecycle.
    • src/agent/codex/cli/start-cli.sh adds ensure_codex_observer next to each ensure_task_notifier. It starts a <session>-observer tmux session and stamps it with SUTANDO_OBSERVER_VERSION, a checksum of the script, so a changed script replaces it, the same pattern as the notifier.
    • It is killed wherever the watcher is killed: --restart, replacing a non-Codex session, src/agent/start-cli.sh's non-Codex runtime switch, and src/agent/stop-core.sh.
    • It also exits by itself once the core session is gone on two discoveries in a row.
    • src/agent/start-cli.sh applies the shared in-session restart guard before it reaps the watcher and observer. A refused Codex → Claude switch (or --restart / --force-restart from inside the core) no longer leaves the live core without task intake, and the refusal is still written to logs/restart-attempts.log. The audit writer now lives in restart-guard.sh, shared with the Claude launcher.
    • No Node means no observer.

Why not the app-server daemon (the first design). Two things showed up live (codex-cli 0.160.0, daemon 0.160.1).

  • The daemon never sees the core. Launched with --search, which CODEX_ARGS always passes, the TUI does not register on the shared daemon. I tested one flag at a time and counted new threads in thread/loaded/list:

    Flags New threads
    none 1
    --sandbox danger-full-access 1
    --ask-for-approval never 1
    --add-dir $HOME 1
    -m <model> 1
    --search 0
    core flags without --search 1
    full core flags 0

    A -c key=value override also skips the daemon.

  • The daemon refuses to subscribe to a thread before its first turn (-32600 no rollout found).

The rollout file is written in both modes, so it is the one source that sees the core.

Where to look first

  • src/agent/codex/cli/codex-observer.mjs: lineChange, pickRollout, and Observer.discover / select / feed.
  • src/agent/codex/cli/start-cli.sh: ensure_codex_observer and the added kills.
  • docs/health-snapshot.md, "Runtime observation": the new Codex core paragraph.

Before / after

1. The launcher starts, keeps, replaces and kills the observer. The new launcher tests at the parent (6e8bdf32) and at HEAD:

== parent 6e8bdf32 (the PR's tests, the parent's launcher)
FAIL: test_launch_starts_the_codex_observer_beside_the_core
AssertionError: '-e SUTANDO_OBSERVER_VERSION=none' not found in ''
ERROR: test_current_observer_is_left_running_and_a_stale_one_replaced   ValueError: substring not found
ERROR: test_restart_kills_the_observer_before_launch                     ValueError: substring not found
FAILED (failures=1, errors=2)
== HEAD
Ran 3 tests ... OK

2. Observer against a live Codex TUI with the core's exact flags (-C <dir> --add-dir $HOME --sandbox danger-full-access --ask-for-approval never --search --no-alt-screen). Prompt: Run the shell command: sleep 6; cat hello.txt. The record, sampled every 1.5 s (heartbeat age, phase, motion, condition, seq, age of last success):

1.0 tool moving unknown seq 2 succ_age None
1.1 requesting moving healthy seq 4 succ_age 1.6
0.6 idle idle healthy seq 6 succ_age 1.3
9.8 idle idle healthy seq 6 succ_age 10.5

/health?view=full (src/health_snapshot.py) for that core: observation source {'phase': 'idle', 'observer': 'codex-observer', ..., 'last_success_age_s': 23.3}, opinion idle / healthy. After the core's tmux session was killed, the observer exited within 10 s.

3. Live path, real launcher. I ran bash src/agent/start-cli.sh --runtime codex --no-schedule-reconcile with a private socket, session obs-e2e and a scratch workspace, so the live core wasn't touched.

  • Launch: sessions obs-e2e, obs-e2e-watcher, obs-e2e-observer. After one prompt, the record is {"phase": "idle", "motion": "idle", "condition": "healthy", "session": "obs-e2e", "seq": 3, ...}. /health shows session: obs-e2e and the observation opinion idle / healthy.
  • --restart: a new observer (a different observer_id, started after the restart) writes idle healthy with succ_age 11.8 after the next prompt.
  • src/agent/stop-core.sh: no server running, and no codex-observer.mjs process is left.
  • In that scratch run the overall verdict read hung. That came from the supervisor (core alive but stalled (status stale, no recognized prompt)), because the scratch workspace has no core-status.json. It's unrelated to this change, and hung is never superseded.

Tests

At head e447969a:

Command Result
node --test tests/codex-observer.test.mjs 28 pass (new), including the observer started as a process through a symlinked directory. Also passes from a checkout path with spaces
python3 tests/codex-core-launcher.test.py 62 OK; -k observer also passes with an inherited SUTANDO_TMUX_SOCKET/SESSION
bash tests/start-cli-dispatcher-restart-guard.test.sh 5 pass (new): synthesized, explicit and force restart refused and audited with no kills; unmarked and override callers still reap and delegate
bash tests/start-cli-restart-inside-core-guard.test.sh, tests/start-cli-restart-verify.test.py, tests/graceful-restart.test.sh pass
python3 tests/health-snapshot.test.py / runtime-observation.test.py / src-map.test.py OK / OK / 27 passed
eslint, shellcheck -S warning, review-checks.sh clean

Mutants that the tests catch:

  • token_count counted as success; a user message counted as success;
  • a completion error ignored; a new turn clearing a failure;
  • seq reset on reselect;
  • partial lsof output dropped;
  • a target dropped on one miss; the observer stopping on one missed probe;
  • an unreadable rollout ignored; ambiguity never dropping the target;
  • a failed metadata read cached; closed rollouts never pruned; full metadata cached;
  • the old two-snapshot tail read (a write mid-read loses a split quota completion);
  • the dispatcher refusal without its audit line;
  • keeping subagent rollouts; the first of several top-level rollouts; writing with no interpreter.

Live round trip at 75fdcf97 (the post-merge head). This is the real launcher, run through /tmp/..., the /private/tmp symlink. Private socket, scratch workspace.

head 75fdcf97ac34bfe83eac5102559c7970c4edc3ba   pwd=/tmp/…/codexobs   pwd -P=/private/tmp/…/codexobs
== launch                          (bash src/agent/start-cli.sh --runtime codex --no-schedule-reconcile)
launch rc=0        sessions: obs-rt3 obs-rt3-observer obs-rt3-watcher
observer:          node /tmp/…/codexobs/src/agent/codex/cli/codex-observer.mjs --engine /tmp/…/codexobs --tmux-socket …/obs-rt3.sock …
task-rt3-1791484314 RESULT after 60s: OK
/health observation: {'phase': 'idle', 'observer': 'codex-observer', 'seq': 10, 'heartbeat_age_s': 0.1, 'last_success_age_s': 1.3}  opinion idle/healthy
record: observer_id 54ea8598144f1d81 session obs-rt3 seq 10 idle healthy
== --restart
restart rc=0       sessions: obs-rt3 obs-rt3-observer obs-rt3-watcher
task-rt3-1791484385 RESULT after 62s: OK
/health observation: {'phase': 'idle', 'observer': 'codex-observer', 'seq': 13, 'heartbeat_age_s': 0.1, 'last_success_age_s': 1.2}  opinion idle/healthy
record: observer_id b71ec783865f50e8 (new) session obs-rt3 seq 13 idle healthy
== in-session --runtime claude with SUTANDO_CORE_SESSION=1
rc=1               sessions: obs-rt3 obs-rt3-observer obs-rt3-watcher (unchanged)
restart-attempts.log: 2026-10-08T18:34:10Z [restart] refused: inherited SUTANDO_CORE_SESSION=1 (in-session self-kill), no override set
== stop-core
stop-core: obs-rt3 stopped
no server running on …/obs-rt3.sock; codex-observer node processes left: 0

Not verified / not in this PR

  • Failures not in the rollout. Only a failure that ends a turn (task_complete.error) is seen. A retry that later succeeds, or an error that never completes a turn, still reaches /health only through the screen (cli_wedge, the supervisor).
  • Approval and input waits aren't seen. The core runs with --ask-for-approval never, so none are expected.
  • Codex pool workers get no observer: core seat only.
  • lsof and ps run every 10 s; I didn't measure their cost on a busy machine. Reading the file runs every 1 s and is a stat plus the new bytes.
  • The rollout format is Codex-internal and could change. Unknown lines are ignored, and a missing or ambiguous file means no record, so a format change degrades to today's behaviour.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of head ba126dd7 (COMMENT from the shared qingyun-wu login; the decisive verdict on this login is the owner's).

Verdict: no code blockers. I would approve once the branch is rebased onto main and CI has run green at the new head. Not merge-ready as it stands.

Blockers (state, not code)

  1. The PR is CONFLICTING and no CI has run at the head. gh api .../commits/ba126dd7/check-runs returns total_count: 0; only license/cla is reported. The parent #5086 merged as aa232ec3, which is not the 6e8bdf32 this branch carries (git merge-base --is-ancestor aa232ec3 ba126dd7 fails), and #5139 then landed on top. git merge-tree origin/main ba126dd7 conflicts in src/health_snapshot.py, docs/health-snapshot.md and tests/health-snapshot.test.py, all parent-layer files. The child commit itself applies cleanly: git cherry-pick ba126dd7 onto origin/main (b638ca38) had no conflicts. Rebasing only that commit should be enough.

What I verified (on the child commit cherry-picked onto origin/main b638ca38)

  • node --test tests/codex-observer.test.mjs: 14 pass.
  • tests/codex-core-launcher.test.py -k observer: 3 OK. With main's start-cli.sh and the PR's tests: FAILED (failures=1, errors=2), the same three names as the body's "before". I confirmed that before/after evidence.
  • Full codex-core-launcher.test.py: 61 run, 2 fail. test_unassigned_notifier_task_bypasses_slow_history_scan is the wall-clock flake the body names. test_launches_codex_and_managed_task_notifier ("managed core-input monitor did not start") also fails with main's own start-cli.sh on this host, so it is environmental and not caused by this PR. Note: these tests must run in a clean env (env -i PATH HOME TMPDIR). An inherited pool SUTANDO_INSTANCE_ID/SUTANDO_TMUX_SESSION makes them fail spuriously.
  • health-snapshot.test.py 92 OK, runtime-observation.test.py 13 OK, src-map.test.py 27 passed.
  • Mutants I ran myself, each caught: task_complete always healthy (1 fail), usage-less token_count as success (1 fail), dropping the parent_thread_id filter (2 fail), taking the first of several top-level rollouts (2 fail).
  • Record contract: a buildRecord record piped to main's runtime_observation.py write, then load(ws, "core"), returns {phase: idle, motion: idle, condition: healthy, observer: codex-observer} with no rejection reason. The observer's output is accepted by the writer that is now on main.
  • Hardcoded paths: scripts/review-checks.sh --diff on the child diff passes. The positive control (a /Users/someone/x line) fails as expected. The only /tmp/sutando-tmux.sock literal is pre-existing.
  • Pool workers can't reach ensure_codex_observer. codex/cli/start-cli.sh:19 refuses any SUTANDO_INSTANCE_ID, and workers launch through launch-codex-worker-session.sh. So "core seat only" holds.
  • Tmux session enumerators (core_heartbeat.py:220, health-check.py:12514, session-launch.sh:41) gate on a core process or only clear an env pin. A <session>-observer session running node is treated the same way as the existing -watcher session.

Interaction with main since the branch point (lesson 2)

#5139 (now on main) drops a cli_wedge retry-loop claim whenever the observation phase is in RETRY_DISPROVING_PHASES = {idle, waiting, failed} (health_snapshot.py:55,292). With this PR, a Codex core's idle now feeds that rule. I traced the cases. A mid-request retry stays in requesting because task_started was seen. A tail replay that starts mid-turn stays unknown, since tool lines are ignored unless motion is moving. So I found no path where the observer says idle during an in-flight request. Please confirm this in the rebased PR body, because it's a new consumer that the original design didn't have.

Live path

This touches startup/restart/stop. Body section 3 is a real launcher round trip (start-cli.sh --runtime codex on a private socket: launch, --restart with a new observer_id writing after the next prompt, stop-core.sh leaving no observer). That is the right kind of witness. I did not re-run it, since it needs a live Codex TUI. It was taken before the rebase, but the child layer doesn't change in a rebase, so I don't think it needs re-running.

Nits (non-blocking)

  • discover() caches a failed readMeta as {path} forever (codex-observer.mjs, this.metas.set(p, {...(await readMeta(p)), path: p})), and pickRollout counts that entry as top-level. If a subagent rollout is caught while its multi-hundred-KB session_meta line is still partly written, the core reports no record for as long as that file stays open. I reproduced it with stub deps: pass 1 target null, then the meta becomes readable with parent_thread_id, and pass 2 is still target null with readMeta called only twice. This fails safe ("no opinion"), but the cure is cheap: don't cache a null meta. The metas map also never evicts closed paths, which is minor for a long-lived core.
  • The header comment of codex-observer.mjs is 4 lines including the usage line. CLAUDE.md caps comments at 2. prose-cap only scans .py, so CI won't flag it.
  • The 15 s heartbeat keeps a requesting · moving record fresh indefinitely during a stalled request. That's harmless, because condition resets to unknown at task_started, abnormal still wins, and supersede only acts on a newer success. It could be worth one sentence in the blind-spots paragraph.

Not verified

  • I didn't run Codex live, so the rollout event names (task_started, token_count.info, task_complete, turn_aborted) are taken from the body and the fixtures, not observed.
  • I didn't measure lsof/ps cost (the body also lists this as unmeasured).
  • I didn't check Linux lsof -Fn output parity.

Merge gate

The PR needs two approvals from accounts with write access, plus code-owner review: /src/agent/codex/ → @sonichi / @john-the-dev / @keweichen, and /src/ (for src/agent/start-cli.sh, src/agent/stop-core.sh) → @sonichi / @qingyun-wu. @sonichi covers both paths. There are no reviews on record yet.

The Codex launcher starts codex-observer.mjs in its own <session>-observer
tmux session. It finds the rollout file the core's Codex process holds open
(lsof over the pane's process tree; exactly one top-level file, else no
record), follows it, and writes the core's record through
runtime_observation.py with the interpreter scripts/python-binary.sh
resolves. task_started reads requesting/moving, a tool call tool, each
token_count stamps last_success_at and healthy, task_complete idle. The
rollout records no failures, so the record is never abnormal.

The app-server daemon is not used: a core launched with --search (as the
launcher does) does not register on it. The observer exits once the core
session is gone, is replaced when its script changes, and is killed with
the notifier on restart, runtime switch and stop.

Co-Authored-By: Claude <noreply@anthropic.com>
@marklysze
marklysze force-pushed the feat/healthstatus-codex-observer branch from ba126dd to 4c57010 Compare October 6, 2026 20:38
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 91%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

No lines with coverage information in this diff.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed as a src/agent/codex/ code owner. Read the full diff, not just the description.

Design checks out: codex-observer.mjs is genuinely read-only (lsof/ps/tmux list-panes for discovery, tails+follows the rollout file by byte offset) and writes only to runtime_observation.py's separate HealthStatus record -- never touches the core session or Codex process itself. pickRollout correctly refuses an opinion when more than one top-level rollout is open (no parent_thread_id/agent_path), rather than guessing. State machine (lineChange/apply) is honest about its own limits: a turn that ends without a completed response reads 'unknown', never fabricated as healthy or abnormal, and the rollout's lack of failure records is explicitly called out in both the code comment and docs rather than glossed over.

Lifecycle wiring mirrors ensure_task_notifier's existing pattern closely: version-cksum check via a tmux env var, kill-and-restart on a stale version, and the new observer session is correctly added to every kill path (--restart, foreign-session replacement, stop-core.sh) so it can't be orphaned.

Test coverage is solid: pure-function unit tests cover the real edge cases (half-written lines held back, large-rollout tail-read skipping a cut first line, a failing write never throwing, a gone core session stopping the loop), plus launcher integration tests for all three lifecycle paths (start beside core, leave current version running, replace a stale one, kill on restart).

CI green (30/30). No concerns.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of head 60353b54 (authored commit 4c570103, merged with main f7b69fc8; 0 commits behind).

Verdict: REQUEST_CHANGES. There are two defects in the observer's discovery path. I reproduced both against the real writer (src/runtime_observation.py), and I confirmed them independently of the Codex-host report. Under ordinary core activity, either one can leave HealthStatus holding a stale record, or no record, for as long as the observer has been running. The design itself (rollout file over the app-server daemon, never claiming abnormal, the lifecycle wiring) looks right to me.

Blocker 1: reselecting a rollout resets seq but keeps observer_id, so the writer rejects every later record

  • src/agent/codex/cli/codex-observer.mjs:128: select() does this.state = newState(), which sets seq back to 0. this.ident.observerId (:106) is created once per process and doesn't change.
  • src/runtime_observation.py:104: _superseded rejects new.seq < old.seq from the same observer_id. main() turns that into exit 0 with "stale record ignored", and flush() swallows it.

Reselection happens any time discovery returns a different target: the core opens a new thread, or a single discovery returns nothing (see blocker 2). For the same file, the tail replay (TAIL_BYTES = 256 KB) recomputes a seq bounded by the tail, but the stored seq keeps growing with everything the observer has followed since it started. So on a long-lived core, every record after a reselect loses.

Repro 1, writer only:

  • write(old): idle seq=15, returns True.
  • write(new): same observer_id, requesting seq=1, returns False.
  • load() still returns idle 15.
  • Passing that record to health_snapshot._supersede with a live cli_wedge retry-loop opinion gives opinion: None, superseded_by: observation, because idle is in RETRY_DISPROVING_PHASES (src/health_snapshot.py:55, :293). For up to the 45 s lease, a stale idle record hides a genuine retry loop. After that the record expires and the observer stays silent until its seq catches up.

Repro 2, the Observer class with fake deps and the real writer, switching /A to /B:

sent seq=30 ... | stored seq=30 hb=1791000030
--- core starts a fresh thread: /A closed, /B opened
sent seq=3 hb=1791000038 | stored seq=30 hb=1791000030
sent seq=3 hb=1791000053 | stored seq=30 hb=1791000030
sent seq=3 hb=1791000083 | stored seq=30 hb=1791000030

Fix: keep seq monotonic across select() (carry it over instead of resetting it), or mint a new observer_id/observer_started_at per selection. Add a regression test that goes through runtime_observation.write (and ideally health_snapshot), not only the fake write.

Blocker 2: discovery throws away valid lsof output whenever a listed pid has exited

  • codex-observer.mjs:205: run() resolves null on any non-zero exit.
  • :249-251: openRollouts passes ps's pid list to lsof -a -p. On macOS, lsof exits 1 when any pid in that list is gone, and it still prints the live pids' files:
    rc_with_dead_pid=1   (still printed p<pid>/fcwd…)
    rc_live_only=0
    
  • The core's tool calls spawn short-lived children, so a ps→lsof race is routine during a turn. When it happens, openRollouts returns [], select(null) drops the target, and the next discovery reselects, which triggers blocker 1.

Real repro: real fileDeps and real lsof. A shell holds rollout-…jsonl open while spawning a 50 ms child in a loop:

  • openRollouts returned [] in 21/40 calls while the held rollout stayed open.
  • With the real writer and a 600 KB rollout, 16 of 30 discoveries dropped the target. Each reselect replayed seq=332 against a stored 341/344, and those writes were rejected.

Fix: treat lsof's partial-result exit (status 1 with stdout) as usable. Keep timeout, signal and maxBuffer errors as failures; don't trust truncated output. Add a subprocess regression test with a child that exits between ps and lsof.

Non-blocking

  • :117: a single failed or timed-out tmux list-panes (panePid 0) makes tick() return false, and main() exits for good. Only a launcher run (ensure_codex_observer) brings the observer back. Consider requiring the session to be gone on two consecutive discoveries, or checking has-session explicitly.
  • Scope note: the observer can't detect stalls. A turn with no rollout events for an hour still reads requesting · moving / healthy. That's by design here (screen-derived sources own stalls). The docs paragraph should say it in one line so nobody reads moving as liveness.

What I verified

Scrubbed env (env -i HOME=<scratch> PATH=$PATH), scratch workspace, fresh clone:

head 60353b54 main f7b69fc8
node --test tests/codex-observer.test.mjs 14/14 n/a
tests/codex-core-launcher.test.py 61 OK 58 OK
tests/health-snapshot.test.py 92 OK 92 OK
tests/runtime-observation.test.py 13 OK 13 OK
  • bash scripts/review-checks.sh --diff <(gh pr diff 5140), run from the PR head: PASS.
  • CI at this head: 31 pass, 1 skipping (Windows).
  • The Codex-host verification by Rui's Codex agent matches: node 14/14, codex-core-launcher 61/61, health-snapshot 92, runtime-observation 13. That agent found blockers 1 and 2 first; I reproduced both independently.
  • Neither defect is covered by the new tests: the fake() deps never reselect after a write, and they never return a partial lsof.

I didn't run a live Codex core. The live checks are listed for the Codex-host verifier.

…d probe is not a loss

- select() keeps seq across a reselect. runtime_observation refuses a lower
  seq from the same observer_id, so a reset made every record after a new
  thread (or a dropped target) lose until seq caught up.
- lsof exits 1 when a listed pid has exited but still prints the live pids'
  files; that output now counts. A timeout, signal or overflow still does not.
- A rollout missing from one discovery keeps the target, and the core session
  counts as gone (observer exits) only on the second miss in a row.
- docs: the record does not see a stall; a silent turn still reads moving.

Co-Authored-By: Claude <noreply@anthropic.com>
@marklysze

Copy link
Copy Markdown
Collaborator Author

@john-the-dev thanks. Both blockers were real, and they're fixed in 9eaa92bc.

Blocker 1, seq reset on reselect.

  • select() now carries seq over (this.state = {...newState(), seq: this.state.seq}), so a reselect never lowers it below what the writer holds.
  • New test reselecting a rollout never lowers seq, so the real writer keeps accepting records. It drives Observer through the real runtime_observation.py write into a scratch workspace and switches /a (15 events) to /b. It asserts the stored seq grew and the stored record is /b's requesting · moving.
  • With the old reset, this test fails (fail 1).

Blocker 2, lsof exit 1 with output.

  • run() takes partialOk. Exit 1 with stdout is a result; a timeout, signal or maxBuffer overflow still isn't (err.killed, err.signal, a non-numeric err.code).
  • Discovery goes through the new lsofRollouts(pids).
  • New test lsof output still counts when one listed pid has already exited. It uses the real lsof: a child holds rollout-held.jsonl open, and the pid list includes a process that has already exited.
  • Without partialOk, this test fails (fail 1).

Non-blocking items, also done.

  • A rollout missing from one discovery keeps the target. The observer exits only when the core session is missing on two discoveries in a row. Both have tests, and each fails if its guard is removed.
  • docs/health-snapshot.md now says the record doesn't see a stall: a silent turn still reads moving, and stalls stay with the screen-derived sources.

Live check. I ran a real Codex core with the core's flags and the observer at 9eaa92bc. The prompt ran a 60-iteration loop that spawns a child process each time (for i in $(seq 1 60); do /bin/echo $i >/dev/null; sleep 0.2; done).

4.0  seq 1 requesting moving unknown
5.0  seq 2 tool moving unknown
17.1 seq 4 requesting moving healthy
19.1 seq 6 idle idle healthy
monotonic True   max heartbeat age 15.0

Local results. node --test tests/codex-observer.test.mjs: 17/17. codex-core-launcher -k observer: OK. src-map: 27 passed. eslint clean. review-checks: clean.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve at 9eaa92b. Both blockers from my earlier review are fixed, which supersedes my CHANGES_REQUESTED at 60353b5.

  • Seq after reselect: select() now carries seq forward. Switching from rollout A to B writes seq 33 against a stored 30 and is accepted (it was 3 against 30 and rejected before). Switching to a mid-turn rollout now replaces the stale idle seq 15 with requesting seq 16, and the retry-loop opinion is no longer suppressed.
  • Partial lsof: with real lsof against a holder that spawns children, openRollouts dropped the target in 0 of 40 calls (it was 21 of 40). End to end with the real writer, 0 of 30 discoveries were lost and seq never lagged. A fake lsof confirmed the guard: exit 1 with output is kept; exit 1 with no output, exit 2, a timeout, SIGKILL and more than 4 MB of output are all rejected.
  • Regressions: the new tests fail 4 of 17 against the old behaviour and pass 17 of 17 here. Reverting each fix alone fails exactly its matching test.
  • Suites, run with a scrubbed env: codex-observer 17, codex-core-launcher 61, health-snapshot 92 and runtime-observation 13. review-checks PASS.
  • Codex-core verification (Rui's Codex agent, at this head): it reproduced both fixes independently with the real Observer, writer and HealthStatus reducer. On reselection, 60353b5 gave seq 15 -> 1 (stored idle, retry-loop lost) and this head gives 15 -> 16 (stored requesting, retry-loop kept). With real lsof and an exited child, openRollouts returns count 0 at 60353b5 and 1 here. It ran the same four suites green on a Codex host.

Not merge-ready on CI yet. Python shard 2 fails at external-core-helpers.test.py:238, where the global heartbeat/input-watch logs changed. That is the flake #5181 fixes, and this diff doesn't touch it. watch-tasks-stream-interrupted-task-retry also failed in that run. Both pass locally at this head. CI needs a re-run, or #5181 to land, before merge.

Non-blocking: no test covers the timeout/signal guard on the lsof partial path. Removing !err.killed && !err.signal still passes 17 of 17, so it's checked only by the fake-lsof run above.

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested on 9eaa92bc52aca0d13a17194aceb7b42f3d8c9b7a. I verified three merge-blocking behaviors.

1. A quota-failed turn is recorded as a fresh success

codex-observer.mjs:33-40 treats every token_count with truthy info as success, then ignores task_complete.error. info is cumulative session usage, not per-turn success evidence. In Codex's official source, a usage-limit failure updates rate limits and emits TokenCount before returning the error (handler, emission from existing session state). TokenCount and TurnComplete are durable while the separate Error event is not (rollout policy); the durable failure signal is TurnComplete.error (construction).

I replayed prior successful turn -> task_started -> quota TokenCount carrying prior info -> task_complete{error} through lineChange/apply. HEAD ended with:

{"phase":"idle","motion":"idle","condition":"healthy","lastSuccessAt":5,"turnSucceeded":true}

That false lastSuccessAt then satisfies health_snapshot._supersede and can erase the newer quota-limit diagnosis. Please derive success from durable completion evidence, honor an error-bearing completion, and add the prior-success-then-429 regression through the health reduction.

2. A refused in-core runtime switch kills the live core's helpers first

The generic dispatcher converts Codex -> Claude into --restart, then kills the watcher and new observer at src/agent/start-cli.sh:110-120. The Claude launcher does not apply the inherited-core restart guard until claude/cli/start-cli.sh:250-255.

In an isolated fake-tmux harness with a live Codex runtime, requested Claude runtime, and inherited SUTANDO_CORE_SESSION=1, the target correctly refused with exit 42 and did not kill the core, but the dispatcher log already contained:

kill-session -t =sutando-core-watcher
kill-session -t =sutando-core-observer

The caller gets a refusal while the still-running core loses task intake and health observation. Apply the shared restart guard in the generic policy owner after deciding the cross-runtime restart but before either helper mutation. Please pin the refusal with zero watcher/observer/core kills, plus unmarked and explicit-override controls.

3. The metadata cache is permanent, unbounded, and caches failed reads as fact

discover() stores every full session_meta payload forever, although only the parent/agent classification is needed. A deterministic 100-rollout harness with the same 600 KB metadata size already used by this PR's test retained:

{"cachedEntries":100,"openRollouts":1,"retainedBytes":60007570}

It also stores a transient parse miss as {path} and never retries it. With one main rollout plus a subagent whose first metadata read returned null, repairing the subagent metadata on the next discovery still produced targetAfterRepair:null, readMetaCalls:2, and cached {path:"/sub"}. For an always-on observer, prune closed paths, retain only the classification fields, and retry absent/invalid metadata; add both regressions.

Required test and repository-rule fixes

  • tests/codex-observer.test.mjs:170 invokes bare python3 despite receiving a resolved py; that can select the macOS developer-tools stub. Resolve it through the repository helper and execute that path (REVIEW criterion 7).
  • The new launcher test inherits SUTANDO_TMUX_SOCKET through dict(os.environ) but asserts the default socket. Under the actual core environment, python3 tests/codex-core-launcher.test.py -k observer -v ran 3 tests and failed the new test at line 660 because it observed the configured socket; the scrubbed run passed 3/3. Pin or remove that variable in the fixture.
  • Non-blocking cleanup: the new module's opening comment spans four physical lines; the repository rule caps code comments at two.

Evidence and readiness

I reviewed the cumulative merge-base diff and the 60353b54..9eaa92bc fix layer. The earlier sequence-reset and partial-lsof findings are fixed. At this exact head, 210 focused tests passed in a scrubbed environment: observer 17, launcher 61, health snapshot 92, runtime observation 13, and src-map 27. git diff --check and the review checks also passed. The current required Python aggregate is red from the separately tracked external-helper /tmp race, so I am not attributing that failure to this patch.

Worst case without these fixes: a refused runtime switch silently strands a live core without intake, while a real quota failure is simultaneously published as healthy and suppresses the correct quota diagnosis; long-lived observers also retain rollout metadata without bound. This head is not ready to merge. A re-review can approve once the three behaviors and the two required test issues above are fixed and the current required checks are green.

… error; the dispatcher refuses before reaping

- A model output (assistant message, reasoning, tool call) is the success
  evidence. token_count is not: Codex writes one from running totals before
  returning a usage-limit failure, which read a failed turn as a fresh success.
- task_complete.error makes the record abnormal (failed · idle) with
  codex_error_info mapped to needs-login, quota-limit or api-error; it stands
  until a later model output, and a new turn alone does not clear it.
- Discovery keeps only the open rollouts' parent/agent classification, prunes
  closed ones, and retries a session_meta it could not read.
- src/agent/start-cli.sh applies the shared in-session restart guard before it
  reaps the watcher and observer, so a refused Codex -> Claude switch no
  longer strips a live core of task intake and observation.
- Tests: the real-writer test resolves python through scripts/python-binary.sh;
  the launcher fixture drops an inherited SUTANDO_TMUX_SOCKET/SESSION.
- The module's opening comment is two lines.

Co-Authored-By: Claude <noreply@anthropic.com>
@marklysze

Copy link
Copy Markdown
Collaborator Author

@keweichen thanks. All three behaviours, both test issues and the comment cleanup are fixed in 9a882240.

1. A quota-failed turn is no longer a success.

  • What I checked locally first:
    • Across 584 local rollouts, 130 task_complete events carry error: usage_limit_exceeded 27, server_overloaded 34, internal_server_error 2, other 67.
    • A failed turn's lines are task_started → user message → token_count (info set) → task_complete{error}.
    • It has no model output.
  • Success evidence is now a model output: an assistant message, reasoning, or a tool call. token_count no longer counts.
  • task_complete with error gives failed · idle · abnormal. codex_error_info maps to:
    • needs-login: unauthorized
    • quota-limit: usage_limit_exceeded, session_budget_exceeded
    • api-error: anything else
  • condition_since is set when the failure arrives, and the failure stands until a later model output. A new task_started alone doesn't clear it.
  • Tests:
    • your replay, prior success → task_started → token_count → task_complete{error: usage_limit_exceeded}, now ends failed/idle/abnormal/quota-limit with last_success_at still at the earlier success;
    • the same sequence through runtime_observation.py write and health_snapshot.py --agent core reads abnormal · quota-limit;
    • the error-code mapping;
    • a turn with only token_count stays unknown.

2. The dispatcher refuses before it reaps anything.

  • src/agent/start-cli.sh sources restart-guard.sh and applies sutando_restart_guard_refuses to --restart/--force-restart. That happens after the cross-runtime decision and before any watcher or observer kill-session.
  • The launchers keep their own guard for direct calls.
  • New test tests/start-cli-dispatcher-restart-guard.test.sh uses a fake tmux and a stub launcher, with a live Codex core and Claude requested:
    • inherited SUTANDO_CORE_SESSION=1: refused, with zero watcher, observer or core kills and no launcher run;
    • unmarked caller: helpers reaped, restart delegated;
    • SUTANDO_ALLOW_INSESSION_RESTART=1: helpers reaped, restart delegated.
  • Without the dispatcher change, the first case fails, and its log shows both kills.

3. Metadata cache.

  • Discovery keeps only {parent_thread_id, agent_path} for rollouts that are open right now. Closed paths are pruned.
  • An unreadable session_meta isn't cached, so it's retried on the next discovery and is never counted as a top-level rollout.
  • Test: a 600 KB main rollout plus a subagent whose first read returns null.
    • The main rollout is chosen on the first pass.
    • The subagent is classified on the retry.
    • Closing it prunes it.
    • readMeta runs 3 times in total.

Test and repo-rule fixes.

  • The real-writer tests now run the interpreter scripts/python-binary.sh resolves.
  • run_launcher drops an inherited SUTANDO_TMUX_SOCKET and SUTANDO_TMUX_SESSION.
    • With SUTANDO_TMUX_SOCKET=/tmp/live-core.sock SUTANDO_TMUX_SESSION=live-core set, -k observer passes. Without the fix it fails, as you saw.
  • The module's opening comment is two lines; src-map is regenerated.

Mutants, each caught by a named test:

  • token_count counted as success;
  • completion error ignored;
  • a new turn clearing a failure;
  • a user message counted as success;
  • a failed read cached;
  • closed rollouts never pruned;
  • full metadata cached.

Local results.

  • codex-observer 21/21
  • codex-core-launcher 61 OK
  • health-snapshot OK, runtime-observation OK
  • src-map 27 passed
  • both restart-guard shell tests pass
  • eslint, shellcheck -S warning and review-checks.sh are clean

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on exact head 9a88224091dff970b03cd70d37953bfcd9733fbe. I verified that the earlier sequence, partial-lsof, quota-classification, cache-bounding, restart-safety, interpreter-resolution, and inherited-socket findings are fixed on their ordinary paths. The current head still has three runtime/state blockers and one required test-path fix.

1. Blocker: unreadable rollout metadata is treated as proof that the rollout is irrelevant

discover() retries an unreadable session_meta, but omits that open path from kept and passes null to pickRollout. With one known top-level rollout and one persistently unreadable sibling, the unknown sibling is therefore treated as if it were known to be a subagent. The known rollout remains selected, misses resets to zero, and its old state keeps receiving fresh heartbeats forever.

My deterministic current-head control held /old at idle/healthy and returned null for /new on every metadata read. After three discoveries and heartbeats:

{
  "target": "/old",
  "cached": ["/old"],
  "writes": [
    {"phase":"idle","condition":"healthy","heartbeat_at":1791342000,"seq":3},
    {"phase":"idle","condition":"healthy","heartbeat_at":1791342015,"seq":3},
    {"phase":"idle","condition":"healthy","heartbeat_at":1791342030,"seq":3},
    {"phase":"idle","condition":"healthy","heartbeat_at":1791342045,"seq":3}
  ]
}

The new test currently pins the unsafe assumption directly: “an unreadable sibling is not a second top-level rollout”. That contradicts the documented contract, “one top-level file, else no record”: unreadable means unknown, not subagent. The stale idle phase can also suppress a real cli_wedge retry loop through _supersede.

Please count any unreadable open rollout as ambiguity while still retrying its metadata. Retaining the current target for one transient miss is reasonable; a second ambiguous discovery must stop refreshing/drop it. Add persistent-unreadable, repaired-as-subagent, and repaired-as-second-top-level regressions.

2. Blocker: the initial tail can consume bytes past the offset it records, then lose a completion

readTail stats the file for the returned offset, then readRange stats it again and reads through the newer size. If Codex appends between those snapshots, the observer consumes the append but records the older offset. follow() rereads it; when the append was a partial JSON line, rest + reread duplicates the prefix, parsing fails, and that event is skipped permanently.

I forced exactly that interleaving with a split quota completion:

{
  "after_select": {"initial_bytes":198,"actual_bytes":245,"offset":198,"rest_bytes":47,"phase":"requesting","condition":"healthy"},
  "after_follow": {"actual_bytes":341,"offset":341,"rest_bytes":0,"phase":"requesting","motion":"moving","condition":"healthy","reason":null}
}

The completed task_complete{error: usage_limit_exceeded} should have produced failed/idle/abnormal/quota-limit; it was lost. Please read from one fd/snapshot or return the actual consumed end offset, and add an append-between-snapshot-and-read regression proving a split completion is applied exactly once.

3. Blocker: the safe dispatcher refusal bypasses Claude's durable restart audit

The new guard correctly runs before helper reaping, but src/agent/start-cli.sh:116-135 exits before the Claude launcher can execute its established log_restart_attempt contract. In the same isolated canonical-dispatcher harness:

merge-base: rc=1  log_exists=yes  refusal_count=1
HEAD:       rc=1  log_exists=no   refusal_count=0

This affects same-runtime Claude --restart/--force-restart and the Codex-to-Claude switch that synthesizes --restart. Preserve the pre-mutation refusal without dropping its durable audit, and pin the canonical dispatcher for explicit restart, force-restart, and synthesized cross-runtime restart—not only stderr and zero kills.

4. Required test fix: the observer suite cannot run from a path containing spaces

tests/codex-observer.test.mjs:16-19 uses URL .pathname, leaving a space encoded as %20. From an exact-head worktree named sutando pr5140..., the suite failed before collecting its 21 tests:

rc=1
.../sutando%20pr5140.../scripts/python-binary.sh: No such file or directory
tests 1, pass 0, fail 1

This host's canonical checkout itself lives below Application Support, so the failure is not hypothetical. Use fileURLToPath(new URL('..', import.meta.url)) and rerun the suite from a spaced checkout.

Verification and readiness

At this head, the ordinary-path suites passed: observer 21/21, full Codex launcher 61/61, health snapshot 92/92, runtime observation 13/13, dispatcher guard 3/3, direct runtime guard, and src-map 27/27. The cumulative merge-base diff passes git diff --check and review-checks.sh; a hardcoded /Users/... positive control fails as expected. Required CI and CLA are green. The original gap is real: the merge base has no Codex observer and explicitly documents the Codex runtime as having no observation record.

Those good paths do not cover the failures above. The worst case is a stale old rollout renewing idle/healthy indefinitely and suppressing a real retry loop, while the initial-tail race can permanently drop the very failure completion meant to correct that record. Separately, a safely refused restart is now absent from the audit used to diagnose failed restart attempts.

This head is not ready to merge. After the fixes move the head, please provide a fresh real post-restart task/health round trip at that exact head; the existing live witnesses predate 9a882240, which changes both observer semantics and startup dispatch. Please also update the PR body, which still says token_count is success evidence and that the observer never reports abnormal.

Non-blocking cleanup: tests/start-cli-dispatcher-restart-guard.test.sh:2-5 opens with a four-line comment block, above the repository's two-line cap.

…d is one snapshot; refusals stay audited

- pickRollout treats an open rollout whose session_meta cannot be read as
  possibly top-level: ambiguous, so the target is kept for one discovery and
  dropped on the next instead of renewing an old record forever.
- readTail/readFrom read through one fd with one fstat and return the end of
  the bytes actually read, so a write landing during the initial tail is
  followed exactly once instead of duplicated into an unparseable line.
- The restart-attempts audit writer moves into restart-guard.sh; the
  dispatcher's pre-reap refusal logs through it, as the Claude launcher does.
- tests: fileURLToPath for the repo path (checkouts with spaces); dispatcher
  test covers synthesized, explicit and force restarts and their audit line.

Co-Authored-By: Claude <noreply@anthropic.com>
@marklysze

Copy link
Copy Markdown
Collaborator Author

@keweichen thanks. All four are fixed in c6b678c1, along with the cleanup, the PR body and a fresh live round trip.

1. An unreadable rollout is treated as ambiguous.

  • pickRollout returns null whenever any open rollout's session_meta is unreadable, because it could be a second top-level thread. Its metadata is still retried.
  • The current target is kept through one ambiguous discovery and dropped on the second. After that, heartbeats stop renewing the record.
  • Regressions:
    • persistent-unreadable: one ambiguous discovery keeps /old, the second drops it, and no more writes happen;
    • repaired-as-subagent: the main rollout is selected once the subagent is classified;
    • repaired-as-second-top-level: no target.
  • The test that pinned the old assumption is rewritten. Mutants killed: ignoring an unreadable rollout, and ambiguity never dropping the target.

2. The tail read uses one snapshot.

  • readRange opens one fd, takes one fstat, reads up to that size and returns the end of the bytes actually read. readTail records that end as the offset.
  • fileDeps(opts, io) takes an injectable io for the regression test:
    • Codex extends a half-written task_complete{error: usage_limit_exceeded} line just after the reader first learns the file's size.
    • Then the rest of the line lands.
    • The follow applies it exactly once: failed · abnormal · quota-limit, and the offset equals the file size.
  • With the old two-snapshot read, that test fails.

3. Refusals stay audited.

  • The restart-attempts.log writer moved into restart-guard.sh as sutando_restart_attempt_log.
  • The Claude launcher's log_restart_attempt calls it, with output unchanged.
  • The dispatcher's pre-reap refusal logs through it too: kind restart or force-restart, plus SUTANDO_RESTART_GUARD_REASON.
  • tests/start-cli-dispatcher-restart-guard.test.sh runs the canonical dispatcher. It covers the synthesized Codex → Claude restart, explicit --restart and --force-restart. Each is refused with exactly one audit line, no watcher, observer or core kill, and no launcher run.
  • The unmarked and override controls still reap and delegate.
  • Removing the audit call fails the three refusal cases.

4. Paths with spaces.

  • The test now uses fileURLToPath(new URL('..', import.meta.url)).
  • I ran the suite from a worktree named sutando pr5140 spaced at c6b678c1: 24/24.

Cleanup. The dispatcher test's header is two lines.

Live round trip at c6b678c1. Real launcher, private socket, scratch workspace:

  • launch: the observer session came up, a task returned OK, and /health showed the observation (codex-observer, idle/healthy, last_success_age_s 2.5);
  • --restart: a new observer_id, the next task returned OK, and the observation was current again;
  • an in-session --runtime claude: rc=1, all three sessions untouched, and the refusal is in restart-attempts.log.

Full output is in the PR body. The scratch run's overall gateway-down comes from the supervisor, because no gateway runs there.

PR body rewritten for the new rules: model-output success, task_complete.error failures with their reasons, ambiguity handling, the one-snapshot read, and the dispatcher guard with its audit.

Local results.

  • codex-observer 24/24
  • codex-core-launcher 61 OK
  • the dispatcher guard test, the in-core restart guard, start-cli-restart-verify and graceful-restart all pass
  • health-snapshot and runtime-observation OK, src-map 27 passed
  • eslint, shellcheck and review-checks are clean
  • start-cli-model-pin.test.py fails 2 cases on this machine, and fails the same way on sonichi/main — environmental, not from this PR.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at c6b678c.

Every finding from keweichen's two CRs (at 9eaa92b and 9a88224) is fixed, and each fix has a regression test. Run against the head's tests, 8/24 observer tests fail on 9eaa92b and 4/24 fail on 9a88224. The dispatcher refusal cases fail on both older heads.

Mutants at head are all caught: token_count counted as success, the completion error ignored, an unreadable rollout taken for a subagent, a failed metadata read cached, closed paths not pruned, the old double-snapshot readTail, and helpers reaped before the guard.

Suites passed in a scrubbed env:

  • codex-observer 24/24
  • codex-core-launcher 61/61
  • health-snapshot 92/92
  • runtime-observation 13/13
  • dispatcher guard 5/5

review-checks passes and CI is green.

Rui's Codex-core agent verified this head independently on a macOS Codex host:

  • A persistently unreadable sibling drops the target on the second discovery, and heartbeats stop renewing the record.
  • A split usage_limit_exceeded completion during the initial tail read applies once, as failed/abnormal/quota-limit.
  • Prior success followed by quota: the full health_snapshot keeps both the observer failure and the pane quota claim, with no superseded_by. In the positive control, a later model output does supersede the claim.
  • Dispatcher refusal with an inherited SUTANDO_CORE_SESSION=1, on a private tmux fixture: exits 1, sessions are unchanged, and exactly one audit line is written.

Not independently verified: the dispatcher refusal and the post-restart task/health round trip on an authenticated live core. Both rely on Mark's exact-head witness (#5140 (comment)).

Non-blocking: the /health regression has no competing pane claim, so the token_count-as-success mutant passes it. Only the unit test on last_success_at pins that case.

…g pane claim

A cli_wedge quota-limit claim that began after the real success must stand;
counting the failed turn's token_count as a success would supersede it, and
this test alone now catches that.

Co-Authored-By: Claude <noreply@anthropic.com>
@marklysze

Copy link
Copy Markdown
Collaborator Author

@john-the-dev thanks for the approval. I've done the non-blocking item in 10339fcf (test only).

  • The /health quota regression now writes a competing cli_wedge quota-limit claim. The claim starts after the real success and before the failed turn.
  • The test asserts that the claim stands: its opinion is quota-limit, and there's no superseded_by.
  • With token_count counted as success, that test alone now fails (--test-name-pattern='/health reads': fail 1).
  • codex-observer is 24/24.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at 10339fc. This replaces my approval at c6b678c because of one silent defect.

Blocker: the observer never starts when run through a symlinked path

codex-observer.mjs:343 calls main() only when import.meta.url === pathToFileURL(process.argv[1]).href. Node resolves import.meta.url to the real path but leaves argv[1] as typed. Both launchers build REPO with plain pwd, which keeps symlinks. So for any checkout reached through a symlink, the observer exits 0 at once and makes no probes, and /health has no runtime observation, exactly as before this PR. That includes anything under /tmp, which is macOS's own symlink to /private/tmp. Nothing errors, and the core itself is unaffected.

I reproduced it with a stub tmux that logs every call:

How the observer was invoked Exit tmux probes
Real path still running (alarm) 1
Real path containing a space still running (alarm) 1
Through a directory symlink 0, immediately 0
Through /tmp/... 0, immediately 0

Rui's Codex agent reproduced it independently: through a symlink it exited 0 with zero probes, and canonicalizing argv[1] restored the probes.

No test starts the observer as a process, so the entrypoint and main() never run in the suite.

Fix: compare against pathToFileURL(fs.realpathSync(process.argv[1])), or have the launchers use pwd -P. Then add one process-level test that starts the script through a symlink and asserts it probes.

Kewei's points: all resolved in code at this head

  • Unreadable rollout is ambiguous. Lines 100-104; discovery keeps the target for one miss, then drops it at lines 163-166. The three regressions exist.
  • Single-fd tail read. readRange at lines 266-277 uses one fd and returns the real end offset.
  • Dispatcher refuses before killing helpers. It writes one shared audit entry (restart-guard.sh:15-23, start-cli.sh:116-127). The guard test fails 5/5 at the merge base and passes 5/5 at head.
  • token_count is not success. An error completion records failed/abnormal.
  • Bounded metadata cache.
  • Spaces in paths. The suite passes 24/24 from a directory with a space in its name.

Non-blocking test gaps

Each of these mutations survived the suite:

  • stop-core.sh no longer kills the observer.
  • The main-loop try/catch is removed.
  • fileDeps.write is passed a different --workspace.
  • Replacing a non-Codex session leaves the observer running.

Worth closing in the same push if it's convenient.

Also

The live post-restart round trip in the body was run at c6b678c. Runtime code is unchanged since then, because 10339fc is test-only, but the symlink fix will change runtime code, so please run it again at the new head.

…op, writer and kill paths

- The entrypoint check resolves argv[1] with realpath before comparing it to
  import.meta.url (which Node resolves), so a checkout reached through a
  symlink such as /tmp no longer exits 0 without probing.
- The main loop is runLoop(observer, sleep): a throwing tick is survived,
  a gone core ends it.
- Tests: the observer started as a process through a symlinked directory
  probes the core session; isEntrypoint; runLoop; fileDeps.write's argv
  and stdin; stop-core kills the observer; the direct Codex launcher's
  foreign-session replacement kills the observer.

Co-Authored-By: Claude <noreply@anthropic.com>
@marklysze

Copy link
Copy Markdown
Collaborator Author

@john-the-dev thanks. That was a real one. Fixed in e447969a, along with all four test gaps.

Blocker: symlinked entrypoint.

  • isEntrypoint(argv1, moduleUrl) compares pathToFileURL(fs.realpathSync(argv1)) with import.meta.url, and returns false for a missing path.
  • The main loop is now runLoop(observer, sleep).
  • New process-level test: the observer is started through a symlinked directory, with a stub tmux on PATH. It must make its -S /x.sock list-panes -t =sutando-core probe.
  • With the old check, that test fails.

Non-blocking gaps, each mutant now killed:

  • stop-core.sh doesn't kill the observer: tests/stop-core.test.sh case 4 now also asserts sutando-core-observer is gone.
  • Main-loop try/catch removed: runLoop test with a throwing tick, then true, then false, giving 3 ticks and 2 sleeps.
  • fileDeps.write given another --workspace: the test runs a stub interpreter and checks argv and stdin exactly (…/src/runtime_observation.py write --workspace '/ws dir' and the record JSON).
  • A non-Codex replacement leaving the observer running: the existing test went through the dispatcher's synthesized --restart, which is why your mutant survived. The new test_direct_launcher_replacing_a_foreign_session_kills_the_observer_too calls src/agent/codex/cli/start-cli.sh directly and asserts the observer kill comes before the new core.

Live round trip at e447969a, run through /tmp/... (the /private/tmp symlink, so pwd ≠ pwd -P):

  • launch: an observer process running from /tmp/…, the task returned OK, and /health showed the observation idle/healthy (last_success_age_s 2.2);
  • --restart: a new observer_id, the task returned OK, and the observation was current again;
  • an in-session --runtime claude: rc=1, sessions untouched, and one audit line;
  • stop-core: no server and no observer left.

Full output is in the PR body.

Local results.

  • codex-observer 28/28
  • codex-core-launcher 62 OK
  • stop-core.test.sh, dispatcher guard 5/5, health-snapshot and src-map all pass
  • eslint, shellcheck and review-checks are clean

…dex-observer

# Conflicts:
#	src/agent/codex/cli/start-cli.sh
#	tests/codex-core-launcher.test.py
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@keweichen keweichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at exact head 75fdcf97ac34bfe83eac5102559c7970c4edc3ba. This supersedes my CHANGES_REQUESTED review at 9a882240.

I re-read the full activated observer, writer, health-merge, launcher/dispatcher, restart, and stop paths against base c7da6002, including the merge conflict that produced this head. The conflict resolution preserves ensure_codex_observer inside start_core_session_and_helpers() for both launcher branches.

The earlier blockers are fixed and discriminated:

  • Unreadable open-rollout metadata is ambiguity, with one-discovery grace and then target drop. The three targeted tests pass; restoring the old “ignore unreadable” rule makes all three fail.
  • Tail/follow reads now use one fd/size snapshot and advance to the bytes actually read. Restoring the old two-snapshot behavior makes the mid-read split-quota test fail (requesting/unknown instead of failed/abnormal/quota-limit).
  • The dispatcher audits synthesized, explicit, and force-restart refusals before reaping helpers. Removing the audit call makes those three cases fail.
  • The entrypoint resolves the invoked path. The process-level symlink test passes; restoring the old URL comparison makes it time out without ever probing tmux.

Current-head local evidence:

  • node --test tests/codex-observer.test.mjs: 28/28 pass, with the new tests collected by name.
  • python3 tests/codex-core-launcher.test.py: 72/72 pass.
  • Dispatcher guard: 5/5; stop-core: all cases pass.
  • Health snapshot: 92/92; runtime observation: 13/13; src-map: 27/27.
  • git diff --check and scripts/review-checks.sh: pass. GitHub CI and CLA are green at this head.

For the live-path gate, the author’s real launcher round trip at e447969a covers launch through the /tmp symlink, task → observation → /health, restart with a new observer and second task, in-session refusal/audit, and stop cleanup. I did not repeat that quota-consuming live run at the merge commit; I verified the only launcher conflict here was the main-branch session-wait helper extraction above, then ran the expanded 72-test launcher suite on the merge result.

Worst-case disruption is a wrong or costly health observation from the always-on ps/lsof poller. It is bounded to reporting: the observer is read-only, performs no recovery/restart, treats ambiguity as no opinion, swallows probe/write failures, and its record expires after 45 seconds without renewal. The unmeasured busy-host polling cost remains disclosed and is non-blocking.

Verdict: APPROVE — the code is ready at this head. This does not override the merge gate: john-the-dev still has an outstanding CHANGES_REQUESTED review on an older head, which must be replaced or dismissed before merge readiness is evaluated.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 75fdcf97 by Chi's Sutando agent. Every standing change request is fixed and I found no new blocker. This is a COMMENT, not an approval, because one item is missing: a live core round trip at this head (REVIEW.md lesson 15).

john-the-dev's CR at 10339fc: fixed. I checked this by running the observer.
isEntrypoint now compares pathToFileURL(realpathSync(argv1)) against import.meta.url (codex-observer.mjs:350-358). I started the observer with a stub tmux:

How it was started 75fdcf9 10339fc
real path runs runs
through a directory symlink runs exits 0 without running
via /tmp runs exits 0 without running

The new test at tests/codex-observer.test.mjs:344 covers this case.

keweichen's CRs at 9eaa92b and 9a88224: fixed. I checked these by reading the code at head.

  • token_count no longer counts as success, and an error completion is applied.
  • An unreadable rollout counts as ambiguous and is dropped on the second miss.
  • The metadata cache is bounded.
  • The tail is read from a single fd.
  • The restart guard runs before helpers are killed.
  • The tests use fileURLToPath and resolve the repo's Python.

The merge of main is resolved correctly. Against main's parent it only adds lines, so nothing from main was lost. In start-cli.sh, the two ensure_codex_observer calls became one call inside start_core_session_and_helpers(), which covers both branches.

Tests run at head:

Suite Result
codex-observer 28/28
codex-core-launcher 72 OK
restart-guard 5 PASS
stop-core all pass
health-snapshot 92 OK
runtime-observation 13 OK

review-checks.sh passes. CI shows 33 green and 1 skipped, and CLA is green.

Still missing: a live round trip at this head. Mark's live run (launch via /tmp, --restart, stop-core) was at e447969, before the merge restructured the Codex start path. One post-merge live run would close this, and I'd approve after that.

Not run by me: a live Codex core.

@marklysze

Copy link
Copy Markdown
Collaborator Author

@sonichi here is the live round trip at exactly 75fdcf97, the post-merge head where ensure_codex_observer sits inside start_core_session_and_helpers().

Setup:

  • the real launcher, run from the /tmp path, which is a symlink to /private/tmp (so the symlinked entrypoint is exercised too);
  • a private socket and a scratch workspace;
  • each task was dropped into tasks/ and delivered by the notifier.
head 75fdcf97ac34bfe83eac5102559c7970c4edc3ba   pwd=/tmp/…/codexobs   pwd -P=/private/tmp/…/codexobs
== launch                          (bash src/agent/start-cli.sh --runtime codex --no-schedule-reconcile)
launch rc=0        sessions: obs-rt3 obs-rt3-observer obs-rt3-watcher
observer:          node /tmp/…/codexobs/src/agent/codex/cli/codex-observer.mjs --engine /tmp/…/codexobs --tmux-socket …/obs-rt3.sock …
task-rt3-1791484314 RESULT after 60s: OK
/health observation: {'phase': 'idle', 'observer': 'codex-observer', 'seq': 10, 'heartbeat_age_s': 0.1, 'last_success_age_s': 1.3}  opinion idle/healthy
record: observer_id 54ea8598144f1d81 session obs-rt3 seq 10 idle healthy
== --restart
restart rc=0       sessions: obs-rt3 obs-rt3-observer obs-rt3-watcher
task-rt3-1791484385 RESULT after 62s: OK
/health observation: {'phase': 'idle', 'observer': 'codex-observer', 'seq': 13, 'heartbeat_age_s': 0.1, 'last_success_age_s': 1.2}  opinion idle/healthy
record: observer_id b71ec783865f50e8 (new) session obs-rt3 seq 13 idle healthy
== in-session --runtime claude with SUTANDO_CORE_SESSION=1
rc=1               sessions: obs-rt3 obs-rt3-observer obs-rt3-watcher (unchanged)
restart-attempts.log: 2026-10-08T18:34:10Z [restart] refused: inherited SUTANDO_CORE_SESSION=1 (in-session self-kill), no override set
== stop-core
stop-core: obs-rt3 stopped
no server running on …/obs-rt3.sock; codex-observer node processes left: 0

I updated the PR body's live section to this run.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve at 75fdcf97. This replaces my CHANGES_REQUESTED at 10339fcf. The owner (Rui) has accepted the existing evidence for the live gate.

My CR is fixed.

  • isEntrypoint() resolves argv[1] with realpathSync before comparing it with import.meta.url (codex-observer.mjs:350-358).
  • The process-level test at tests/codex-observer.test.mjs:344 launches through a symlinked dir. Putting the old comparison back makes it fail.
  • A real launch via /tmp keeps running and probes tmux.

Evidence relied on:

  • Live run (Mark's): launch via /tmp, --restart with a new observer_id, a refused in-session switch with its audit line, and stop-core, all at e447969a. That's the commit carrying the fix. 75fdcf97 only adds a hand-resolved merge of main. Three independent diffs (mine, Chi's agent's, Rui's Codex core's) found that merge equals main plus the observer hunks only, with the two ensure_codex_observer calls folded into start_core_session_and_helpers().
  • Rui's Codex core at this head, without a live core: codex-core-launcher 72/72 at head against 68/68 on main on the same host, observer 28/28, restart-guard 5/5, and stop-core passing. The real observer, launched through a directory symlink against an isolated tmux session, exited 0 with no stderr after the session was removed. The old entrypoint check reproduces the silent exit.
  • Others at this head: keweichen re-reviewed and approved. Chi's agent re-reviewed (COMMENT) and found every standing CR fixed and no new blocker.
  • CI: all 15 required checks are green on their latest runs.

Not verified: an authenticated live launch/restart/attach/stop at 75fdcf97 itself. The owner accepted Mark's run at e447969a in its place.

@john-the-dev
john-the-dev merged commit d563931 into sonichi:main Oct 8, 2026
50 of 52 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.

5 participants