wip
CI for debye_bec / test (push) Successful in 1m0s
CI for debye_bec / test (pull_request) Successful in 1m0s

This commit is contained in:
x01da
2026-09-01 15:37:36 +02:00
parent 7c5bd4ce67
commit ac6b58197e
2 changed files with 54 additions and 70 deletions
@@ -18,8 +18,12 @@ from __future__ import annotations
from typing import Literal
from bec_lib.logger import bec_logger
from .schedule_item import ScheduleItem, ScheduleItemStatus
logger = bec_logger.logger
def protected_prefix_length(items: list[ScheduleItem]) -> int:
"""How many items, from the start, are no longer PENDING."""
@@ -41,7 +45,9 @@ def index_of(items: list[ScheduleItem], item_id: str | None) -> int | None:
return None
def pick_next_runnable(items: list[ScheduleItem]) -> ScheduleItem | Literal["stop"] | None:
def pick_next_runnable(
items: list[ScheduleItem], repeat_aborted_item
) -> ScheduleItem | Literal["stop"] | None:
"""
What the execution loop should do next: always re-derived from scratch
(never a remembered index/object) so edits made to the PENDING suffix
@@ -51,10 +57,15 @@ def pick_next_runnable(items: list[ScheduleItem]) -> ScheduleItem | Literal["sto
earlier item failed/was aborted (execution stays parked there until the
operator intervenes), or `None` if every item is COMPLETED.
"""
logger.info(f"pick next runnable, repeat abort item is {repeat_aborted_item}")
for item in items:
logger.info(f"item: {item}")
if item.status == ScheduleItemStatus.COMPLETED:
continue
if item.status in (ScheduleItemStatus.FAILED, ScheduleItemStatus.ABORTED):
if item.status == ScheduleItemStatus.ABORTED:
if not repeat_aborted_item:
continue
if item.status == ScheduleItemStatus.FAILED:
return "stop"
return item # PENDING, or RUNNING (reconciled as still active)
return item # PENDING, RUNNING or ABORTED if repeat_aborted_item is set (reconciled as still active)
return None
@@ -64,21 +64,15 @@ DEFAULT_COMMANDS = [
_ACTIVE_QUEUE_STATES = ("PENDING", "RUNNING")
_ITEM_ID_ROLE = Qt.UserRole + 1 # QListWidgetItem data role used to map a row back to an item_id
_STATUS_ICON = {
ScheduleItemStatus.PENDING: "\u23f3", # hourglass
ScheduleItemStatus.RUNNING: "\u25b6", # play
ScheduleItemStatus.COMPLETED: "\u2705", # check mark
ScheduleItemStatus.FAILED: "\u274c", # cross mark
ScheduleItemStatus.ABORTED: "\u23f9", # stop
}
# TODO: Colors should change when the item is selected, otherwise won't be readable
ICON_SIZE = 20
_ICON_MAP = {
ScheduleItemStatus.PENDING: ("hourglass", "#919090"),
ScheduleItemStatus.RUNNING: ("cycle", "#2980b9"),
ScheduleItemStatus.COMPLETED: ("check", "#27ae60"),
ScheduleItemStatus.FAILED: ("warning", "#e74c3c"),
ScheduleItemStatus.ABORTED: ("cancel", "#e74c3c"),
ScheduleItemStatus.ABORTED: ("cancel", "#e6d922"),
}
@@ -224,6 +218,8 @@ class Scheduler(BECWidget, QWidget):
# schedule (another instance of this widget, a script, ...).
self.bec_dispatcher.connect_slot(self._on_remote_update, self._endpoint)
self._closing = threading.Event()
# ------------------------------------------------------------------ #
# UI
# ------------------------------------------------------------------ #
@@ -378,8 +374,7 @@ class Scheduler(BECWidget, QWidget):
# again shortly); accepting a wholesale replacement here
# would orphan those objects. See module docstring.
logger.info(
"Ignoring remote schedule update for '%s' while a local run is in progress.",
self.schedule_name,
f"Ignoring remote schedule update for {self.schedule_name} while a local run is in progress."
)
return
self.schedule = new_schedule
@@ -438,27 +433,16 @@ class Scheduler(BECWidget, QWidget):
def _refresh_ui(self):
with self._lock:
items = list(self.schedule.items)
is_running = self.schedule.is_running
self.list_widget.blockSignals(True)
self.list_widget.clear()
selected_row = None
for row, item in enumerate(items):
# text = f"{_STATUS_ICON.get(item.status, '?')} {item.command}"
text = f"{item.command}"
icon_name, color = _ICON_MAP[item.status]
icon = material_icon(
icon_name, size=(ICON_SIZE, ICON_SIZE), color=color, convert_to_pixmap=True
)
# if item.status == ScheduleItemStatus.RUNNING:
# self._spin_anim.start()
# else:
# self._spin_anim.stop()
# self._label.setPixmap(icon)
if item.error:
text += f" ({item.error.strip().splitlines()[-1]})"
list_item = QListWidgetItem(text, self.list_widget)
@@ -472,33 +456,6 @@ class Scheduler(BECWidget, QWidget):
self._selected_item_id = None
self.list_widget.blockSignals(False)
# idx = None
# selected_item = None
# if self._selected_item_id is not None:
# for i, item in enumerate(items):
# if item.item_id == self._selected_item_id:
# idx, selected_item = i, item
# break
# # Editable = the selected item hasn't started yet. Thanks to the
# # protected-prefix invariant, everything after the first PENDING
# # item is guaranteed PENDING too, so a plain index/status check is
# # enough here - see module docstring.
# can_edit_selected = (
# selected_item is not None and selected_item.status == ScheduleItemStatus.PENDING
# )
# self.run_btn.setEnabled(not is_running)
# # Adding is always safe: new items are clamped into the PENDING
# # suffix regardless of what's selected (see add_item()).
# self.add_btn.setEnabled(True)
# self.edit_btn.setEnabled(can_edit_selected)
# self.delete_btn.setEnabled(can_edit_selected)
# self.move_up_btn.setEnabled(can_edit_selected and idx not in (None, 0))
# self.move_down_btn.setEnabled(
# can_edit_selected and idx is not None and idx < len(items) - 1
# )
if self._guard.enabled:
state = "OK" if self._guard.is_clear() else "PAUSED - waiting to resume"
value = self._guard.current_value
@@ -540,7 +497,6 @@ class Scheduler(BECWidget, QWidget):
@SafeSlot()
def _on_selection_changed(self, row: int):
logger.info("On selection changed")
item = self.list_widget.item(row) if row >= 0 else None
self._selected_item_id = item.data(_ITEM_ID_ROLE) if item is not None else None
self._update_buttons()
@@ -695,12 +651,26 @@ class Scheduler(BECWidget, QWidget):
with self._lock:
if self.schedule.is_running:
return
repeat_aborted_item = False
if any(item.status == ScheduleItemStatus.ABORTED for item in self.schedule.items):
repeat_aborted_item = (
QMessageBox.question(
self,
"Repeat aborted scan",
"Would you like to repeat the aborted scan?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
== QMessageBox.StandardButton.Yes
)
self._abort_requested = False
self.schedule.is_running = True
self._persist_locked()
self.schedule_changed.emit()
self.submit_task(
self._run_all, on_complete=self._on_run_finished, on_failed=self._on_run_failed
self._run_all,
repeat_aborted_item,
on_complete=self._on_run_finished,
on_failed=self._on_run_failed,
)
@SafeSlot()
@@ -869,16 +839,16 @@ class Scheduler(BECWidget, QWidget):
# the `schedule_changed` Qt signal so they are marshalled back onto
# the GUI thread instead of touching widgets directly)
# ------------------------------------------------------------------ #
def _pick_next_runnable_locked(self):
def _pick_next_runnable_locked(self, repeat_aborted_item):
"""Must be called while holding `self._lock`; see `schedule_logic.pick_next_runnable`."""
return pick_next_runnable(self.schedule.items)
return pick_next_runnable(self.schedule.items, repeat_aborted_item)
def _run_all(self):
def _run_all(self, repeat_aborted_item):
logger.info("_run_all was called")
namespace = {"scans": self.scans, "dev": self.dev}
while not self._abort_requested:
with self._lock:
next_item = self._pick_next_runnable_locked()
next_item = self._pick_next_runnable_locked(repeat_aborted_item)
if next_item is None or next_item == "stop":
logger.info("Next item is None or stop")
break
@@ -891,7 +861,7 @@ class Scheduler(BECWidget, QWidget):
logger.info("in _run_all, set is_running to false")
self.schedule.is_running = False
self._persist_locked()
self.schedule_changed.emit()
self._emit_schedule_changed()
def _execute_item(self, item: ScheduleItem, namespace: dict):
if item.kind == "scan":
@@ -899,7 +869,7 @@ class Scheduler(BECWidget, QWidget):
# the guard is clear or the operator aborts. See "Guard
# interaction" in the module docstring.
cleared = self._guard.wait_until_clear(should_abort=lambda: self._abort_requested)
self.schedule_changed.emit()
self._emit_schedule_changed()
if not cleared:
return
@@ -908,7 +878,7 @@ class Scheduler(BECWidget, QWidget):
item.error = None
item.started_at = time.time()
self._persist_locked()
self.schedule_changed.emit()
self._emit_schedule_changed()
try:
report = eval(
@@ -964,7 +934,7 @@ class Scheduler(BECWidget, QWidget):
final_status = item.status
self._current_report = None
self._current_item_kind = None
self.schedule_changed.emit()
self._emit_schedule_changed()
if final_status in (ScheduleItemStatus.COMPLETED, ScheduleItemStatus.FAILED):
notify_item_finished(
@@ -986,7 +956,7 @@ class Scheduler(BECWidget, QWidget):
item.status = ScheduleItemStatus.COMPLETED
item.finished_at = time.time()
self._persist_locked()
self.schedule_changed.emit()
self._emit_schedule_changed()
return
time.sleep(0.5)
@@ -1002,13 +972,16 @@ class Scheduler(BECWidget, QWidget):
self._persist_locked()
self._refresh_ui()
# ------------------------------------------------------------------ #
# cleanup
# ------------------------------------------------------------------ #
def _emit_schedule_changed(self):
if self._closing.is_set():
return
self.schedule_changed.emit()
def cleanup(self):
# Note: we deliberately do NOT abort a running schedule here. The
# whole point of the Redis-backed design is that closing this
# widget must not interrupt anything already submitted to the
# scan/device server - see the module docstring.
# The schedule itself must continue running after the widget is closed.
# We only tell the worker that the UI/Qt object is no longer available.
self._closing.set()
self._guard.cleanup()
super().cleanup() # also disconnects all bec_dispatcher subscriptions for us
super().cleanup()