20 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. The Wine lane provides that critical-path signal but executes over a Linux kernel and case-sensitive ext4, requires a hoisted dependency layout and host-created symlinks, and cannot prove NTFS, DACL, ConPTY, crash-durability, or native process behavior. With the native serial references disabled, ordinary CI also needs an automatic real Windows-kernel result on every pull-request head even when that result is not part of branch protection.
The coverage audit found that PR #499 had restored deterministic native-Windows LSP coverage, but a later GUI branch replayed its three temporary source exclusions from stale branch state. The current LSP fixtures skip only genuinely POSIX primitives and otherwise exercise the supported Windows process, transport, and lifecycle paths, so excluding connection.ts, index.ts, and instance.ts hid supported behavior rather than a platform limitation.
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 run the workspace build and production site. 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 an 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. The job is deliberately absent from all-checks-passed.needs: the aggregate neither waits for it nor changes conclusion because of it, while the native job retains its own unmasked success or failure result.
Inside windows-native, workspace build, production-site, and 100%-per-file coverage failures make that job fail, while the broader static, documentation, package, and built-artifact portability inventory remains observational. The 16-core lane gives coverage a six-worker budget, split into four instrumented workers and two exempt-heavy workers, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal has reproduced in shared worker threads on Windows as well as POSIX; the two-gate schedule also prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Two asynchronous fixtures whose real process or lazy grammar startup can exceed Vitest's default polling window use explicit five-second waits without changing their asserted outcomes. Linux remains the owner of duplicate lint and snapshot enforcement.
The 16-core allocation is the measured stable point for this inventory. Relative to the previous two-core serial job, the complete native lane fell from 32 minutes 11 seconds to 6 minutes 27 seconds while all 41 gates and the unchanged per-file coverage threshold passed. A 32-core run reduced aggregate gate time by only 1.47 seconds and still triggered the same 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.
The coverage follow-up then exercised the serial heavy suites on the native host and removed their remaining path-spelling assumptions. Filesystem identity assertions compare native real paths instead of Git's slash convention with Node's temporary-directory spelling; quoted diagnostics are matched in their escaped form; TypeScript-owned file names are compared after separator normalization; and Typert passes a slash-normalized config name consistently across TypeScript's read and parse boundary so malformed Windows configs produce the owned analysis error instead of a compiler debug failure. The Oxlint subprocess contract also uses the same explicit twenty-second budget as its neighboring executable probes. These are portability repairs to supported tests and parser behavior, not platform skips or coverage exclusions.
The blocking coverage gate exposed two more fixture contracts that had never run on the native lane. The JSONL materialization fault now asserts the structured filesystem error code because the Windows durable-directory implementation owns an ENOTDIR code without copying it into human prose. The ACP teardown ladder now uses Node children instead of assuming a POSIX shell and asserts Windows' force-termination outcome rather than POSIX signal names; POSIX still proves the SIGTERM and SIGKILL tiers. Those suites load native bindings or own real process trees, so the Windows thread pool runs them in the existing fork-isolated project while still merging their coverage into the same per-file threshold.
After the branch incorporated a newer master, the next native coverage run found the last uncatalogued watcher path and a stress-test budget. skill-local opened existing Chokidar roots with the configured spelling, so %TEMP% could still reach libuv through C:\\Users\\RUNNER~1 while events used the long directory name; its root and ancestor modes now share the canonical watch-path contract, while discovery retains the configured path. The newly added 10,000-session descendant walk also exceeded Vitest's default timeout under Windows coverage instrumentation, so that unchanged stack-safety workload has an explicit twenty-second stress-test budget rather than a smaller depth or a platform skip.
The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used process.kill() or subprocess.kill() to send SIGTERM, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud.
Running the complete instrumented graph instead of the earlier reduced inventory exposed the remaining cross-platform fixture contracts. Windows path identity now accounts for 8.3 aliases, native separators, Git checkout line endings, cross-drive relative paths, and file URLs before constructing loader symlinks. The JSONL durable-directory helper applies the extended-length namespace to probes and staging creation, real product tests invoke portable executable entries and tolerate bounded Windows handle release, and stress tests retain their workloads with explicit coverage budgets. A credential document or watch path whose deepest existing ancestor is a file now fails ENOTDIR on every host, while skill-local uses effect-owned persistent Chokidar handles so asynchronous libuv errors are contained instead of escaping the test process.
The final root-probe failure came from applying the extended-length namespace to the drive root as well as long descendants. Node rejected the bare root probe as EISDIR, cascading through every JSONL fixture and assembled binary that materialized a session. The Windows durable-directory helper now probes the short drive root in its native spelling and namespaces only descendants; an injected Win32-path unit test locks both spellings while native coverage exercises the real filesystem.
The next complete coverage run reached six independent late failures rather than one shared cascade. React queue-action coverage now resolves its mocked request inside an awaited act() before observing the settled render. The unclosed-Markdown workload keeps all 6,400 candidates under an explicit three-second coverage budget, and the asynchronous workspace-projection warning test gives its outer case a twenty-second budget larger than its ten-second poll. Real Claude Code teardown uses asynchronous recursive removal with ten bounded retries after every managed handle reports exit, accommodating Windows' delayed handle release without weakening the quiescence assertions.
Two product boundaries required foundation repairs. Include's debounced config persistence previously launched an unobserved promise from a timer; a transient Windows EPERM while replacing cordis.yml could therefore lose the disabled row and escape as an unhandled rejection. The vendored writer now serializes writes, retries only transient access/busy failures with bounded backoff, observes every rejection, and drains the latest write at teardown; the real Loader composition injects one EPERM and proves the durable retry. Codex 0.146 advertised exec_command to the loopback Responses model on Windows but rejected the returned call in its own router, the same upstream failure class tracked in openai/codex#31665. Development evidence is pinned to the current stable 0.147.0 release: regenerated upstream schemas preserve the provider-owned handshake, thread/turn, approval, user-input, and elicitation contract. Because Codex can advertise the legacy shell_command instead when unified exec is unavailable on the host, the loopback model now selects an advertised command tool and supplies that tool's argument shape rather than injecting exec_command unconditionally. The real-product suite therefore proves unattended rejection without a side effect and whole-tree exit through the product's actual default tool inventory on each host.
The subsequent exact hosted run isolated seven other fixture contracts. The PowerShell background-output case now waits for process completion before draining and comparing the final delta, while the pi-ai idle-watchdog case retains a bounded one-second close deadline that accommodates the delayed Windows socket notification. The asynchronous workspace projection seeds its in-memory filesystem at the host-resolved root. The Include retry acceptance asserts the injected failure and eventual persistence rather than an incidental total rename count, which may include another valid serialized write. LSP's bare-command fixture supplies a .cmd executable through PATHEXT on Windows, and URI rendering expectations distinguish the execution world's path convention from the test host's separators. None of these changes skips a supported path or weakens the asserted outcome.
That run also made syntax highlighting sensitive to runner contention rather than source text. Shiki's JavaScript engine deferred TextMate regexes longer than 3,000 characters until their first match, while Shiki counted that compilation against its 500 ms per-line tokenization budget. A busy Windows coverage worker could therefore stop the first TypeScript line after the const match and return the remainder under the same keyword style. The client now uses Shiki's default regex translation with lazy compilation disabled and tokenizes one representative sample for each boot grammar without a startup cutoff while constructing the singleton. Scanner creation and pattern compilation therefore finish before user content enters the unchanged 500 ms per-line budget. The token-boundary and Markdown DOM fixtures continue to require the complete highlighted result rather than accepting the partial stream.
The same exact hosted run showed that three concurrent instrumented Vitest workers were an unsafe budget for the standard Windows image: otherwise independent Git-merge and JSON-RPC HTTP integration cases reached the default five-second ceiling together. At that stage the native lane temporarily gave Vitest one worker, while the real Git subprocess suite and the two real HTTP composition cases received explicit fifteen-second integration budgets without changing their workloads or assertions. The translation merge fixture also preserves import.meta.resolve('tsx/esm') as a file: URL when passing it to Node's --import; converting it to a drive-letter path had failed before the driver could print its owned recovery guidance. After the latest package regrouping, the fork-isolated JSONL suite's inventory follows its new packages/session/ location rather than silently returning that process-bound suite to the shared thread pool.
The project-skill composition fixture had one separate eventual-consistency race: on a contended host the agent could begin its next model step after write returned but before Chokidar invalidated the skill catalog, moving the replacement catalog behind the subsequent skill call. The fixture now holds that post-write tool boundary until the real registry observes hot-skill, then retains its strict request-order and durable-transcript assertions. Production code remains asynchronous; the test explicitly waits for the watcher contract it intends to exercise instead of relying on scheduler timing or accepting a different request index.
The next exact-head run passed all 10,933 instrumented tests but correctly failed the per-file threshold at 99.95%, exposing five branches that Linux happened to cover. Deterministic cross-platform fixtures now exercise backward PTY scrollback pagination, a settings document that names a directory, an invalid SQLite filename, and an atomic-writer lock beneath a regular-file parent. The credentials provider's remaining stat and mode-enforcement arm is intrinsically POSIX, so it carries the same narrow annotated peer ignore used by the durable JSONL and storage backends; its behavior test remains enforced on POSIX. The threshold and source-file inventory remain unchanged.
The follow-up exact-head run passed all 10,937 instrumented tests and narrowed the threshold result to 99.99%. Its two remaining lines showed that the first PTY fixture had reached the page-offset helper but only supplied two pages, and that Windows path canonicalization rejected the real invalid-path fixture before readFile reached the reload-policy branch. The PTY fixture now supplies three backward pages, while the watcher fixture injects one non-absence read failure after the real permission check; both retain the observable output or last-good-snapshot assertions they exist to prove.
The next run reached the repaired branches but one real PowerShell executor composition case timed out at Vitest's five-second ceiling before the coverage report. That fixture had configured the product timeout to the same five seconds as the test timeout, leaving no budget for the executor to return either its owned result or its owned timeout classification under instrumentation. The command now has a ten-second product budget and the integration test a fifteen-second ceiling; its exit code, output, and resolved-timeout assertions are unchanged.
The following exact-head run passed all 10,938 instrumented tests and isolated four remaining locations whose existing fixtures depended on host scheduling. The E2B service retains its real surviving-group cleanup fixture and separately injects and observes an immediate automatic terminal-release rejection before proving disposal retries it. The pi-ai discovery fixture drives cancellation from a controlled response body read instead of racing a local socket timer, and the persistent-bash fixture makes an incremental PTY delta the only recoverable output before asserting the rendered fallback. These cases exercise the supported branches directly on every host; the coverage inventory and denominator remain unchanged.
POSIX mode bits, chmod-based unreadability, and chmod-based writer-lock refusal do not exist as equivalent Windows facilities. Those acceptance cases remain enforced on POSIX and are skipped on Windows; content, atomic replacement, symlink safety, rollback and recovery through platform-independent filesystem conflicts, and native Windows long-path behavior remain covered. Only intrinsically POSIX source arms carry narrow, explained denominator ignores; no source file or platform-independent branch is excluded from Windows coverage to accommodate these differences.
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 longest hosted job and for Windows capacity. The independent result keeps that signal automatic without changing the existing required path.
Run only Wine on pull requests. Wine reaches the 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.
Run native Windows only after merge. A post-merge reference diagnoses portability regressions after they enter master; it does not give reviewers an exact-head native result.
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, and native addon signal. The native job is slower than Wine and duplicates setup plus the two blocking builds, but it also executes the portability inventory that exposed path, watcher, and lifecycle 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 Windows. A failure unique to either job must be classified against that boundary rather than weakened or silently skipped. Native coverage enforces the repository's per-file threshold without Windows-only source exclusions for supported LSP behavior. Native snapshots remain a named gap rather than being implied by the job name; they require their own tested contract before joining the native lane.