fix(queue): persist "Print Anyway" so scheduler stops re-flagging it

When the user clicked Print Anyway on a filament-deficit warning, the
  acknowledgement was one-shot. The route cleared manual_start and
  filament_short, then the next scheduler tick re-ran
  compute_deficit_for_queue_item against identical spool state, found
  the same deficit, and re-set both flags. The item bounced between
  "user said anyway" and "scheduler re-blocked" — every Play click
  returned 409, every confirm got rolled back on the next tick.

  Add a persistent acknowledgement flag on the queue item:

  - New column `skip_filament_check` on print_queue. SQLite + Postgres
    migration branched on is_sqlite() so Postgres doesn't reject
    DEFAULT 0 on BOOLEAN.

  - PrintQueueItemCreate + PrintQueueItemResponse schemas + the
    TypeScript types carry the field.

  - POST /print-queue/{id}/start with skip_filament_check=true now
    ALSO sets item.skip_filament_check = True (not just clearing
    manual_start / filament_short).

  - PrintScheduler._block_on_filament_deficit short-circuits to
    False — no compute, no flag-setting, no notification — when
    item.skip_filament_check is True. We trust the operator's
    decision and stop fighting them.

  - PrintModal at queue-creation time threads
    skip_filament_check=true into the create payload when the user
    clicks Print Anyway on the frontend deficit warning, so a print
    that was warned-then-acknowledged at add-to-queue time goes in
    pre-acknowledged — scheduler never blocks it on first tick.

  Flag is not auto-cleared on spool swap by design: if remaining is
  now sufficient, the check returns no deficit anyway, so the flag
  is moot. Auto-clearing would add lifecycle complexity without
  changing behaviour.

  AMS Backup awareness (the other half of the discussion) intentionally
  NOT included — verified the H2D's bit-26 of print.cfg toggles with
  the printer-side AMS Backup setting, but the X1C's cfg has a
  different shape entirely and verifying every model family isn't
  realistic. Silently under-warning would be worse than always
  per-slot. The check stays single-slot for now.
This commit is contained in:
maziggy 2026-06-09 10:43:07 +02:00
parent fdcc063d9f
commit 85fbd7fc35
11 changed files with 175 additions and 2 deletions

View file

@ -199,6 +199,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
"auto_off_after": item.auto_off_after,
"manual_start": item.manual_start,
"filament_short": bool(item.filament_short),
"skip_filament_check": bool(item.skip_filament_check),
"ams_mapping": ams_mapping_parsed,
"plate_id": item.plate_id,
"bed_levelling": item.bed_levelling,
@ -539,6 +540,7 @@ async def add_to_queue(
require_previous_success=data.require_previous_success,
auto_off_after=data.auto_off_after,
manual_start=data.manual_start,
skip_filament_check=data.skip_filament_check,
ams_mapping=ams_mapping_json,
plate_id=data.plate_id,
bed_levelling=data.bed_levelling,
@ -1096,6 +1098,12 @@ async def start_queue_item(
# Print Anyway / no deficit: clear the flags and let the scheduler dispatch.
item.manual_start = False
item.filament_short = False
# Persist the user's "Print Anyway" decision so the scheduler does not
# immediately re-flag this item on the next tick (#1698-followup). The
# pre-fix behaviour bounced between "user said anyway" and
# "scheduler re-blocked on same deficit" forever.
if skip_filament_check:
item.skip_filament_check = True
# Credit the clicker as the item's owner when no prior owner is set —
# VP-uploaded queue items arrive over FTP unattributed, so without this
# the print log's User column stays blank even when auth is on

View file

@ -935,6 +935,17 @@ async def run_migrations(conn):
else:
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT false")
# Migration: skip_filament_check flag on print_queue (#1698-followup).
# Persists the user's "Print Anyway" acknowledgement so the scheduler
# doesn't re-flag the item every tick after they've confirmed dispatch
# despite the deficit warning. Set from the start route's skip_filament_check
# query param and from PrintModal at queue-creation time. Postgres / SQLite
# boolean default branch matches filament_short above.
if is_sqlite():
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT 0")
else:
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT false")
# Migration: Add queue_force_color_match column to virtual_printers (#1188).
# Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
# from the 3MF onto the queue item's filament_overrides so the scheduler

View file

@ -85,6 +85,14 @@ class PrintQueueItem(Base):
# block automatically.
filament_short: Mapped[bool] = mapped_column(Boolean, default=False)
# User has acknowledged the filament-shortage warning for this item
# ("Print Anyway"). Set by the start route when the user passes
# skip_filament_check=true, or at queue-creation time if PrintModal's
# frontend deficit warning was acknowledged. Survives scheduler ticks so
# the dispatch no longer bounces between "user said anyway" and
# "scheduler re-flagged" (#1698-followup).
skip_filament_check: Mapped[bool] = mapped_column(Boolean, default=False)
# Tracking
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View file

@ -28,6 +28,10 @@ class PrintQueueItemCreate(BaseModel):
require_previous_success: bool = False
auto_off_after: bool = False # Power off printer after print completes
manual_start: bool = False # Requires manual trigger to start (staged)
# Persistent "Print Anyway" acknowledgement (#1698-followup). When set,
# PrintModal already showed the deficit warning and the user confirmed,
# so the scheduler does not re-flag this item on the next tick.
skip_filament_check: bool = False
# AMS mapping: list of global tray IDs for each filament slot
# Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)
ams_mapping: list[int] | None = None
@ -96,6 +100,9 @@ class PrintQueueItemResponse(BaseModel):
# (#1496). Display-only — the ▶ click recomputes deficit against live
# spool state.
filament_short: bool = False
# User has acknowledged "Print Anyway" — scheduler skips the deficit check
# for this item (#1698-followup).
skip_filament_check: bool = False
ams_mapping: list[int] | None = None
plate_id: int | None = None # Plate ID for multi-plate 3MF files
# Print options

View file

@ -1846,6 +1846,14 @@ class PrintScheduler:
since been swapped to one with enough material clears the flag here
so the next scheduler tick dispatches it.
"""
# User has explicitly acknowledged the deficit ("Print Anyway") —
# don't re-flag, don't even compute. Without this short-circuit the
# scheduler bounces between "user said anyway" (route clears
# manual_start) and "scheduler re-blocked" (this method re-flags it
# on identical spool state) (#1698-followup).
if item.skip_filament_check:
return False
try:
deficit = await compute_deficit_for_queue_item(db, item)
except Exception as e:

View file

@ -147,6 +147,40 @@ class TestPrintQueueAPI:
assert result["status"] == "pending"
assert result["manual_start"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_with_skip_filament_check(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""PrintModal "Print Anyway" persists skip_filament_check on creation (#1698-followup)."""
printer = await printer_factory()
archive = await archive_factory()
data = {
"printer_id": printer.id,
"archive_id": archive.id,
"skip_filament_check": True,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["skip_filament_check"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_skip_filament_check_defaults_false(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Default add-to-queue has skip_filament_check=False — no silent bypass."""
printer = await printer_factory()
archive = await archive_factory()
data = {"printer_id": printer.id, "archive_id": archive.id}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["skip_filament_check"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_with_project_id(
@ -583,6 +617,53 @@ class TestQueueStartEndpoint:
# decision to print anyway.
assert called_with == {}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_start_with_skip_flag_persists_acknowledgement(
self,
async_client: AsyncClient,
queue_item_factory,
db_session,
):
"""skip_filament_check=true sets the persistent flag on the queue item
so the scheduler doesn't re-flag it on the next tick (#1698-followup).
Without persistence the route's flag-clearing only survives until the
next scheduler tick re-runs the deficit check on identical spool
state and re-promotes the item the user has to click Play+Confirm
every single tick.
"""
item = await queue_item_factory(manual_start=True, filament_short=True)
assert item.skip_filament_check is False
response = await async_client.post(f"/api/v1/queue/{item.id}/start?skip_filament_check=true")
assert response.status_code == 200
body = response.json()
assert body["skip_filament_check"] is True
await db_session.refresh(item)
assert item.skip_filament_check is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_start_without_skip_flag_does_not_set_acknowledgement(
self,
async_client: AsyncClient,
queue_item_factory,
db_session,
):
"""A successful Play click with no deficit must NOT silently set the
acknowledgement flag only an explicit Print Anyway should.
"""
item = await queue_item_factory(manual_start=False, filament_short=False)
assert item.skip_filament_check is False
response = await async_client.post(f"/api/v1/queue/{item.id}/start")
assert response.status_code == 200
await db_session.refresh(item)
assert item.skip_filament_check is False
class TestQueueCancelEndpoint:
"""Tests for the /queue/{item_id}/cancel endpoint."""

View file

@ -117,3 +117,41 @@ async def test_helper_exception_does_not_wedge_dispatch(scheduler, db_session, q
assert blocked is False
await db_session.refresh(item)
assert item.filament_short is False
@pytest.mark.asyncio
async def test_skip_filament_check_short_circuits_without_compute(scheduler, db_session, queue_item):
"""User clicked Print Anyway (skip_filament_check=True): no compute, no flag (#1698-followup).
Pre-fix the scheduler re-ran the deficit check on every tick, re-set
manual_start/filament_short to True, and the item bounced between
"user said anyway" (route clears flags) and "scheduler re-blocked"
forever. With the persistent acknowledgement flag the scheduler bails
early without even touching the deficit helper.
"""
item = await queue_item(skip_filament_check=True)
compute_mock = AsyncMock(
return_value=[
FilamentDeficit(
slot_id=1,
ams_id=0,
tray_id=0,
filament_type="PLA",
required_grams=270.0,
remaining_grams=200.0,
),
]
)
with patch(
"backend.app.services.print_scheduler.compute_deficit_for_queue_item",
compute_mock,
):
blocked = await scheduler._block_on_filament_deficit(db_session, item)
assert blocked is False
compute_mock.assert_not_awaited()
await db_session.refresh(item)
# Flags must not get re-set by the scheduler now that the user has
# acknowledged the deficit.
assert item.filament_short is False
assert item.manual_start is False

View file

@ -1898,6 +1898,11 @@ export interface PrintQueueItem {
// any required slot's grams (#1496). Surfaced on the queue row as a
// "filament short" badge; cleared on a successful ▶ click (live recheck).
filament_short: boolean;
// Persistent "Print Anyway" acknowledgement — once true the scheduler
// skips the deficit check for this item (#1698-followup). Set by the
// start route when skip_filament_check=true, or at queue creation if
// PrintModal's deficit warning was acknowledged.
skip_filament_check: boolean;
ams_mapping: number[] | null; // AMS slot mapping for multi-color prints
filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null; // Filament overrides for model-based assignment
plate_id: number | null; // Plate ID for multi-plate 3MF files
@ -1966,6 +1971,9 @@ export interface PrintQueueItemCreate {
require_previous_success?: boolean;
auto_off_after?: boolean;
manual_start?: boolean; // Requires manual trigger to start (staged)
// PrintModal "Print Anyway" on the deficit warning — persisted so the
// scheduler doesn't immediately re-flag this item (#1698-followup).
skip_filament_check?: boolean;
ams_mapping?: number[] | null; // AMS slot mapping for multi-color prints
plate_id?: number | null; // Plate ID for multi-plate 3MF files
// Print options

View file

@ -653,6 +653,10 @@ export function PrintModal({
auto_off_after: scheduleOptions.autoOffAfter,
gcode_injection: scheduleOptions.gcodeInjection,
manual_start: scheduleOptions.scheduleType === 'manual',
// When the user clicks "Print Anyway" on the frontend deficit warning,
// persist that acknowledgement so the scheduler doesn't immediately
// re-flag the item on its first dispatch tick (#1698-followup).
skip_filament_check: options?.skipFilamentCheck === true ? true : undefined,
ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
plate_id: plateOverride !== undefined ? plateOverride : selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime

File diff suppressed because one or more lines are too long

View file

@ -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-lEQPGYKn.js"></script>
<script type="module" crossorigin src="/assets/index-Dx3eHcCx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BvmIMSUd.css">
</head>
<body>