diff --git a/docs/dashboard.md b/docs/dashboard.md index be8d980b..09f842bb 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -18,8 +18,8 @@ kept in sync with the `HOTKEYS` keymap and the modal `BINDINGS` in | `R` | Respawn a down task (releases its claim so the runner re-spawns it) | | `p` | Open the task's URL in the browser | | `w` | Open the task's workdir (its per-task clone) in the host's file manager | -| `e` | Snooze the highlighted task for 12 hours | -| `E` | Snooze the highlighted task indefinitely | +| `e` | Snooze the highlighted task for 12 hours (cascades to its governed tasks) | +| `E` | Snooze the highlighted task indefinitely (cascades to its governed tasks) | | `a` | List the task's artifacts | | `A` | List the task's **repo's** artifacts (shared by every task in it) | | `g` | Open the repo config screen | @@ -33,6 +33,14 @@ kept in sync with the `HOTKEYS` keymap and the modal `BINDINGS` in `x` (drop) is the only state transition the dashboard drives; every other transition starts a new agentic turn and is triggered from inside the container. +Snoozing mutes a task — the row dims and its turn cell reads `snoozed · Nh left` — and it +**cascades down the governance tree**: every non-terminal task governed (transitively) by the +snoozed one records the same deadline, so muting a governor mutes its whole ensemble. Un-snoozing +the governor (`e` again while the snooze is active) clears theirs too; a child's own longer snooze +is overwritten by its governor's. Nothing cascades upward — `e` on a governed task mutes that task +alone. Tasks created under an already-snoozed governor don't inherit it; snooze the governor again +to re-cascade. + ## Navigation | Key | Action | diff --git a/src/panopticon/taskservice/service.py b/src/panopticon/taskservice/service.py index a54b08d8..a8caa7b9 100644 --- a/src/panopticon/taskservice/service.py +++ b/src/panopticon/taskservice/service.py @@ -637,13 +637,51 @@ async def set_snooze(self, task_id: str, until: str | None) -> Task: The value is stored verbatim (any ISO-8601 string, or ``None`` to clear); whether a finite deadline is active is decided by the dashboard alone. Leaves ``state``/``turn``/``blocked`` untouched — a plain recorded fact, like the url. + + Snoozing **cascades down the governance tree**: every non-terminal task governed + (transitively) by this one records the same value, so muting a governor mutes its whole + ensemble — and clearing the governor's snooze clears theirs. Nothing cascades upward: a + child's snooze is its own. """ task = await self.get_task(task_id) task.snoozed_until = until await self._save_task(task) _log.debug("task %s: snoozed_until → %s", task_id, until) + await self._cascade_snooze_governed(task.id, until) return task + async def _cascade_snooze_governed(self, governor_id: str, until: str | None) -> None: + """Record ``until`` on every non-terminal task governed (transitively) by governor_id. + + Terminal tasks are skipped — a finished task demands no attention to mute — but the walk + still descends *through* them, so a live grandchild under a completed child is not shielded + from its governor's snooze. A child already holding ``until`` is left alone (no ``updated_at`` + churn on a repeated snooze), and ``seen`` guards the walk against a governance cycle. + """ + children: dict[str, list[str]] = {} + for task in await self._store.list_tasks_summary(): + if task.governor_task_id is not None: + children.setdefault(task.governor_task_id, []).append(task.id) + seen = {governor_id} + queue = list(children.get(governor_id, ())) + count = 0 + while queue: + child_id = queue.pop(0) + if child_id in seen: + continue + seen.add(child_id) + queue.extend(children.get(child_id, ())) + # Re-read the full record: the summary rows above carry no history, so they must never + # be persisted over a stored task. + child = await self.get_task(child_id) + if child.state in TERMINAL_LABELS or child.snoozed_until == until: + continue + child.snoozed_until = until + await self._save_task(child) + count += 1 + if count: + _log.info("task %s: cascade-snoozed %d governed task(s)", governor_id, count) + async def set_sort_weight(self, task_id: str, sort_weight: int) -> Task: """Set the task's dashboard sort weight (default 0; higher sorts first). diff --git a/tests/taskservice/test_api.py b/tests/taskservice/test_api.py index b93e7ba5..6c261796 100644 --- a/tests/taskservice/test_api.py +++ b/tests/taskservice/test_api.py @@ -439,6 +439,25 @@ def test_set_snooze_records_deadline_verbatim(client: TestClient) -> None: assert cleared.json()["snoozed_until"] is None +def test_set_snooze_cascades_to_governed_tasks_over_rest(client: TestClient) -> None: + gov_id = _new_task(client) + child = client.post( + "/tasks", json={"repo_id": "r1", "workflow": "spike", "governor_task_id": gov_id} + ) + assert child.status_code == 201, child.text + child_id = child.json()["id"] + + snoozed = client.put(f"/tasks/{gov_id}/snooze", json={"until": "2026-08-06T03:00:00+00:00"}) + assert snoozed.json()["snoozed_until"] == "2026-08-06T03:00:00+00:00" + # the response carries the snoozed task; the ensemble's mute is read back off each child + child_body = client.get(f"/tasks/{child_id}").json() + assert child_body["snoozed_until"] == "2026-08-06T03:00:00+00:00" + assert child_body["state"] == "ITERATING" # a plain recorded fact — lifecycle untouched + + client.put(f"/tasks/{gov_id}/snooze", json={"until": None}) + assert client.get(f"/tasks/{child_id}").json()["snoozed_until"] is None + + def test_set_sort_weight_over_rest(client: TestClient) -> None: task_id = _new_task(client) before = client.get(f"/tasks/{task_id}").json() diff --git a/tests/taskservice/test_service.py b/tests/taskservice/test_service.py index 45fba023..b811442f 100644 --- a/tests/taskservice/test_service.py +++ b/tests/taskservice/test_service.py @@ -275,6 +275,105 @@ async def test_blocked_marker_survives_turn_flips(tmp_path: Path) -> None: assert (await svc.set_blocked(task.id, False)).blocked is False # cleared only explicitly +# -- snooze: an operator mute that cascades down the governance tree ---------------- + + +_UNTIL = "2026-08-06T03:00:00+00:00" + + +async def test_snooze_cascades_to_governed_children(tmp_path: Path) -> None: + svc = await make_service(tmp_path) + gov = await svc.create_task("r1", "spike") + child_a = await svc.create_task("r1", "spike", governor_task_id=gov.id) + child_b = await svc.create_task("r1", "spike", governor_task_id=gov.id) + ungoverned = await svc.create_task("r1", "spike") + + snoozed = await svc.set_snooze(gov.id, _UNTIL) + + assert snoozed.snoozed_until == _UNTIL + assert (await svc.get_task(child_a.id)).snoozed_until == _UNTIL + assert (await svc.get_task(child_b.id)).snoozed_until == _UNTIL + assert (await svc.get_task(ungoverned.id)).snoozed_until is None # not in the ensemble + + +async def test_snooze_cascades_through_nested_governors(tmp_path: Path) -> None: + svc = await make_service(tmp_path) + gov = await svc.create_task("r1", "spike") + child = await svc.create_task("r1", "spike", governor_task_id=gov.id) + grandchild = await svc.create_task("r1", "spike", governor_task_id=child.id) + + await svc.set_snooze(gov.id, _UNTIL) + + assert (await svc.get_task(child.id)).snoozed_until == _UNTIL + assert (await svc.get_task(grandchild.id)).snoozed_until == _UNTIL + + +async def test_snooze_skips_terminal_children_but_descends_through_them(tmp_path: Path) -> None: + svc = await make_service(tmp_path) + gov = await svc.create_task("r1", "spike") + done = await svc.create_task("r1", "spike", governor_task_id=gov.id) + live_sibling = await svc.create_task("r1", "spike", governor_task_id=gov.id) + under_done = await svc.create_task("r1", "spike", governor_task_id=done.id) + await svc.set_state(done.id, Complete.label) # finished before the governor was snoozed + + await svc.set_snooze(gov.id, _UNTIL) + + assert (await svc.get_task(done.id)).snoozed_until is None # nothing to mute + assert (await svc.get_task(live_sibling.id)).snoozed_until == _UNTIL + # A finished intermediate doesn't shield the live work below it. + assert (await svc.get_task(under_done.id)).snoozed_until == _UNTIL + + +async def test_clearing_a_governors_snooze_clears_its_ensemble(tmp_path: Path) -> None: + svc = await make_service(tmp_path) + gov = await svc.create_task("r1", "spike") + child = await svc.create_task("r1", "spike", governor_task_id=gov.id) + await svc.set_snooze(child.id, "9999-12-31T23:59:59+00:00") # its own, longer snooze + await svc.set_snooze(gov.id, _UNTIL) + assert (await svc.get_task(child.id)).snoozed_until == _UNTIL # overwritten by the governor + + cleared = await svc.set_snooze(gov.id, None) + + assert cleared.snoozed_until is None + assert (await svc.get_task(child.id)).snoozed_until is None + + +async def test_snoozing_a_child_leaves_its_governor_and_siblings_alone(tmp_path: Path) -> None: + svc = await make_service(tmp_path) + gov = await svc.create_task("r1", "spike") + child = await svc.create_task("r1", "spike", governor_task_id=gov.id) + sibling = await svc.create_task("r1", "spike", governor_task_id=gov.id) + + await svc.set_snooze(child.id, _UNTIL) # nothing cascades upward or sideways + + assert (await svc.get_task(gov.id)).snoozed_until is None + assert (await svc.get_task(sibling.id)).snoozed_until is None + + +async def test_snooze_cascade_terminates_on_a_governance_cycle(tmp_path: Path) -> None: + svc = await make_service(tmp_path) + first = await svc.create_task("r1", "spike") + second = await svc.create_task("r1", "spike", governor_task_id=first.id) + await svc.set_governor(first.id, second.id) # a cycle: set_governor doesn't reject one + + await svc.set_snooze(first.id, _UNTIL) # must not recurse forever + + assert (await svc.get_task(first.id)).snoozed_until == _UNTIL + assert (await svc.get_task(second.id)).snoozed_until == _UNTIL + + +async def test_recascading_an_unchanged_snooze_leaves_children_untouched(tmp_path: Path) -> None: + svc = await make_service(tmp_path) + gov = await svc.create_task("r1", "spike") + child = await svc.create_task("r1", "spike", governor_task_id=gov.id) + await svc.set_snooze(gov.id, _UNTIL) + stamped = (await svc.get_task(child.id)).updated_at + + await svc.set_snooze(gov.id, _UNTIL) # same value again + + assert (await svc.get_task(child.id)).updated_at == stamped # no updated_at churn + + async def test_create_task_seeds_sort_weight(tmp_path: Path) -> None: svc = await make_service(tmp_path) task = await svc.create_task("r1", "spike", sort_weight=8) diff --git a/tests/terminal/test_dashboard.py b/tests/terminal/test_dashboard.py index 4302c908..91137150 100644 --- a/tests/terminal/test_dashboard.py +++ b/tests/terminal/test_dashboard.py @@ -646,6 +646,30 @@ async def test_active_snooze_dims_the_row_and_labels_the_turn_cell() -> None: assert cell._spans and all(s.style == "dim" for s in cell._spans) +async def test_cascaded_snooze_mutes_a_governed_child_row() -> None: + # The task service cascades a governor's snooze onto its governed tasks, so a child arrives + # carrying the deadline on its own record — the display path needs no governor lookup. + governor = {**_TASK, "id": "gov", "slug": "orchestrator", "snoozed_until": _at(4)} + child = { + **_TASK, + "id": "wrk", + "slug": "worker", + "governor_task_id": "gov", + "snoozed_until": _at(4), + } + app = Dashboard(_FakeClient([governor, child]), now=lambda: _NOW) # type: ignore[arg-type] + async with app.run_test() as pilot: + await pilot.pause() + table = app.query_one("#tasks", DataTable) + table.move_cursor(row=table.get_row_index("gov")) + await pilot.press("enter") # governors start collapsed — expand to see the child row + await pilot.pause() + row = table.get_row("wrk") + assert row[1].plain == "snoozed · 4h left" # the child's turn cell carries the label too + for cell in row: # and the whole child row is muted, tree prefix and all + assert cell._spans and all(s.style == "dim" for s in cell._spans) + + async def test_expired_snooze_resumes_normal_presentation_without_mutating() -> None: task = {**_TASK, "snoozed_until": _at(-1)} # deadline already passed at _NOW client = _FakeClient([task])