fix(pending-delete): drop the staging container with its last batch (#1145)

Deleting a folder that holds models answered "A deletion is still pending inside
this folder. Wait for the undo window to expire." even though nothing was staged
and no window was running. `.lm-pending-delete` is only a container: the purge,
the undo and the staging rollback all removed the batch dir inside it and left the
container behind, and the folder manifest matched the directory *name* alone. Any
folder where a model had ever been deleted with the undo window was therefore
undeletable for good, and the same name match refused folder renames.

* `staging_dir_has_content()` is the shared answer to "is there still a batch
  here?": an empty container is a leftover, a quarantined batch (`*.orphaned`) is
  preserved data and still counts, and an unreadable directory counts too — never
  delete over something that could not be listed.
* The container is removed with its last batch (purge, undo, staging rollback,
  merged-loser cleanup). `rmdir` only ever succeeds on an empty directory, and the
  path is checked to really be a `.lm-pending-delete`, so a model folder is never
  at risk.
* A manifest-less batch dir with nothing in it is a crash leftover between
  `makedirs` and the manifest write: it is dropped instead of quarantined, which
  would have left an empty container that blocks deletion just the same.

Verified: purge, undo and the empty-manifest-less sweep all leave no container
behind, the container survives while another batch is staged, and an empty one no
longer blocks folder delete or rename while a real (or quarantined) batch still
does. 3818 backend tests pass.
This commit is contained in:
Will Miao
2026-10-12 10:34:55 +08:00
parent 3a718ce2c3
commit 5976224d75
4 changed files with 208 additions and 7 deletions
+19 -5
View File
@@ -12,7 +12,10 @@ from ..utils.recipes_paths import path_is_or_contains_recipes_dir
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
from ..services.settings_manager import get_settings_manager
from ..services.model_lifecycle_service import _require_path_in_library_roots
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
from ..services.pending_delete_service import (
PENDING_DELETE_DIR_NAME,
staging_dir_has_content,
)
logger = logging.getLogger(__name__)
@@ -881,7 +884,12 @@ class ModelMoveService:
excluded_paths = self._excluded_model_paths()
for dirpath, dirnames, filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames:
# The container alone means nothing: an empty `.lm-pending-delete`
# is a leftover from a purge, an undo or a failed staging, and
# treating it as pending made the folder undeletable for good.
if PENDING_DELETE_DIR_NAME in dirnames and staging_dir_has_content(
os.path.join(dirpath, PENDING_DELETE_DIR_NAME)
):
pending_delete_job = True
for name in dirnames:
@@ -1072,9 +1080,15 @@ class ModelMoveService:
@staticmethod
def _has_pending_delete_job(absolute_path: str) -> bool:
"""Return True when a staged-delete batch lives inside the subtree."""
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames:
"""Return True when a staged-delete batch lives inside the subtree.
An empty ``.lm-pending-delete`` container does not count: it is a
leftover, not a batch, and a rename must not be refused because of one.
"""
for dirpath, dirnames, _filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames and staging_dir_has_content(
os.path.join(dirpath, PENDING_DELETE_DIR_NAME)
):
return True
return False