14 KiB
Agent Note: Dual Wine and native Windows pull-request CI
Status: implemented
English | 中文
Problem
The required pull-request Windows verdict needs a fast win32 toolchain signal without making the aggregate wait for scarce Windows capacity. Wine provides that critical-path signal but runs over a Linux kernel and case-sensitive ext4, uses a hoisted dependency layout, and cannot prove NTFS, DACL, ConPTY, crash durability, or native process behavior. With the native serial references disabled, every pull-request head also needs an automatic real Windows-kernel result.
A coverage audit found that stale branch state had restored temporary exclusions for supported LSP sources. Native Windows therefore needed to execute the complete supported source inventory at the same 100%-per-file threshold instead of relying on a smaller platform-specific denominator.
Decision
The required windows job in ci.yml remains windows node 24 / wine blocking on ubuntu-latest. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the shared Wine gate script that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable windows job id remains a dependency of all checks passed. The archived Wine experiment preserves its measured trade-offs, while this note owns the current dual topology.
Every pull request also starts four independent native jobs on the organization-owned dsh-windows-2025-16core runner: windows-build, windows-coverage, windows-native-tests, and windows-observational. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through pnpm/action-setup, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable retargets all four jobs to the in-house pool. Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline.
windows-build and windows-native-tests are dependencies of all checks passed; their workspace-build and targeted native-process results are blocking. windows-coverage remains an ordinary job but is absent from aggregate needs, so its 100%-per-file result stays red and visible without delaying the required verdict. windows-observational is also absent from aggregate needs and uses continue-on-error because Linux owns the blocking static, documentation, package, and built-artifact verdicts.
windows-coverage completes a workspace build before in-job partitioned coverage starts four single-worker instrumented shards beside a two-worker exempt-heavy gate. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds. windows-observational owns its own workspace build and production-site validation, starts the independent static gates together, and caps publint at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's needs edge still requires a successful build, while its after edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, Knip, NodeNext, package, and documentation processes. The SQLite busy-journal pacing fixture injects two busy results followed by success under the normal busy budget and observes each inter-attempt delay, keeping schema-setup scheduling outside its timing assertion. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only scripts/ sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers after the build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement.
The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on /; they now use Node's platform basename. Chokidar consumers received %TEMP% through the C:\\Users\\RUNNER~1 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias.
Portable filesystem fixtures derive paths with node:path, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup ERR_INVALID_ARG_VALUE is non-absence on every host, rather than depending on whether a file ancestor produces ENOTDIR or ENOENT. Worker-death fixtures drive real termination from the host after observing their protocol preconditions instead of calling process.exit() inside a nested Windows Worker; this preserves the worker-exit contract without exposing the enclosing Vitest fork to Node's process-wide native exit assertion. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling. The randomized SQLite differential property retains all 100 seeded runs and uses a 120-second Windows budget because simultaneous native jobs can contend for the shared runner host; POSIX keeps the 60-second budget.
Native watchers use canonicalizeWatchPath() to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves ENOTDIR for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity, attaches listeners, and awaits its main watcher before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. A skill root that is itself a symbolic link remains unexpanded when watchFollowSymlinks: false, allowing Chokidar to enforce that boundary.
Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide .cmd shims and PATHEXT where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared file: dependencies expose command shims identically on Windows. The bundled installer exports pnpm's own workspace-ignore configuration, retains PNPM_HOME for pnpm data while removing that directory from lifecycle PATH, and prioritizes .CMD in PATHEXT; nested Git-package installation therefore cannot rejoin the enclosing workspace or let an inherited Windows pnpm executable outrank the transaction-owned wrapper.
Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The process-shutdown controller lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write.
Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.149.1 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host.
Alternatives considered
Make every native Windows result a dependency of all checks passed. This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for coverage and the duplicated observational inventory. Requiring the build and targeted native-process suite retains fast native correctness signals while the other results remain automatic.
Run only Wine on pull requests. Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken.
Mark every non-blocking native job continue-on-error. The observational job uses this setting because Linux owns its blocking verdict. Coverage remains an ordinary job outside aggregate needs, so a threshold failure stays visibly red without blocking the aggregate.
Exclude unsupported-looking files or weaken Windows fixtures. Rejected because the affected LSP, watcher, persistence, client, and process behavior is supported. Peer-platform branches are marked narrowly; portable outcomes stay in the denominator and are exercised through host-realistic fixtures.
Keep GitHub's standard windows-2025 runner. That portable two-core image completed the exact inventory reliably, but its 32-minute serial result made the automatic native signal substantially less useful than the selected 16-core runner.
Use a 32-core or larger runner. The 32-core comparison improved aggregate gate time by only 1.47 seconds over 16 cores and failed in Node's CJS lexer; earlier high-concurrency 32-core and 64-core trials failed in the same class. More capacity therefore added allocation cost without a stable end-to-end gain.
Consequences
Wine preserves the required aggregate's existing critical path and job identity. Native coverage and observational results can still be pending or red when all checks passed turns green, so branch protection consumes Wine plus the targeted native build and process checks while reviewers and follow-up automation consume the remaining native results.
Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native jobs duplicate setup and repeat builds across the build, coverage, and observational workspaces, but they lower each job's process count and expose path, watcher, lifecycle, and fixture defects hidden by the compatibility lane.
Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native jobs use separate immutable workspaces on the organization-owned 16-core Windows runner. A failure unique to either topology must be classified against that boundary rather than weakened or silently skipped.