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.
This commit is contained in:
yetuge 2026-11-15 21:01:09 +08:00 committed by GitHub
parent a1a6866ebc
commit 778f53a456
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 45 additions and 6 deletions

View File

@ -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

View File

@ -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

View File

@ -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,
),
]

View File

@ -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