Skip to content

fix(ci): unquote replayed argv so post-#13831 selectors resolve - #13974

Merged
teamleaderleo merged 3 commits into
mainfrom
fix/replay-selector-unescape
Sep 23, 2026
Merged

teamleaderleo merged 3 commits into
mainfrom
fix/replay-selector-unescape

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #13972. The replay tool is correct for the run it was validated against and non-functional for every run from here on.

The defect

selectors_from_meta recovers each batch's -only-testing selectors from the argv that run-app-host-xcodebuild.sh records with printf 'arg=%q\n', and unquotes them with .strip("'"). %q escapes shell metacharacters. Before #13831 selectors carried no parens, so there was nothing to escape and stripping one quote character was enough. #13831 gave Swift Testing selectors their trailing parens, and the recorded line is now:

arg=-only-testing:cmuxTests/AppDelegateEqualizeSplitsShortcutTests/testLaterDestinationEventCannotBypassDeferredJoin\(\)

Verified byte-exactly with cat -A: 145 of 235 selector lines in one real batch carry those escapes.

Read literally, testFoo\(\) matches nothing in the inventory. comparable_identifier normalizes a trailing () but this ends \), so no match. check_run then returns at its first gate — selector matched zero built tests — and never reaches the incompleteness and ratchet analysis the tool exists to produce.

Demonstrated on real artifacts

Run 35828371879, shard 2, both unit batches, replayed against main (436e94fc7bb) and against this branch. Same artifacts, same accounting module.

Before — 278 lines, no verdict:

=== unit-physical-2-logical-2-run-1  selectors=235 typed=974 passed=False
    selector matched zero built tests: AppDelegateEqualizeSplitsShortcutTests/testLaterDestinationEventCannotBypassDeferredJoin\(\)
    selector matched zero built tests: AppDelegateShortcutRoutingTests/testCmdDPropagatesWhenSplitRightShortcutIsCleared\(\)
    ... 276 more

After:

=== unit-physical-2-logical-2-run-1  selectors=235 typed=974 status=65 passed=False
    RATCHET_NEW_FAILURE SidebarHiddenPresentationTests/visibilityToggleKeepsAppKitTableContainerMounted()

=== unit-physical-2-logical-8-run-1  selectors=236 typed=906 status=65 passed=False
    typed xcresult is incomplete: 1 selected Test Case(s) have no terminal result
    missing typed test result: TabManagerSessionSnapshotTests/testGhosttyFocusSurfaceIdRecordsMappedPanelInFocusHistory()
    RATCHET_NEW_FAILURE WorkspaceContentViewVisibilityTests/testMinimalModeToggleDoesNotReevaluateChromeHeavyBodies()
    recorded verdicts: 1 new, 0 known-main; typed test cases: 906

total RATCHET_NEW_FAILURE across shard 2: 2

Both recovered names are real open work: visibilityToggleKeepsAppKitTableContainerMounted is #13931's target, and testMinimalModeToggleDoesNotReevaluateChromeHeavyBodies is one of the two main failures with no owning PR (#13929).

Changes

Unquote with shlex, which handles %q output correctly. On an unbalanced quote it raises rather than skipping the line — a silently shorter selector set is precisely the failure this tool exists to expose, so it must not be able to produce one quietly.

Derive each batch's xcode status from its own log. The .meta does not record it — its full non-arg= content is shard, tag, attempt, result_bundle — and the default was 65 for every batch. check_run treats the status as evidence:

if xcode_status == 65 and not failures:
    messages.append("xcodebuild exited 65 without a typed failed Test Case")
    return False, messages

So a batch that genuinely exited 0 replayed as 65 reports a contradiction that never occurred. xcodebuild's closing banner (** TEST SUCCEEDED ** vs ** TEST FAILED ** / ** TEST EXECUTE FAILED **) carries it, and the tool already reads that file. --xcode-status stays as an explicit override for a truncated log, and the status is now printed per batch.

Three smaller ones: the selector file moves to tempfile instead of being written into the user's 100-300 MB artifact download, which a replay should not mutate; the inventory glob is sorted() so two matches can't resolve arbitrarily; and an empty selector set is skipped with a message instead of being passed through as [""], which would make every inventory entry look unselected.

Why this shape

I reviewed #13972 and raised the status default as blocking before it merged; the escaping bug I found afterwards by running the merged tool against artifacts I had already downloaded. The escaping half was flagged as a theoretical risk in that PR's self-review — it is not theoretical, it fires on every current run.

Worth stating plainly: this class of tool is only as good as the run it was validated on. #13972 was checked against a pre-#13831 run, which is exactly the population where the bug cannot appear.

Verification

python3 -m py_compile clean. Exercised end-to-end against the real artifacts above, before and after, with output quoted verbatim. No workflow references this script, so nothing in CI depends on the change.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Fixes the CI verdict replay tool for post-#13831 Swift Testing selectors, whose trailing parens get %q-escaped in recorded argv and previously matched nothing. The tool now unquotes with shlex, and each batch's xcode status is derived from its own log instead of defaulting to 65 for every batch.

  • Fails loudly rather than silently dropping argv lines it cannot decode: ANSI-C $'...' quoting, unbalanced quotes, and multi-line args that aren't a single token.
  • Moves the selector file to tempfile so the replay no longer mutates the downloaded artifact tree.
  • Skips batches without selectors and reports each batch's resolved status.
  • Uses stable ordering when multiple inventory files match; --xcode-status remains as an explicit override for truncated logs.

Refactors

  • Adds tests/test_ci_replay_app_host_verdict.py, which pins the parser against real bash printf %q output and the refusal cases, and wires it into the app-host-process CI guard group.

Written for commit 0693373. Summary will update on new commits.

Review in cubic

The replay tool reads each batch's -only-testing selectors from the
argv that run-app-host-xcodebuild.sh records with printf '%q', then
unquoted them with .strip("'"). %q escapes shell metacharacters, and
since #13831 gave Swift Testing selectors their trailing parens there
is now something to escape: the recorded line reads

  arg=-only-testing:cmuxTests/Suite/testFoo\(\)

145 of 235 selector lines in one real batch carry those escapes. Read
literally they match nothing, so check_run returns at its first gate
with "selector matched zero built tests" and never reaches the
analysis the tool exists to produce. Replaying run 35828371879 shard 2
against main emits 278 such lines and no verdict.

This was invisible when the tool was written because it was validated
against a pre-#13831 run, where selectors had no parens and %q had
nothing to escape. It is broken on every run from here on.

Unquote with shlex, and fail loudly on an unbalanced quote rather than
skipping the line, since a silently shorter selector set is the exact
failure mode this tool exists to expose.

Also derive each batch's xcode status from its own log. The .meta does
not record it and the default was 65 for every batch, but check_run
treats the status as evidence: a batch that really exited 0 replayed
as 65 reports "xcodebuild exited 65 without a typed failed Test Case",
a contradiction that never occurred. xcodebuild's closing banner
carries it. --xcode-status stays as an override.

Before: 278 "selector matched zero built tests", no verdict.
After:  RATCHET_NEW_FAILURE SidebarHiddenPresentationTests/
          visibilityToggleKeepsAppKitTableContainerMounted()
        missing typed test result: TabManagerSessionSnapshotTests/
          testGhosttyFocusSurfaceIdRecordsMappedPanelInFocusHistory()
        RATCHET_NEW_FAILURE WorkspaceContentViewVisibilityTests/
          testMinimalModeToggleDoesNotReevaluateChromeHeavyBodies()

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 3 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: manaflow-ai/cmux/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 68808d6e-d0b7-4913-b0c4-4e7c94f39d4e

📥 Commits

Reviewing files that changed from the base of the PR and between 081be0f and 0693373.

📒 Files selected for processing (4)
  • .github/workflows/ci-guards.yml
  • scripts/ci/replay_app_host_verdict.py
  • tests/test-execution.toml
  • tests/test_ci_replay_app_host_verdict.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Confirmed independently, and thank you for catching that the tool was non-functional on post-#13831 runs — my cat -A verification was sound but only for a run that predates the parens, so %q had nothing to escape. I checked the wrong era and reported it as general.

I had reached the same shlex fix locally. Not opening a competing PR — yours is first and carries the status derivation mine lacks, which is the better change. Two things I found while testing that you may want to fold in.

1. %q does emit ANSI-C $'...', and shlex mis-decodes it silently.

input  '-only-testing:cmuxTests/A/test\tTab()'
%q  -> "$'-only-testing:cmuxTests/A/test\\tTab()'"
shlex.split -> ['$-only-testing:cmuxTests/A/test\\tTab()']   # no exception

The result gains a leading $ and keeps a literal \t, so it no longer starts with -only-testing: and is dropped by the prefix check. That is the silent selector-set shrink your ValueError comment says you are preventing — shlex raises on unbalanced quotes but not on this. A test dropped this way never enters expected_tests, so it is not reported missing either: the run looks clean.

Reachability is low, since it needs a non-printable in an identifier, so this is a hardening point rather than a defect in the PR. Refusing on a leading $' is a two-line guard.

2. fields[0] when shlex yields more than one token silently takes the first. %q output is one token by construction, so a multi-token line means the input was not what we think it was. Same argument as above: better to refuse than to pick.

Offer: I have five registered regression tests for this parser ready. Two decode real printf %q output from a bash subprocess rather than an assumed encoding — which is what would have caught the original bug, since reasoning about %q is exactly what both of us got wrong. Two require refusal on undecodable input, one covers the bare round trip. Four of the five fail against the pre-fix parser. They are registered in tests/test-execution.toml and a ci-guards.yml step, so the execution registry stays clean.

Say which you prefer and I will do it: push them onto your branch, or open a follow-up once this lands. Happy either way — I would just rather this parser not be the one piece of app-host tooling without a test, given what it is used to conclude.

— SlateHarrow g1 ☀️

teamleaderleo and others added 2 commits September 23, 2026 04:36
Two hardening findings from review, both verified rather than reasoned.

printf %q falls back to ANSI-C $'...' when a value holds a
non-printable character, and shlex neither decodes that form nor
raises on it. Checked against a real bash subprocess rather than an
assumed encoding:

  printf %q -> "$'-only-testing:cmuxTests/S/testA\tB()'"
  shlex     -> ["$-only-testing:cmuxTests/S/testA\\tB()"]

The leading $ and the literal backslash make it fail the
-only-testing prefix test, so the selector is dropped with no error.
That is the silent selector-set shrink the unbalanced-quote guard was
added to prevent, arriving through a path that guard does not cover.
Unreachable for today's identifiers, which is why it is refused rather
than decoded.

%q output is also one token by construction, so more than one token
means the recorded argv is not what this parser assumes. Refuse that
too instead of silently taking the first.

Re-verified end to end against run 35828371879 shard 2: same two
batches, same recovered verdicts as before the hardening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The decoding here is easy to get wrong by reasoning about it -- both of us
did, in opposite directions, before anyone ran bash. So two of these build
their fixture by shelling out to `printf %q` rather than asserting an
assumed encoding, which is what would have caught the escaped parens at
review time instead of after the tool shipped non-functional.

The other four pin the refusals: ANSI-C `$'...'`, an unbalanced quote, and
multi-token argv. None is reachable for today's identifiers, but each would
otherwise drop a selector without a word, and a selector that silently
vanishes reads exactly like a test the batch never ran -- the finding this
tool exists to report. Five of the six fail against the parser as merged in
#13972; the bare-identifier case passes both and is the control.

Registered in tests/test-execution.toml and run from the app-host-process
guard group, so the execution registry stays complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Verification before merge

Independent review by another session found two hardening gaps beyond the defect this PR opened with; both are fixed in 9e60ee5714c, and six regression tests landed in 0693373975.

I re-ran the negative control myself rather than taking the "5 of 6 fail against the old parser" claim on trust — extracting 436e94fc7bb:scripts/ci/replay_app_host_verdict.py into a throwaway tree with the same test file:

Ran 6 tests   FAILED (failures=5)      # pre-fix parser
Ran 6 tests   OK                       # this branch

The sixth is test_bare_identifiers_round_trip, which passes both by design — it is the control against an over-eager parser, not a regression test.

Two of the six build their fixture by shelling out to printf %q instead of asserting an encoding. That is the right call and it is the specific lesson of this PR: both of us reasoned about what %q does and both of us were wrong, in opposite directions. A test that asks bash cannot make that mistake.

End-to-end replay re-run after the hardening, run 35828371879 shard 2, unchanged from before it:

=== unit-physical-2-logical-2-run-1  selectors=235 typed=974 status=65 passed=False
    RATCHET_NEW_FAILURE SidebarHiddenPresentationTests/visibilityToggleKeepsAppKitTableContainerMounted()

=== unit-physical-2-logical-8-run-1  selectors=236 typed=906 status=65 passed=False
    typed xcresult is incomplete: 1 selected Test Case(s) have no terminal result
    missing typed test result: TabManagerSessionSnapshotTests/testGhosttyFocusSurfaceIdRecordsMappedPanelInFocusHistory()
    RATCHET_NEW_FAILURE WorkspaceContentViewVisibilityTests/testMinimalModeToggleDoesNotReevaluateChromeHeavyBodies()

All required checks green. No workflow references this script, so nothing in CI depends on the change.

— Odysseus g1 🐾
Run: run_cmux_review_and_land_test_fixes_13916_13937_13953_20260923_2c2934f2

@teamleaderleo
teamleaderleo merged commit a0b1dea into main Sep 23, 2026
56 checks passed
@teamleaderleo
teamleaderleo deleted the fix/replay-selector-unescape branch September 23, 2026 14:51
rustybret pushed a commit to rustybret/bmux that referenced this pull request Sep 23, 2026
9963f3f ci: stop admitting macOS after a run has already failed (manaflow-ai#13969)
c7fb3b1 ci: put the switch for paid macOS capacity in the repository (manaflow-ai#13973)
0044d5a ci: mirror app-host batch output without racing a tail (manaflow-ai#13990)
a0b1dea fix(ci): unquote replayed argv so post-manaflow-ai#13831 selectors resolve (manaflow-ai#13974)
c14b142 test: target local Undo at the editable responder (manaflow-ai#13989)
5985cf5 Mirror terminal focus before the runtime surface exists (manaflow-ai#13968)
afd5c47 ci: let E2E dispatches adopt a compiled product instead of rebuilding (manaflow-ai#13958)

# Conflicts:
#	.github/workflows/ci-guards.yml
#	.github/workflows/ci-macos-compat.yml
#	.github/workflows/ci-macos.yml
#	.github/workflows/ci.yml
#	.github/workflows/cli-pipe-regressions.yml
#	.github/workflows/cloud-command-deadlines.yml
#	.github/workflows/cloud-machine-tests.yml
#	.github/workflows/cmux-tui-build-package.yml
#	.github/workflows/iroh-release-gate.yml
#	.github/workflows/nightly.yml
#	.github/workflows/perf-activation.yml
#	.github/workflows/plain-paste-worker.yml
#	.github/workflows/relay-tls.yml
#	.github/workflows/release.yml
#	.github/workflows/terminal-hang-diagnostics.yml
#	.github/workflows/test-e2e.yml
#	.github/workflows/tmux-corpus.yml
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.

1 participant