Ver Fonte

fix(archive): "Print again" on the printer's screen is archived with its 3MF (#3307)

- A touchscreen reprint names no file Bambuddy can find: the printer
  reports its history copy under a generic name
  (file:///userdata/project_file.gcode.3mf) and the 3MF's internal
  title, which need not be the card file's name. The second run of a
  job archived fine the first time got a no-3MF fallback.
- When every lookup misses, the newest archive of the same job on the
  same printer (same subtask name and plate in its start report, a
  layer count known on both sides equal, 3MF on disk) lends its 3MF.
  Copied per printer, archived under its own name, the source noted
  in _print_data and the log.
- Skipped only when the directory walk listed a file for the print
  and its transfer failed; a cool-off or a timed-out sweep path is no
  evidence the retry can find this file. A failed copy still leaves
  the fallback archive.
maziggy há 14 horas atrás
pai
commit
5f70e363c8

+ 3 - 0
CHANGELOG.md

@@ -174,6 +174,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **"Print again" on the printer's screen is archived with its 3MF (#3307, reported by @elizabethburg)** — A print that archived in full the first time got an empty archive (no 3MF, thumbnail or filament) when started again with **Print again** on the printer. On a reprint the printer doesn't name the file on the card: it reports its own internal copy under a generic name, plus the model's title from inside the 3MF. When that title differs from the file name, as with a project saved under another name, Bambuddy couldn't find the file.
+  - **Now:** when the file can't be found, Bambuddy looks for an earlier archive of the same job on the same printer and archives the new run with that 3MF. The earlier run must have been sent from the slicer or started on the printer, with the same title and plate, and the same layer count when the printer has already reported one. The log names the archive the 3MF came from.
+  - **Unchanged:** a run with no such earlier archive still gets the name-and-timing archive. When the card lists a file for the print and only its download failed, the retry fetches that file instead.
 - **Check Belt Tension on a P1 printer links to Bambu Lab's belt tensioning guide (#3302)** — The maintenance task's help link on a P1P or P1S opened Bambu Lab's general P1 maintenance recommendations instead of a belt guide. It now opens the "X1/P1 Series Belt tensioning procedure" page, which covers both series.
 - **Searching "#123" in Assign Spool finds that spool whatever the slot holds (#3301)** — Searching for a spool's number with a `#` in front, as in `#123`, only looked among the spools matching the slot's material. A spool of another material came up empty unless **Show all spools** was ticked, as if it had been deleted.
   - **Now:** a `#` search shows exactly that spool, whatever its material. Assigning a spool of another material still asks first, as before. Any other search still lists only spools matching the slot.

+ 120 - 1
backend/app/main.py

@@ -6,6 +6,7 @@ import os
 import posixpath
 import re
 import secrets
+import shutil
 import time
 from contextlib import asynccontextmanager
 from datetime import datetime, timedelta, timezone
@@ -3819,6 +3820,65 @@ _fallback_3mf_retry_tasks: dict[int, asyncio.Task] = {}
 _fallback_recovery_locks: dict[int, asyncio.Lock] = {}
 
 
+# How many of a printer's newest archives a touchscreen reprint is compared
+# against (#3307). A repeat is usually of a recent job; the bound keeps a
+# print start from reading a long history's extra_data.
+_REPEAT_LOOKBACK = 200
+
+
+async def _archived_3mf_for_repeat(
+    db, printer_id: int, subtask_name: str | None, gcode_file: str | None, total_layers: int | None
+):
+    """The newest archive of the job this printer is running again, with its 3MF.
+
+    A "Print again" on the printer's screen never names the file on the card:
+    it reports its own history copy under a generic name
+    (``file:///userdata/project_file.gcode.3mf``), the 3MF's internal title as
+    ``subtask_name`` and the plate as ``gcode_file`` -- and the title is often
+    not the card file's name, so every lookup built from it misses (#3307).
+    An earlier run that was archived recorded that same title and plate in its
+    ``_print_data``, and holds the 3MF.
+
+    Same printer, same title, same plate, a 3MF still on disk. A layer count
+    known on both sides must agree: the same title and plate re-sliced is
+    another job. None when nothing matches.
+    """
+    from backend.app.models.archive import PrintArchive
+
+    if not subtask_name or not gcode_file:
+        return None
+    rows = (
+        (
+            await db.execute(
+                select(PrintArchive)
+                .where(
+                    PrintArchive.printer_id == printer_id,
+                    PrintArchive.deleted_at.is_(None),
+                    PrintArchive.file_path.isnot(None),
+                    PrintArchive.file_path != "",
+                )
+                .order_by(PrintArchive.created_at.desc(), PrintArchive.id.desc())
+                .limit(_REPEAT_LOOKBACK)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    for archive in rows:
+        extra = archive.extra_data if isinstance(archive.extra_data, dict) else {}
+        run = extra.get("_print_data")
+        if not isinstance(run, dict):
+            continue
+        if run.get("subtask_name") != subtask_name or run.get("filename") != gcode_file:
+            continue
+        if total_layers and archive.total_layers and archive.total_layers != total_layers:
+            continue
+        if not (app_settings.base_dir / archive.file_path).is_file():
+            continue
+        return archive
+    return None
+
+
 async def _recover_fallback_archive(archive_id: int, source_3mf: Path, printer_id: int, *, price: bool = True) -> bool:
     """Fill in a no-3MF archive from a 3MF that turned up later.
 
@@ -5314,6 +5374,11 @@ async def on_print_start(printer_id: int, data: dict):
         # leaves this False and schedules nothing. Anything else means the
         # transfer, not the file, is what failed, and that does not last (#3063).
         ftp_transfer_failed = False
+        # The narrower case of the above: the directory walk *listed* a file
+        # for this print and could not fetch it, so the file is on the card
+        # under a name that matches. The only failure that rules out an
+        # earlier run's 3MF standing in for it (#3307).
+        walk_transfer_failed = False
 
         # The print's name, for a fallback archive whose `subtask_name` the
         # plate guard below had to disown. Display only, and deliberately kept
@@ -5554,6 +5619,51 @@ async def on_print_start(printer_id: int, data: dict):
                 # The listing named the file, so it is on the card;
                 # only the transfer failed (#3063).
                 ftp_transfer_failed = True
+                walk_transfer_failed = True
+
+        # A "Print again" on the printer's screen names no file Bambuddy can
+        # find: only its history copy under a generic name, and the 3MF's
+        # internal title (#3307). When an earlier run of the same job was
+        # archived, its 3MF is the one being printed again. Not when the
+        # directory walk listed a file for this print and only its transfer
+        # failed: that file is on the card, and the retry fetches it. A
+        # cool-off or a failed sweep path is no such evidence -- the retry
+        # tries the same names, which this print's file never had.
+        reused_3mf_from: int | None = None
+        if not downloaded_filename and not walk_transfer_failed:
+            reported_layers = raw_mqtt.get("total_layer_num")
+            earlier = await _archived_3mf_for_repeat(
+                db,
+                printer_id,
+                subtask_name,
+                filename,
+                reported_layers if isinstance(reported_layers, int) and reported_layers > 0 else None,
+            )
+            if earlier is not None:
+                reuse_name = Path(earlier.filename or "").name or f"archive_{earlier.id}.3mf"
+                # Per printer: two printers reprinting the same job at once
+                # must not write one temp file (archive_print names the
+                # archive after original_filename, not this path).
+                reuse_path = app_settings.archive_dir / "temp" / f"reprint_{printer_id}_{reuse_name}"
+                try:
+                    reuse_path.parent.mkdir(parents=True, exist_ok=True)
+                    await asyncio.to_thread(shutil.copyfile, app_settings.base_dir / earlier.file_path, reuse_path)
+                except OSError as e:
+                    # A copy that fails leaves the print the fallback archive
+                    # it would have had, not no archive at all.
+                    logger.warning("Could not copy the 3MF of archive %s for printer %s: %s", earlier.id, printer_id, e)
+                else:
+                    temp_path = reuse_path
+                    downloaded_filename = reuse_name
+                    reused_3mf_from = earlier.id
+                    cache_3mf_download(printer_id, reuse_name, temp_path)
+                    logger.info(
+                        "No 3MF on printer %s for %s (%s); using the 3MF of archive %s, an earlier run of the same job",
+                        printer_id,
+                        subtask_name,
+                        filename,
+                        earlier.id,
+                    )
 
         # Validate the downloaded 3MF actually matches the plate that's running
         # (#1204): subtask_name lags across consecutive plates of the same model,
@@ -5892,8 +6002,17 @@ async def on_print_start(printer_id: int, data: dict):
                 archive = await service.archive_print(
                     printer_id=printer_id,
                     source_file=temp_path,
-                    print_data={**data, "status": "printing"},
+                    print_data={
+                        **data,
+                        "status": "printing",
+                        # Where the 3MF came from when the printer had none to
+                        # give (#3307), for whoever reads this archive later.
+                        **({"_3mf_from_archive": reused_3mf_from} if reused_3mf_from is not None else {}),
+                    },
                     subtask_id=subtask_id,
+                    # The reused copy's temp name carries the printer id; a
+                    # download's is the file's own name, as before.
+                    original_filename=downloaded_filename if reused_3mf_from is not None else None,
                 )
             except IntegrityError:
                 # archive_print removed the folder it had copied the 3MF into.

+ 401 - 0
backend/tests/unit/test_print_again_reuses_archived_3mf_3307.py

@@ -0,0 +1,401 @@
+"""A "Print again" from the printer's screen is archived with its 3MF (#3307).
+
+On a touchscreen reprint the printer never names the file on the card. It
+reports its own history copy under a generic name
+(``file:///userdata/project_file.gcode.3mf``, which port 990 cannot serve), the
+3MF's internal title as ``subtask_name`` -- "IKEA Skadis Support Feet", while
+the card file is "Support Feet 20mm Standoff_Build Sandbox.gcode.3mf" -- and
+``/data/Metadata/plate_3.gcode``. Every name built from those misses, so the
+second run of a job archived fine the first time got a no-3MF fallback.
+
+The first run's archive holds that 3MF, and its ``_print_data`` records the
+same subtask name and plate. When the lookup comes back empty, the newest such
+archive on the same printer lends its 3MF to the new run. A layer count the
+printer has already reported must agree, so a re-slice under the same title is
+not mistaken for the same job. Not on a cool-off or a failed transfer: there
+the file was reachable and its own retry fetches the real one.
+"""
+
+import shutil
+from contextlib import ExitStack
+from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.core.config import settings as app_settings
+from backend.app.main import (
+    _active_prints,
+    _archived_3mf_for_repeat,
+    _expected_print_creators,
+    _expected_print_registered_at,
+    _expected_prints,
+    _print_ams_mappings,
+    _timelapse_baselines,
+)
+from backend.app.models.archive import PrintArchive
+from backend.app.models.printer import Printer
+from backend.app.services.bambu_ftp import FileNotOnPrinterError
+
+pytestmark = pytest.mark.unit
+
+TITLE = "IKEA Skadis Support Feet"
+PLATE_3 = "/data/Metadata/plate_3.gcode"
+CARD_FILE = "Support Feet 20mm Standoff_Build Sandbox.gcode.3mf"
+
+
+@pytest.fixture
+def base_dir(tmp_path):
+    # archive_dir too: it is its own setting, and the copy lands in its temp/.
+    with (
+        patch.object(app_settings, "base_dir", tmp_path),
+        patch.object(app_settings, "archive_dir", tmp_path / "archive"),
+    ):
+        yield tmp_path
+
+
+def _stored_3mf(base_dir, folder: str, content: bytes = b"PK\x03\x04 3mf") -> str:
+    path = base_dir / "archive" / folder / CARD_FILE
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_bytes(content)
+    return str(path.relative_to(base_dir))
+
+
+class TestFindingTheEarlierRun:
+    @pytest.fixture
+    async def printers(self, db_session):
+        for n in (1, 2):
+            db_session.add(
+                Printer(id=n, name=f"H2C-{n}", serial_number=f"SN{n}", ip_address="192.0.2.1", access_code="1")
+            )
+        await db_session.commit()
+
+    async def _archive(self, db, base_dir, folder, *, printer_id=1, subtask=TITLE, gcode=PLATE_3, age_min=60, **fields):
+        archive = PrintArchive(
+            printer_id=printer_id,
+            filename=CARD_FILE,
+            file_path=_stored_3mf(base_dir, folder),
+            file_size=10,
+            print_name=f"{TITLE} - Plate 3",
+            status="completed",
+            created_at=datetime.now(timezone.utc) - timedelta(minutes=age_min),
+            extra_data={"_print_data": {"filename": gcode, "subtask_name": subtask}},
+            **fields,
+        )
+        db.add(archive)
+        await db.commit()
+        return archive
+
+    async def _find(self, db, *, printer_id=1, subtask=TITLE, gcode=PLATE_3, layers=None):
+        return await _archived_3mf_for_repeat(db, printer_id, subtask, gcode, layers)
+
+    @pytest.mark.asyncio
+    async def test_the_same_job_on_the_same_printer_is_found(self, db_session, printers, base_dir):
+        first = await self._archive(db_session, base_dir, "14")
+        assert (await self._find(db_session)).id == first.id
+
+    @pytest.mark.asyncio
+    async def test_the_newest_run_wins(self, db_session, printers, base_dir):
+        await self._archive(db_session, base_dir, "14", age_min=120)
+        newer = await self._archive(db_session, base_dir, "15", age_min=10)
+        assert (await self._find(db_session)).id == newer.id
+
+    @pytest.mark.asyncio
+    async def test_another_plate_is_not(self, db_session, printers, base_dir):
+        await self._archive(db_session, base_dir, "14", gcode="/data/Metadata/plate_2.gcode")
+        assert await self._find(db_session) is None
+
+    @pytest.mark.asyncio
+    async def test_another_title_is_not(self, db_session, printers, base_dir):
+        await self._archive(db_session, base_dir, "14", subtask="IKEA Skadis Hook")
+        assert await self._find(db_session) is None
+
+    @pytest.mark.asyncio
+    async def test_another_printer_is_not(self, db_session, printers, base_dir):
+        await self._archive(db_session, base_dir, "14", printer_id=2)
+        assert await self._find(db_session) is None
+
+    @pytest.mark.asyncio
+    async def test_a_deleted_archive_is_not(self, db_session, printers, base_dir):
+        await self._archive(db_session, base_dir, "14", deleted_at=datetime.now(timezone.utc))
+        assert await self._find(db_session) is None
+
+    @pytest.mark.asyncio
+    async def test_a_fallback_archive_without_a_3mf_is_not(self, db_session, printers, base_dir):
+        archive = await self._archive(db_session, base_dir, "14")
+        archive.file_path = ""
+        await db_session.commit()
+        assert await self._find(db_session) is None
+
+    @pytest.mark.asyncio
+    async def test_an_archive_whose_file_is_gone_is_skipped_for_an_older_one(self, db_session, printers, base_dir):
+        older = await self._archive(db_session, base_dir, "14", age_min=120)
+        await self._archive(db_session, base_dir, "15", age_min=10)
+        shutil.rmtree(base_dir / "archive" / "15")
+        assert (await self._find(db_session)).id == older.id
+
+    @pytest.mark.asyncio
+    async def test_a_different_layer_count_is_another_slice(self, db_session, printers, base_dir):
+        await self._archive(db_session, base_dir, "14", total_layers=120)
+        assert await self._find(db_session, layers=96) is None
+        assert await self._find(db_session, layers=120) is not None
+
+    @pytest.mark.asyncio
+    async def test_an_unknown_layer_count_on_either_side_does_not_decide(self, db_session, printers, base_dir):
+        await self._archive(db_session, base_dir, "14")
+        assert await self._find(db_session, layers=96) is not None
+        assert await self._find(db_session, layers=None) is not None
+
+    @pytest.mark.asyncio
+    async def test_nothing_to_match_on_finds_nothing(self, db_session, printers, base_dir):
+        # A run that recorded no plate is no evidence for a print reporting none.
+        await self._archive(db_session, base_dir, "14", gcode="")
+        assert await self._find(db_session, gcode="") is None
+        await self._archive(db_session, base_dir, "15", subtask="")
+        assert await self._find(db_session, subtask="") is None
+
+    @pytest.mark.asyncio
+    async def test_fallback_archives_do_not_use_up_the_lookback(self, db_session, printers, base_dir):
+        """Every failed run leaves a no-3MF row; they must not push the run
+        that has the file out of the rows compared."""
+        good = await self._archive(db_session, base_dir, "14", age_min=120)
+        empty = await self._archive(db_session, base_dir, "17", age_min=10)
+        empty.file_path = ""
+        await db_session.commit()
+        with patch("backend.app.main._REPEAT_LOOKBACK", 1):
+            assert (await self._find(db_session)).id == good.id
+
+    @pytest.mark.asyncio
+    async def test_unreadable_extra_data_is_skipped(self, db_session, printers, base_dir):
+        archive = await self._archive(db_session, base_dir, "14")
+        archive.extra_data = {"_print_data": "not a dict"}
+        await db_session.commit()
+        assert await self._find(db_session) is None
+
+
+# --- on_print_start wiring ---------------------------------------------------
+
+
+@pytest.fixture
+def _clear_dicts():
+    dicts = (
+        _expected_prints,
+        _expected_print_registered_at,
+        _expected_print_creators,
+        _print_ams_mappings,
+        _active_prints,
+        _timelapse_baselines,
+    )
+    for d in dicts:
+        d.clear()
+    yield
+    for d in dicts:
+        d.clear()
+
+
+def _printer():
+    printer = MagicMock()
+    printer.id = 1
+    printer.auto_archive = True
+    printer.external_camera_enabled = False
+    printer.external_camera_url = None
+    printer.plate_detection_enabled = False
+    printer.name = "H2C"
+    printer.model = "H2C"
+    printer.ip_address = "192.0.2.10"
+    printer.access_code = "12345678"
+    return printer
+
+
+async def _print_again(
+    base_dir,
+    *,
+    earlier,
+    project_url="file:///userdata/project_file.gcode.3mf",
+    download=None,
+    listing=(),
+    blocked=False,
+):
+    """Run on_print_start for the reporter's reprint; every lookup misses."""
+    printer = _printer()
+
+    def execute_router(stmt, *args, **kwargs):
+        sql = str(stmt).lower()
+        if "from printers" in sql or "from printer " in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=printer),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[printer]))),
+            )
+        return MagicMock(
+            scalar_one_or_none=MagicMock(return_value=None),
+            scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+        )
+
+    added: list = []
+    session = AsyncMock()
+    session.__aenter__ = AsyncMock(return_value=session)
+    session.__aexit__ = AsyncMock()
+    session.execute = AsyncMock(side_effect=execute_router)
+    session.commit = AsyncMock()
+    session.refresh = AsyncMock()
+    session.add = MagicMock(side_effect=added.append)
+
+    state = MagicMock(current_project_url=project_url, sdcard=True, sdcard_reported=True)
+    archived = MagicMock(id=99, printer_id=1, status="printing", filename=CARD_FILE)
+    service = MagicMock()
+    finder = AsyncMock(return_value=earlier)
+    copied: dict = {}
+
+    async def archive_print(**kwargs):
+        copied["bytes"] = kwargs["source_file"].read_bytes()
+        return archived
+
+    service.archive_print = AsyncMock(side_effect=archive_print)
+
+    with ExitStack() as stack:
+        session_maker = stack.enter_context(patch("backend.app.main.async_session"))
+        notif = stack.enter_context(patch("backend.app.main.notification_service"))
+        plug = stack.enter_context(patch("backend.app.main.smart_plug_manager"))
+        ws = stack.enter_context(patch("backend.app.main.ws_manager"))
+        relay = stack.enter_context(patch("backend.app.main.mqtt_relay"))
+        pm = stack.enter_context(patch("backend.app.main.printer_manager"))
+        stack.enter_context(
+            patch(
+                "backend.app.main.download_file_async",
+                new=download or AsyncMock(side_effect=FileNotOnPrinterError("550")),
+            )
+        )
+        stack.enter_context(patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)))
+        stack.enter_context(patch("backend.app.main.get_cached_3mf", return_value=None))
+        stack.enter_context(patch("backend.app.main.cache_3mf_download"))
+        stack.enter_context(patch("backend.app.main.peek_plate_index_in_3mf", return_value=3))
+        stack.enter_context(
+            patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=list(listing)))
+        )
+        stack.enter_context(patch("backend.app.main.ftps_handshake_blocked", return_value=blocked))
+        stack.enter_context(
+            patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30)))
+        )
+        stack.enter_context(patch("backend.app.main._record_energy_start", new_callable=AsyncMock))
+        stack.enter_context(patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock))
+        stack.enter_context(patch("backend.app.main._maybe_start_layer_timelapse"))
+        stack.enter_context(patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock))
+        stack.enter_context(patch("backend.app.main._schedule_fallback_3mf_retry", new=MagicMock()))
+        stack.enter_context(
+            patch("backend.app.main._ask_outcome_for_external_print", new=AsyncMock(return_value=False))
+        )
+        stack.enter_context(
+            patch("backend.app.main._drop_archive_of_deleted_printer", new=AsyncMock(return_value=False))
+        )
+        stack.enter_context(patch("backend.app.main._printer_gone", new=AsyncMock(return_value=False)))
+        stack.enter_context(patch("backend.app.main.ArchiveService", return_value=service))
+        stack.enter_context(patch("backend.app.main._archived_3mf_for_repeat", new=finder))
+        session_maker.return_value = session
+        notif.on_print_start = AsyncMock()
+        plug.on_print_start = AsyncMock()
+        ws.send_print_start = AsyncMock()
+        ws.send_archive_updated = AsyncMock()
+        ws.send_archive_created = AsyncMock()
+        relay.on_print_start = AsyncMock()
+        pm.get_status = MagicMock(return_value=state)
+        pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST3307"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(1, {"filename": PLATE_3, "subtask_name": TITLE, "raw_data": {"total_layer_num": 120}})
+
+    fallback = next(
+        (r for r in added if isinstance(getattr(r, "extra_data", None), dict) and r.extra_data.get("no_3mf_available")),
+        None,
+    )
+    return SimpleNamespace(service=service, finder=finder, fallback=fallback, copied=copied)
+
+
+def _earlier(base_dir, content=b"PK\x03\x04 the first run's 3MF"):
+    return SimpleNamespace(id=14, filename=CARD_FILE, file_path=_stored_3mf(base_dir, "14", content))
+
+
+@pytest.mark.usefixtures("_clear_dicts")
+class TestPrintAgain:
+    @pytest.mark.asyncio
+    async def test_the_reported_case_is_archived_with_the_earlier_runs_3mf(self, base_dir):
+        run = await _print_again(base_dir, earlier=_earlier(base_dir))
+
+        assert run.fallback is None
+        assert run.copied["bytes"] == b"PK\x03\x04 the first run's 3MF"
+        kwargs = run.service.archive_print.await_args.kwargs
+        # Said where it came from, for whoever reads the archive later.
+        assert kwargs["print_data"]["_3mf_from_archive"] == 14
+        # Named after the card file, not after the temp copy.
+        assert kwargs["original_filename"] == CARD_FILE
+
+    @pytest.mark.asyncio
+    async def test_the_copy_is_per_printer(self, base_dir):
+        """Two printers reprinting the same job at once must not share one
+        temp file: one copy would truncate the other mid-archive."""
+        run = await _print_again(base_dir, earlier=_earlier(base_dir))
+        assert run.service.archive_print.await_args.kwargs["source_file"].name == f"reprint_1_{CARD_FILE}"
+
+    @pytest.mark.asyncio
+    async def test_a_copy_that_fails_leaves_the_fallback_archive(self, base_dir):
+        earlier = SimpleNamespace(id=14, filename=CARD_FILE, file_path="archive/gone/" + CARD_FILE)
+        run = await _print_again(base_dir, earlier=earlier)
+        assert run.fallback is not None
+        run.service.archive_print.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_looked_up_by_title_plate_and_layer_count(self, base_dir):
+        run = await _print_again(base_dir, earlier=_earlier(base_dir))
+        assert run.finder.await_args.args[1:] == (1, TITLE, PLATE_3, 120)
+
+    @pytest.mark.asyncio
+    async def test_the_earlier_archive_keeps_its_own_file(self, base_dir):
+        earlier = _earlier(base_dir)
+        await _print_again(base_dir, earlier=earlier)
+        assert (base_dir / earlier.file_path).read_bytes() == b"PK\x03\x04 the first run's 3MF"
+
+    @pytest.mark.asyncio
+    async def test_a_name_sweep_that_missed_too(self, base_dir):
+        """The reporter's first failure (archive 17): no internal-storage URL,
+        a full sweep and directory walk, all missing."""
+        run = await _print_again(base_dir, earlier=_earlier(base_dir), project_url=None)
+        assert run.fallback is None
+        assert run.service.archive_print.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_no_earlier_run_is_a_fallback_archive_as_before(self, base_dir):
+        run = await _print_again(base_dir, earlier=None)
+        assert run.fallback is not None
+        run.service.archive_print.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_not_when_the_card_listed_a_file_for_it(self, base_dir):
+        """The directory walk found a file named for the print and only its
+        transfer failed: that file is on the card, and the retry fetches it --
+        possibly a newer slice than the earlier run."""
+        run = await _print_again(
+            base_dir,
+            earlier=_earlier(base_dir),
+            project_url=None,
+            download=AsyncMock(return_value=False),
+            listing=[{"name": f"{TITLE}.gcode.3mf", "is_directory": False}],
+        )
+        run.finder.assert_not_awaited()
+        assert run.fallback is not None
+
+    @pytest.mark.asyncio
+    async def test_a_sweep_path_that_timed_out_is_no_such_evidence(self, base_dir):
+        """The retry would try the same names, which this print's file never
+        had, so leaving it to the retry left the archive empty for good."""
+        run = await _print_again(
+            base_dir, earlier=_earlier(base_dir), project_url=None, download=AsyncMock(return_value=False)
+        )
+        assert run.fallback is None
+        assert run.service.archive_print.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_nor_is_a_cool_off(self, base_dir):
+        run = await _print_again(base_dir, earlier=_earlier(base_dir), project_url=None, blocked=True)
+        assert run.fallback is None
+        assert run.service.archive_print.await_count == 1