diff --git a/CHANGELOG.md b/CHANGELOG.md index b6bef66f9..35bd60e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,8 @@ All notable changes to Bambuddy will be documented in this file. - **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** — Operator-flipped "out of service" state per printer, surfaced as a wrench icon + amber pill on the card and a checkbox in the Edit Printer dialog. Requested for three real-world scenarios that all share the same shape: (1) parallel Bambuddy installs (dev + prod, primary + warm spare) where the printer rejects concurrent MQTT clients except one, leaving the others in a flicker-online state burning CPU and network; (2) printers under repair / awaiting spare parts that shouldn't accept queue jobs but should remain visible on the dashboard so they aren't forgotten; (3) temporary suspension during maintenance work. **What was already there, what was missing.** The backend field `Printer.is_active: bool` has shipped since the initial Bambuddy release — toggling it via `PATCH /printers/{id}` already disconnects MQTT (`printer_manager.disconnect_printer` at `printers.py:366`), stops the printer from being eligible for queue dispatch (`print_scheduler.py:520, 1588`, `print_queue.py:383`), excludes it from model-based filament lookups (`printers.py:197`), excludes it from metrics + diagnostic snapshots + scheduled-backup runs (`metrics.py:105`, `diagnostic_snapshot.py:126`, `github_backup.py:333`, `maintenance.py:457`), and is already honoured by PrinterSelector (filtered with a "show inactive" override, greyed + "(inactive)" label when shown). All three of Ferdi's use cases were structurally supported by `is_active` from day one. **The missing piece was UI exposure.** `grep is_active` on `PrintersPage.tsx` returned zero hits — no menu item, no edit field, no toggle. The only way to flip it was a direct API call. This change adds the surfaces that should have been there all along. **Card UI — replacement, not addition.** Per Ferdi-conversation feedback, the maintenance state replaces the print-status / cover-image container rather than stacking above it, so card heights stay identical across the grid: in expanded mode the same `` header renders an amber panel (wrench icon + "In Maintenance" + subtitle + Exit button) where the cover + progress would normally be; in compact mode a single amber pill replaces the progress bar. The header connection pill is also swapped — instead of the red "Offline" pill (which would be misleading because the disconnect is deliberate) the card shows an amber "Maintenance" pill, and the "Run Diagnostic" CTA is suppressed (that's reserved for involuntary offline triage). HMS / Queue / Firmware status pills are still gated by `status?.connected` so they fall away naturally with the MQTT disconnect. **Three entry points.** (1) Printer card three-dot overflow menu — `Enter maintenance mode` / `Exit maintenance mode` with a wrench icon, adjacent to the Edit and Reconnect actions. (2) Exit button inside the in-card amber panel, so a user noticing the card from across the room can flip back without opening the menu. (3) Checkbox in the EditPrinterModal — `Maintenance mode` with the same subtitle as the help line, so the toggle is discoverable from the edit dialog too (the checkbox is the inverse of `is_active` because the user-facing concept is "is this in maintenance" not "is it active"). **Mid-print safety prompt.** Entering maintenance mode on a printer in `RUNNING` / `PAUSE` state triggers a confirmation dialog before the toggle fires — disconnecting MQTT mid-print stops progress tracking + completion notifications for the in-flight job, which is usually NOT what the operator wants (they probably meant "after this print finishes"). Idle / FINISH / FAILED states skip the dialog and toggle directly. **What this does NOT change.** No backend change (`is_active` was already wired everywhere); no new permission (uses existing `printers:update`); no behaviour change for any other consumer (queue dispatch, scheduler, metrics, picker, backup — all already honoured `is_active`). The card stays visible on the Printers page (greyed temps/controls/fans below the amber banner) so the printer doesn't disappear from the operator's mental map — Ferdi explicitly wanted to remember it's there. Doesn't auto-pause Smart Plug logic or notification providers (would be a sensible follow-up if Ferdi asks; out of scope here to keep the diff bounded to "expose the existing gate"). The scheduled-maintenance dashboard at `/maintenance` (interval-tracked rod-cleaning / lube / belt tasks via the existing `MaintenanceHistory` and `PrinterMaintenance` models) is conceptually adjacent but operationally distinct — the dashboard tracks "this printer is due for cleaning"; Maintenance Mode tracks "this printer is currently out of service." A future "perform maintenance task → optionally enter maintenance mode while you do it" link is the natural connection but isn't wired here. **i18n.** Twelve new keys under `printers.maintenance.*` (title / subtitle / pillLabel / exitButton / menuEnter / menuExit / toastEntered / toastExited / confirmMidPrintTitle / confirmMidPrintMessage / editFieldLabel / editFieldHelp) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5228 leaves per locale, no English fallback. **Tests.** 4 new cases in `PrintersPage.test.tsx::'maintenance mode (#1476)'`: amber status panel renders with Exit button (and the regular "No active job" / "Ready to print" copy is absent — confirms the swap, not a stacked render); header pill swaps to amber Maintenance and the diagnostic CTA is suppressed; clicking Exit issues a `PATCH /printers/{id}` with `is_active: true`; active printers never show the maintenance panel. Existing test fixture (`mockPrinters`) got an explicit `is_active: true` to keep the existing 56 tests green on the new render path. **Type:** `PrinterCreate.is_active?: boolean` added to the TypeScript surface so the field flows cleanly through the existing `api.updatePrinter` helper. **Build + checks.** Full PrintersPage vitest 60/60 green; `npm run build` clean; ESLint clean; i18n parity 5228 × 11 locales green. ### Fixed +- **False-positive "Print Stopped" notification on reprint after MQTT reconnect (#1807, reported by @volodymyr-doba)** — Reporter's P1S with a heavily-queued workload sporadically fired a "Print Stopped" push notification while the print was still actively running (screenshots showed 48% progress + a "50% Complete" notification 10 minutes after the spurious stop). Caught in a support bundle: `[RECONCILE] Printer 1: synthesising missed PRINT COMPLETE for archive 31 — subtask_id changed ('1844213296' → '2103771517')` at 10:26:08, immediately after the MQTT reconnect signature (`Requesting firmware version info` + `Sending K-profile priming request`) and immediately followed by `gcode_state: RUNNING` on the same wire — proof the print never actually stopped. **Root cause.** `bambu_mqtt.py:3647` mints a fresh `submission_id` (`int(time.time() * 1000) % 2_147_483_647 or 1`) per dispatch, so the queue-reprinting flow sends a NEW `subtask_id` to the printer each time. `main.py:2564` and `main.py:2773` set `archive.subtask_id` only when the stored value was empty (`not archive.subtask_id`) — so on reprint the row kept the FIRST run's id. When MQTT reconnects mid-print (network blip, printer reboot, Bambuddy restart), `reconcile_stale_active_prints` from #1542 runs against every `status="printing"` archive; `_is_active_archive_stale` at `main.py:3722` compared the stale stored id against the printer's live id, returned `(True, "subtask_id changed (…)")`, and the synthesizer fired a `status="aborted"` payload through `on_print_complete` — which is the exact code path the "Print Stopped" notification listens to. Collateral: the in-progress queue item was marked `cancelled` because the synthesized complete killed Bambuddy's tracking state. **Fix.** Two places in `main.py` — the expected-print promotion branch (line 2564) and the duplicate-printing-archive branch (line 2773) — now update `archive.subtask_id` whenever the new effective id **differs** from the stored one, not only when the stored one is empty. The comparison-based gate preserves the noop-on-stable-push behaviour the original `not archive.subtask_id` guard provided (the same id from a repeat push doesn't trigger a rewrite) while picking up reprint dispatches that mint a fresh id. The path is already inside expected-print promotion — meaning Bambuddy itself dispatched this print in-process — so the live id genuinely belongs to this archive; the write is safe. **Why the second notification ("Nozzle/Extruder Error") in the screenshot is unrelated.** That's a separate HMS event at 10:26:25, likely a stored error code the printer flushed on reconnect. The queue scheduler correctly observed `state=RUNNING, awaiting_plate_clear=True` at 10:26:08 and did NOT try to dispatch the next queue item, so the HMS "Device is busy" reason is NOT a side-effect of the bogus reconcile — both notifications fired in the same minute because they share a common trigger (the MQTT reconnect). **Tests.** 3 new cases in `test_reprint_updates_subtask_id.py` exercising the actual `on_print_start` expected-archive path: stale stored id gets rewritten on reprint (the #1807 scenario, asserts `archive.subtask_id == "2103771517"`); first-run still sets the id on an archive with `subtask_id=None`; stable repeat-push with the same id leaves the field untouched. Mocks the full session / printer / notification / WS / smart-plug / MQTT-relay surface that `on_print_start` touches so the test stays unit-fast. Full backend `pytest -n 8`: 552/552 in 17 s on the print-lifecycle suite. Ruff clean. **Scope.** Two-line guard rewrite; no DB migration, no schema change, no notification surface change, no frontend change. The reconciler itself (the consumer of `archive.subtask_id`) stays unchanged — it was doing the right thing given the data it had. + - **SpoolBuddy "Assign to AMS" pushed Generic instead of the user's custom slicer preset (#1815, reported by @Bgabor997)** — On P1S, assigning a spool with a Bambu Cloud user preset (PFUS-prefix `slicer_filament`) via SpoolBuddy left Bambu Studio's Device tab showing "Generic " instead of the actual custom preset (e.g. "Jayo PETG HF (Custom)"). Manual "Configure" from Bambuddy's AMS card with the same preset worked correctly — reporter triple-checked the preset existed in their slicer and that Bambuddy's inventory showed the right name. **Root cause** in `slicer_filament_resolver.py:197-203`: the defensive filter that catches PFUS / PFCN cloud-preset IDs leaking into `tray_info_idx` (the printer's calibration table can't key on cloud-preset hashes) cleared `setting_id` alongside `tray_info_idx`. `setting_id` is what the slicer uses to find the actual preset, and PFUS / PFCN are VALID values there — they're only invalid as `tray_info_idx`. When the Bambu Cloud detail lookup didn't return a `filament_id` (cloud-unauth on the `on_ams_change` replay path, transient cloud failure, or older custom presets whose detail JSON omits `filament_id`), the resolver fell through to `normalize_slicer_filament` which round-tripped the PFUS as `tray_info_idx`, the defensive filter fired, BOTH fields were cleared, and the caller's generic-material fallback at `inventory.py:157-165` filled `tray_info_idx=GFG99` AND `setting_id=GFSG99` — Bambu Studio resolved the slot to Generic PETG. The manual Configure modal works because the frontend at `ConfigureAmsSlotModal.tsx:503-512` calls the cloud detail API itself, sets `trayInfoIdx` to the resolved `filament_id`, and **preserves** the `PFUS` as `setting_id` in the request — so the backend's MQTT push lands both correct fields. **Fix.** The defensive filter still clears `tray_info_idx` for PFUS / PFCN / material-name leaks, but now preserves `setting_id` when it's a valid slicer reference (`PFUS` / `PFCN` cloud user/shared preset, or `GFS` Bambu official preset). Material-name leaks (e.g. `setting_id="PETG"`) are still cleared — those are never valid slicer references. Post-fix MQTT push carries `tray_info_idx=GFG99` (generic — firmware-acceptable for HMS / drying / colour matching) AND `setting_id=PFUS` (slicer uses this to load the user's actual custom preset). **What stays the same.** Bambuddy's own AMS card still reads `tray_info_idx` from `raw_data` and displays the generic material on cloud-unauth paths — same fundamental limitation as today, because without cloud resolution the backend has no way to know the real `P*` filament_id. This is the secondary symptom the reporter mentioned ("Bambuddy configure modal also shows Generic as default"); fixing it requires a deeper layered fallback (look up `LocalPreset` by name, or query the printer's live `kprofiles`, or cache the resolved cloud detail) and is out of scope for this drop. The slicer-side fix is the user's explicit ask. **Tests.** 5 new cases in `test_slicer_filament_resolver.py`: PFUS cloud-unavailable preserves setting_id (reporter's scenario); PFCN cloud-unavailable preserves setting_id (#1648 partner-preset shape); PFUS cloud-resolved still works as before (regression guard); GFS cloud-unavailable resolves via `normalize_slicer_filament` (regression guard for the Bambu-official cloud-down path); literal material name ("PETG") still clears both (regression guard that PFUS preservation doesn't accidentally preserve material-name leaks). Full backend `pytest -n 30`: 6410/6410 in 73 s. Ruff clean. **Scope.** No DB / API surface / i18n / frontend changes. No change to the caller (`apply_spool_to_slot_via_mqtt`) — its `if tray_info_idx and not setting_id` guard at `inventory.py:172` already preserves whatever setting_id the resolver returns. No change to the manual Configure modal path (already carried both fields end-to-end). The `on_ams_change` replay path in `main.py` (which passes `current_user=None` and was the original motivator for the defensive filter) now also preserves setting_id — same desired outcome since the replay only fires when SpoolBuddy pre-assigned an empty slot and the spool was later inserted, and the slicer needs the setting_id to resolve the right preset. - **H2S active-tray highlight stuck on AMS slot 1 during external-spool prints (#1822, reported by @ojimpo)** — On H2S (single-nozzle, `n3f` AMS), prints feeding from the external spool showed AMS SLOT 1 highlighted in the UI for the entire job. Display-only — Spoolman usage credit was already correct via the #1276 `ams_mapping=[-1]→254` path — but the active-tray ring on the printer card pointed at the wrong spool. **Root cause** in `bambu_mqtt.py::_handle_ams_data`: X1C / P1S / A1 firmware correctly reports `tray_now=254` when the external spool is the active feed, so the single-nozzle branch's `0–3` passthrough never sees it. H2S firmware instead reports `tray_now=0` (the AMS's idle slot) throughout external-only prints — reporter's MQTT debug log captured 2883 pushes with `tray_now=0`, 143 with `255` (unloaded), zero with `254`. The single-nozzle branch then trusted the wire value and `state.tray_now` landed on slot 0. **Fix.** The single-nozzle branch now checks `_captured_ams_mapping` (the slicer-captured per-filament mapping that the request-topic intercept already tracks) before the existing P2S multi-AMS resolver runs. When every entry is `-1` (the print uses ONLY the external spool — `-1` is the canonical external sentinel), `state.tray_now` is promoted to `254` regardless of what the AMS dict says. The override is intentionally narrow: it only fires when the captured mapping is non-empty AND every entry equals -1. Mixed prints (`[5, -1]`) and AMS-only prints (`[5]`) are NOT overridden — reporter only confirmed the bug for the all-external case, and we have no evidence H2S misreports mid-print swaps; trusting the firmware on those paths preserves correctness for users with multi-filament setups. Prints started without a captured mapping (printer-screen start, or before Bambuddy connected to MQTT) fall through unchanged — the wrong value persists in that edge case, but no other regression. **No model gating.** Future single-nozzle models with the same firmware quirk inherit the fix for free, and printers that already report `254` correctly enter the override branch but the assignment is a no-op (assigning 254 when the wire said `tray_now=254` requires `parsed_tray_now <= 3` to be false in the first place — the branch never even reaches them). Dual-nozzle (H2D / H2C / X2D), the P2S multi-AMS local-slot resolver (#420), `tray_now > 3` (already a global ID), and `tray_now=255` (unloaded) are all unchanged. **Tests.** 7 new cases in `TestTrayNowH2SExternalSpoolOverride` pinning every limb of the contract: all-external `[-1]` promotes; multi-external `[-1, -1, -1]` also promotes; AMS-only `[5]` does NOT override; mixed `[5, -1]` does NOT override; `_captured_ams_mapping=None` does NOT override; empty list `[]` does NOT override (defensive — `all([])` returns True, so we explicitly guard); unload after override correctly transitions `254 → 255`. Adjacent single-nozzle X1E and P2S test classes stay green — they don't set `_captured_ams_mapping` so the new branch falls through to the unchanged path. Full backend `pytest -n 30`: 6405/6405 in 71 s. Ruff clean. **No frontend, schema, or API surface change** — the wire format and the `PrinterStatus.tray_now` field shape are unchanged; only the value computed for that field on H2S external-only prints is now correct. diff --git a/backend/app/main.py b/backend/app/main.py index 4aa371bb9..8d32f83bf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2561,7 +2561,16 @@ async def on_print_start(printer_id: int, data: dict): _dispatched = getattr(_client, "last_dispatch_subtask_id", None) if _client else None if _dispatched: effective_subtask_id = str(_dispatched).strip() or None - if effective_subtask_id and not archive.subtask_id: + # Update on first-set OR on reprint (the queue dispatcher mints + # a fresh subtask_id per dispatch in bambu_mqtt:3647). Skipping + # the rewrite for reprints leaves the archive holding the FIRST + # run's id; if MQTT then reconnects mid-print, the reconciler + # (#1542) compares the stale stored id against the printer's + # live id, sees a mismatch, and synthesises a bogus PRINT + # COMPLETE — exactly the false-positive "Print Stopped" reported + # in #1807. Inequality check preserves the noop-on-stable-push + # behaviour the earlier `not archive.subtask_id` guard provided. + if effective_subtask_id and archive.subtask_id != effective_subtask_id: archive.subtask_id = effective_subtask_id # #1403 follow-up: VP-queue archives are created with # printer_id=None at queue-add time (we don't know which @@ -2769,8 +2778,11 @@ async def on_print_start(printer_id: int, data: dict): ) # Track this as the active print _active_prints[(printer_id, existing_archive.filename)] = existing_archive.id - # Attach subtask_id retroactively so future restarts can resume - if subtask_id and not existing_archive.subtask_id: + # Attach subtask_id retroactively so future restarts can resume. + # Compare for inequality (not "is empty") to also pick up reprint + # dispatches that mint a fresh id — see #1807 for the bogus + # "Print Stopped" the strict-empty guard caused on reconnect. + if subtask_id and existing_archive.subtask_id != subtask_id: existing_archive.subtask_id = subtask_id await db.commit() # Also set up energy tracking if not already tracked (#941: persisted column) diff --git a/backend/tests/unit/test_reprint_updates_subtask_id.py b/backend/tests/unit/test_reprint_updates_subtask_id.py new file mode 100644 index 000000000..625d3212f --- /dev/null +++ b/backend/tests/unit/test_reprint_updates_subtask_id.py @@ -0,0 +1,223 @@ +"""Regression for #1807: false-positive "Print Stopped" notification on the +expected-archive reprint path. + +Bambuddy mints a fresh subtask_id per dispatch (``bambu_mqtt.py:3647``). On a +reprint, the archive row is reused — so the stored ``archive.subtask_id`` is +still the value from the FIRST run. The earlier ``not archive.subtask_id`` +guard at ``on_print_start`` skipped the rewrite, so the row kept the stale id. + +Then, if MQTT reconnects mid-print (which it routinely does — network blips, +printer reboots, Bambuddy restarts), ``reconcile_stale_active_prints`` (#1542) +compares the printer's live subtask_id against the stored one, sees a +mismatch, and synthesises a "missed PRINT COMPLETE" → bogus Print Stopped +notification while the print keeps running. + +The fix: update ``archive.subtask_id`` whenever the new effective id differs +from the stored one, not only when the stored one is empty. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.core.config import settings as app_settings +from backend.app.main import ( + _active_prints, + _expected_print_creators, + _expected_print_registered_at, + _expected_prints, + _print_ams_mappings, + _timelapse_baselines, + register_expected_print, +) + + +@pytest.fixture(autouse=True) +def _clear_dicts(): + _expected_prints.clear() + _expected_print_registered_at.clear() + _expected_print_creators.clear() + _print_ams_mappings.clear() + _active_prints.clear() + _timelapse_baselines.clear() + yield + _expected_prints.clear() + _expected_print_registered_at.clear() + _expected_print_creators.clear() + _print_ams_mappings.clear() + _active_prints.clear() + _timelapse_baselines.clear() + + +def _patches(): + return ( + patch("backend.app.main.async_session"), + patch("backend.app.main.notification_service"), + patch("backend.app.main.smart_plug_manager"), + patch("backend.app.main.ws_manager"), + patch("backend.app.main.printer_manager"), + patch("backend.app.main.mqtt_relay"), + patch("backend.app.main._record_energy_start", new_callable=AsyncMock), + patch("backend.app.main._load_objects_from_archive"), + patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock), + patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock), + patch( + "backend.app.main._list_timelapse_videos", + new=AsyncMock(return_value=([], "/timelapse")), + ), + ) + + +def _build_mocks(mock_printer, mock_archive): + def execute_router(stmt, *args, **kwargs): + sql = str(stmt).lower() + if "from printers" in sql or "from printer " in sql: + return MagicMock( + scalar_one_or_none=MagicMock(return_value=mock_printer), + scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))), + ) + if "from print_archives" in sql or "from print_archive" in sql: + return MagicMock( + scalar_one_or_none=MagicMock(return_value=mock_archive), + scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))), + ) + return MagicMock( + scalar_one_or_none=MagicMock(return_value=None), + scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))), + ) + + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock() + mock_session.execute = AsyncMock(side_effect=execute_router) + mock_session.commit = AsyncMock() + return mock_session + + +def _make_archive(*, archive_id: int, stored_subtask_id: str | None): + mock_archive = MagicMock() + mock_archive.id = archive_id + mock_archive.filename = "Ikea-drybox_silicabox.3mf" + mock_archive.subtask_id = stored_subtask_id + mock_archive.print_time_seconds = None + mock_archive.created_by_id = None + mock_archive.printer_id = 1 + mock_archive.print_name = "Ikea-drybox_silicabox" + mock_archive.status = "archived" + mock_archive.file_path = f"archives/{archive_id}/Ikea-drybox_silicabox.3mf" + mock_archive.energy_start_kwh = None + mock_archive.timelapse_path = None + return mock_archive + + +def _make_printer(): + mock_printer = MagicMock() + mock_printer.id = 1 + mock_printer.auto_archive = True + mock_printer.external_camera_enabled = False + mock_printer.external_camera_url = None + mock_printer.name = "TestP1S" + return mock_printer + + +async def _drive(tmp_path, mock_archive, mqtt_subtask_id: str | None): + """Drive ``on_print_start`` with a print-start payload carrying the + given ``subtask_id`` (the printer-echoed id at PRINT START — set by the + queue dispatcher's fresh ``submission_id``).""" + mock_printer = _make_printer() + register_expected_print(1, mock_archive.filename, archive_id=mock_archive.id, ams_mapping=None) + mock_session = _build_mocks(mock_printer, mock_archive) + + ( + async_session_p, + notif_p, + plug_p, + ws_p, + pm_p, + relay_p, + _energy, + _load_obj, + _store_spoolman, + _send_start, + _list_tl, + ) = _patches() + + with ( + async_session_p as mock_session_maker, + notif_p as mock_notif, + plug_p as mock_plug, + ws_p as mock_ws, + pm_p as mock_pm, + relay_p as mock_relay, + _energy, + _load_obj, + _store_spoolman, + _send_start, + _list_tl, + patch.object(app_settings, "base_dir", tmp_path), + ): + mock_session_maker.return_value = mock_session + mock_notif.on_print_start = AsyncMock() + mock_plug.on_print_start = AsyncMock() + mock_ws.send_print_start = AsyncMock() + mock_ws.send_archive_updated = AsyncMock() + mock_relay.on_print_start = AsyncMock() + mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123")) + # last_dispatch_subtask_id fallback shouldn't fire — MQTT carried one. + mock_pm.get_client = MagicMock(return_value=MagicMock(last_dispatch_subtask_id=None)) + + from backend.app.main import on_print_start + + await on_print_start( + 1, + { + "filename": mock_archive.filename, + "subtask_name": mock_archive.print_name, + "raw_data": {"subtask_id": mqtt_subtask_id} if mqtt_subtask_id is not None else {}, + }, + ) + + +@pytest.mark.asyncio +async def test_reprint_updates_stale_subtask_id(tmp_path): + """The #1807 case: archive stored an OLD subtask_id from the first run. + On reprint dispatch the printer echoes a fresh one — the archive's + stored id must be rewritten so the reconciler doesn't flag the live + print as stale on next MQTT reconnect.""" + archive = _make_archive(archive_id=31, stored_subtask_id="1844213296") + + await _drive(tmp_path, archive, mqtt_subtask_id="2103771517") + + assert archive.subtask_id == "2103771517", ( + "expected-archive reprint promotion must update archive.subtask_id to the " + "new dispatch id; leaving the old value lets reconcile_stale_active_prints " + "synthesise a bogus PRINT COMPLETE on the next MQTT reconnect (#1807)" + ) + + +@pytest.mark.asyncio +async def test_first_run_still_sets_subtask_id(tmp_path): + """Regression guard for the previously-correct first-run path: an + archive with no stored subtask_id must still have it written on the + first MQTT-echoed PRINT START.""" + archive = _make_archive(archive_id=99, stored_subtask_id=None) + + await _drive(tmp_path, archive, mqtt_subtask_id="2103771517") + + assert archive.subtask_id == "2103771517" + + +@pytest.mark.asyncio +async def test_stable_push_does_not_rewrite(tmp_path): + """The original `not archive.subtask_id` guard's intent was to avoid + rewriting on every push that carries the same id. The inequality check + preserves that no-op behaviour: same id in, no rewrite.""" + archive = _make_archive(archive_id=15, stored_subtask_id="2103771517") + # Replace the bare attribute with a MagicMock so we can detect any write, + # not just observe the post-call value (which would match even on a + # spurious "store the same value back" rewrite). + initial = archive.subtask_id + + await _drive(tmp_path, archive, mqtt_subtask_id="2103771517") + + assert archive.subtask_id == initial