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
+57 -2
View File
@@ -359,7 +359,9 @@ async def test_delete_folder_success_manifest_carries_excluded_model_count(tmp_p
@pytest.mark.asyncio
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
batch = target / ".lm-pending-delete" / "batch1"
batch.mkdir(parents=True)
(batch / "manifest.json").write_text("{}", encoding="utf-8")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
@@ -370,6 +372,43 @@ async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path:
assert target.exists()
@pytest.mark.asyncio
async def test_delete_folder_ignores_an_empty_staging_container(tmp_path: Path):
"""An empty `.lm-pending-delete` is a leftover, not a pending deletion.
Every purge, undo and failed staging used to leave the container behind, and
the name match alone made the folder undeletable for good: "A deletion is
still pending inside this folder. Wait for the undo window to expire." with
no window left to wait for (issue #1145 follow-up).
"""
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is True
assert not target.exists()
@pytest.mark.asyncio
async def test_delete_folder_refuses_over_a_quarantined_batch(tmp_path: Path):
"""Quarantine preserves the user's staged files, so it still blocks."""
target = _make_nested(tmp_path)
quarantined = target / ".lm-pending-delete" / "batch1.orphaned"
quarantined.mkdir(parents=True)
(quarantined / "model.safetensors").write_bytes(b"user-data")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is False
assert result["code"] == "busy"
assert (quarantined / "model.safetensors").read_bytes() == b"user-data"
@pytest.mark.asyncio
async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path):
scanner = FakeScanner([tmp_path])
@@ -669,7 +708,9 @@ async def test_rename_folder_refuses_symlinked_directory(tmp_path: Path):
@pytest.mark.asyncio
async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
batch = target / ".lm-pending-delete" / "batch1"
batch.mkdir(parents=True)
(batch / "manifest.json").write_text("{}", encoding="utf-8")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
@@ -681,6 +722,20 @@ async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path:
assert scanner.renamed_folders == []
@pytest.mark.asyncio
async def test_rename_folder_ignores_an_empty_staging_container(tmp_path: Path):
"""The leftover container must not block a rename either."""
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), "animation")
assert result["success"] is True
assert scanner.renamed_folders
@pytest.mark.asyncio
async def test_rename_folder_requires_path(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora")