فهرست منبع

fix(queue): close #1818 — Resume after failure clears the gate

      Single failure on a printer with require_previous_success queue items
      permanently skipped every downstream + every new item — the
      _check_previous_success lookback always walked back to the original
      failed row (skipped is excluded from the lookback), and no code path
      could dismiss that failure.

      Three pieces:

      1. PrintQueueItem.gate_acknowledged Boolean column (default False).
         SQLite/Postgres-safe ALTER, dialect-branched DEFAULT.

      2. _check_previous_success skips rows where gate_acknowledged=True so
         acknowledged failures walk past the lookback. Fresh post-resume
         failures still gate independently.

      3. POST /api/v1/queue/printer/{printer_id}/resume — gated on
         QUEUE_UPDATE_ALL — acknowledges failed/aborted items for that
         printer AND restores items where
         status='skipped' AND error_message='Previous print failed or was
         aborted' back to pending in one transaction. Returns
         {acknowledged, restored}.

      Frontend banner above the active Queue tab surfaces blocked printers,
      fires a warning-variant ConfirmModal, and shows a precise toast on
      success.
maziggy 2 ماه پیش
والد
کامیت
ba7af59bdd

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 58 - 0
backend/app/api/routes/print_queue.py

@@ -1091,6 +1091,64 @@ async def reorder_queue(
     return {"message": f"Reordered {len(data.items)} items"}
     return {"message": f"Reordered {len(data.items)} items"}
 
 
 
 
+@router.post("/printer/{printer_id}/resume")
+async def resume_queue_after_failure(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
+):
+    """Clear the previous-success gate for a printer and restore skipped items.
+
+    Single atomic op (#1818):
+
+    * Sets ``gate_acknowledged=True`` on every ``failed`` / ``aborted`` queue
+      item for this printer that's still in the scheduler's lookback window,
+      so the next ``_check_previous_success`` call ignores them.
+    * Restores ``skipped`` items whose ``error_message`` matches the
+      scheduler's exact "Previous print failed or was aborted" gate string
+      back to ``pending`` (clears ``error_message`` + ``completed_at``).
+
+    Returns counts so the UI can render a precise toast. No-op endpoint
+    (zero counts) when called against a printer with no gate to clear.
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    ack_result = await db.execute(
+        select(PrintQueueItem)
+        .where(PrintQueueItem.printer_id == printer_id)
+        .where(PrintQueueItem.status.in_(["failed", "aborted"]))
+        .where(PrintQueueItem.gate_acknowledged == False)  # noqa: E712
+    )
+    to_ack = ack_result.scalars().all()
+    for failed_item in to_ack:
+        failed_item.gate_acknowledged = True
+
+    restore_result = await db.execute(
+        select(PrintQueueItem)
+        .where(PrintQueueItem.printer_id == printer_id)
+        .where(PrintQueueItem.status == "skipped")
+        .where(PrintQueueItem.error_message == "Previous print failed or was aborted")
+    )
+    to_restore = restore_result.scalars().all()
+    for skipped_item in to_restore:
+        skipped_item.status = "pending"
+        skipped_item.error_message = None
+        skipped_item.completed_at = None
+
+    await db.commit()
+
+    logger.info(
+        "Resume after failure on printer %s: acknowledged %d failure(s), restored %d skipped item(s)",
+        printer_id,
+        len(to_ack),
+        len(to_restore),
+    )
+    return {"acknowledged": len(to_ack), "restored": len(to_restore)}
+
+
 @router.post("/{item_id}/cancel")
 @router.post("/{item_id}/cancel")
 async def cancel_queue_item(
 async def cancel_queue_item(
     item_id: int,
     item_id: int,

+ 9 - 0
backend/app/core/database.py

@@ -3079,6 +3079,15 @@ async def run_migrations(conn):
             "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT false",
             "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT false",
         )
         )
 
 
+    # Migration: Add gate_acknowledged column to print_queue (#1818). Cleared
+    # by the per-printer "Resume after failure" action so the scheduler's
+    # `_check_previous_success` lookback skips this row. Postgres rejects
+    # `DEFAULT 0` for BOOLEAN columns.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT false")
+
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)
     await _migrate_rename_user_print_template_names(conn)

+ 9 - 0
backend/app/models/print_queue.py

@@ -90,6 +90,15 @@ class PrintQueueItem(Base):
     # Status: pending, printing, completed, failed, skipped, cancelled
     # Status: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
     status: Mapped[str] = mapped_column(String(20), default="pending")
 
 
+    # Cleared by the per-printer "Resume after failure" action (#1818) so the
+    # scheduler's `_check_previous_success` lookback skips this row. Without
+    # this, a single `failed` or `aborted` print poisoned every later
+    # `require_previous_success` item on the same printer forever — the
+    # lookback excluded `skipped` but had no way to dismiss the originating
+    # failure. The flag is per-item, not per-printer, so a fresh failure
+    # after a resume re-gates downstream items independently.
+    gate_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # Set by the dispatch scheduler when the assigned spool can't satisfy
     # Set by the dispatch scheduler when the assigned spool can't satisfy
     # this print's per-slot filament weight (#1496). Display-only flag — the
     # this print's per-slot filament weight (#1496). Display-only flag — the
     # actual deficit is recomputed live every time the user clicks ▶, so
     # actual deficit is recomputed live every time the user clicks ▶, so

+ 6 - 0
backend/app/services/print_scheduler.py

@@ -1901,12 +1901,18 @@ class PrintScheduler:
         items — counting it as a failed predecessor was the cascade bug that
         items — counting it as a failed predecessor was the cascade bug that
         let a single cancellation block 18 items over 3 days for the reporter.
         let a single cancellation block 18 items over 3 days for the reporter.
         Only `failed` and `aborted` — real print-attempt failures — block.
         Only `failed` and `aborted` — real print-attempt failures — block.
+
+        Failures with `gate_acknowledged=True` (set by the per-printer Resume
+        action — #1818) are also excluded from the lookback so the user can
+        clear the gate after fixing the physical issue without having to
+        re-queue every downstream job.
         """
         """
         result = await db.execute(
         result = await db.execute(
             select(PrintQueueItem)
             select(PrintQueueItem)
             .where(PrintQueueItem.printer_id == item.printer_id)
             .where(PrintQueueItem.printer_id == item.printer_id)
             .where(PrintQueueItem.id != item.id)
             .where(PrintQueueItem.id != item.id)
             .where(PrintQueueItem.status.in_(["completed", "failed", "cancelled", "aborted"]))
             .where(PrintQueueItem.status.in_(["completed", "failed", "cancelled", "aborted"]))
+            .where(PrintQueueItem.gate_acknowledged == False)  # noqa: E712
             .order_by(PrintQueueItem.completed_at.desc())
             .order_by(PrintQueueItem.completed_at.desc())
             .limit(1)
             .limit(1)
         )
         )

+ 244 - 0
backend/tests/integration/test_print_queue_api.py

@@ -2247,3 +2247,247 @@ class TestAbortedStatusNormalisation:
         assert row["archive_deleted"] is False
         assert row["archive_deleted"] is False
         assert row["archive_name"] == "Live Archive"
         assert row["archive_name"] == "Live Archive"
         assert row["archive_thumbnail"] == "archives/test/live/thumbnail.png"
         assert row["archive_thumbnail"] == "archives/test/live/thumbnail.png"
+
+
+class TestResumeQueueAfterFailure:
+    """Integration tests for POST /api/v1/queue/printer/{id}/resume (#1818)."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+            defaults = {
+                "name": f"Resume Printer {counter}",
+                "ip_address": f"192.168.42.{100 + counter}",
+                "serial_number": f"RESUMESERIAL{counter:04d}",
+                "access_code": "12345678",
+                "model": "P1S",
+            }
+            defaults.update(kwargs)
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+            defaults = {
+                "filename": f"resume_print_{counter}.3mf",
+                "print_name": f"Resume Print {counter}",
+                "file_path": f"/tmp/resume_print_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"resumehash{counter:08d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    async def _add_item(self, db_session, printer, archive_factory, **kwargs):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        archive = await archive_factory()
+        defaults = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "status": "pending",
+            "require_previous_success": True,
+        }
+        defaults.update(kwargs)
+        item = PrintQueueItem(**defaults)
+        db_session.add(item)
+        await db_session.commit()
+        await db_session.refresh(item)
+        return item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_unknown_printer_returns_404(self, async_client: AsyncClient):
+        resp = await async_client.post("/api/v1/queue/printer/999999/resume")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_no_op_on_clean_queue(self, async_client: AsyncClient, printer_factory):
+        """Calling resume on a printer with no failures and no skipped items
+        returns zero counts — endpoint is idempotent and safe to spam."""
+        printer = await printer_factory()
+        resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
+        assert resp.status_code == 200
+        assert resp.json() == {"acknowledged": 0, "restored": 0}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_acknowledges_failed_and_restores_skipped(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Reporter's scenario: failed predecessor + N skipped downstream items.
+        Resume sets gate_acknowledged on the failure and flips skipped → pending."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        failed = await self._add_item(db_session, printer, archive_factory, status="failed")
+        skipped_1 = await self._add_item(
+            db_session,
+            printer,
+            archive_factory,
+            status="skipped",
+            error_message="Previous print failed or was aborted",
+        )
+        skipped_2 = await self._add_item(
+            db_session,
+            printer,
+            archive_factory,
+            status="skipped",
+            error_message="Previous print failed or was aborted",
+        )
+
+        resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
+        assert resp.status_code == 200
+        assert resp.json() == {"acknowledged": 1, "restored": 2}
+
+        failed_id = failed.id
+        skipped_ids = [skipped_1.id, skipped_2.id]
+        db_session.expire_all()
+
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_id))
+        assert result.scalar_one().gate_acknowledged is True
+
+        for sid in skipped_ids:
+            result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == sid))
+            row = result.scalar_one()
+            assert row.status == "pending"
+            assert row.error_message is None
+            assert row.completed_at is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_preserves_skipped_items_with_other_reasons(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Skipped items whose error_message is something OTHER than the
+        gate string (e.g. filament-deficit promotion, future skip reasons)
+        must not be touched — they encode different user intent."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        gate_skip = await self._add_item(
+            db_session,
+            printer,
+            archive_factory,
+            status="skipped",
+            error_message="Previous print failed or was aborted",
+        )
+        other_skip = await self._add_item(
+            db_session,
+            printer,
+            archive_factory,
+            status="skipped",
+            error_message="User skipped via UI",
+        )
+
+        gate_id = gate_skip.id
+        other_id = other_skip.id
+        resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
+        assert resp.json() == {"acknowledged": 0, "restored": 1}
+
+        db_session.expire_all()
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == gate_id))
+        assert result.scalar_one().status == "pending"
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == other_id))
+        assert result.scalar_one().status == "skipped"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_scoped_to_printer(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """A resume on printer A must not clear printer B's gate — farms run
+        each printer's queue independently."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        p1 = await printer_factory()
+        p2 = await printer_factory()
+        failed_p1 = await self._add_item(db_session, p1, archive_factory, status="failed")
+        failed_p2 = await self._add_item(db_session, p2, archive_factory, status="failed")
+
+        failed_p1_id = failed_p1.id
+        failed_p2_id = failed_p2.id
+        resp = await async_client.post(f"/api/v1/queue/printer/{p1.id}/resume")
+        assert resp.json() == {"acknowledged": 1, "restored": 0}
+
+        db_session.expire_all()
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_p1_id))
+        assert result.scalar_one().gate_acknowledged is True
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_p2_id))
+        assert result.scalar_one().gate_acknowledged is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_handles_aborted_status(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Aborted prints (printer-detected mid-print failure) gate the same
+        way failed prints do and must also be acknowledgeable."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        aborted = await self._add_item(db_session, printer, archive_factory, status="aborted")
+        aborted_id = aborted.id
+        resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
+        assert resp.json() == {"acknowledged": 1, "restored": 0}
+
+        db_session.expire_all()
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == aborted_id))
+        assert result.scalar_one().gate_acknowledged is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_idempotent_second_call_is_no_op(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Calling resume twice on the same printer doesn't re-acknowledge
+        the same failure — the second call sees acknowledged=0, restored=0."""
+        printer = await printer_factory()
+        await self._add_item(db_session, printer, archive_factory, status="failed")
+        await self._add_item(
+            db_session,
+            printer,
+            archive_factory,
+            status="skipped",
+            error_message="Previous print failed or was aborted",
+        )
+
+        first = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
+        assert first.json() == {"acknowledged": 1, "restored": 1}
+
+        second = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
+        assert second.json() == {"acknowledged": 0, "restored": 0}

+ 47 - 1
backend/tests/unit/test_check_previous_success.py

@@ -47,7 +47,11 @@ def queue_factory(db_session, printer_factory):
             printer_holder["p"] = await printer_factory()
             printer_holder["p"] = await printer_factory()
         return printer_holder["p"]
         return printer_holder["p"]
 
 
-    async def _add(status: str, error_message: str | None = None) -> PrintQueueItem:
+    async def _add(
+        status: str,
+        error_message: str | None = None,
+        gate_acknowledged: bool = False,
+    ) -> PrintQueueItem:
         printer = await _make_printer()
         printer = await _make_printer()
         counter["n"] += 1
         counter["n"] += 1
         item = PrintQueueItem(
         item = PrintQueueItem(
@@ -56,6 +60,7 @@ def queue_factory(db_session, printer_factory):
             error_message=error_message,
             error_message=error_message,
             completed_at=base_time + timedelta(minutes=counter["n"]),
             completed_at=base_time + timedelta(minutes=counter["n"]),
             require_previous_success=True,
             require_previous_success=True,
+            gate_acknowledged=gate_acknowledged,
         )
         )
         db_session.add(item)
         db_session.add(item)
         await db_session.commit()
         await db_session.commit()
@@ -170,3 +175,44 @@ async def test_completed_then_failed_blocks(scheduler, db_session, queue_factory
     await queue_factory["add"]("failed")
     await queue_factory["add"]("failed")
     pending = await queue_factory["add_pending"]()
     pending = await queue_factory["add_pending"]()
     assert await scheduler._check_previous_success(db_session, pending) is False
     assert await scheduler._check_previous_success(db_session, pending) is False
+
+
+# ---- #1818: per-printer Resume-after-failure gate acknowledgement ----
+
+
+@pytest.mark.asyncio
+async def test_acknowledged_failure_is_excluded(scheduler, db_session, queue_factory):
+    """The reporter scenario: failure with gate_acknowledged=True must NOT
+    block. Without the acknowledge filter, a single failure poisons every
+    later require_previous_success item forever."""
+    await queue_factory["add"]("failed", gate_acknowledged=True)
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is True
+
+
+@pytest.mark.asyncio
+async def test_acknowledged_aborted_is_excluded(scheduler, db_session, queue_factory):
+    await queue_factory["add"]("aborted", gate_acknowledged=True)
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is True
+
+
+@pytest.mark.asyncio
+async def test_fresh_failure_after_ack_still_blocks(scheduler, db_session, queue_factory):
+    """Per-item acknowledgement is independent — a NEW failure after the
+    user resumed the queue must re-gate downstream items so they don't
+    silently steamroll past a real problem."""
+    await queue_factory["add"]("failed", gate_acknowledged=True)  # the old one
+    await queue_factory["add"]("failed", gate_acknowledged=False)  # fresh post-resume
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is False
+
+
+@pytest.mark.asyncio
+async def test_acknowledged_failure_walks_back_to_completed(scheduler, db_session, queue_factory):
+    """After acknowledging the failure, the next real predecessor (a
+    completed print prior to the failure) governs the gate."""
+    await queue_factory["add"]("completed")
+    await queue_factory["add"]("failed", gate_acknowledged=True)
+    pending = await queue_factory["add_pending"]()
+    assert await scheduler._check_previous_success(db_session, pending) is True

+ 11 - 0
frontend/src/api/client.ts

@@ -4771,6 +4771,17 @@ export const api = {
     const qs = opts?.skipFilamentCheck ? '?skip_filament_check=true' : '';
     const qs = opts?.skipFilamentCheck ? '?skip_filament_check=true' : '';
     return request<PrintQueueItem>(`/queue/${id}/start${qs}`, { method: 'POST' });
     return request<PrintQueueItem>(`/queue/${id}/start${qs}`, { method: 'POST' });
   },
   },
+  /**
+   * Clear the `require_previous_success` gate for a printer after the user
+   * resolves the failure. Acknowledges any failed/aborted predecessors and
+   * restores skipped items whose error_message matches the gate string
+   * back to pending. Returns counts so the UI can render a precise toast.
+   */
+  resumeQueueAfterFailure: (printerId: number) =>
+    request<{ acknowledged: number; restored: number }>(
+      `/queue/printer/${printerId}/resume`,
+      { method: 'POST' },
+    ),
   bulkUpdateQueue: (data: PrintQueueBulkUpdate) =>
   bulkUpdateQueue: (data: PrintQueueBulkUpdate) =>
     request<PrintQueueBulkUpdateResponse>('/queue/bulk', {
     request<PrintQueueBulkUpdateResponse>('/queue/bulk', {
       method: 'PATCH',
       method: 'PATCH',

+ 9 - 0
frontend/src/i18n/locales/de.ts

@@ -1229,6 +1229,15 @@ export default {
       batchCreateFailed: 'Stapel konnte nicht erstellt werden',
       batchCreateFailed: 'Stapel konnte nicht erstellt werden',
       batchUngrouped: '{{count}} Eintrag/Einträge aus Stapel gelöst',
       batchUngrouped: '{{count}} Eintrag/Einträge aus Stapel gelöst',
       batchUngroupFailed: 'Stapel konnte nicht aufgelöst werden',
       batchUngroupFailed: 'Stapel konnte nicht aufgelöst werden',
+      resumedAfterFailure: 'Warteschlange fortgesetzt — {{restored}} Auftrag/Aufträge wieder eingereiht',
+      resumeAfterFailureFailed: 'Warteschlange konnte nicht fortgesetzt werden',
+    },
+    resumeAfterFailure: {
+      banner: '{{printer}} ist durch einen vorherigen Druckfehler blockiert — {{count}} Auftrag/Aufträge übersprungen',
+      bannerHint: 'Behebe das Druckerproblem und setze die Warteschlange dann fort, um die übersprungenen Aufträge wiederherzustellen und die Sperre aufzuheben.',
+      button: 'Nach Fehler fortsetzen',
+      confirmTitle: 'Warteschlange nach Fehler fortsetzen?',
+      confirmMessage: 'Setze {{count}} übersprungenen Auftrag/Aufträge auf {{printer}} wieder auf „Ausstehend“ und hebe die Vorgängersperre auf. Stelle vorher sicher, dass der Drucker bereit ist.',
     },
     },
     // Timeline view
     // Timeline view
     timeline: {
     timeline: {

+ 9 - 0
frontend/src/i18n/locales/en.ts

@@ -1239,6 +1239,15 @@ export default {
       batchCreateFailed: 'Failed to create batch',
       batchCreateFailed: 'Failed to create batch',
       batchUngrouped: 'Ungrouped {{count}} item(s)',
       batchUngrouped: 'Ungrouped {{count}} item(s)',
       batchUngroupFailed: 'Failed to ungroup batch',
       batchUngroupFailed: 'Failed to ungroup batch',
+      resumedAfterFailure: 'Resumed queue — {{restored}} job(s) restored to pending',
+      resumeAfterFailureFailed: 'Failed to resume queue',
+    },
+    resumeAfterFailure: {
+      banner: '{{printer}} is blocked by a previous-print failure — {{count}} job(s) skipped',
+      bannerHint: 'Fix the printer issue, then resume to restore the skipped jobs and clear the gate.',
+      button: 'Resume after failure',
+      confirmTitle: 'Resume queue after failure?',
+      confirmMessage: 'Restore {{count}} skipped job(s) on {{printer}} to pending and clear the previous-print gate. Make sure the printer is ready before continuing.',
     },
     },
     // Timeline view
     // Timeline view
     timeline: {
     timeline: {

+ 9 - 0
frontend/src/i18n/locales/es.ts

@@ -1229,6 +1229,15 @@ export default {
       batchCreateFailed: 'Error al crear el lote',
       batchCreateFailed: 'Error al crear el lote',
       batchUngrouped: '{{count}} elemento(s) desagrupado(s)',
       batchUngrouped: '{{count}} elemento(s) desagrupado(s)',
       batchUngroupFailed: 'Error al desagrupar el lote',
       batchUngroupFailed: 'Error al desagrupar el lote',
+      resumedAfterFailure: 'Cola reanudada — {{restored}} trabajo(s) restaurado(s) a pendientes',
+      resumeAfterFailureFailed: 'Error al reanudar la cola',
+    },
+    resumeAfterFailure: {
+      banner: '{{printer}} está bloqueado por un fallo de impresión previo — {{count}} trabajo(s) omitido(s)',
+      bannerHint: 'Soluciona el problema de la impresora y reanuda para restaurar los trabajos omitidos y limpiar el bloqueo.',
+      button: 'Reanudar tras fallo',
+      confirmTitle: '¿Reanudar la cola tras el fallo?',
+      confirmMessage: 'Restaurar {{count}} trabajo(s) omitido(s) en {{printer}} a pendientes y limpiar el bloqueo del trabajo anterior. Asegúrate de que la impresora esté lista antes de continuar.',
     },
     },
     // Timeline view
     // Timeline view
     timeline: {
     timeline: {

+ 9 - 0
frontend/src/i18n/locales/fr.ts

@@ -1229,6 +1229,15 @@ export default {
       batchCreateFailed: 'Échec de la création du lot',
       batchCreateFailed: 'Échec de la création du lot',
       batchUngrouped: '{{count}} élément(s) dégroupé(s)',
       batchUngrouped: '{{count}} élément(s) dégroupé(s)',
       batchUngroupFailed: 'Échec du dégroupement du lot',
       batchUngroupFailed: 'Échec du dégroupement du lot',
+      resumedAfterFailure: 'File reprise — {{restored}} tâche(s) restaurée(s) en attente',
+      resumeAfterFailureFailed: 'Échec de la reprise de la file',
+    },
+    resumeAfterFailure: {
+      banner: '{{printer}} est bloquée par un échec d\'impression précédent — {{count}} tâche(s) ignorée(s)',
+      bannerHint: 'Résolvez le problème de l\'imprimante, puis reprenez la file pour restaurer les tâches ignorées et lever le blocage.',
+      button: 'Reprendre après échec',
+      confirmTitle: 'Reprendre la file après l\'échec ?',
+      confirmMessage: 'Restaurer {{count}} tâche(s) ignorée(s) sur {{printer}} en attente et lever le blocage de l\'impression précédente. Assurez-vous que l\'imprimante est prête avant de continuer.',
     },
     },
     // Timeline view
     // Timeline view
     timeline: {
     timeline: {

+ 9 - 0
frontend/src/i18n/locales/it.ts

@@ -1229,6 +1229,15 @@ export default {
       batchCreateFailed: 'Creazione lotto non riuscita',
       batchCreateFailed: 'Creazione lotto non riuscita',
       batchUngrouped: '{{count}} elemento/i separato/i',
       batchUngrouped: '{{count}} elemento/i separato/i',
       batchUngroupFailed: 'Separazione del lotto non riuscita',
       batchUngroupFailed: 'Separazione del lotto non riuscita',
+      resumedAfterFailure: 'Coda ripresa — {{restored}} processo/i ripristinato/i come in attesa',
+      resumeAfterFailureFailed: 'Ripresa della coda non riuscita',
+    },
+    resumeAfterFailure: {
+      banner: '{{printer}} è bloccata da un errore di stampa precedente — {{count}} processo/i saltato/i',
+      bannerHint: 'Risolvi il problema della stampante, poi riprendi per ripristinare i processi saltati e rimuovere il blocco.',
+      button: 'Riprendi dopo errore',
+      confirmTitle: 'Riprendere la coda dopo l\'errore?',
+      confirmMessage: 'Ripristinare {{count}} processo/i saltato/i su {{printer}} come in attesa e rimuovere il blocco della stampa precedente. Assicurati che la stampante sia pronta prima di continuare.',
     },
     },
     // Timeline view
     // Timeline view
     timeline: {
     timeline: {

+ 9 - 0
frontend/src/i18n/locales/ja.ts

@@ -1228,6 +1228,15 @@ export default {
       batchCreateFailed: 'バッチの作成に失敗しました',
       batchCreateFailed: 'バッチの作成に失敗しました',
       batchUngrouped: '{{count}}件のグループを解除しました',
       batchUngrouped: '{{count}}件のグループを解除しました',
       batchUngroupFailed: 'バッチのグループ解除に失敗しました',
       batchUngroupFailed: 'バッチのグループ解除に失敗しました',
+      resumedAfterFailure: 'キューを再開しました — {{restored}}件のジョブを保留状態に戻しました',
+      resumeAfterFailureFailed: 'キューの再開に失敗しました',
+    },
+    resumeAfterFailure: {
+      banner: '{{printer}} は前回の印刷失敗によりブロックされています — {{count}}件のジョブをスキップしました',
+      bannerHint: 'プリンターの問題を解決してから再開すると、スキップされたジョブを復元してブロックを解除できます。',
+      button: '失敗後に再開',
+      confirmTitle: '失敗後にキューを再開しますか?',
+      confirmMessage: '{{printer}} のスキップされた {{count}} 件のジョブを保留状態に戻し、前回の印刷ブロックを解除します。続行する前にプリンターが準備完了していることを確認してください。',
     },
     },
     // Timeline view
     // Timeline view
     timeline: {
     timeline: {

+ 9 - 0
frontend/src/i18n/locales/ko.ts

@@ -1161,6 +1161,15 @@ export default {
       batchCreateFailed: '배치 만들기에 실패했습니다',
       batchCreateFailed: '배치 만들기에 실패했습니다',
       batchUngrouped: '{{count}}개 항목의 그룹을 해제했습니다',
       batchUngrouped: '{{count}}개 항목의 그룹을 해제했습니다',
       batchUngroupFailed: '배치 그룹 해제에 실패했습니다',
       batchUngroupFailed: '배치 그룹 해제에 실패했습니다',
+      resumedAfterFailure: '큐를 재개했습니다 — {{restored}}개 작업을 대기 상태로 복원했습니다',
+      resumeAfterFailureFailed: '큐 재개에 실패했습니다',
+    },
+    resumeAfterFailure: {
+      banner: '{{printer}}이(가) 이전 인쇄 실패로 인해 차단되었습니다 — {{count}}개 작업이 건너뛰어졌습니다',
+      bannerHint: '프린터 문제를 해결한 후 재개하여 건너뛴 작업을 복원하고 차단을 해제하세요.',
+      button: '실패 후 재개',
+      confirmTitle: '실패 후 큐를 재개하시겠습니까?',
+      confirmMessage: '{{printer}}에서 건너뛴 {{count}}개 작업을 대기 상태로 복원하고 이전 인쇄 차단을 해제합니다. 계속하기 전에 프린터가 준비되었는지 확인하세요.',
     },
     },
     timeline: {
     timeline: {
       listView: '목록',
       listView: '목록',

+ 9 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1229,6 +1229,15 @@ export default {
       batchCreateFailed: 'Falha ao criar o lote',
       batchCreateFailed: 'Falha ao criar o lote',
       batchUngrouped: '{{count}} item(ns) desagrupado(s)',
       batchUngrouped: '{{count}} item(ns) desagrupado(s)',
       batchUngroupFailed: 'Falha ao desagrupar o lote',
       batchUngroupFailed: 'Falha ao desagrupar o lote',
+      resumedAfterFailure: 'Fila retomada — {{restored}} trabalho(s) restaurado(s) para pendente',
+      resumeAfterFailureFailed: 'Falha ao retomar a fila',
+    },
+    resumeAfterFailure: {
+      banner: '{{printer}} está bloqueada por uma falha de impressão anterior — {{count}} trabalho(s) ignorado(s)',
+      bannerHint: 'Resolva o problema da impressora e retome para restaurar os trabalhos ignorados e remover o bloqueio.',
+      button: 'Retomar após falha',
+      confirmTitle: 'Retomar a fila após a falha?',
+      confirmMessage: 'Restaurar {{count}} trabalho(s) ignorado(s) em {{printer}} para pendente e remover o bloqueio da impressão anterior. Verifique se a impressora está pronta antes de continuar.',
     },
     },
     // Timeline view
     // Timeline view
     timeline: {
     timeline: {

+ 9 - 0
frontend/src/i18n/locales/tr.ts

@@ -1229,6 +1229,15 @@ export default {
       batchCreateFailed: 'Yığın oluşturma başarısız',
       batchCreateFailed: 'Yığın oluşturma başarısız',
       batchUngrouped: '{{count}} öğe gruptan çıkarıldı',
       batchUngrouped: '{{count}} öğe gruptan çıkarıldı',
       batchUngroupFailed: 'Yığını gruptan çıkarma başarısız',
       batchUngroupFailed: 'Yığını gruptan çıkarma başarısız',
+      resumedAfterFailure: 'Kuyruk devam ettirildi — {{restored}} iş bekleyene geri alındı',
+      resumeAfterFailureFailed: 'Kuyruk devam ettirilemedi',
+    },
+    resumeAfterFailure: {
+      banner: '{{printer}} önceki bir baskı hatası nedeniyle engellendi — {{count}} iş atlandı',
+      bannerHint: 'Yazıcı sorununu çözün, ardından atlanan işleri geri yüklemek ve engellemeyi kaldırmak için devam ettirin.',
+      button: 'Hatadan sonra devam et',
+      confirmTitle: 'Hatadan sonra kuyruğa devam edilsin mi?',
+      confirmMessage: '{{printer}} üzerindeki {{count}} atlanan işi bekleyene geri yükleyin ve önceki baskı engelini kaldırın. Devam etmeden önce yazıcının hazır olduğundan emin olun.',
     },
     },
     // Zaman çizelgesi görünümü
     // Zaman çizelgesi görünümü
     timeline: {
     timeline: {

+ 9 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1229,6 +1229,15 @@ export default {
       batchCreateFailed: '创建批次失败',
       batchCreateFailed: '创建批次失败',
       batchUngrouped: '已取消分组 {{count}} 项',
       batchUngrouped: '已取消分组 {{count}} 项',
       batchUngroupFailed: '取消批次分组失败',
       batchUngroupFailed: '取消批次分组失败',
+      resumedAfterFailure: '已恢复队列 — {{restored}} 个任务已恢复为待处理',
+      resumeAfterFailureFailed: '恢复队列失败',
+    },
+    resumeAfterFailure: {
+      banner: '{{printer}} 因之前的打印失败而被阻塞 — 已跳过 {{count}} 个任务',
+      bannerHint: '解决打印机问题后恢复队列,以还原跳过的任务并清除阻塞。',
+      button: '失败后恢复',
+      confirmTitle: '失败后恢复队列?',
+      confirmMessage: '将 {{printer}} 上的 {{count}} 个跳过的任务恢复为待处理,并清除上一个打印的阻塞。继续之前请确保打印机已就绪。',
     },
     },
     // Timeline view
     // Timeline view
     timeline: {
     timeline: {

+ 9 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -1229,6 +1229,15 @@ export default {
       batchCreateFailed: '建立批次失敗',
       batchCreateFailed: '建立批次失敗',
       batchUngrouped: '已取消分組 {{count}} 項',
       batchUngrouped: '已取消分組 {{count}} 項',
       batchUngroupFailed: '取消批次分組失敗',
       batchUngroupFailed: '取消批次分組失敗',
+      resumedAfterFailure: '已恢復佇列 — {{restored}} 個工作已恢復為待處理',
+      resumeAfterFailureFailed: '恢復佇列失敗',
+    },
+    resumeAfterFailure: {
+      banner: '{{printer}} 因先前的列印失敗而被阻擋 — 已跳過 {{count}} 個工作',
+      bannerHint: '解決印表機問題後恢復佇列,以還原跳過的工作並清除阻擋。',
+      button: '失敗後恢復',
+      confirmTitle: '失敗後恢復佇列?',
+      confirmMessage: '將 {{printer}} 上的 {{count}} 個跳過的工作恢復為待處理,並清除上一個列印的阻擋。繼續前請確認印表機已就緒。',
     },
     },
     // Timeline view
     // Timeline view
     timeline: {
     timeline: {

+ 111 - 0
frontend/src/pages/QueuePage.tsx

@@ -58,6 +58,7 @@ import {
   PackageOpen,
   PackageOpen,
   Ungroup,
   Ungroup,
   Ban,
   Ban,
+  PlayCircle,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api, ApiError } from '../api/client';
 import { api, ApiError } from '../api/client';
 import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
 import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
@@ -1208,6 +1209,13 @@ export function QueuePage() {
   } | null>(null);
   } | null>(null);
   const [selectedItems, setSelectedItems] = useState<number[]>([]);
   const [selectedItems, setSelectedItems] = useState<number[]>([]);
   const [showBulkEditModal, setShowBulkEditModal] = useState(false);
   const [showBulkEditModal, setShowBulkEditModal] = useState(false);
+  // #1818: per-printer Resume-after-failure confirm modal. Tracks which
+  // printer's gate the user is about to clear; null when no modal is open.
+  const [resumeConfirm, setResumeConfirm] = useState<{
+    printerId: number;
+    printerName: string;
+    skippedCount: number;
+  } | null>(null);
   const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
   const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
     const saved = localStorage.getItem('queue.historySortBy');
     const saved = localStorage.getItem('queue.historySortBy');
     return (saved as 'date' | 'name' | 'printer') || 'date';
     return (saved as 'date' | 'name' | 'printer') || 'date';
@@ -1431,6 +1439,21 @@ export function QueuePage() {
     onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'),
     onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'),
   });
   });
 
 
+  const resumeAfterFailureMutation = useMutation({
+    mutationFn: (printerId: number) => api.resumeQueueAfterFailure(printerId),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      setResumeConfirm(null);
+      showToast(
+        t('queue.toast.resumedAfterFailure', {
+          restored: result.restored,
+          acknowledged: result.acknowledged,
+        }),
+      );
+    },
+    onError: () => showToast(t('queue.toast.resumeAfterFailureFailed'), 'error'),
+  });
+
   const createBatchMutation = useMutation({
   const createBatchMutation = useMutation({
     mutationFn: (data: { name: string; item_ids: number[] }) => api.createBatch(data),
     mutationFn: (data: { name: string; item_ids: number[] }) => api.createBatch(data),
     onSuccess: (batch) => {
     onSuccess: (batch) => {
@@ -1823,6 +1846,42 @@ export function QueuePage() {
     });
     });
   }, [groupedRows, t]);
   }, [groupedRows, t]);
 
 
+  // #1818: printers whose queue is gated by a prior failure that's poisoning
+  // downstream `require_previous_success` items. We surface a per-printer
+  // Resume banner above the active queue so the user can clear the gate +
+  // restore the skipped jobs in one click, without re-queuing each one.
+  // Detection key: skipped + the scheduler's exact gate string. Other skip
+  // reasons (filament deficit, etc.) get their own UX and stay untouched.
+  const gateBlockedPrinters = useMemo<
+    Array<{ printerId: number; printerName: string; skippedCount: number }>
+  >(() => {
+    const counts = new Map<number, { name: string; count: number }>();
+    queue?.forEach((item) => {
+      if (
+        item.status === 'skipped' &&
+        item.error_message === 'Previous print failed or was aborted' &&
+        item.printer_id
+      ) {
+        const existing = counts.get(item.printer_id);
+        if (existing) {
+          existing.count += 1;
+        } else {
+          counts.set(item.printer_id, {
+            name: item.printer_name || `Printer #${item.printer_id}`,
+            count: 1,
+          });
+        }
+      }
+    });
+    return Array.from(counts.entries())
+      .map(([printerId, { name, count }]) => ({
+        printerId,
+        printerName: name,
+        skippedCount: count,
+      }))
+      .sort((a, b) => a.printerName.localeCompare(b.printerName));
+  }, [queue]);
+
   const aggregateForRows = (rows: QueueRow[]) => {
   const aggregateForRows = (rows: QueueRow[]) => {
     let count = 0;
     let count = 0;
     let time = 0;
     let time = 0;
@@ -1891,6 +1950,43 @@ export function QueuePage() {
         t={t}
         t={t}
       />
       />
 
 
+      {/* #1818: Resume-after-failure banner. One row per printer whose queue
+          is gated by a prior failed/aborted print. Visible regardless of
+          tab/layout so the user can clear the gate without hunting for
+          skipped items. Hidden entirely when no gates are active. */}
+      {activeTab === 'queue' && gateBlockedPrinters.length > 0 && hasPermission('queue:update_all' as Permission) && (
+        <div className="mb-4 space-y-2">
+          {gateBlockedPrinters.map(({ printerId, printerName, skippedCount }) => (
+            <div
+              key={printerId}
+              className="flex items-center gap-3 px-4 py-3 bg-orange-500/10 border border-orange-500/30 rounded-lg"
+            >
+              <AlertCircle className="w-5 h-5 text-orange-400 flex-shrink-0" />
+              <div className="flex-1 min-w-0">
+                <div className="text-sm text-orange-200">
+                  {t('queue.resumeAfterFailure.banner', {
+                    printer: printerName,
+                    count: skippedCount,
+                  })}
+                </div>
+                <div className="text-xs text-orange-200/70 mt-0.5">
+                  {t('queue.resumeAfterFailure.bannerHint')}
+                </div>
+              </div>
+              <button
+                onClick={() =>
+                  setResumeConfirm({ printerId, printerName, skippedCount })
+                }
+                className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-500/20 hover:bg-orange-500/30 text-orange-100 text-sm rounded-md border border-orange-500/40 transition-colors flex-shrink-0"
+              >
+                <PlayCircle className="w-4 h-4" />
+                {t('queue.resumeAfterFailure.button')}
+              </button>
+            </div>
+          ))}
+        </div>
+      )}
+
       {/* Filters */}
       {/* Filters */}
       <div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
       <div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
         <select
         <select
@@ -2370,6 +2466,21 @@ export function QueuePage() {
         />
         />
       )}
       )}
 
 
+      {/* #1818: Resume-after-failure confirm */}
+      {resumeConfirm && (
+        <ConfirmModal
+          title={t('queue.resumeAfterFailure.confirmTitle')}
+          message={t('queue.resumeAfterFailure.confirmMessage', {
+            printer: resumeConfirm.printerName,
+            count: resumeConfirm.skippedCount,
+          })}
+          confirmText={t('queue.resumeAfterFailure.button')}
+          variant="warning"
+          onConfirm={() => resumeAfterFailureMutation.mutate(resumeConfirm.printerId)}
+          onCancel={() => setResumeConfirm(null)}
+        />
+      )}
+
       {/* Clear History Confirm Modal */}
       {/* Clear History Confirm Modal */}
       {showClearHistoryConfirm && (
       {showClearHistoryConfirm && (
         <ConfirmModal
         <ConfirmModal

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است