From 778f53a456560aead68fd8f805dafb3b0cc970a0 Mon Sep 17 00:00:00 2001 From: yetuge <182123929+yetuge@users.noreply.github.com> Date: Sun, 15 Nov 2026 21:01:09 +0800 Subject: [PATCH] fix(workspace): treat hidden output paths as working state (#8254) * fix(workspace): treat hidden output paths as working state get_changed_output_paths counted every regular file under outputs as a produced artifact, so a finished run that left a cursor/state JSON or a .work/ scratch tree there was recorded as delivery incomplete. Skip paths with a hidden segment below outputs; visible files still count. Part of #8239. * fix(workspace): share the outputs prefix and document the hidden-path rule The delivery filter partitioned on a hardcoded /outputs/ while the scanner built the same prefix in recorder.py, so a future prefix change would silently stop filtering working state. Export one OUTPUTS_VIRTUAL_PREFIX used by both, and record the hidden-path exclusion in the runtime guidance contract. --- backend/docs/runtime-guidance-details.md | 5 +++- .../deerflow/workspace_changes/diff.py | 25 ++++++++++++++++--- .../deerflow/workspace_changes/recorder.py | 4 +-- backend/tests/test_workspace_changes.py | 17 +++++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/backend/docs/runtime-guidance-details.md b/backend/docs/runtime-guidance-details.md index 8b7d8754e5..4b47147178 100644 --- a/backend/docs/runtime-guidance-details.md +++ b/backend/docs/runtime-guidance-details.md @@ -161,7 +161,10 @@ and after scans. Its budget is shared and consumed root by root (workspace before outputs), so a workspace at the limit starves outputs. `get_changed_output_paths()` inherits this uncertainty: newly observed outputs are suppressed when the before snapshot was truncated, while modifications -observed in both snapshots remain eligible. The worker passes this list through +observed in both snapshots remain eligible. Hidden paths are working state, not +deliverables: any dot-prefixed segment below the outputs root (a cursor/state +JSON, a `.work/` scratch tree) is excluded, so it never enters `produced_paths`. +The worker passes this list through `_produced_output_paths()` to its delivery check; an empty list leaves `produced_paths` and `verification` absent from the delivery receipt, and `_delivery_error()` returns no error. When that empty list accompanies a truncated diff --git a/backend/packages/harness/deerflow/workspace_changes/diff.py b/backend/packages/harness/deerflow/workspace_changes/diff.py index 60fe5bf0de..00de151099 100644 --- a/backend/packages/harness/deerflow/workspace_changes/diff.py +++ b/backend/packages/harness/deerflow/workspace_changes/diff.py @@ -109,18 +109,37 @@ def get_changed_paths(before: WorkspaceSnapshot, after: WorkspaceSnapshot) -> se return changed +#: Virtual prefix of the outputs workspace root. Shared with ``recorder.py`` so +#: the delivery filter below cannot silently drift from the scanner's paths. +OUTPUTS_VIRTUAL_PREFIX = "/mnt/user-data/outputs" + + +def _is_hidden_output_path(path: str) -> bool: + """Whether any segment below the outputs root starts with a dot. + + Scheduled tasks and agents keep working state under outputs (a cursor or + state JSON, a ``.work/`` scratch tree). Those files are not deliverables, so + they must not make an otherwise finished run fail delivery verification. + """ + prefix = f"{OUTPUTS_VIRTUAL_PREFIX}/" + if not path.startswith(prefix): + return False + return any(segment.startswith(".") for segment in path[len(prefix) :].split("/") if segment) + + 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. + Hidden paths are working state, not deliverables. 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: + if snapshot is not None and snapshot.root == "outputs" and not snapshot.symlink and not _is_hidden_output_path(path): paths.append(path) return paths diff --git a/backend/packages/harness/deerflow/workspace_changes/recorder.py b/backend/packages/harness/deerflow/workspace_changes/recorder.py index 0439470b12..1873d67917 100644 --- a/backend/packages/harness/deerflow/workspace_changes/recorder.py +++ b/backend/packages/harness/deerflow/workspace_changes/recorder.py @@ -10,7 +10,7 @@ from typing import Any from deerflow.config import get_paths -from .diff import compare_snapshots, get_changed_paths +from .diff import OUTPUTS_VIRTUAL_PREFIX, compare_snapshots, get_changed_paths from .scanner import scan_workspace_roots from .types import ( WORKSPACE_CHANGES_EVENT_CATEGORY, @@ -35,7 +35,7 @@ def build_thread_workspace_roots(thread_id: str, *, user_id: str | None = None) WorkspaceRoot( name="outputs", host_path=paths.sandbox_outputs_dir(thread_id, user_id=user_id), - virtual_prefix="/mnt/user-data/outputs", + virtual_prefix=OUTPUTS_VIRTUAL_PREFIX, ), ] diff --git a/backend/tests/test_workspace_changes.py b/backend/tests/test_workspace_changes.py index ce692d7748..7d9b94739d 100644 --- a/backend/tests/test_workspace_changes.py +++ b/backend/tests/test_workspace_changes.py @@ -478,6 +478,23 @@ def test_get_changed_output_paths_ignores_externalized_tool_results(tmp_path): assert get_changed_output_paths(before, after) == ["/mnt/user-data/outputs/report.md"] +def test_get_changed_output_paths_ignores_hidden_working_state(tmp_path): + """Hidden files and directories under outputs are working state, not deliverables.""" + roots = _roots(tmp_path) + outputs = roots[1].host_path + before = scan_workspace_roots(roots) + + (outputs / ".digest_cursor.json").write_text("{}", encoding="utf-8") + (outputs / ".work").mkdir() + (outputs / ".work" / "chart.png").write_bytes(b"png") + (outputs / "sub").mkdir() + (outputs / "sub" / ".state.json").write_text("{}", encoding="utf-8") + (outputs / "sub" / "report.md").write_text("deliverable", encoding="utf-8") + after = scan_workspace_roots(roots) + + assert get_changed_output_paths(before, after) == ["/mnt/user-data/outputs/sub/report.md"] + + def test_scan_workspace_roots_can_skip_text_loading(tmp_path): roots = _roots(tmp_path) workspace = roots[0].host_path