From 5dc98f75fbb6efb7e659cab7bad6f6e928e161cd Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Mon, 6 Apr 2026 09:53:44 -0700 Subject: [PATCH] fix: scheduled task notifications not delivered on cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GenerationCancelled extends BaseException, not Exception, so it bypassed the except handler in _run_initial. The finally block ran but _extract_last_assistant_content returned "" (response never appended to messages), and _fire_notify_targets bailed on the empty content guard. Fixes: - Catch BaseException (not just Exception) in _run_initial so GenerationCancelled is handled and the UI state is cleaned up - Remove the empty-content suppression in _fire_notify_targets — scheduled tasks should always deliver, even with a fallback message when no output was captured --- tests/test_notify_completion.py | 7 +++++-- turnstone/server.py | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/test_notify_completion.py b/tests/test_notify_completion.py index ca536a58..981134fb 100644 --- a/tests/test_notify_completion.py +++ b/tests/test_notify_completion.py @@ -349,11 +349,14 @@ class TestFireNotifyTargets: mock_deliver.assert_not_called() @patch("turnstone.server._deliver_notification") - def test_empty_content_skipped(self, mock_deliver): + def test_empty_content_delivers_fallback(self, mock_deliver): + """Empty content should still deliver with a fallback message.""" ws = MagicMock() ws.notify_targets = '[{"channel_type":"discord","channel_id":"1"}]' _fire_notify_targets(ws, "") - mock_deliver.assert_not_called() + mock_deliver.assert_called_once() + payload = mock_deliver.call_args[0][1] + assert "no output captured" in payload["message"] @patch("turnstone.server._deliver_notification") def test_invalid_json_targets_skipped(self, mock_deliver): diff --git a/turnstone/server.py b/turnstone/server.py index 6ee43d2d..bb211e6c 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -1735,8 +1735,10 @@ def _extract_last_assistant_content(session: Any) -> str: def _fire_notify_targets(ws: Any, content: str) -> None: """Send completion notifications to all configured targets.""" - if not content or not ws.notify_targets: + if not ws.notify_targets: return + if not content: + content = "(Task completed — no output captured)" try: targets = json.loads(ws.notify_targets) @@ -2032,7 +2034,7 @@ async def create_workstream(request: Request) -> JSONResponse: def _run_initial() -> None: try: session.send(initial_message) - except Exception: + except BaseException: if isinstance(ws.ui, WebUI): ws.ui.on_stream_end() ws.ui.on_state_change("idle")