diff --git a/apps/api/src/services/lifecycle_hooks.py b/apps/api/src/services/lifecycle_hooks.py index 553fec0..17c07e8 100644 --- a/apps/api/src/services/lifecycle_hooks.py +++ b/apps/api/src/services/lifecycle_hooks.py @@ -118,6 +118,14 @@ async def publish_lifecycle_event( await event_bus.publish(event_type, payload) # Create notification for instance owner (fire-and-forget) + # Skip intermediate "starting" notifications — only notify on terminal states + # (failed or successful attempts) + _is_starting_intermediate = event_type == "instance.started" and ( + status or instance.status + ) == "starting" + if _is_starting_intermediate: + return + severity = "error" if event_type == "instance.error" else "info" title = _derive_title(event_type) diff --git a/apps/api/tests/integration/test_notifications_lifecycle.py b/apps/api/tests/integration/test_notifications_lifecycle.py index e6e9cfa..3e76fac 100644 --- a/apps/api/tests/integration/test_notifications_lifecycle.py +++ b/apps/api/tests/integration/test_notifications_lifecycle.py @@ -88,12 +88,12 @@ async def test_instance(db_session: AsyncSession) -> ToolInstance: @pytest.mark.asyncio @pytest.mark.integration -async def test_lifecycle_event_creates_notification( +async def test_lifecycle_started_intermediate_skips_notification( db_session: AsyncSession, event_bus: InstanceEventBus, test_instance: ToolInstance, ) -> None: - """Triggering a lifecycle event creates a notification for the instance owner.""" + """Intermediate 'starting' state does NOT create a notification.""" received: list[InstanceEventPayload] = [] def subscriber(payload: InstanceEventPayload) -> None: @@ -109,13 +109,39 @@ async def test_lifecycle_event_creates_notification( instance=test_instance, event_type="instance.started", status="starting", - message="Container started", + message="Container starting...", ) # Event still published assert len(received) == 1 - # Notification created + # No notification created for intermediate state + result = await db_session.execute( + select(Notification).where(Notification.user_id == test_instance.owner_id) + ) + notifications = list(result.scalars().all()) + assert len(notifications) == 0 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_lifecycle_running_creates_notification( + db_session: AsyncSession, + event_bus: InstanceEventBus, + test_instance: ToolInstance, +) -> None: + """Successful terminal state (running) creates a notification.""" + from src.services.lifecycle_hooks import publish_lifecycle_event + + await publish_lifecycle_event( + event_bus=event_bus, + session=db_session, + instance=test_instance, + event_type="instance.health_changed", + status="running", + message="Container running", + ) + result = await db_session.execute( select(Notification).where(Notification.user_id == test_instance.owner_id) ) @@ -124,10 +150,9 @@ async def test_lifecycle_event_creates_notification( n = notifications[0] assert n.category == "instance" assert n.severity == "info" - assert n.title == "Container started" + assert n.title == "Health Changed" assert n.source_type == "tool_instances" assert n.source_id == test_instance.id - assert n.message == "Container started" @pytest.mark.asyncio