Skip to content

Commit b3cfdb8

Browse files
authored
Add cancel support for running reports (#1819)
1 parent 567edd2 commit b3cfdb8

2 files changed

Lines changed: 141 additions & 9 deletions

File tree

lumen/ai/report.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ class Task(Viewer):
8181
running = param.Boolean(doc="""
8282
Whether the task is currently running.""")
8383

84-
status = param.Selector(objects=["idle", "running", "success", "error"], default="idle", doc="""
84+
status = param.Selector(objects=["idle", "running", "success", "error", "cancelled"], default="idle", doc="""
8585
The current status of the task.""")
8686

8787
steps_layout = param.ClassSelector(default=None, class_=(ListLike, NamedListLike), allow_None=True, doc="""
@@ -230,11 +230,14 @@ async def execute(self, context: TContext | None = None, **kwargs) -> tuple[list
230230
if not self._prepared:
231231
await self.prepare(context)
232232
views, out_context = await self._execute(context, **kwargs)
233+
except asyncio.CancelledError:
234+
self.status = "cancelled"
235+
raise
233236
except Exception:
234237
self.status = "error"
235238
raise
236239
finally:
237-
if self.status != "error":
240+
if self.status not in ("error", "cancelled"):
238241
self.status = "success"
239242
self.running = False
240243
self.out_context = out_context
@@ -411,6 +414,9 @@ async def _execute(self, context: TContext, **kwargs):
411414
new = []
412415
try:
413416
new, new_context = await self._run_task(i, task, context, **kwargs)
417+
except asyncio.CancelledError:
418+
self.status = "cancelled"
419+
raise
414420
except MissingContextError:
415421
# Re-raise MissingContextError to allow retry logic at Plan level
416422
raise
@@ -432,8 +438,8 @@ async def _execute(self, context: TContext, **kwargs):
432438
break
433439
views += new
434440
finally:
435-
self._current = i + (0 if task.status == "error" else 1)
436-
if self.status != "error":
441+
self._current = i + (0 if task.status in ("error", "cancelled") else 1)
442+
if self.status not in ("error", "cancelled"):
437443
self.status = "success"
438444
contexts = [self.context] if self.context else []
439445
contexts += [task.out_context for task in self]
@@ -845,6 +851,8 @@ class Report(TaskGroup):
845851
auto_execute = param.Boolean(default=False, doc="""
846852
If True, automatically execute the report on initialization.""")
847853

854+
_active_task = param.ClassSelector(class_=asyncio.Task, default=None, allow_None=True)
855+
848856
_tasks = param.List(item_type=Section)
849857

850858
level = 1
@@ -875,6 +883,12 @@ def _init_view(self):
875883
self._run = IconButton(
876884
icon="play_arrow", on_click=self._execute_event, margin=0, size="large",
877885
description="Execute Report", loading=self.param.running,
886+
visible=self.param._active_task.rx.is_(None),
887+
)
888+
self._stop = IconButton(
889+
icon="stop", on_click=self._handle_cancel, margin=0, size="large",
890+
description="Stop Report", color="error",
891+
visible=self.param._active_task.rx.is_not(None),
878892
)
879893
self._clear = IconButton(
880894
icon="clear", on_click=lambda _: self.reset(), margin=0, size="large",
@@ -926,6 +940,7 @@ def _init_view(self):
926940
self._menu = Row(
927941
self._header_title,
928942
self._run,
943+
self._stop,
929944
self._clear,
930945
self._collapse,
931946
self._export,
@@ -935,6 +950,7 @@ def _init_view(self):
935950
self._dial = SpeedDial(
936951
items=[
937952
{"label": "Execute Report", "icon": "play_arrow"},
953+
{"label": "Stop Report", "icon": "stop"},
938954
{"label": "Clear Report", "icon": "clear"},
939955
{"label": "Export as Notebook", "icon": "description", "format": "ipynb"},
940956
{"label": "Export as HTML", "icon": "language", "format": "html"},
@@ -972,7 +988,12 @@ def _init_view(self):
972988
async def _trigger_event(self, item: dict):
973989
icon = item["icon"]
974990
if icon == "play_arrow":
975-
await self._execute_event()
991+
if self._active_task is not None and not self._active_task.done():
992+
self._handle_cancel()
993+
else:
994+
await self._execute_event()
995+
elif icon == "stop":
996+
self._handle_cancel()
976997
elif icon == "clear":
977998
self.reset()
978999
elif icon in ("description", "language"):
@@ -990,7 +1011,7 @@ def _update_run_state(self):
9901011
@param.depends('status', 'views', watch=True)
9911012
def _update_icon_visibility(self):
9921013
"""Show/hide icons based on whether report has outputs."""
993-
has_outputs = self.status in ("success", "error") or bool(self.views)
1014+
has_outputs = self.status in ("success", "error", "cancelled") or bool(self.views)
9941015
self._clear.visible = has_outputs
9951016
self._collapse.visible = has_outputs
9961017
self._export.visible = has_outputs
@@ -1008,11 +1029,27 @@ def _update_icon_visibility(self):
10081029
}
10091030

10101031
async def _execute_event(self, event=None):
1032+
if self._active_task is not None and not self._active_task.done():
1033+
# Already running; the stop button handles cancellation
1034+
return
10111035
await asyncio.sleep(0.01) # yield the event loop to allow button loading state to update
1012-
await self.execute()
1036+
task = asyncio.create_task(self.execute())
1037+
self._active_task = task
1038+
task.add_done_callback(self._on_execute_done)
1039+
1040+
def _on_execute_done(self, task):
1041+
"""Clear the active task reference so UI controls flip back to idle."""
1042+
if task is not self._active_task:
1043+
return
1044+
self._active_task = None
1045+
1046+
def _handle_cancel(self, event=None):
1047+
"""Cancel the in-flight report execution, if any."""
1048+
if self._active_task is not None and not self._active_task.done():
1049+
self._active_task.cancel()
10131050

10141051
async def _export_report(self, item=None):
1015-
if len(self) and self.status != "success":
1052+
if len(self) and self.status not in ("success", "cancelled", "error"):
10161053
await self.execute()
10171054
fmt = item.get("format", "ipynb") if isinstance(item, dict) else "ipynb"
10181055
title = self.title or "Report"
@@ -1035,7 +1072,7 @@ def _open_settings(self, event=None):
10351072

10361073
def _populate_view(self):
10371074
self._view[:] = objects = [(task.title, task) for task in self]
1038-
has_outputs = self.status in ("success", "error") or bool(self.views)
1075+
has_outputs = self.status in ("success", "error", "cancelled") or bool(self.views)
10391076
if has_outputs:
10401077
self._view.active = list(range(len(objects)))
10411078
else:

lumen/tests/ai/test_report.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,3 +392,98 @@ async def test_report_to_html():
392392
assert "<html" in html_string.lower()
393393
assert "Hello Report" in html_string
394394
assert "Hello" in html_string
395+
396+
397+
class BlockingAction(Action):
398+
"""Action that waits on a signal before returning, so tests can deterministically cancel mid-execution."""
399+
400+
def __init__(self, started, release, **params):
401+
super().__init__(**params)
402+
self._started = started
403+
self._release = release
404+
405+
async def _execute(self, context, **kwargs):
406+
self._started.set()
407+
await self._release.wait()
408+
return [Markdown("should not render")], {"text": "never"}
409+
410+
411+
async def test_task_execute_marks_cancelled():
412+
started = asyncio.Event()
413+
release = asyncio.Event()
414+
action = BlockingAction(started, release, title="Blocks")
415+
416+
task = asyncio.create_task(action.execute())
417+
await started.wait()
418+
task.cancel()
419+
with pytest.raises(asyncio.CancelledError):
420+
await task
421+
422+
assert action.status == "cancelled"
423+
assert action.running is False
424+
425+
426+
async def test_taskgroup_cancel_propagates():
427+
started = asyncio.Event()
428+
release = asyncio.Event()
429+
tg = TaskGroup(BlockingAction(started, release, title="Blocks"), B(), title="Group")
430+
431+
task = asyncio.create_task(tg.execute())
432+
await started.wait()
433+
task.cancel()
434+
with pytest.raises(asyncio.CancelledError):
435+
await task
436+
437+
assert tg.status == "cancelled"
438+
assert tg[0].status == "cancelled"
439+
assert tg[1].status == "idle"
440+
441+
442+
async def test_report_cancel_mid_execution():
443+
started = asyncio.Event()
444+
release = asyncio.Event()
445+
report = Report(
446+
Section(BlockingAction(started, release, title="Blocks"), title="S1"),
447+
Section(B(), title="S2"),
448+
title="Cancellable",
449+
)
450+
451+
await report._execute_event()
452+
await started.wait()
453+
assert report._active_task is not None and not report._active_task.done()
454+
455+
report._handle_cancel()
456+
# Wait for the task to finish reacting to the cancel
457+
while report._active_task is not None:
458+
await asyncio.sleep(0.01)
459+
460+
assert report.status == "cancelled"
461+
assert report[0].status == "cancelled"
462+
assert report[1].status == "idle"
463+
464+
465+
async def test_report_handle_cancel_idempotent():
466+
report = Report(Section(HelloAction(), title="S1"), title="NoOp")
467+
# No task running: _handle_cancel must not raise
468+
report._handle_cancel()
469+
assert report._active_task is None
470+
471+
472+
async def test_report_completed_sections_preserved_on_cancel():
473+
started = asyncio.Event()
474+
release = asyncio.Event()
475+
report = Report(
476+
Section(HelloAction(), title="Done"),
477+
Section(BlockingAction(started, release, title="Blocks"), title="S2"),
478+
title="Partial",
479+
)
480+
481+
await report._execute_event()
482+
await started.wait()
483+
report._handle_cancel()
484+
while report._active_task is not None:
485+
await asyncio.sleep(0.01)
486+
487+
assert report[0].status == "success"
488+
assert report[1].status == "cancelled"
489+
assert report.status == "cancelled"

0 commit comments

Comments
 (0)