Jelajahi Sumber

fix(queue): a job queued for "Any" model is only given a printer whose spools the slot matcher can map (filament on the right nozzle, a spool per slot), instead of being held there, handed back and given the same printer again

maziggy 1 hari lalu
induk
melakukan
aed9d91a58

+ 4 - 0
CHANGELOG.md

@@ -169,6 +169,10 @@ 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.
 - **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
 ### Fixed
+- **A job queued for "Any" model no longer keeps returning to a printer that can't map its filament** — When choosing a printer, the queue only checked that each filament type, and each forced colour, was loaded somewhere on it. On a dual-nozzle printer (H2D, H2C, X2D) without a filament track switch, a spool only feeds the nozzle it belongs to. A printer with the filament on the other nozzle was still chosen, and the job was held there and handed back. **Start** then let it go without offering **Print Anyway**, and the next pass chose the same printer again.
+  - **Now passed over:** a printer is only chosen when the job's filaments can be matched to its spools the way they are when the job is sent. Another printer of the model that has them gets the job.
+  - **Also on single-nozzle printers:** the queue gives each slot its own spool, so two slots of the same material need two spools of it. A printer with only one spool of it was chosen and looped the same way.
+  - **When none has them,** the job waits unassigned, with the filament it needs in its reason, and goes out once a spool is loaded. **Print Anyway** is unchanged.
 - **Force color match is kept for a job queued to a specific printer (#3280, reported by @Lioncat55)** — A job queued for one printer, for example through a virtual printer with **Force color match** on, printed on the first spool of the same material when none had the requested colour. A job queued for "Any" model was held in the same situation.
 - **Force color match is kept for a job queued to a specific printer (#3280, reported by @Lioncat55)** — A job queued for one printer, for example through a virtual printer with **Force color match** on, printed on the first spool of the same material when none had the requested colour. A job queued for "Any" model was held in the same situation.
   - **Now held:** the job waits with "Needs PLA #A4AAAC" on the queue row until a spool of that material, in exactly that colour, is loaded. Then **Start** sends it. A spool of a similar colour no longer counts, and when the file asks for a particular variant (Basic, Matte, Silk), a spool of another variant doesn't either. A spool that reports no variant still counts.
   - **Now held:** the job waits with "Needs PLA #A4AAAC" on the queue row until a spool of that material, in exactly that colour, is loaded. Then **Start** sends it. A spool of a similar colour no longer counts, and when the file asks for a particular variant (Basic, Matte, Silk), a spool of another variant doesn't either. A spool that reports no variant still counts.
   - **One spool per slot:** two slots asking for the same filament need two spools of it, for a job queued for one printer and for "Any" model alike. Before, one spool counted for both when choosing a printer, and the second slot was then left without one.
   - **One spool per slot:** two slots asking for the same filament need two spools of it, for a job queued for one printer and for "Any" model alike. Before, one spool counted for both when choosing a printer, and the second slot was then left without one.

+ 95 - 25
backend/app/services/print_scheduler.py

@@ -1383,6 +1383,11 @@ class PrintScheduler:
         # pass. Cleared at the top of each pass so a re-sliced file is never
         # pass. Cleared at the top of each pass so a re-sliced file is never
         # served from a previous tick.
         # served from a previous tick.
         self._filament_req_memo: dict[tuple, list[dict] | None] = {}
         self._filament_req_memo: dict[tuple, list[dict] | None] = {}
+        # Per-pass memo for `_unmatched_on` (#3280): choosing a printer asks the
+        # matcher about every idle candidate, and a pass can ask again for the
+        # same job and printer (another variant, a previous-print retry, the
+        # low-filament move). Cleared with the requirement memo.
+        self._unmatched_memo: dict[tuple, list[str]] = {}
         # When each "any model" job held for low filament last looked for
         # When each "any model" job held for low filament last looked for
         # another printer (#3137), by queue item id.
         # another printer (#3137), by queue item id.
         self._short_hold_checked_at: dict[int, float] = {}
         self._short_hold_checked_at: dict[int, float] = {}
@@ -1578,6 +1583,7 @@ class PrintScheduler:
         """
         """
         # Scoped to one pass: a file re-sliced between ticks must be re-read.
         # Scoped to one pass: a file re-sliced between ticks must be re-read.
         self._filament_req_memo.clear()
         self._filament_req_memo.clear()
+        self._unmatched_memo.clear()
         async with async_session() as db:
         async with async_session() as db:
             # Check if shortest-job-first scheduling is enabled
             # Check if shortest-job-first scheduling is enabled
             sjf_enabled = await self._get_bool_setting(db, "queue_shortest_first")
             sjf_enabled = await self._get_bool_setting(db, "queue_shortest_first")
@@ -2362,6 +2368,12 @@ class PrintScheduler:
                             self._resolve_variant(item, candidate)
                             self._resolve_variant(item, candidate)
                             return await self._filament_short_on(db, item, pid)
                             return await self._filament_short_on(db, item, pid)
 
 
+                        async def unmatched_on(
+                            pid: int, item: PrintQueueItem = item, candidate: _ModelCandidate = candidate
+                        ) -> list[str]:
+                            self._resolve_variant(item, candidate)
+                            return await self._unmatched_on(db, item, pid)
+
                         while True:
                         while True:
                             match_id, match_reason = await self._find_idle_printer_for_model(
                             match_id, match_reason = await self._find_idle_printer_for_model(
                                 db,
                                 db,
@@ -2378,6 +2390,7 @@ class PrintScheduler:
                                 printer_scope=item_scope,
                                 printer_scope=item_scope,
                                 is_short=is_short,
                                 is_short=is_short,
                                 drying_ids=drying_held,
                                 drying_ids=drying_held,
+                                unmatched_on=unmatched_on,
                             )
                             )
                             # "Only if the previous print succeeded" is about the
                             # "Only if the previous print succeeded" is about the
                             # printer the job lands on: one whose last print failed
                             # printer the job lands on: one whose last print failed
@@ -3283,6 +3296,7 @@ class PrintScheduler:
         printer_scope: PrinterScope = ALL_PRINTERS,
         printer_scope: PrinterScope = ALL_PRINTERS,
         is_short: Callable[[int], Awaitable[bool]] | None = None,
         is_short: Callable[[int], Awaitable[bool]] | None = None,
         drying_ids: set[int] | None = None,
         drying_ids: set[int] | None = None,
+        unmatched_on: Callable[[int], Awaitable[list[str]]] | None = None,
     ) -> tuple[int | None, str | None]:
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
         """Find an idle, connected printer matching the model with compatible filaments.
 
 
@@ -3307,6 +3321,11 @@ class PrintScheduler:
                       and "Print Anyway" still has a printer to print on.
                       and "Print Anyway" still has a printer to print on.
             drying_ids: Excluded printers held by a drying cycle; named "(drying)"
             drying_ids: Excluded printers held by a drying cycle; named "(drying)"
                         in the reason, as a pinned job's wait is.
                         in the reason, as a pinned job's wait is.
+            unmatched_on: Names the filaments the slot matcher would leave without a
+                          tray on a printer (#3280). A printer it names any for is
+                          passed over like one missing a filament type: the checks
+                          above only ask whether each type and forced colour is
+                          loaded somewhere, not on the right nozzle or once per slot.
 
 
         Returns:
         Returns:
             Tuple of (printer_id, waiting_reason):
             Tuple of (printer_id, waiting_reason):
@@ -3419,6 +3438,20 @@ class PrintScheduler:
                     )
                     )
                     continue
                     continue
 
 
+            # The matcher has the last word: a printer it cannot map would be
+            # held and given back, then chosen again on the next pass (#3280).
+            if unmatched_on is not None:
+                unmatched = await unmatched_on(printer.id)
+                if unmatched:
+                    printers_missing_filament.append((printer.name, unmatched))
+                    logger.debug(
+                        "Skipping printer %s (%s) - the matcher leaves unmapped: %s",
+                        printer.id,
+                        printer.name,
+                        unmatched,
+                    )
+                    continue
+
             # If preference-only overrides exist, rank by color matches (existing behaviour)
             # If preference-only overrides exist, rank by color matches (existing behaviour)
             if pref_overrides:
             if pref_overrides:
                 color_matches = self._count_override_color_matches(printer.id, pref_overrides)
                 color_matches = self._count_override_color_matches(printer.id, pref_overrides)
@@ -4262,11 +4295,10 @@ class PrintScheduler:
         This replaces a failure, not a success: without it the item is uploaded,
         This replaces a failure, not a success: without it the item is uploaded,
         rejected by the firmware with 0700_8012, retried twice more and failed
         rejected by the firmware with 0700_8012, retried twice more and failed
         anyway with "never started the print after N dispatch attempts". So this
         anyway with "never started the print after N dispatch attempts". So this
-        applies on the model-based path too, even though it means an "Any <model>"
-        job stops at the first printer offered rather than trying its siblings —
-        deferring instead would need the check to move inside
-        ``_find_printer_for_model``'s candidate loop, since un-assigning here just
-        re-assigns the same printer on the next tick.
+        applies on the model-based path too. There it is rarely reached: choosing
+        the printer already asks the matcher (``_unmatched_on``, #3280) and passes
+        over one it cannot map, so an "Any <model>" job tries its siblings and
+        otherwise waits; this remains for a match that failed at selection time.
         """
         """
         item.status = "failed"
         item.status = "failed"
         item.error_message = message
         item.error_message = message
@@ -8198,6 +8230,9 @@ class PrintScheduler:
             async def is_short(pid: int) -> bool:
             async def is_short(pid: int) -> bool:
                 return await self._filament_short_on(db, item, pid, require_known=True)
                 return await self._filament_short_on(db, item, pid, require_known=True)
 
 
+            async def unmatched_on(pid: int) -> list[str]:
+                return await self._unmatched_on(db, item, pid)
+
             match_id, _ = await self._find_idle_printer_for_model(
             match_id, _ = await self._find_idle_printer_for_model(
                 db,
                 db,
                 item.target_model,
                 item.target_model,
@@ -8208,6 +8243,7 @@ class PrintScheduler:
                 require_plate_clear=require_plate_clear,
                 require_plate_clear=require_plate_clear,
                 printer_scope=printer_scope,
                 printer_scope=printer_scope,
                 is_short=is_short,
                 is_short=is_short,
+                unmatched_on=unmatched_on,
             )
             )
             # Every idle printer short comes back as the first of them.
             # Every idle printer short comes back as the first of them.
             if match_id is None or await is_short(match_id):
             if match_id is None or await is_short(match_id):
@@ -8311,6 +8347,58 @@ class PrintScheduler:
             await db.commit()
             await db.commit()
         return False
         return False
 
 
+    async def _unmatched_requirements(
+        self, db: AsyncSession, item: PrintQueueItem, printer_id: int, mapping: list | None
+    ) -> list[dict]:
+        """The requirements ``mapping`` leaves without a tray on ``printer_id`` (#2799).
+
+        ``None`` is a mapping that resolved nothing. Only a printer that reported
+        loaded trays makes that a finding — see ``_block_on_unmatched_filament``
+        on why an empty list is not one.
+        """
+        if mapping is None:
+            status = printer_manager.get_status(printer_id)
+            if status is None or not self._build_loaded_filaments(status):
+                return []
+            # Every required slot reads unresolved against an empty mapping.
+            mapping = []
+
+        required = await self._get_filament_requirements(db, item)
+        if required:
+            self._apply_filament_overrides(item, required)
+        else:
+            # An unreadable 3MF still has its forced colours to go by: they are
+            # what the mapping was built from (#3280).
+            required = _forced_override_requirements(item)
+        return _unresolved_required(required, mapping) if required else []
+
+    async def _unmatched_on(self, db: AsyncSession, item: PrintQueueItem, printer_id: int) -> list[str]:
+        """What ``item`` would be held for on ``printer_id``, as the queue row names it.
+
+        Asks the matcher and the unmatched-filament hold, but changes nothing.
+        Choosing a printer for an "any model" job only checked that each type
+        and forced colour was loaded somewhere, which a dual-nozzle printer with
+        the spool on the other nozzle, or one spool asked for twice, passes. The
+        hold then gave the printer back, and the next pass chose it again.
+
+        Empty when the hold would let the item through, including "Print
+        Anyway" and a match that fails: the printer is then chosen as before.
+        """
+        if item.skip_filament_check:
+            return []
+        key = (item.id, item.archive_id, item.library_file_id, item.plate_id, item.filament_overrides, printer_id)
+        if key in self._unmatched_memo:
+            return list(self._unmatched_memo[key])
+        try:
+            mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
+            unmatched = await self._unmatched_requirements(db, item, printer_id, mapping)
+        except Exception as e:
+            logger.warning("Filament match check failed for item %s on printer %s: %s", item.id, printer_id, e)
+            return []
+        described = [_describe_filament(req, "nozzle_id") for req in unmatched]
+        self._unmatched_memo[key] = described
+        return list(described)
+
     async def _block_on_unmatched_filament(
     async def _block_on_unmatched_filament(
         self,
         self,
         db: AsyncSession,
         db: AsyncSession,
@@ -8351,6 +8439,7 @@ class PrintScheduler:
         if item.skip_filament_check or not item.printer_id:
         if item.skip_filament_check or not item.printer_id:
             return False
             return False
 
 
+        mapping: list | None = None
         if item.ams_mapping:
         if item.ams_mapping:
             try:
             try:
                 mapping = json.loads(item.ams_mapping)
                 mapping = json.loads(item.ams_mapping)
@@ -8358,27 +8447,8 @@ class PrintScheduler:
                 return False
                 return False
             if not isinstance(mapping, list):
             if not isinstance(mapping, list):
                 return False
                 return False
-        else:
-            # Nothing resolved, or nothing survived revalidation. Only a printer
-            # that reported loaded trays makes that a finding — see the
-            # docstring on why an empty list is not one.
-            status = printer_manager.get_status(item.printer_id)
-            if status is None or not self._build_loaded_filaments(status):
-                return False
-            # Every required slot reads unresolved against an empty mapping.
-            mapping = []
-
-        required = await self._get_filament_requirements(db, item)
-        if required:
-            self._apply_filament_overrides(item, required)
-        else:
-            # An unreadable 3MF still has its forced colours to go by: they are
-            # what the mapping was built from (#3280).
-            required = _forced_override_requirements(item)
-            if not required:
-                return False
 
 
-        unmatched = _unresolved_required(required, mapping)
+        unmatched = await self._unmatched_requirements(db, item, item.printer_id, mapping)
         if not unmatched:
         if not unmatched:
             # No cleanup needed here: once start is pressed the item is no
             # No cleanup needed here: once start is pressed the item is no
             # longer staged, and whichever exit runs next overwrites the reason
             # longer staged, and whichever exit runs next overwrites the reason

+ 287 - 0
backend/tests/unit/test_model_path_unmatched_filament.py

@@ -0,0 +1,287 @@
+"""An "any model" job is only given a printer the slot matcher can map (#3280).
+
+Choosing the printer asked cheaper questions than dispatch does: is each
+filament type loaded somewhere, is each forced colour loaded somewhere. On a
+dual-nozzle printer the matcher only uses a spool on the slot's own nozzle, so
+a printer with the filament on the other side passed the choice, the matcher
+left the slot unmatched, and the unmatched-filament hold (#2799) staged the job
+and gave the printer back. Start then found no printer to check, the next pass
+chose the same printer, and the job was held again, with no Print Anyway
+offered in between.
+
+Now the choice asks the matcher itself and passes over a printer it cannot
+map. When no idle printer can, the job stays pending with the filament it
+needs in its reason and is picked up once one is loaded.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+from backend.tests.unit.test_scheduler_model_filament_short_3137 import (
+    _add_item,
+    _get_item,
+    _run,
+    queue_db,  # noqa: F401 - fixture
+)
+
+NEEDS = ["PLA #FFFFFF (left nozzle)"]
+
+
+def _unmatched_on(unmatched: set[int]) -> AsyncMock:
+    """``_unmatched_on`` stand-in: the printers in ``unmatched`` cannot map the job."""
+
+    async def check(db, item, printer_id):
+        return list(NEEDS) if printer_id in unmatched else []
+
+    return AsyncMock(side_effect=check)
+
+
+class TestChoosingAPrinter:
+    async def _find(self, ctx, unmatched: set[int], **kwargs):
+        scheduler = PrintScheduler()
+
+        async def unmatched_on(pid):
+            return list(NEEDS) if pid in unmatched else []
+
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+            patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
+        ):
+            async with ctx.session_maker() as db:
+                return await scheduler._find_idle_printer_for_model(
+                    db, "X1C", set(), unmatched_on=unmatched_on, **kwargs
+                )
+
+    @pytest.mark.asyncio
+    async def test_passes_over_a_printer_the_matcher_cannot_map(self, queue_db):
+        assert await self._find(queue_db, {1}) == (2, None)
+
+    @pytest.mark.asyncio
+    async def test_no_printer_when_none_can_map_it(self, queue_db):
+        printer_id, reason = await self._find(queue_db, {1, 2, 3})
+        assert printer_id is None
+        assert "PLA #FFFFFF (left nozzle)" in reason
+
+    @pytest.mark.asyncio
+    async def test_colour_ranking_skips_a_printer_the_matcher_cannot_map(self, queue_db):
+        """Preference overrides rank printers by colour; the best one still has
+        to be mappable."""
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF"}]
+        scheduler = PrintScheduler()
+
+        async def unmatched_on(pid):
+            return list(NEEDS) if pid == 2 else []
+
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+            patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
+            patch.object(
+                scheduler, "_count_override_color_matches", MagicMock(side_effect=lambda pid, o: 2 if pid == 2 else 1)
+            ),
+        ):
+            async with queue_db.session_maker() as db:
+                found = await scheduler._find_idle_printer_for_model(
+                    db, "X1C", set(), filament_overrides=overrides, unmatched_on=unmatched_on
+                )
+
+        assert found == (1, None)
+
+
+class TestQueuePass:
+    @pytest.mark.asyncio
+    async def test_goes_to_a_printer_that_can_map_it(self, queue_db):
+        item_id = await _add_item(queue_db)
+        launched = MagicMock()
+        scheduler = PrintScheduler()
+
+        with patch.object(scheduler, "_unmatched_on", _unmatched_on({1})):
+            await _run(queue_db, scheduler, short=set(), launched=launched)
+
+        launched.assert_called_once()
+        item = await _get_item(queue_db, item_id)
+        assert item.printer_id == 2
+        assert item.manual_start is False
+
+    @pytest.mark.asyncio
+    async def test_waits_unassigned_while_no_printer_can_map_it(self, queue_db):
+        item_id = await _add_item(queue_db)
+        launched = MagicMock()
+        scheduler = PrintScheduler()
+
+        with patch.object(scheduler, "_unmatched_on", _unmatched_on({1, 2, 3})):
+            await _run(queue_db, scheduler, short=set(), launched=launched)
+
+        launched.assert_not_called()
+        item = await _get_item(queue_db, item_id)
+        assert item.printer_id is None
+        assert item.manual_start is False
+        assert "PLA #FFFFFF (left nozzle)" in item.waiting_reason
+
+    @pytest.mark.asyncio
+    async def test_a_job_held_for_low_filament_is_not_moved_to_a_printer_that_cannot_map_it(self, queue_db):
+        item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
+        launched = MagicMock()
+        scheduler = PrintScheduler()
+
+        with patch.object(scheduler, "_unmatched_on", _unmatched_on({2})):
+            await _run(queue_db, scheduler, short={1}, launched=launched)
+
+        assert (await _get_item(queue_db, item_id)).printer_id == 3
+
+
+class TestUnmatchedOn:
+    """The check itself answers what the unmatched-filament hold would."""
+
+    REQ = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 1}]
+
+    @staticmethod
+    def _item(**fields):
+        values = {
+            "id": 7,
+            "skip_filament_check": False,
+            "filament_overrides": None,
+            "archive_id": None,
+            "library_file_id": 3,
+            "plate_id": 1,
+        }
+        values.update(fields)
+        return SimpleNamespace(**values)
+
+    def _scheduler(self, mapping, requirements=REQ, loaded=True):
+        scheduler = PrintScheduler()
+        if isinstance(mapping, Exception):
+            scheduler._compute_ams_mapping_for_printer = AsyncMock(side_effect=mapping)
+        else:
+            scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=mapping)
+        scheduler._get_filament_requirements = AsyncMock(
+            return_value=[dict(r) for r in requirements] if requirements else None
+        )
+        scheduler._build_loaded_filaments = MagicMock(
+            return_value=[{"global_tray_id": 0, "type": "PLA", "color": "#FFFFFF"}] if loaded else []
+        )
+        return scheduler
+
+    @pytest.mark.asyncio
+    async def test_an_unmatched_slot_is_named(self):
+        scheduler = self._scheduler([-1])
+        with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
+            assert await scheduler._unmatched_on(AsyncMock(), self._item(), 4) == NEEDS
+
+    @pytest.mark.asyncio
+    async def test_a_mapped_slot_is_fine(self):
+        scheduler = self._scheduler([0])
+        with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
+            assert await scheduler._unmatched_on(AsyncMock(), self._item(), 4) == []
+
+    @pytest.mark.asyncio
+    async def test_print_anyway_is_never_unmatched(self):
+        scheduler = self._scheduler([-1])
+        assert await scheduler._unmatched_on(AsyncMock(), self._item(skip_filament_check=True), 4) == []
+
+    @pytest.mark.asyncio
+    async def test_no_mapping_counts_only_when_the_printer_reported_trays(self):
+        """The hold's own rule (#2589): an empty tray list is not knowing yet."""
+        with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
+            assert await self._scheduler(None)._unmatched_on(AsyncMock(), self._item(), 4) == NEEDS
+            assert await self._scheduler(None, loaded=False)._unmatched_on(AsyncMock(), self._item(), 4) == []
+
+    @pytest.mark.asyncio
+    async def test_a_failing_match_does_not_pass_the_printer_over(self):
+        scheduler = self._scheduler(RuntimeError("boom"))
+        with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
+            assert await scheduler._unmatched_on(AsyncMock(), self._item(), 4) == []
+
+    @pytest.mark.asyncio
+    async def test_an_unreadable_file_is_judged_on_its_forced_colours(self):
+        forced = '[{"slot_id": 1, "type": "PLA", "color": "#A4AAAC", "force_color_match": true}]'
+        scheduler = self._scheduler([-1], requirements=None)
+        with patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock()):
+            assert await scheduler._unmatched_on(AsyncMock(), self._item(filament_overrides=forced), 4) == [
+                "PLA #A4AAAC"
+            ]
+
+
+class TestDualNozzleEndToEnd:
+    """The reported shape with the real matcher: white PLA on the right nozzle,
+    a slot that prints on the left. Type checks pass; the matcher refuses."""
+
+    REQ = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 1, "tray_info_idx": ""}]
+
+    @staticmethod
+    def _trays(extruder_id):
+        return [
+            {"global_tray_id": 0, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "", "extruder_id": extruder_id},
+        ]
+
+    def _scheduler(self, extruder_id):
+        scheduler = PrintScheduler()
+        scheduler._build_loaded_filaments = MagicMock(return_value=self._trays(extruder_id))
+        scheduler._get_filament_requirements = AsyncMock(side_effect=lambda db, item: [dict(r) for r in self.REQ])
+        scheduler._get_bool_setting = AsyncMock(return_value=False)
+        scheduler._get_job_name = AsyncMock(return_value="bracket")
+        scheduler._get_printer = AsyncMock(return_value=MagicMock(model="H2D"))
+        return scheduler
+
+    @staticmethod
+    def _item():
+        item = MagicMock()
+        item.id = 9
+        item.printer_id = 4
+        item.archive_id = None
+        item.library_file_id = 3
+        item.plate_id = 1
+        item.ams_mapping = None
+        item.skip_filament_check = False
+        item.filament_overrides = None
+        item.manual_start = False
+        item.waiting_reason = None
+        return item
+
+    @pytest.mark.parametrize(("extruder_id", "held"), [(0, True), (1, False)])
+    @pytest.mark.asyncio
+    async def test_the_choice_and_the_hold_agree(self, extruder_id, held):
+        scheduler = self._scheduler(extruder_id)
+        item = self._item()
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock(fila_switch=None, ams_filament_backup=None)
+            unmatched = await scheduler._unmatched_on(db, item, 4)
+            await scheduler._ensure_ams_mapping(db, 4, item)
+            blocked = await scheduler._block_on_unmatched_filament(db, item)
+
+        assert bool(unmatched) is held
+        assert blocked is held
+        if held:
+            assert unmatched == ["PLA #FFFFFF (left nozzle)"]
+
+    @pytest.mark.asyncio
+    async def test_asked_once_per_pass_for_the_same_job_and_printer(self):
+        scheduler = self._scheduler(0)
+        item = self._item()
+        scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[-1])
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock(fila_switch=None, ams_filament_backup=None)
+            first = await scheduler._unmatched_on(AsyncMock(), item, 4)
+            second = await scheduler._unmatched_on(AsyncMock(), item, 4)
+            scheduler._unmatched_memo.clear()
+            await scheduler._unmatched_on(AsyncMock(), item, 4)
+
+        assert first == second == ["PLA #FFFFFF (left nozzle)"]
+        assert scheduler._compute_ams_mapping_for_printer.await_count == 2
+
+
+class TestTheMemoLastsOnePass:
+    @pytest.mark.asyncio
+    async def test_a_new_pass_asks_again(self, queue_db):
+        """Spools change between passes; an answer from the last one is stale."""
+        scheduler = PrintScheduler()
+        scheduler._unmatched_memo[("stale",)] = list(NEEDS)
+
+        await _run(queue_db, scheduler, short=set(), launched=MagicMock())
+
+        assert ("stale",) not in scheduler._unmatched_memo

+ 40 - 0
backend/tests/unit/test_scheduler_cross_model_variants.py

@@ -497,3 +497,43 @@ async def test_failed_candidate_steps_aside_for_the_alternative(queue_db):
     item = await _get_item(queue_db, item_id)
     item = await _get_item(queue_db, item_id)
     assert item.printer_id == 2
     assert item.printer_id == 2
     assert item.target_model == "H2C"
     assert item.target_model == "H2C"
+
+
+@pytest.mark.asyncio
+async def test_printer_checks_read_the_candidates_own_file(queue_db):
+    """The low-filament (#3137) and matcher (#3280) checks a printer must pass
+    read the file off the row, so each must see the variant being tried."""
+    await _add_variant_item(queue_db, [{"model": "H2S"}, {"model": "H2C"}])
+    scheduler = PrintScheduler()
+    seen: dict[str, list] = {"short": [], "unmatched": []}
+
+    async def short(db, item, pid, **kwargs):
+        seen["short"].append(item.library_file_id)
+        return False
+
+    async def unmatched(db, item, pid):
+        seen["unmatched"].append(item.library_file_id)
+        return []
+
+    async def finder(db, model, exclude_ids, *args, is_short=None, unmatched_on=None, **kwargs):
+        # One check per variant, so neither can lean on the other having put
+        # the variant's file on the row already.
+        if model == "H2S":
+            await unmatched_on(1)
+            return None, "busy"
+        await is_short(2)
+        return 2, None
+
+    with (
+        patch.object(scheduler, "_filament_short_on", AsyncMock(side_effect=short)),
+        patch.object(scheduler, "_unmatched_on", AsyncMock(side_effect=unmatched)),
+    ):
+        await _run_check_queue(queue_db, scheduler, AsyncMock(side_effect=finder))
+
+    async with queue_db.session_maker() as db:
+        files = [
+            v.library_file_id
+            for v in (await db.execute(select(PrintQueueVariant).order_by(PrintQueueVariant.position))).scalars()
+        ]
+    assert seen["unmatched"] == files[:1]
+    assert seen["short"] == files[1:]