Repository navigation
fix: the Windows batch — rename retry, root canonicalization, POSIX-shaped expectations - #411
Conversation
Every canonical write in the framework routes through atomicWriteFile, which writes a uniquely named sibling temp file and renames it over the destination. On Windows that rename fails with EPERM while another writer holds or is replacing the same file, so two concurrent writers -- two MCP hosts, Pi and MCP, the usage log appending as a phase completes -- lost a write with an error rather than serializing. The forty-writer concurrency test in tests/state-store.test.mjs failed with twenty EPERM rejections on windows-latest; status.yaml, the usage log, Broad-Side state.json and the library index all land through this function. The rename now goes through retryOnTransientFsError: a bounded, jittered retry on EPERM/EBUSY/EACCES that propagates any other error on the first attempt, and the last transient one once the 500ms budget is spent. The temp-file cleanup is unchanged, and the retry wraps only the rename, so a permission failure on the temp write still fails immediately. A POSIX rename-over-existing is atomic and does not report those codes for contention, so the loop is one behavior on every platform rather than a win32 branch. Six tests exercise it directly -- all six fail on the previous code -- and the forty-writer test proves the wiring end to end on the test-windows job. Closes #393 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ucQwVaxXUXxzHAvmgaNp7
isWithinPathResolved resolved the target's existing prefix through symlinks (#223) but the root through realpath alone, which throws on a .codecarto/ that is not there yet and fell back to the root as spelled. Wherever an ancestor needed expanding the two operands then disagreed -- the 8.3 short name the Windows runner puts in %TEMP% (C:\Users\RUNNER~1\...), macOS' /var -> /private/var -- and a phase session's write to .codecarto/findings/contracts/out.md, the one place it may write, was blocked while that directory did not exist yet. That is the windows-latest failure in tests/phase-compaction.test.mjs; it reproduces on Linux through a symlinked ancestor, so settling it needed no Windows run. Both operands now go through resolveExistingPrefix, and the primitive takes the base a relative operand resolves against. The Pi orchestrator hook and the phase hook call that one primitive instead of each hand-rolling resolveExistingPrefix + canonicalPath + isWithinPath -- the duplication one of them had drifted from -- and the MCP spec_path containment check inherits the fix. What containment refuses is unchanged. Three tests, each failing on the previous code: an unborn root under a symlinked ancestor, the phase guard's first write through one, and the base a relative operand resolves against. Closes #394 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ucQwVaxXUXxzHAvmgaNp7
The windows-latest job's first run reported ten failures. Two were
product defects, fixed in the preceding two commits; the other eight were
expectations written for /-separated paths.
- tests/broadside.test.mjs took an outputDir's last segment with
split("/"), which does not split a backslash path. basename does.
- tests/config-problems.test.mjs and tests/pi-publish.test.mjs compared
raw library.path and source_repo lines. A path containing backslashes
is correctly emitted as a quoted YAML scalar with those backslashes
escaped, so no raw-line pattern matches it; all four now compare the
value the parser returns, which round-trips.
- tests/mcp-library.test.mjs interpolated a path into a RegExp source,
where backslashes read as escapes and \b becomes a word boundary.
includes says what was meant.
- tests/config-problems.test.mjs matched an invalid-namespace refusal
against a /-rooted pattern. The refusal table now also takes a literal
prefix, which a path can be and a pattern cannot.
- tests/synthesis.test.mjs compared a joined specPath against a /-joined
literal. Both sides now come from path.join, and the expectation
carries the entries/ segment the anchored regex never reached.
One doesNotMatch in the same family, which a quoted backslash path would
have satisfied vacuously rather than failing, is a parsed-value
comparison too.
Every mechanism was checked against Windows-shaped data on Linux -- win32
path semantics, and the repo's own YAML emitter fed a C:\... path: the old
form fails and the new form holds in each case. No product code changed.
continue-on-error stays on the test-windows job. Whether the suite is
green there can only be settled by a run, so the gate flip belongs in a
follow-up once one reports green.
Refs #395
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ucQwVaxXUXxzHAvmgaNp7
The windows-latest run on this branch went from ten failures to two, and
both are the same family as the eight already fixed: a test stops at its
first failing assertion, so each of these sat behind one the run had
already reported.
- tests/config-problems.test.mjs had a third raw library.path comparison,
after the refusals loop, where a successful --namespace init writes both
keys. Now a parsed deepEqual.
- tests/pi-publish.test.mjs had a second /-rooted source_repo pattern, the
one checking that publishing v2 leaves v1's recorded path alone. Now a
parsed equality against fx.cwd, which says what was meant and is
stronger than "starts with a slash".
Both were reproduced against the exact paths from the failing job before
fixing: the repo's emitter, fed
C:\Users\RUNNER~1\AppData\Local\Temp\cc-config-problems-VYK5zA\pi-library,
produces the quoted, backslash-escaped scalar the log shows as `actual`
byte for byte, the old forms fail on it, and the new forms hold on both
path shapes.
Rather than patch these two and risk a third round, tests/ was swept for
every instance of the shapes involved: raw-line comparisons against an
interpolated path, a path interpolated into a pattern, and split("/") on a
path. There are no others. What the sweep turns up is writes that feed the
parser (unquoted backslash scalars, which it reads verbatim), URLs, which
are always /-separated, and patterns that already escape their input or
interpolate a count, version or model id.
The changelog entry is now ten expectations rather than eight, and says
why the issue's list could only name eight.
Refs #395
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ucQwVaxXUXxzHAvmgaNp7
|
The A test stops at its first failing assertion, which is why the run could only ever report one bad expectation per test — and why #395's list of eight was what was visible rather than the whole set:
Both were reproduced before fixing, against the exact paths from the failing job: the repo's own emitter, fed Rather than patch two more and risk a third round, I swept So the PR description's "eight" is now ten — the changelog entry says ten and explains why the issue could only name eight. Everything else in the description stands, including Generated by Claude Code |
|
Correcting one claim in the description above, and filing what it was about: #412. The description's last note says
So the fix is one more line wherever the guard appears, not a guard added to one file. #412 has the reproduction, the seven files, and why changing production to read Nothing in this PR changes — the failure is pre-existing, green on both CI platforms, and untouched here. Only my description of its cause was off. Also noted for the record: the Generated by Claude Code |
Summary
The three issues the
test-windowsjob's first run produced, in the order the 2026-09-15 closeout names. Ten failures onwindows-latest: two product defects and eight test expectations written for POSIX paths.atomicWriteFile's rename retries while a holder blocks it (#393). Every canonical write in the framework routes throughatomicWriteFile, which renames a sibling temp file over the destination. On Windows that rename fails withEPERMwhile another writer holds or is replacing the same file, so two concurrent writers — two MCP hosts, Pi and MCP, the usage log appending as a phase completes — lost a write with an error rather than serializing. The rename now goes through a newretryOnTransientFsError: a bounded, jittered retry onEPERM/EBUSY/EACCESthat propagates any other error on the first attempt and the last transient one once the 500 ms budget is spent. The retry wraps only the rename, so a permission failure on the temp write still fails immediately, and the temp-file cleanup is unchanged. Nowin32branch — a POSIX rename-over-existing is atomic and does not report those codes for contention, so one code path means the Windows job exercises what Linux does.A containment root that does not exist yet resolves like its target (#394).
isWithinPathResolvedresolved the target's existing prefix through symlinks (#223) but the root throughrealpathalone, which throws on a.codecarto/that is not there yet and fell back to the root as spelled. Wherever an ancestor needed expanding the two operands then disagreed — the 8.3 short name the runner puts in%TEMP%(C:\Users\RUNNER~1\…), macOS'/var→/private/var— and a phase session's write to.codecarto/findings/…was blocked while that directory did not exist yet. Both operands now resolve throughresolveExistingPrefix, and the primitive takes the base a relative operand resolves against. The Pi orchestrator hook and the phase hook now call that one primitive instead of each hand-rollingresolveExistingPrefix+canonicalPath+isWithinPath— the duplication one of them had drifted from — and the MCPspec_pathcheck inherits the fix. What containment refuses is unchanged.Eight expectations no longer assume POSIX paths (#395). An
outputDir's last segment taken withsplit("/"), which does not split a backslash path; four comparisons against a rawlibrary.pathorsource_repoline, where a path with backslashes is correctly emitted as a quoted YAML scalar with those backslashes escaped; a path interpolated into aRegExpsource, where\bbecomes a word boundary; a refusal message matched against a/-rooted pattern; and a joined path compared against a/-joined literal. Each now compares a parsed value, abasename, apath.joinon both sides, or a literal prefix.Type of change
Checklist
CHANGELOG.mdgains an[Unreleased]section with one entry per issuetest:, which appears once in history and is the accurate prefix for a test-only changeRelated issues
Closes #393
Closes #394
Refs #395
Additional notes
continue-on-errorstays on thetest-windowsjob, deliberately. #395 makes the gate flip the consequence of these three fixes, but whether the suite is green on Windows can only be settled by a run — flipping it blind would turn an unverified job into a PR blocker. That is why #395 isRefsand notCloses: the flip is a one-line follow-up once this PR's Windows job reports green, and that is the moment to close the issue.Nine new tests, each failing on the previous code. Verified by stashing the source change and re-running. Six cover the retry loop directly (each transient code retried until the operation lands, the budget spent still propagating the last error, permanent codes rejected on the first attempt, a thrown non-
Errorpassed through); three cover containment (an unborn root under a symlinked ancestor, the phase guard's first write through one, the base a relative operand resolves against).How the Windows-specific claims were checked without a Windows runner. #394 reproduces on Linux through a symlinked ancestor — the same divergence the 8.3 short name causes — so no Windows run was needed to find or fix it, and the issue's "needs a run on Windows with the two resolved paths printed" turned out not to hold. For #395 every mechanism was replayed against Windows-shaped data on Linux, using
win32path semantics and the repo's own YAML emitter fed aC:\…path: the old form fails and the new form holds in each of the five cases. What could not be verified here is the end-to-end behaviour of #393's fix, because a POSIX rename cannot be made to reportEPERMfor contention — the forty-writer concurrency test is the reproduction, and this PR's Windows job is what settles it.One correction to #394 worth recording. Its "on Windows a phase sub-agent cannot write its findings at all and the Pi surface does not work" overstates what the code shows. The guard only misfires while
.codecarto/is absent; in an initialized workspace the root realpaths fine and both sides agree, and the orchestrator hook's roots always exist at hook time. It was a latent consistency bug whose only reachable manifestation was the test — still worth fixing, since the Windows job cannot go green without it and the invariant is now true rather than accidentally true.Reviewers may want to look at the 500 ms retry budget (the issue asked for "a few hundred milliseconds";
graceful-fsuses 60 s, sized for dev tooling) and the decision to fix #394 at the primitive rather than patching each root call. I checked the other four containment sites and left them alone:agent-runner.ts:51derives its candidate from the canonicalized root so both sides already agree, and the threesameWorkspace-style comparisons (index.ts,mcp-server/server.ts,core/library.ts) are each guarded by apathExistsimmediately before, so both operands always exist.The pre-existing local failure, for the record.
tests/library.test.mjs→resolvePublishSourceRepo records origin's fetch URL verbatimfails in any environment carrying a globalurl.<base>.insteadOfgit setting, which rewrites whatgit remote get-urlreports.tests/pi-publish.test.mjsalready guards against exactly this by pointingGIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEMat a nonexistent path;tests/library.test.mjshas the same assertion with no such guard. It is green on CI's bare runners and untouched by this branch, so it is left for its own issue rather than folded in here.🤖 Generated with Claude Code
https://claude.ai/code/session_011ucQwVaxXUXxzHAvmgaNp7
Generated by Claude Code