# Agent Note: Dual Wine and native Windows pull-request CI

Status: implemented

English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md)

## 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](../../../../.github/workflows/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](../../../../scripts/wine-windows-gates.sh) 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](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.

Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It 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 `pnpm run check:ci:windows-complete` under native PowerShell. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline.

The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage.

The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. 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. Workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 60-minute job deadline still bounds a stuck run. 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. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. 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.

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](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) 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.147.0 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 native Windows a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for the slowest hosted job and for Windows capacity. The independent result keeps the signal automatic without changing the existing required path.

**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 the native job `continue-on-error`.** That would make its check appear successful after a gate failure. Keeping an ordinary independent job preserves the diagnostic conclusion; omission from aggregate `needs` is the only non-blocking mechanism.

**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 Windows can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine while reviewers and follow-up automation consume the separate native result.

Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native job duplicates setup and the two blocking builds and is materially slower on the standard image, but it also exposes 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 job uses the immutable workspace on the organization-owned 16-core Windows runner. A failure unique to either job must be classified against that boundary rather than weakened or silently skipped.
