瀏覽代碼

fix(queue): tell a pinned queue item why it is waiting (issue #3074)

A job queued as "Any X1C" explains itself when it cannot start: the
model-based branch builds a reason for every candidate printer and puts it
on the row, so the queue shows "Busy: X1C-01" or "Waiting for filament:
X1C-02 (needs PETG)". The same job pinned to one printer showed nothing.
It sat at Pending with waiting_reason NULL for as long as that printer was
busy, which from the outside is indistinguishable from a queue that has
stopped working -- the reporter watched fourteen minutes of it while his
X1C ran a print he had started from its own screen.

The fixed-printer branch had six ways out and none of them wrote the field.
The sensor interlock (#1148) was its only writer, and it cleared the field
up front on every pass where no sensor was holding the printer, so NULL was
not an oversight on those paths but a guarantee.

Every exit now writes, through one helper. The reasons reuse the
model-based branch's vocabulary so _is_busy_only() keeps deciding what is
worth a notification: a printer that is printing, drying, or working
through the item ahead of this one reads as "Busy: <printer>" and stays
silent, because it resolves itself. A printer that is off with no Auto On
plug, and one whose plug could not switch it on, are worth saying.

A finished plate nobody has acknowledged is split out from plain busy and
named as itself. _is_printer_idle() returns the same plain False for that
and for a running print, but they are not the same thing to the person
looking at the queue: one clears itself and the other needs somebody to
walk over to the printer.

That notification fires on the transition into asking, where a busy-only
reason counts as not asking. Testing whether the item was waiting at all --
which is what the model-based branch does -- would never fire it here:
nobody's queue goes straight from idle to an unconfirmed plate, it waits
behind the print first. The cost is that a printer dropping offline,
returning busy and dropping again asks twice rather than once.

The interlock stays silent. It has never sent this notification, and a
change about what the queue displays is not the place to start.

Clearing the field up front is gone with it. It existed so a shut door
could not leave "Waiting on Enclosure Door" standing while the printer
stayed busy with something else, and the new rule carries that guarantee
instead -- whichever exit runs next overwrites it, and the dispatch path
clears it.

Two paths clear it that the report did not mention. A staged item and a
future-scheduled one skip before this branch and never reach it again, so
anything written on an earlier pass would outlive its condition for the
life of the row. That includes the filament-deficit check, which stages the
item itself.

The notification is wrapped: a queue that cannot say why it is waiting is
the bug being fixed, and a queue that stops dispatching because a provider
timed out would be a worse one.

On the frontend, the queue timeline drops any pending item carrying a
reason, on the grounds that such an item will not auto-dispatch. That held
while only the model-based branch wrote the field; "Busy: <printer>" is
now the commonest reason there is, and it describes the very chain the
timeline forecasts, so the rule would have emptied the view for anyone
whose queue is pinned. It now asks whether the reason needs the user, via
a small shared reader of the same shape the scheduler encodes.

Which job goes out, and when, is unchanged: running the previous scheduler
and this one over the same 768 states dispatches the same items in the same
order with the same statuses, across 1452 rows that now carry a reason.
maziggy 1 天之前
父節點
當前提交
48244bcfcf

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 ### Fixed
+- **A queue item pinned to one printer never said why it was waiting (#3074, reported by @Sawtaytoes)** — the reporter watched a job pinned to a specific X1C sit at Pending for fourteen minutes, with nothing in the UI or the API to say why, while that printer ran a print he had started from its own screen. The same job queued as "Any X1C" explains itself, because the model-based half of the scheduler builds a sentence for every way a job could not start and puts it on the row. The pinned half never wrote that field at all: the Home Assistant sensor interlock was its only writer, and it actively cleared the field whenever no sensor was holding the printer. Pinned items now say what they are waiting on in the same words — **Busy: X1C-01** while it prints, while it dries, or while another job on that printer goes first; **Offline, no Auto On smart plug: X1C-01** when nothing can switch it back on — and the row shows the purple **Waiting** badge instead of a silent Pending. "Finished, waiting for you to confirm the plate is clear" is split out from plain busy and named as itself, because it is the one case on that list that does not resolve on its own. A printer that is simply printing still sends no notification, exactly as before, and neither does a sensor interlock; only the cases that need you to go and do something do. On the queue timeline, a job that is merely next in line still draws its forecast bar as before, while one that is waiting for you now drops off it rather than promising a start time nobody can keep. Nothing about which job goes out, or when, has changed.
 - **A plate printed entirely from the external spool stalled at preheat and failed (#3087, reported by @Notaseraf)** — the reporter's P1S heated up, sat there for ten minutes and then paused with HMS 07FF_8012, "Failed to get AMS mapping table". Resuming just reheated it; prints that used the AMS were fine. The plate was a single filament out of a seven-filament MakerWorld project, mapped by hand to the external spool. A project's filaments are numbered across the whole project, so a plate that prints only the seventh one carries six placeholder entries in front of it — the same shape BambuStudio sends. Bambuddy read that as "nothing here is on the spool holder" and told the printer to use the AMS anyway, with a mapping that pointed at no tray at all, which is precisely the mapping table the firmware then could not find. A plate whose every printed filament sits on the external spool now dispatches with the AMS switched off, so it prints. The decision is made against the plate's own filament list rather than guessed from the mapping alone, because a placeholder entry and a filament that could not be matched to any tray look identical in the mapping, and sending the second one to the spool holder would silently print it in the wrong material (#2589). A plate that mixes the spool holder with an AMS tray, or that has a filament which matched nothing, is untouched and still behaves exactly as before, as are dual-nozzle printers — on those the same switch selects which nozzle to feed rather than whether to use the AMS.
 - **A printer that had been offline for hours could stop the whole server (#3068, reported by @bazza2000)** — the reporter's A1 had been unreachable for 38 hours but still answered on its MQTT port, which is exactly the case the connection watchdog exists for: rebuild the session with a fresh client so nothing left over from the dead one can replay onto the new print. The rebuild ended in a call that waits for the old connection's network thread to finish, and that thread was stuck part-way through a TLS handshake the printer never completed, where nothing can interrupt it. The wait had no limit and it ran on the thread that serves every request, so Bambuddy stopped answering — the web UI, the API and the health check alike — while the process itself stayed up, which is why Docker's restart policy never kicked in. Retiring an old connection no longer waits for it: the replacement is built immediately and the old one is shut down in the background, cut off from the new session first so a printer that eventually answers cannot report itself connected again after being replaced. The same wait sat on three other printer paths — the queue's dispatch recovery, the staleness check behind an ordinary status poll, and editing, deleting or disconnecting a printer — and on the shutdown of the MQTT relay and smart-plug connections, where a wedged broker kept the process from exiting at all. All of them are fixed together. A connection that takes more than a few seconds to shut down now says so in the log instead of silently taking the server with it.
 - **Auto-drying skipped every composite spool (#3067, reported by @TheUltimateC0der)** — the reporter's AMS-HT would not dry PA6-CF, and drying the same spool by hand worked. The scheduler reduced a tray to a preset key by splitting on spaces only, so "PA6-CF" stayed "PA6-CF", matched none of the eight rows the preset table has, and the tray was read as holding nothing worth drying. It was never only nylon: of the 41 types a printer can report, 33 had no row under that rule, and 20 of those — every -CF, -GF and -AERO variant of PLA, PETG, ABS, ASA, PC and PA — have a base material sitting right there. The drying popover has resolved these correctly since 1.2.5, which is exactly why doing it by hand worked; the scheduler now shares that rule, including the alias map that reads PA6, PA11, PA12, PAHT, PPA and Nylon as PA. A row you have added for the exact type still wins over the base material's, and a material whose base is not in the table either is still left alone rather than dried at a temperature nobody chose. The same lookup decides per-filament humidity thresholds and the chamber preheat target, so a composite now gets the override and the preheat that were set for its material too.

+ 1 - 1
backend/app/schemas/print_queue.py

@@ -187,7 +187,7 @@ class PrintQueueItemResponse(BaseModel):
     target_location: str | None = None  # Target location filter for model-based assignment
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     filament_overrides: list[dict] | None = None  # Filament overrides for model-based assignment
-    waiting_reason: str | None = None  # Why a model-based job hasn't started yet
+    waiting_reason: str | None = None  # Why this job hasn't started yet (empty once it can)
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     library_file_id: int | None  # For queue items from library files
     cost_center_id: int | None = None

+ 189 - 17
backend/app/services/print_scheduler.py

@@ -1291,6 +1291,95 @@ class PrintScheduler:
                 claimed_printers.add(printer_id)
                 mark_busy(printer_id, "selected for dispatch in this pass")
 
+            # The user-facing half of `busy_reasons` (#3074). The same decisions
+            # worded for a different audience: the log wants "still inside its
+            # post-dispatch hold window", the queue row wants to know the printer
+            # is taken and nothing is broken. Only the cases that would read wrong
+            # as a plain "Busy" are recorded here; the rest fall back to it.
+            item_hold_reasons: dict[int, str] = {}
+
+            # Names and models for the printers this pass may have to write a
+            # waiting reason about, read once rather than per skip per tick. The
+            # model-based branch already has its names from `_printers_for_model`.
+            pinned_printer_ids = {i.printer_id for i in items if i.printer_id}
+            pinned_printers: dict[int, tuple[str, str]] = {}
+            if pinned_printer_ids:
+                pinned_rows = await db.execute(
+                    select(Printer.id, Printer.name, Printer.model).where(Printer.id.in_(pinned_printer_ids))
+                )
+                pinned_printers = {pid: (name or "", model or "") for pid, name, model in pinned_rows.all()}
+
+            def printer_label(printer_id: int) -> str:
+                """What to call this printer in a queue row."""
+                entry = pinned_printers.get(printer_id)
+                return (entry[0] if entry else "") or f"printer {printer_id}"
+
+            async def hold_item(item: PrintQueueItem, reason: str | None, *, notify: bool = True) -> None:
+                """Record why *item* is not going out, in the words the queue row shows.
+
+                The fixed-printer branch's single writer for ``waiting_reason``
+                (#3074). Before this, the sensor interlock was the only thing that
+                wrote the field there, so an item pinned to a printer that was
+                merely printing sat at `pending` with nothing to show for it —
+                indistinguishable from a queue that had stopped working — while
+                the same job queued as "Any <model>" explained itself.
+
+                Every exit from that branch now calls this, which is also what
+                replaced the interlock's old habit of clearing the field up front:
+                a lifted hold cannot leave "Waiting on Enclosure Door" standing,
+                because whichever exit runs next overwrites it and the dispatch
+                path clears it.
+
+                A notification goes out when this item starts asking for
+                something, and only then: the new reason needs the user, and what
+                it replaced did not. "What it replaced did not" has to include a
+                busy-only reason, not just an empty one. The sequence this branch
+                actually produces is a print running (``Busy: X1C-01``) and then
+                the plate it left behind (``Waiting for plate confirmation``), and
+                testing "was the field empty" would call that no transition at all
+                and stay quiet through the one case worth saying out loud.
+
+                The cost is that a printer dropping offline, coming back busy and
+                dropping again asks twice rather than once. That is the honest
+                reading — it went wrong twice — and the alternative was a rule
+                that never fired for the case this was built for.
+
+                *notify* is how a caller opts out. The sensor interlock does: it
+                has never sent this notification, and a change about what the
+                queue *displays* is not the place to start (#1148).
+                """
+                if item.waiting_reason == reason:
+                    return
+                # Busy-only and empty are the same thing here: neither is the
+                # queue asking the user for anything.
+                was_asking = bool(item.waiting_reason) and not self._is_busy_only(item.waiting_reason)
+                item.waiting_reason = reason
+                await db.commit()
+                if not notify or not reason or was_asking or self._is_busy_only(reason):
+                    return
+                try:
+                    job_name = await self._get_job_name(db, item)
+                    entry = pinned_printers.get(item.printer_id) if item.printer_id else None
+                    await notification_service.on_queue_job_waiting(
+                        job_name=job_name,
+                        target_model=(entry[1] if entry else "") or "",
+                        waiting_reason=reason,
+                        db=db,
+                    )
+                except Exception as e:
+                    # A queue that cannot say why it is waiting is the bug being
+                    # fixed here; a queue that stops dispatching because a
+                    # notification provider is down would be a worse one.
+                    logger.debug("Waiting notification failed for item %s: %s", item.id, e)
+
+            async def hold_for_printer(
+                item: PrintQueueItem, printer_id: int, log_reason: str, item_reason: str
+            ) -> None:
+                """Take *printer_id* out of this pass and tell the item's owner why."""
+                mark_busy(printer_id, log_reason)
+                item_hold_reasons.setdefault(printer_id, item_reason)
+                await hold_item(item, item_reason)
+
             # Defense-in-depth (#1157): augment busy_printers with any printer
             # still in its post-dispatch hold window. Empirically, the DB seed
             # above can miss in-flight items in a multi-plate batch — same-file
@@ -1415,11 +1504,18 @@ class PrintScheduler:
                     if sched.tzinfo is None:
                         sched = sched.replace(tzinfo=timezone.utc)
                     if sched > datetime.now(timezone.utc):
+                        # Waiting on the clock, not on a printer.
+                        await hold_item(item, None)
                         skip_reasons["scheduled_future"] = skip_reasons.get("scheduled_future", 0) + 1
                         continue
 
                 # Skip items that require manual start
                 if item.manual_start:
+                    # Waiting on the user, not on a printer. Cleared here because
+                    # this is the last pass that will look at the row: a staged
+                    # item never reaches the branches below again, so a reason
+                    # left from before it was staged would stand forever (#3074).
+                    await hold_item(item, None)
                     skip_reasons["manual_start"] = skip_reasons.get("manual_start", 0) + 1
                     continue
 
@@ -1430,24 +1526,35 @@ class PrintScheduler:
                     # to shut the enclosure" need to read differently, and only
                     # one of them is something the user can fix.
                     #
-                    # The interlock is the only thing that writes a
-                    # waiting_reason on this branch — the model-based branch
-                    # nulls it at the moment it assigns a printer — so any
-                    # reason still standing once the hold lifts is stale and is
-                    # cleared here. Doing it at dispatch instead would leave a
-                    # shut door reading "Waiting on Enclosure Door" for as long
-                    # as the printer stayed busy with something else.
+                    # It used to be the only thing that wrote a waiting_reason on
+                    # this branch, and it cleared the field up front so a lifted
+                    # hold could not leave a shut door reading "Waiting on
+                    # Enclosure Door". `hold_item` carries that guarantee now —
+                    # every exit below writes — so the clear is gone and the
+                    # interlock is an ordinary hold like the rest (#3074).
                     interlock_reason = interlocked.get(item.printer_id)
-                    reason = f"Waiting on {interlock_reason}" if interlock_reason else None
-                    if item.waiting_reason != reason:
-                        item.waiting_reason = reason
-                        await db.commit()
                     if interlock_reason:
+                        # Silent, exactly as it has always been. #1148 built this
+                        # as a hold that shows on the row, never as an alert, and
+                        # routing it through the shared writer must not quietly
+                        # turn every open door into a notification.
+                        await hold_item(item, f"Waiting on {interlock_reason}", notify=False)
                         skip_reasons["sensor_interlock"] = skip_reasons.get("sensor_interlock", 0) + 1
                         continue
 
                     # Specific printer assignment (existing behavior)
                     if item.printer_id in busy_printers:
+                        # Whatever took the printer out of this pass — a print
+                        # already running on it, a post-dispatch hold, an upload
+                        # still in flight, an item ahead of this one in the same
+                        # pass — reads the same way from the queue: the printer is
+                        # taken and this item is in line for it. The exceptions
+                        # that do not (an offline printer, say) recorded their own
+                        # wording in `item_hold_reasons` when they held it.
+                        await hold_item(
+                            item,
+                            item_hold_reasons.get(item.printer_id) or f"Busy: {printer_label(item.printer_id)}",
+                        )
                         continue
 
                     # Check if printer is idle
@@ -1479,16 +1586,36 @@ class PrintScheduler:
                                 printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
                             else:
                                 logger.warning("Could not power on printer %s via smart plug", item.printer_id)
-                                mark_busy(item.printer_id, "smart-plug power-on failed")
+                                await hold_for_printer(
+                                    item,
+                                    item.printer_id,
+                                    "smart-plug power-on failed",
+                                    f"Offline: {printer_label(item.printer_id)} — the smart plug could not power it on",
+                                )
                                 continue
                         else:
-                            # No plug or auto_on disabled
-                            mark_busy(item.printer_id, "offline, with no smart plug to power it on")
+                            # No plug or auto_on disabled. Worded exactly as the
+                            # model-based branch words it (#2786): this is the one
+                            # entry on that list the user has to act on, because
+                            # Bambuddy will never switch this printer on itself.
+                            await hold_for_printer(
+                                item,
+                                item.printer_id,
+                                "offline, with no smart plug to power it on",
+                                f"Offline, no Auto On smart plug: {printer_label(item.printer_id)}",
+                            )
                             continue
 
                     # Check if printer is idle (busy with another print)
                     if not printer_idle:
-                        mark_busy(item.printer_id, "not idle")
+                        await hold_for_printer(
+                            item,
+                            item.printer_id,
+                            "not idle",
+                            self._pinned_hold_reason(
+                                item.printer_id, printer_label(item.printer_id), require_plate_clear
+                            ),
+                        )
                         continue
 
                     # Drying blocks the queue, if the user asked it to. A hold
@@ -1497,7 +1624,14 @@ class PrintScheduler:
                     if self._drying_in_progress.get(item.printer_id) and await self._get_bool_setting(
                         db, "queue_drying_block"
                     ):
-                        mark_busy(item.printer_id, "drying, and drying is set to block the queue")
+                        # Busy-shaped on purpose: the cycle ends on its own and
+                        # the job goes out, so there is nothing to alert about.
+                        await hold_for_printer(
+                            item,
+                            item.printer_id,
+                            "drying, and drying is set to block the queue",
+                            f"Busy: {printer_label(item.printer_id)} (drying)",
+                        )
                         continue
 
                     # Check condition (previous print success)
@@ -1506,6 +1640,8 @@ class PrintScheduler:
                             item.status = "skipped"
                             item.error_message = "Previous print failed or was aborted"
                             item.completed_at = datetime.now(timezone.utc)
+                            # Not pending any more, so not waiting for anything.
+                            item.waiting_reason = None
                             await db.commit()
                             logger.info("Skipped queue item %s - previous print failed", item.id)
 
@@ -1535,6 +1671,10 @@ class PrintScheduler:
                     # promote the item to manual_start so the user must
                     # acknowledge via the ▶ button (which re-checks live).
                     if await self._block_on_filament_deficit(db, item):
+                        # Now staged for the user to start by hand, and the row
+                        # shows the filament-short badge instead. Cleared because
+                        # a staged item never reaches this branch again.
+                        await hold_item(item, None)
                         continue
 
                     # Hold this item back for the next pass rather than racing
@@ -1543,7 +1683,12 @@ class PrintScheduler:
                     # its place in this printer's queue.
                     if _library_row_conflict(item):
                         skip_reasons["library_row_in_use"] = skip_reasons.get("library_row_in_use", 0) + 1
-                        mark_busy(item.printer_id, "holding its place while another item releases a library row")
+                        await hold_for_printer(
+                            item,
+                            item.printer_id,
+                            "holding its place while another item releases a library row",
+                            f"Busy: {printer_label(item.printer_id)}",
+                        )
                         continue
 
                     # Print takes priority: stop a cycle Bambuddy armed, now
@@ -1571,6 +1716,11 @@ class PrintScheduler:
                     # Queue the dispatch instead of running it here — see
                     # _dispatch_selected(). busy_printers still gets the printer
                     # immediately, so nothing else in this pass can target it.
+                    #
+                    # The reason goes first: this item is not waiting for anything
+                    # any more, and the model-based branch clears its own at the
+                    # equivalent moment (#3074).
+                    await hold_item(item, None)
                     _claim_library_row(item)
                     dispatch_ids.append(item.id)
                     claim_printer(item.printer_id)
@@ -3781,6 +3931,28 @@ class PrintScheduler:
             logger.debug("Printer %d: not idle — state=%s", printer_id, state.state)
         return idle
 
+    @staticmethod
+    def _pinned_hold_reason(printer_id: int, printer_name: str, require_plate_clear: bool) -> str:
+        """Why a connected, non-idle printer cannot take this job, for the queue row (#3074).
+
+        Only ever asked about a printer :meth:`_is_printer_idle` has just refused
+        and that the fixed-printer branch has already found connected, so the two
+        offline cases answer at their own exits and never arrive here.
+
+        The default is the model-based branch's ``Busy:`` wording, which
+        :meth:`_is_busy_only` reads as "resolves itself, stay quiet". That is also
+        the right answer for the connected-but-no-telemetry second or two after a
+        reconnect: the model-based branch has always reported it that way, and it
+        is not something to wake anybody up for.
+
+        A plate nobody has confirmed is the one case here that does not resolve
+        itself — somebody has to walk over to the printer — so it is worded as
+        itself and allowed to notify.
+        """
+        if require_plate_clear and printer_manager.is_awaiting_plate_clear(printer_id):
+            return f"Waiting for plate confirmation: {printer_name}"
+        return f"Busy: {printer_name}"
+
     async def _get_setting(self, db: AsyncSession, key: str) -> str | None:
         """Read a setting value from the database."""
         result = await db.execute(select(Settings).where(Settings.key == key))

+ 11 - 4
backend/tests/unit/test_scheduler_ha_interlock_1148.py

@@ -179,9 +179,14 @@ class TestFixedPrinter:
     async def test_the_reason_clears_even_when_the_printer_is_still_busy(self, queue_db):
         """You shut the door, but the printer is midway through something else.
 
-        The hold has lifted and the queue must say so. Clearing the reason only
-        at dispatch would leave a shut door reading "Waiting on Enclosure Door"
-        for the rest of a ten-hour print.
+        The hold has lifted and the queue must say so. Leaving the old reason
+        standing would have a shut door reading "Waiting on Enclosure Door" for
+        the rest of a ten-hour print.
+
+        The reason no longer goes to None here: since #3074 the branch always
+        says what it is waiting on, and what it is waiting on now is the print.
+        The invariant under test is the same one — the lifted hold does not
+        survive the pass that lifted it.
         """
         item_id = await _add_item(queue_db, printer_id=1)
         scheduler = PrintScheduler()
@@ -191,7 +196,9 @@ class TestFixedPrinter:
         await _run(queue_db, scheduler, {}, launched, idle=False)
 
         launched.assert_not_called()
-        assert (await _get_item(queue_db, item_id)).waiting_reason is None
+        reason = (await _get_item(queue_db, item_id)).waiting_reason
+        assert "Enclosure Door" not in (reason or "")
+        assert reason == "Busy: X1C-1"
 
     @pytest.mark.asyncio
     async def test_another_printers_sensor_does_not_hold_this_one(self, queue_db):

+ 507 - 0
backend/tests/unit/test_scheduler_pinned_waiting_reason_3074.py

@@ -0,0 +1,507 @@
+"""A queue item pinned to one printer explains itself (#3074).
+
+Bambuddy has two ways to queue a job. "Any X1C" goes through the model-based
+branch, which builds a sentence for every way the job could not start and puts
+it on the row: `Busy: X1C-01`, `Waiting for filament: X1C-02 (needs PETG)`. The
+same job pinned to a specific printer went through a branch that wrote nothing.
+
+The reporter watched a pinned item sit at `pending` with `waiting_reason: null`
+for fourteen minutes while its printer ran a print started from the printer's
+own screen. Nothing in the UI or the API said why, and a queue that will not say
+why it is waiting is indistinguishable from a queue that has stopped working.
+
+Two rules hold everything here together:
+
+- **Every exit writes.** Not one path out of the fixed-printer branch may leave
+  the field as it found it, or a reason from an earlier pass outlives the
+  condition that produced it.
+- **Only what the user must act on makes a noise.** A printer that is merely
+  printing resolves itself; the wording stays inside what `_is_busy_only` reads
+  as silent. A plate nobody has confirmed does not resolve itself, so it is
+  worded as itself and allowed through.
+"""
+
+from contextlib import ExitStack
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+async def ctx():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    async with session_maker() as db:
+        db.add(
+            Printer(
+                id=1,
+                name="X1C-01",
+                serial_number="X1C0001",
+                ip_address="10.0.0.1",
+                access_code="x",
+                model="X1C",
+                is_active=True,
+            )
+        )
+        await db.commit()
+
+    try:
+        yield SimpleNamespace(session_maker=session_maker)
+    finally:
+        await engine.dispose()
+
+
+async def _add_item(ctx, *, printer_id=1, target_model=None, position=1, manual_start=False):
+    async with ctx.session_maker() as db:
+        lib = LibraryFile(
+            filename="job.gcode.3mf",
+            file_path="/library/job.gcode.3mf",
+            file_size=10,
+            file_type="gcode.3mf",
+            file_metadata={"sliced_for_model": "X1C"},
+        )
+        db.add(lib)
+        await db.flush()
+        item = PrintQueueItem(
+            status="pending",
+            position=position,
+            printer_id=printer_id,
+            target_model=target_model,
+            library_file_id=lib.id,
+            manual_start=manual_start,
+        )
+        db.add(item)
+        await db.commit()
+        return item.id
+
+
+async def _set(ctx, key, value):
+    async with ctx.session_maker() as db:
+        db.add(Settings(key=key, value=value))
+        await db.commit()
+
+
+async def _item(ctx, item_id):
+    async with ctx.session_maker() as db:
+        return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+
+
+async def _run(
+    ctx,
+    scheduler,
+    *,
+    idle=True,
+    connected=True,
+    awaiting_plate_clear=False,
+    blocked=None,
+    plugs=None,
+    launched=None,
+    waiting=None,
+):
+    """One check_queue pass with the printer in the state the test cares about."""
+    launched = launched or MagicMock()
+    patches = [
+        patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
+        patch("backend.app.core.database.async_session", ctx.session_maker),
+        patch(
+            "backend.app.services.print_scheduler.printer_manager.is_connected",
+            MagicMock(return_value=connected),
+        ),
+        patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+        patch(
+            "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
+            MagicMock(return_value=awaiting_plate_clear),
+        ),
+        patch(
+            "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
+            AsyncMock(return_value=blocked or {}),
+        ),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
+            waiting or AsyncMock(),
+        ),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
+            AsyncMock(),
+        ),
+        patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=idle)),
+        patch.object(scheduler, "_check_auto_drying", AsyncMock()),
+        patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
+        patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
+        patch.object(scheduler, "_get_smart_plugs", AsyncMock(return_value=plugs or [])),
+        patch.object(scheduler, "_launch_uploads", launched),
+    ]
+    with ExitStack() as stack:
+        for p in patches:
+            stack.enter_context(p)
+        await scheduler.check_queue()
+    return launched
+
+
+class TestThePinnedItemSaysWhyItIsWaiting:
+    """The reporter's four cases, each of which used to produce ``None``."""
+
+    @pytest.mark.asyncio
+    async def test_a_printer_midway_through_a_print(self, ctx):
+        """The exact fourteen minutes from the report: a print started at the
+        printer's own screen, and a pinned item with nothing to show for it."""
+        item_id = await _add_item(ctx)
+
+        launched = await _run(ctx, PrintScheduler(), idle=False)
+
+        launched.assert_not_called()
+        assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01"
+
+    @pytest.mark.asyncio
+    async def test_a_plate_nobody_has_confirmed(self, ctx):
+        """The other half of the report. `_is_printer_idle` returns a plain
+        False for both this and a running print, but they are not the same
+        thing to the person looking at the queue: this one waits on them."""
+        await _set(ctx, "require_plate_clear", "true")
+        item_id = await _add_item(ctx)
+
+        await _run(ctx, PrintScheduler(), idle=False, awaiting_plate_clear=True)
+
+        assert (await _item(ctx, item_id)).waiting_reason == "Waiting for plate confirmation: X1C-01"
+
+    @pytest.mark.asyncio
+    async def test_an_item_queued_behind_another_on_the_same_printer(self, ctx):
+        """The commonest case of all, and one the reporter never even reached:
+        two items pinned to one printer. The second leaves the pass at the
+        `busy_printers` test, several checks before anything that could have
+        described the printer."""
+        first = await _add_item(ctx, position=1)
+        second = await _add_item(ctx, position=2)
+
+        launched = await _run(ctx, PrintScheduler(), idle=True)
+
+        assert launched.call_args[0][0] == [first]
+        assert (await _item(ctx, second)).waiting_reason == "Busy: X1C-01"
+
+    @pytest.mark.asyncio
+    async def test_a_printer_that_is_off_with_nothing_to_switch_it_on(self, ctx):
+        """Worded exactly as the model-based branch words it (#2786). This is
+        the one entry here the user has to act on themselves: with no enabled
+        Auto On plug, Bambuddy will never power this printer up for the queue."""
+        item_id = await _add_item(ctx)
+
+        await _run(ctx, PrintScheduler(), connected=False)
+
+        assert (await _item(ctx, item_id)).waiting_reason == "Offline, no Auto On smart plug: X1C-01"
+
+    @pytest.mark.asyncio
+    async def test_a_drying_cycle_the_user_asked_to_block_the_queue(self, ctx):
+        await _set(ctx, "queue_drying_block", "true")
+        item_id = await _add_item(ctx)
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = True
+
+        launched = await _run(ctx, scheduler, idle=True)
+
+        launched.assert_not_called()
+        assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01 (drying)"
+
+
+class TestTheReasonNeverOutlivesTheThingItDescribes:
+    """Every exit from the branch writes, so no pass can leave a stale reason."""
+
+    @pytest.mark.asyncio
+    async def test_it_clears_the_moment_the_item_goes_out(self, ctx):
+        item_id = await _add_item(ctx)
+        scheduler = PrintScheduler()
+        await _run(ctx, scheduler, idle=False)
+        assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01"
+
+        launched = await _run(ctx, scheduler, idle=True)
+
+        assert launched.call_args[0][0] == [item_id]
+        assert (await _item(ctx, item_id)).waiting_reason is None
+
+    @pytest.mark.asyncio
+    async def test_a_busy_printer_replaces_a_lifted_interlock(self, ctx):
+        """The guarantee the interlock used to buy by clearing the field up
+        front, now bought by the exits all writing instead."""
+        item_id = await _add_item(ctx)
+        scheduler = PrintScheduler()
+        await _run(ctx, scheduler, idle=False, blocked={1: "Enclosure Door"})
+        assert (await _item(ctx, item_id)).waiting_reason == "Waiting on Enclosure Door"
+
+        await _run(ctx, scheduler, idle=False)
+
+        assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01"
+
+    @pytest.mark.asyncio
+    async def test_staging_an_item_drops_the_reason_it_was_carrying(self, ctx):
+        """A staged item never reaches the fixed-printer branch again, so
+        whatever it was carrying when the user staged it would otherwise stand
+        for as long as the row lived."""
+        item_id = await _add_item(ctx)
+        scheduler = PrintScheduler()
+        await _run(ctx, scheduler, idle=False)
+        assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01"
+
+        async with ctx.session_maker() as db:
+            item = (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+            item.manual_start = True
+            await db.commit()
+
+        await _run(ctx, scheduler, idle=False)
+
+        assert (await _item(ctx, item_id)).waiting_reason is None
+
+    @pytest.mark.asyncio
+    async def test_the_reason_is_written_once_not_once_per_tick(self, ctx):
+        """Four passes over a printer that is printing throughout must not be
+        four writes — the scheduler runs on a timer and this row is read by the
+        UI on a poll."""
+        item_id = await _add_item(ctx)
+        scheduler = PrintScheduler()
+        for _ in range(4):
+            await _run(ctx, scheduler, idle=False)
+
+        item = await _item(ctx, item_id)
+        assert item.waiting_reason == "Busy: X1C-01"
+        assert item.status == "pending"
+
+
+class TestOnlyWhatTheUserMustActOnMakesANoise:
+    """`_is_busy_only` decided this for the model-based branch; the reporter
+    asked for the same restraint here, and the wording is what enforces it."""
+
+    @pytest.mark.asyncio
+    async def test_a_printer_that_is_simply_printing_stays_silent(self, ctx):
+        await _add_item(ctx)
+        waiting = AsyncMock()
+
+        await _run(ctx, PrintScheduler(), idle=False, waiting=waiting)
+
+        waiting.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_a_drying_cycle_stays_silent(self, ctx):
+        await _set(ctx, "queue_drying_block", "true")
+        await _add_item(ctx)
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = True
+        waiting = AsyncMock()
+
+        await _run(ctx, scheduler, idle=True, waiting=waiting)
+
+        waiting.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_an_item_waiting_its_turn_stays_silent(self, ctx):
+        await _add_item(ctx, position=1)
+        await _add_item(ctx, position=2)
+        waiting = AsyncMock()
+
+        await _run(ctx, PrintScheduler(), idle=True, waiting=waiting)
+
+        waiting.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_an_unconfirmed_plate_is_worth_saying_once(self, ctx):
+        await _set(ctx, "require_plate_clear", "true")
+        await _add_item(ctx)
+        waiting = AsyncMock()
+        scheduler = PrintScheduler()
+
+        for _ in range(3):
+            await _run(ctx, scheduler, idle=False, awaiting_plate_clear=True, waiting=waiting)
+
+        waiting.assert_called_once()
+        assert waiting.call_args.kwargs["waiting_reason"] == "Waiting for plate confirmation: X1C-01"
+        assert waiting.call_args.kwargs["target_model"] == "X1C"
+
+    @pytest.mark.asyncio
+    async def test_the_plate_notification_survives_the_print_that_came_before_it(self, ctx):
+        """The sequence this branch actually produces, and the one a naive
+        "was the field empty" transition test gets wrong.
+
+        Nobody's queue goes straight from idle to an unconfirmed plate. It waits
+        behind the print first, carrying "Busy: X1C-01" for however long that
+        takes, and only then does the plate appear. Asking whether the item was
+        waiting at all would call that no transition and stay silent through the
+        single case on this list that needs a human.
+        """
+        await _set(ctx, "require_plate_clear", "true")
+        item_id = await _add_item(ctx)
+        scheduler = PrintScheduler()
+        waiting = AsyncMock()
+
+        await _run(ctx, scheduler, idle=False, waiting=waiting)
+        assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01"
+        waiting.assert_not_called()
+
+        await _run(ctx, scheduler, idle=False, awaiting_plate_clear=True, waiting=waiting)
+
+        assert (await _item(ctx, item_id)).waiting_reason == "Waiting for plate confirmation: X1C-01"
+        waiting.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_it_does_not_ask_twice_for_the_same_thing(self, ctx):
+        """Busy, plate, plate, plate — one notification, not three."""
+        await _set(ctx, "require_plate_clear", "true")
+        await _add_item(ctx)
+        scheduler = PrintScheduler()
+        waiting = AsyncMock()
+
+        await _run(ctx, scheduler, idle=False, waiting=waiting)
+        for _ in range(3):
+            await _run(ctx, scheduler, idle=False, awaiting_plate_clear=True, waiting=waiting)
+
+        waiting.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_an_interlock_has_never_notified_and_still_does_not(self, ctx):
+        """#1148 built the sensor interlock as a hold that shows on the row, not
+        as an alert. Routing it through the shared writer must not quietly turn
+        every open enclosure door into a notification."""
+        item_id = await _add_item(ctx)
+        waiting = AsyncMock()
+
+        await _run(ctx, PrintScheduler(), blocked={1: "Enclosure Door"}, waiting=waiting)
+
+        assert (await _item(ctx, item_id)).waiting_reason == "Waiting on Enclosure Door"
+        waiting.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_a_printer_nobody_can_switch_on_is_worth_saying(self, ctx):
+        await _add_item(ctx)
+        waiting = AsyncMock()
+
+        await _run(ctx, PrintScheduler(), connected=False, waiting=waiting)
+
+        waiting.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_a_provider_that_is_down_does_not_stop_the_queue(self, ctx):
+        """The bug being fixed is a queue that cannot say why it is waiting. A
+        queue that stops dispatching because a webhook timed out would be the
+        worse one."""
+        await _set(ctx, "require_plate_clear", "true")
+        item_id = await _add_item(ctx)
+        scheduler = PrintScheduler()
+
+        await _run(
+            ctx,
+            scheduler,
+            idle=False,
+            awaiting_plate_clear=True,
+            waiting=AsyncMock(side_effect=RuntimeError("provider down")),
+        )
+        assert (await _item(ctx, item_id)).waiting_reason == "Waiting for plate confirmation: X1C-01"
+
+        launched = await _run(ctx, scheduler, idle=True)
+
+        assert launched.call_args[0][0] == [item_id]
+
+
+class TestWhatMustNotChange:
+    @pytest.mark.asyncio
+    async def test_an_interlock_still_reads_as_itself(self, ctx):
+        """#1148's wording is what the user acts on — it names the sensor."""
+        item_id = await _add_item(ctx)
+
+        launched = await _run(ctx, PrintScheduler(), blocked={1: "Enclosure Door"})
+
+        launched.assert_not_called()
+        assert (await _item(ctx, item_id)).waiting_reason == "Waiting on Enclosure Door"
+
+    @pytest.mark.asyncio
+    async def test_an_idle_printer_still_dispatches(self, ctx):
+        item_id = await _add_item(ctx)
+
+        launched = await _run(ctx, PrintScheduler(), idle=True)
+
+        assert launched.call_args[0][0] == [item_id]
+        assert (await _item(ctx, item_id)).waiting_reason is None
+
+    @pytest.mark.asyncio
+    async def test_the_plate_gate_is_not_consulted_when_it_is_switched_off(self, ctx):
+        """`require_plate_clear` defaults to off. With the gate off, an
+        unconfirmed plate is not what is holding the queue, and saying so would
+        send the user to a prompt that is not there."""
+        item_id = await _add_item(ctx)
+
+        await _run(ctx, PrintScheduler(), idle=False, awaiting_plate_clear=True)
+
+        assert (await _item(ctx, item_id)).waiting_reason == "Busy: X1C-01"
+
+
+class TestTheWordingStaysInsideWhatIsBusyOnlyUnderstands:
+    """The silence is enforced by string prefixes, so pin them directly rather
+    than only through the scheduler. A future rewording that drops the ``Busy:``
+    prefix would turn every printing fleet into a notification source."""
+
+    @pytest.mark.parametrize(
+        "reason",
+        [
+            "Busy: X1C-01",
+            "Busy: X1C-01 (drying)",
+        ],
+    )
+    def test_these_are_silent(self, reason):
+        assert PrintScheduler._is_busy_only(reason) is True
+
+    @pytest.mark.parametrize(
+        "reason",
+        [
+            "Waiting for plate confirmation: X1C-01",
+            "Offline, no Auto On smart plug: X1C-01",
+            "Offline: X1C-01 — the smart plug could not power it on",
+            "Waiting on Enclosure Door",
+        ],
+    )
+    def test_these_are_not(self, reason):
+        assert PrintScheduler._is_busy_only(reason) is False
+
+
+class TestPinnedHoldReason:
+    """The one new branch, exercised without a scheduler pass around it."""
+
+    def test_a_printing_printer_is_busy(self):
+        with patch(
+            "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
+            MagicMock(return_value=False),
+        ):
+            assert PrintScheduler._pinned_hold_reason(1, "X1C-01", True) == "Busy: X1C-01"
+
+    def test_an_unconfirmed_plate_is_named(self):
+        with patch(
+            "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
+            MagicMock(return_value=True),
+        ):
+            assert PrintScheduler._pinned_hold_reason(1, "X1C-01", True) == "Waiting for plate confirmation: X1C-01"
+
+    def test_the_gate_being_off_beats_the_flag(self):
+        """The flag is persisted, so it survives the setting being turned off."""
+        with patch(
+            "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
+            MagicMock(return_value=True),
+        ):
+            assert PrintScheduler._pinned_hold_reason(1, "X1C-01", False) == "Busy: X1C-01"
+
+    def test_no_telemetry_yet_reads_as_busy(self):
+        """A printer that reconnected a second ago has no status, which
+        `_is_printer_idle` refuses. It resolves itself within a tick or two, and
+        the model-based branch has always reported it as plain busy."""
+        with patch(
+            "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
+            MagicMock(return_value=False),
+        ):
+            assert PrintScheduler._pinned_hold_reason(9, "X1C-01", True) == "Busy: X1C-01"

+ 57 - 0
frontend/src/__tests__/components/QueueTimelineView.test.tsx

@@ -114,3 +114,60 @@ describe('QueueTimelineView job ordering', () => {
     expect(barsLeftToRight()).toEqual(['Running now', 'Long job', 'Short job']);
   });
 });
+
+describe('QueueTimelineView and the scheduler waiting reasons (#3074)', () => {
+  /** The scheduler now puts a reason on a pinned item too, and the commonest
+   *  one by far -- "Busy: <printer>" -- describes the very chain this view
+   *  forecasts. Dropping every item that has a reason would empty the timeline
+   *  for anyone whose queue is pinned to specific printers. */
+  function renderWith(items: PrintQueueItem[]) {
+    render(
+      <QueueTimelineView
+        queueItems={items}
+        printers={printers}
+        printerStatuses={{ 1: { progress: 50, remaining_time: 30, state: 'RUNNING' } }}
+        sjfEnabled={false}
+        onItemClick={() => {}}
+        t={(key: string) => key}
+      />,
+    );
+  }
+
+  it('still forecasts an item that is only waiting its turn', () => {
+    renderWith([
+      running,
+      pending(1, 'Long job', 1, 2 * HOUR, { waiting_reason: 'Busy: X1C-01' }),
+      pending(2, 'Medium job', 2, HOUR, { waiting_reason: 'Busy: X1C-01' }),
+    ]);
+    expect(barsLeftToRight()).toEqual(['Running now', 'Long job', 'Medium job']);
+  });
+
+  it('still forecasts one held behind a drying cycle', () => {
+    renderWith([running, pending(1, 'Long job', 1, 2 * HOUR, { waiting_reason: 'Busy: X1C-01 (drying)' })]);
+    expect(barsLeftToRight()).toEqual(['Running now', 'Long job']);
+  });
+
+  it('drops one that is waiting for the user', () => {
+    // These do not start on their own, so a bar would be a promise the queue
+    // cannot keep.
+    for (const reason of [
+      'Waiting for plate confirmation: X1C-01',
+      'Offline, no Auto On smart plug: X1C-01',
+      'Waiting on Enclosure Door',
+      'Waiting for filament: X1C-01 (needs PETG)',
+    ]) {
+      const { unmount } = render(
+        <QueueTimelineView
+          queueItems={[running, pending(1, 'Long job', 1, 2 * HOUR, { waiting_reason: reason })]}
+          printers={printers}
+          printerStatuses={{ 1: { progress: 50, remaining_time: 30, state: 'RUNNING' } }}
+          sjfEnabled={false}
+          onItemClick={() => {}}
+          t={(key: string) => key}
+        />,
+      );
+      expect(barsLeftToRight()).toEqual(['Running now']);
+      unmount();
+    }
+  });
+});

+ 50 - 0
frontend/src/__tests__/utils/waitingReason.test.ts

@@ -0,0 +1,50 @@
+/**
+ * The frontend half of the `waiting_reason` shape contract (#3074).
+ *
+ * The scheduler encodes "starts by itself" as a reason made only of `Busy: ...`
+ * clauses, joined with ` | `, and decides notifications on that basis. The UI
+ * reads the same bit to decide what still belongs on a forecast. These cases
+ * are the exact strings both branches of the scheduler emit, so a reword on
+ * either side lands here first.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { isBusyOnlyWaitingReason } from '../../utils/waitingReason';
+
+describe('isBusyOnlyWaitingReason', () => {
+  it('treats nothing as not-busy rather than busy', () => {
+    // An item with no reason is dispatchable, not "waiting its turn"; callers
+    // check the reason's presence separately.
+    expect(isBusyOnlyWaitingReason(null)).toBe(false);
+    expect(isBusyOnlyWaitingReason(undefined)).toBe(false);
+    expect(isBusyOnlyWaitingReason('')).toBe(false);
+  });
+
+  it.each([
+    'Busy: X1C-01',
+    'Busy: X1C-01 (drying)',
+    'Busy: X1C-01, X1C-02',
+    'Busy: X1C-01 | Busy: X1C-02',
+  ])('reads %s as waiting its turn', reason => {
+    expect(isBusyOnlyWaitingReason(reason)).toBe(true);
+  });
+
+  it.each([
+    'Waiting for plate confirmation: X1C-01',
+    'Offline, no Auto On smart plug: X1C-01',
+    'Offline: X1C-01 — the smart plug could not power it on',
+    'Waiting on Enclosure Door',
+    'Waiting for filament: X1C-01 (needs PETG)',
+    'No available X1C printers',
+    'Every file for this job has been deleted — add a file back or remove the item',
+  ])('reads %s as waiting for the user', reason => {
+    expect(isBusyOnlyWaitingReason(reason)).toBe(false);
+  });
+
+  it('needs every clause to be busy, not just the first', () => {
+    // The scheduler only joins with " | " when every clause is busy, but the
+    // reader must not assume that -- one clause needing the user makes the
+    // whole reason need the user.
+    expect(isBusyOnlyWaitingReason('Busy: X1C-01 | Offline: X1C-02')).toBe(false);
+  });
+});

+ 1 - 1
frontend/src/api/client.ts

@@ -2533,7 +2533,7 @@ export interface PrintQueueItem {
   target_model: string | null;  // Target printer model for model-based assignment
   target_location: string | null;  // Target location filter for model-based assignment
   required_filament_types: string[] | null;  // Required filament types for model-based assignment
-  waiting_reason: string | null;  // Why a model-based job hasn't started yet
+  waiting_reason: string | null;  // Why this job hasn't started yet (empty once it can)
   // Cross-model alternatives (#671), in priority order. Empty for ordinary
   // items. Present until dispatch resolves one, after which library_file_id and
   // target_model name the candidate that actually ran.

+ 11 - 5
frontend/src/components/QueueTimelineView.tsx

@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useRef } from 'react';
 import { ChevronLeft, ChevronRight, Clock, Layers, Printer as PrinterIcon } from 'lucide-react';
 import { formatDuration, parseUTCDate } from '../utils/date';
 import { compareQueueOrder, queueLaneKey } from '../utils/queueOrder';
+import { isBusyOnlyWaitingReason } from '../utils/waitingReason';
 import type { PrintQueueItem, Printer } from '../api/client';
 import { api } from '../api/client';
 import { Button } from './Button';
@@ -91,8 +92,11 @@ export function QueueTimelineView({
   //  • pending with explicit scheduled_time → at that time
   //  • pending ASAP that chain behind a print actually running on the same
   //    lane → forecast
-  // Staged (manual_start) and waiting (waiting_reason) items are not on the
-  // timeline because they won't auto-dispatch — they'd be misleading bars.
+  // Staged (manual_start) and blocked items are not on the timeline because
+  // they won't auto-dispatch — they'd be misleading bars. "Blocked" is not the
+  // same as "has a waiting_reason": since #3074 an item queued behind a running
+  // print carries one too ("Busy: X1C-01"), and that is the chain this view
+  // exists to forecast. Only a reason that needs the user takes an item off.
   // Idle-printer ASAP queues also stay off until something starts on them.
   const events = useMemo<ScheduleEvent[]>(() => {
     const result: ScheduleEvent[] = [];
@@ -128,10 +132,12 @@ export function QueueTimelineView({
         lanesWithActive.add(lk);
         chainEndByLane.set(lk, Math.max(chainEndByLane.get(lk) ?? nowMs, endTime.getTime()));
       } else if (item.status === 'pending') {
-        // Skip un-committed pending shapes — staged items and waiting items
-        // won't auto-dispatch, so a bar would lie.
+        // Skip un-committed pending shapes — staged items and blocked items
+        // won't auto-dispatch, so a bar would lie. An item merely waiting its
+        // turn behind a print does auto-dispatch, and is the whole point of the
+        // chain forecast below.
         if (item.manual_start) continue;
-        if (item.waiting_reason) continue;
+        if (item.waiting_reason && !isBusyOnlyWaitingReason(item.waiting_reason)) continue;
         const lk = queueLaneKey(item);
         if (!pendingByLaneKey.has(lk)) pendingByLaneKey.set(lk, []);
         pendingByLaneKey.get(lk)!.push(item);

+ 6 - 4
frontend/src/pages/QueuePage.tsx

@@ -1836,10 +1836,12 @@ export function QueuePage() {
   // Queue items eligible for an "if started now" ETA (#2740).
   //
   // The ETA answers "when would this finish if it began right now", so it may
-  // only appear on items that really could begin right now. Deriving that from
-  // waiting_reason alone is not enough: the scheduler only writes that field on
-  // the model-based assignment path (print_scheduler.py), so an item pinned to a
-  // specific printer sits behind a running job with waiting_reason still NULL.
+  // only appear on items that really could begin right now. waiting_reason now
+  // covers the pinned-printer case too (#3074), but it is still not enough on
+  // its own: it says whether the scheduler had a reason to hold the item on its
+  // last pass, not whether this item is the one that printer takes next. Two
+  // items pinned to the same free printer both come back with no reason, and
+  // only one of them can start now — which is what the ordering below works out.
   //
   // Computed from the unfiltered queue on purpose — hiding a printer behind the
   // location filter must not make its printer look free.

+ 23 - 0
frontend/src/utils/waitingReason.ts

@@ -0,0 +1,23 @@
+/**
+ * Reading the scheduler's `waiting_reason` (#3074).
+ *
+ * The backend writes one sentence per held queue item and encodes one bit in
+ * its shape: a reason made only of `Busy: ...` clauses means the job starts by
+ * itself once a printer frees up, and anything else means somebody has to do
+ * something — load filament, switch a printer on, confirm a plate. The
+ * scheduler uses that bit to decide whether a hold is worth a notification
+ * (`PrintScheduler._is_busy_only`), and the UI needs the same distinction to
+ * decide whether a held item still belongs on a forecast.
+ *
+ * Kept in one place on this side too, so the two halves of the contract are one
+ * grep apart.
+ */
+
+/** Does this reason mean "waiting its turn", rather than "waiting for you"? */
+export function isBusyOnlyWaitingReason(reason: string | null | undefined): boolean {
+  if (!reason) return false;
+  return reason
+    .split(' | ')
+    .map(part => part.trim())
+    .every(part => part.startsWith('Busy:'));
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DmKOXvHy.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BMoCmvVS.js"></script>
+    <script type="module" crossorigin src="/assets/index-DmKOXvHy.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff