From 98e8598adbcab0d66e8ae2de0faf059585dcddb6 Mon Sep 17 00:00:00 2001 From: ywy <143893413+ywy-ok@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:24:24 +0800 Subject: [PATCH] fix(config): delete thread dir through extended-length path to avoid WinError 145 (#8144) Co-authored-by: Nan Gao --- CHANGELOG.md | 6 ++++++ .../packages/harness/deerflow/config/paths.py | 16 +++++++++++----- backend/tests/test_paths_user_isolation.py | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7394df56e5..7129601db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,12 @@ This section accumulates work toward the **2.3.0** milestone ### Fixed +- **threads:** Deleting a conversation on Windows no longer fails with + `Failed to delete local thread data.` when the workspace holds a file whose + path exceeds `MAX_PATH`. The thread directory is now resolved through the + Windows extended-length namespace before removal. + ([#8144](https://github.com/bytedance/deer-flow/pull/8144)) + - **channels:** A WeCom group command that follows the bot mention now runs as a command. WeCom keeps the mention inside the message text (`@RobotA /new`), and the classifier required a bare leading `/`, so `/new` and `/status` sent in a diff --git a/backend/packages/harness/deerflow/config/paths.py b/backend/packages/harness/deerflow/config/paths.py index 9a2c184a83..c7ad2761a8 100644 --- a/backend/packages/harness/deerflow/config/paths.py +++ b/backend/packages/harness/deerflow/config/paths.py @@ -508,12 +508,18 @@ class Paths: def delete_thread_dir(self, thread_id: str, *, user_id: str | None = None) -> None: """Delete all persisted data for a thread. - Idempotent: missing thread directories are ignored. A file the host marks - read-only is retried once after clearing that bit, because Windows refuses - to unlink it where POSIX only needs a writable parent; a file that stays - undeletable still raises. + Idempotent: missing thread directories are ignored. The tree is removed + through the Windows extended-length namespace so a nested file whose full + path exceeds ``MAX_PATH`` (for example a plugin's ``files/.docx`` + under a deep user/thread workspace) is still unlinked. Without the prefix + ``shutil.rmtree`` leaves the long child behind and ``os.rmdir`` fails with + ``WinError 145``. + + A file the host marks read-only is retried once after clearing that bit, + because Windows refuses to unlink it where POSIX only needs a writable + parent; a file that stays undeletable still raises. """ - thread_dir = self.thread_dir(thread_id, user_id=user_id) + thread_dir = extended_length_path(self.thread_dir(thread_id, user_id=user_id)) try: shutil.rmtree(thread_dir) except FileNotFoundError: diff --git a/backend/tests/test_paths_user_isolation.py b/backend/tests/test_paths_user_isolation.py index c3015c1dd7..65657cea75 100644 --- a/backend/tests/test_paths_user_isolation.py +++ b/backend/tests/test_paths_user_isolation.py @@ -307,6 +307,25 @@ class TestEnsureAndDeleteWithUserId: paths.delete_thread_dir("t1", user_id="u1") assert paths.thread_dir("t1", user_id="u1").exists() + def test_delete_thread_dir_uses_extended_length_path(self, paths: Paths, monkeypatch): + """``shutil.rmtree`` receives the path resolved through ``extended_length_path``. + + The fix for the ``MAX_PATH`` deletion failure is a single line: resolve + the thread directory through ``extended_length_path`` before removing it. + That call is a no-op on POSIX, so this pins the wiring by patching the + resolver to return a marker and asserting ``shutil.rmtree`` receives it. + """ + from deerflow.config import paths as paths_module + + marker = object() + monkeypatch.setattr(paths_module, "extended_length_path", lambda _path: marker) + received = [] + monkeypatch.setattr(paths_module.shutil, "rmtree", lambda path, *args, **kwargs: received.append(path)) + + paths.ensure_thread_dirs("t1", user_id="u1") + paths.delete_thread_dir("t1", user_id="u1") + assert received == [marker] + def test_ensure_thread_dirs_legacy_still_works(self, paths: Paths): paths.ensure_thread_dirs("t1") assert paths.sandbox_work_dir("t1").is_dir()