Skip to content

fix(tests): make the test suite pass on native Windows, run the full suite in Windows CI - #96458

Merged
ethernet8023 merged 19 commits into
NousResearch:mainfrom
ethernet8023:ethie/windows-tests
Sep 24, 2026
Merged

ethernet8023 merged 19 commits into
NousResearch:mainfrom
ethernet8023:ethie/windows-tests

Conversation

@ethernet8023

@ethernet8023 ethernet8023 commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Makes the test suite pass on a native Windows host, with no regression on
Linux. It also turns on the full suite for Windows in CI.

The Windows baseline was 227 failing test files and about 1290 failing nodes.
Most failures had two causes: tests that use POSIX-only or symlink-only
behavior, and real cross-platform bugs in the production code. This PR fixes
both. The CI change then makes Windows run the full suite on every PR, the
same as Linux, so the fixes stay fixed.

Related Issue

N/A (no tracked issue).

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✅ Tests (adding or improving test coverage)

Changes Made

Production fixes (19 files)

  • file_operations: use the translate_path flag, send snippets as base64, and
    use sys.executable (the MS Store python3 stub and the list2cmdline
    backslash collapse both broke snippets)
  • approval: treat backslash as a Windows path separator, not an escape
  • registry, browser_registry, secret_sources, plugins: normalize scope-key case
  • checkpoint_manager, plugins_cmd: clear read-only bits before delete
  • deadline: make MAX_SAFE_TIMEOUT_S fit the Windows limit
  • image_routing, acp, cua_backend, daytona: fix Windows and POSIX paths
  • hermes_state: match backslash in the retag LIKE clause
  • kanban, disk-cleanup: match drive paths and split command arguments
  • skills_tool, skill_commands: emit supporting-file paths as posix (the
    listings and their footer examples use forward slashes on every OS)
  • scripts: emit host separators through as_posix

Test changes (about 210 files)

  • Gate POSIX-only tests with the linux_only marker.
  • Gate symlink-only tests with the require_symlinks marker.
  • Fix tests that assume a POSIX filesystem, POSIX process behavior, or a
    hardcoded port.

CI (tests.yml, ci.yaml; tests-os.yml deleted)

  • One three-OS matrix: Linux and Windows run the full suite through
    scripts/run_tests.sh. The conftest OS-marker skip does the platform gating.
  • macOS keeps macos_only for now, with the same file-narrowing helper and
    exit-5 guard as before.
  • hermes_home_key: a falsy path resolves to the default home, so the
    scope or hermes_home_key() guards at the call sites are gone.

How to Test

  1. On a Windows host, run scripts/run_tests.sh <file> for any file this PR
    touches. It passes.
  2. The Linux and Windows CI lanes run the full suite. The gated tests skip
    on the foreign OS, and the rest must pass.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — I ran each touched file through scripts/run_tests.sh; CI owns the full run
  • I've added tests for my changes (the test gating and fixes are the change)
  • I've tested on my platform: Windows 11

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

@alt-glitch alt-glitch added type/bug Something isn't working comp/acp Agent Communication Protocol adapter comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins comp/tools Tool registry, model_tools, toolsets tool/browser Browser automation (CDP, Playwright) backend/daytona Daytona cloud workspace platform/windows Native Windows-specific behavior or breakage P2 Medium — degraded but workaround exists sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Aug 27, 2026
@ethernet8023
ethernet8023 requested a review from a team August 30, 2026 18:13
@ethernet8023 ethernet8023 changed the title fix(tests): make the test suite pass on native Windows without regressing Linux fix(tests): make the test suite pass on native Windows, run the full suite in Windows CI Aug 30, 2026
@ethernet8023 ethernet8023 added the ci-reviewed applied to manually approve dangerous changes label Aug 30, 2026
@ethernet8023
ethernet8023 force-pushed the ethie/windows-tests branch 2 times, most recently from 1b855f3 to 4868b5c Compare August 31, 2026 20:10
Gate the POSIX-only and symlink-only tests with the linux_only and
require_symlinks markers. Fix the real cross-platform bugs:

- file_operations: use the translate_path flag, send snippets as base64, and
  use sys.executable (the MS Store python3 stub and the list2cmdline
  backslash collapse both broke snippets)
- approval: treat backslash as a Windows path separator, not an escape
- registry, browser_registry, secret_sources, plugins: normalize scope-key case
- checkpoint_manager, plugins_cmd: clear read-only bits before delete
- deadline: make MAX_SAFE_TIMEOUT_S fit the Windows limit
- image_routing, acp, cua_backend, daytona: fix Windows and POSIX paths
- hermes_state: match backslash in the retag LIKE clause
- kanban, disk-cleanup: match drive paths and split command arguments
- scripts: emit host separators through as_posix

207 test files are gated or isolated.
- dashboard_auth_gate: bind port 0, not 9119 (the pre-bind probe fails on
  any machine with a live gateway on the default port)
- dashboard_unified_launch: cover the Windows subprocess.Popen reexec path
- hermes_state FTS5: force the non-WAL path so the trace callback on
  db._conn observes the enrichment query
hermes_home_key already resolved None to the default home. Make it treat an
empty string the same way, then remove the now-redundant guards at the call
sites:

- scope or hermes_home_key() -> hermes_home_key(scope)
- the two ternary forms -> hermes_home_key(scope)

The scope=None sites that mean "global fallback" are unchanged.
…only

Fold the OS-specific lanes into tests.yml's test job as a three-OS matrix.
Linux and Windows run the full suite (conftest skips foreign-OS markers).
macOS keeps macos_only for now. Delete tests-os.yml.
…test gaps

The scope-normalization sweep only fixed the read paths
(list_providers/get_provider/registry_generation) in most registries;
the write paths (register/snapshot/restore) still stored under the raw
scope string while reads resolved through hermes_home_key — so a
scoped registration was written under one key and read under another
(agent/terminal_env_registry.py test_scoped_registration_isolated
failed on both Linux and Windows CI).

- add hermes_constants.normalize_scope(scope): hermes_home_key for
  non-None, None preserved for the process-global layer — one named
  function instead of the expression repeated at ~20 call sites
- apply it on both sides of all nine scoped registries
  (browser, terminal_env, image_gen, transcription, tts, video_gen,
  web_search, secret_sources, dashboard_auth, tools.registry)
- agent/deadline: MAX_SAFE_TIMEOUT_S on win32 leaves 60s headroom under
  the DWORD-ms ceiling so margin-adding consumers (human-wait +60s)
  stay platform-safe; tools/approval fail-closed fallback mirrors it
- tools/approval: strip shell escapes everywhere except inside
  drive-anchored paths (the Windows shell is bash, so r\m still spells
  rm; only C:\... backslashes must survive deobfuscation)
- consolidate the duplicated _rmtree_force into hermes_cli/fs_utils
  and repoint both callers
- hermes_cli/kanban + disk-cleanup: shlex.split(posix=False) keeps
  wrapping quotes in tokens; strip them before dispatch/matching
- test gating: SIGKILL durability conformance cells are linux_only
  (TerminateProcess is not SIGKILL), macOS TCC anchor symlink-tree
  classes are linux_only, Linux chromium profile tests resolve the
  fixture home via expanduser patch + posix-joined expecteds so they
  run identically on every host
…ndows CI

normalize_scope unified the two scope contracts that live on the same
registries, which was wrong: WRITE paths (register/snapshot/restore)
treat None as the process-global layer, but READ paths (get_provider/
list_providers/registry_generation) treat None as 'resolve to the
active home's scope' — hermes_home_key(None) is the active default home.
Routing reads through normalize_scope hid every scoped registration
from ambient-home lookups: 52 linux failures across plugin discovery,
secret-source profile isolation, web-search/browser backend selection
(the CI run on e66a627aa5).

- read sites go back to hermes_home_key(scope); write/slot sites keep
  normalize_scope (None preserved); the docstring now spells out both
  contracts so the unification bug can't be reintroduced blind
- run_tests_parallel.py default worker count: cpu_count*2 -> cpu_count.
  Oversubscription made the 32-core windows runner run 64 pytest
  subprocesses; the suite's own linux measurement showed one worker per
  core is the flat optimum, and windows CI showed the cost — 160 files
  past the 300s per-file cap (they pass in seconds on an idle box), each
  burn a kill + retry cycle, and the churn dominated the lane's wall
  clock. docker.yml's explicit nproc pin now just documents its own
  intent instead of fighting a *2 default
- windows CI lane: HERMES_TEST_FILE_TIMEOUT=900 (via a matrix field)
  so contended-but-passing files stop getting killed at 300s
At 60 minutes the lane hit the job ceiling at 88.6% (run 33338310764)
with only 9 files actually hitting the per-file cap — the 900s file
timeout worked, but the lane's long tail of server/socket/subprocess
files that run minutes-per-file on the CI runner (seconds on an idle
dev box) needed more wall, not more file headroom. 120 min fits the
observed curve (76 files >300s; ~11.4% of work left at the 60-min
mark) with margin, and the per-file cap still bounds any genuinely
hung file. Linux and macOS keep 30-minute budgets.
Process-spawn-heavy test files pay the real-time scan on every spawn
and temp write; that inflated the lane's slow tail to minutes-per-file
(they pass in seconds on an idle dev box). The runner is ephemeral and
single-purpose — scanning it protects nothing. Best-effort step: if a
runner image refuses the preference change, the lane still passes,
just slower.
Two of the three failure classes on the windows-2025 runner are
environmental, not test bugs:

- bash resolved to the WSL stub in System32 (no distro installed), so
  every bash-harness test got the UTF-16 banner and exit 1. Prepend Git
  bin to PATH.
- 16 large files hit the 900s per-file cap under 64-worker contention
  (1802s = cap x 2 attempts). Pin workers to 32 and raise the cap to
  2400s, lane budget to 150 min.
…nner

The per-file model pays a spawn+import wall of ~0.5-1.5s per file x ~3400
files on the Windows runner — a ~6-minute floor before any test runs
(measured: bare spawn 140ms serial / 531ms under 32-way contention; the
full pytest boot 1.4-1.8s per file; one-process collection of the whole
tree takes only 43s, so the architecture itself is the ~8x multiplier).

xdist with --dist loadfile pays that wall once per worker and keeps each
file's tests on ONE worker. Cross-file state pollution is bounded to files
co-scheduled on a worker; failures from that are stateful-test bugs to fix
(the same class PR NousResearch#29016 fixed when it moved the other way).

Linux stays on the per-file runner: its spawn floor is ~15ms, so isolation
is nearly free there. Add pytest-xdist==3.8.0 to the dev extras and
scripts/run_xdist.sh. Windows-only experiment; the local 6h36m xdist run
on a polluted dev box (MSIX PYTHONPATH + SAC) is not representative — CI
renders the real verdict.
The first xdist run died with 'runner lost communication' (run 33389773498,
1h15m, no logs flushed). Cause: the per-file runner launched every pytest
with start_new_session, so each file's process-tree-killing tests could
only reach their own children. xdist puts 32 workers and the controller in
one process family, and the live_system_guard_bypass tests run REAL kill
sweeps (install.ps1 -Stage venv does per-PID taskkill /F /T against every
python.exe it enumerates) — a sweep under shared workers can take out a
sibling worker or the runner agent itself.

Two phases now: xdist loadfile for the bulk; the 15 process-killer files
run serially, one plain pytest per file, so a sweep's only reachable
victims are its own children.
…mmed CLIs

test_command_token_source: the key_cmd fixtures were POSIX shell
commands (printf/echo/date) run through _mint's shell=True — cmd.exe
on Windows, where none of those behave the same. New _pycmd() helper
base64-wraps a python -c payload so the command is pure printable
ASCII that both cmd.exe and POSIX sh run unchanged.

doctor: _gh_authenticated() crashed the whole doctor run with
WinError 5 when the PATH gh resolves to a Windows Store/MSIX
reparse-point shim (CreateProcess denies those outside the package
context). Treat an unspawnable gh as not-authenticated — a
diagnostic probe must never take down the run. The shared test
helper also stops doctor from probing the ambient gh at all (the
gh-specific behaviors have their own mocked tests).
… tests

- test_web_ui_build: the mock seam drifted — _build_web_ui resolves
  npm through _resolve_node_runtime_npm (hermes_constants'
  launchable-variant scan), not shutil.which; patch the actual seam.
  On Windows the old mock resolved None and the build path bailed
  before the assertions under test
- test_update_wedged_gateway: TestLoopTickWitness binds real AF_UNIX
  sockets, which Windows CPython builds don't expose — gate the class
  to the POSIX lanes that run that transport
- test_browser_real_profile: the SingletonLock fixture symlink falls
  back to a placeholder file on Windows without the symlink privilege
  (same exclusion contract); the two POSIX-mode assertions (0600/0700
  chmod bits are not expressible by os.chmod on Windows) gate
  linux_only; test_relaunch_path_does_snapshot now mocks Popen (the
  real-binary launch) like its siblings instead of only subprocess.run
…rocess machinery

scripts/run_tests.sh now runs pytest-xdist -n <N> --dist loadfile as
the single canonical path on every OS (Linux and Windows CI lanes both
use it). The per-file subprocess model (run_tests_parallel.py, and the
interim run_xdist.sh experiment) is deleted along with its two
self-tests: persistent xdist workers pay the interpreter+import wall
once per worker instead of once per file (~0.5-1.5s x ~3400 files was
a ~6-minute floor on Windows), and --dist loadfile pins a file's tests
to ONE worker, bounding state pollution to co-scheduled files — which
is exactly the class of flake we are now committing to fix properly.

The serial process-killer quarantine phase is dropped too. It existed
to keep process-tree-sweep tests from killing sibling xdist workers;
the durable fix belongs in those tests (sweeps must target their own
children, not enumerate every python process), and keeping a
divergent two-phase path would hide that work.

Kept from the old wrapper: hermetic env -i scrubbing, Windows
location-var forwarding, venv probing, bytecode pre-compile,
-m 'not integration', and the HERMES_TEST_IMAGE docker-knob
allowlist. HERMES_TEST_FILE_TIMEOUT/FILE_RETRIES/SLICE go away with
the runner they parameterized; -j/HERMES_TEST_WORKERS now map to xdist
-n (Linux CI pins 96, Windows 32, default auto).

Docs updated to match: AGENTS.md (runner contract, flake policy,
isolation section), CONTRIBUTING.md, tests/conftest.py comments,
classify_changes docstring + its lane expectation (a .sh runner no
longer trips the supply-chain scan lane), comfyui README,
hermes-agent contributor guide, debugpy skill and its website doc.
A process created a directory literally named '%SystemDrive%' in the
repo root (unexpanded env-var path) and the previous commit's git add
-A swept its Defender cache .db files in. Not part of the tree.
The fixed linux_only/macos_only/windows_only trio can only say 'one OS,
no qualifiers' — it cannot express 'anything except macOS', 'Windows
but only arm64', or 'POSIX-family behaviour'. The new platforms marker
takes any number of spec strings (any-of) plus optional arch filters:

  @pytest.mark.platforms("linux")
  @pytest.mark.platforms("not macos")
  @pytest.mark.platforms("windows", arch="arm64")
  @pytest.mark.platforms("posix")

Specs: linux / macos / windows / posix / any and 'not <spec>'.
arch matches platform.machine() with alias normalization
(amd64→x86_64, aarch64→arm64); arch_negate inverts it. Unknown specs
and stray keyword arguments are hard UsageErrors — a silent typo would
mean a test skipped on every host, which is the exact green-zero-
coverage failure the marker machinery exists to prevent.

The legacy trio remains accepted as aliases routing through the same
skip path (a mechanical rewrite of the ~500 existing call sites is a
separate sweep); tests/hermes_cli/test_linux_desktop_entry.py converts
as the reference usage. list_os_marked_tests.py now accepts both the
short platform names (matching quoted platforms() specs, including
negated ones) and the legacy _only names, and returns nonzero when a
marker selects nothing. The macOS CI lane passes -m macos.

Also documents the module-mark stacking trap: module-level
platforms/_only plus a per-test host marker trips the conftest's
double-mark hard reject — AGENTS.md now says so. Verified: the earlier
xdist INTERNALERROR crash on test_linux_desktop_entry +
test_browser_real_profile was exactly that stacked double mark, gone
with per-test platforms() marks (76 passed, 44 skipped on Windows).
…/windows_only trio

No shims, one marker system. The full mechanical sweep:

- 165 test files converted: every @pytest.mark.<os>_only decorator and
  pytestmark assignment is now platforms("<os>"). Docstring/comment
  prose mentioning the trio rewritten to the platforms() vocabulary.
- conftest: _OS_MARKS, the legacy skip loop, the double-mark reject for
  the trio, and the selector-tagging shim are deleted. The single
  platforms() gate does all host gating; _reject_contradictory_platform_
  marks replaces the old reject (one platforms() marker per test — a
  module-level pytestmark stacked on a per-test marker is the historic
  skipped-everywhere-green-everywhere failure and stays a hard error).
- pyproject: only the platforms marker is registered.
- list_os_marked_tests.py: rewritten for the single vocabulary — takes
  a platform name (linux/macos/windows), matches quoted platforms(...)
  specs including negated and any-of forms, exits nonzero on empty
  selection. Its test file rewritten to match (bare identifiers must
  not match; the spec must appear inside the string literal).
- tests.yml macOS lane: marker: macos feeds the lister; the pytest
  selection is -m "platforms and not integration" — the marker name is
  the selector, the conftest's per-test host skips are the gate.
- test_os_marker_gating.py rewritten for the new reject (the old file
  tested deleted machinery).
- AGENTS.md / CONTRIBUTING.md updated to the single vocabulary.

Verified: full-tree compile; marker unit tests (36); lister tests;
macOS-lane simulation (21 files, 180 collected / 429 deselected on a
windows host); broad xdist slices through scripts/run_tests.sh
(367 + 1674 passed, zero refactor-attributable failures — the 6 red
tests in the second slice fail identically with the changes stashed).
…, xdist on Windows

xdist-as-standard was the wrong shape for Linux: the per-file subprocess
model ran the full suite there in 3m45s with zero cross-file failures,
while the first xdist run took 7m51s and failed 111 tests. The two
costs are structural opposites:

  * xdist multiplies full-tree collection by the worker count — every
    worker imports the whole suite and pays pytest's per-item
    fixture-closure machinery (profiled: ~98M function calls to
    collect 42k items against the conftest's autouse fixtures —
    _matchfactories alone 1.37M calls, traverse_fixture_closure 783k).
    On the 96-core runner that front-loads 106s before any test runs.
  * The per-file model pays a spawn+import wall per file: ~15ms on
    Linux (nothing), 0.5-1.5s on Windows (~6-min floor across 3400
    files — the dominant cost of that lane).

So scripts/run_tests.sh now dispatches on host: run_tests_parallel.py
(restored verbatim from b8696aedea^, with its two self-tests) on POSIX,
pytest-xdist --dist loadfile on Windows. Both paths share one hermetic
env contract (env -i scrub, TZ=UTC, PYTHONHASHSEED=0, venv probing,
bytecode pre-compile). -j/HERMES_TEST_WORKERS feeds either backend.

The 111 xdist co-scheduling failures on Linux were the per-file
model's isolation guarantee surfacing as test bugs — with the POSIX
lane back on per-file, that class disappears where it never existed;
the Windows lane keeps loadfile (with its remaining co-scheduling
hazards as fix-at-the-test work).

Workflow comments, AGENTS.md, CONTRIBUTING.md, conftest comments and
the classify test follow the dual-path truth.
New dev extra exact-pins pytest-xdist==3.8.0; the
every-exact-pin-exempt contract test caught the missing whitelist
entry.
salch-cred added a commit to salch-cred/hermes-agent that referenced this pull request Sep 3, 2026
The rg-gated tests evaluated subprocess.run(["which", "rg"]) inside the
skipif decorator arguments at module import. On Windows there is no
which executable, so that raises FileNotFoundError during collection
and the whole 10-test module is lost instead of two tests skipping.

Gate the rg tests on shutil.which (resolved once at import, no
subprocess), and POSIX-skip the find/grep fallback tests whose shell
command strings only exist on Unix. Windows now collects 10 tests
(2 cross-platform pass, 8 platform-skips); Linux CI is unchanged —
every test still runs there.

Not covered by NousResearch#96458's 305-file set.
@ethernet8023
ethernet8023 merged commit e97de8d into NousResearch:main Sep 24, 2026
33 of 36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend/daytona Daytona cloud workspace ci-reviewed applied to manually approve dangerous changes comp/acp Agent Communication Protocol adapter comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists platform/windows Native Windows-specific behavior or breakage sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows tool/browser Browser automation (CDP, Playwright) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants