fix(workspace): publish the snapshot text cache byte-exactly on every host (#7110)

* fix(workspace): publish the snapshot text cache byte-exactly on every host

* test(workspace): pin diff line counts and guard the text cache on Windows CI

* ci(workspace): run Windows text-cache guards on source changes

Include the workspace_changes package in the Windows workflow's PR path
filter so scanner or diff regressions run the native Windows guards before
merge, rather than waiting for the weekly schedule.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
lukyturnflow 2026-10-22 15:42:32 +08:00 committed by GitHub
parent 493178dd86
commit c8875320a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 77 additions and 7 deletions

View File

@ -1,6 +1,8 @@
name: Windows Live Test Policy
# These offline policy tests spawn pytest children that collect live tests.
# These offline policy tests spawn pytest children that collect live tests,
# and the workspace text-cache tests pin byte-exact cache round-trips whose
# regression only fails on native Windows (see test_scanner_text_cache_atomic).
on:
workflow_dispatch:
schedule:
@ -9,8 +11,11 @@ on:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- '.github/workflows/backend-windows-live-policy.yml'
- 'backend/packages/harness/deerflow/workspace_changes/**'
- 'backend/tests/test_client_live_policy.py'
- 'backend/tests/test_client_live.py'
- 'backend/tests/test_scanner_text_cache_atomic.py'
- 'backend/tests/test_workspace_changes.py'
- 'backend/pyproject.toml'
- 'backend/uv.lock'
@ -48,8 +53,11 @@ jobs:
- name: Install backend dependencies
run: uv sync --locked --group dev
- name: Check isolated child environment and live-test collection policy
- name: Check isolated child environment, live-test collection policy, and text-cache byte exactness
# Explicit nodes include all four collection cases and avoid make.
# The two workspace-changes modules pin byte-exact text-cache
# round-trips whose regression (newline translation) only fails on
# native Windows — ubuntu CI cannot catch it (PR #7110 review).
run: >-
uv run python -m pytest
tests/test_client_live_policy.py::test_child_env_stays_isolated_but_bootable_on_windows
@ -57,4 +65,6 @@ jobs:
tests/test_client_live_policy.py::test_explicit_opt_in_collects_live_tests
tests/test_client_live_policy.py::test_ci_blocks_live_collection_even_with_opt_in
tests/test_client_live_policy.py::test_opt_in_without_config_reports_missing_config
tests/test_scanner_text_cache_atomic.py
tests/test_workspace_changes.py
-q

View File

@ -198,7 +198,11 @@ def _snapshot_text(file: FileSnapshot | None) -> str | None:
return file.text
if file.text_path:
try:
with open(file.text_path, encoding="utf-8") as cached:
# The cache is published byte-exactly (the scanner pins
# newline="\n"), so read it back without newline translation:
# universal newlines would silently rewrite a decoded CRLF
# snapshot into LF text and corrupt the served content.
with open(file.text_path, encoding="utf-8", newline="") as cached:
return cached.read()
except OSError:
return None

View File

@ -328,10 +328,17 @@ def _snapshot_symlink(root: WorkspaceRoot, host_file: Path) -> FileSnapshot | No
def _publish_text_atomically(path: Path, content: str) -> None:
"""Publish text through a temporary file so a partial write cannot survive."""
"""Publish text through a temporary file so a partial write cannot survive.
``newline="\\n"`` keeps the publication byte-exact on every host: the
default text-mode translation would rewrite a decoded CRLF snapshot's
``\\n`` characters into ``os.linesep`` on Windows, turning ``\\r\\n``
content into ``\\r\\r\\n`` bytes that every later reader serves verbatim
as the file's text.
"""
fd, temporary = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())

View File

@ -93,6 +93,21 @@ def test_cache_text_file_returns_a_complete_entry(tmp_path, scanner):
assert Path(returned).name == hashlib.sha256(virtual_path.encode()).hexdigest()
def test_cache_text_file_publishes_decoded_crlf_content_byte_exactly(tmp_path, scanner):
"""A decoded CRLF snapshot must reach the cache verbatim on every host.
The default text-mode write translates ``\\n`` into ``os.linesep``, which
on native Windows turns ``\\r\\n`` content into ``\\r\\r\\n`` bytes -- the
cache is then served as the file's text with a spurious blank line.
"""
cache_dir = tmp_path / "cache"
cache_dir.mkdir()
returned = scanner._cache_text_file("a,b\r\n1,2\r\n", "/workspace/report.csv", cache_dir)
assert Path(returned).read_bytes() == b"a,b\r\n1,2\r\n"
def test_cache_entry_survives_a_failed_publish(tmp_path, monkeypatch, scanner):
"""The real entry point: a failed publish must not damage the cached entry."""
cache_dir = tmp_path / "cache"

View File

@ -77,15 +77,49 @@ def test_compare_snapshots_reports_text_file_changes(tmp_path):
assert changes["/mnt/user-data/workspace/old.txt"].status == "deleted"
def test_cached_crlf_text_round_trips_byte_exactly_into_diffs(tmp_path):
"""The text cache is published and read back byte-exactly on every host.
The pre-run snapshot carries its text through ``_publish_text_atomically``
(write) and ``_snapshot_text`` (read) while the post-run snapshot carries
text inline, exactly like ``record_workspace_changes``. Host newline
translation on either cache side would corrupt the served text and the
built diff (a spurious blank line per CRLF line on native Windows).
"""
roots = _roots(tmp_path)
(roots[0].host_path / "notes.md").write_bytes(b"first\r\nsecond\r\n")
before_cache = tmp_path / "cache-before"
before = scan_workspace_roots(roots, text_cache_dir=before_cache)
(roots[0].host_path / "notes.md").write_bytes(b"first\r\nthird\r\n")
after = scan_workspace_roots(roots)
assert before.files["/mnt/user-data/workspace/notes.md"].text_path is not None
result = compare_snapshots(before, after)
change = {c.path: c for c in result.files}["/mnt/user-data/workspace/notes.md"]
assert "-second" in change.diff
assert "+third" in change.diff
# Pin the line counts: a host that translates newlines on the cache write
# and re-translates on read rounds the cached text up to "first\n\nsecond\n"
# (one spurious blank line per CRLF line), which shows up here as an extra
# deletion line. "-second"/"+third" alone stay present under that bug, so
# only the counts catch it — on any host, not just native Windows.
assert (change.additions, change.deletions) == (1, 1), change.diff
@pytest.mark.parametrize("include_text", [False, True])
@pytest.mark.parametrize("size", [16, SAMPLE_BYTES + 1])
def test_scan_keeps_other_files_when_a_file_disappears_before_hashing(tmp_path, monkeypatch, include_text, size):
roots = _roots(tmp_path)
workspace, outputs = [root.host_path for root in roots]
(workspace / "a-kept.txt").write_text("kept\n", encoding="utf-8")
# Byte-pinned fixtures (tests/AGENTS.md): a text-mode write translates the
# LF byte into CRLF on native Windows, and the compared snapshot text
# would then round-trip as "report\r\n" instead of "report\n".
(workspace / "a-kept.txt").write_bytes(b"kept\n")
vanished = workspace / "scratch.txt"
vanished.write_text("x" * size, encoding="utf-8")
(outputs / "report.md").write_text("report\n", encoding="utf-8")
(outputs / "report.md").write_bytes(b"report\n")
real_hash = scanner._sha256_file
removed = []