Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down
38 changes: 38 additions & 0 deletions src/panopticon/taskservice/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
19 changes: 19 additions & 0 deletions tests/taskservice/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
99 changes: 99 additions & 0 deletions tests/taskservice/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions tests/terminal/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading