fix(workspace): keep snapshots when files vanish during hashing (#7057)

Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com>
This commit is contained in:
haotianqi666 2026-10-19 07:40:52 +08:00 committed by GitHub
parent 5f9a521d60
commit 81daa578f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 116 additions and 2 deletions

View File

@ -2598,6 +2598,11 @@ Image bytes loaded for a vision-model call are transient: DeerFlow removes the h
After each run, DeerFlow records a workspace change summary for the run-owned `workspace` and `outputs` directories. The Web UI shows a compact "files changed" badge on the assistant turn; opening it reveals created, modified, and deleted files with text diffs when safe to display. Uploads are excluded because they are user inputs, not agent-generated changes, and stdio MCP temporary/debug files under the DeerFlow-owned `.mcp/` namespace are excluded because they are process-internal state (like `.git/` and `node_modules/`, any directory named `.mcp` is excluded at any depth). Large, binary, or sensitive-looking files are shown as metadata only.
If a file disappears while being hashed, the scan omits that file and continues
recording the others. A concurrent scratch-file removal therefore does not
discard the whole snapshot or suppress detection of other produced outputs.
This remains a best-effort scan, not an atomic filesystem snapshot.
Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. When a completed response successfully presents between 2 and 50 files, its final file card also offers one ZIP download. Archive membership comes from the terminal delivery receipt rather than browser-supplied paths, and the ZIP contains the current file versions, which may have changed since the response. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes. Source previews also recognize extensionless `Dockerfile` and `Makefile` artifacts by their file names. Unknown file types, including names such as `constructor` and `__proto__`, retain the download fallback.
Unsaved artifact edits survive switching chats, workspace pages, or visiting the

View File

@ -219,7 +219,10 @@ def _snapshot_file(
return None
binary = host_file.suffix.lower() in BINARY_EXTENSIONS or _looks_binary(sample)
sha256 = _sha256_file(host_file) if size <= limits.max_file_bytes_for_diff else None
try:
sha256 = _sha256_file(host_file) if size <= limits.max_file_bytes_for_diff else None
except FileNotFoundError:
return None
text: str | None = None
text_path: str | None = None
reason: DiffUnavailableReason | None = None

View File

@ -302,7 +302,11 @@ secrets, shell/Compose dotenv overrides, readable read-only secrets, and teardow
without secret exports. `down` must not probe, read, or generate secrets.
Writable-directory cases remain portable.
## Workspace text cache
## Workspace snapshots and text cache
`test_workspace_changes.py` and `test_run_worker_delivery.py` remove real files
at the hash-read boundary. A vanished source must not discard other file records
or produced-output detection; permission and other hash I/O errors still propagate.
`test_scanner_text_cache_atomic.py` verifies complete publication and preservation
of an existing entry on failure. A cleanup failure must preserve the original

View File

@ -19,6 +19,7 @@ from deerflow.runtime.runs.schemas import RunStatus
from deerflow.runtime.runs.store.memory import MemoryRunStore
from deerflow.runtime.runs.worker import RunContext, _delivery_content_with_outputs, run_agent
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.workspace_changes.api import get_workspace_changes_response
def _make_bridge():
@ -52,6 +53,64 @@ def test_delivery_verification_treats_presented_directory_as_covering_produced_f
assert delivery["satisfied"] is True
@pytest.mark.anyio
@pytest.mark.parametrize("disappear_during", ["before-run", "after-run"])
async def test_vanished_scratch_file_does_not_discard_run_file_changes(tmp_path, monkeypatch, disappear_during):
import deerflow.workspace_changes.scanner as scanner
paths = Paths(base_dir=tmp_path)
monkeypatch.setattr("deerflow.workspace_changes.recorder.get_paths", lambda: paths)
user_id = get_effective_user_id()
workspace = paths.sandbox_work_dir("thread-1", user_id=user_id)
outputs = paths.sandbox_outputs_dir("thread-1", user_id=user_id)
workspace.mkdir(parents=True)
outputs.mkdir(parents=True)
scratch = workspace / "scratch.txt"
if disappear_during == "before-run":
scratch.write_text("temporary\n", encoding="utf-8")
removed = []
real_hash = scanner._sha256_file
def remove_before_hash(path):
if path == scratch:
path.unlink()
removed.append(path)
return real_hash(path)
monkeypatch.setattr(scanner, "_sha256_file", remove_before_hash)
run_manager = RunManager()
record = await run_manager.create("thread-1")
store = MemoryRunEventStore()
class ProducingAgent:
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
if disappear_during == "after-run":
scratch.write_text("temporary\n", encoding="utf-8")
(outputs / "report.md").write_text("Report ready\n", encoding="utf-8")
yield {"messages": [AIMessage(content="Created report.md.")]}
await run_agent(
_make_bridge(),
run_manager,
record,
ctx=RunContext(checkpointer=None, event_store=store),
agent_factory=lambda *, config: ProducingAgent(),
graph_input={},
config={},
)
assert removed == [scratch]
changes = await get_workspace_changes_response(store, "thread-1", record.run_id)
assert changes["available"] is True
assert [(change["path"], change["status"]) for change in changes["files"]] == [("/mnt/user-data/outputs/report.md", "created")]
assert "+Report ready" in changes["files"][0]["diff"]
delivery = await _delivery_events(store, "thread-1", record.run_id)
assert delivery[0]["content"]["produced_paths"] == ["/mnt/user-data/outputs/report.md"]
assert delivery[0]["content"]["satisfied"] is False
assert record.status == RunStatus.error
assert record.error == "Artifact delivery incomplete: no produced output artifact was presented"
@pytest.mark.anyio
async def test_delivery_event_records_present_files_paths_on_success():
run_manager = RunManager()

View File

@ -5,6 +5,7 @@ from pathlib import Path
import pytest
import deerflow.workspace_changes.scanner as scanner
from deerflow.config.paths import Paths
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.runs.manager import RunManager
@ -76,6 +77,48 @@ def test_compare_snapshots_reports_text_file_changes(tmp_path):
assert changes["/mnt/user-data/workspace/old.txt"].status == "deleted"
@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")
vanished = workspace / "scratch.txt"
vanished.write_text("x" * size, encoding="utf-8")
(outputs / "report.md").write_text("report\n", encoding="utf-8")
real_hash = scanner._sha256_file
removed = []
def remove_before_hash(path):
if path == vanished:
path.unlink()
removed.append(path)
return real_hash(path)
monkeypatch.setattr(scanner, "_sha256_file", remove_before_hash)
snapshot = scan_workspace_roots(roots, include_text=include_text)
assert removed == [vanished]
assert set(snapshot.files) == {"/mnt/user-data/workspace/a-kept.txt", "/mnt/user-data/outputs/report.md"}
assert snapshot.files["/mnt/user-data/outputs/report.md"].sha256 is not None
assert snapshot.files["/mnt/user-data/outputs/report.md"].text == ("report\n" if include_text else None)
@pytest.mark.parametrize("error", [PermissionError("denied"), OSError("disk read failed")])
def test_scan_does_not_hide_operational_hash_errors(tmp_path, monkeypatch, error):
roots = _roots(tmp_path)
(roots[0].host_path / "report.md").write_text("report\n", encoding="utf-8")
def fail_hash(path):
raise error
monkeypatch.setattr(scanner, "_sha256_file", fail_hash)
with pytest.raises(type(error), match=str(error)):
scan_workspace_roots(roots)
def test_get_changed_output_paths_returns_only_created_or_modified_regular_outputs(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path