Files
roboco/tests/integration/test_migration_079_notification_backoff.py
T
1d5a8e846f fix(notifications): exponential backoff + CAS claim for expired-unacked re-escalation (#652)
The sweep re-escalated every expired unacked ack-required notification
on every ~60s tick, forever — the live incident: 3 fresh blocker
escalations + Telegram DMs per minute from a static stale pile. Now
each notification carries reescalation_count / last_reescalated_at /
reescalation_delivered_count (migration 079): first fire at expiry,
then doubling intervals from 1h capped at 24h, hard stop after
ROBOCO_NOTIFICATION_MAX_REESCALATIONS (default 5) with one permanent
log carrying attempts-vs-delivered so 'seen and ignored' is
distinguishable from 'route never worked'. The due/wait/capped decision
is a pure function in foundation/policy/communications.py.

Per adversarial review, the attempt slot is claimed by compare-and-set
(UPDATE ... WHERE reescalation_count = :n) BEFORE delivery — the
previous draft leaned on the 60s dedup window, which never engages for
BLOCKER_ESCALATION (_LOOP_PRONE_TYPES excludes it), so concurrent
sweeps would have double-delivered. A lost claim skips delivery
outright. Legacy rows read as count=0 and keep today's first-fire
semantics. 61 tests incl. a two-session CAS race and a real alembic
upgrade/downgrade round trip.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 00:05:41 +02:00

84 lines
2.7 KiB
Python

"""Re-escalation backoff columns on notifications (migration 079).
Migration 079 adds ``notifications.reescalation_count`` /
``.reescalation_delivered_count`` (integer, not null, default 0) and
``.last_reescalated_at`` (timestamptz, null). The real upgrade/downgrade
chain is verified separately against a throwaway Postgres; these assertions
guard the resulting schema shape and a value round-trip.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, NotificationTable
from roboco.models import AgentRole, AgentStatus, NotificationPriority, NotificationType
from roboco.models.base import Team
from sqlalchemy import select
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_notification(db_session: AsyncSession) -> NotificationTable:
sender = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(sender)
await db_session.flush()
notification = NotificationTable(
type=NotificationType.BLOCKER_ESCALATION,
priority=NotificationPriority.HIGH,
from_agent=sender.id,
to_agents=[sender.id],
subject="stale",
body="body",
requires_ack=True,
)
db_session.add(notification)
await db_session.flush()
return notification
@pytest.mark.asyncio
async def test_reescalation_backoff_columns_default(db_session: AsyncSession) -> None:
notification = await _seed_notification(db_session)
assert notification.reescalation_count == 0
assert notification.reescalation_delivered_count == 0
assert notification.last_reescalated_at is None
@pytest.mark.asyncio
async def test_reescalation_backoff_columns_round_trip(
db_session: AsyncSession,
) -> None:
notification = await _seed_notification(db_session)
stamped_at = datetime.now(UTC)
attempts, delivered = 3, 2
notification.reescalation_count = attempts
notification.reescalation_delivered_count = delivered
notification.last_reescalated_at = stamped_at
await db_session.flush()
row = (
await db_session.execute(
select(NotificationTable).where(NotificationTable.id == notification.id)
)
).scalar_one()
assert row.reescalation_count == attempts
assert row.reescalation_delivered_count == delivered
assert row.last_reescalated_at == stamped_at