Skip to content

feat(ag2-sparrow): ag2-sparrow-codex-local hosts the Codex connector under the shared supervised-session lifecycle - #5761

Merged
sonichi merged 7 commits into
mainfrom
feat/sparrow-codex-local-connect
Oct 22, 2026
Merged

sonichi merged 7 commits into
mainfrom
feat/sparrow-codex-local-connect

Conversation

@sonichi

@sonichi sonichi commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Priority

Level: low
Reason: new opt-in console script, the Codex counterpart of #5695 (the local Claude Code shape). Since the review rounds it also moves the holder sequence, the occupancy words and the launchd shim into connect_common, which the existing claude-local runtime now runs through (its 214-test suite and the Mod harness stay green).

Closes

N/A

Summary

Adds ag2-sparrow-codex-local (packages/ag2-sparrow/ag2_sparrow/adapters/codex/connect_cli.py): install | run | debug-run | verify | uninstall for the Codex connector (#5611) under a service manager, on the supervised-session lifecycle connect_common that #5695 made shared. The launcher holds the work root for its own life and the connector runs as its direct child with --held, attached as the consumer whose liveness fences the root (holder_lock.attach_consumer).

Where to look first:

  • connect_cli.py: install, run / _held / _supervise, verify, occupancy_line.
  • codex_connector.py: --held and serve_held; child_env passing CODEX_HOME.
  • README section "Launch under a supervisor".

How it works

  1. install checks codex --version against the pin (0.160.1, measured; the line starting with codex), canonicalizes --work, and hands connect_common.install the values: stop the job and prove it absent, prove the old root free, write the marked 0600 config (ROOT persisted once) and the marked unit (tmp + replace), register and start, then require the root held by a fresh launcher of adapter codex-local from this install within 30 s. Anything less is one line and the files as written; an unowned config or unit is a final refusal naming what to remove by hand.
  2. run is the manager's entrypoint (refused without AG2_SPARROW_MANAGED=1; debug-run is the hand start). It re-checks the pin, takes the root's lock (task_io.acquire_lock, adapter codex-local; another holder is 75, which the unit does not relaunch over), makes tasks, results, state, state/threads under the held inode, spawns python -m …codex_connector --work <root> --held --server stdio --sandbox <s> [--allow-non-owner] [--model <m>] under PATH (configured codex first), HOME, USER, PYTHONPATH and CODEX_HOME when recorded, and attaches it as the consumer. A SIGTERM terminates the connector, waits 15 s, kills, reaps, releases and exits 143. The connector's own 75 is reported as 1, so 75 means only another holder. Never --once, --idle-exit, --push, --thread-id, --ephemeral, --developer-instructions, stub or proxy.
  3. --held on the connector: no lock of its own; it starts only once the holder's record names its pid as the consumer (bounded wait), else exits 2, so a hand start with --held cannot run two connectors on one root.
  4. verify: config, the pin, codex login status under the same allowlist (never the token), inbox/results, the relay token's mode, the marked unit, the manager's own word for the job, and who holds the root (the launcher and, once attached, its connector). uninstall: the shared any-to-absent path; keeps the Codex login, relay.env and the work root with state/threads.

Rebased onto main after #5695

The branch was built before #5695 evolved connect_common; its own extraction of connect_common.py and its thinning of the Claude CLI were superseded and dropped at the rebase. The review rounds since then changed connect_common.py, claude_code/hold.py, claude_code/cli.py and claude_code/launcher.py on purpose: the launcher-side policy both shapes had as copies is one owner now, and the Claude path runs through it. The Codex side was rebuilt on what main owns:

From the shared modules (no copy in this adapter):

  • connect_common: the Shape, config/unit rendering (MANAGED_VAR, KillMode, RestartPreventExitStatus=75, launchd KeepAlive on anything but a clean exit), canonical_root, the per-shape operation lock, install (quiescent replacement + fresh-holder confirmation), managed_problem, the launchd entry shim (run_launchd, launchd_exit, run_child_argv), the holder sequence (hold_and_supervise: take the root, refuse a tasks symlink, the shape's prepare/spawn hooks, stop-before-start, attach the child as the consumer, reap on any failure, the release line; supervise_child, kill_and_reap), the occupancy words (holder_words, occupancy_line with a labels parameter), check/relay_check/verify_tail/manager_detail, uninstall and the kept line. claude_code's hold, cli and launcher delegate to the same functions; the adapter-boundaries suite pins both adapters' delegation and refuses a copy of any owned step.
  • holder_lock / task_io occupancy: the launcher holds, attach_consumer names the connector, lock_holder / holder_consumer / occupancy feed verify and install's closing line (read inside connect_common).
  • _dirs.device_env_path() for the relay token row.
    Codex-specific (stays in the adapter): the Shape values (pin 0.160.1, ^codex anchor, config keys CODEX, CODEX_HOME, SANDBOX, ALLOW_NON_OWNER, MODEL; run --launchd), the connector argv (--held --codex <the checked executable>) and its forbidden flags, child_env (PATH order, PYTHONPATH, CODEX_HOME), codex login status, the sandbox/non-owner echo, the prepare hook (the connector's dirs under the held inode) and spawn hook, the --held consumer mode in the connector, and the exit mapping of the connector's own 75.

Not applicable here: the claims-step boundary, task_lifecycle (src + vendored copy, drift test) and the disposition-based retry/backoff/stuck marks belong to the Claude Mod's claim protocol; the Codex connector (#5611, unchanged on main) answers task files directly through task_io, so there is no claim to retry. The drift test is run and in sync.

Not offered yet (named in the README limits): the agent's default root under the agents dir (agent_identity.root_segment) and the per-agent bridge declaration (worker.json for sparrowd). That policy lives in adapters/claude_code/cli.py (resolve_root, install_identity, served_elsewhere, wire_bridge, bridge_problem); giving Codex the same shape means extracting it to a shared owner first, not copying it, so it is a follow-up. This PR keeps --work required, as the original scope did.

What changed since the last review (c666ac3 -> c8a6a90)

  • Every owner turn names its own cwd (John P2, agreed by Qingyun): turn/start now carries the turn's working directory (the pinned 0.160.1 TurnStartParams cwd override), decided per turn by Connector.turn_cwd: the owner's --cwd or the held inode's current pathname (getcwd() after the fchdir), a non-owner's scratch dir (isolation unchanged). Before, the cwd was read only at thread open/resume, the cached-channel branch returned early and turn/start sent none, so a warmed owner channel kept the old pathname after a rename, which by then named the replacement. The stub app-server echoes the turn's cwd when the turn carries one, else the thread's; the differential recording gains the cwd key on each turn/start (the only delta, noted in the recording).
  • Pin, John's falsifying check as real processes (real launcher, production connector, repo stub app-server): LauncherProcess.test_a_root_renamed_and_replaced_after_startup_is_served_by_inode_and_the_replacement_untouched now completes an owner turn on a channel first, then renames the root, recreates it and holds the replacement; the same channel's next turn reports cwd=<moved root> (fails at c666ac3: it reports the replacement's pathname), a fresh channel's turn does too (the control, passes at c666ac3), results land in the held inode, and the replacement keeps its task with no connector results or state. Unit pin: TurnCwdTest (owner turn from the root, non-owner from scratch; errors at c666ac3, where run_turn takes no cwd).
  • Qingyun's non-blocking items: (2) the boundary pin now also refuses an owned step imported directly or under an alias (from ...task_io import acquire_lock as take), AST-based, with its own test; (4) the stale "20 tests" evidence lines are corrected. Also: the launcher cleanup now SIGTERMs before it SIGKILLs, so a failing rename case reaps its connector instead of orphaning it (seen while running the falsifying check). Not changed: (1) the in-process HeldConnector tests remain the pin for the fchdir (Popen's cwd= already pins the inode for the launcher tests); (3) the 75 mapping difference between the shapes is pre-existing and left for its own change.

What changed in the previous round (34f61d5 -> c666ac3)

  • The held connector lives in the root inode (Kewei 1): the launcher passes the held root's descriptor (--root-fd, via pass_fds); under --held the connector fchdirs into it and names tasks/, results/ and state/ relative to it, so task_io.root_path() covers the pre-spawn window and the inode covers the rest. --held without the descriptor is a usage error; a descriptor that is not a directory is exit 2 with one line. The owner thread's cwd is a pathname by the app-server protocol: it is the inode's current pathname, resolved per turn. Pin, as real processes: LauncherProcess.test_a_root_renamed_and_replaced_after_startup_is_served_by_inode_and_the_replacement_untouched (rename, recreate the pathname, another process holds the replacement; the moved root still gets its result, the replacement keeps its task and gains no state).
  • debug-run under the operation lock, and never taken for the managed launcher (Kewei 2): debug-run enters the shape's operation lock before its checks and releases it right after the acquisition (the shared helper's new acquired() hook), and publishes its holder record as codex-local.debug (connect_common.debug_adapter); _fresh_holder confirms only the shape's own label, so an install whose managed launcher lost to a hand holder reports "did not acquire". Pins: the lock refusal with no root touched, the label with the operation lock free during the serve, and install over a debug holder (Install.test_a_debug_holder_that_won_the_root_is_never_taken_for_the_managed_launcher).
  • The Linux CI failures (Kewei's diagnosis, confirmed in the job log): the fake codex wrote codex-server.json non-atomically, the poller read the torn file, launcher() raised before registering cleanup, and the leaked launcher's pipes surfaced as ResourceWarning: unclosed file during subprocess._cleanup() inside the next test's captured stderr. Now the fake records by temp file + rename, the launcher helper registers its cleanup (kill + communicate) before the first read, and the waits are one bounded helper. The wrapper itself was fine under dash (the sibling SIGTERM test passed on the same runner); verified again under a py3.12 venv.
  • Diff coverage: the held happy path runs traced under a holder that attached it, and the shared helper's prepare refusal, reap tolerance and unobserved words, the CLI's login and line-break refusals, and --root-fd errors are covered. Measured the gate's way (instrumented runs of the suites that reach the changed files, coverage combine, diff-cover --compare-branch=origin/main --fail-under=95): 99% (8 of 828 changed lines missing: two loop-fallthrough lines in the test file, the differential driver's lambda, two lines each in codex_connector.py and connect_common.py), from 94% at 34f61d5.

What changed two rounds ago (8238ecf -> 34f61d5)

  • The executable that runs is the one that was checked (John P2 1, Susan 2): the launcher passes --codex <configured path> and codex_connector.server_argv(server, codex) starts the app-server from it; no PATH lookup. Pin: LauncherProcess.test_the_app_server_comes_from_the_configured_executable_not_a_codex_earlier_on_path (a codex earlier on PATH that records itself and must not run; fails at 8238ecf).
  • --held serves only as a verified consumer (John P2 2, Susan 1): the record must name this pid and this process's start time and verify it alive (live is True); liveness false, unknown, or another start time is exit 2 with one reason line, no app-server. Pin: HeldConnector.test_held_whose_record_names_this_pid_without_verified_liveness_is_refused (three cases, each wait bounded so a mutated check fails instead of hanging; fails at 8238ecf).
  • launchd no longer relaunches over another holder (Qingyun B1): the Codex Shape runs run --launchd with keep_alive_on_success=False; the shim and its exit table moved from claude_code/launcher.py into connect_common (launchd_exit, run_launchd, run_child_argv) and both shapes use them. README line and the KeepAlive pin updated ({"SuccessfulExit": False}, ProgramArguments [.., run, --launchd]); Run.test_run_launchd_is_the_shared_shim_over_this_launcher pins the table through the Codex entry.
  • One holder sequence, one set of occupancy words (Qingyun B2, Susan 3): connect_common.hold_and_supervise / supervise_child / kill_and_reap / holder_words / occupancy_line(labels=...); claude_code/hold.py, claude_code/cli.py and codex/connect_cli.py delegate (hold keeps its prepare/spawn/go-byte steps as hooks; the Claude tests that patch task_io.* and hold._supervise still take effect because the helper looks every step up at call time). test_adapter_boundaries.test_the_launcher_side_adapters_delegate_the_holder_sequence_the_occupancy_words_and_the_shim pins the delegation and refuses any owned step in those modules.
  • Nits: _held's stop-before-start, spawn-error and unconfirmed-kill branches covered (Run.test_a_stop_before_the_spawn_a_spawn_error_and_a_connector_that_survives_the_kill); README line on a SIGKILLed debug-run launcher orphaning the connector (root stays held, next start exits 75); README "first act" line qualified for --held (Susan); the fake codex is a /bin/sh wrapper exec-ing the quoted interpreter, pinned through a path with a space (Susan); the push test and the connector env test use explicit, cleared env fixtures so an ambient CODEX_HOME cannot reach them (John, Susan); the differential driver's server_argv stand-in takes the executable argument.

Checklist

  • Confirmed no other open PR closes the same issue (gh pr list -R sonichi/sutando --state open; feat(ag2-sparrow): Codex Cloud adapter, an opt-in way for a Codex Cloud task to answer as an AG2 Space agent #5650 is the Codex Cloud half)
  • Git author email matches my CLA-signed email
  • Single concern per PR
  • Feature isn't already covered on main (no ag2-sparrow-codex-local script there)
  • Test added (packages/ag2-sparrow/tests/test_codex_local_connect.py, 20; repo wrapper tests/ag2-sparrow-codex-local-connect.test.py; CI line)
  • Before/after evidence pasted below
  • Every claim verifiable from the diff or the pasted output
  • Live path (REVIEW.md §15, the separate gate): install on a real host with a real codex app-server is not witnessed yet (whether the app-server runs without a TTY under launchd's gui domain or a systemd user unit is the open question in the README limits); the launcher/connector/lock path is exercised as real processes in the suite
  • Stacked: N/A (parent feat(ag2-sparrow): ag2-sparrow-connect claude-local hosts the local Claude Code session #5695 is merged; rebased on it)
  • No host-specific hardcoded paths; fixtures under a temp HOME
  • CI workflow edit: one test line added to the existing ag2-sparrow step (same job, runs on this PR)
  • No workspace/home resolution reinvented (_dirs.device_env_path(), connect_common._home())

Before / after evidence

Before (main at 7f7f56a): no ag2-sparrow-codex-local console script; python3 -m ag2_sparrow.adapters.codex.codex_connector --held is an unknown argument.

After (this head), one file at a time under env -i PATH=/opt/homebrew/bin:/usr/bin:/bin HOME=$HOME nice -n 19:

packages/ag2-sparrow/tests/test_codex_local_connect.py      Ran 35 tests  OK
tests/ag2-sparrow-codex-local-connect.test.py               Ran 35 tests  OK
packages/ag2-sparrow/tests/test_codex_connector.py          Ran 50 tests  OK
packages/ag2-sparrow/tests/test_task_lifecycle.py           Ran 252 tests OK
tests/ag2-sparrow-task-lifecycle.test.py                    Ran 252 tests OK
packages/ag2-sparrow/tools/test_no_drift.py                 PASS — package in sync with src/
packages/ag2-sparrow/tests/test_claude_cloud_session_adapter.py  Ran 65 tests OK
packages/ag2-sparrow/tests/test_cloud_session_contract.py   Ran 10 tests  OK
packages/ag2-sparrow/tests/test_adapter_boundaries.py       Ran 8 tests   OK
packages/ag2-sparrow/tests/test_adapter_differential.py     Ran 6 tests   OK
packages/ag2-sparrow/tests/test_claude_code_adapter.py      Ran 214 tests OK
packages/ag2-sparrow/tests/test_wheel_contents.py           PASS (4 rows)
git diff --check clean; ruff clean; scripts/review-checks.sh PASS

At 8238ecf (the Codex README keeps the pinned upgrade-paths sentence; the installer is stated beside it as keeping that order), the repo-level suites that grep READMEs:

tests/holder-lock.test.py                        Ran 233 tests  OK (skipped=1)   (both HolderLockTest and HolderLockTestVendored README pins pass)
tests/engine-conflict-resolve.test.py            Ran 15 tests   OK
tests/eslint-workflow-scope-gate.test.py         rc 0
tests/gateway-result-worker-attribution.test.py  Ran 21 tests   OK
tests/health-check-skill-symlinks-live.test.py   rc 0
tests/marketplace.test.py                        Ran 51 tests   OK
tests/pool-record-single-owner.test.py           Ran 14 tests   OK
tests/release-docs-audit.test.py                 rc 0
tests/runtime-api-identity-view.test.py          Ran 47 tests   OK
tests/trusted-capabilities.test.py               Ran 15 tests   OK
git diff --check clean; scripts/review-checks.sh PASS

At 34f61d5, one file at a time under the same env -i … nice -n 19:

packages/ag2-sparrow/tests/test_codex_local_connect.py          Ran 25 tests  OK
tests/ag2-sparrow-codex-local-connect.test.py                   Ran 25 tests  OK
packages/ag2-sparrow/tests/test_codex_connector.py              Ran 49 tests  OK
packages/ag2-sparrow/tests/test_adapter_boundaries.py           Ran 9 tests   OK   (new delegation pin)
packages/ag2-sparrow/tests/test_claude_code_adapter.py          Ran 214 tests OK   (the Claude path on the shared helper)
claude plugin test ag2space-inbox (CLI 2.1.304)                 67 pass, 0 fail
packages/ag2-sparrow/tests/test_task_lifecycle.py               Ran 252 tests OK
tests/ag2-sparrow-task-lifecycle.test.py                        Ran 252 tests OK;  src/ and vendored copies identical
packages/ag2-sparrow/tools/test_no_drift.py                     PASS — package in sync with src/
packages/ag2-sparrow/tests/test_claude_cloud_session_adapter.py Ran 65 tests  OK
packages/ag2-sparrow/tests/test_cloud_session_contract.py       Ran 10 tests  OK
packages/ag2-sparrow/tests/test_adapter_differential.py         Ran 6 tests   OK
packages/ag2-sparrow/tests/test_wheel_contents.py               PASS (4 rows)
tests/holder-lock.test.py                                       Ran 233 tests OK (skipped=1)
README-grep suites (engine-conflict-resolve 15, gateway-result-worker-attribution 21, marketplace 51,
  pool-record-single-owner 14, runtime-api-identity-view 47, trusted-capabilities 15; eslint-workflow-scope-gate,
  health-check-skill-symlinks-live, release-docs-audit rc 0)     OK
git diff --check clean; ruff clean; scripts/review-checks.sh PASS

The suite's LauncherProcess runs the launcher as a real process over the real connector and the stub app-server: the lock record names the launcher (adapter codex-local, owner_pid = launcher pid), the attached consumer is the connector pid, CODEX_HOME reached the app-server and the relay token did not, SIGTERM exits 143, the lock reads free with no stale record, and the connector is gone.

At c8a6a90 (this round), one file at a time under env -i … nice -n 19, caches cleared:

packages/ag2-sparrow/tests/test_codex_local_connect.py          Ran 35 tests  OK
tests/ag2-sparrow-codex-local-connect.test.py                   Ran 35 tests  OK
packages/ag2-sparrow/tests/test_codex_connector.py              Ran 50 tests  OK
packages/ag2-sparrow/tests/test_adapter_boundaries.py           Ran 10 tests  OK
packages/ag2-sparrow/tests/test_adapter_differential.py         Ran 6 tests   OK
packages/ag2-sparrow/tests/test_claude_code_adapter.py          Ran 214 tests OK
packages/ag2-sparrow/tests/test_task_lifecycle.py               Ran 252 tests OK
tests/ag2-sparrow-task-lifecycle.test.py                        Ran 252 tests OK
packages/ag2-sparrow/tools/test_no_drift.py                     PASS — package in sync with src/
packages/ag2-sparrow/tests/test_claude_cloud_session_adapter.py Ran 65 tests  OK
packages/ag2-sparrow/tests/test_cloud_session_contract.py       Ran 10 tests  OK
packages/ag2-sparrow/tests/test_wheel_contents.py               PASS
tests/holder-lock.test.py                                       Ran 233 tests OK (skipped=1)
diff-cover vs merge base (gate's recipe, instrumented root suites)  Coverage: 99% (996 lines, 9 missing)
git diff --check clean; ruff clean; scripts/review-checks.sh --diff <merge-base diff> PASS (rc 0)

At c666ac3 (previous round), one file at a time under env -i … nice -n 19, under both Python 3.14 (host) and a Python 3.12 venv for the connect suite:

packages/ag2-sparrow/tests/test_codex_local_connect.py          Ran 35 tests  OK   (3.14 and 3.12)
tests/ag2-sparrow-codex-local-connect.test.py                   Ran 35 tests  OK
packages/ag2-sparrow/tests/test_codex_connector.py              Ran 49 tests  OK
packages/ag2-sparrow/tests/test_adapter_boundaries.py           Ran 9 tests   OK
packages/ag2-sparrow/tests/test_adapter_differential.py         Ran 6 tests   OK
packages/ag2-sparrow/tests/test_claude_code_adapter.py          Ran 214 tests OK
claude plugin test ag2space-inbox (CLI 2.1.304)                 67 pass, 0 fail
packages/ag2-sparrow/tests/test_task_lifecycle.py               Ran 252 tests OK
tests/ag2-sparrow-task-lifecycle.test.py                        Ran 252 tests OK;  src/ and vendored copies identical
packages/ag2-sparrow/tools/test_no_drift.py                     PASS — package in sync with src/
packages/ag2-sparrow/tests/test_claude_cloud_session_adapter.py Ran 65 tests  OK
packages/ag2-sparrow/tests/test_cloud_session_contract.py       Ran 10 tests  OK
packages/ag2-sparrow/tests/test_wheel_contents.py               PASS (4 rows)
tests/holder-lock.test.py                                       Ran 233 tests OK (skipped=1)
README-grep suites (engine-conflict-resolve 15, gateway-result-worker-attribution 21, marketplace 51,
  pool-record-single-owner 14, runtime-api-identity-view 47, trusted-capabilities 15; eslint-workflow-scope-gate,
  health-check-skill-symlinks-live, release-docs-audit rc 0)     OK
diff-cover vs origin/main (gate's recipe, py3.12)              Coverage: 99% (828 lines, 8 missing)
git diff --check clean; ruff clean; scripts/review-checks.sh PASS

Test plan

  • The suite above (fake codex answering --version, login status and app-server --listen stdio:// by exec-ing the package stub; a fake manager whose start verbs run a stand-in holder in another process; a real child for the attach).
  • Live witness, when the owner says go: install on a throwaway work root with the real codex, an owner task through the connector, a second launcher refused with 75, SIGTERM leaving no orphan app-server, uninstall leaving the login and relay.env untouched.

Rebased onto main ca6c542

  • connect_common.py imports (textual conflict): main imports service_manager as _sm (feat(credentials): one owner for gateway credential precedence and masking (prerequisite for #5665) #5698 moved the manager runner there); this PR adds task_io for the shared holder sequence. Kept both: from . import _dirs, service_manager as _sm, task_io.
  • test_codex_local_connect.py (semantic, no textual conflict): main moved connect_common._run into service_manager._run, so the codex-local manager fake now patches service_manager._run, as the claude-local suite already does. New commit 887848d; no product code changed.
  • claude_code/cli.py, launcher.py, hold.py, ci.yml and pyproject.toml merged automatically; main's changes there (HMAC strict policy, the moved manager helpers) are kept unchanged.

Re-run after the rebase, one file at a time: test_codex_local_connect (35 OK), test_codex_connector (50 OK), test_claude_code_adapter (238 OK), test_adapter_boundaries (10 OK), test_adapter_differential (6 OK), test_wheel_contents (PASS), the repo wrappers for codex-local connect (35 OK) and the claude-local wheel (1 OK); ruff clean; scripts/review-checks.sh PASS.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

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

Diff Coverage

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

  • packages/ag2-sparrow/ag2_sparrow/adapters/claude_code/cli.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/adapters/claude_code/hold.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/adapters/claude_code/launcher.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/adapters/codex/codex_connector.py (84.4%): Missing lines 565,567,569-573
  • packages/ag2-sparrow/ag2_sparrow/adapters/codex/connect_cli.py (100%)
  • packages/ag2-sparrow/ag2_sparrow/connect_common.py (99.0%): Missing lines 846
  • packages/ag2-sparrow/tests/adapter_differential_driver.py (0.0%): Missing lines 215
  • packages/ag2-sparrow/tests/test_adapter_boundaries.py (100%)
  • packages/ag2-sparrow/tests/test_codex_connector.py (100%)
  • packages/ag2-sparrow/tests/test_codex_local_connect.py (98.9%): Missing lines 456-457,663-667

Summary

  • Total: 1008 lines
  • Missing: 16 lines
  • Coverage: 98%

packages/ag2-sparrow/ag2_sparrow/adapters/codex/codex_connector.py

Lines 561-577

  561             # The record must name this incarnation (pid and start) and verify it alive: a stale record
  562             # naming a reused pid, or one whose lineage cannot be read, serves nothing.
  563             if consumer.get("live") is True and consumer.get("start") == start_time(os.getpid()):
  564                 break
! 565             why = ("a start time that is not this process's" if consumer.get("start") != start_time(os.getpid())
  566                    else "liveness unknown" if consumer.get("live") is None else "liveness false")
! 567             sys.stderr.write("refusing to start: --held, but the holder of %s names this pid with %s, not verified\n"
  568                              % (os.getcwd(), why))
! 569             return 2
! 570         if stop_requested() or time.monotonic() >= deadline:
! 571             sys.stderr.write("refusing to start: --held, but no holder of %s attached this process\n" % os.getcwd())
! 572             return 2
! 573         time.sleep(0.05)
  574     args.inbox, args.results, args.state = "tasks", "results", "state"
  575     # The owner thread's cwd is a pathname by protocol: None here means the inode's current one, per turn.
  576     args.cwd = os.path.abspath(cwd_given) if cwd_given else None
  577     return exit_code(serve_locked(args))

packages/ag2-sparrow/ag2_sparrow/connect_common.py

Lines 842-850

  842     try:
  843         owner = task_io.acquire_lock(root, adapter or shape.name, retry_s=ACQUIRE_RETRY_S)
  844     except LockRefused as e:
  845         if acquired is not None:
! 846             acquired()
  847         print(e.line, file=sys.stderr)
  848         return EXIT_HELD if e.code == 3 else EXIT_UNUSABLE
  849     if acquired is not None:
  850         acquired()

packages/ag2-sparrow/tests/adapter_differential_driver.py

Lines 211-219

  211     from ag2_sparrow.adapters.codex import codex_connector as cc
  212     log, tee = work.parent / (work.name + ".rpc"), work.parent / (work.name + ".tee.py")
  213     tee.write_text(TEE)
  214     stub = os.path.join(os.path.dirname(cc.__file__), "stub_app_server.py")
! 215     cc.server_argv = lambda server, codex="codex": [sys.executable, str(tee), str(log), stub]
  216     with contextlib.redirect_stderr(io.StringIO()):
  217         # --allow-non-owner restores the pre-gate serving set, so the recording still compares every
  218         # served path byte for byte; the gate itself is pinned in test_codex_connector.
  219         rc = cc.main(["--server", "stub", "--work", str(work), "--poll", "0.01", "--allow-non-owner", *extra])

packages/ag2-sparrow/tests/test_codex_local_connect.py

Lines 452-461

  452             fd = os.open(cm.oplock_path(cli.LOCAL), os.O_RDWR | os.O_CREAT, 0o600)
  453             try:
  454                 fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
  455                 seen["oplock_free"] = True
! 456             except BlockingIOError:
! 457                 seen["oplock_free"] = False
  458             finally:
  459                 os.close(fd)
  460             return child.wait()
  461         with mock.patch.object(cm, "_run_until_end", side_effect=during):

Lines 659-671

  659     @staticmethod
  660     def end(proc):
  661         """Whatever the test decided: the launcher ended and its pipes closed, so nothing leaks into the next test."""
  662         if proc.poll() is None:
! 663             proc.terminate()  # the launcher reaps its connector on SIGTERM; a SIGKILL would orphan it
! 664             try:
! 665                 proc.communicate(timeout=30)
! 666             except subprocess.TimeoutExpired:
! 667                 proc.kill()
  668         proc.communicate(timeout=30)
  669 
  670     def test_the_app_server_comes_from_the_configured_executable_not_a_codex_earlier_on_path(self):
  671         """The executable the version check passed is the one that runs: a `codex` earlier on PATH is not."""

@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.

Changes requested at 8238ecfe. There are two P2 findings, both reproduced with behavioral probes.

1. The executable that runs is not necessarily the one that was configured and version-checked. packages/ag2-sparrow/ag2_sparrow/adapters/codex/connect_cli.py:61 prepends only the configured executable's directory to PATH, while codex_connector.py:441 runs the literal codex. Scenario: a real launcher, connector and stub server, configured with a valid executable named codex-selected. Its version check passes, but the app-server is started by a different codex further down PATH. Adding a codex alias next to the selected executable makes the configured program run instead. Fix: pass the exact configured executable through to server creation, or reject unsupported executable names before installing. Please cover this with a behavioral test.

2. The held mode serves without a lock on PID equality alone. codex_connector.py:533 compares PIDs and ignores the lineage/liveness result from the holder reader. Scenario: a synthetic stale fingerprint passed through the real task_io.holder_consumer reader returns the current PID with live=False, yet serve_held still enters serve_locked and returns 0. A controlled live=None also serves. Fix: require that the current process incarnation verifies as alive, and refuse when the result is false or unknown. This models the stale-record and PID-reuse boundary; kernel PID reuse was not forced.

Also: with an ambient CODEX_HOME set, an existing push test's two-key environment assertion fails. That test needs an explicit environment fixture.

What was run:

  • 20 local-connect tests pass.
  • 49 connector tests pass with ambient CODEX_HOME removed.
  • holder-lock: 233 tests complete, 1 skipped.
  • 8 adapter-boundary tests, the package drift check and the scanner pass.
  • Current-head CI: 33 pass, 3 skipped, CLA passes.
  • Paths checked: shared lifecycle delegation, install/uninstall and supervision, argv/environment, packaging, CI wiring and README scope.
  • The default-root and worker.json deferrals are explicit.

Limits: no live install, login or provider call. The opt-in REVIEW.md §15 witness (real launchd, app-server, task→reply) is still a separate gate.

Review by Rui's Codex-core Sutando, filed by Rui's Claude core.

@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.

REQUEST_CHANGES: two blockers, both cases where this PR drifts from the shape #5695 merged. Tests and CI are green. These are separate from @john-the-dev's two findings (the configured executable vs the literal codex, and serve_held's PID-only check). Those still stand.

B1: on macOS, launchd relaunches the agent while another session holds the root.

  • connect_common's systemd unit has RestartPreventExitStatus=75, but launchd has no equivalent. That is why #5695 ships run --launchd with launchd_exit() (claude_code/launcher.py:576, "logical 75 is terminal (exit 0, launchd stops respawning)") and keep_alive_on_success=False.
  • The Codex Shape (codex/connect_cli.py:35-39) takes the defaults. Rendered at this head:
    • codex-local: KeepAlive <true/>, ProgramArguments [.../ag2-sparrow-codex-local, run]
    • claude-local: KeepAlive {SuccessfulExit: false}, ProgramArguments [..., run, --launchd]
  • So when the root is held elsewhere, the agent exits 75 and launchd relaunches it every ThrottleInterval (30 s). That contradicts README.md:46 ("which the manager does not relaunch over"), and tests/test_codex_local_connect.py:220 pins KeepAlive true ("always relaunched").
  • Fix: move the launchd entry shim and exit table into connect_common and use them from both shapes, rather than adding a Codex copy.

B2: adapter policy is copied, not shared.

  • connect_cli.py:159-210 (_held/_supervise) repeats hold.py:147-215 step for step:
    • acquire_lock with code 3 → EXIT_HELD;
    • the _is_link("tasks") refusal;
    • stop-before-start returning 128+sig;
    • holder_record_bytes → spawn → start_time None → LockRefused(2) → attach_consumer, or reap ("no consumer: %s");
    • release_lock with the "may still be alive" line;
    • _supervise over cm._run_until_end.
  • holder_words/occupancy_line (connect_cli.py:94-110) is claude_code/cli.py:442-458 with the nouns changed.
  • CLAUDE.md says "never copy policy code between bridges" and "do not add a copy because the extraction looked large". Extract a connect_common hold-and-supervise helper that takes the adapter's prepare/spawn/exit-map steps as hooks, add a labels parameter on cm.occupancy_line, and pin both adapters' delegation in tests.

Nits (non-blocking):

  • Mutating serve_held's consumer check (codex_connector.py:533 → if True:) makes the suite hang rather than fail. A per-test time limit would turn that into a failure.
  • _held's failure branches are uncovered: lines 167, 171-176, 180-181, 185, 193, 203-206 and 216-221 per the coverage gate. These include stopped-before-start, spawn OSError and the reap timeout.
  • Under debug-run, a SIGKILLed launcher orphans the connector. The orphan's liveness keeps the root held, so the next start exits 75. Worth a README line.

Verified:

  • test_codex_local_connect 20 OK, wrapper 20 OK, test_codex_connector 49 OK, test_adapter_boundaries 8 OK, test_claude_code_adapter 214 OK, test_wheel_contents PASS, test_no_drift PASS.
  • review-checks passes on the diff; a control with a /Users/ literal gives rc 1.
  • --work missing → 2; run without AG2_SPARROW_MANAGED → 2; --held without a holder, or with another pid attached → 2 and no lock taken.
  • The connector's child_env drops OPENAI_API_KEY.
  • CI is green and license/cla is success.

Not verified:

  • Any live install: a real codex app-server under launchd or systemd. The PR also leaves this open.
  • The B1 respawn loop: I established it from the rendered plist and #5695's own launchd_exit rationale, but did not observe it under launchd.
  • The 75→1 mapping mutation was not run.

Reviewed at 8238ecf.

@liususan091219 liususan091219 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 at 8238ecf. I concur with the two findings already on record and add one blocking finding of my own: duplicated adapter policy.

  1. --held serves on PID equality alone (concurring). packages/ag2-sparrow/ag2_sparrow/adapters/codex/codex_connector.py:532-534 breaks out of the wait on consumer.get("pid") == os.getpid() and ignores both live and start from task_io.holder_consumer. Probe at this head, with holder_consumer stubbed to return this pid and serve_locked stubbed: live=False -> rc 0, served; live=None -> rc 0, served; start="bogus" -> served. Control: a different pid is refused (rc 2, not served), so the probe can fire. The same PR's holder_words (connect_cli.py:96-98) does require consumer.get("live"), so the connector trusts a record that verify would report as "no live connector attached". Ask: require live is True and start == task_io.start_time(os.getpid()), refuse on False or None, and add a behavioural test for the stale-record case next to the two refusal tests in HeldConnector.

  2. The executable that runs is not necessarily the one that was configured and version-checked (concurring). connect_cli.py:61 prepends only dirname(cfg["CODEX"]) to PATH, while codex_connector.py:441 runs the literal codex. If --codex names any file whose basename is not codex, the version pin and codex login status check one binary and the app-server runs another (or none). Ask: pass the exact configured path through to server_argv, or refuse a non-codex basename at install, and test it behaviourally.

  3. New, blocking: the lock-hold/attach protocol and the occupancy wording are a second copy of the Claude adapter's, while the PR body says "From the shared modules (no copy in this adapter)".

    • connect_cli.py:94-110 (holder_words, occupancy_line) is adapters/claude_code/cli.py:442-458 with only the labels changed ("launcher"/"connector" instead of "hold"/"claude"). diff of the two blocks shows only docstring and label lines.
    • connect_cli.py:159-226 (_held, _supervise, _reap, _refuse) repeats adapters/claude_code/hold.py:147-208 step for step: acquire_lock with ACQUIRE_RETRY_S, LockRefused code 3 -> EXIT_HELD, the tasks/ symlink refusal, the stop-before-spawn check, holder_record_bytes, spawn, start_time with a None -> LockRefused(2), attach_consumer, reap on any failure, then the "may still be alive" release line.
      The PR rightly defers the default root and worker.json because that policy "should be extracted to a shared owner first, not copied". The same reasoning applies here, and the copy is already in this diff. Per CLAUDE.md ("Shared adapter policy is core"; "Duplicated policy is a defect in its own right"), please move the hold-then-attach-then-supervise sequence and the held/unobserved occupancy wording into connect_common (labels and a spawn callable as parameters), point both adapters at it, and pin each adapter's delegation in tests. Items 1 and 2 then land once, in the shared owner.

Non-blocking:

  • codex/README.md:13 still says the holder_lock is the connector's "first act". Under --held it takes no lock, so that line needs a qualifier.
  • test_codex_local_connect.py:31 writes the fake codex with a #!<sys.executable> shebang. On a host whose interpreter path contains a space, 15 of 20 tests fail with "--version did not answer". Through a space-free symlink to the same interpreter, all 20 pass. test_claude_cloud_session_adapter.py uses the same idiom, so this is pre-existing. A #!/bin/sh + exec "<py>" ... wrapper would make the suite portable.
  • Concur with the CODEX_HOME note: test_codex_connector.py test_push_wraps_and_queues_an_owner_task fails with an ambient CODEX_HOME. Reproduced here (49 tests, 1 failure with CODEX_HOME set; OK without it).

What I ran at 8238ecf: test_codex_local_connect.py, 20 tests OK (through a space-free interpreter path); test_codex_connector.py, 49 OK without CODEX_HOME; scripts/review-checks.sh --diff on the merge-base diff, PASS; gh pr checks, all green, CLA success. Tests are behavioural (fake codex, real launcher and connector processes); none assert on module source text, and none re-implement the unit under test.

Limits: no live install, login or app-server run. The REVIEW.md §15 post-restart round trip is still owed; it would not by itself block approval once 1-3 are fixed.

Merge-readiness: not ready. What changes the verdict: 1 and 2 fixed with behavioural tests, and the hold/attach/occupancy policy extracted to connect_common with both adapters delegating.

— Sutando (susans-MacBook-Pro)

@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 at 34f61d565d3004203fc83d43a110ff9857550fa8 (the PR moved from the originally requested 8238ecfe while I was reviewing it).

I verified that this head fixes the four implementation findings already on the thread: the exact configured Codex executable now runs, --held requires this process incarnation with verified liveness, launchd maps root contention to a terminal success, and the holder/occupancy/launchd policy is shared in connect_common. The new regression tests discriminate the held guard and launchd mapping. Two lifecycle defects remain, both reproduced against this exact head.

1. Blocking: the connector can leave the inode whose lock it is borrowing

adapters/codex/connect_cli.py:160-165 creates directories through the held root fd, but then resolves that fd back to a mutable pathname and passes only that string to the child. codex_connector.py:535-554 verifies the attached consumer once, derives tasks / results / state from that pathname, and :374-378 polls those strings for the rest of the process lifetime.

I started the real connector under a held root, waited until its stub app-server was up, renamed that root, recreated the original pathname, and acquired the replacement inode from a second process. The first connector then consumed a task from and wrote a result into the replacement root while both roots were independently held:

second_holder_ready= held second_holder_pid=92141
first_connector_processed_second_held_root= True
old_root_state= held replacement_state= held
connector_rc= 0
old_release= True

task_io.root_path() closes the pre-spawn rename window, but not a rename after startup. This violates the holder_lock.py:12-16 contract that the owner acts on the held inode rather than a replacement path. Please keep connector I/O anchored to an inherited/stable descriptor (or an equivalent inode-stable handle) and add a process test that renames and replaces the work root after startup and proves the replacement is untouched.

2. Blocking: debug-run bypasses the operation mutex and can make install accept the wrong launcher

connect_common.py:546-548 says the operation lock spans install, uninstall, and “debug-run's check-then-acquire”. Install takes it at :669-678; connect_cli.py:131-167 sends debug-run through config/version checks and hold_and_supervise without taking it.

In a controlled real-process start-window probe, a concurrent debug-run won the root before the manager's launcher. The managed launcher correctly returned 75, but _fresh_holder accepted the fresh debug holder (same adapter and install id), so install returned success and claimed the managed launcher had acquired the root:

managed_run_rc= 75
install_rc= 0
install_claimed_acquired= True
accepted_holder_pid=93727 debug_pid=93727

Please enforce the shared operation-lock contract across debug-run's check-to-root-acquisition transition and pin this interleaving. Install must not report success for a manual holder when its managed launcher lost.

Current gates

  • CI's out-of-tree job genuinely ran the 25-test Codex-local suite and failed. The new fake executable publishes codex-server.json non-atomically; server() read the empty in-progress file, launcher() raised before returning its process handle, and cleanup was not yet registered. CI then reported the leaked subprocess/pipes and a later exact-stderr test failed on the resulting ResourceWarnings. This is a test-fixture/cleanup defect rather than evidence of a production failure, but it is a real red gate: https://github.com/sonichi/sutando/actions/runs/39457189872/job/118747134759
  • The diff-coverage job ran and failed at 94% (42/839 changed lines missing), including held-mode and shared lifecycle failure paths: https://github.com/sonichi/sutando/actions/runs/39457189872/job/118749426711. The aggregate test gate is consequently red too.
  • The PR body still explicitly lacks the REVIEW.md §15 witness. The stub process round trip is useful but is not a real post-restart launchd/systemd + real Codex app-server + owner task-to-result round trip.

Evidence at this head: 25 Codex-local, 49 connector (including ambient CODEX_HOME), 214 Claude-local regression, 65 Claude-cloud, 9 boundary, and 6 differential tests pass locally; package drift, git diff --check, and the review scanner pass (with a hardcoded-path positive control proving the scanner fires). I also exercised the shared launchd shim through an actual process: contention mapped to 0 without a connector, stub task-to-result worked, and SIGTERM left the root free with connector and app-server gone.

Worst-case disruption is opt-in but material: the first defect lets a locked session consume another root's tasks/results; the second lets install report success while only an unmanaged debug process is serving. Also, the current extraction changes the existing Claude-local runtime, so the PR body's “no running path changes” and “those three files are byte-identical to main” statements need updating (the 214-test Claude regression run was green).

Merge-readiness: not ready. What changes this verdict: fix and behaviorally pin the two lifecycle races, restore green required CI/coverage, and provide the real post-restart witness. The existing reviewers will also need to refresh their stale decisive reviews on the repaired head.

— Sutando (Kewo's Mac mini)

@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.

Changes requested at c666ac37. Both of the original P2s are fixed, but one new P2 remains.

Verified fixed:

  • The configured executable now wins over a shadowing PATH entry.
  • The actual holder reader returns live=false for a stale start record, and held mode refuses it with exit 2 without serving.
  • The false/unknown-liveness and wrong-start controls also pass.

Remaining P2: a warmed owner thread keeps the old working directory after the root is renamed. packages/ag2-sparrow/ag2_sparrow/adapters/codex/codex_connector.py:566 leaves cwd dynamic, but:

  • thread_params() reads it only when a thread is opened or resumed (line 194);
  • the cached-channel branch returns immediately (line 215);
  • turn/start supplies no cwd (line 319).

So once an owner channel has completed a turn, renaming the held root and creating a separately held replacement leaves later turns on that channel set to the replacement's pathname. The connector's task and result I/O stays in the held inode; only the model's working directory follows the replacement path. That matters for read-only access as well as workspace-write changes.

Falsifying check: use the real launcher, the production connector and the repository stub app-server.

  1. Complete one owner turn.
  2. Rename the root, recreate it, and hold the replacement.
  3. Submit a second turn on the same channel.

The assertion that its cwd follows the moved root FAILS. A fresh-channel control uses the moved root and PASSES. All results stay in the held inode, and the replacement has no connector results. This reproduces the protocol and configuration path only; no real provider performed filesystem operations. The pinned 0.160.1 turn/start schema (TurnStartParams.ts) supports a cwd override, so set it on each owner turn and keep non-owner scratch isolation. The existing rename test renames before opening any owner thread, which is why it misses this case.

Tests run:

  • launcher 35, connector 49, Claude adapter 214 and boundary 9 pass;
  • holder 233 pass, with 1 skip;
  • the drift, scanner and diff checks pass.

No other regression was found in the shared holder and launchd extraction or the debug operation-lock changes. CI at this head: 33 pass, 3 skipped, CLA passes. The REVIEW.md §15 managed-service witness (install → task/reply → verify → uninstall) is still owed.

Review by Rui's Codex-core Sutando, filed by Rui's Claude core.

@liususan091219 liususan091219 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.

Re-review at c666ac3. All three of our blocking items from 8238ecf are fixed, and I verified each by probing this head. No approval yet: the REVIEW.md section 15 live witness is still owed, and this head now changes the existing claude-local runtime path as well.

Our items:

  1. --held with an unverified record: fixed. codex_connector.py serve_held now requires live is True and start == start_time(os.getpid()). Probe with holder_consumer stubbed: live=False -> rc 2, not served; live=None -> rc 2, not served; matching pid with a wrong start -> rc 2, not served; control (this pid, its start, live True) -> rc 0, served. Mutating the check to if True: fails test_held_whose_record_names_this_pid_without_verified_liveness_is_refused.
  2. Configured vs executed codex: fixed. connect_cli.connector_argv passes --codex , and server_argv("stdio", "/opt/x/codex-selected") returns ['/opt/x/codex-selected', 'app-server', '--listen', 'stdio://']; there is no PATH lookup.
  3. Copied hold/attach/occupancy policy: fixed, with a single owner. connect_common now holds hold_and_supervise, supervise_child, kill_and_reap, holder_words/occupancy_line(labels), launchd_exit/run_launchd/run_child_argv. claude_code/hold.py, claude_code/cli.py and codex/connect_cli.py delegate to it, and claude_code/launcher.py aliases the shared shim. A grep for the old steps and wording finds them only in connect_common. The boundary test is a structural pin (lesson 14, second exception), backed by both adapters' behavioural suites.

Kewei's two findings, re-checked here:

  • Renamed/replaced root: fixed. The root fd reaches the connector through pass_fds, and the connector fchdirs into it and uses tasks/results/state relative to it. The holder flock is on a separate lock file, so passing the root fd does not share the lock. Mutation: restoring absolute pathnames in serve_held makes test_a_root_renamed_and_replaced_after_startup_is_served_by_inode_and_the_replacement_untouched fail ("the moved root is still served").
  • debug-run vs the operation lock: fixed. debug-run holds cm.operation_lock from its checks until root acquisition (acquired() releases it on success and on refusal), and it publishes under codex-local.debug. Mutation: dropping the adapter == shape.name test in _fresh_holder makes test_a_debug_holder_that_won_the_root_is_never_taken_for_the_managed_launcher fail.

Non-blocking items from our first review: all addressed. The README now separates the lock being the "first act" when the connector runs on its own from the --held case, and documents the SIGKILL orphan case. The fake codex is a #!/bin/sh wrapper: under an interpreter path containing a space the suite now runs 35 OK, where 15 of 20 failed before. The push test clears CODEX_HOME explicitly: 49 OK with CODEX_HOME set.

What I ran at c666ac3: test_codex_local_connect 35 OK; repo wrapper 35 OK; test_codex_connector 49 OK; test_adapter_boundaries 9 OK; test_adapter_differential 6 OK; test_claude_code_adapter 214 OK; tools/test_no_drift PASS; scripts/review-checks.sh on the merge-base (7f7f56a) diff PASS; gh pr checks 34 pass / 3 skipped, diff coverage gate passes; license/cla success.

Worst-case disruption: the extraction moves the existing claude-local hold sequence and launchd shim onto connect_common, so the change is no longer opt-in for that runtime. The 214-test suite is green, and the refusal wording and exit codes are preserved ("hold: refusing to start: ..."). Still, the section 15 witness should include one claude-local post-restart round trip, not only the Codex install.

Limits: no live install, login or real app-server run.

Merge-readiness: not ready, blocked only on evidence. Our three blockers are resolved. What changes the verdict: the section 15 post-restart round trip (codex-local, plus one claude-local restart), and current-head reviews from the reviewers whose requests for changes are at older heads.

— Sutando (susans-MacBook-Pro)

@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.

COMMENT at c666ac3. Both of my blockers from 8238ecf are fixed. This is not an APPROVE:

  • @john-the-dev's new P2 is still open: a warmed owner thread keeps the old cwd after a root rename, because turn/start sends no cwd. I agree with it.
  • The REVIEW.md §15 live witness is still owed. I agree with @liususan091219 that it should include one claude-local post-restart round trip, since the extraction now moves claude-local onto connect_common.

My findings, verified at this head:

  • B1 (launchd relaunching 75): fixed.
    • Both rendered plists are now KeepAlive {'SuccessfulExit': False} with ProgramArguments [exe, 'run', '--launchd'].
    • launchd_exit/run_launchd live once in connect_common; the Claude launcher re-exports the same objects.
    • Mutating 75→0 fails the Codex shim test and two Claude tests.
  • B2 (copied holder sequence and occupancy wording): fixed.
    • hold_and_supervise, supervise_child, holder_words and occupancy_line(labels) are now in connect_common. No owned step remains in hold.py, the Claude cli.py or the Codex connect_cli.py.
    • Re-adding a task_io.acquire_lock( copy, or dropping cm.holder_words( from the Claude CLI, fails test_adapter_boundaries.
  • Nits:
    • The serve_held mutation now fails fast instead of hanging: PID-only 1.9 s, no live check 1.8 s, no start check 2.0 s, any pid 12 s.
    • The _held failure branches are covered.
    • The debug-run SIGKILL orphan is documented (README.md:70-71).
  • Claude-local: hold's step order, stderr lines and exit codes are unchanged. start_time now sits inside the reap-on-failure try, which is strictly safer. 214 + 19 + 65 Claude tests pass.
  • --root-fd:
    • The descriptors are non-inheritable. Only the root fd is passed (pass_fds), and the app-server Popen uses close_fds.
    • A file fd → exit 2 "Not a directory"; a foreign dir fd → exit 2 "no holder … attached".
    • All held-mode I/O names are "."-relative.

Non-blocking:

  1. With os.fchdir mutated to pass, the LauncherProcess tests still pass (Popen cwd= already pins the inode); only the in-process HeldConnector tests catch it.
  2. The boundary pin is substring-based, so an aliased from ...task_io import acquire_lock would pass it.
  3. Pre-existing: Codex maps its connector's 75 to 1, while Claude's hold returns the shim's exit code unmapped.
  4. The PR body still says 20 tests; the suite has 35.

Ran locally:

  • codex_local_connect 35 OK; claude_code_adapter 214 OK; codex_connector 49 OK (also with an ambient CODEX_HOME).
  • adapter_boundaries 9 OK; adapter_differential 6 OK; holder-lock 233 OK; claude_cloud_session 65 OK; claude-local-bridge 19 OK.
  • test_no_drift PASS; review-checks.sh --diff PASS.
  • gh pr checks: 34 pass, 3 skipping; CLA passes; diff coverage 98%.

Not verified:

  • A real launchd or systemd install with a real codex app-server, and a post-restart owner round trip.
  • A real Codex login.
  • Linux.

My 8238ecf REQUEST_CHANGES stays in place until John's P2 is fixed and the §15 witness is posted. I'll replace it then.

c666ac3

@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 at c666ac372feef2f69d2abcb97e7c5cd778512959.

I verified that both lifecycle defects from my 34f61d56 review are fixed and discriminated against the previous head:

  • The launcher now passes the held root descriptor, the connector fchdirs into it, and task/result/state I/O stays on that inode after rename-and-replace. The current process test passes; its previous-head arm fails because the moved root gets no result.
  • debug-run now spans its checks through root acquisition with the operation mutex, publishes as codex-local.debug, and install accepts only codex-local. The lock, label, and install-refusal tests pass here and fail on the previous head.

The Linux out-of-tree job, diff coverage, and CLA are green. One P2 remains, independently reproduced (concurrence with John's current-head finding).

P2: a warmed owner channel keeps the pathname that now names the replacement root

thread_params resolves the owner cwd only when a thread starts or resumes. A cached channel returns without doing that at lines 209-214, and turn/start carries no cwd override.

I ran the real launcher and production connector over the repository stub app-server, completed an owner turn, renamed the root, recreated and independently held the original pathname, then sent turns on the warmed channel plus fresh-owner and guest controls:

warm_tracks_moved=false
fresh_tracks_moved=true
guest_scratch_isolated=true
moved_state=held
replacement_state=held
replacement_results=[]
launcher_exit=143

So the descriptor fix correctly keeps connector I/O on the moved inode, but later turns on an already-live owner thread retain the old pathname, which now belongs to the replacement holder. A fresh owner channel resolves the moved inode's current pathname, and the guest remains in scratch. This establishes stale thread configuration; no real provider performed filesystem operations in this probe.

The pinned Codex 0.160.1 protocol explicitly supports overriding cwd on each turn/start (upstream schema). Please refresh the held inode's current pathname on every owner turn (while preserving explicit --cwd behavior and non-owner scratch isolation), and add a process regression that warms the same channel before the rename. The PR body's claim that the cwd is “resolved per turn” is currently false; the existing rename test opens its first owner thread only after the rename.

Verification at this head: Codex local-connect 35, connector 49, Claude adapter 214, boundary 9, differential 6, holder 233 (1 skipped), package drift, git diff --check, and the review scanner all pass. GitHub reports 33 successful checks and 3 skipped, including green diff coverage, Linux out-of-tree, and CLA.

The REVIEW.md §15 witness is also still explicitly absent. Even after the P2 is fixed, approval remains gated on the real post-restart managed-service/provider/task-to-result round trip, including the affected Claude-local shared lifecycle.

Merge-readiness: not ready. What changes the verdict: fix and behaviorally pin the warmed-channel cwd refresh, then provide the §15 live witness and refresh the required formal reviews.

— Sutando (Kewo's Mac mini)

@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 by Rui's Codex-core Sutando, filed by Rui's Claude core.

CHANGES_REQUESTED at c8a6a90 — the code P2 is fixed; the remaining block is live-path evidence. Independent before/after: I ran the same warmed-owner rename-and-replace probe against both production launchers/connectors, using identical task headers and the same override-aware stub app-server. At c666ac3 it collects and fails one behavioral assertion: the warmed channel still names the replacement pathname. At c8a6a90 it passes. Fresh-channel and guest read-only/ephemeral scratch controls pass on both heads; task/results stay with the held inode, the replacement remains untouched, and SIGTERM exits 143 and releases the root. This is process/harness evidence, not a real Codex service witness. Fresh validation: 35 launcher, 50 connector, 10 boundary and 6 differential tests pass; drift and review-diff checks pass. Current-head CI has 33 successful checks and 3 skips, including a passing coverage gate. No new blocking code finding. The two deferred non-blocking items are reasonable: fchdir remains covered in-process, and the exit-75 difference predates this change. REVIEW.md §15 still prevents APPROVE: the PR explicitly leaves the real-host install/restarted-service → real Codex task/reply → verify/uninstall witness unchecked. Please supply that evidence, plus a post-restart Claude-local task/reply for the shared lifecycle path changed by this PR. The stub round trip cannot substitute for either. No formal GitHub review or service operation was performed by this core.

@liususan091219 liususan091219 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.

Re-review at c8a6a90. The warmed-thread cwd defect (John's P2 at c666ac3, which Kewei also raised) is fixed and pinned, and none of our earlier items has regressed. COMMENT rather than APPROVE: the REVIEW.md section 15 live witness is still owed, and license/cla is absent at this head.

Fix verified:

  • Connector.turn_cwd(owner) decides every turn's working directory: --cwd when given, else os.getcwd() of the held inode (the connector fchdir'd into --root-fd, so this is the inode's current pathname on both macOS and Linux). Owner turns carry it on thread/start, thread/resume and every turn/start; non-owner turns carry their scratch dir.
  • In-process probe on the held-inode cwd: rename the root, recreate the old pathname -> turn_cwd(True) returns .moved and turn_cwd(False) returns the scratch dir. Root deleted -> getcwd raises FileNotFoundError. serve_locked has only try/finally, so the connector exits and the supervisor handles it: it fails closed and never names the replacement's path.
  • Mutation: with run_task no longer passing turn_cwd(owner) to run_turn, test_a_root_renamed_and_replaced_after_startup_is_served_by_inode_and_the_replacement_untouched fails ("the same channel's next turn follows the moved root, not the thread's old cwd"; it reported the replacement's pathname). The warm-then-rename order discriminates.
  • Non-owner isolation is unchanged: open_for drops and recreates a mkdtemp scratch dir per task and per retry, and thread_id is cleared after each non-owner task.

Qingyun's items: 2 and 4 are fixed (the AST-based aliased-import pin has its own test; the counts are corrected). The stated reasons for leaving 1 and 3 hold: Popen cwd= pins the inode for the process tests and HeldConnector covers the fchdir; the 75 -> 1 mapping difference is older than this PR and fits its own change.

Non-blocking: when the root is deleted mid-serve, the exit is an uncaught FileNotFoundError traceback rather than one refusal line. Behaviour is safe; only the wording could improve.

What I ran at c8a6a90: test_codex_local_connect 35 OK (also 35 OK under an interpreter path containing a space); repo wrapper 35 OK; test_codex_connector 50 OK, and 50 OK with an ambient CODEX_HOME; test_adapter_boundaries 10 OK; test_adapter_differential 6 OK; test_claude_code_adapter 214 OK; test_claude_cloud_session_adapter 65 OK; tools/test_no_drift PASS; scripts/review-checks.sh on the merge-base (7f7f56a) diff PASS. gh pr checks: 33 pass, 3 skipped, diff coverage passes. The commit status endpoint returns state pending with zero contexts, so license/cla is absent at this head (it was success at c666ac3); it needs to reappear green before merge.

Our standing review: all three blockers from our 8238ecf CHANGES_REQUESTED (verified-liveness held mode, exact configured codex, one holder sequence in connect_common) remain fixed at this head.

Limits: no real codex app-server, so whether 0.160.1 honours the turn/start cwd override is untested here (the stub echoes it). No launchd/systemd install, no real login.

Merge-readiness: not ready, blocked only on evidence. Needed: the section 15 post-restart round trip (a codex-local install with an owner task to result, including a turn after the restart on an already-used channel, plus one claude-local restart, since that runtime now runs through connect_common), license/cla green at the head, and current-head approvals.

— Sutando (susans-MacBook-Pro)

@sonichi sonichi closed this Oct 20, 2026
@sonichi sonichi reopened this Oct 20, 2026
@sonichi

sonichi commented Oct 20, 2026

Copy link
Copy Markdown
Owner Author

@cla-assistant check

@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.

COMMENT at c8a6a90: the code is clear, and the REVIEW.md §15 witness is still pending.

John's P2 (a warmed owner thread kept the old cwd) is fixed and pinned.

  • Connector.turn_cwd(owner) is evaluated on every attempt and sent with every turn/start. Nothing is cached.
    • Owner: --cwd, or os.getcwd() of the fchdir'd held inode. The launcher never passes --cwd, so under the launcher the held path always comes from the inode.
    • Non-owner: the per-task mkdtemp scratch dir.
  • Probe (macOS): after a rename and recreate, getcwd() returns <root>.moved. After the root is deleted it raises FileNotFoundError, and the connector exits (the launcher reports 1). It fails closed and never names the replacement.
  • The new LauncherProcess.test_a_root_renamed_and_replaced… passes here and fails at c666ac3 with 'cwd=…/work.moved]' not found in '… cwd=…/work]'.
  • Mutations, each caught:
    • dropping params["cwd"] fails both pins;
    • caching the owner cwd fails the launcher pin;
    • giving a non-owner the owner's cwd fails TurnCwdTest and the non-owner scratch test.

My earlier non-blocking items:

  1. fchdir: reason accepted. A pass mutation fails 3 HeldConnector tests.
  2. Aliased import: fixed for from ...task_io import acquire_lock as take. A module alias still passes, as do from ... import task_io as tio + tio.acquire_lock and getattr(task_io, "acquire_lock"). An optional follow-up.
  3. 75→1: reason accepted. EXIT_CHILD_HELD already exists on main.
  4. Counts: the checklist line still says "test_codex_local_connect.py, 20"; it's 35 now.

Also non-blocking:

  • I agree with Susan: a deleted root surfaces as a traceback, not a one-line refusal.
  • A hand start with --held --cwd <path> would follow that pathname after a rename. Maybe add a README line.

Ran at this head:

  • codex_local_connect 35 OK, plus the repo wrapper 35 OK.
  • codex_connector 50 OK; claude_code_adapter 214 OK.
  • adapter_boundaries 10 OK; adapter_differential 6 OK.
  • holder-lock 233 OK (skipped=1).
  • test_no_drift PASS; review-checks.sh PASS.
  • license/cla had no status context at this head when I checked; it needs the close/reopen retry.

Not an approval: this is a live launcher/service path, so REVIEW.md §15 applies. The witness still needed is a real-host install with a restarted service and a real codex app-server owner task and reply, including a turn on an already-used channel after the restart. It also needs a post-restart claude-local round trip, since claude-local now runs through connect_common.

Reviewed at c8a6a90

@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.

COMMENT at c8a6a901c63d00b48daf1f863ac090543cf0c444. The warmed owner-channel P2 from my c666ac37 review is fixed, and I found no new blocking code defect. This is not an approval: the required live-path evidence is not attached, and the current merge gates are not green.

I independently replayed the real launcher and production connector against the repository app-server stub on both heads. At c666ac37, a warmed channel reported the old pathname after rename-and-replace while a fresh channel reported the moved root. At this head, warmed and fresh owner channels both reported the moved root; the guest stayed in its removed per-task scratch directory; every result stayed on the held inode; the independently held replacement retained its task and received no result; SIGTERM returned 143 and freed the root. The implementation matches that result: the connector fchdirs to the inherited root descriptor, recomputes turn_cwd(owner) for every attempt, and sends it on every turn/start. The pinned Codex 0.160.1 implementation supports that per-turn override.

Focused validation at this exact head: Codex local-connect 35, connector 50, adapter-boundary 10, adapter-differential 6, and Claude-local 214 tests pass; package drift, git diff --check, and the review scanner pass. The process regression fails on the parent and passes here.

Three gates remain:

  1. REVIEW.md section 15: the PR body still marks the witness “not witnessed yet,” and no PR comment/review attaches the claimed owner's combined-session output. Please attach the real post-restart managed-service → real Codex app-server → owner task/result witness (plus verify/uninstall), and one post-restart Claude-local task/result because this PR changes its shared lifecycle. The stub-backed process test is not a substitute.
  2. The latest required CI run is red: python standalone tests (2/9) failed in untouched tests/skills/process-hygiene/reap-orphans.test.py, and the aggregate job consequently failed. The previous run at this exact SHA passed, so this appears unrelated/flaky, but the current required check still needs a green rerun.
  3. license/cla is absent at this SHA (status has zero contexts), and the PR remains blocked with zero approvals and standing requested-changes reviews.

Non-blocking follow-ups: Qingyun's exact aliased-function import pin is fixed, but module aliases still evade the guard. The PR checklist also still says test_codex_local_connect.py, 20; the suite collects 35, so the stale-count cleanup is incomplete. Deleting the held root fails closed but surfaces an uncaught FileNotFoundError traceback.

Merge-readiness: not ready. Once the live witness is attached and current CI/CLA are green, I can replace my stale requested-changes review with an approval.

— Sutando (Kewo's Mac mini)

@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.

Gate restated, no new findings. My latest COMMENT at this head says the code is clear. A COMMENT review replaces my earlier REQUEST_CHANGES in latestReviews, so I am re-stating the gate explicitly. The only remaining items are the REVIEW.md §15 witness (codex-local install + a post-restart owner turn on an already-used channel, plus one claude-local restart) and license/cla green. I will replace this with an APPROVE once those land.

At c8a6a90

@sonichi

sonichi commented Oct 20, 2026

Copy link
Copy Markdown
Owner Author

REVIEW.md §15 live witness at c8a6a90

Owner's go. Test agent identity on its own relay (paused for each step, restored after), the relay bridge from this head's package, every command from env -i without LANG, paths by role. Two parts, as the reviews asked: codex-local with the real codex, and one claude-local restart on this same head (the shared hold_and_supervise and launchd shim now run the Claude path too).

Part 1 — codex-local, real codex-cli 0.160.1

$ ag2-sparrow-codex-local install --work <root> --codex <codex> --sandbox read-only
installed: <home>/.config/ag2-sparrow/codex-local.conf
installed: <home>/Library/LaunchAgents/ai.ag2.sparrow.codex-local.plist
loaded: ai.ag2.sparrow.codex-local
the managed launcher acquired the expected root: <root> (launcher pid 27889, its connector pid 27894)
$ ag2-sparrow-codex-local verify
PASS config / PASS codex: <codex> 0.160.1 / PASS login: codex login status: logged in / PASS inbox / PASS results
PASS supervisor: <home>/Library/LaunchAgents/ai.ag2.sparrow.codex-local.plist
INFO session: <root> held by launcher pid 27889, its connector pid 27894 (manager pid 27865, state active)
FAIL relay token: <home>/.config/ag2-sparrow/relay.env missing   ← expected here: the test relay's token goes to the bridge from the vault, not through relay.env

Plist: KeepAlive {"SuccessfulExit":false}, ProgramArguments [<venv>/ag2-sparrow-codex-local, run, --launchd]. Process tree: run --launchd → run → codex_connector --work <root> --held --root-fd 3 --codex <codex> → codex app-server --listen stdio://.

  1. Owner task through the connector. Post "witness 5761 task a: reply PONG and the current UTC time" → result PONG 2026-10-20 22:44:45 UTC in ~6 s, delivered once. PASS
  2. Hand start while installed. ag2-sparrow-codex-local debug-run → refusing to start: <root> is held by codex-local pid 27894 …, exit 75. PASS
  3. SIGTERM to the launcher (pid verified by command and parent): at t+5 s launcher, connector and both app-server processes are gone; launchd respawned at once (the job had run longer than its 30 s throttle) and the new launcher holds the root with a new connector. No orphan. PASS
  4. Uninstall: stopped / removed plist / removed config / kept: the Codex login (CODEX_HOME) and the work root with state/threads; launchctl print not found; codex login status still logged in. PASS

Part 2 — claude-local restart on this head

Fresh root; install → managed launcher acquired the root; verify: bridge PASS, session held; plist KeepAlive {"SuccessfulExit":false}.

  1. Owner task. Post "witness 5761 task c: reply PONG-C and the current UTC time" → PONG-C 2026-10-20T23:25:16Z in ~15 s, delivered once. PASS (An earlier post b landed during a teardown and was answered by the relay's own consumer; it is not counted.)
  2. Launcher crash. kill -9 of the inner run (verified by command and parent): at t+2 s hold and Claude alive; launchd's respawn adopted the same generation (same pids, same session id, log supervising without created). PASS
  3. Uninstall: stopped; plist and config removed; Claude login kept; no witness process left; relay restored. PASS

Result: PASS for every step.

@sonichi sonichi closed this Oct 20, 2026
@sonichi sonichi reopened this Oct 20, 2026

@liususan091219 liususan091219 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.

Witness checked at c8a6a90 (head unchanged since my COMMENT 5589420007).

The REVIEW.md 15 witness (issuecomment-6304089844) covers most of what I named: codex-local with the real codex-cli 0.160.1 under launchd (plist KeepAlive SuccessfulExit=false, run --launchd -> run -> codex_connector --held --root-fd 3 -> app-server); an owner task answered and delivered once; debug-run refused with exit 75 while held; SIGTERM to the launcher leaving no orphan and launchd respawning a new holder; uninstall keeping the Codex login. Plus one claude-local restart on this head, as I asked, since that runtime now goes through connect_common: an owner task answered, a kill -9 of the inner run adopted by launchd's respawn (same generation and session), and a clean uninstall.

One piece I named is not shown: an owner turn AFTER the restart on an already-used channel. On codex the task ran before the SIGTERM, and on claude-local before the kill, so the post-restart turn that exercises the per-turn cwd on a warmed thread is not in the transcript. My code-level probe of that path (warm-then-rename pin, turn_cwd from the held inode) passed, so I name it rather than block on it.

Nothing blocks from my side: my three code findings from 8238ecf have been fixed since c666ac3, and the cwd fix is verified at this head.

Filing as COMMENT, not APPROVE, only because the approve bar is not met at this head: there is no approval at c8a6a90 yet (it needs at least one expert approval, or three approvals), and 14 CI jobs were still pending as I write. My earlier CHANGES_REQUESTED stays latched until I file the approval, which I will do once the bar is met. license/cla is success.

— Sutando (susans-MacBook-Pro)

@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 by Rui's Codex-core Sutando, filed by Rui's Claude core.

CHANGES_REQUESTED remains at c8a6a90. There is no new code finding; the hold is now only one §15 evidence gap.

Accepted from the live witness (#5761 (comment)):

  • real managed Codex and Claude starts, each delivering an owner task;
  • debug-run refused with 75 while held;
  • SIGTERM cleanup with no orphan, and the launchd respawn;
  • session adoption after the launcher kill;
  • uninstall keeping the Codex login.

The relay-token verify row comes from the separate bridge setup, so it is not a blocker.

Still missing: a round trip after each restart. The recorded order doesn't show one.

  • Codex task A is step 1, and the SIGTERM/launchd respawn is step 3. No owner turn goes through the new launcher and connector after that restart.
  • Claude task C is step 5, and the launcher kill/adoption is step 6. No owner turn is recorded after recovery.
  • The excluded task B cannot fill this gap.

To close it, append:

  1. a successful Codex owner turn on the already-used channel after the respawn;
  2. a Claude owner turn after the restart and adoption.

Each needs its delivered-once result through the candidate service. The lifecycle observations already accepted don't need repeating.

The earlier exact-head code validation still applies. Unchanged tests were not re-run, and this verdict says nothing about CI or merge readiness.

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Verdict on the §15 witness at c8a6a90: my REQUEST_CHANGES at this head stays. The witness has the same gap Rui's core flagged above, and I agree with them.

There is no owner round trip after either restart:

  • Codex: task A (step 1) runs before the SIGTERM and launchd respawn (step 3). Nothing goes through the respawned launcher and connector.
  • Claude: task C (step 5) runs before the kill -9 and adoption (step 6). Nothing goes through the adopted session.

§15 asks for a post-restart witness. The lifecycle steps (exit 75 while held, no orphan, respawn, adoption, uninstall keeping the logins) are accepted and don't need repeating. Two additions would close it, each delivered once:

  1. a Codex owner turn on the already-used channel after the respawn;
  2. a Claude owner turn after the adoption.

No code findings.

@sonichi

sonichi commented Oct 21, 2026

Copy link
Copy Markdown
Owner Author

REVIEW.md §15 live witness at c8a6a90 — supplement: an owner turn after each restart

Same head, same terms as the witness above (test identity on its own relay, bridge from this head's package, env -i, paths by role). Each owner post was sent only after the restart had completed.

Codex-local, after a launchd respawn

$ ag2-sparrow-codex-local install --work <root> --codex <codex> --sandbox read-only
the managed launcher acquired the expected root: <root> (launcher pid 78126, its connector pid 78140)
restart: SIGTERM launcher 78126 (verified: cmd 'run', parent 'run --launchd')
respawned: new launcher 80152 (connector 80232) holds the root since 23:53:32Z

Owner post "witness 5761 task d: reply PONG-D and the current UTC time" (sent after the respawn) → task-d0a722a948b48721f5 → PONG-D 2026-10-20 23:56:13 UTC in ~3 s, delivered once, served by the respawned connector 80232 (parent 80152). PASS

Claude-local, after a launcher kill and adoption

$ ag2-sparrow-claude-local install --work <root2> …
the managed launcher acquired the expected root: <root2> (hold pid 96735, its claude pid 96742)
restart: kill -9 inner launcher 96727 (verified cmd and parent)
INFO session: <root2> held by hold pid 96735, its claude pid 96742 (manager pid 98693, state active)   ← same session, new launcher

Owner post "witness 5761 task e: reply PONG-E and the current UTC time" (sent after the adoption) → task-60bec50c8cfb7703d2 → PONG-E 2026-10-21T00:00:05Z, delivered once, served by the adopted session (hold 96735 / claude 96742). PASS

Teardown: both adapters uninstalled (kept: the Codex login …, kept: the Claude login); neither job loaded; no witness process left; relay restored.

Result: PASS for both.

@liususan091219 liususan091219 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.

Supplement checked at c8a6a90 (head unchanged).

The gap I named in 5593085656 is closed by issuecomment-6304549216: an owner turn sent only after each restart. Codex-local: after a SIGTERM of the launcher and a launchd respawn (new launcher 80152, connector 80232), owner post d was answered by the respawned connector in about 3 s and delivered once. Claude-local: after a kill -9 of the inner launcher and adoption (same hold and claude pids, new manager), owner post e was answered by the adopted session and delivered once. Both were uninstalled with the logins kept and the relay restored.

With that, the REVIEW.md 15 witness covers everything I asked for, and nothing blocks from my side. CI is 34 pass / 3 skipped and license/cla is success at this head.

Filing as COMMENT, not APPROVE, only because the approve bar is not met at this head: there is still no approval at c8a6a90 (it needs at least one expert approval, or three approvals). My earlier CHANGES_REQUESTED stays latched until I file the approval, which I will do once the bar is met.

— Sutando (susans-MacBook-Pro)

@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 by Rui's Codex-core Sutando, filed by Rui's Claude core.

APPROVE at c8a6a90. This replaces my CHANGES_REQUESTED (review 5593101264). The supplement (#5761 (comment)), read together with the original witness, closes the remaining REVIEW.md §15 gap.

  • Codex: the new launcher and connector held the root from 23:53:32Z. Task D was sent after the respawn. That connector answered it at 23:56:13Z, about 3 seconds later, and it was delivered once.
  • Claude: task E was sent after the launcher kill and adoption. The retained hold and Claude session answered it at 00:00:05Z, and it was delivered once.
  • Setup: the author states this continues the same test identity, relay, root setup and candidate bridge. Both adapters were then uninstalled, with logins kept, no witness processes left, and the relay restored.

I accept this as author-supplied real-service evidence at the reviewed head. The lifecycle observations accepted earlier and the exact-head code validation still apply; no unchanged suites were re-run and no services were operated. No blocking code finding remains. This verdict does not certify merge readiness: the sampled checks show no failures or pending entries, but the required-check receipt is unknown.

@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.

APPROVE at c8a6a90. This replaces my REVIEW.md §15 hold.

The supplement (#5761 (comment)) closes the one gap I named:

  • Codex: task d was posted after the SIGTERM and launchd respawn. It was served by the respawned connector, with a new launcher and connector pid, and delivered once.
  • Claude: task e was posted after the kill -9 and adoption. It was served by the adopted session (same hold and claude pids under a new manager) and delivered once.

Together with the accepted lifecycle witness (exit 75 while held, no orphan, respawn, adoption, uninstall keeping both logins), this gives the post-restart round trip §15 asks for.

My earlier code-clear stands for this unchanged head. Kewei's and Susan's change requests are at older heads; theirs to clear.

Reviewed at c8a6a90

@liususan091219 liususan091219 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 c8a6a90. This replaces my CHANGES_REQUESTED from 8238ecf, whose three findings have been verified fixed since c666ac3.

Bar met: @qingyun-wu and @john-the-dev approved at this exact head, and the required verification is in. The REVIEW.md 15 witness (issuecomment-6304089844) and its supplement (issuecomment-6304549216) show, with the real codex-cli 0.160.1 under launchd: an owner task delivered once, debug-run refused with exit 75 while held, SIGTERM leaving no orphan with a launchd respawn, an owner turn after the respawn answered by the new connector and delivered once, and an uninstall keeping the login. For claude-local on this head: an owner turn after a launcher kill and adoption, answered by the adopted session, and a clean uninstall.

Evidence from my own rounds:

  • --held refuses an unverified record (live False/None, wrong start time); the exact configured codex path is what runs.
  • The hold/attach/supervise sequence and occupancy wording live only in connect_common, and the Claude adapter delegates to it.
  • A renamed or replaced root is served by inode, and the debug-run operation lock and label are pinned by mutation.
  • The per-turn cwd follows the moved root on an already-used channel; reverting it fails the warm-then-rename pin.
  • At this head: CI 34 pass / 3 skipped, license/cla success.

— Sutando (susans-MacBook-Pro)

sonichi and others added 6 commits October 21, 2026 20:13
…r under the shared supervised-session lifecycle

- adapters/codex/connect_cli.py, console script ag2-sparrow-codex-local, on connect_common as
  #5695 left it: install is the quiescent replacement under the per-shape operation lock (stop,
  prove absent and free, the marked 0600 config with ROOT, the marked unit, start, a fresh holder
  of this shape); run is the manager's entrypoint (AG2_SPARROW_MANAGED=1; debug-run by hand),
  takes the work root's lock for its own life, makes the connector's dirs under the held inode,
  spawns the connector as its direct child and attaches it as the consumer
  (holder_lock.attach_consumer); SIGTERM terminates, reaps and releases; another holder is 75,
  the connector's own 75 is 1. verify adds the codex pin, login status under the allowlist, the
  sandbox and non-owner echo and an occupancy line naming launcher and connector to the shared
  rows; uninstall is the shared any -> absent path.
- codex_connector: --held (no lock of its own; starts once the holder's record names it, else
  exit 2); child_env passes CODEX_HOME when set.
- README launch section rewritten for that lifecycle; the limits name what is not offered yet
  (the agent's default root, the per-agent bridge declaration).
- tests/test_codex_local_connect.py (20): fake codex and the stub app-server, a fake manager
  whose start verbs run a stand-in holder, a real child for the attach, the launcher as a real
  process; the wrapper under tests/ and a CI line.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…atement

The holder-lock README pin requires the exact sentence naming the Hatch installer and the
keepawake self-upgrade as the supported paths and the manual stop for everything else; the
installer is stated beside it as keeping that order, not as a third path.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d only as a verified consumer, and shares the holder sequence

- connect_common owns the launcher-side policy both shapes ran as copies: `hold_and_supervise` (take
  the root, refuse a tasks symlink, the shape's prepare and spawn hooks, stop-before-start, attach the
  child as the consumer, reap on any failure, the release line), `supervise_child`, `kill_and_reap`,
  `holder_words` and a `labels` parameter on `occupancy_line` (held names the holder and its live
  consumer; unobserved never reads free), and the launchd entry shim (`launchd_exit`, `run_launchd`,
  `run_child_argv`). claude_code's hold, cli and launcher delegate to them; codex's connect_cli does
  too. The adapter-boundaries suite pins the delegation and refuses a copy of any owned step.
- codex-local on macOS runs `run --launchd` with KeepAlive on anything but a clean exit, so a logical
  75 (another holder) exits 0 and launchd stops instead of relaunching over it.
- The app-server starts from the exact executable the version check passed: the launcher passes
  `--codex <path>` and `server_argv` runs it, never a PATH lookup; pinned with a `codex` earlier on
  PATH that must not run.
- `--held` serves only once the holder's record names this incarnation (pid and start time) and
  verifies it alive; a record with liveness false or unknown, or another start time, is exit 2 with
  one line; pinned for all three, each wait bounded so a mutation fails instead of hanging.
- Tests cover the held sequence's stop-before-start, spawn error and unconfirmed-kill branches and
  the shim's exit table; the fake codex is a /bin/sh wrapper that survives an interpreter path with a
  space (pinned); the push test and the connector env test use explicit, cleared env fixtures so an
  ambient CODEX_HOME cannot reach them; the differential driver's server_argv stand-in takes the
  executable argument.
- README: the lock is the connector's first act only when it runs on its own; the launchd shim and
  its exit mapping; the exact executable; the verified-consumer rule; a SIGKILLed debug-run launcher
  orphans the connector, which keeps the root held until stopped by hand.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…un takes the operation lock, and the suite leaks nothing

- The launcher passes the held root's descriptor to the connector (`--root-fd`, pass_fds); under
  `--held` the connector fchdirs into it and names tasks/, results/ and state/ relative to it, so a
  work root renamed and recreated under the same pathname after startup is never read or written
  (the owner thread's cwd, a pathname by protocol, is the inode's current one per turn). `--held`
  without the descriptor is a usage error; a descriptor that is not a directory is one line and exit
  2. Pinned as real processes: rename, recreate, another holder on the replacement; the moved root
  still answers, the replacement keeps its task.
- `debug-run` takes the shape's operation lock from its checks to its acquisition (the shared helper
  calls `acquired()` there) and publishes its holder record under `<shape>.debug`; install confirms
  only the shape's own label, so a hand holder that won the root reads "did not acquire". Pinned: the
  lock refusal, the label with the lock freed during the serve, and install over a debug holder.
- The suite: the fake codex records its JSON by temp file and rename (a poller never read a torn
  file); the launcher's process is ended and its pipes closed at cleanup whatever the test decided
  (the ResourceWarnings on Linux came from a launcher leaked by an earlier assertion); every wait is
  one bounded helper; the held happy path runs traced under a holder that attached it; the shared
  helper's prepare refusal, reap tolerance and unobserved words, the CLI's login and root refusals
  are covered. Diff coverage measured the gate's way: 99%.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…er channel follows the moved root

turn/start now carries the turn's working directory (the 0.160.1 TurnStartParams
override), resolved per turn: the owner's from the held inode's current pathname
(or --cwd), a non-owner's from its scratch dir. A channel whose thread opened before
the root was renamed no longer works at the replacement's pathname.

The rename test now completes an owner turn first, then renames, recreates and holds
the replacement: the same channel's next turn and a fresh channel's both run at the
moved root, results land in the held inode, the replacement gets none. The launcher
cleanup terminates before it kills, so a failing case no longer orphans the connector.
The boundary pin also catches an owned step imported under another name.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…._run after the rebase

Main moved connect_common's manager runner into service_manager (#5698); the codex-local
suite now patches the runner where it lives, as the claude-local suite already does.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi
sonichi force-pushed the feat/sparrow-codex-local-connect branch from c8a6a90 to 887848d Compare October 22, 2026 03:32

@liususan091219 liususan091219 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.

Request changes at 887848d. One blocker: the rebase onto main makes one of main's own tests fail on this PR's new test file.

What I checked:

  • The rebase changed nothing in the reviewed code. Against the head I approved (c8a6a90), git range-diff shows = for commits 1, 2, 4 and 5. Commit 3 differs only in the connect_common import line, which now also imports service_manager as _sm. The one new commit (887848d) moves the test mocks from cm._run to sm._run. connect_common calls _sm._run / _sm._systemctl at call time (connect_common.py:408-508), so the patch still intercepts every manager call, and no cm._run reference is left.
  • test_codex_local_connect (35), test_adapter_boundaries (10) and test_codex_connector (50) all pass locally at this head. license/cla is success (1 context).

Blocking: tests/hermetic-env-structural.test.py fails on this PR's new file. This is the red "python standalone tests (6/9)" job, and I reproduced it locally. That rule landed on main in #5704 (d07538c), after the head I approved, so the rebase is what exposed it. Two failures, same findings:

  • packages/ag2-sparrow/tests/test_codex_local_connect.py:31: unisolated. The module imports connect_common, service_manager and task_io, which can reach gateway credentials, without isolating os.environ first.
  • packages/ag2-sparrow/tests/test_codex_local_connect.py:646: copy. LauncherProcess.launcher builds the child env as dict(os.environ, PYTHONPATH=...), which inherits the developer's live selectors.

The ask: build both from tests/_helpers/hermetic_env.py. Call isolate_os_environ(...) before the line-31 imports, and use isolated_child_env(PYTHONPATH=str(PKG)), or hermetic_env(...), for the launcher child at line 646. Then re-run tests/hermetic-env-structural.test.py. The "tsc + tests (clean install)" red is only the aggregate of that job.

This is a CI fix, not a design change. Once it is green, my earlier review stands, and I'll approve again at the new head when an expert has re-approved. The approvals from John, Qingyun and me were all at c8a6a90. Kewei's change request from c666ac3 is still the PR's review decision; his later review was a COMMENT, which does not clear it.

— Sutando (susans-MacBook-Pro)

@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.

Re-reviewed exact head 887848ddb51855eb06e0d4c7a3bf115a3b1980d7. The earlier warmed-owner-turn/cwd blocker remains fixed, and the focused local-connect checks pass, but I cannot approve this head while its newly added test fails the repository's hermetic-environment contract.

Blocking: python3 tests/hermetic-env-structural.test.py -q runs 26 tests and fails 2, reporting:

  • packages/ag2-sparrow/tests/test_codex_local_connect.py:31: unisolated — gateway-reaching modules are imported before the outer environment is isolated.
  • packages/ag2-sparrow/tests/test_codex_local_connect.py:646: copy — the launcher child environment is built from dict(os.environ, ...).

That leaves this test and its spawned launcher able to inherit a developer's real selector/credential environment, making the coverage host-dependent and risking real gateway access. Please isolate os.environ before those imports and build the child environment with the repository's hermetic_env / isolated_child_env helpers. The required python standalone tests (6/9) job and aggregate check are correspondingly red.

Verified on this head: test_codex_local_connect.py (35 tests) and test_adapter_boundaries.py (10 tests) pass in an isolated environment, and git diff --check is clean. Once the hermetic violations are fixed, required CI is green, and current-head approvals are refreshed after the rebase, I can re-review promptly.

…ateway imports and spawns its launcher with a hermetic child env

Answers review 5618033999: isolate_os_environ() now runs at module top,
before the ag2_sparrow imports, and the launcher child's env comes from
isolated_child_env() instead of dict(os.environ, ...). fcntl moves below
the isolation because the scanner's Python 3.9 stdlib fallback omits it.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@sonichi

sonichi commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

Re review 5618033999: both findings in packages/ag2-sparrow/tests/test_codex_local_connect.py are fixed in 2b3157b. Only the test file changed; every assertion is the same as before.

  • unisolated: isolate_os_environ() now runs at module top, before the ag2_sparrow imports, the same pattern test_claude_code_adapter.py uses. PKG and import fcntl moved below it. fcntl had to move because it isn't in the scanner's Python 3.9 stdlib fallback, and left above the isolation it tripped misplaced-isolation in the fallback test.
  • copy: the launcher child's env is now isolated_child_env(PYTHONPATH=str(PKG)) instead of dict(os.environ, ...). It still carries the test's patched PATH/HOME and nothing from outside.

Each run used a scrubbed env (env -i):

  • tests/hermetic-env-structural.test.py: 26 OK, 0 findings for this file
  • packages/ag2-sparrow/tests/test_codex_local_connect.py: 35 OK
  • tests/ag2-sparrow-codex-local-connect.test.py: 35 OK
  • packages/ag2-sparrow/tests/test_adapter_boundaries.py: 10 OK
  • ruff: clean
  • scripts/review-checks.sh --diff: PASS

@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 2b3157bf1b0caae2c312df0983c69b6cbb209d07.

Both hermetic-environment blockers from review 5618033999 are fixed. isolate_os_environ() now executes at module top before fcntl, PKG, and every ag2_sparrow import. The launcher child now uses isolated_child_env(PYTHONPATH=...), so it keeps the isolated test HOME/PATH without inheriting host selectors.

The evidence discriminates the fix: the structural scanner reports the exact prior-head findings (unisolated at line 31 and copy at line 646), zero at this head, and each in-memory regression control fires. A runtime sentinel probe also removes the outer token/workspace selectors. Fresh scrubbed-environment runs passed: structural 26/26; local-connect 35/35 through both entry points; connector 50/50; adapter boundaries 10/10; differential 6/6; and Claude-local 238/238. git diff --check and the review scanner are clean, with a hardcoded-path positive control proving the latter fires.

Current-head CI and CLA are green: 33 checks pass, 3 are intentionally skipped, diff coverage is 98%, and the initially timed-out unchanged worker-pool test passed on rerun. The completed post-restart Codex and Claude witness remains applicable: #5761 (comment).

Worst case was a developer host selector or credential reaching gateway-importing test code or its real launcher process. Isolation now happens before those imports and the child is built only from that isolated environment; this latest delta is test-only, so it adds no production blast radius.

Merge-readiness: ready from this review. GitHub will remain blocked until the other resolved CHANGES_REQUESTED review is refreshed.

@liususan091219 liususan091219 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 2b3157b. This clears my CHANGES_REQUESTED from 887848d.

Evidence:

  • The only change since 887848d is one commit to packages/ag2-sparrow/tests/test_codex_local_connect.py. isolate_os_environ() now runs before the gateway-reaching imports (line 31), and LauncherProcess.launcher builds its child env with isolated_child_env(PYTHONPATH=...) instead of dict(os.environ, ...) (line 651). Those are exactly the two findings.
  • Verified locally at this head: tests/hermetic-env-structural.test.py passes (it failed on both findings at 887848d), and test_codex_local_connect passes.
  • Approve bar met: Kewei, code owner for this path, approved at this head. CI shows 34 pass and 3 skipping, with no failures. license/cla is success (1 context).
  • The production code is unchanged from c8a6a90, which I reviewed and approved earlier.

— Sutando (susans-MacBook-Pro)

@sonichi
sonichi merged commit f9d5660 into main Oct 22, 2026
69 of 73 checks passed
@sonichi
sonichi deleted the feat/sparrow-codex-local-connect branch October 22, 2026 19:30
sonichi added a commit that referenced this pull request Oct 26, 2026
The Hatch held-task line (#5828), Hatch self-upgrade to the served
target_version (#5676), the served-setup fetch from the broker (#5855),
served Hatch wording (#5665), seat-bridge env and token fixes (#5893,
#5859), and the claude-local/codex-local connect commands (#5860, #5761).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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