CI is red on main (run 32599089654, commit 3c07139 — #479 "allow switching working directory for new runs in Fleet TUI"). Windows was green on the previous main run, so all three failures below are new.
| Job | Failures |
|---|
| Test (3.12, ubuntu) | 1 |
| Test (3.13, ubuntu) | 1 |
| Test (3.13, windows) | 9 |
Two of these are real product bugs, one is a test-only bug. Details and evidence below.
1. DirectoryPickerModal's tree overwrites the pre-filled input — product bug
Failing:tests/test_fleet/test_tui_actions.py::TestDirectoryPickerModal — 1 test on Ubuntu (test_empty_input_is_rejected_in_place), 5 on Windows.
AssertionError: assert RunsScreen() is DirectoryPickerModal()
AssertionError: assert WindowsPath('.../pytest-0') == WindowsPath('.../test_valid_directory_dismisses0/chosen')
Root cause
src/conductor/fleet/tui/actions.py:831 mirrors any highlighted tree node into #dir-path:
defon_tree_node_highlighted(self, event: Tree.NodeHighlighted[DirEntry]) ->None:
data=event.node.dataifdataisnotNone:
self.query_one("#dir-path", Input).value=str(data.path)Textual posts NodeHighlighted for the tree's root node as soon as the tree populates — no user involved. Because _tree_root(current) is current.parent, the input's pre-filled current directory is immediately replaced by its parent.
Measured against the real modal:
prefill expected : /tmp/tmpvjf45gio
input after pause: '/tmp'
highlight event : ('/tmp', tree.has_focus=False, focused='Input')
This is wrong in the shipped product, independent of the tests: open the picker and press Enter, and you get the parent of the directory the input was showing — contradicting the class docstring ("pre-filled with the current launch directory").
Why it fails only on CI
The highlight is post_messaged (textual/widgets/_tree.py:1172) from the cursor_line watcher, which fires only after DirectoryTree._load_directory (a @work(thread=True) worker) plus the _loader async queue have landed. The tests assign input.value directly and press Enter, so it is a race:
- Locally the clobber arrives before the assignment → the test's value survives → passes.
- On CI it arrives after → the tree root wins.
That produces both observed signatures: the three "rejected in place" tests find a now-valid directory in the input, so _accept dismisses the modal (app.screen is RunsScreen); and the two "dismisses with path" tests get tmp_path.parent (the tree root) instead of the directory they typed.
Available discriminator
The automatic highlights arrive with the tree unfocused (the input is focused on mount); genuine browsing has it focused. Textual focuses on MouseDown (textual/screen.py:1933-1939) before delivering the click that moves the cursor, so gating the mirror on focus keeps both mouse and keyboard browsing working.
2. TestShortenHome assumes POSIX — test-only bug
Failing:tests/test_fleet/test_tui_theme.py::TestShortenHome — 3 tests, Windows only.
AssertionError: assert '\\home\\jason\\src\\proj' == '~/src/proj'
AssertionError: assert '\\tmp\\a' == '/tmp/a'
AssertionError: assert '\\home\\jasonx\\proj' == '/home/jasonx/proj'
Root cause
The tests (test_tui_theme.py:104-116) do monkeypatch.setenv("HOME", "/home/jason"). On Windows Path.home() resolves through ntpath.expanduser, which reads USERPROFILE, else HOMEDRIVE + HOMEPATH — never HOME:
if'USERPROFILE'inos.environ:
userhome=os.environ['USERPROFILE']
elifnot'HOMEPATH'inos.environ:
returnpath
So the patch is inert, /home/jason/src/proj isn't relative to the runner's real home, and shorten_home correctly returns it unchanged — as str(WindowsPath(...)), i.e. with backslashes.
A second latent problem stacks on top: even with the home directory patched correctly, str(Path("~", "src", "proj")) is ~\src\proj on Windows, not ~/src/proj.
shorten_home itself (src/conductor/fleet/tui/theme.py:145) is platform-correct — it uses Path.home() and Path.is_relative_to. Only the tests are POSIX-only; they need a platform-agnostic home (e.g. patching Path.home) and separator-agnostic expectations.
3. remove_run_record doesn't survive a Windows sharing violation — product bug
Failing:tests/test_fleet/test_tui_runs.py::TestRunsScreenPolling::test_poll_tick_removes_completed_run — Windows only.
AssertionError: the poll tick never dropped the removed run from the table (waited 20s)
Root cause
This is not timing — #478 already converted this test from a fixed sleep to a condition wait, and it still fails.
remove_run_record → _safe_unlink (src/conductor/fleet/records.py:502) makes exactly oneunlink() attempt and swallows OSError:
try:
f.unlink()
exceptFileNotFoundError:
returnFalseexceptOSError:
logger.warning("Could not remove run record file: %s", f, exc_info=True)
returnFalseOn Windows, CPython opens files without FILE_SHARE_DELETE, so while a concurrent reader holds a handle, unlink raises PermissionError (ERROR_SHARING_VIOLATION). That reader is present and aggressive in this test: RunsScreen.refresh_runs → asyncio.to_thread(_collect_runs) → read_run_records() → _load_record_file → f.read_text() (records.py:704), on a timer the test shrinks to 0.05s (40× the 2s production interval), under coverage tracing.
Simulating a single collision on Linux confirms the chain:
remove_run_record -> False after 1 unlink attempt(s)
records still visible: ['run-a']
One lost race and the record is permanent — there is no retry, and the caller discards the False — so the row never leaves the table and wait_for times out at 20s with exactly the observed message.
Precedent already in the same module
write_run_record already handles this identical Windows failure mode for the write path via _replace_with_retry (records.py:432), whose docstring names it explicitly:
Windows is different: os.replace raises PermissionError (ERROR_ACCESS_DENIED/ERROR_SHARING_VIOLATION) when another process holds a handle to the destination — and this record is read constantly, by conductor status, fleet list, the TUI's ~2s poll, and the --web-bg launch gate.
The delete path has no equivalent bounded retry. Beyond the test, this means a run cleaning up after itself (remove_run_record_for_current_process()) can silently leave a stale record behind on Windows whenever a fleet scan is mid-read.
Suggested remedies
- Gate the tree → input mirror on the tree actually having focus, so only real browsing writes to the input. Add a regression test asserting the pre-filled directory survives the tree settling (deterministic, unlike the current ordering-dependent tests).
- Make
TestShortenHome platform-agnostic: patch Path.home rather than HOME, and compare against str(Path("~", ...)). - Give
_safe_unlink the same bounded Windows retry as _replace_with_retry.
CI is red on
main(run 32599089654, commit 3c07139 — #479 "allow switching working directory for new runs in Fleet TUI"). Windows was green on the previousmainrun, so all three failures below are new.Two of these are real product bugs, one is a test-only bug. Details and evidence below.
1.
DirectoryPickerModal's tree overwrites the pre-filled input — product bugFailing:
tests/test_fleet/test_tui_actions.py::TestDirectoryPickerModal— 1 test on Ubuntu (test_empty_input_is_rejected_in_place), 5 on Windows.Root cause
src/conductor/fleet/tui/actions.py:831mirrors any highlighted tree node into#dir-path:Textual posts
NodeHighlightedfor the tree's root node as soon as the tree populates — no user involved. Because_tree_root(current)iscurrent.parent, the input's pre-filled current directory is immediately replaced by its parent.Measured against the real modal:
This is wrong in the shipped product, independent of the tests: open the picker and press Enter, and you get the parent of the directory the input was showing — contradicting the class docstring ("pre-filled with the current launch directory").
Why it fails only on CI
The highlight is
post_messaged (textual/widgets/_tree.py:1172) from thecursor_linewatcher, which fires only afterDirectoryTree._load_directory(a@work(thread=True)worker) plus the_loaderasync queue have landed. The tests assigninput.valuedirectly and press Enter, so it is a race:That produces both observed signatures: the three "rejected in place" tests find a now-valid directory in the input, so
_acceptdismisses the modal (app.screenisRunsScreen); and the two "dismisses with path" tests gettmp_path.parent(the tree root) instead of the directory they typed.Available discriminator
The automatic highlights arrive with the tree unfocused (the input is focused on mount); genuine browsing has it focused. Textual focuses on
MouseDown(textual/screen.py:1933-1939) before delivering the click that moves the cursor, so gating the mirror on focus keeps both mouse and keyboard browsing working.2.
TestShortenHomeassumes POSIX — test-only bugFailing:
tests/test_fleet/test_tui_theme.py::TestShortenHome— 3 tests, Windows only.Root cause
The tests (
test_tui_theme.py:104-116) domonkeypatch.setenv("HOME", "/home/jason"). On WindowsPath.home()resolves throughntpath.expanduser, which readsUSERPROFILE, elseHOMEDRIVE+HOMEPATH— neverHOME:So the patch is inert,
/home/jason/src/projisn't relative to the runner's real home, andshorten_homecorrectly returns it unchanged — asstr(WindowsPath(...)), i.e. with backslashes.A second latent problem stacks on top: even with the home directory patched correctly,
str(Path("~", "src", "proj"))is~\src\projon Windows, not~/src/proj.shorten_homeitself (src/conductor/fleet/tui/theme.py:145) is platform-correct — it usesPath.home()andPath.is_relative_to. Only the tests are POSIX-only; they need a platform-agnostic home (e.g. patchingPath.home) and separator-agnostic expectations.3.
remove_run_recorddoesn't survive a Windows sharing violation — product bugFailing:
tests/test_fleet/test_tui_runs.py::TestRunsScreenPolling::test_poll_tick_removes_completed_run— Windows only.Root cause
This is not timing — #478 already converted this test from a fixed sleep to a condition wait, and it still fails.
remove_run_record→_safe_unlink(src/conductor/fleet/records.py:502) makes exactly oneunlink()attempt and swallowsOSError:On Windows, CPython opens files without
FILE_SHARE_DELETE, so while a concurrent reader holds a handle,unlinkraisesPermissionError(ERROR_SHARING_VIOLATION). That reader is present and aggressive in this test:RunsScreen.refresh_runs→asyncio.to_thread(_collect_runs)→read_run_records()→_load_record_file→f.read_text()(records.py:704), on a timer the test shrinks to 0.05s (40× the 2s production interval), undercoveragetracing.Simulating a single collision on Linux confirms the chain:
One lost race and the record is permanent — there is no retry, and the caller discards the
False— so the row never leaves the table andwait_fortimes out at 20s with exactly the observed message.Precedent already in the same module
write_run_recordalready handles this identical Windows failure mode for the write path via_replace_with_retry(records.py:432), whose docstring names it explicitly:The delete path has no equivalent bounded retry. Beyond the test, this means a run cleaning up after itself (
remove_run_record_for_current_process()) can silently leave a stale record behind on Windows whenever a fleet scan is mid-read.Suggested remedies
TestShortenHomeplatform-agnostic: patchPath.homerather thanHOME, and compare againststr(Path("~", ...))._safe_unlinkthe same bounded Windows retry as_replace_with_retry.