From 4937e4ea72992c3a764b1aa0cb011eb39fd7f3af Mon Sep 17 00:00:00 2001 From: Panopticon Agent Date: Wed, 5 Aug 2026 17:43:20 +0000 Subject: [PATCH] Sort actively-snoozed roots to the end of the active section (REQ-038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An actively-snoozed, ungoverned task is not demanding operator attention, so demote it to the end of the active section: after ordinary non-terminal tasks but still above COMPLETE/DROPPED. _make_sort_key now takes the display clock and its leading element becomes a three-way section int (0 active, 1 snoozed-active root, 2 terminal) instead of the is_terminal bool. The demotion is gated on having no governor, so a snooze never splits an ensemble — governed children stay adjacent to their governor via the later grouping step, and a snoozed governor root carries its children down with it. With now=None no task is treated as snoozed, preserving prior ordering. Co-Authored-By: Claude Opus 4.8 --- src/panopticon/terminal/dashboard.py | 26 ++++++-- tests/terminal/test_dashboard.py | 88 ++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/src/panopticon/terminal/dashboard.py b/src/panopticon/terminal/dashboard.py index f4ce28e7..e36ff779 100644 --- a/src/panopticon/terminal/dashboard.py +++ b/src/panopticon/terminal/dashboard.py @@ -110,10 +110,17 @@ def _make_sort_key( by_updated: bool = False, -) -> Callable[[JsonObj], tuple[bool, bool, float, str]]: + now: datetime | None = None, +) -> Callable[[JsonObj], tuple[int, bool, float, str]]: """Return a sort key function for the task table. - 1. non-terminal before terminal — COMPLETE/DROPPED sink to the bottom. + 1. section: active (0) before snoozed-active roots (1) before terminal (2). An actively + snoozed, ungoverned task is not demanding attention, so it sinks to the end of the active + section — after ordinary non-terminal tasks but still above COMPLETE/DROPPED. Requires + ``now`` (the display clock); with ``now=None`` no task is treated as snoozed, so the section + collapses to the original active-before-terminal split. Governed children are exempt so an + ensemble is never split by a child's snooze — they stay adjacent to their governor via the + later grouping step. 2. turn priority: for active tasks the user's turn comes first (operator action needed); for terminal tasks the agent's turn comes first (task just finished). 3. timestamp: @@ -124,8 +131,15 @@ def _make_sort_key( 4. id as a stable tiebreaker. """ - def key(task: JsonObj) -> tuple[bool, bool, float, str]: + def key(task: JsonObj) -> tuple[int, bool, float, str]: is_terminal = task["state"] in TERMINAL_LABELS + is_snoozed_root = ( + not is_terminal + and now is not None + and not task.get("governor_task_id") + and _snooze_label(task, now) is not None + ) + section = 2 if is_terminal else int(is_snoozed_root) # 0 active, 1 snoozed root, 2 terminal turn_first = "agent" if is_terminal else "user" turn_after_priority = task["turn"] != turn_first # False (priority) sorts before True if is_terminal or by_updated: @@ -142,7 +156,7 @@ def key(task: JsonObj) -> tuple[bool, bool, float, str]: except ValueError: ts = 0.0 return ( - is_terminal, # False (active) before True (terminal) + section, # 0 active, 1 snoozed-active root, 2 terminal turn_after_priority, # priority turn sorts first within each section ts, task["id"], # stable tiebreaker @@ -1759,7 +1773,9 @@ def action_refresh(self) -> None: selected = self._current # keep the operator's highlight across the rebuild (feed refresh) display_now = self._now() # one clock read per repaint drives every snooze label/dimming table.clear() - ordered = sorted(self._client.list_tasks(), key=_make_sort_key(self._sort_by_updated)) + ordered = sorted( + self._client.list_tasks(), key=_make_sort_key(self._sort_by_updated, display_now) + ) new_multi_runner = ( len({r.get("host") for r in self._client.live_runners() if r.get("host")}) > 1 ) diff --git a/tests/terminal/test_dashboard.py b/tests/terminal/test_dashboard.py index 8f851025..b36de211 100644 --- a/tests/terminal/test_dashboard.py +++ b/tests/terminal/test_dashboard.py @@ -2441,6 +2441,94 @@ def test_group_by_governor_tree_connectors_nested() -> None: assert terminal == [] +# --- snooze demotion in the sort key (REQ-038): active > snoozed-active root > terminal ---------- + + +def test_snoozed_root_sorts_after_active_before_terminal_both_modes() -> None: + # An actively-snoozed ungoverned task sinks to the end of the active section — after ordinary + # non-terminal tasks but still above COMPLETE/DROPPED — in BOTH sort modes. + active = {**_TASK, "id": "act", "slug": "active", "created_at": _at(-1)} + snoozed = { + **_TASK, + "id": "snz", + "slug": "snoozed", + "snoozed_until": _at(4), + "created_at": _at(-2), + } + terminal = {**_TASK, "id": "trm", "slug": "done", "state": "COMPLETE", "created_at": _at(-3)} + for by_updated in (False, True): + order = [ + t["id"] + for t in sorted([snoozed, terminal, active], key=_make_sort_key(by_updated, _NOW)) + ] + assert order == ["act", "snz", "trm"], f"by_updated={by_updated}" + + +def test_expired_snooze_keeps_ordinary_active_ordering() -> None: + # An expired snooze is inactive → the task is NOT demoted; it sorts as an ordinary active task + # (section 0), unlike a live snooze (section 1). + key = _make_sort_key(now=_NOW) + expired = {**_TASK, "id": "exp", "snoozed_until": _at(-1)} # deadline already passed + live = {**_TASK, "id": "liv", "snoozed_until": _at(4)} + plain = {**_TASK, "id": "pln"} + assert key(expired)[0] == 0 # not demoted + assert key(plain)[0] == 0 + assert key(live)[0] == 1 # demoted + + +def test_snooze_demotion_ignores_now_none() -> None: + # Regression guard: with no display clock, no task is treated as snoozed, so the section + # collapses to the pre-snooze active(0)-before-terminal(2) split — identical ordering to before. + key = _make_sort_key(now=None) + snoozed = {**_TASK, "id": "snz", "snoozed_until": _at(4)} + plain = {**_TASK, "id": "pln"} + terminal = {**_TASK, "id": "trm", "state": "DROPPED"} + assert key(snoozed)[0] == key(plain)[0] == 0 # snooze ignored → not demoted + assert key(terminal)[0] == 2 + + +def test_snoozed_governed_child_does_not_split_ensemble() -> None: + # A snooze on a governed child must NOT demote it out of its ensemble — the exemption is gated + # on having no governor. The child stays adjacent to its (unsnoozed) governor. + governor = { + **_TASK, + "id": "gov", + "slug": "orch", + "governor_task_id": None, + "created_at": _at(-1), + } + child = { + **_TASK, + "id": "chd", + "slug": "worker", + "governor_task_id": "gov", + "snoozed_until": _at(4), # actively snoozed, but governed → not demoted + } + other = {**_TASK, "id": "oth", "slug": "solo", "governor_task_id": None, "created_at": _at(-2)} + sorted_tasks = sorted([governor, child, other], key=_make_sort_key(now=_NOW)) + active, terminal = _group_by_governor(sorted_tasks) + assert [(t["id"], p) for t, p in active] == [("gov", ""), ("chd", "└─ "), ("oth", "")] + assert terminal == [] + + +def test_snoozed_governor_root_carries_children_below_active() -> None: + # A snoozed governor *root* is demoted to the end of the active section, carrying its children + # with it (the ensemble travels as a unit, ordered by the root's key). + governor = { + **_TASK, + "id": "gov", + "slug": "orch", + "governor_task_id": None, + "snoozed_until": _at(4), + } + child = {**_TASK, "id": "chd", "slug": "worker", "governor_task_id": "gov"} + other = {**_TASK, "id": "oth", "slug": "solo", "governor_task_id": None} # ordinary active root + sorted_tasks = sorted([governor, child, other], key=_make_sort_key(now=_NOW)) + active, terminal = _group_by_governor(sorted_tasks) + assert [(t["id"], p) for t, p in active] == [("oth", ""), ("gov", ""), ("chd", "└─ ")] + assert terminal == [] + + def test_slug_cell_prefix_tree_connectors() -> None: task = {**_TASK, "slug": "worker", "memo": None} assert _slug_cell(task).plain == "worker" # no prefix (root)