mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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>
84 lines
2.7 KiB
Python
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
|