Fix notifications skipped during state snapshots
Capture each notification payload together with its GUID and update cursor under the notification manager lock. This prevents concurrent notify_user events from advancing the WebUI cursor without delivering the corresponding toast.\n\nAdd regression coverage for notification cursor continuity and update the helper contracts.
Alessandro committed
Jul 12, 2026 at 12:46 UTC
5c1d50f92888ef007798e23455732330310cccec
5 files changed
+34
-12
helpers/notification.py
+12
-9
@@ -156,21 +156,24 @@ class NotificationManager:
156
return [n for n in self.notifications if n.timestamp >= cutoff]
157
158
def output(self, start: int | None = None, end: int | None = None) -> list[dict]:
159
+ return self.output_with_state(start, end)[0]
160
+
161
+ def output_with_state(
162
+ self, start: int | None = None, end: int | None = None
163
+ ) -> tuple[list[dict], str, int]:
164
with self._lock:
165
if start is None:
166
start = 0
167
if end is None:
168
end = len(self.updates)
169
updates = self.updates[start:end]
165
- notifications = list(self.notifications)
166
-
167
- out = []
168
- seen = set()
169
- for update in updates:
170
- if update not in seen and update < len(notifications):
171
- out.append(notifications[update].output())
172
- seen.add(update)
173
- return out
170
+ out = []
171
+ seen = set()
172
+ for update in updates:
173
+ if update not in seen and update < len(self.notifications):
174
+ out.append(self.notifications[update].output())
175
+ seen.add(update)
176
+ return out, self.guid, len(self.updates)
177
178
def output_all(self) -> list[dict]:
179
with self._lock:
helpers/notification.py.dox.md
+2
@@ -21,6 +21,7 @@
21
- `add_notification(self, type: NotificationType, priority: NotificationPriority, message: str, title: str=..., detail: str=..., display_time: int=..., group: str=..., id: str=...) -> NotificationItem`
22
- `get_recent_notifications(self, seconds: int=...) -> list[NotificationItem]`
23
- `output(self, start: int | None=..., end: int | None=...) -> list[dict]`
24
+ - `output_with_state(self, start: int | None=..., end: int | None=...) -> tuple[list[dict], str, int]`
25
- `output_all(self) -> list[dict]`
26
- `mark_read_by_ids(self, notification_ids: list[str]) -> int`
27
- `update_item(self, no: int, **kwargs) -> None`
@@ -30,6 +31,7 @@
31
32
- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
33
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
34
+- Notification payloads, GUIDs, and update cursors are captured under one lock so WebUI snapshots cannot skip notifications created during snapshot assembly.
35
- Observed side-effect areas: filesystem deletion, settings/state persistence.
36
- Imported dependency areas include: `dataclasses`, `datetime`, `enum`, `helpers.localization`, `threading`, `uuid`.
37
helpers/state_snapshot.py
+5
-3
@@ -282,7 +282,9 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1:
282
log_end = 0
283
284
notification_manager = AgentContext.get_notification_manager()
285
- notifications = notification_manager.output(start=notifications_from_no)
285
+ notifications, notifications_guid, notifications_version = (
286
+ notification_manager.output_with_state(start=notifications_from_no)
287
+ )
288
289
scheduler = TaskScheduler.get()
290
@@ -352,8 +354,8 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1:
354
"log_progress_active": bool(active_context.log.progress_active) if active_context else False,
355
"paused": active_context.paused if active_context else False,
356
"notifications": notifications,
355
- "notifications_guid": notification_manager.guid,
356
- "notifications_version": len(notification_manager.updates),
357
+ "notifications_guid": notifications_guid,
358
+ "notifications_version": notifications_version,
359
}
360
361
validate_snapshot_schema_v1(snapshot)
helpers/state_snapshot.py.dox.md
+1
@@ -41,6 +41,7 @@
41
42
- Important called helpers/classes observed in the source: `dataclass`, `_build_schema_from_typeddict`, `get_origin`, `timezone.strip`, `StateRequestV1`, `localization.get_timezone`, `localization.set_timezone`, `ctxid.strip`, `_coerce_non_negative_int`, `AgentContext.get_notification_manager`, `notification_manager.output`, `_get_agent_profile_labels`, `ctxs.sort`, `tasks.sort`, `validate_snapshot_schema_v1`, `_coerce_state_request_inputs`, `super.__init__`, `get_args`, `_annotation_to_isinstance_types`, `TypeError`.
43
- Snapshot building prunes non-running in-memory contexts that were previously saved but no longer have a `chat.json`, preventing stale sidebar rows after chat files are deleted outside `/chat_remove`.
44
+- Notification payloads use the manager's matching GUID and cursor from the same atomic read, preventing a concurrent notification from being skipped by the WebUI.
45
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
46
47
## Work Guidance
tests/test_snapshot_schema_v1.py
+14
@@ -108,3 +108,17 @@ def test_snapshot_schema_rejects_unexpected_top_level_keys():
108
109
with pytest.raises(ValueError):
110
snapshot.validate_snapshot_schema_v1(payload)
111
+
112
+
113
+def test_notification_payload_and_cursor_are_captured_together():
114
+ from helpers.notification import NotificationManager, NotificationPriority, NotificationType
115
+
116
+ manager = NotificationManager()
117
+ manager.add_notification(NotificationType.INFO, NotificationPriority.HIGH, "first")
118
+
119
+ notifications, _, version = manager.output_with_state()
120
+ manager.add_notification(NotificationType.INFO, NotificationPriority.HIGH, "second")
121
+
122
+ assert [item["message"] for item in notifications] == ["first"]
123
+ assert version == 1
124
+ assert [item["message"] for item in manager.output(start=version)] == ["second"]