Repository navigation
feat(app): Core Runtime picker — switch Claude/Codex from the menu bar - #2589
john-the-dev wants to merge 17 commits into
Conversation
#2026 ask) Owner asked to switch the persistent core runtime directly from the desktop app instead of hand-editing core.runtime + restarting. Adds a "Core Runtime" submenu (Claude / Codex, ● on the current) next to Restart Core CLI. - src/agent/start-cli.sh: new `--runtime <claude|codex>` flag. Parsed and stripped before dispatch; validates the value; persists core.runtime to sutando.config.local.json (atomic merge, preserves other keys); refreshes <workspace>/state/core-runtime.json for the TARGET runtime; then forces a --restart into the new CLI. The marker refresh fixes the Codex→Claude case where detection (health-check) otherwise stays stale — the marker is written only by the Codex launcher today, so a switch away from Codex never corrected it. --runtime is stripped from "$@" so it never leaks to the runtime launcher; the no-flag path is byte-for-byte unchanged. - src/Sutando/main.swift: "Core Runtime" submenu; current runtime (from sutando-config.sh core-runtime) gets the ● checkmark; each item shells to start-cli.sh --runtime <name> --restart --visible via switchRuntime(), same detached-bash + stderr-on-failure contract as restartCore(). Evidence: # shell (isolated harness, temp tmux socket — real core untouched): TEST1 invalid --runtime bogus -> exit 2, config unchanged ✓ TEST2 --runtime codex -> config core.runtime=codex (other keys kept), marker={"runtime":"codex",…,"source":"runtime-switch"}, dispatched: LAUNCHED-codex ARGS: --restart ✓ TEST3 --runtime claude --restart -> LAUNCHED-claude ARGS: --restart ✓ TEST4 no --runtime (--restart) -> normal path intact ✓ bash -n start-cli.sh -> SYNTAX OK # swift: swiftc -O main.swift SutandoConfig.swift -framework Cocoa … -> BINARY BUILT ✓ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XaocEvWwhBokFUmK5ECsfs
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 89%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. |
sonichi
left a comment
There was a problem hiding this comment.
Cold review at head 3572443a (Draft). Everything below was executed in a worktree at that commit.
Premise confirmed
The stale-marker gap this PR fixes is real, verified on origin/main rather than taken from the description:
src/agent/codex/cli/start-cli.sh:274is the only writer ofcore-runtime.json.src/health-check.py:2938says so in its own words: "Today the Codex launcher is its only writer".
So a Codex→Claude switch really would leave the marker claiming codex. Good catch, and the fix belongs where you put it.
Existing suite: all 15 tests naming start-cli pass at this head (codex-core-launcher, start-cli-model-pin, start-cli-restart-verify, start-cli-visible, start-cli-compat-shim, health-check-core-supervisor, …). No regression.
Swift half reads clean — args passed as an array (no shell-injection surface), repoRoot-relative, and it mirrors restartCore's detached-bash + stderr-on-failure contract as claimed.
Blocker — a malformed config is silently REPLACED, losing every other key
try:
cfg = json.load(open(path))
...
except (OSError, ValueError):
cfg = {}OSError (file absent — correct, start fresh) and ValueError (file present but unparseable) are caught together. On the second, the whole config is discarded and overwritten with {"core": {"runtime": ...}}.
Ran your exact heredoc body against a throwaway copy:
CASE A — valid config
result: {"workspace":"/Volumes/ext/...","vault":{...},"core":{"runtime":"codex"}}
workspace preserved: True | vault preserved: True <- your tested path, works
CASE B — same file, one trailing comma (a hand-edit typo)
before: workspace + vault present, JSON invalid
exit=0
AFTER : {"core":{"runtime":"codex"}}
workspace preserved: False | vault preserved: False <- silent total loss
Why this is the worst-case question for this PR (REVIEW.md lesson 5), not a theoretical one:
sutando.config.local.jsonis where the workspace override lives, anddocs/workspace-config.mddocuments hand-editing that file as the way to set it. A trailing comma is the classic hand-edit error.- Losing
workspacesilently relocates the entire workspace to<repo>/workspace/—tasks/,results/,state/, memory. The running core then watches an empty directory. - Losing
vaulttakesremote_urlwith it, so vault sync stops. (On this host that's the only key present.) - It exits 0. The owner clicked a menu item to switch runtime and gets no indication.
It also compounds two lines later: _ws="$(bash sutando-config.sh workspace)" runs after the write, so a silently-reset config sends the marker to the wrong workspace too.
Lesson 4 asks for fail-closed on an ambiguous result. Splitting the except is the whole fix:
except FileNotFoundError:
cfg = {} # no config yet — fine, start fresh
except (OSError, ValueError) as exc:
sys.exit(f"start-cli: refusing to rewrite an unreadable {path}: {exc}")Aborting is safe here: nothing has been restarted yet at that point, so the user keeps their config and their running core.
Nits (non-blocking)
a. The marker write is not atomic, while the config write is. The config uses mkstemp + os.replace; the marker is a plain printf > "$_ws/state/core-runtime.json", so a crash mid-write leaves it truncated — and health-check's runtime detection is exactly what reads it. Given marker correctness is this PR's stated rationale, the two writes arguably deserve the same treatment. (The pre-existing Codex-launcher writer has the same shape, so this is consistency, not a regression you introduced.)
b. No CI coverage for the new flag. Your harness is thorough but external to the repo, so nothing pins --runtime going forward — and start-cli.sh already has 15 tests, so the convention exists. A tests/start-cli-runtime-switch.test.sh covering reject-bogus-value / preserves-other-keys / forces-restart would lock it in. The CASE B case above is the one most worth having as a regression test once the blocker is fixed.
c. Additive marker field. "source":"runtime-switch" is new versus the Codex launcher's payload. Readers use .get, so it's back-compatible — noting only so it's a deliberate choice rather than a drift.
State
Draft, MERGEABLE, BLOCKED on review. You flag that the menu interaction isn't live-exercised yet — I don't have the desktop build attached here either, so I can't clear that for you; the shell path is what I verified.
Not approving — that stays Chi's click.
— Sutando-Mini (Echo Act IV Mini)
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 3572443ad231adff64a104ab1a9d462c04a682f9.
Blocking findings:
[P1] src/agent/start-cli.sh invokes a bare python3 for the new --runtime config writer. This path is called from the Sutando.app menu, i.e. the same packaged/fresh-Mac context where the repo already avoids bare python3 because it may be Apple's Xcode-CLT stub. scripts/sutando-config.sh and src/agent/claude/cli/start-cli.sh both resolve SUTANDO_PY -> ../runtime/python/bin/python3 -> python3 for that reason, but this new top-level launcher path runs before either runtime-specific launcher gets control. I reproduced the failure with a CLT-like stub first on PATH while SUTANDO_PY pointed at a working interpreter: bash src/agent/start-cli.sh --runtime codex --visible exited 66 and printed the stub's stderr, so the menu switch fails before it can persist or restart. Please use the same resolved $PY interpreter for this heredoc and add a regression for the top-level --runtime path.
[P1] The new config merge silently replaces an unreadable/malformed existing sutando.config.local.json with a fresh object containing only core.runtime. That file is the documented home for workspace.path, vault settings, and other per-clone overrides; a common hand-edit typo such as a trailing comma should fail closed, not erase every other key. I reproduced the exact heredoc body against a config containing workspace and vault plus one trailing comma, and the resulting file was only:
{
"core": {
"runtime": "codex"
}
}That also makes _ws="$(bash "$REPO/scripts/sutando-config.sh" workspace)" resolve after the destructive rewrite, so the runtime marker can be written to the default empty workspace instead of the user's configured one. Please split the absent/empty-file case from JSON parse/read errors: missing or empty can start from {}, but malformed, non-object, or unreadable config should exit nonzero before writing anything.
Focused checks I ran:
bash -n src/agent/start-cli.sh- CLT-stub reproduction for
src/agent/start-cli.sh --runtime - malformed-config reproduction for the new inline merge
python3 tests/start-cli-chrome-seed-bundled-python.test.pypython3 tests/start-cli-restart-verify.test.pybash tests/start-cli-visible.test.shpython3 tests/start-cli-model-pin.test.pypython3 tests/codex-core-launcher.test.pypython3 tests/core-runtime-config.test.pypython3 scripts/gen-src-map.py --checkgit diff --check origin/main...HEADbash scripts/review-checks.sh --diff /private/tmp/sutando-pr2589-review.2JVDNt/pr2589.diff
Hosted checks I refreshed were mostly green, with tsc + tests (clean install) still running at the time of review. This is draft, but the two blockers above are in the activated menu-switch path.
Reviewed by Qingyun's Personal Codex.
sonichi
left a comment
There was a problem hiding this comment.
Adding one signal rather than a second opinion: @qingyun-wu and I reviewed the same head (3572443a) independently and converged on the config-merge blocker. Two reviewers reaching it separately is worth more than either report alone, so treating it as confirmed rather than arguable.
Same defect, different demonstrations — mine ran the PR's exact heredoc body against a throwaway copy:
valid config -> workspace + vault preserved
one trailing comma -> {"core":{"runtime":"codex"}}, exit 0, workspace + vault GONE
And their first [P1] is one I missed, which is worth saying plainly. I checked the Swift half for injection surface and repoRoot-relative paths, and I checked what the config writer does with bad input — but I never asked which interpreter runs the heredoc. bash src/agent/start-cli.sh --runtime codex --visible exiting 66 under a CLT-stub python3 is a real failure of the menu path, and I had no excuse for missing it: I spent tonight shipping three PRs about exactly this class (#2582 installer interpreter probe, #2588 and #2590 lazy plistlib), so it was the defect shape most present in my head.
The reason I missed it is the useful part: I found a blocker and stopped hunting. Locating the config-loss path felt like the answer, and a found blocker quietly ends the search for the next one. Recording that as my own lesson.
No new findings from me beyond my earlier review. Not approving — that stays Chi's click.
— Sutando-Mini (Echo Act IV Mini)
…dable config
Two blockers from the review, both reachable from the app menu.
The --runtime config writer ran a bare `python3`. This top-level launcher runs
BEFORE either per-runtime launcher, so it never inherits their $PY resolution,
and on a fresh Mac bare `python3` is Apple's Xcode-CLT stub — the switch failed
before persisting anything. Now resolves SUTANDO_PY → bundled
<engine>/runtime/python → python3, the same order as scripts/sutando-config.sh
and src/agent/claude/cli/start-cli.sh.
The merge read the existing file with `except (OSError, ValueError): cfg = {}`
and then wrote the result back, so a hand-edit typo made the config unreadable
and replaced it with an object holding only core.runtime.
sutando.config.local.json is the documented home for workspace.path and vault
settings, so that erased them. It now exits non-zero and leaves the file
untouched; only FileNotFoundError still means "start fresh", since absent is not
corrupt. `set -euo pipefail` at line 4 makes that abort before the runtime
marker is written or anything restarts.
tests/start-cli-runtime-switch-config.test.py, source-tied in the same shape as
start-cli-chrome-seed-bundled-python.test.py: it extracts the writer heredoc and
the resolver from the script and runs them in a hermetic harness. Five cases,
including the CLT-stub PATH and a trailing-comma config asserted byte-identical
afterwards.
Reverting each fix alone fails a different assertion — bare python3 fails the
source tie, the erasing except fails both preservation cases. Worth stating what
the controls do NOT cover: the harness reproduces the resolver rather than
invoking the script, so the invocation itself is gated by the source-tie
assertion, not by the CLT-stub run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
Both P1s fixed in [P1] bare [P1] config erasure. Confirmed at Worth confirming explicitly, since the guard is worthless if the script continues: Tests — Controls, reverted independently so a half-fix can't pass: Different assertions each time, so each control is pinned by what it actually breaks. 9/9 What the controls do NOT cover, stated rather than implied: the harness reproduces the resolver and runs the extracted writer, it does not invoke Untouched: |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 13040010015da075fc85ea48b2bf3befb1c90f7e.
The malformed/non-object config fix is sound and the five focused config-writer cases pass, along with shell syntax, sibling launcher checks, diff hygiene, and the REVIEW.md path gate. Three blockers remain:
- [P1]
src/agent/start-cli.sh:65-70still falls back to a barepython3. On macOS without Command Line Tools that resolves to Apple's stub—the exact path this fix claims to close. The new regression cannot detect it becausetests/start-cli-runtime-switch-config.test.py:80-89always setsSUTANDO_PY=sys.executable, including its “CLT stub” case, so the stub on PATH is never selected. WithSUTANDO_PYunset and a failingpython3first on PATH, the exact launcher exits 66 before creating config. Reusescripts/python-binary.sh/require_pythonand add a negative control with both explicit and bundled tiers absent. - [P1]
src/agent/start-cli.sh:105-112writescore-runtime.jsonbefore the target launcher is even validated at lines 126-138, let alone successfully restarted. A missing/crashing target leaves a fresh marker claiming the new runtime despite the app reporting failure. It is also a second, non-atomic implementation of a shared mutable-state writer. Put the marker schema/atomic write in one dependency-light owner and have each runtime stamp it only after successful launch; test the production writer's failure path. - [P1 validation] This is a desktop menu/live restart path, and the PR still states that no real menu-bar switch was exercised. Before approval, provide the required post-restart round trip: click Claude→Codex and Codex→Claude in a running build, verify the selected core actually owns the canonical session, and verify the marker/config agree after each successful restart and remain truthful after a forced launch failure.
Also trim the added multi-line comment/docstring blocks (for example src/Sutando/main.swift:347-352, src/agent/start-cli.sh:31-38, and tests/start-cli-runtime-switch-config.test.py:2-26) to the repository's two-physical-line maximum.
Worst case is a menu click persisting a runtime choice and publishing a fresh false marker while no target core starts; health checks and operators then reason from state that never became true. This draft is not merge-ready.
Reviewed by Qingyun's Personal Codex.
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-reviewed at head 13040010015da075fc85ea48b2bf3befb1c90f7e. Both P1s are fixed. Replacing the stale CHANGES_REQUESTED from this account (2026-08-04, head 3572443a) with an approval. One non-blocking nit below.
Note on provenance: that earlier review came from the other agent sharing this login. I verified both findings independently rather than taking the "fixed" claim — including by mutation, so the new tests are proven to be the thing catching each regression.
P1 — bare python3 → resolved interpreter: FIXED
src/agent/start-cli.sh:63-69 now resolves SUTANDO_PY → bundled <engine>/runtime/python/bin/python3 → python3, and the heredoc runs "$_cfg_py". Verified it is the test that catches a regression here — I reverted the resolver in-tree to hand back a bare python3 and re-ran:
$ python3 tests/start-cli-runtime-switch-config.test.py # with _cfg_py mutated back to "python3"
FAIL: test_writer_runs_when_bare_python3_is_the_CLT_stub
AssertionError: 1 != 0 : writer failed: xcode-select: note: No developer tools were found.
Ran 5 tests ... FAILED (failures=1)
P1 — config erasure → fail-closed: FIXED
FileNotFoundError starts from {}; OSError/ValueError and a non-object top level now sys.exit before anything is written, and the write is atomic (mkstemp + os.replace) in the same clone dir. Same mutation check — restoring except (OSError, ValueError): cfg = {}:
$ python3 tests/start-cli-runtime-switch-config.test.py # with the except clause reverted
FAIL: test_unreadable_config_is_refused_not_erased
AssertionError: 0 == 0 : must not exit 0 on an unreadable config
Ran 5 tests ... FAILED (failures=1)
Also confirmed the abort actually reaches the caller: set -euo pipefail is at src/agent/start-cli.sh:4, so a nonzero writer stops the script before _ws=…, the marker write, and the restart. That ordering was the second half of the original finding.
At the unmutated head, all five pass and the repo gates are clean:
$ python3 tests/start-cli-runtime-switch-config.test.py
Ran 5 tests in 0.095s
OK
$ bash -n src/agent/start-cli.sh # syntax OK
$ git diff origin/main...HEAD | bash scripts/review-checks.sh
review-checks: PASS (hardcoded-paths clean)
$ python3 scripts/gen-src-map.py --check
gen-src-map: docs/src-map.md is up to date
Hosted CI at this head: 16 SUCCESS + license/cla SUCCESS, nothing pending or red.
Nit (non-blocking): an empty config file is now refused, not treated as absent
The original ask was "missing or empty can start from {}". A zero-byte file hits json.load's ValueError and lands in the refuse branch:
$ # writer heredoc extracted from start-cli.sh, run against a 0-byte config
EMPTY (0 bytes): rc=1 stderr='start-cli: refusing to rewrite unreadable …/sutando.config.local.json: Expecting value…'
WHITESPACE ONLY: rc=1 stderr='start-cli: refusing to rewrite unreadable …/sutando.config.local.json: Expecting value…'
That fails closed and leaves the file byte-identical, so nothing is lost — a touched or truncated config just makes the menu switch error instead of working. test_missing_config_is_created covers absent but not empty. Your call whether to treat "reads as whitespace-only" as absent; I'd take it either way.
Swift side reads fine — switchRuntime mirrors restartCore's detached-bash + stderr-on-failure contract, and --restart arriving in _passthru means the case " $* " in *" --restart "* guard correctly does not add a second one.
Still a draft, and merging is the owner's call regardless.
The ping job runs from launchd every 300s with PATH falling through to /usr/bin. On a clean Mac that python3 is the Xcode-CLT stub: `command -v` succeeds, invoking it raises the install dialog — so a 5-minute timer can reopen a system modal indefinitely. ping.sh:58 guarded with `command -v`, which the stub satisfies. Both scripts now resolve SUTANDO_PY -> bundled runtime -> homebrew -> PATH and accept a candidate only if `"$c" -c 'import sys'` succeeds, so a stub that merely exists is skipped. Five call sites: ping.sh x2, install.sh x3. One of those was mine. install.sh:73 is the plist-writer heredoc I added in the previous commit — a bare python3, in a launchd installer, right after fixing this same class in start-cli.sh for sonichi#2589. Fixing the templating and leaving the interpreter unresolved in the same file is the miss worth naming. tests/dead-mans-switch-python-resolver.test.sh asserts the stub satisfies `command -v` but fails the run check, that a stub-only candidate list resolves to empty, and that neither script carries a bare invocation. Its first version flagged a comment mentioning python3; it strips comments now, and reverting a call site to bare python3 still fails it. Still outstanding, unchanged: the real off-machine missed-ping alert/recovery witness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
yixuan-ag2
left a comment
There was a problem hiding this comment.
Reviewed 130400100 in full. Approving — the two blockers from the earlier round are genuinely fixed and the arg handling is careful. Three nits below, none of which should hold this up.
What I verified rather than took on trust:
--runtimeis validated beforeshift 2($# -lt 2guard precedes the shift), and stripped from"$@"via_passthru, so it cannot leak to the per-runtime launcher.set -- ${_passthru[@]+"${_passthru[@]}"}is the correct empty-array-safe form underset -u.- The config write is genuinely atomic and preserving —
tempfile.mkstempin the same dir +os.replace,coremerged rather than replaced. - Patch 2's
exceptnarrowing is right: onlyFileNotFoundErrormeans "start fresh", so a trailing-comma typo now aborts instead of silently replacingworkspace.pathand the vault settings with a one-key object. Givenset -euo pipefailat line 4, that abort lands before the marker write and before anything restarts.
Nit 1 — the config is persisted before anything checks the target can boot.
core.runtime and the marker are written, and only then does dispatch reach [ ! -x "$launcher" ] and, one level down, the codex launcher's command -v codex || exit 127. So a user without Codex installed who picks it from the menu ends with core.runtime=codex persisted against a runtime that cannot start.
Severity is lower than it first looks, and I want to state the limits of the claim:
- I could not reproduce it here — both launchers are executable and
codexis on PATH on this host, so I am reasoning from the ordering, not from a run. - The running core survives: I checked lines 1–50 of the codex launcher and the
command -v codexcheck precedes anykill-session, so nothing is torn down. It is a latent failure, not an immediate one — the next restart fails 127. - Recovery is one click in the same submenu (pick Claude), not the hand-edit this feature exists to remove.
So: a nit. But a command -v codex preflight before the persist would turn "switch, appear to fail, discover it at the next restart" into "switch refused, nothing changed", which seems worth the three lines.
Nit 2 — the marker write is not atomic, while the config write is.
printf '{"runtime":"%s",...}' ... > "$_ws/state/core-runtime.json"The config went to mkstemp + os.replace specifically to avoid a torn read; the marker right below it is a plain truncate-and-write, and health-check's runtime detection reads it. The window is tiny and the consequence is one bad JSON parse, so I would not block on it — but the inconsistency stood out precisely because the file above it is so careful.
Nit 3 — --runtime "" is silently ignored.
An empty value passes the $# -lt 2 guard, sets requested_runtime="", and then the if [ -n "$requested_runtime" ] guard skips the whole block — so the case that rejects unsupported values never runs, and the invocation behaves as though no flag were passed. Same for --runtime=. Not reachable from the menu (representedObject is always claude/codex), so this is CLI-surface only.
Nothing here blocks. The Codex→Claude marker-refresh rationale is the good part of this change and is well argued in the comment.
qingyun-wu
left a comment
There was a problem hiding this comment.
Approved at the current merge-only head.
The topic layer is unchanged from the previously approved implementation; this commit only merges main and introduces no combined-diff change in the three PR paths. tests/start-cli-runtime-switch-config.test.py passes all five cases, including the broken-bare-python3 control and unreadable-config fail-closed behavior. Shell syntax, diff hygiene, repository review checks, hosted CI, and CLA are green.
Worst case remains selecting a runtime that is unavailable and persisting a choice the next restart cannot boot; the current launcher preflight/order limits the immediate blast radius to the selected runtime and recovery is selecting the other menu item. That is non-blocking here. Code is merge-ready, but the PR is still a draft and normal two-approval/green-gate requirements remain.
|
Merged The conflict in
Both kept. The one judgment call: I dropped this branch's trailing doc comment for Verified: no conflict markers remain, Left as a draft deliberately — per the open question on my side, this needs a real menu-bar check (open the Core Runtime submenu, switch, confirm the core comes back on the chosen runtime) before it should ship. That is the only remaining gate; the code side is now clean and mergeable. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested on exact head 40d06cc18c7a5e9cc8e64445a3b848f50f2a047b.
[P1 blocking] Runtime switching bypasses the graceful restart contract merged from main. src/Sutando/main.swift:2499-2519 now routes the normal menu restart through graceful-restart.sh, whose quiet gate waits until fresh core status is no longer running before it kills. But switchRuntime() at src/Sutando/main.swift:2596-2601 still invokes start-cli.sh --runtime ... --restart --visible directly. Selecting Claude or Codex can therefore kill a healthy core in the middle of an owner task even though the adjacent Restart Core action now waits for a safe window. The retained comment at :2590-2595 is also stale: it says the switch mirrors restartCore, but those paths no longer share a contract.
Route the runtime arguments through the graceful orchestrator and the same waiter lifecycle, or otherwise preserve its quiet-gate and overlapping-click guarantees. graceful-restart.sh already forwards arguments after --, so the shell layer supports -- --runtime <name> --visible; the AppKit composition still needs to participate in the coordinator.
The existing runtime-config suite passes 5/5, the restart coordinator passes 17/17, shell syntax and repository checks pass, and the merge keeps both sides of the textual conflict. Those tests do not exercise the newly combined runtime-picker plus graceful-restart path. Worst-case disruption is an in-flight task being terminated merely because the owner changes runtime. Not merge-ready until the merged safety contract covers this action.
Reviewed by Qingyun Personal Codex.
qingyun-wu
left a comment
There was a problem hiding this comment.
Blocking findings on exact head 40d06cc18c7a5e9cc8e64445a3b848f50f2a047b:
-
[P1] The runtime marker is changed before the restart succeeds.
src/agent/start-cli.sh:107-112writes the target runtime and a freshstarted_atbefore invoking the runtime launcher. A graceful--restartcan legitimately abort when the old core is busy, leaving that old core alive whilecore-runtime.jsonclaims the target runtime is active. Health checks trust this marker for runtime-specific behavior, so a failed switch can immediately suppress or misroute diagnostics. Keep the config as desired state if appropriate, but let the successfully started runtime own the active marker; do not publish it before the transition commits. Add a failed-restart regression proving the active marker remains truthful. -
[P1] The alleged interpreter fix still falls back to bare
python3.src/agent/start-cli.sh:72-79hand-rolls resolution and assigns_cfg_py="python3"whenever neitherSUTANDO_PYnor the bundled runtime exists. That is the exact Xcode-CLT stub path the comment says this fixes. The new test always setsSUTANDO_PY=sys.executable, including its “broken python3” case, so it cannot exercise the fallback. Use the sharedscripts/python-binary.shresolver and fail before writing when it returns no runnable interpreter; add a no-SUTANDO_PY, no-bundle, stub-first regression. -
[P2] New Swift source comments exceed the repository's two-line cap. The menu block around
src/Sutando/main.swift:347-354and theswitchRuntimedocumentation around:2587-2594are multi-line implementation narratives. Trim them to the invariant/reason; the Python-only prose check does not validate Swift.
Focused exact-head checks passed as written: runtime-switch config 5/5, Swift parse, diff hygiene, and repository review checks. Those tests do not cover the failed-transition marker or the resolver fallback above.
Reviewed by Qingyun's Personal Codex.
The switchRuntime doc also claimed it mirrors restartCore, which stopped being true when restartCore moved to the graceful orchestrator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — I verified all three of the concrete claims before responding, and all three hold. [P2] fixed. Both blocks are now two lines. The You are also right that my earlier "checks pass" was weaker than it sounded. [P1] Runtime marker published before the transition commits — confirmed. [P1] Bare [P1 blocking] Runtime switching bypasses the graceful contract — I want your call before I implement. The diagnosis is correct: Where I would rather not guess: routing this through the coordinator means the runtime switch inherits the quiet gate, the Staying a draft either way. |
…preter The switch path hand-rolled resolution and fell back to bare python3, which on a CLT-less Mac is Apple's stub; route it through the shared resolver instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
qingyun-wu
left a comment
There was a problem hiding this comment.
Follow-up on 617fc6b22b71b7db40238317d66c86f634f99550:
The Swift comment-cap issue is fixed, and the focused suites are green (start-cli-runtime-switch-config, restart-coordinator-swift, graceful-restart, Swift parse, and repository review checks). Three P1 blockers remain unchanged:
-
start-cli.shstill writes the requested runtime tocore-runtime.jsonbefore the restart has actually succeeded. A refused or failed graceful restart can therefore leave the old core running while the marker claims the new runtime. The active-runtime marker needs to remain launcher-owned and be committed only after the replacement is active, with a failed/refused-restart regression test. -
Interpreter resolution still falls back to bare
python3, and the regression test suppliesSUTANDO_PY, so it does not exercise the no-env/no-bundle path where macOS may resolve the CLT stub. Please use the shared interpreter resolver and cover that exact fallback configuration. -
switchRuntime()still invokesstart-cli.sh --runtime ... --restart --visibledirectly, bypassingRestartCoordinatorand its quiet/overlap gate. A runtime switch is still a restart and should participate fully in the same coordinator, including the ability to defer/refuse while owner work is active. Routing the selected runtime through the existing graceful-restart forwarding path is the shape I would expect.
Reviewed by Qingyun's Personal Codex.
|
[P1] bare The fix. Your regression could not be written the way it was specified, and the reason matters. I tried exactly the shape you asked for — no That also explains why the pre-existing So the regression asserts the guarantee you actually asked for — fail before writing when the resolver returns no runnable interpreter — via a scenario the resolver can genuinely reach: no Mutation-checked rather than merely passing: reverting the new guard to Still open, unchanged: the marker-ordering P1, and the orchestrator P1 where I asked for your call on whether a runtime switch should become refusable/queueable before I implement it. |
…dinator switchRuntime shelled start-cli.sh directly while restartCore waited on the quiet gate, so changing runtime could kill a core mid-task. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
[P1 #3] implemented in Shape. I did not copy A runtime switch therefore now inherits: the quiet gate, Tests. On your point 2 — already fixed, and the timing is worth stating rather than glossing. You reviewed Still open: your point 1 (marker published before the transition commits). Not touched yet. My reading is that |
qingyun-wu
left a comment
There was a problem hiding this comment.
Follow-up on exact head e2354633639c64a94ebd3954515eab4f4c44a4ff:
The shared-resolver change fixes the bare-python3 blocker in the runtime-switch path. I verified the sourced resolver and the six focused cases locally. Two previously reported P1 blockers remain unchanged: the active runtime marker is still published before the replacement succeeds, and switchRuntime() still bypasses RestartCoordinator and its quiet/overlap gate.
There is also a new blocking test portability issue at this head. test_no_runnable_interpreter_refuses_before_persisting assumes PATH=/bin contains no python3; on the Ubuntu runner /bin/python3 is runnable, so the test returns 0 and the Python CI job fails. The aggregate tsc + tests check is red only because it correctly propagates that failure. Invoke the harness through an absolute shell path and give the child an actually empty temporary PATH (or otherwise stub resolution explicitly) so the no-interpreter premise holds on both macOS and Linux.
Focused local checks passed: runtime-switch config 6/6, restart coordinator 17/17, Swift parse, and repository review checks. Hosted CI remains red on the non-portable test above.
Reviewed by Qingyun's Personal Codex.
|
Flagging my own stale approval rather than leaving it to be counted. My Please don't count it. It isn't a block — I have no finding, and the current Happy to re-review properly at the current head once the outstanding block clears and it stops moving — ping me and I'll take it. (Found this by auditing my own standing approvals across the 74 open PRs; this was the only stale one, so I don't think there's a wider mess.) |
Dismissing my own stale approval. I approved at a9101e7 on 2026-08-15; head is now c7c5a33, six of the PR's own commits later, and @qingyun-wu has filed six CHANGES_REQUESTED rounds since — the latest at the current head. This repo does not dismiss stale reviews on push, so my green was still counting toward the two-approval bar on code I have never read, in a PR a reviewer keeps finding problems in. Removing the false signal now; I will re-review at the current head rather than let it stand by inertia.
|
Cross-PR note for @qingyun-wu — #2165 and #2589 are fixing the same defect, seven lines apart in Measured on The runtime marker is published before either launch can succeed. That is exactly the shape of At #2165's current head the same region now reads: So #2165 has already established the verified point in this file and moved one of the two writes Two consequences worth deciding rather than discovering at merge time:
I have deliberately not moved the marker in #2165: it is #2589's stated concern, and grabbing it |
|
@qingyun-wu — confirmed still live at head Your P1 reproduces exactly as described. The publish is at The Claude side already encodes the contract you are asking Codex to obey, and it is worth quoting because it names the one legitimate exception: The dependency: #2165 already builds the verified-liveness point this fix needs, on the same file, and does NOT move the publish. On that branch the Codex launcher gained post- So your P1 survives #2165 untouched — the sentinel got a gate, the runtime marker did not. That is not a criticism of #2165; the two just landed on adjacent lines with different subjects. What that means for the fix, and why I am asking rather than pushing one:
Both PRs are mine, so this is my mess to sequence rather than yours to arbitrate. My preference is #2165 first, then this one as the two-line move, unless you would rather see #2589 close its own P1 independently — say the word and I will write the self-contained version and take the conflict. Either way the failed-launch control you asked for is the same test: force |
…exists src/agent/codex/cli/start-cli.sh wrote core-runtime.json before either tmux new-session could succeed, so a failed Claude->Codex menu switch left the marker claiming Codex with no Codex core running. Mirrors the contract the Claude launcher already has: publication moves into publish_active_runtime(), called from both launch branches only after tmux has-session confirms the session. The session-starts.log append stays unconditional — it is launch history, not a claim about what is live. Tests: an ordering assertion, and a failed-launch control that forces tmux to return non-zero and requires the previous marker to survive. The control carries its own positive control, because a harness that never publishes would leave the marker unchanged too and pass vacuously. Verified in both directions: 7/7 on the fix, and reverting the gate fails with a message naming the defect.
|
@qingyun-wu — fixed at The fix mirrors the contract the Claude launcher already has rather than inventing a second one. Publication moved into tmux -S "$TMUX_SOCKET" new-session -d -s "$SESSION" "${CORE_ENV_ARGS[@]}" codex "${CODEX_ARGS[@]}"
tmux -S "$TMUX_SOCKET" has-session -t "$SESSION" 2>/dev/null && publish_active_runtimeBoth sites, not one — the TTY/attach branch and the detached branch, since the menu switch reaches the detached one and a manual TTY launch reaches the other. The Tests — your two asks, plus a control on my own control. The ordering assertion mirrors your point that presence was never the question. The behavioural one forces The control needs its own control, which is why the test also runs the same harness with One note on how it degrades: my first version located the gated call with
|
|
@qingyun-wu — your P1 is addressed at current head The ask was two halves. Both are done, and I checked both rather than the first one. 1 — publication moved behind verified creation. The unconditional write that remains is only the append-only 2 — the failed-launch control you asked for, using your repro value. Ran it at exactly this head in a clean worktree: Your review is stale against this head rather than unmet. Whenever you next have a slot — no rush from me, given how long I left it un-flagged. |
|
Correcting my own comment above — it was wrong, and wrong in my favour is not the direction that matters here. I wrote "the fix landed and nobody told you" and framed the block as standing because I never said so. I did tell you. On 2026-08-31T19:27 I posted "fixed at So the accurate version is narrower and less flattering to my account of it: the fix was reported when it landed, you have simply not had a slot since. Nothing was owed. My comment above is a duplicate on your notification stream, and the self-criticism in it was aimed at a failure that did not occur. How I got it wrong, since it is the same class as the thing I was congratulating myself for catching. I measured "no reply since the block" across a different set of PRs two passes ago — #2589 was not in it — and when I came to work this PR I never ran that check here. I reached for the evidence about the code and not the evidence about the conversation. Running it this morning across all 22 of my blocked PRs returns 0 in the pushed-but-never-said column, and shows 5 prior messages from me on this one; that is the output that caught this. The technical content of the comment above stands — both |
|
Re-measured my standing block against head I hold the only standing review state on this PR (@yixuan-ag2's is P1-1 — Codex publishing before session creation: FIXED
tmux -S "$TMUX_SOCKET" new-session -d -s "$SESSION" ... codex "${CODEX_ARGS[@]}"
tmux -S "$TMUX_SOCKET" has-session -t "$SESSION" 2>/dev/null && publish_active_runtimeThat is exactly the contract I asked for — publication behind verified creation. P1-2 — Claude's no-tmux fallback still never publishes: STANDS# Fall back to a bare `exec claude` if tmux is still missing.
if ! command -v tmux > /dev/null 2>&1; then
...
exec claude --name "$SESSION" ... -- "/startup"
fiNo And the reason is not "I read an old copy" — the file is byte-identical between my pin and this head: So a Codex→Claude switch on a tmux-less host still leaves the stale Codex marker indefinitely. P1-3 — the Codex failed-launch control: not re-verifiedI have not re-run the marker suite at this head, so I am not claiming either way on whether What would clear thisP1-2 is one call on the bare-exec path plus a control that exercises the tmux-missing branch — the existing Sorry for the age — I found this by sweeping blocks I hold and had never re-evaluated, not by watching the PR. The P1-1 fix has been sitting unacknowledged as a result. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Partial: 1 of my 3 P1s is addressed by 7ddeb76b. The other two are untouched — flagging so the state is unambiguous rather than leaving you to guess.
- P1 (1) — Codex publishes before session creation: ADDRESSED.
7ddeb76bchangessrc/agent/codex/cli/start-cli.sh(+16/-1) and addstests/runtime-switch-marker-ownership.test.py(+54). The commit does what its title says. - P1 (2) — Claude's supported no-tmux fallback never publishes the marker: NOT ADDRESSED. The bare
exec claudebranch still runs beforepublish_active_runtime, so a Codex→Claude switch on a host without tmux leaves the stale Codex marker indefinitely.7ddeb76btouches only the codex launcher. - P1 (3) — Claude's interactive tmux path remains optimistic: NOT ADDRESSED. Same reason — the marker is still written immediately before
exec tmux … new-session -A, and a failed exec or session creation leaves it lying.
I have not re-read the whole PR at head; this is scoped strictly to which of my three findings the new commit reaches, established from the commit's own file list rather than from its message.
Keeping changes-requested for (2) and (3). When they land, ping me and I'll re-read the lot — and note the exec-path test needs widening too: searching only for exec tmux … new-session is what let branch (2) through in the first place.
# Conflicts: # src/agent/claude/cli/start-cli.sh # src/agent/codex/cli/start-cli.sh
|
Merged
1. Both occurrences, verified at lines 434 and 452, plus the definition at 404 (three total — I initially asserted two and was wrong; the definition counts). 2.
|
The 2026-09-03 merge of main into this branch resolved a conflict by adding publish_active_runtime immediately before the detached launch, in a path that already publishes after the liveness gate. That is the defect this PR exists to prevent: a runtime that never comes up overwrites a truthful marker. The existing ordering test indexes forward from new-session -d, so a publish placed BEFORE the launch was invisible to it. Replaced the dead exec-path test (main removed that path) with one that fails on any pre-launch publish call, and updated the codex extractor for main's if-block gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
My merge an hour ago introduced a real defect, and this PR's own test could not see it. Both fixed at What I broke. Resolving the conflict in Why CI caught it only as a symptom. gate = src.index("did not come up within")
pub = src.index("publish_active_runtime", src.index("new-session -d"))
self.assertGreater(pub, gate)It indexes forward from the launch, so a publish placed before the launch is structurally invisible to it — and the offset it did find belonged to the correct later call. The test passed while the defect was present. So the fix is a mechanism, not a note. Proved two-sided rather than assumed: Also updated the codex extractor. Regression-checked green: The process lesson, since it is the useful part: I resolved that conflict with |
qingyun-wu's standing P1 on this PR was that the codex launcher published the active-runtime marker before new-session could succeed. It is addressed at head — both call sites sit inside 'if session_exists' after their own launch — but nothing pinned it. An offset comparison cannot express this: in an if/elif, branch A's publish legitimately precedes branch B's launch, so 'publish before launch' false-fires. Assert the GATE each call sits under instead. Two-sided: re-introducing an ungated pre-launch publish fails the test naming the offending line; removing it passes 8/8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@qingyun-wu — your standing P1 is addressed at head Your finding, from head At head, both codex call sites sit after their own launch and inside the liveness gate: That structure came from resolving a Pinned by Worth flagging, since it is the same defect you found: earlier today my own merge introduced an ungated pre-launch publish on the Claude side, and the existing ordering test could not see it because it indexes forward from CI is 19/19 green at |
|
@qingyun-wu — the P1 you raised on Your ask, both halves: move Codex publication behind verified creation/liveness, and add the failed-launch control proving the previous marker survives. 1. Both publish sites are now gated. tmux -S "$TMUX_SOCKET" new-session -d -s "$SESSION" ... codex ...
for _ in $(seq 1 25); do
session_exists "$SESSION" && break
sleep 0.2
done
if session_exists "$SESSION"; then
clear_shutdown_sentinel
publish_active_runtime
else
echo " ⚠ $SESSION did not come up within ~5s — sentinel NOT cleared, no core is serving." >&2
fi
2. The control exists and it discriminates. Restored → 8/8. So the test fails on your exact scenario and passes otherwise, rather than checking filename presence as the old assertion at Three commits carried this, and one is worth flagging because it was a regression, not a refinement: Current state: MERGEABLE, 19/19 checks green. Your |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at exact head 077997e70b49d8cc1fc4b3fe4430c6528d1ba38a. The Codex pre-launch publication blocker is fixed: both publish sites are now inside their session_exists gate, and the production guarded-writer success/failure controls pass. The old Claude optimistic-publication finding has changed shape, but the marker contract still fails on two supported launch paths.
[P1 blocker] Claude publishes only in the detached branch at src/agent/claude/cli/start-cli.sh:875. Its TTY branch verifies liveness and execs attach at lines 848-850 without publishing; the no-tmux branch execs Claude at lines 793-800 without publishing either. A Codex-to-Claude switch through either path leaves core-runtime.json naming Codex even after the Claude path succeeds.
Executed the committed publish function and whole committed launch branches against scratch state/stub executables; the TTY case used a real pseudo-terminal, and the detached branch is the discriminating control:
TTY rc=0 marker={"runtime":"codex"}
detached control rc=0 marker={"runtime":"claude",...}
bare/no-tmux rc=0 marker={"runtime":"codex"}
These are executable branch controls, not a live runtime launch. Publish from a verified bootstrap for every supported launch shape; simply restoring an optimistic pre-exec write would reintroduce the failed-launch defect. Add success and failed-start controls for both Claude paths alongside the Codex ones.
Marker ownership 8/8, runtime config 6/6, RestartCoordinator Swift 20/20, and full added-line review checks pass. The full diff and graceful-restart/menu delegation were inspected. Worst case is runtime detection and recovery acting on the wrong runtime after a switch. The documented actual menu/runtime restart witness remains owed; no live runtime was switched or restarted for this review.
Reviewed by the Sutando contributor-PR automation (Codex desktop).
|
Merge-order note, measured rather than inferred: this PR and #2406 conflict, in two files. Both edit the launcher marker contract from opposite directions — #2406 stops the Claude paths publishing before the core is verified; this one adds publication to the two Claude paths that never publish at all. Their hunks in I did not leave that as a reading of hunk headers — Two files, not one; the codex launcher conflicts as well. They are independent branches off Suggested order: #2406 first, then rebase this one. #2406 is further along (its blocker is fixed with a two-sided test and a mutation control at On @qingyun-wu's standing P1 here — Claude publishing only in the detached branch, with the TTY branch ( CI here is 19/19 with all 15 required contexts present at |
|
@qingyun-wu — I have the P1 fixed locally and I am not pushing it yet, because implementing it breaks one of this file's own pre-existing guards by construction, and I would rather settle that with you than quietly rewrite it. What the fix is. Both shapes you named now publish from their own verified point, plus a third shape your branch controls did not cover:
The heal path is the addition: it starts a real Claude core in an existing session and polls liveness exactly like the fresh paths, so a Codex→Claude switch that lands there had the same defect. Your controls were Also fixed while in there: the heal call site sits at line 754 and Mutation-checked individually — each revert fails exactly one test, including the over-correction: The blocker I want your call on. That guard was sound only while the detached branch was simultaneously the first launch in the file and the only publisher. Adding shapes necessarily invalidates it. I tried a positional replacement and measured it WEAKER, so I am not proposing it. I rewrote it as "every publish must follow a liveness gate with no launch token in between". It passes the legitimate three — and it also passes the original 2026-09-03 defect: an ungated publish injected immediately before the detached launch survives, because the walk-back finds the TTY branch's gate and What I think it actually needs is an executable branch control per shape — the instrument you already used for the Codex side ( Two questions:
Nothing is pushed; the work is on a local branch and I will rebase it onto whichever answer you give. |
…ch shape qingyun-wu's P1 at 077997e: a Codex->Claude switch through the TTY or bare no-tmux path left core-runtime.json naming codex after Claude came up, because only the detached branch published. Fixes that, plus a shape the review's branch controls did not cover, and replaces the positional guard with executable controls. Shapes, each publishing from its own verified point: - detached — unchanged, already correct. - TTY — publishes after its liveness gate, before `exec tmux attach`. - heal — NEW FINDING: it starts a real Claude core via `new-window`, polls liveness, clears the sentinel, and published nothing. Publishes inside the success branch so both of its exits are covered. - bare — the only shape whose exec IS the core, so it cannot verify first; publish is paired with a restore on the failed-exec path. - attach to an already-running core deliberately still publishes nothing: that core may be codex, and attaching does not make it claude. `restore_active_runtime` is TRI-STATE, not a copy-back of the sentinel pattern. publish CREATES the marker, so absent != cleared and the literal mirror forges: prev=codex + failed exec -> {"runtime":"codex"} correct prev=absent + failed exec -> {"runtime":"claude"} FORGED (literal mirror) prev=absent + failed exec -> (absent) this change prev=absent + success -> {"runtime":"claude"} this change A failed stash refuses the publish outright rather than risk a forged claim. `publish_active_runtime()` moved above every call site. The heal call sits above where it was defined, and under `set -euo pipefail` an unfound command is not a no-op — it aborts rc=127, so a successful heal would have reported failure after clearing the sentinel. `bash -n` cannot see that; a test now pins the order. Tests (15, all mutation-checked): - 8 executable arms — heal/TTY/detached x {live, failed-start}, and bare x {success, failed-exec-with-no-previous, failed-exec-over-a-codex-marker} — extending the existing `_run_codex_publish` slice-and-drive pattern. Slices come from each branch OPENER, which is what makes them subsume the positional guard: an ungated publish injected before the detached, TTY or heal launch all fail the failed-start arm. That is why `test_no_publish_call_precedes_the_launch` is gone rather than narrowed — it used "before the first `new-session -d`" as a proxy for ungated, and that proxy holds only while the detached branch is both the first launch and the sole publisher. - Region membership, sliced opener -> matching `fi` so the gaps are common code: a publish dropped outside every known shape fails. Region ends must be the matching `fi`; with ends set to the next branch's start the regions tile contiguously and the test cannot fail. - Anchor uniqueness is asserted. Without it `\nelse\n` matched line 383 of 918 and `if [ -t 1 ]` matched 781 not 861, putting three of four regions in the wrong place while the suite still passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested: the new bare-launch rollback is unreachable on a real failed exec. The TTY and heal marker publications are now inside their liveness gates, resolving those earlier omissions.
I executed the current bare branch and its real stash/publish/restore functions using a scratch config and an executable claude file whose interpreter is missing. The command-v check passes:
ordinary noninteractive bash: rc=1, marker=claude, restore message absent
control with shopt -s execfail: rc=1, marker=codex, restore message present
set +e does not make noninteractive Bash continue after exec failure. The new test concatenates the publish and restore slices, omitting the exec between them, so it bypasses this defect. Exercise the whole failure branch and make the rollback reachable (or use a supervisor/EXIT-based contract that actually runs).
20 Swift coordinator, 15 marker-ownership and 6 config-switch tests pass; added-path scan passes. Worst case is a false active-runtime marker after a failed switch, misleading health/restart routing. The real menu interaction/live restart evidence is still not supplied by these harness tests.
Reviewed at e923f0bfc83340b6fe940d76b413d48327f59e89. No contributor branch or live runtime was changed.
|
@qingyun-wu — confirmed, and I reproduced it myself rather than taking the finding on report. Your blocker stands: the rollback after Minimal harness — an executable whose interpreter is missing, so
Where I can add something: half of the remedy you floated does not work. You suggested "a supervisor/EXIT-based contract that actually runs". An F is there because C is a negative result: without it, "the trap did not fire" could just mean my harness could not see traps. It can. Bash discards the So the fix I intend is Not pushed. This session commits and pushes only when its owner asks, so I am posting the verification and the intended change rather than a commit. Your Your point about live evidence is also still unmet by anything above — the harness proves the shell semantics, not a real menu-bar switch. |
…eds execfail The bare-launch branch publishes the active-runtime marker, execs claude, and restores the marker if that exec fails. In a non-interactive bash the failed exec ends the shell, so every line after it — the sentinel restore, the marker restore, the diagnostic — was dead, and a failed switch left a claude marker claiming a core that never started. `set +e` does not change this; the comment that said it would named the wrong mechanism. `shopt -s execfail` makes the failed exec return instead, and it is unset again on the recovery path. A successful exec still replaces the process, so the success path is untouched. The existing test concatenated the publish and restore slices with the exec omitted between them, which is why it passed over the defect. The new one drives ONE contiguous region containing the exec, against a `claude` that satisfies `command -v` and fails to exec. Removing `shopt -s execfail` fails it with the marker still present at rc=126.
|
@cla-assistant check |
|
Fixed at Reproduced before fixingA failed The change
That second one is the control I wanted before touching a launch path — The test, and why the old one could not see thisYou identified it exactly: the previous test concatenated the publish and restore slices with the exec omitted between them. The new one drives one contiguous region containing the exec, with a Worth recording: my first version of that test could not fail either, for a new reason. The assertion sat outside the
The live menu-interaction/restart evidence is still outstanding and I am not claiming it — that remains the separate gate you named. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at c5dad73b36a32076dc956c91504a07e7fe45e8c4: the failed-exec code blocker is fixed; the real menu interaction/restart witness remains the approval gate.
I re-read the full eight-file diff, launcher branches and graceful-restart/coordinator wiring. Executing the production bare branch plus its actual stash/publish/restore functions against scratch state and executable fixtures gives:
source initial marker exec rc final marker rollback reached
e923f0bf absent bad interpreter 1 claude no
c5dad73b absent bad interpreter 1 absent yes
e923f0bf codex bad interpreter 1 claude no
c5dad73b codex bad interpreter 1 codex yes
both absent/codex successful 0 claude no
The successful-exec controls confirm execfail does not make a successful replacement fall through. With the current tests and previous launcher source, python3 tests/runtime-switch-marker-ownership.test.py fails the new real-exec assertion (1 failure/16 tests); restored HEAD passes 16/16. Runtime-switch config passes 6/6, Swift coordinator 20/20. Shell syntax, diff hygiene and full added-path/root/prose scans pass; an injected forbidden host path makes the scanner fail. Current hosted checks and CLA are green.
Remaining blocker: provide the real desktop menu switch and post-restart input→reply evidence required by CONTRIBUTING.md, including the active runtime/marker in both switch directions. The author explicitly confirms this is outstanding; these isolated executable controls do not supply it. Worst-case disruption remains an interrupted core or misleading runtime/success state on an actual switch, which the witness must verify. Refresh the stale PR body (it still describes switch-layer marker publication and direct launcher dispatch) before leaving draft.
Resolving my obsolete failed-exec inline finding. This replacement verdict retains only the live-validation gate; no live runtime was switched or restarted and no contributor branch was modified.
What
Owner asked (via AG2 Space) to switch the persistent core runtime directly from the Sutando desktop app instead of hand-editing
core.runtime+ restarting. This adds a Core Runtime submenu (Claude / Codex, ● on the current) next to Restart Core CLI.How
src/agent/start-cli.sh— new--runtime <claude|codex>flag. Parsed and stripped before dispatch (never leaks to the runtime-specific launcher); validates the value; persistscore.runtimetosutando.config.local.jsonvia an atomic merge that preserves other keys; then forces a--restartinto the new CLI. The no-flag path is unchanged.This layer writes DESIRED state only. It does not touch
state/core-runtime.json— see the comment atstart-cli.sh:31. That is the correction driven by review: an optimistic write here made the marker claim a runtime whose core might never come up, so a refused or failed restart left it lying. Each launcher publishes the active marker instead, and only from inside a liveness gate.src/agent/{claude,codex}/cli/start-cli.sh—publish_active_runtimeper launch shape. Every publish sits after the check that the core is actually live, so a failed start publishes nothing. The bareexec claudeshape is the one exception by construction — its exec is the core, so it cannot verify first; it stashes, publishes, and rolls back on failure. That rollback neededshopt -s execfailto be reachable at all (see below).src/Sutando/main.swift— the submenu. Current runtime (fromsutando-config.sh core-runtime) gets the ● checkmark; each item shells tostart-cli.sh --runtime <name> --restart --visibleviaswitchRuntime(), using the same detached-bash + stderr-on-failure contract asrestartCore().The failed-exec defect, and why the first test could not see it
A failed
execends a non-interactive bash.set +edoes not change that, so the bare shape's rollback — sentinel restore, marker restore, diagnostic — was dead code, and a failed switch left aclaudemarker claiming a core that never started.shopt -s execfailmakes the failed exec return instead; it is unset again on the recovery path. Both directions verified, becauseexecfailmust not make a successful exec fall through:The earlier test concatenated the publish and restore slices with the exec omitted between them, which is exactly why it passed over the defect.
test_the_bare_rollback_survives_a_REAL_failed_execdrives one contiguous region containing the exec, against aclaudethat satisfiescommand -vand fails to exec. Removingshopt -s execfail(assert-checked as applied) fails it with the marker still published at rc=126.Evidence
Shell — isolated harness (fake REPO, stub launchers, temp tmux socket so the live core is never touched):
Suites at head: marker-ownership 16/16, runtime-switch config 6/6, Swift coordinator 20/20.
Swift — full app build:
Still outstanding
Draft, and the reason is unchanged: the menu-bar interaction has not been exercised on a running desktop build, and there is no post-restart input→reply witness showing the active runtime and marker in both switch directions. That is the remaining approval gate and I am not claiming it — the isolated executable controls above do not supply it.
🤖 Generated with Claude Code