Uh oh!
There was an error while loading. Please reload this page.
ci: run the test suite on Windows as well as Linux - #390
ci: run the test suite on Windows as well as Linux#390Jason Robert (jrob5756) merged 14 commits into
Conversation
CI ran ubuntu-latest only. Conductor has a substantial amount of Windows- specific code that nothing executed: cli/pid.py is an entire OpenProcess/GetExitCodeProcess ctypes implementation, cli/bg_runner.py handles CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS and job-object breakaway, and there are sys.platform branches in cli/app.py and cli/update.py. Three bugs found in one week of Windows use all reached a release: microsoft#342 (JSON truncation via a WINDOWS-only chunked-write path), microsoft#344 (conductor stop orphaning runs because CTRL_BREAK_EVENT needs a shared console) and microsoft#382 (MarkupError through the console path). The gap is bidirectional, which is the part worth stressing. While fixing microsoft#344 I wrote a test that patched conductor.cli.app.os.kill; because os is one shared module object, on POSIX that also patched pid.py's liveness probe and turned DEAD into UNKNOWN. It passed on Windows and would have failed on Ubuntu. In the other direction, the Windows liveness logic is covered everywhere by patching a _kernel32 mock -- good design, but it means no job anywhere validates the real ctypes argtypes/restype, so an ABI mistake would be CI-green and fail only for users. Deliberately ONE Python version on Windows rather than a full 2x2: the platform is the variable, not the interpreter, and the install-scripts job already provisions a Windows runner on every PR, so this is incremental cost rather than a new platform commitment. Two things this required beyond the matrix: - The 'Remove bundled Copilot CLI binary' step used ind, which is not a native Windows command. It now runs under shell: bash (Git Bash is preinstalled on the runner) and also matches the Windows venv layout, where scripts live under Scripts/ and the binary carries a .exe suffix. - Coverage upload is now gated on ubuntu-latest as well as 3.12, so a second runner cannot upload a partial report for the same commit and skew the totals. I expect the first Windows run to surface failures rather than be green -- tests that fake a platform via sys.platform, and any assertions on POSIX-shaped paths or rendered console output, are the likely candidates. Better to see them than to keep not knowing. Fixesmicrosoft#385
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@## main #390 +/- ##
=======================================
Coverage ? 91.78% =======================================
Files ? 112 Lines ? 18278 Branches ? 0 =======================================
Hits ? 16777 Misses ? 1501 Partials ? 0 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Frank Li (franklixuefei)
commented
Aug 8, 2026
First Windows run: 30 failed, 5,076 passed (99.4%)Better than I expected, and more useful than a green run would have been — every failure is a real portability bug that was invisible before. All 30 are test-level assumptions, not conductor defects. I checked each class against the product code; nothing here is a bug a user would hit.
Four distinct causes, all mechanical: 1. Hardcoded POSIX separators (~20 of 30) The product normalises correctly; the test compares against a literal. 2. POSIX file modes (5, all in
3. Line endings (1) 4. POSIX-shaped fixture paths (1) The test builds a path the product then correctly rejects. How would you like this split?I do not think 30 test fixes belong in the same PR as "add a CI job" — it makes the review much harder and mixes two concerns. Three options, and I am happy with any:
My preference is 2 — the fixes are mechanical and a blocking job from day one is worth more than an advisory one — but it is your call on review load, and 1 is the lower-risk way to start. One thing worth noting either way: the platform-specific modules this job exists for — |
The Windows CI job added in the previous commit found a real product defect on its
first run, alongside 29 test-level POSIX assumptions.
Product fix — `_format_toml` built TOML by interpolating values straight into *basic*
strings, which process backslash escapes. A `path` registry whose source is a Windows
path (`C:\Users\alice\...`) therefore serialised `\U`, which TOML reads as a Unicode
escape and rejects with "Invalid hex value" — so `conductor registry add` wrote a
`registries.toml` that could never be read back. Not Windows-only: any source
containing `\` or `"` corrupted the file on every platform. Values are now escaped per
the TOML v1.0 spec. This single fix cleared all 10 `test_registry/test_integration.py`
failures.
Regression coverage: the existing round-trip test used `/dev/workflows`, a POSIX path
with nothing to escape, which is why this was never caught. Added a round-trip over
backslashes plus a parametrised case for each TOML metacharacter — `\U` (invalid hex),
`\t`/`\n` (silent corruption rather than an error), `\x`, an embedded quote, and a
trailing backslash. Verified they fail without the fix: reverting `_toml_escape`
turns all 6 red and nothing else.
Test-level fixes, by cause:
- POSIX permission bits (`test_manager_truncation`) — `stat.S_IMODE` reports 0o666/0o777
on Windows whatever mode was requested. The mode assertions are now POSIX-guarded;
the surrounding behaviour still runs everywhere.
- `test_spill_os_error_falls_back_to_marker_without_path` simulated failure with
`mkdir(mode=0o500)`, a no-op on Windows, so the spill silently succeeded and produced
the wrong marker. It now patches `os.open` to raise, exercising the same
`except (OSError, ValueError)` branch deterministically on every platform — better
coverage than the permission side-effect it replaces.
- Path separators — `.copilot/skills`, `.hermes/profiles/chloe` and the spill marker
were compared against literal forward slashes. The discovery warning embeds `repr()`
of the search list, which doubles backslashes on Windows, so that assertion accounts
for both forms rather than being weakened.
- `Path("/plug")` and `Path("/skills/acme")` are absolute on POSIX but not on Windows,
which needs a drive. Anchored to the current drive root via a helper.
- `~` expansion — Windows resolves `~` from `USERPROFILE`, not `HOME`, so setting `HOME`
alone left `expanduser()` pointing at the real profile.
- Line endings — the child's text-mode stdout emits `\r\n`; asserted via `splitlines()`.
- `chmod(0o000)` blocks neither a Windows owner nor root; those tests now skip on
Windows using the same idiom already present in `test_discovery.py`.
- `Skill.md` vs `SKILL.md` cannot express a mis-cased filename on a case-insensitive
filesystem, so that scenario skips on Windows.
Verified on Windows: 4,717 passed. The 3 remaining failures (`test_aca_runner`,
`test_agent_iteration_limits`) reproduce identically with this commit's production
change reverted, so they are pre-existing and environmental, not introduced here.
`ruff check` and `ruff format --check` are clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ba2dfe2e-2c85-4d70-88f9-a604a700a0c4Frank Li (franklixuefei)
commented
Aug 8, 2026
Correction, and a result: the Windows job found a product bug on its first runWhen I opened this I reported the first Windows run as "30 failures, all test-level POSIX Ten of the thirty were one real defect in The bug
lines.append(f'source = "{entry.source}"')TOML basic strings process backslash escapes. A source = "C:\Users\alice\AppData\Local\workflows"
This is not Windows-only. It is only reliably reachable on Windows because paths there start Fixed by escaping per the TOML v1.0 spec. Why the existing tests missed itThe round-trip test used a POSIX path with nothing to escape: "dev": RegistryEntry(type=RegistryType.path, source="/dev/workflows"),Added a backslash round-trip plus a parametrised case per metacharacter: The other 29Genuinely test-level, grouped by cause in the commit message: POSIX mode bits ( One of these is now a better test on both platforms: On the earlier question about how to split thisI asked which of three split options you'd prefer, including landing the job VerificationRun locally on Windows (Python 3.14): 4,717 passed. The 3 remaining failures Happy to split the product fix into its own PR ahead of the CI change if you'd prefer them |
…obing POSIX paths there
`validate_connection` carries a comment saying to keep it in sync with the SDK's
`_find_cli`. It had drifted, in two ways — both surfaced by the new Windows CI job.
1. The bundled-CLI probe only looked for `_bundled/claude`. The SDK names that binary
per-platform (`claude.exe` on Windows, `claude` elsewhere — see `_find_bundled_cli`),
so on Windows conductor reported "Claude CLI not found" even with the bundled binary
present and usable. Also probes `claude.exe` via `shutil.which`, since PATH may hold
npm's `claude.cmd` shim while the native executable lives elsewhere.
2. The hardcoded fallback list probed POSIX paths on every platform. `Path("/usr/local/bin/claude")`
is rooted but driveless, so on Windows it resolves against the current drive —
`C:\usr\local\bin\claude` — a location any unprivileged local user can create. A planted
file there would make conductor report the CLI as available. The SDK refuses these on
Windows for exactly this reason, describing it as a binary-planting probe; conductor now
matches, probing only `~/.local/bin/claude.exe` there.
The remaining test fixes follow the same patterns as the previous commit: POSIX mode
assertions guarded (`S_IMODE` reports 0o666 on Windows whatever mode was requested), and
the explicit-spill-dir assertion compares against the *resolved* directory, since the
manager resolves before writing and `resolve()` also expands 8.3 short components on
Windows.
Windows CI went 30 failures -> 4 with the previous commit; these are those 4.
Verified locally on Windows: 4,716 passed. The 3 pre-existing failures (`test_aca_runner`
x2, `test_agent_iteration_limits`) reproduce with the production change reverted;
`test_performance` is `pytestmark = pytest.mark.performance` and is deselected in CI.
`ruff check` and `ruff format --check` clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ba2dfe2e-2c85-4d70-88f9-a604a700a0c4result.output is a JSON document, so a Windows path inside it carries JSON-escaped backslashes (custom-spill\\\\mcp-...). Comparing the raw path could therefore never match on Windows. Accept either encoding rather than weakening the assertion to a loose substring. Last of the 30 Windows failures: the job now runs 5,106 tests green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ba2dfe2e-2c85-4d70-88f9-a604a700a0c4
Frank Li (franklixuefei)
commented
Aug 8, 2026
✅ Green — 10/10, Windows included
The 30 → 0 walk, and what it cost to get there:
The second product defect
Scope note: conductor uses this for a boolean availability check, not to select what it spawns — so On being wrongI opened this PR asserting "all 30 are test-level POSIX assumptions, zero product defects." Two of That is also the argument for the job existing: it found two real bugs on its first two runs, in code VerificationLocal Windows run (Python 3.14): 4,716 passed. Three failures ( Ready for review. Happy to split the two product fixes into their own PR ahead of the CI change if |
Jason Robert (jrob5756)
left a comment
There was a problem hiding this comment.
Thanks for doing the triage and digging in! The Windows leg earns its place: it found a real cross-platform bug in _format_toml on its first run, and it makes TestIsProcessAliveWindowsReal execute for the first time since it was written.
Most of my comments are about the two production fixes that came along for the ride, not the CI change itself
The one I can't leave inline: neither production fix has a CHANGELOG entry. The registry TOML bug hit every platform, not just Windows, so it belongs under Unreleased > Fixed alongside the Claude CLI detection change.
I pulled both claude-agent-sdk wheels to check the SDK claims. Details in the comment on claude_agent_sdk.py.
| # driveless path such as "/usr/local/bin/claude" resolves against the current | ||
| # drive (C:\usr\local\bin\claude) — a location any unprivileged local user can | ||
| # create — so probing it would let a planted file report the CLI as available. | ||
| # The SDK refuses these on Windows for exactly this reason. |
There was a problem hiding this comment.
I pulled both wheels to check this. claude-agent-sdk 0.2.87, which is what uv.lock pins and what CI installs, has no platform branch in _find_cli at all. It probes all six POSIX paths on every OS, Windows included. The branch you're mirroring shows up much later: 0.2.134 has it, with almost this exact binary-planting wording.
Two consequences. The comment tells a future maintainer the hole is closed upstream, and at the pinned version it isn't, since the SDK still probes and still spawns a planted C:\usr\local\bin\claude. And on Windows, a user whose CLI sits at ~/.claude/local/claude (where Claude Code's local installer puts it) now gets validate_connection() == False even though the SDK would find and run it. What they see is "Failed to connect... Check your credentials and network connection", plus a suggestion to npm-install a CLI they already have.
Worth noting only /usr/local/bin/claude is driveless. The other five are Path.home()-anchored, so the planting argument doesn't cover them. Dropping just that one entry on Windows would give you the hardening without the false negative.
If you keep the narrowing, name the version you audited. This file already does that well at line 1274 ("audited against claude-agent-sdk 0.2.87").
| # The SDK refuses these on Windows for exactly this reason. | |
| # Note this narrows Conductor's report only: claude-agent-sdk 0.2.87 (the | |
| # locked version) still probes these unconditionally in _find_cli, so a | |
| # planted binary can be spawned even when this returns False. Later SDKs | |
| # (>= 0.2.13x) refuse them; this matches that behaviour ahead of the pin. |
| return True | ||
| # On Windows, PATH may hold npm's claude.cmd shim while the native claude.exe | ||
| # lives elsewhere; the SDK probes the .exe name explicitly for the same reason. | ||
| if is_windows and shutil.which("claude.exe"): |
There was a problem hiding this comment.
Line 805 returns first here. On Windows shutil.which("claude") already applies PATHEXT, so a claude.exe on PATH gets found there and this never runs.
0.2.134 makes the equivalent probe reachable by vetting the hit with _is_windows_native_exe first, so an npm claude.cmd shim doesn't short-circuit it. Without that vetting the branch is dead. Either port the vetting or drop the branch and its comment.
| from pathlib import Path | ||
| # Bundled CLI takes precedence (matches the SDK's own resolution). | ||
| is_windows = platform.system() == "Windows" |
There was a problem hiding this comment.
Every other Windows check in src/ uses sys.platform == "win32". There are 11 of them, and cli/pid.py:44 calls it out as the house idiom.
Beyond consistency it costs testability: the repo fakes Windows by patching sys.platform (test_script.py:472 does exactly this), and nothing patches platform.system. Switching would let you exercise the new branch from Linux.
Needs import sys at module scope, which this file doesn't currently have.
| is_windows=platform.system()=="Windows" | |
| is_windows=sys.platform=="win32" |
| for name, entry in config.registries.items(): | ||
| lines.append("") | ||
| lines.append(f"[registries.{name}]") |
There was a problem hiding this comment.
The values are escaped now, but the table key still isn't, and _validate_registry_name only rejects / and \. I ran it: my.team, has"quote, with space, a=b and a#b all write successfully and then never load.
That's a worse version of the bug you're fixing, because add_registry, remove_registry and get_registry all call load_config() first. Once the file is written the user can't remove the entry that broke it, and unrelated registries go down with it. conductor registry add my.team reports success.
Names also become cache directory names (registry/cache.py:93), and ", :, *, ? are illegal in Windows filenames, so a charset check at write time covers both problems. Something like ^[A-Za-z0-9._-]+$ in _validate_registry_name would subsume the existing / and \ cases.
If you'd rather escape than reject:
| lines.append(f"[registries.{name}]") | |
| lines.append(f'[registries."{_toml_escape(name)}"]') |
| @patch("conductor.providers.claude_agent_sdk.ClaudeAgentOptions", Mock) | ||
| async def test_validate_connection_returns_true_when_bundled_cli_present(self) -> None: | ||
| """The SDK ships a bundled CLI under _bundled/claude — detection should succeed.""" | ||
| """The SDK ships a bundled CLI under ``_bundled/`` — detection should succeed. |
There was a problem hiding this comment.
The new Windows branch in validate_connection has no test behind it. On Linux is_windows is always False, so it's dead code. On the Windows runner the bundled claude.exe ships in the wheel, so the probe returns True at line 801 and the fallback block never runs there either. You could delete the whole branch and all three CI legs stay green.
It's testable from Linux, since platform is imported inside the function:
withpatch("platform.system", return_value="Windows"):
...Asserting the probe set per platform would have caught the SDK version mismatch while you were writing it, because you'd have had to put ~/.claude/local/claude -> False in the test as a literal expectation.
| assert spill_file.exists() | ||
| assert spill_file.read_text() == full_text | ||
| assert stat.S_IMODE(spill_file.stat().st_mode) == 0o600 | ||
| if _POSIX_PERMS: |
There was a problem hiding this comment.
This reports PASSED on Windows having asserted nothing about the mode of a file that holds raw tool output. Same shape at line 263, at test_pydantic_ai_mcp.py:349 and :411, and at test_claude_agent_sdk.py:1826, where the file holds resolved env values and Authorization headers.
You already have the honest mechanism a few lines up. Splitting the mode assertion into its own @requires_posix_perms test makes the report say SKIPPED rather than PASSED, which is the difference between a known gap and an invisible one.
| used to turn that same mistake into silence — one fewer skill, no message. | ||
| """ | ||
| @pytest.mark.skipif( |
There was a problem hiding this comment.
The reason is accurate, but it describes the fixture rather than the behaviour. expand_skills_root reports any immediate subdirectory without a SKILL.md; the mis-cased filename is only one way to trigger it. A file that isn't a skill manifest keeps the assertion running everywhere:
(root/"oops"/"notes.md").write_text("a file that is not SKILL.md")This one matters more than most, because mis-casing SKILL.md is a Windows-authoring mistake specifically: it works locally and then vanishes for Linux teammates. Windows is where you'd most want the diagnostic covered. Keeping the mis-cased variant as a separate POSIX-only test would preserve both.
| find .venv -path '*/copilot/bin/copilot*' -delete 2>/dev/null || true | ||
| find .venv -iname 'copilot.exe' -delete 2>/dev/null || true |
There was a problem hiding this comment.
The binary ships inside the package on both platforms (copilot/bin/copilot on Linux, copilot/bin/copilot.exe in the win_amd64 wheel), and there's no console-script entry point, so it never lands in Scripts/. The pattern above already matches the Windows layout: the leading */ absorbs Lib/site-packages/ and the trailing * absorbs .exe. I checked against both wheels.
So line 138 is an unanchored -iname sweep of the whole venv that deletes nothing the line above missed.
Separately, find exits 0 when it matches nothing, and 2>/dev/null || true hides -delete failures, which are likelier on Windows against a read-only file. Either way the binary survives, tests taking the real CLI path hang, and the job dies at the 10-minute timeout with an error naming neither the CLI nor this step.
| find .venv -path '*/copilot/bin/copilot*' -delete 2>/dev/null || true | |
| find .venv -iname 'copilot.exe' -delete 2>/dev/null || true | |
| found=$(find .venv -path '*/copilot/bin/copilot*' -print -delete) | |
| if [ -z "$found" ]; then | |
| echo "::error::No bundled Copilot CLI matched. The SDK layout changed; tests taking the real CLI path will hang until the job timeout." | |
| exit 1 | |
| fi | |
| echo "Removed: $found" |
| # is preinstalled). Without it the step fails on Windows, since `find` | ||
| # is not a native command there. |
There was a problem hiding this comment.
find.exe ships in System32, so a bare find does resolve to a real binary under cmd or pwsh. It's the legacy DOS search tool with unrelated semantics. shell: bash is still the right fix, but "not a native command" would send someone hunting for a missing tool rather than a colliding one.
While you're in here, the header block at line 9 still describes the test job as "Python 3.12 and 3.13" with no platform, and it does name platforms for install-scripts further down.
| # is preinstalled). Without it the step fails on Windows, since `find` | |
| #is not a native command there. | |
| # is preinstalled). Without it the step fails on Windows, where a bare | |
| #`find` resolves to the unrelated DOS search tool in System32. |
Addresses the review on microsoft#390. The SDK claim was wrong, and I verified it against the wheels rather than the changelog. claude-agent-sdk 0.2.87 -- the version uv.lock pins and CI installs -- has no platform branch in _find_cli at all: it probes all six POSIX paths on every OS, Windows included. The comment told a future maintainer the hole was closed upstream when at the pinned version it is not. Worse, the narrowing caused a false negative. Only "/usr/local/bin/claude" is driveless; the other five are Path.home()-anchored, so dropping them made validate_connection() return False on Windows for a CLI at ~/.claude/local/claude -- where Claude Code's own local installer puts it, and which the SDK would find and run. The user saw "Failed to connect ... check your credentials" plus advice to npm-install a CLI they already had. Now only the driveless entry is skipped, the comment names the audited version, and it says plainly that this narrows conductor's report only. Dropped the claude.exe branch. shutil.which applies PATHEXT on Windows, so a claude.exe on PATH is already found by the line above and the branch was dead. 0.2.134 makes the equivalent probe reachable by vetting the hit first; there is nothing to port from 0.2.87. platform.system() -> sys.platform == "win32", the house idiom (11 other sites; cli/pid.py calls it out). That is what makes the branch testable from Linux, since the repo fakes Windows by patching sys.platform and nothing patches platform.system. Registry names could still corrupt the config. Escaping the values left the table key unescaped and the charset unchecked, so 'has"quote', 'with space', 'a=b' and 'a#b' all wrote successfully and then failed to parse -- and because add_registry, remove_registry and get_registry all load first, the user could not remove the entry that broke it and unrelated registries went down with it. Names are now restricted to [A-Za-z0-9._-], which also keeps them legal as cache directory names on Windows. The charset check alone was not enough: the key is written unquoted, so "my.team" -- which the suggested regex allows -- became [registries.my.team], a nested table, and the entry silently vanished on load rather than failing loudly. The key is quoted now as well, and a round-trip test pins it. Tests. The Windows branch had none: on Linux is_windows is always False, and on the Windows runner the bundled claude.exe short-circuits before the fallbacks, so the block ran on no CI leg and could have been deleted with all three staying green. It now asserts the probe *set* per platform, which is what forces ~/.claude/local/claude to be written down as a literal expectation. Registry names get charset and round-trip coverage. CHANGELOG entries added for both production fixes; the PR had none. 2,152 passed across test_registry, test_providers, test_cli and test_executor. ruff check and `ty check src` both unchanged from this branch's baseline (2 and 68).
Frank Li (franklixuefei)
commented
Aug 10, 2026
Thanks — the SDK finding was the important one and it was right. Verified against the wheels rather than release notes, then fixed in The SDK claim, confirmedPulled 0.2.87 and read ifcli:=shutil.which("claude"):
returnclilocations= [
Path.home() /".npm-global/bin/claude",
Path("/usr/local/bin/claude"),
Path.home() /".local/bin/claude",
Path.home() /"node_modules/.bin/claude",
Path.home() /".yarn/bin/claude",
Path.home() /".claude/local/claude",
]So the comment told a future maintainer the hole was closed upstream when at the pinned version it is not. And your second consequence is the one that would have hurt: only Now only the driveless entry is skipped on Windows, the comment names the audited version as you suggested, and it says plainly that this narrows conductor's report only. (0.2.134 is not on our package proxy — latest available is 0.2.132 — so I confirmed the 0.2.87 half directly and took your reading of the later one.) The other two on that file
Registry names — worse than described, and the suggested fix alone was not enoughReproduced all five. But I found the charset check by itself would not have closed it: the key is written unquoted at So I did both of your suggestions: quote the key and restrict the charset. A round-trip test pins that a dotted name stays one registry key. TestsYou were right that the Windows branch had no test behind it, and about why: on Linux It now asserts the probe set per platform, which is exactly the thing that forces One honest caveat on discrimination: those two tests fail against the pre-fix code, but partly for a mechanical reason — the old code used CHANGELOGAdded for both production fixes, as you asked — the registry TOML escaping and the Claude CLI detection change, plus the name-validation fix. Not done hereThe skipped-on-Windows file-mode tests ( Gates2,152 passed across |
Frank Li (franklixuefei)
commented
Aug 10, 2026
CI update: the Windows leg is now failing, and it is doing its job. Merged Confirmed pre-existing rather than assumed. I checked out plain Identical set, identical on this branch. Nothing in this PR touches There appear to be two distinct problems:
That is the third real cross-platform bug this leg has found, after How would you like to sequence it? Three options as I see them:
Happy to take the first or second if you would rather not context-switch; say which and I will do it. I have not guessed, because all three are defensible and it is your PR's scope to spend. Everything else on this PR is green: Lint, Type Check, both Ubuntu legs, Frontend, both Install Scripts legs, and Validate Example Workflows. |
# Conflicts: # CHANGELOG.md
The Windows CI leg this PR adds went red on 7 tests. All of them are real platform bugs in \conductor\, not test artefacts, so they are fixed here rather than skipped -- a CI leg that has to be read past is worse than none. Three defects, all reachable only from a \ile://\ source: 1. The cache key is built from the URL path split on \/\, so a native Windows path arrived as one segment with its backslashes intact. The key kept them and the checkout landed at a drive-absolute location outside the cache root -- the escape the \..\ check exists to prevent, through a door it does not cover. 2. A drive colon survived into a segment, so an owner of \C:_src\ read as a drive, or mid-name as an NTFS alternate data stream. 3. Flattening a deep path into a single owner segment produced a name long enough that \git\ could not create \.git\ inside it (260-char cap). Fixed by folding separators for \ile://\, substituting the characters that change a path's meaning on Windows, and replacing an over-long segment with a digest of itself. Applied on every platform: one workflow file must resolve to the same cache layout wherever it runs, and a Windows-gated branch would never execute on the Linux legs. Separately, \_publish\ tolerated losing a race to a concurrent fetch only for the POSIX errnos. Windows reports a non-empty destination directory as ERROR_ACCESS_DENIED, so the tolerance never applied and a second source resolving to the same SHA failed outright. Safe to accept because the readiness sentinel is written after publishing, so a winner that died mid-clone leaves no sentinel and the tree is re-fetched. And \_is_local_path\ asked \pathlib.Path\, the running platform's flavour, so \/srv/plugins\ was not a local path on Windows. Both conventions now. Tests: \shutil.rmtree\ of a git repo fails on Windows because git marks objects read-only, so the four sites that delete a checkout share a helper that clears the bit. New assertions cover containment directly rather than leaving it to whichever test happened to notice -- reverting any of the three fixes fails them.
Frank Li (franklixuefei)
commented
Aug 11, 2026
Update: I stopped asking how to sequence around the Windows failures and fixed them. I had offered three options for working around the 7 failures on the new Windows leg. That was the wrong question -- a CI leg you have to read past is worse than no CI leg, and this PR exists to make Windows visible. All 7 turned out to be real platform bugs in What was actually brokenThree distinct defects, all reachable only from a
This is what Separately, So the tolerance never applied there and a second source resolving to the same SHA failed the whole fetch. Accepting it is safe because the readiness sentinel is written after publishing: a winner that died mid-clone leaves no sentinel, And Choices worth challenging
Verification
New assertions cover containment directly rather than leaving it to whichever test happened to notice. Reverting each fix independently:
Five tests still fail on my Windows box and do not fail on CI's -- two |
The containment fix traded one Windows failure for another, and CI caught what my box did not: my home directory is shorter than the runner's, so the same path came to 264 characters there and 240 here. Worth stating plainly, because it is the reason this was not visible until now: the escaped path was *short*. Landing the checkout at a drive-absolute location outside the cache had been masking a length problem the whole time. Containing it exposed the second bug rather than causing it. Two changes: - Bound a key segment at 24 characters rather than 48, keeping the tail instead of the head -- what survives is the directory nearest the repository, which is the part someone browsing the cache can recognise, where the head is a drive letter and \Users\. Sized against CI's actual worst case: 264 at 48, 240 at 24, against a 260 limit. - Pass \core.longpaths=true\ to every git invocation. That is git-for- Windows' opt-in to the extended-length API, and without it git refuses any path over 260 regardless of what Conductor generates. Set per-invocation because the depth is a property of the paths Conductor builds, not a preference of the user's. A no-op on POSIX. Neither alone is enough. The bound keeps Conductor's own paths reasonable; \core.longpaths\ covers a user whose cache legitimately sits somewhere deep.
Frank Li (franklixuefei)
commented
Aug 11, 2026
Correction to my last comment, and CI is now green: The first attempt regressed it to 23 failures, and the reason is worth recording because it is the same trap twice. I claimed the containment fix was verified because What that exposed is the more interesting part. The escaped path was short. Landing the checkout at a drive-absolute location outside the cache had been masking a length problem the whole time — so containing it did not cause the second bug, it revealed one that had been there since Two changes in
Neither alone is enough. The bound keeps Conductor's own paths reasonable; I also A/B'd Final state, all against the runner rather than my box:
11 of 11 checks green. |
Review follow-ups on the Windows test matrix, all on the CI change itself. The removal step could not fail and never checked that it worked. `find` exits 0 when it matches nothing, so `2>/dev/null || true` masked genuine errors rather than the no-match case. If the SDK layout ever changes the binary survives, tests taking the real CLI path hang on auth, and the job dies at the 10-minute timeout with an error naming neither the CLI nor this step. It now fails immediately with an actionable annotation. The second `find` deleted nothing the first missed, and the comment justifying it was wrong. Verified against both wheels: the binary ships inside the package (`copilot/bin/copilot`, `copilot/bin/copilot.exe`) with no console-script entry point, so it never lands in `Scripts/`. One pattern covers both layouts -- the leading `*/` absorbs `lib/pythonX.Y/` or `Lib/`, the trailing `*` absorbs `.exe`. `find` is also not absent on Windows: `find.exe` ships in System32. It collides with the DOS search tool, which is a different debugging trail than a missing binary, so the comment now says so. The header block listed the test job as Ubuntu-only, while naming platforms for install-scripts a few lines down. Both paths exercised locally against a real venv: present -> removed, exit 0; absent -> annotation, exit 1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`test_subdirectory_without_skill_md_is_reported` was skipped entirely on Windows, but the reason described the fixture rather than the behaviour. `expand_skills_root` reports any immediate subdirectory without a `SKILL.md`; a mis-cased `Skill.md` is only one way to reach it, and it is the one way that cannot exist on a case-insensitive filesystem. Using a plain non-manifest filename keeps the general assertion live on every platform. The mis-cased case stays as its own test with the existing skip, so nothing is lost. Worth having on Windows specifically: mis-casing `SKILL.md` is a Windows authoring hazard -- it resolves locally and then vanishes for everyone else -- so Windows is where the diagnostic most wants covering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Jason Robert (jrob5756)
commented
Aug 12, 2026
Pushed the four leftovers onto your branch rather than sending you round again — they were all small and all on the CI change itself. Two commits: The removal step now verifies itself. It could not fail and never checked that it worked: Dropped the second Two comment corrections. Split the skills-root test so the general case runs on Windows. The skip reason was accurate but described the fixture, not the behaviour: Gates from a clean lockfile sync: 5,734 passed, 14 skipped. On the file-mode gap — yes, take it as a follow-up. Your scoping argument holds, and there is a second reason to keep it out of here: Three things I want to call out from re-reviewing, because they are the reason this took one round instead of three. You were too hard on your own tests. You flagged that the two probe-set tests fail against the pre-fix code "partly for a mechanical reason" since the old code used Pushing back on my registry suggestion was correct. The charset check alone would have let Not taking any of the three workarounds was the right call. "A CI leg you have to read past is worse than no CI leg" is exactly right, and the cache-escape bug underneath was worth finding — a Approving once CI comes back green. |
# Conflicts: # CHANGELOG.md
`test_plugin_path_survives_the_report` interpolated `tmp_path` straight into a YAML *double-quoted* scalar. Those process backslash escapes, so on Windows the runner's `C:\Users\runneradmin\...` reached the parser as `\U` -- a Unicode escape -- and the fixture failed with "expected escape sequence of 8 hexdecimal numbers, but found 's'" before the assertion under test ever ran. Same class as the `_format_toml` bug this branch already fixed, one layer out: a value with meaning to the serialiser, interpolated as if it were inert. YAML is a superset of JSON, so `json.dumps` produces a scalar that is valid on every platform and round-trips the path exactly. Verified against the literal path from the failing CI run: the old form raises ScannerError, the new form round-trips. Arrived with microsoft#408, so it is not a regression from this branch -- but it is only visible because of the Windows leg this branch adds, and the suite has to be green for that leg to mean anything. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`test_plugin_path_survives_the_report` puts a rich markup marker inside a directory name. `/` is a path separator on Windows, so `plug[/etc/x]` becomes three nested components -- `plug[`, `etc`, `x]` -- and the literal string the assertion looks for cannot appear in the rendered path. Rich's crashing form is a *closing* tag, so it needs the slash. There is no Windows-expressible variant to substitute, which makes this a scenario the platform cannot host rather than an assertion to loosen. Scoped to the one parametrized case rather than the test: `[dim]` is a legal Windows directory name, so the silent-deletion half still runs there. The crashing form keeps its coverage too -- ten other cases in this module put it through error text, agent names, descriptions and manifest messages, none of which touch the filesystem. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Jason Robert (jrob5756)
commented
Aug 12, 2026
Resolved the conflict and got the Windows leg green. The conflict was Two test failures on the Windows leg after the merge, both from The first is the same bug class this branch already fixed, one layer out. The fixture interpolated The second one I want to flag, because the honest answer was a skip and I would rather say why than have it look like giving up. The test puts a rich markup marker inside a directory name, and the I scoped the skip to the one parametrized case rather than the test, so That is the fourth and fifth real cross-platform bug this leg has surfaced, after Windows: 6,258 passed, 58 skipped. Linux: 6,302 passed, 14 skipped. Lint, format and typecheck clean. I also ran the removal step against a genuinely fresh Nothing outstanding from my side. Approving now — the file-mode gap is yours to take as the follow-up we agreed. |
Jason Robert (jrob5756)
left a comment
There was a problem hiding this comment.
LGTM. Approved!
Uh oh!
There was an error while loading. Please reload this page.
The Windows CI job added in #390 exercised this code for the first time and found five failures. One is a real defect. `write_run_record`'s "atomic write" relied on `os.replace`, which is atomic and reader-proof on POSIX but raises `PermissionError` on Windows while another process holds the destination open. This record is read constantly -- `conductor status`, `fleet list`, the TUI's ~2s poll, the `--web-bg` launch gate -- so the write failed, `cli/run.py` swallowed it, and the run silently became undiscoverable and unstoppable: exactly the defect the run record exists to prevent, reachable only on Windows. `_replace_with_retry` retries briefly there; a persistent permission error still surfaces rather than stalling. Predicted as R13 in review and deferred as unlikely -- CI reproduced it deterministically. The remaining four were tests asserting POSIX-only behaviour: - `TestStopProcessUnexpectedOSError` and `test_kill_already_dead_pid_does_not_raise` simulate an errno from the kill primitive by patching `os.kill`. `terminate_process` dispatches to `_terminate_process_windows` (ctypes `TerminateProcess`) there, so the patch is never reached and the assertions describe a path Windows cannot take. Skipped with that reason. Note the sibling `..._removed_when_process_confirmed_dead_after_oserror` was passing on Windows for the same wrong reason, and is covered by the class-level skip. - `test_concurrent_writers_never_yield_torn_read`'s reader now tolerates `PermissionError` alongside `FileNotFoundError`: both are races with `os.replace` itself, not the *torn* read the test exists to rule out. - `_rendered_footer` assumed the footer is the compositor's last strip. It is on Linux; on Windows the trailing strip is blank, so every footer assertion read an empty line. It now finds the row carrying a binding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…an interactive TUI (#431) * feat(fleet): add the Fleet Manager (run records, conductor fleet, TUI) Every run path now writes a run_id-keyed JSON run record describing its mode/PID/workflow/port, so foreground, --web and --web-bg runs are all discoverable the same way. This fixes the bug where a plain `conductor run` was invisible to `conductor stop` and to discovery, which only ever saw port-keyed .pid files written by --web-bg. - fleet/records.py: RunRecord + atomic write/read/prune primitives, tolerant of corrupt JSON and legacy port-keyed .pid files. - fleet/summary.py: RunSummary derived from a bounded event-log tail (status, current step, elapsed, tokens, cost, open gate). Status comes from explicit event markers, never inferred from timing. - fleet/history.py, fleet/retention.py, fleet/launch.py: History enumeration, $TMPDIR/conductor event-log retention, and New Run launch reusing cli/bg_runner.launch_background. - fleet/tui/: Textual app with Runs, Run detail, Providers, Registries, New Run and History screens, plus shared stop/kill/gate actions and debounced terminal notifications. Behind the optional `tui` extra. - cli/fleet.py: `conductor fleet` (bare launches the TUI), `fleet list`, `fleet prune`; `conductor stop` now operates over run records. - settings.py: read-only ~/.conductor/config.toml ([fleet.retention]). - cli/pid.py is retained only so pre-upgrade background runs stay discoverable; parent-side PID writing is gone. Squashed from the E1-E15 epic series; that granular history is preserved at tag backup/fleet-manager-pre-rebase. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(fleet): make the New-run form readable and launchable from a registry The New-run screen had no CSS at all, so Textual laid every widget out `width: auto`: a field's label carried the workflow's entire description on one unwrappable line and ran off the right edge, an unlabelled Checkbox collapsed to a truncated "X…" with nothing to say which field it belonged to, and the Launch button sat below a full-height scroller rather than where it could be seen. - Split each field into a heading (name, type, required), a wrapping muted description line, and the widget itself. - Carry the field name as the Checkbox's own label. - Add DEFAULT_CSS: `width: 100%` is what makes a description wrap, and a `1fr` field list keeps the launch bar on screen. - Focus the first field once a workflow resolves. Also bind `n` on both Registries drill-down screens (the workflows list acts on the highlighted row, matching `k`/`g` on Runs) to open New Run with `<workflow>@<registry>` pre-filled and resolved -- launching the workflow you are looking at no longer means escaping back out and retyping a reference you just navigated through. That makes New Run reachable three levels deep, where popping a single screen on a successful launch would land back on a workflow's inputs with the just-started run nowhere in sight, so `FleetApp.return_to_runs()` unwinds to Runs instead -- where the run appears on the next poll tick. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(fleet): give the TUI a design system and a run preview Five of the six screens carried no CSS at all, so Textual laid them out with raw defaults: tables flush against column 0, no spacing, and every cell the same visual weight as its neighbours. The home screen showed three rows of runs above seventeen blank lines, and the footer silently truncated mid-word, dropping the History binding entirely. Design system: - tui/theme.py is now the single status vocabulary. runs.py, history.py and run_detail.py each defined their own overlapping glyph map, none of which carried colour, so a failure and an unknown outcome landed with identical weight. Colours are Rich style names, not Textual CSS variables -- these render inside Text objects, where $success would be printed literally. - App-level CSS plus a default theme, applied in on_mount rather than as a `theme = ...` class attribute: App.theme is a reactive, and assigning a plain string in the class body replaces the descriptor, which leaves the app looking themed while silently breaking theme switching. - Screens inherit $background, not $surface. Surface is the *elevated* colour (nord #3B4252 vs #2E3440), so painting screens with it produced a flat haze with dim text on top. - `unknown` is left unstyled rather than dim grey: it is History's most common row, and dark-grey-on-dark was near invisible. Run preview: - A summary bar (counts by status, token and cost totals) and a preview pane showing what the one-line row has no column for: PID, port, an open gate's prompt and options, and the workflow's step chain with the current step marked. - The table is height:auto capped at 60% rather than 1fr, which is what removed the dead band between it and the pane. - The trailing column is Mode, not Port: a blank port was the only signal that a run was foreground, and blank reads as "no data yet". New-run form: the Resolve and Launch buttons are gone. Enter already resolved the reference, and a form whose submit control sits below its own scroll region is worse than one submitted from anywhere; both are keystrokes now (ctrl+r / ctrl+s), with a hint line that says why a launch is not yet possible instead of grey ing out a control. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(fleet): open the dashboard on WSL and recover a legacy run's event log Two failures seen against a real background run. Dashboard would not open. On WSL there is no X display and usually no Linux browser, so `webbrowser.open` falls through to `gio`, which answers "Operation not supported" and opens nothing. WSL is now detected and the URL handed to the Windows side -- `wslview` when the `wslu` package is installed, otherwise PowerShell's Start-Process. `explorer.exe` also works but exits non-zero on success, so it cannot report whether it opened anything, which is exactly what the caller needs. The caller made that invisible: it discarded `webbrowser.open`'s return value and notified "Opened dashboard" unconditionally, so a failed open still reported success. It now surfaces the failure and prints the URL, which is the part the user actually needs when no browser will open. No run details. A pre-Fleet-Manager `.pid` file records no event-log path, so current step, tokens, cost and topology all came out blank -- while the log sat on disk beside it. The record does carry the run id and the log's filename ends in it, so the pairing is recoverable. Only an unambiguous match is adopted: run ids are 8 hex chars and a resumed run reuses its predecessor's, so one id can legitimately name several logs (a test suite pinning one produces dozens), and showing a run's details against another run's log is worse than showing none. The lookup does not go through `retention.event_log_root()` -- that creates the directory because it is about to delete inside it, and this is a read-only path that may not exist. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(fleet): give the TUI a design system, a DAG preview and step drill-down Builds the Fleet Manager TUI out from a bare table into something you can actually read a running fleet from. Presentation is factored into leaf modules rather than inlined in the screens that use them: `anim.py` drives every moving glyph from one screen-level frame counter (so they stay in phase and there is one timer, not one per widget), `art.py` holds the whitespace-significant banner art that cannot be edited safely inline, and `dag.py` renders where a run is in its workflow -- shared by the Runs preview and the run-detail header, which both had to answer that question. Two new screens: a splash that covers the run-record scan and per-run bounded log read the first Runs render already blocked on, and a step drill-down (`enter` on a run-detail row) answering what a single step actually did -- its input, output and activity stream -- which neither table above it could show. `summary.py` grows `derive_step_detail` and a bounded head read to feed it, and `records.py` grows `find_event_log_for_run` so a run's log can be located from its id and start time. Runs-screen keys are split into two blocks -- row-scoped (`enter`, `w`, `k`, `g`) then fleet-scoped (`n`, `p`, `r`, `h`, `K`, `q`) -- with `BlockFooter` drawing the rule between them, since ordering alone renders as one undifferentiated run of keys. `enter` needs `priority` to appear at all: DataTable binds it first, hidden, and silently shadowed it. Row-scoped keys retire while the fleet is empty. `K` (kill *all*) moves into the fleet block, away from the single-run `k` it sat one stray Shift from. Also fixes a real engine bug the fleet view exposed: a `questions` node emitted `gate_presented` and never the matching `gate_resolved`, so every consumer tracking "is this run waiting on a human" -- the fleet summary and the dashboard both -- showed the run parked at a question it had already answered, for the rest of the run. `workflow_started` now also records the inputs a run was launched with, so two runs of one workflow are distinguishable in the log by something other than their ids. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(cli): declare --input-json so background launches with inputs work `bg_runner` forwards workflow inputs to the child via `--input-json`, but `conductor run` never declared that option -- so every `--web-bg` launch carrying an input died immediately with exit code 2 ("No such option: --input-json"). That took the Fleet Manager's New Run screen with it: its form is generated from the workflow's declared `input:` block, so it essentially always passes some. The flag is load-bearing rather than incidental. The New Run form coerces each field to its declared type (`fleet/launch.py::_coerce_input` returns a `string`-typed value verbatim) and nothing downstream restores that typing -- the engine's `_apply_input_defaults` only fills in *missing* inputs, so whatever the child's CLI parse produces is final. Routing these through the public `--input` flag would hand them to `coerce_value`, whose command-line heuristic re-guesses a bare `true`/`42`/`[1,2]` into a bool/int/list and silently discards the declared type. `_serialize_input_value`'s docstring claimed `coerce_value` tries `json.loads` first, so a JSON-quoted string would decode back to a string. It does not -- it only tries JSON for values starting with `[` or `{`, so every string arrives with its quotes baked in. That false comment reads as though `--input` already handled this, which is the likeliest reason the option was never wired up; it now describes the real mechanism. The existing tests missed all of this because they patch out `subprocess.Popen` and assert on the argv the launcher *builds*, which only proves it built what the test expected. `TestLaunchedArgvIsAcceptedByTheChildCLI` checks every flag the launcher emits against the child command's real, declared Click options, catching argv/CLI divergence without paying for a subprocess. Also promotes the Fleet Manager to a headline README section alongside the web dashboard -- screenshot, key features, and the breadth-vs-depth split between the two surfaces -- and trims the CLI-reference entry to a pointer rather than a second copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(fleet): close code-review blockers in the Fleet Manager Nine blocking defects from the review of this PR, plus the highest-value recommendations. Data loss and correctness: - An unrecognised run-record `mode` no longer raises. A raise reached `_read_and_prune` as `corrupt`, which deletes **without consulting liveness** -- so a record written by a newer Conductor made an older one delete a live run's record and orphan that process from `stop`, `status` and the fleet. It now normalises prefix-wise (`fg*` to `fg`, else `bg`), which also keeps D1's stop confirmation armed rather than silently dropping it. `mode` is a `RunMode` Literal so the single write site is checked by `ty`. - `stop` no longer clears a legacy PID file by port alone. The port-keyed fallback fires on the *normal* path (a cooperating child removes its own record), so a freed-and-rebound port meant deleting a different, live run's file -- the hazard issue #344 exists to prevent, under a comment claiming the removal was identity-checked. Adds `remove_pid_file_for_pid`, which matches on port *and* pid and delegates to `remove_pid_file_at`. Reporting a success that did not happen: - `conductor fleet prune` reported a wholesale sweep failure -- including the symlink-tamper refusal -- as "Nothing to prune." with exit 0. `PruneResult.error` carries it; `prune_event_logs` still never raises, since the opportunistic startup sweep depends on that. - Per-file deletion failures had nowhere to go, so a read-only log directory reported a working sweep. `_safe_unlink` now distinguishes "already absent" from a refusal, and `PruneResult.failed` is reported. - The TUI kill action announced "Killed 0 run(s)." at *informational* severity while discarding every reason -- including the identity-mismatch refusal, a safety stop -- into a dropped buffer. `StopOutcome.failed` carries them to the user. Rich markup (AGENTS.md, Console Output): - The kill-confirmation dialog markup-parsed the workflow names it was listing for deletion; two gate notifications and two more sites did the same; `fleet prune --help` dropped the `[fleet.retention]` section name it existed to communicate. - `test_markup_guards.py` gains rules I and J for the surfaces that let those through: Textual content sinks (`Static`/`Label`/`notify`, and every `str` cell of a `DataTable`) and Typer command docstrings. Both were verified against the pre-fix tree. Rule I found a live instance of the same defect in the run-detail table, fixed here. Test isolation: - `read_run_records()` is a pruning reader, and `cli.pid.pid_dir()` ignores `CONDUCTOR_HOME`, so the suite could delete a developer's live run records. An autouse fixture redirects both directories, mirroring the existing `_isolate_event_log_root`. Also: `stop --json` takes D1's refusal branch instead of skipping the gate; foreground self-exclusion works off Linux via a dedicated `CONDUCTOR_SELF_RUN_ID` (a separate name because `event_log.py` reads `CONDUCTOR_RUN_ID` as "adopt this run id"); an inherited `SIG_IGN` is honoured; the event-log head read is lazy; `textual>=8.0` to match the tested floor; dead code and two inert parameters removed; docs, CHANGELOG and AGENTS.md corrected where they described code that no longer exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(fleet): make run-record writes and the stop tests Windows-safe The Windows CI job added in #390 exercised this code for the first time and found five failures. One is a real defect. `write_run_record`'s "atomic write" relied on `os.replace`, which is atomic and reader-proof on POSIX but raises `PermissionError` on Windows while another process holds the destination open. This record is read constantly -- `conductor status`, `fleet list`, the TUI's ~2s poll, the `--web-bg` launch gate -- so the write failed, `cli/run.py` swallowed it, and the run silently became undiscoverable and unstoppable: exactly the defect the run record exists to prevent, reachable only on Windows. `_replace_with_retry` retries briefly there; a persistent permission error still surfaces rather than stalling. Predicted as R13 in review and deferred as unlikely -- CI reproduced it deterministically. The remaining four were tests asserting POSIX-only behaviour: - `TestStopProcessUnexpectedOSError` and `test_kill_already_dead_pid_does_not_raise` simulate an errno from the kill primitive by patching `os.kill`. `terminate_process` dispatches to `_terminate_process_windows` (ctypes `TerminateProcess`) there, so the patch is never reached and the assertions describe a path Windows cannot take. Skipped with that reason. Note the sibling `..._removed_when_process_confirmed_dead_after_oserror` was passing on Windows for the same wrong reason, and is covered by the class-level skip. - `test_concurrent_writers_never_yield_torn_read`'s reader now tolerates `PermissionError` alongside `FileNotFoundError`: both are races with `os.replace` itself, not the *torn* read the test exists to rule out. - `_rendered_footer` assumed the footer is the compositor's last strip. It is on Linux; on Windows the trailing strip is blank, so every footer assertion read an empty line. It now finds the row carrying a binding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(fleet): wait for the footer to paint before asserting on it `_rendered_footer` read the compositor after a single `pause()`. That is enough on Linux; on Windows the first frame can land before the footer is composited, so the assertion read a blank line -- the last remaining Windows CI failure, and unrelated to the binding grouping these tests cover. It now polls until the footer row appears. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jason Robert <jasonrobert@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fixes#385.
The gap
CI ran
ubuntu-latestonly for Lint, Type Check and Test. Conductor has a substantial amount of Windows-specific code that nothing executed:cli/pid.py— an entireOpenProcess/GetExitCodeProcessctypes implementationcli/bg_runner.py—CREATE_NEW_PROCESS_GROUP,DETACHED_PROCESS, job-object breakaway and its access-denied fallbacksys.platform == "win32"branches incli/app.pyandcli/update.pyThree bugs found in one week of Windows use all reached a release: #342 (JSON truncation via a
if WINDOWSchunked-write path), #344 (conductor stoporphaning runs becauseCTRL_BREAK_EVENTneeds a shared console), #382 (MarkupErrorthrough the console path).The gap is bidirectional, which is the part I would stress
It is tempting to read this as "Linux CI can't catch Windows bugs". It is worse than that — Linux-only CI also lets Windows-passing tests hide Linux breakage, because the existing tests fake the platform.
Both directions bit me while fixing #344:
conductor.cli.app.os.kill.osis a single shared module object, so on POSIX that also patchedcli/pid.py's liveness probe and turned aDEADresult intoUNKNOWN. It passed on Windows and would have failed on Ubuntu CI. Caught by review, not by a test run._liveness_windowsis covered everywhere by monkey-patching a_kernel32mock. That is deliberate and good — the logic is exercised on every platform — but it means nothing validates the real ctypesargtypes/restype. An ABI mistake would be CI-green and fail only on a user's machine.So the current setup cannot catch either direction, and the mocked-platform tests give a false sense of coverage for the one platform where the code actually differs.
The change
Windows added to the test matrix on one Python version rather than a full 2×2:
The platform is the variable here, not the interpreter — the Windows-specific code is not version-sensitive. And the
Install Scriptsjob already provisions a Windows runner on every PR (its own comment says it runs there because "the failure modes actually live" there), so this is incremental cost on infrastructure that is already paid for, not a new platform commitment.Two things this required beyond the matrix:
find, which is not a native Windows command — the step would have failed outright. It now runs undershell: bash(Git Bash is preinstalled on the runner) and also matches the Windows venv layout, where scripts live underScripts/and the binary carries a.exesuffix.ubuntu-latestas well as 3.12, so a second runner cannot upload a partial report for the same commit and skew the totals.I expect the first Windows run to be red
I would rather say that up front than have it look like a surprise. Likely causes, in order:
patch("conductor.cli.pid.sys.platform", "win32")and the_kernel32mocks are written to run on Linux; some will double up confusingly on real Windows. There is already a correct pattern for this in the repo —TestIsProcessAliveWindowsRealguards withskipif./tmp/...string literals, and anything comparing rendered console output.I am happy to triage that first red run and push the fixes onto this branch — I have a Windows environment and have already been through these modules for #381, #383 and #387. Equally happy for you to prefer a narrower first step (e.g. Windows restricted to
tests/test_cli/test_pid.py,test_stop.py,test_bg_runner.py, where essentially all the platform-specific code lives) — say the word and I will rework it.Related
#342 / #381, #344 / #383, #382 / #387 — every one of them is a bug this job would plausibly have caught before release.