TAI-YUAN GUO f5907405a0
fix(workspace): don't infer created or deleted files from truncated snapshots (#7172)
* fix(workspace): don't infer created or deleted files from truncated snapshots

A bounded workspace scan stops at max_scanned_files and marks the snapshot
truncated. Absence from such a snapshot is unknown, so creation is only
inferred from a complete before snapshot and deletion only from a complete
after snapshot. get_changed_paths() is the single filter for the summary,
text capture and output-delivery detection.

The scanner now walks subdirectories in sorted order so the truncation
boundary is reproducible across the before and after scans, and the docs
note that the budget is consumed root by root (workspace before outputs).

Supersedes #5231.

Co-authored-by: liutian <91778998+ImcLiuQian@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(runtime): record truncated delivery verification skips

Record verification_skipped=truncated_pre_run_snapshot in run.delivery
receipts when a truncated pre-run scan leaves no provable output changes.
Carry the snapshot into both normal and exceptional receipt finalization.

Keep observed-output verification and terminal statuses unchanged. Cover
success, failure, cancellation and capture failures through the real worker,
and document the per-run receipt marker.

---------

Co-authored-by: liutian <91778998+ImcLiuQian@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-22 16:52:53 +08:00

232 lines
8.6 KiB
Python

from __future__ import annotations
import difflib
from .types import (
DiffUnavailableReason,
FileSnapshot,
WorkspaceChangeLimits,
WorkspaceChangeResult,
WorkspaceChangeStatus,
WorkspaceChangeSummary,
WorkspaceFileChange,
WorkspaceSnapshot,
)
def compare_snapshots(
before: WorkspaceSnapshot,
after: WorkspaceSnapshot,
*,
limits: WorkspaceChangeLimits | None = None,
) -> WorkspaceChangeResult:
resolved_limits = limits or WorkspaceChangeLimits()
changes: list[WorkspaceFileChange] = []
created = modified = deleted = symlink_created = additions = deletions = 0
total_diff_bytes = 0
truncated = before.truncated or after.truncated
for path in sorted(get_changed_paths(before, after)):
before_file = before.files.get(path)
after_file = after.files.get(path)
status = _status(before_file, after_file)
if status == "created":
created += 1
elif status == "modified":
modified += 1
elif status == "symlink_created":
symlink_created += 1
else:
deleted += 1
diff, line_additions, line_deletions, diff_truncated, reason = _build_diff(
path,
before_file,
after_file,
remaining_bytes=max(0, resolved_limits.max_total_diff_bytes - total_diff_bytes),
)
if diff:
total_diff_bytes += len(diff.encode("utf-8"))
if diff_truncated or reason in {"large", "truncated"}:
truncated = True
additions += line_additions
deletions += line_deletions
if len(changes) < resolved_limits.max_files:
sample = after_file or before_file
assert sample is not None
changes.append(
WorkspaceFileChange(
path=path,
root=sample.root,
status=status,
binary=bool((after_file or before_file).binary if (after_file or before_file) else False),
sensitive=bool((after_file or before_file).sensitive if (after_file or before_file) else False),
size_before=before_file.size if before_file else None,
size_after=after_file.size if after_file else None,
sha256_before=before_file.sha256 if before_file else None,
sha256_after=after_file.sha256 if after_file else None,
diff=diff,
diff_truncated=diff_truncated,
diff_unavailable_reason=reason,
additions=line_additions,
deletions=line_deletions,
symlink=bool((after_file or before_file).symlink if (after_file or before_file) else False),
symlink_target_before=before_file.symlink_target if before_file else None,
symlink_target_after=after_file.symlink_target if after_file else None,
)
)
else:
truncated = True
return WorkspaceChangeResult(
summary=WorkspaceChangeSummary(
created=created,
modified=modified,
deleted=deleted,
symlink_created=symlink_created,
additions=additions,
deletions=deletions,
truncated=truncated,
),
files=changes,
limits=resolved_limits,
)
def get_changed_paths(before: WorkspaceSnapshot, after: WorkspaceSnapshot) -> set[str]:
changed: set[str] = set()
for path in set(before.files) | set(after.files):
before_file = before.files.get(path)
after_file = after.files.get(path)
# Absence from a partial scan does not prove creation or deletion.
if (before_file is None and before.truncated) or (after_file is None and after.truncated):
continue
if before_file and after_file and _same_file(before_file, after_file):
continue
changed.add(path)
return changed
def get_changed_output_paths(before: WorkspaceSnapshot, after: WorkspaceSnapshot) -> list[str]:
"""Return regular output changes established by the bounded snapshots.
Paths absent from a truncated before snapshot are omitted, including
genuinely new files; modifications observed in both snapshots are retained.
The worker's delivery check relies on this list and is skipped when it is
empty, so an empty result does not prove that the run produced no outputs.
"""
paths: list[str] = []
for path in sorted(get_changed_paths(before, after)):
snapshot = after.files.get(path)
if snapshot is not None and snapshot.root == "outputs" and not snapshot.symlink:
paths.append(path)
return paths
def _status(
before_file: FileSnapshot | None,
after_file: FileSnapshot | None,
) -> WorkspaceChangeStatus:
# A symlink now occupying a path that was not already a symlink is always
# surfaced distinctly - whether it is brand new (before_file is None) or it
# just replaced a regular file (before_file is None => "deleted" would
# otherwise be reported even though the path is still alive on disk, just
# as a symlink that may point anywhere on the host).
before_was_symlink = before_file is not None and before_file.symlink
after_is_symlink = after_file is not None and after_file.symlink
if after_is_symlink and not before_was_symlink:
return "symlink_created"
if before_file is None:
return "created"
if after_file is None:
return "deleted"
return "modified"
def _same_file(before_file: FileSnapshot, after_file: FileSnapshot) -> bool:
if before_file.sha256 is not None and after_file.sha256 is not None:
return before_file.sha256 == after_file.sha256
return before_file.size == after_file.size and before_file.mtime_ns == after_file.mtime_ns
def _build_diff(
path: str,
before_file: FileSnapshot | None,
after_file: FileSnapshot | None,
*,
remaining_bytes: int,
) -> tuple[str, int, int, bool, DiffUnavailableReason | None]:
reason = _diff_unavailable_reason(before_file, after_file)
if reason is not None:
return "", 0, 0, False, reason
before_text = _snapshot_text(before_file) if before_file else ""
after_text = _snapshot_text(after_file) if after_file else ""
if before_file is not None and before_text is None:
return "", 0, 0, False, None
if after_file is not None and after_text is None:
return "", 0, 0, False, None
lines = list(
difflib.unified_diff(
before_text.splitlines(),
after_text.splitlines(),
fromfile=f"a{path}",
tofile=f"b{path}",
lineterm="",
)
)
diff = "\n".join(lines)
additions, deletions = _count_diff_lines(lines)
if len(diff.encode("utf-8")) > remaining_bytes:
return "", additions, deletions, True, "truncated"
return diff, additions, deletions, False, None
def _diff_unavailable_reason(
before_file: FileSnapshot | None,
after_file: FileSnapshot | None,
) -> DiffUnavailableReason | None:
files = [file for file in (before_file, after_file) if file is not None]
for preferred in ("symlink", "sensitive", "binary", "large"):
if any(file.content_unavailable_reason == preferred for file in files):
return preferred # type: ignore[return-value]
return None
def _snapshot_text(file: FileSnapshot | None) -> str | None:
if file is None:
return ""
if file.text is not None:
return file.text
if file.text_path:
try:
# 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
return None
def _count_diff_lines(lines: list[str]) -> tuple[int, int]:
additions = 0
deletions = 0
# difflib.unified_diff always emits the two file headers ("--- a/…" then
# "+++ b/…") first; skip them by position. A prefix test can't tell them
# apart from a hunk-body line whose content starts with "-- "/"++ " (e.g. a
# deleted SQL comment "-- get users" becomes the diff line "--- get users"),
# which would then be dropped from the count.
for line in lines[2:]:
if line.startswith("+"):
additions += 1
elif line.startswith("-"):
deletions += 1
return additions, deletions